The .ipynb file boundary: serialize the in-memory nb (see notebook.py) to a real Jupyter notebook and load one back. A Prompt cell and its Assistant reply are stored solveit-style as a single markdown cell – the prompt, a ##### 🤖Reply🤖<!-- SOLVEIT_SEPARATOR_... --> line, then the reply – so their structure lives in the cell source (which nbdev-clean leaves untouched) rather than in metadata (which it strips). Kept apart from the data model (notebook.py) and the rendering/routes (cells.py) so file-format concerns live in one place.
The #| export pragma
boopiter keeps nbdev’s #| export pragma out of a cell’s editable source entirely, tracking it as a flag instead. _has_export/_strip_export detect and remove that leading line when reading a cell from disk.
Saving
save_notebook writes nb out as a real Jupyter .ipynb, merging each Prompt + following Assistant into one markdown cell (solveit separator + the assistant’s <details> block + the reply). _blocks_to_nb_outputs converts boopiter’s output blocks into valid nbformat outputs so saved files still render in Jupyter. Cell ids and the separator’s reply-id are reused across saves to keep git diffs clean.
Serialize nb.cells to a real Jupyter notebook file ({nb.name}.ipynb in the cwd by default). A Prompt cell and the Assistant reply that follows it are stored solveit-style as a SINGLE markdown cell – prompt, a ##### 🤖Reply🤖<!-- SOLVEIT_SEPARATOR_... --> line, the assistant’s <details> block, then the reply – so the prompt/reply structure lives in the cell source (which nbdev-clean leaves alone) rather than in strippable metadata. note/code/raw stay plain native cells; code cells keep their #| export pragma and outputs. Cell ids (and the separator’s reply-id hex) are preserved across saves so unchanged cells don’t churn git diffs. A solveit_ai:true flag is also stamped on prompt/reply cells for solveit’s benefit – boopiter itself relies only on the separator.
Loading
load_notebook is the inverse: a markdown cell carrying the SOLVEIT_SEPARATOR splits back into a Prompt + Assistant pair (re-extracting the <details> block); plain markdown is a note; code cells get their #| export flag detected. It falls back to legacy metadata.boopiter for notebooks saved by older boopiter, and to nbformat cell types for non-boopiter notebooks.
Load a Jupyter notebook file into nb, replacing its current contents – the inverse of save_notebook(). A markdown cell containing the SOLVEIT_SEPARATOR splits back into a Prompt cell plus its Assistant reply (with the <details> block re-extracted into details); other markdown is a note; code cells get their leading #| export detected and stripped. For notebooks saved by older boopiter it falls back to reading legacy metadata.boopiter (ctype/visible/details); for plain non-boopiter notebooks it falls back to nbformat cell types.
Round-trip test
Proves the point of this whole scheme: save a notebook, wipe every cell’s metadata (what nbdev-clean does), reload, and confirm the prompt/reply structure – plus code/note/raw, #| export, and the assistant details – all survive because they live in the cell source, not metadata. Runs under nbdev-test.
# Round-trip test: the prompt/reply structure (and code/note/raw, export, details) must survive# nbdev-clean, which strips cell metadata. We prove it by saving, deleting ALL metadata (exactly# what nbdev-clean does to the parts we no longer rely on), then reloading and checking.import tempfile as _tf, json as _json, os as _osnb.cells.clear(); nb._nid =0; nb.selected =Nonenb.add('note', '# hi there')nb.add('prompt', 'What is 2+2?')nb.add('assistant', 'It is 4.', details='<details><summary>Reply details</summary><ul><li>Model: test</li></ul></details>')nb.add('code', 'x = 4', export=True)nb.add('raw', 'literal text')_p = _tf.mktemp(suffix='.ipynb')save_notebook(_p)_doc = _json.load(open(_p)) # simulate nbdev-clean: wipe all metadata_doc['metadata'] = {}for _c in _doc['cells']: _c['metadata'] = {}_json.dump(_doc, open(_p, 'w'))load_notebook(_p)assert [c.ctype for c in nb.cells] == ['note','prompt','assistant','code','raw'], [c.ctype for c in nb.cells]_a = nb.cells[2]assert _a.ctype =='assistant'and _a.details and'Model: test'in _a.detailsassert nb.cells[3].export and nb.cells[3].source =='x = 4'assert nb.cells[1].source =='What is 2+2?'and nb.cells[1].ctype =='prompt'_os.remove(_p)print('round-trip survived a full metadata strip:', [c.ctype for c in nb.cells])
round-trip survived a full metadata strip: ['note', 'prompt', 'assistant', 'code', 'raw']