@ocramz/pi-notebook-py

A Jupyter-shaped Python notebook for the pi agent: an ordered list of cells over one persistent namespace, with staleness hints, percent-format persistence and plots the model can see.

Packages

Package details

extension

Install @ocramz/pi-notebook-py from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@ocramz/pi-notebook-py
Package
@ocramz/pi-notebook-py
Version
0.3.0
Published
Aug 30, 2026
Downloads
214/mo · 214/wk
Author
ocramz
License
MIT
Types
extension
Size
161.7 KB
Dependencies
0 dependencies · 4 peers
Pi manifest JSON
{
  "extensions": [
    "./extensions/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-notebook-py

A Jupyter-shaped Python notebook for the pi coding agent.

A notebook is an ordered list of cells over a persistent namespace. Notebooks can be saved to disk as regular Python files. The extension keeps separate venvs (a default one, and optionally one per notebook). Cells may return image data, which may be fed to vision-capable models.

> Load the CSV, plot the distribution, and save the notebook.

  nb_cell   import pandas as pd
  nb_cell   df = pd.read_csv("sales.csv")
  nb_cell   df.describe()
            * [3] c3 12.4ms  DataFrame(8, 4)
  nb_cell   df.total.hist()
            * [4] c4 240.1ms  <Axes: >
              [attached: image/png]          <- the model sees the histogram
  nb_notebook save ./analysis.py
            saved 4 cell(s) to ./analysis.py

Staleness annotations

Every response shows which cells may have stale results:

* [7] c2 1.2ms  15
failing: c6
stale (a cell above them changed since they ran): c3, c4 — nb_run {op: "all"} to bring the
notebook back in step
unrun: c5
failing its last run raised
stale has run, but a cell above it has changed, run, or moved since
unrun never executed since it was created, edited, or restarted

The kernel never re-runs anything on its own — deciding is the agent's job. Two properties make the report trustworthy, and both are pinned as tests: after nb_run {op: "all"} nothing is stale, and editing cell i marks exactly the cells below it.

Editing a cell drops its previous output rather than keeping it on screen the way Jupyter does. The output belonged to code the cell no longer contains, and showing it is exactly the confusion this kernel exists to report.

Tools

Tool What it does
nb_cell The cell list: add, edit, delete, move, list, read. after puts a cell anywhere; run: false writes without executing; kind: "markdown" makes a prose cell, which is never executed.
nb_run cell, all, above, below. all always restarts the interpreter first (Restart & Run All) — for a replay over the current namespace, below from the first cell is the same run without the restart. eval evaluates one expression without creating a cell; file runs a .py as a fresh process. Neither touches a cell.
nb_notebook The notebook as a named document: notebooks, use (create: true starts a fresh one), save, open (run: true to run every cell after loading), and the digest report.
nb_env The interpreter the cells run in: install (pip, into the interpreter the kernel is actually running), report (which python, and what is in it), restart (replace it — the namespace and every imported module go, the cells stay).

And two slash commands, so the human shares the same namespace:

/nb                       list the cells
/nb add <src>             add and run one
/nb run <id>  /nb run-all
/nb read <id>
/nb save <path>  /nb open <path>
/nb notebooks             every notebook in this project, and its environment
/nb new <name>  /nb use <name>
/nb drop-venv <name>      delete one notebook's venv
/nb <expr>                evaluate an expression without creating a cell
/nb-python <path>         pin this notebook's interpreter (restarts the kernel)
/nb-python clear          undo the pin; back to the notebook's own venv

Four reports

None of these disturbs a cell: nothing becomes stale, nothing is re-run, and no cell moves.

nb_env {op: "report"}       which python is this, and what is in it
nb_notebook {op: "digest"}  is the committed checkpoint still what the kernel holds
nb_run {op: "file", path}   run a .py in this interpreter, as a fresh process
nb_run {op: "eval", src}    evaluate one expression, without creating a cell

nb_env {op: "report"} prints the interpreter that is actually running, which of the four rules below chose it, the version, and every installed package as name==version lines — a requirements.txt you can store next to a result as env.lock. The package list comes from importlib.metadata, in the kernel process, so it needs no pip: a uv-built venv has none, and bootstrapping one to answer would mutate the environment the question was about. When uv is on PATH, uv pip freeze answers instead, because it renders an editable or VCS install as the reference it is rather than as a bare version. The report names which of the two produced it, and lock: false asks for just the interpreter.

The interpreter is read from the live process rather than re-derived, because the two can disagree: if a venv cannot be built, the kernel comes up under the base interpreter and the rule that chose the path is describing an environment nothing is running in. That case is called out in the report, since it also means nb_env {op: "install"} has been installing somewhere else.

digest hashes the checkpoint and asks the kernel to hash the file it would write. They should always match — the checkpoint is rewritten after every change — so a divergence means one of exactly two things: the file was edited outside the session, or a checkpoint write failed. The report says which hashes differ and names the call that resolves it. It never starts a kernel to answer: with no process running there is nothing that could have diverged, and building a venv to compute a hash would be a strange thing to do to someone who asked for a hash.

nb_run {op: "file"} runs a .py under the notebook's own interpreter, as an ordinary fresh process — so it sees whatever nb_env {op: "install"} put there, and binds nothing in the namespace. It is how to check the helper module from the section below before importing it, without paying a restart or making everything stale. It takes args as sys.argv[1:], has its own time budget separate from the kernel's (a script that hangs costs the script, not the session), and keeps the tail of each output stream. Two things it deliberately does not do: capture plots — a fresh process has no figure hook — and put the project directory on sys.path. A script gets its own directory there, exactly as python file.py from a shell would, where a cell gets the project directory; see docs/semantics.md §3.10.

nb_run {op: "eval"} evaluates one expression against the live namespace and creates no cell — the agent's half of what /nb <expr> has always been for the human. It is for looking: a shape, a column name, a length. It is the one report that is not purely a read, because an expression can have side effects, so it counts as a mutation for the purposes of the session's own bookkeeping even though it moves no cell. Nothing about it is recorded in the notebook, which is the point and also the limit: anything worth keeping should be a cell.

Notebooks have their own environments

A session is always on a named notebook — default until you say otherwise. The name does two things:

<project>/.pi/notebooks/sales.py     the checkpoint. Source. Commit it.
~/.pi/notebook-py/venvs/<project>/sales/    its interpreter. Never in the repo.

nb_env {op: "install"} installs into the notebook the session is on, and nowhere else, so two notebooks in one project can hold conflicting versions of the same package. Switching is nb_notebook {op: "use", name} — it discards the namespace, because the new notebook has a different interpreter and carrying globals across would be exactly the stale state this kernel exists to report.

The checkpoint is rewritten after every change, so it is always current and always committable. It carries the notebook's name in a jupytext frontmatter fence:

# ---
# notebook: sales
# ---

# %% id="c1"
import pandas as pd

which is what lets open put a file back into the environment it was written under instead of guessing. A file without a fence — anything jupytext wrote, or any plain .py — opens in the current notebook, and the reply says so rather than letting you find out at the first ImportError.

What to commit

.pi/notebooks/*.py and nothing else. There is no ignore rule to add, because there is nothing to ignore: venvs are not in the working tree at all. That is deliberate rather than tidy — a venv carries an absolute home = in its pyvenv.cfg, absolute shebangs in its scripts, and platform-specific compiled extensions, so a committed one is broken on every other machine, not merely large. git add -A cannot sweep one in because there is none to sweep.

To reclaim the disk: /nb drop-venv <name> deletes one notebook's venv and never its source. /nb notebooks prints where each one is, including a venv left stranded by a /nb-python pin. It is a slash command rather than an agent tool because deciding a notebook is finished is yours to do, not the model's — see §3.8 of docs/semantics.md.

The file format

nb_notebook save writes jupytext percent format — cells as # %% blocks in an ordinary .py:

# %% id="c1"
import pandas as pd

# %% [markdown] id="c2"
# What this notebook is for

# %% id="c3"
df = pd.read_csv("sales.csv")

It opens in Jupyter and VS Code, diffs and greps like source, and pi's own edit tool can change it. It stores no outputs. That is the deliberate cost of a file that diffs: an opened notebook has code and no results, so every cell comes back unrun, and images are session-only.

open reconciles against what is already loaded. A cell whose id and source both still match keeps its output and its execution count, so editing the file in a real editor and loading it back re-runs only what actually changed. Anything new, edited, or moved comes back unrun — and keeps its id, so the handle the agent is holding stays valid.

Saving over a file with no # %% in it is refused unless overwrite is passed. Any Python file parses as a one-cell notebook, so parsing is too weak a guard against clobbering a real module.

Plots

pi accepts image blocks in a tool result, so a figure goes to the model's eyes rather than being described to it. After each cell the kernel saves and closes every open matplotlib figure, the way the inline backend consumes them on display; failing that, it tries the display value's _repr_png_, which is what PIL and most rendering libraries already speak.

matplotlib is never imported by the kernel — py/nbkernel/display.py only reads sys.modules, so a notebook that never plots pays nothing, and the kernel stays stdlib-only. The subprocess is spawned with MPLBACKEND=Agg, unless the environment already sets one, so a headless import cannot reach for a display. Four images per cell and about 1 MB each; over that, one downscale attempt and then a note saying what was dropped.

The project directory is importable

The kernel runs in the project directory and puts it on sys.path, as Jupyter does, so long or reusable code can go in a .py file next to the notebook and be imported from a small cell. Editing that file afterwards needs nb_env {op: "restart"}: import is a sys.modules hit, so a restart here replaces the interpreter rather than just resetting the namespace — the cells survive, by way of the checkpoint. Same caveat as Jupyter for the path order: a project file named io.py shadows the stdlib one. See docs/semantics.md §3.9.

Install

pi package add @ocramz/pi-notebook-py

Needs Python 3.12 or newer. Each notebook gets its own interpreter, found in this order:

PI_PYTHON escape hatch; overrides everything, including per notebook
~/.pi/notebook-py/pins/<project>/<name> written by /nb-python. Machine-local, because an absolute path is
notebookPy.python.<name> in .pi/settings.json the pin a team can share. Relative, so "./.venv" means the same thing everywhere
~/.pi/notebook-py/venvs/<project>/<name> otherwise: a venv this extension builds and owns

<project> is the directory's basename plus a hash of its real path, so two checkouts of the same repo do not share an environment. uv is used to build the venv when it is on PATH — its cache hardlinks, which is what makes a venv per notebook cheap — and python -m venv otherwise.

Everything else is optional, under notebookPy in .pi/settings.json:

{
  "notebookPy": {
    "default": "sales",
    "python": { "sales": "./.venv" },
    "venvRoot": "/fast-disk/nb-venvs"
  }
}

default names the notebook a session opens on — the one called default, when the key is absent. PI_NOTEBOOK overrides it, which is how a container or a CI job picks one without editing the project's settings. PI_NOTEBOOK_VENV_ROOT overrides venvRoot, and PI_NOTEBOOK_HOME moves the venvs and the pins together. If a venvRoot ends up inside the repository, the pattern is appended to .git/info/exclude — repo-local, untracked, in nobody's diff — so the guarantee above survives the escape hatch.

Running in a container

The venvs live under $HOME, so give $HOME/.pi/notebook-py a named volume. Without one, every fresh container rebuilds every notebook's environment from scratch:

podman run -it --rm \
  -v "$PWD":/workspace -w /workspace \
  -v pi-config:/root/.pi \
  -v pi-notebook-venvs:/root/.pi/notebook-py \
  <your-pi-image>

(docker run is the same. This repo's own Makefile mounts exactly these for make dev, if you want a worked example.) The nesting is deliberate: pi's credentials and sessions are kilobytes with a long life, and the venvs are gigabytes you may want to throw away without logging in again.

The container builds its own venvs, separate from the host's — and it has to. If they were in the working tree they would be shared over the bind mount, and a venv works for exactly one side: the interpreter path, the shebangs and every compiled extension differ between a macOS host and a Linux image. Keying off $HOME means each side builds its own with no coordination and no configuration.

Cleanup. Removing or replacing the container neither reclaims the venvs nor loses them — that is the whole point of the volume. To actually reclaim:

podman volume rm pi-notebook-venvs      # all of them

or, from inside a session, /nb drop-venv <name> for one notebook at a time. The checkpoints under .pi/notebooks/ are in the repo and are never touched by either.

Breaking changes in 0.3

The tool grammar was re-cut so that each tool owns one thing — the cell list, execution, the document, the interpreter — and each action has one spelling. Nothing moved on the wire; every op still reaches the verb it always did.

0.2 0.3
nb_cell {src, …} (no id) nb_cell {op: "add", src, …}
nb_cell {id, src, …} nb_cell {op: "edit", id, src, …}
nb_notebook {op: "delete" | "move" | "list" | "read"} nb_cell {op: <same>}
nb_notebook {op: "new", name} nb_notebook {op: "use", name, create: true}
nb_notebook {op: "env", lock?} nb_env {op: "report", lock?}
nb_notebook {op: "restart"} nb_env {op: "restart"}
nb_install {packages, upgrade?} nb_env {op: "install", packages, upgrade?}
nb_run {op: "all", restart: false} nb_run {op: "below", id: <first cell>}
nb_run {op: "eval", src}, new: the agent's half of /nb <expr>

Two removals worth their own line. nb_run's restart parameter is gone: op: "all" always restarts, because it is the only run that proves the notebook reproduces, and the no-restart replay is run_below from the first cell — the same call, under a name that does not promise a guarantee it cannot give. And nb_notebook {op: "new"} is gone into use's create flag, which asserts in both directions: create: true fails if the notebook exists, its absence fails if it does not, so a mistyped name cannot quietly become a new empty notebook.

The /nb slash commands are unchanged. They are a human's surface, and their last clause is a bare-expression eval fallback — every keyword added to that matcher is an expression a person can no longer evaluate, so the keyword set stays small.

Development

npm test                                       # TS units, against a real python subprocess
npm run typecheck
uv run python -m unittest discover -s test-py  # the kernel's own suite
uvx ruff check py test-py

The interactive and container tiers need the dev container — make dev then make test-tui PKG=pi-notebook-py from the repo root. The interactive tier's live cases call a model API and cost money; see the repo README.

test-py/test_display.py drives a fake pyplot on purpose: matplotlib is not a dependency and must not become one just to test the kernel. Real matplotlib is covered one tier up, in test/container/test_plot_in_image.sh, where it can be installed and driven over the actual wire.