@dungle-scrubs/tallow 0.8.7 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +244 -54
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +1 -1
  5. package/dist/config.js +1 -1
  6. package/dist/sdk.d.ts +39 -0
  7. package/dist/sdk.d.ts.map +1 -1
  8. package/dist/sdk.js +322 -13
  9. package/dist/sdk.js.map +1 -1
  10. package/dist/session-utils.d.ts +8 -0
  11. package/dist/session-utils.d.ts.map +1 -1
  12. package/dist/session-utils.js +38 -1
  13. package/dist/session-utils.js.map +1 -1
  14. package/extensions/__integration__/plan-rejection-feedback.test.ts +272 -0
  15. package/extensions/__integration__/worktree.test.ts +88 -0
  16. package/extensions/_icons/index.ts +2 -3
  17. package/extensions/_shared/inline-preview.ts +2 -4
  18. package/extensions/_shared/interop-events.ts +48 -0
  19. package/extensions/_shared/shell-policy.ts +2 -1
  20. package/extensions/_shared/tallow-paths.ts +48 -0
  21. package/extensions/agent-commands-tool/__tests__/parsing.test.ts +40 -1
  22. package/extensions/agent-commands-tool/index.ts +38 -6
  23. package/extensions/background-task-tool/index.ts +2 -2
  24. package/extensions/bash-tool-enhanced/index.ts +3 -4
  25. package/extensions/cd-tool/index.ts +3 -3
  26. package/extensions/claude-bridge/index.ts +2 -1
  27. package/extensions/command-prompt/__tests__/plugin-sources.test.ts +49 -0
  28. package/extensions/command-prompt/index.ts +36 -0
  29. package/extensions/context-files/index.ts +2 -1
  30. package/extensions/context-fork/index.ts +2 -1
  31. package/extensions/context-usage/__tests__/tool-result-memory.test.ts +62 -0
  32. package/extensions/context-usage/index.ts +169 -8
  33. package/extensions/debug/__tests__/analysis.test.ts +35 -2
  34. package/extensions/debug/__tests__/diagnostics-commands.test.ts +11 -2
  35. package/extensions/debug/analysis.ts +53 -16
  36. package/extensions/debug/index.ts +84 -10
  37. package/extensions/debug/logger.ts +2 -2
  38. package/extensions/health/index.ts +2 -2
  39. package/extensions/hooks/__tests__/claude-compat.test.ts +31 -0
  40. package/extensions/hooks/extension.json +3 -1
  41. package/extensions/hooks/hooks.schema.json +17 -1
  42. package/extensions/hooks/index.ts +52 -7
  43. package/extensions/lsp/index.ts +2 -7
  44. package/extensions/output-styles-tool/index.ts +2 -4
  45. package/extensions/plan-mode-tool/extension.json +1 -0
  46. package/extensions/plan-mode-tool/index.ts +119 -4
  47. package/extensions/prompt-suggestions/index.ts +2 -3
  48. package/extensions/random-spinner/index.ts +2 -3
  49. package/extensions/read-tool-enhanced/__tests__/notebook-read.test.ts +100 -0
  50. package/extensions/read-tool-enhanced/__tests__/notebook.test.ts +136 -0
  51. package/extensions/read-tool-enhanced/extension.json +4 -4
  52. package/extensions/read-tool-enhanced/index.ts +112 -10
  53. package/extensions/read-tool-enhanced/notebook.ts +526 -0
  54. package/extensions/rewind/__tests__/snapshots.test.ts +43 -1
  55. package/extensions/rewind/snapshots.ts +18 -6
  56. package/extensions/session-memory/index.ts +3 -2
  57. package/extensions/stats/stats-log.ts +2 -3
  58. package/extensions/subagent-tool/__tests__/discovery-defaults.test.ts +23 -0
  59. package/extensions/subagent-tool/__tests__/isolation-frontmatter.test.ts +100 -0
  60. package/extensions/subagent-tool/__tests__/model-router.test.ts +8 -0
  61. package/extensions/subagent-tool/agents.ts +77 -16
  62. package/extensions/subagent-tool/index.ts +213 -48
  63. package/extensions/subagent-tool/model-router.ts +3 -3
  64. package/extensions/subagent-tool/process.ts +233 -22
  65. package/extensions/subagent-tool/schema.ts +11 -0
  66. package/extensions/subagent-tool/widget.ts +15 -2
  67. package/extensions/tasks/__tests__/widget-subagents.test.ts +28 -7
  68. package/extensions/tasks/commands/register-tasks-extension.ts +15 -33
  69. package/extensions/tasks/state/index.ts +2 -2
  70. package/extensions/theme-selector/index.ts +3 -7
  71. package/extensions/worktree/__tests__/lifecycle.test.ts +115 -0
  72. package/extensions/worktree/extension.json +31 -0
  73. package/extensions/worktree/index.ts +149 -0
  74. package/extensions/worktree/lifecycle.ts +372 -0
  75. package/package.json +1 -1
  76. package/skills/tallow-expert/SKILL.md +1 -1
