| 1 | package builtin |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "os" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/diff" |
| 11 | "reasonix/internal/tool" |
| 12 | ) |
| 13 | |
| 14 | func init() { tool.RegisterBuiltin(notebookEdit{}) } |
| 15 | |
| 16 | // notebookEdit edits a single cell of a Jupyter notebook (.ipynb). A notebook is |
| 17 | // JSON with a "cells" array; editing it with edit_file means matching escaped |
| 18 | // JSON by hand, which is fragile. This tool targets a cell by index (or id) and |
| 19 | // replaces, inserts, or deletes it, re-serialising so the JSON stays valid and |
| 20 | // unrelated cells, outputs, and top-level metadata are preserved. |
| 21 | // |
| 22 | // roots, when non-empty, confines the target to the workspace (see confine); |
| 23 | // guard rejects Reasonix session-data targets (see SessionDataGuard); the |
| 24 | // zero value registered at init is unconfined and is overridden per run by |
| 25 | // ConfineWriters. workDir, when non-empty, is the directory a relative path |
| 26 | // resolves against (see resolveIn). |
| 27 | type notebookEdit struct { |
| 28 | roots []string |
| 29 | guard SessionDataGuard |
| 30 | managed ManagedConfigPaths |
| 31 | workDir string |
| 32 | } |
| 33 | |
| 34 | func (notebookEdit) Name() string { return "notebook_edit" } |
| 35 | |
| 36 | func (notebookEdit) ReadOnly() bool { return false } |
| 37 | |
| 38 | func (notebookEdit) Description() string { |
| 39 | return "Edit one cell of a Jupyter notebook (.ipynb). Target a cell by 0-based " + |
| 40 | "cell_number (or cell_id). edit_mode: \"replace\" (default) swaps the cell's " + |
| 41 | "source; \"insert\" adds a new cell after cell_number (use -1 to prepend at the " + |
| 42 | "top), taking cell_type and new_source; \"delete\" removes the cell. cell_type is " + |
| 43 | "\"code\" or \"markdown\" (required for insert). Editing a code cell clears its " + |
| 44 | "outputs. Prefer this over edit_file for notebooks — it keeps the JSON valid." |
| 45 | } |
| 46 | |
| 47 | func (notebookEdit) Schema() json.RawMessage { |
| 48 | return json.RawMessage(`{ |
| 49 | "type": "object", |
| 50 | "properties": { |
| 51 | "path": {"type": "string", "description": "Path to the .ipynb notebook."}, |
| 52 | "cell_number": {"type": "integer", "description": "0-based index of the target cell. For insert, the new cell goes after this one (-1 prepends)."}, |
| 53 | "cell_id": {"type": "string", "description": "Target the cell by its id instead of cell_number (replace/delete)."}, |
| 54 | "new_source": {"type": "string", "description": "The cell's new source text (replace/insert)."}, |
| 55 | "cell_type": {"type": "string", "enum": ["code", "markdown"], "description": "Cell type for insert (and optional retype on replace)."}, |
| 56 | "edit_mode": {"type": "string", "enum": ["replace", "insert", "delete"], "description": "replace (default), insert, or delete."} |
| 57 | }, |
| 58 | "required": ["path"] |
| 59 | }`) |
| 60 | } |
| 61 | |
| 62 | type notebookArgs struct { |
| 63 | Path string `json:"path"` |
| 64 | CellNumber *int `json:"cell_number"` |
| 65 | CellID string `json:"cell_id"` |
| 66 | NewSource string `json:"new_source"` |
| 67 | CellType string `json:"cell_type"` |
| 68 | EditMode string `json:"edit_mode"` |
| 69 | } |
| 70 | |
| 71 | // notebook is the minimal .ipynb shape we touch. Unknown top-level keys |
| 72 | // (metadata, nbformat, …) and unknown per-cell keys are preserved verbatim via |
| 73 | // json.RawMessage round-tripping. |
| 74 | type notebook struct { |
| 75 | rest map[string]json.RawMessage |
| 76 | cells []map[string]json.RawMessage |
| 77 | } |
| 78 | |
| 79 | func (n notebookEdit) Execute(ctx context.Context, raw json.RawMessage) (string, error) { |
| 80 | a, err := parseNotebookArgs(raw) |
| 81 | if err != nil { |
| 82 | return "", err |
| 83 | } |
| 84 | a.Path = resolveIn(n.workDir, a.Path) |
| 85 | if err := confineWrite(ctx, n.roots, n.guard, n.managed, a.Path); err != nil { |
| 86 | return "", err |
| 87 | } |
| 88 | data, err := os.ReadFile(a.Path) |
| 89 | if err != nil { |
| 90 | return "", fmt.Errorf("read %s: %w", a.Path, err) |
| 91 | } |
| 92 | nb, err := parseNotebook(data) |
| 93 | if err != nil { |
| 94 | return "", fmt.Errorf("%s: %w", a.Path, err) |
| 95 | } |
| 96 | |
| 97 | idx, summary, err := applyNotebookEdit(nb, a) |
| 98 | if err != nil { |
| 99 | return "", err |
| 100 | } |
| 101 | |
| 102 | out, err := nb.marshal() |
| 103 | if err != nil { |
| 104 | return "", err |
| 105 | } |
| 106 | if err := os.WriteFile(a.Path, out, 0o644); err != nil { |
| 107 | return "", fmt.Errorf("write %s: %w", a.Path, err) |
| 108 | } |
| 109 | return fmt.Sprintf("%s in %s (cell %d; %d cells total)", summary, a.Path, idx, len(nb.cells)), nil |
| 110 | } |
| 111 | |
| 112 | // Preview implements tool.Previewer so a checkpoint can snapshot the notebook's |
| 113 | // before/after for rewind. It mirrors Execute's transformation exactly but never |
| 114 | // writes — same arg parsing and targeting rules, so the previewed change equals |
| 115 | // what Execute would persist. |
| 116 | func (n notebookEdit) Preview(raw json.RawMessage) (diff.Change, error) { |
| 117 | a, err := parseNotebookArgs(raw) |
| 118 | if err != nil { |
| 119 | return diff.Change{}, err |
| 120 | } |
| 121 | a.Path = resolveIn(n.workDir, a.Path) |
| 122 | data, err := os.ReadFile(a.Path) |
| 123 | if err != nil { |
| 124 | return diff.Change{}, fmt.Errorf("read %s: %w", a.Path, err) |
| 125 | } |
| 126 | nb, err := parseNotebook(data) |
| 127 | if err != nil { |
| 128 | return diff.Change{}, fmt.Errorf("%s: %w", a.Path, err) |
| 129 | } |
| 130 | if _, _, err := applyNotebookEdit(nb, a); err != nil { |
| 131 | return diff.Change{}, err |
| 132 | } |
| 133 | out, err := nb.marshal() |
| 134 | if err != nil { |
| 135 | return diff.Change{}, err |
| 136 | } |
| 137 | return diff.Build(a.Path, string(data), string(out), diff.Modify), nil |
| 138 | } |
| 139 | |
| 140 | func parseNotebookArgs(raw json.RawMessage) (notebookArgs, error) { |
| 141 | var a notebookArgs |
| 142 | if err := json.Unmarshal(raw, &a); err != nil { |
| 143 | return a, fmt.Errorf("invalid args: %w", err) |
| 144 | } |
| 145 | // Be forgiving about the source field: models reach for the write_file/edit_file |
| 146 | // vocabulary ("content"/"source"/"new_string"). Accept those as new_source when |
| 147 | // new_source itself wasn't given, so a near-miss call succeeds instead of looping. |
| 148 | if a.NewSource == "" { |
| 149 | var alias struct { |
| 150 | Content string `json:"content"` |
| 151 | Source string `json:"source"` |
| 152 | NewString string `json:"new_string"` |
| 153 | } |
| 154 | _ = json.Unmarshal(raw, &alias) |
| 155 | switch { |
| 156 | case alias.Content != "": |
| 157 | a.NewSource = alias.Content |
| 158 | case alias.Source != "": |
| 159 | a.NewSource = alias.Source |
| 160 | case alias.NewString != "": |
| 161 | a.NewSource = alias.NewString |
| 162 | } |
| 163 | } |
| 164 | if a.Path == "" { |
| 165 | return a, fmt.Errorf("path is required") |
| 166 | } |
| 167 | if a.EditMode == "" { |
| 168 | a.EditMode = "replace" |
| 169 | } |
| 170 | switch a.EditMode { |
| 171 | case "replace", "insert", "delete": |
| 172 | default: |
| 173 | return a, fmt.Errorf("edit_mode must be replace, insert, or delete (got %q)", a.EditMode) |
| 174 | } |
| 175 | return a, nil |
| 176 | } |
| 177 | |
| 178 | // applyNotebookEdit mutates nb.cells per the args and returns the affected index |
| 179 | // and a one-line summary. Cell targeting is by cell_id when set, else cell_number. |
| 180 | func applyNotebookEdit(nb *notebook, a notebookArgs) (int, string, error) { |
| 181 | if a.EditMode == "insert" { |
| 182 | if a.CellType == "" { |
| 183 | return 0, "", fmt.Errorf("cell_type is required for insert") |
| 184 | } |
| 185 | after := -1 |
| 186 | if a.CellNumber != nil { |
| 187 | after = *a.CellNumber |
| 188 | } |
| 189 | if after < -1 || after >= len(nb.cells) { |
| 190 | return 0, "", fmt.Errorf("cell_number %d out of range for insert (notebook has %d cells; use -1 to prepend)", after, len(nb.cells)) |
| 191 | } |
| 192 | cell := newCell(a.CellType, a.NewSource) |
| 193 | at := after + 1 // insert after `after`; -1 → prepend at 0 |
| 194 | nb.cells = append(nb.cells[:at], append([]map[string]json.RawMessage{cell}, nb.cells[at:]...)...) |
| 195 | return at, "inserted " + a.CellType + " cell", nil |
| 196 | } |
| 197 | |
| 198 | idx, err := nb.targetIndex(a) |
| 199 | if err != nil { |
| 200 | return 0, "", err |
| 201 | } |
| 202 | if a.EditMode == "delete" { |
| 203 | nb.cells = append(nb.cells[:idx], nb.cells[idx+1:]...) |
| 204 | return idx, "deleted cell", nil |
| 205 | } |
| 206 | // replace |
| 207 | setCellSource(nb.cells[idx], a.NewSource) |
| 208 | if a.CellType != "" { |
| 209 | nb.cells[idx]["cell_type"] = jsonString(a.CellType) |
| 210 | } |
| 211 | normalizeOutputs(nb.cells[idx], cellTypeOf(nb.cells[idx])) |
| 212 | return idx, "replaced cell source", nil |
| 213 | } |
| 214 | |
| 215 | // targetIndex resolves the cell to act on: cell_id wins when given, else |
| 216 | // cell_number (which must be in range). |
| 217 | func (nb *notebook) targetIndex(a notebookArgs) (int, error) { |
| 218 | if a.CellID != "" { |
| 219 | for i, c := range nb.cells { |
| 220 | if cellID(c) == a.CellID { |
| 221 | return i, nil |
| 222 | } |
| 223 | } |
| 224 | return 0, fmt.Errorf("no cell with id %q", a.CellID) |
| 225 | } |
| 226 | if a.CellNumber == nil { |
| 227 | // A one-cell notebook is unambiguous: default to cell 0 rather than forcing |
| 228 | // the caller to restate it. With more than one cell, require an explicit target. |
| 229 | if len(nb.cells) == 1 { |
| 230 | return 0, nil |
| 231 | } |
| 232 | return 0, fmt.Errorf("cell_number or cell_id is required for %s (notebook has %d cells; pass the 0-based cell_number)", a.EditMode, len(nb.cells)) |
| 233 | } |
| 234 | n := *a.CellNumber |
| 235 | if n < 0 || n >= len(nb.cells) { |
| 236 | return 0, fmt.Errorf("cell_number %d out of range (notebook has %d cells)", n, len(nb.cells)) |
| 237 | } |
| 238 | return n, nil |
| 239 | } |
| 240 | |
| 241 | // parseNotebook decodes just enough of the .ipynb to edit cells while preserving |
| 242 | // every other key (top-level and per-cell) verbatim for re-serialisation. |
| 243 | func parseNotebook(data []byte) (*notebook, error) { |
| 244 | var top map[string]json.RawMessage |
| 245 | if err := json.Unmarshal(data, &top); err != nil { |
| 246 | return nil, fmt.Errorf("not valid notebook JSON: %w", err) |
| 247 | } |
| 248 | rawCells, ok := top["cells"] |
| 249 | if !ok { |
| 250 | return nil, fmt.Errorf("no \"cells\" array — not a notebook") |
| 251 | } |
| 252 | var cells []map[string]json.RawMessage |
| 253 | if err := json.Unmarshal(rawCells, &cells); err != nil { |
| 254 | return nil, fmt.Errorf("cells is not an array of objects: %w", err) |
| 255 | } |
| 256 | return ¬ebook{rest: top, cells: cells}, nil |
| 257 | } |
| 258 | |
| 259 | // marshal re-serialises the notebook with the edited cells, pretty-printed with |
| 260 | // the one-space indent Jupyter uses, and a trailing newline. |
| 261 | func (nb *notebook) marshal() ([]byte, error) { |
| 262 | cellsJSON, err := json.Marshal(nb.cells) |
| 263 | if err != nil { |
| 264 | return nil, err |
| 265 | } |
| 266 | nb.rest["cells"] = cellsJSON |
| 267 | out, err := json.MarshalIndent(nb.rest, "", " ") |
| 268 | if err != nil { |
| 269 | return nil, err |
| 270 | } |
| 271 | return append(out, '\n'), nil |
| 272 | } |
| 273 | |
| 274 | // newCell builds a fresh cell. Jupyter stores source as an array of lines (each |
| 275 | // ending in \n except the last); outputs/execution_count exist only for code. |
| 276 | func newCell(cellType, source string) map[string]json.RawMessage { |
| 277 | c := map[string]json.RawMessage{ |
| 278 | "cell_type": jsonString(cellType), |
| 279 | "metadata": json.RawMessage(`{}`), |
| 280 | "source": sourceLines(source), |
| 281 | } |
| 282 | if cellType == "code" { |
| 283 | c["outputs"] = json.RawMessage(`[]`) |
| 284 | c["execution_count"] = json.RawMessage(`null`) |
| 285 | } |
| 286 | return c |
| 287 | } |
| 288 | |
| 289 | func setCellSource(cell map[string]json.RawMessage, source string) { |
| 290 | cell["source"] = sourceLines(source) |
| 291 | } |
| 292 | |
| 293 | // normalizeOutputs makes a cell's output fields match its (possibly just-retyped) |
| 294 | // type: a code cell's stale results are cleared; a markdown cell must not carry |
| 295 | // outputs/execution_count at all, so a code→markdown retype drops them. |
| 296 | func normalizeOutputs(cell map[string]json.RawMessage, cellType string) { |
| 297 | if cellType == "markdown" { |
| 298 | delete(cell, "outputs") |
| 299 | delete(cell, "execution_count") |
| 300 | return |
| 301 | } |
| 302 | cell["outputs"] = json.RawMessage(`[]`) |
| 303 | cell["execution_count"] = json.RawMessage(`null`) |
| 304 | } |
| 305 | |
| 306 | func cellTypeOf(cell map[string]json.RawMessage) string { |
| 307 | var t string |
| 308 | _ = json.Unmarshal(cell["cell_type"], &t) |
| 309 | return t |
| 310 | } |
| 311 | |
| 312 | func cellID(cell map[string]json.RawMessage) string { |
| 313 | raw, ok := cell["id"] |
| 314 | if !ok { |
| 315 | return "" |
| 316 | } |
| 317 | var id string |
| 318 | _ = json.Unmarshal(raw, &id) |
| 319 | return id |
| 320 | } |
| 321 | |
| 322 | // sourceLines encodes a string as Jupyter's line-array source form: split on |
| 323 | // newlines, keeping the \n on every line but the last (matching nbformat). |
| 324 | func sourceLines(s string) json.RawMessage { |
| 325 | if s == "" { |
| 326 | return json.RawMessage(`[]`) |
| 327 | } |
| 328 | parts := strings.SplitAfter(s, "\n") |
| 329 | // SplitAfter leaves a trailing "" when s ends in \n; drop it. |
| 330 | if parts[len(parts)-1] == "" { |
| 331 | parts = parts[:len(parts)-1] |
| 332 | } |
| 333 | b, _ := json.Marshal(parts) |
| 334 | return b |
| 335 | } |
| 336 | |
| 337 | func jsonString(s string) json.RawMessage { |
| 338 | b, _ := json.Marshal(s) |
| 339 | return b |
| 340 | } |
| 341 |