@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.
- package/README.md +1 -1
- package/dist/cli.js +244 -54
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/sdk.d.ts +39 -0
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +322 -13
- package/dist/sdk.js.map +1 -1
- package/dist/session-utils.d.ts +8 -0
- package/dist/session-utils.d.ts.map +1 -1
- package/dist/session-utils.js +38 -1
- package/dist/session-utils.js.map +1 -1
- package/extensions/__integration__/plan-rejection-feedback.test.ts +272 -0
- package/extensions/__integration__/worktree.test.ts +88 -0
- package/extensions/_icons/index.ts +2 -3
- package/extensions/_shared/inline-preview.ts +2 -4
- package/extensions/_shared/interop-events.ts +48 -0
- package/extensions/_shared/shell-policy.ts +2 -1
- package/extensions/_shared/tallow-paths.ts +48 -0
- package/extensions/agent-commands-tool/__tests__/parsing.test.ts +40 -1
- package/extensions/agent-commands-tool/index.ts +38 -6
- package/extensions/background-task-tool/index.ts +2 -2
- package/extensions/bash-tool-enhanced/index.ts +3 -4
- package/extensions/cd-tool/index.ts +3 -3
- package/extensions/claude-bridge/index.ts +2 -1
- package/extensions/command-prompt/__tests__/plugin-sources.test.ts +49 -0
- package/extensions/command-prompt/index.ts +36 -0
- package/extensions/context-files/index.ts +2 -1
- package/extensions/context-fork/index.ts +2 -1
- package/extensions/context-usage/__tests__/tool-result-memory.test.ts +62 -0
- package/extensions/context-usage/index.ts +169 -8
- package/extensions/debug/__tests__/analysis.test.ts +35 -2
- package/extensions/debug/__tests__/diagnostics-commands.test.ts +11 -2
- package/extensions/debug/analysis.ts +53 -16
- package/extensions/debug/index.ts +84 -10
- package/extensions/debug/logger.ts +2 -2
- package/extensions/health/index.ts +2 -2
- package/extensions/hooks/__tests__/claude-compat.test.ts +31 -0
- package/extensions/hooks/extension.json +3 -1
- package/extensions/hooks/hooks.schema.json +17 -1
- package/extensions/hooks/index.ts +52 -7
- package/extensions/lsp/index.ts +2 -7
- package/extensions/output-styles-tool/index.ts +2 -4
- package/extensions/plan-mode-tool/extension.json +1 -0
- package/extensions/plan-mode-tool/index.ts +119 -4
- package/extensions/prompt-suggestions/index.ts +2 -3
- package/extensions/random-spinner/index.ts +2 -3
- package/extensions/read-tool-enhanced/__tests__/notebook-read.test.ts +100 -0
- package/extensions/read-tool-enhanced/__tests__/notebook.test.ts +136 -0
- package/extensions/read-tool-enhanced/extension.json +4 -4
- package/extensions/read-tool-enhanced/index.ts +112 -10
- package/extensions/read-tool-enhanced/notebook.ts +526 -0
- package/extensions/rewind/__tests__/snapshots.test.ts +43 -1
- package/extensions/rewind/snapshots.ts +18 -6
- package/extensions/session-memory/index.ts +3 -2
- package/extensions/stats/stats-log.ts +2 -3
- package/extensions/subagent-tool/__tests__/discovery-defaults.test.ts +23 -0
- package/extensions/subagent-tool/__tests__/isolation-frontmatter.test.ts +100 -0
- package/extensions/subagent-tool/__tests__/model-router.test.ts +8 -0
- package/extensions/subagent-tool/agents.ts +77 -16
- package/extensions/subagent-tool/index.ts +213 -48
- package/extensions/subagent-tool/model-router.ts +3 -3
- package/extensions/subagent-tool/process.ts +233 -22
- package/extensions/subagent-tool/schema.ts +11 -0
- package/extensions/subagent-tool/widget.ts +15 -2
- package/extensions/tasks/__tests__/widget-subagents.test.ts +28 -7
- package/extensions/tasks/commands/register-tasks-extension.ts +15 -33
- package/extensions/tasks/state/index.ts +2 -2
- package/extensions/theme-selector/index.ts +3 -7
- package/extensions/worktree/__tests__/lifecycle.test.ts +115 -0
- package/extensions/worktree/extension.json +31 -0
- package/extensions/worktree/index.ts +149 -0
- package/extensions/worktree/lifecycle.ts +372 -0
- package/package.json +1 -1
- 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 =
|
|
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
|
-
|
|
595
|
-
|
|
673
|
+
_cellCount?: number;
|
|
674
|
+
_codeCellCount?: number;
|
|
596
675
|
_emptyText?: boolean;
|
|
597
676
|
_imageMetadata?: ImageMetadata;
|
|
598
|
-
|
|
599
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|