@@ -3,6 +3,7 @@
3
3
  * - Live truncated content during execution (first N lines)
4
4
  * - Compact summary when done (✓ file.md (22 lines, 0.8KB))
5
5
  * - Special rendering for SKILL.md files
6
+ * - Structured parsing for PDFs and Jupyter notebooks
6
7
  * - Full content always sent to LLM via context restoration
7
8
  *
8
9
  * Uses raw render functions for renderResult so that
@@ -40,6 +41,7 @@ import {
40
41
  } from "@mariozechner/pi-tui";
41
42
  import { Type } from "@sinclair/typebox";
42
43
  import { getIcon } from "../_icons/index.js";
44
+ import { getTallowSettingsPath } from "../_shared/tallow-paths.js";
43
45
  import {
44
46
  appendSection,
45
47
  dimProcessOutputLine,
@@ -50,6 +52,14 @@ import {
50
52
  renderLines,
51
53
  truncateForDisplay,
52
54
  } from "../tool-display/index.js";
55
+ import {
56
+ formatNotebookOutput,
57
+ isNotebook,
58
+ NOTEBOOK_MARKER,
59
+ NotebookParseError,
60
+ parseNotebook,
61
+ summarizeNotebookCounts,
62
+ } from "./notebook.js";
53
63
  import {
54
64
  formatPdfOutput,
55
65
  isPdf,
@@ -81,10 +91,7 @@ function resolveHome(p: string): string {
81
91
  * @returns Array of resolved skill directory paths from packages
82
92
  */
83
93
  export function getPackageSkillPaths(): string[] {
84
- const settingsPath = join(
85
- process.env.TALLOW_CODING_AGENT_DIR ?? join(homedir(), ".tallow"),
86
- "settings.json"
87
- );
94
+ const settingsPath = getTallowSettingsPath();
88
95
  if (!fs.existsSync(settingsPath)) return [];
89
96
 
90
97
  try {
@@ -362,6 +369,73 @@ async function executePdf(
362
369
  }
363
370
  }
364
371
 
372
+ /**
373
+ * Execute notebook reading: parse notebook JSON, format cell content, and summarize.
374
+ *
375
+ * @param absolutePath - Absolute path to the notebook file
376
+ * @param displayPath - Original user-facing path string
377
+ * @param onUpdate - Streaming update callback for live preview
378
+ * @returns Tool result with notebook content and summary metadata
379
+ */
380
+ function executeNotebook(
381
+ absolutePath: string,
382
+ displayPath: string,
383
+ onUpdate?: (partialResult: {
384
+ content: Array<{ type: "text"; text: string }>;
385
+ details: Record<string, unknown>;
386
+ }) => void
387
+ ): {
388
+ content: Array<{ type: "text"; text: string }>;
389
+ details: Record<string, unknown>;
390
+ isError?: boolean;
391
+ } {
392
+ const notebookContent = fs.readFileSync(absolutePath, "utf-8");
393
+
394
+ try {
395
+ const notebook = parseNotebook(notebookContent);
396
+ const formatted = formatNotebookOutput(notebook);
397
+ const counts = summarizeNotebookCounts(notebook);
398
+
399
+ // Stream a preview during execution
400
+ const previewLines = formatted.split("\n").slice(0, 10).join("\n");
401
+ onUpdate?.({
402
+ content: [{ type: "text" as const, text: previewLines }],
403
+ details: { _preview: true },
404
+ });
405
+
406
+ const sizeKb = (Buffer.byteLength(formatted, "utf-8") / 1024).toFixed(1);
407
+ const summary =
408
+ `${displayPath} (${notebook.cells.length} cells, ` +
409
+ `${counts.codeCells} code, ${counts.markdownCells} markdown, ` +
410
+ `${counts.outputCount} outputs, ${sizeKb}KB extracted)`;
411
+
412
+ return {
413
+ content: [{ type: "text" as const, text: summary }],
414
+ details: {
415
+ [NOTEBOOK_MARKER]: true,
416
+ _fullText: formatted,
417
+ _path: displayPath,
418
+ _filename: displayPath,
419
+ _cellCount: notebook.cells.length,
420
+ _codeCellCount: counts.codeCells,
421
+ _markdownCellCount: counts.markdownCells,
422
+ _outputCount: counts.outputCount,
423
+ _rawCellCount: counts.rawCells,
424
+ _language: notebook.language,
425
+ },
426
+ };
427
+ } catch (err: unknown) {
428
+ if (err instanceof NotebookParseError) {
429
+ return {
430
+ content: [{ type: "text" as const, text: err.message }],
431
+ details: {},
432
+ isError: true,
433
+ };
434
+ }
435
+ throw err;
436
+ }
437
+ }
438
+
365
439
  /**
366
440
  * Linkify the file-path prefix in a summary string.
367
441
  *
@@ -462,6 +536,11 @@ export default function readSummary(pi: ExtensionAPI): void {
462
536
  return executePdf(absolutePath, path, params.pages as string | undefined, onUpdate);
463
537
  }
464
538
 
539
+ // ── Notebook handling (.ipynb) ──────────────────────
540
+ if (isNotebook(absolutePath)) {
541
+ return executeNotebook(absolutePath, path, onUpdate);
542
+ }
543
+
465
544
  // ── Image detection from bytes ──────────────────────
466
545
  // Read first 12 bytes to detect image format by magic numbers.
467
546
  // The base tool handles actual image reading via file-type; we
@@ -591,13 +670,20 @@ export default function readSummary(pi: ExtensionAPI): void {
591
670
  _filename?: string;
592
671
  _fullText?: string;
593
672
  _isSkill?: boolean;
594
- _isPdf?: boolean;
595
- _totalPages?: number;
673
+ _cellCount?: number;
674
+ _codeCellCount?: number;
596
675
  _emptyText?: boolean;
597
676
  _imageMetadata?: ImageMetadata;
598
- [SUMMARY_MARKER]?: boolean;
599
- [PDF_MARKER]?: boolean;
677
+ _isPdf?: boolean;
678
+ _language?: string;
679
+ _markdownCellCount?: number;
680
+ _outputCount?: number;
681
+ _rawCellCount?: number;
682
+ _totalPages?: number;
600
683
  [IMAGE_MARKER]?: boolean;
684
+ [NOTEBOOK_MARKER]?: boolean;
685
+ [PDF_MARKER]?: boolean;
686
+ [SUMMARY_MARKER]?: boolean;
601
687
  }
602
688
  | undefined;
603
689
 
@@ -657,6 +743,21 @@ export default function readSummary(pi: ExtensionAPI): void {
657
743
  return renderLines([footer]);
658
744
  }
659
745
 
746
+ // Notebook file: compact summary collapsed, full text expanded
747
+ if (details?.[NOTEBOOK_MARKER]) {
748
+ const summary = textContent?.text ?? "Notebook";
749
+ const footer = buildFooter(summary);
750
+
751
+ if (expanded && details?._fullText) {
752
+ const lines: string[] = [];
753
+ appendSection(lines, [formatSectionDivider(theme, "Output")]);
754
+ appendSection(lines, details._fullText.split("\n").map(styleProcessLine));
755
+ appendSection(lines, [footer], { blankBefore: true });
756
+ return renderLines(lines, { wrap: true });
757
+ }
758
+ return renderLines([footer]);
759
+ }
760
+
660
761
  // If not summarized, show raw content
661
762
  if (!details?.[SUMMARY_MARKER]) {
662
763
  const raw = textContent?.text ?? "";
@@ -694,10 +795,11 @@ export default function readSummary(pi: ExtensionAPI): void {
694
795
  if (msg.role !== "toolResult") continue;
695
796
 
696
797
  const details = msg.details as Record<string, unknown> | undefined;
697
- // Restore full text for both summarized text files and PDF results
798
+ // Restore full text for summarized text files, PDFs, and notebook results.
698
799
  const isSummarized = details?.[SUMMARY_MARKER] && details._fullText;
699
800
  const isPdfResult = details?.[PDF_MARKER] && details._fullText;
700
- if (!(isSummarized || isPdfResult)) continue;
801
+ const isNotebookResult = details?.[NOTEBOOK_MARKER] && details._fullText;
802
+ if (!(isSummarized || isPdfResult || isNotebookResult)) continue;
701
803
 
702
804
  const textContent = msg.content.find(
703
805
  (c): c is { type: "text"; text: string } => c.type === "text"
@@ -0,0 +1,526 @@
1
+ import { extname } from "node:path";
2
+
3
+ const DEFAULT_NOTEBOOK_LANGUAGE = "python";
4
+ const MAX_OUTPUT_CHARACTERS = 8_000;
5
+ const IMAGE_MIME_TYPES = [
6
+ "image/gif",
7
+ "image/jpeg",
8
+ "image/jpg",
9
+ "image/png",
10
+ "image/webp",
11
+ ] as const;
12
+
13
+ /** Marker for notebook results in details, used by renderResult/context restoration. */
14
+ export const NOTEBOOK_MARKER = "__notebook_read__";
15
+
16
+ /** Parsed notebook output cell. */
17
+ export interface NotebookOutput {
18
+ readonly data?: Record<string, unknown>;
19
+ readonly ename?: string;
20
+ readonly evalue?: string;
21
+ readonly metadata?: Record<string, unknown>;
22
+ readonly name?: string;
23
+ readonly output_type: string;
24
+ readonly text?: string | readonly string[];
25
+ readonly traceback?: readonly string[];
26
+ }
27
+
28
+ /** Parsed notebook cell. */
29
+ export interface NotebookCell {
30
+ readonly cell_type: "code" | "markdown" | "raw";
31
+ readonly execution_count?: number;
32
+ readonly outputs?: readonly NotebookOutput[];
33
+ readonly source: string | readonly string[];
34
+ }
35
+
36
+ /** Parsed notebook structure used by the read tool. */
37
+ export interface ParsedNotebook {
38
+ readonly cells: readonly NotebookCell[];
39
+ readonly language: string;
40
+ readonly metadata: Record<string, unknown>;
41
+ }
42
+
43
+ /** Count summary for notebook cell/output types. */
44
+ export interface NotebookCellCounts {
45
+ readonly codeCells: number;
46
+ readonly markdownCells: number;
47
+ readonly outputCount: number;
48
+ readonly rawCells: number;
49
+ }
50
+
51
+ /** Thrown when a notebook cannot be parsed into a supported shape. */
52
+ export class NotebookParseError extends Error {
53
+ constructor(message: string) {
54
+ super(message);
55
+ this.name = "NotebookParseError";
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Detect whether a file path looks like a Jupyter notebook.
61
+ *
62
+ * @param absolutePath - Absolute path to inspect
63
+ * @returns True when extension is `.ipynb` (case-insensitive)
64
+ */
65
+ export function isNotebook(absolutePath: string): boolean {
66
+ return extname(absolutePath).toLowerCase() === ".ipynb";
67
+ }
68
+
69
+ /**
70
+ * Parse notebook JSON into a normalized representation used by formatters.
71
+ *
72
+ * @param content - Raw `.ipynb` JSON content
73
+ * @returns Parsed notebook structure with normalized cells and metadata
74
+ * @throws {NotebookParseError} When JSON is invalid or notebook shape is unsupported
75
+ */
76
+ export function parseNotebook(content: string): ParsedNotebook {
77
+ const root = parseNotebookJson(content);
78
+ const cellsValue = root.cells;
79
+ if (!Array.isArray(cellsValue)) {
80
+ throw new NotebookParseError("Cannot read notebook: missing cells array");
81
+ }
82
+
83
+ const metadata = toRecord(root.metadata) ?? {};
84
+ const language = detectNotebookLanguage(metadata);
85
+ const cells = cellsValue.map((cell) => normalizeCell(cell));
86
+
87
+ return {
88
+ cells,
89
+ language,
90
+ metadata,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Format parsed notebook cells into LLM-friendly plain text.
96
+ *
97
+ * @param notebook - Parsed notebook from `parseNotebook`
98
+ * @returns Human-readable notebook text with cell separators and structured outputs
99
+ */
100
+ export function formatNotebookOutput(notebook: ParsedNotebook): string {
101
+ const counts = summarizeNotebookCounts(notebook);
102
+ const lines: string[] = [
103
+ `[Notebook: ${notebook.language} | ${notebook.cells.length} cells (${counts.codeCells} code, ${counts.markdownCells} markdown, ${counts.rawCells} raw, ${counts.outputCount} outputs)]`,
104
+ "",
105
+ ];
106
+
107
+ notebook.cells.forEach((cell, index) => {
108
+ if (index > 0) {
109
+ lines.push("---", "");
110
+ }
111
+ lines.push(...formatCell(cell, index + 1, notebook.language));
112
+ });
113
+
114
+ return lines.join("\n").trimEnd();
115
+ }
116
+
117
+ /**
118
+ * Count notebook cell and output totals for summary display.
119
+ *
120
+ * @param notebook - Parsed notebook from `parseNotebook`
121
+ * @returns Aggregated counts used in collapsed summaries
122
+ */
123
+ export function summarizeNotebookCounts(notebook: ParsedNotebook): NotebookCellCounts {
124
+ let codeCells = 0;
125
+ let markdownCells = 0;
126
+ let rawCells = 0;
127
+ let outputCount = 0;
128
+
129
+ for (const cell of notebook.cells) {
130
+ if (cell.cell_type === "code") codeCells += 1;
131
+ if (cell.cell_type === "markdown") markdownCells += 1;
132
+ if (cell.cell_type === "raw") rawCells += 1;
133
+ outputCount += cell.outputs?.length ?? 0;
134
+ }
135
+
136
+ return {
137
+ codeCells,
138
+ markdownCells,
139
+ outputCount,
140
+ rawCells,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Parse a JSON string and validate the top-level notebook object shape.
146
+ *
147
+ * @param content - Raw JSON string
148
+ * @returns Parsed top-level notebook object
149
+ * @throws {NotebookParseError} When JSON is invalid or top-level is not an object
150
+ */
151
+ function parseNotebookJson(content: string): Record<string, unknown> {
152
+ let parsed: unknown;
153
+ try {
154
+ parsed = JSON.parse(content) as unknown;
155
+ } catch {
156
+ throw new NotebookParseError("Cannot read notebook: invalid JSON");
157
+ }
158
+
159
+ if (!isRecord(parsed)) {
160
+ throw new NotebookParseError("Cannot read notebook: expected top-level object");
161
+ }
162
+
163
+ return parsed;
164
+ }
165
+
166
+ /**
167
+ * Normalize a raw notebook cell object.
168
+ *
169
+ * @param value - Raw cell value from parsed JSON
170
+ * @returns Normalized notebook cell
171
+ */
172
+ function normalizeCell(value: unknown): NotebookCell {
173
+ if (!isRecord(value)) {
174
+ return {
175
+ cell_type: "raw",
176
+ source: "[Unsupported cell structure]",
177
+ };
178
+ }
179
+
180
+ const cellType = normalizeCellType(value.cell_type);
181
+ const source = normalizeSource(value.source);
182
+ const executionCount =
183
+ typeof value.execution_count === "number" ? value.execution_count : undefined;
184
+ const outputs = Array.isArray(value.outputs)
185
+ ? value.outputs
186
+ .map((output) => normalizeOutput(output))
187
+ .filter((output): output is NotebookOutput => output !== null)
188
+ : undefined;
189
+
190
+ return {
191
+ cell_type: cellType,
192
+ execution_count: executionCount,
193
+ outputs,
194
+ source,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Normalize a raw notebook output object.
200
+ *
201
+ * @param value - Raw output value from parsed JSON
202
+ * @returns Normalized output, or null when value is not an object
203
+ */
204
+ function normalizeOutput(value: unknown): NotebookOutput | null {
205
+ if (!isRecord(value)) return null;
206
+
207
+ const traceback = Array.isArray(value.traceback)
208
+ ? value.traceback.filter((line): line is string => typeof line === "string")
209
+ : undefined;
210
+ const text = normalizeOptionalSource(value.text);
211
+
212
+ return {
213
+ data: toRecord(value.data),
214
+ ename: typeof value.ename === "string" ? value.ename : undefined,
215
+ evalue: typeof value.evalue === "string" ? value.evalue : undefined,
216
+ metadata: toRecord(value.metadata),
217
+ name: typeof value.name === "string" ? value.name : undefined,
218
+ output_type: typeof value.output_type === "string" ? value.output_type : "stream",
219
+ text,
220
+ traceback,
221
+ };
222
+ }
223
+
224
+ /**
225
+ * Normalize an optional source/text field.
226
+ *
227
+ * @param value - Raw source value
228
+ * @returns Normalized source value, or undefined when missing/unsupported
229
+ */
230
+ function normalizeOptionalSource(value: unknown): string | readonly string[] | undefined {
231
+ if (typeof value === "string") return value;
232
+ if (Array.isArray(value)) {
233
+ return value.filter((item): item is string => typeof item === "string");
234
+ }
235
+ return undefined;
236
+ }
237
+
238
+ /**
239
+ * Normalize a required source field.
240
+ *
241
+ * @param value - Raw source value
242
+ * @returns Normalized source string or string-array
243
+ */
244
+ function normalizeSource(value: unknown): string | readonly string[] {
245
+ return normalizeOptionalSource(value) ?? "";
246
+ }
247
+
248
+ /**
249
+ * Normalize notebook cell type to supported values.
250
+ *
251
+ * @param value - Raw `cell_type` value
252
+ * @returns One of `markdown`, `code`, or `raw`
253
+ */
254
+ function normalizeCellType(value: unknown): "code" | "markdown" | "raw" {
255
+ if (value === "code" || value === "markdown" || value === "raw") return value;
256
+ return "raw";
257
+ }
258
+
259
+ /**
260
+ * Detect notebook language from metadata.
261
+ *
262
+ * @param metadata - Top-level notebook metadata
263
+ * @returns Kernel language name, falling back to python
264
+ */
265
+ function detectNotebookLanguage(metadata: Record<string, unknown>): string {
266
+ const kernelspec = toRecord(metadata.kernelspec);
267
+ const kernelLanguage = typeof kernelspec?.language === "string" ? kernelspec.language.trim() : "";
268
+ if (kernelLanguage.length > 0) return kernelLanguage;
269
+
270
+ const languageInfo = toRecord(metadata.language_info);
271
+ const languageName = typeof languageInfo?.name === "string" ? languageInfo.name.trim() : "";
272
+ if (languageName.length > 0) return languageName;
273
+
274
+ return DEFAULT_NOTEBOOK_LANGUAGE;
275
+ }
276
+
277
+ /**
278
+ * Format a single notebook cell and its outputs.
279
+ *
280
+ * @param cell - Notebook cell to format
281
+ * @param cellNumber - 1-indexed cell number
282
+ * @param language - Notebook language used for code fences
283
+ * @returns Formatted cell lines
284
+ */
285
+ function formatCell(cell: NotebookCell, cellNumber: number, language: string): string[] {
286
+ if (cell.cell_type === "markdown") {
287
+ const markdown = joinSource(cell.source).trimEnd();
288
+ return [`Cell ${cellNumber} [markdown]`, markdown || "[Empty markdown cell]"];
289
+ }
290
+
291
+ if (cell.cell_type === "raw") {
292
+ const raw = joinSource(cell.source).trimEnd();
293
+ return [
294
+ `Cell ${cellNumber} [raw]`,
295
+ "```text",
296
+ raw.length > 0 ? raw : "[Empty raw cell]",
297
+ "```",
298
+ ];
299
+ }
300
+
301
+ const lines: string[] = [`Cell ${cellNumber} [code]`];
302
+ if (typeof cell.execution_count === "number") {
303
+ lines.push(`# [${cell.execution_count}]`);
304
+ }
305
+
306
+ const code = joinSource(cell.source).trimEnd();
307
+ lines.push(`\`\`\`${language}`, code.length > 0 ? code : "", "```");
308
+ lines.push(...formatCellOutputs(cell.outputs));
309
+ return lines;
310
+ }
311
+
312
+ /**
313
+ * Format all outputs for a code cell.
314
+ *
315
+ * @param outputs - Optional code cell outputs
316
+ * @returns Formatted output lines
317
+ */
318
+ function formatCellOutputs(outputs: readonly NotebookOutput[] | undefined): string[] {
319
+ if (!outputs || outputs.length === 0) return [];
320
+
321
+ const lines: string[] = ["Outputs:"];
322
+ outputs.forEach((output, index) => {
323
+ if (index > 0) lines.push("");
324
+ lines.push(...formatOutput(output, index + 1));
325
+ });
326
+ return lines;
327
+ }
328
+
329
+ /**
330
+ * Format one notebook output entry.
331
+ *
332
+ * @param output - Output entry to format
333
+ * @param outputNumber - 1-indexed output number within a cell
334
+ * @returns Formatted output lines
335
+ */
336
+ function formatOutput(output: NotebookOutput, outputNumber: number): string[] {
337
+ if (output.output_type === "error") {
338
+ return formatErrorOutput(output, outputNumber);
339
+ }
340
+
341
+ if (output.output_type === "stream") {
342
+ return formatStreamOutput(output, outputNumber);
343
+ }
344
+
345
+ if (output.output_type === "execute_result" || output.output_type === "display_data") {
346
+ return formatRichOutput(output, outputNumber);
347
+ }
348
+
349
+ return formatUnknownOutput(output, outputNumber);
350
+ }
351
+
352
+ /**
353
+ * Format stream output text.
354
+ *
355
+ * @param output - Stream output entry
356
+ * @param outputNumber - 1-indexed output number
357
+ * @returns Formatted output lines
358
+ */
359
+ function formatStreamOutput(output: NotebookOutput, outputNumber: number): string[] {
360
+ const text = truncateForDisplay(joinSource(output.text).trimEnd());
361
+ if (text.length === 0) {
362
+ return [`[Output ${outputNumber}: stream]`, "[No text output]"];
363
+ }
364
+ return [`[Output ${outputNumber}: stream]`, "```text", text, "```"];
365
+ }
366
+
367
+ /**
368
+ * Format rich display output (`execute_result` / `display_data`).
369
+ *
370
+ * @param output - Rich output entry
371
+ * @param outputNumber - 1-indexed output number
372
+ * @returns Formatted output lines
373
+ */
374
+ function formatRichOutput(output: NotebookOutput, outputNumber: number): string[] {
375
+ const lines: string[] = [`[Output ${outputNumber}: ${output.output_type}]`];
376
+ const data = output.data ?? {};
377
+
378
+ const plainText = extractPlainText(data);
379
+ if (plainText.length > 0) {
380
+ lines.push("```text", truncateForDisplay(plainText.trimEnd()), "```");
381
+ }
382
+
383
+ const imageLines = IMAGE_MIME_TYPES.flatMap((mimeType) => {
384
+ if (!(mimeType in data)) return [];
385
+ const dimensions = getImageDimensionsFromMetadata(output.metadata, mimeType);
386
+ const format = mimeType.split("/")[1]?.toUpperCase() ?? "IMAGE";
387
+ return dimensions
388
+ ? [`[Image output: ${format} ${dimensions.width}x${dimensions.height}]`]
389
+ : [`[Image output: ${format}]`];
390
+ });
391
+ lines.push(...imageLines);
392
+
393
+ const hasHtml = typeof data["text/html"] === "string" || Array.isArray(data["text/html"]);
394
+ if (hasHtml) {
395
+ lines.push("[HTML output]");
396
+ }
397
+
398
+ if (plainText.length === 0 && imageLines.length === 0 && !hasHtml) {
399
+ const fallbackText = truncateForDisplay(joinSource(output.text).trimEnd());
400
+ if (fallbackText.length > 0) {
401
+ lines.push("```text", fallbackText, "```");
402
+ } else {
403
+ lines.push("[Unsupported rich output]");
404
+ }
405
+ }
406
+
407
+ return lines;
408
+ }
409
+
410
+ /**
411
+ * Format error output with traceback.
412
+ *
413
+ * @param output - Error output entry
414
+ * @param outputNumber - 1-indexed output number
415
+ * @returns Formatted output lines
416
+ */
417
+ function formatErrorOutput(output: NotebookOutput, outputNumber: number): string[] {
418
+ const header = [output.ename, output.evalue].filter(Boolean).join(": ") || "Execution error";
419
+ const traceback = output.traceback?.join("\n");
420
+ const body = traceback ? `${header}\n${traceback}` : header;
421
+ return [`[Output ${outputNumber}: error]`, "```text", truncateForDisplay(body), "```"];
422
+ }
423
+
424
+ /**
425
+ * Format unsupported output types using best-effort text extraction.
426
+ *
427
+ * @param output - Unknown output entry
428
+ * @param outputNumber - 1-indexed output number
429
+ * @returns Formatted output lines
430
+ */
431
+ function formatUnknownOutput(output: NotebookOutput, outputNumber: number): string[] {
432
+ const text = truncateForDisplay(joinSource(output.text).trimEnd());
433
+ if (text.length === 0) {
434
+ return [`[Output ${outputNumber}: ${output.output_type}]`, "[Unsupported output]"];
435
+ }
436
+ return [`[Output ${outputNumber}: ${output.output_type}]`, "```text", text, "```"];
437
+ }
438
+
439
+ /**
440
+ * Extract plain text from rich output data payloads.
441
+ *
442
+ * @param data - Rich output `data` map
443
+ * @returns Joined plain-text content, or empty string when absent
444
+ */
445
+ function extractPlainText(data: Record<string, unknown>): string {
446
+ const plain = data["text/plain"];
447
+ if (typeof plain === "string") return plain;
448
+ if (Array.isArray(plain)) {
449
+ return plain.filter((part): part is string => typeof part === "string").join("");
450
+ }
451
+ return "";
452
+ }
453
+
454
+ /**
455
+ * Join notebook source/text fields into a single string.
456
+ *
457
+ * @param source - String or line-array field
458
+ * @returns Combined text string
459
+ */
460
+ function joinSource(source: string | readonly string[] | undefined): string {
461
+ if (typeof source === "string") return source;
462
+ if (Array.isArray(source)) return source.join("");
463
+ return "";
464
+ }
465
+
466
+ /**
467
+ * Truncate very large output segments while preserving context.
468
+ *
469
+ * @param value - Text to truncate
470
+ * @returns Original text if short, otherwise truncated text with notice
471
+ */
472
+ function truncateForDisplay(value: string): string {
473
+ if (value.length <= MAX_OUTPUT_CHARACTERS) return value;
474
+ return `${value.slice(0, MAX_OUTPUT_CHARACTERS)}\n[Output truncated at ${MAX_OUTPUT_CHARACTERS} chars]`;
475
+ }
476
+
477
+ /**
478
+ * Read image dimensions from notebook output metadata.
479
+ *
480
+ * @param metadata - Output metadata payload
481
+ * @param mimeType - Image MIME type key
482
+ * @returns Width/height pair when available, otherwise null
483
+ */
484
+ function getImageDimensionsFromMetadata(
485
+ metadata: Record<string, unknown> | undefined,
486
+ mimeType: string
487
+ ): { height: number; width: number } | null {
488
+ if (!metadata) return null;
489
+ const nested = readDimensionCandidate(metadata[mimeType]);
490
+ if (nested) return nested;
491
+ return readDimensionCandidate(metadata);
492
+ }
493
+
494
+ /**
495
+ * Parse a metadata object and extract numeric width/height.
496
+ *
497
+ * @param value - Metadata candidate object
498
+ * @returns Dimensions when both values are valid positive numbers
499
+ */
500
+ function readDimensionCandidate(value: unknown): { height: number; width: number } | null {
501
+ if (!isRecord(value)) return null;
502
+ const width = typeof value.width === "number" ? value.width : null;
503
+ const height = typeof value.height === "number" ? value.height : null;
504
+ if (width === null || height === null || width <= 0 || height <= 0) return null;
505
+ return { height, width };
506
+ }
507
+
508
+ /**
509
+ * Narrow an unknown value to a plain object record.
510
+ *
511
+ * @param value - Value to narrow
512
+ * @returns Record value or undefined when value is not a plain object
513
+ */
514
+ function toRecord(value: unknown): Record<string, unknown> | undefined {
515
+ return isRecord(value) ? value : undefined;
516
+ }
517
+
518
+ /**
519
+ * Check whether a value is a non-null object (excluding arrays).
520
+ *
521
+ * @param value - Value to check
522
+ * @returns True for plain object-like records
523
+ */
524
+ function isRecord(value: unknown): value is Record<string, unknown> {
525
+ return typeof value === "object" && value !== null && !Array.isArray(value);
526
+ }