@code-yeongyu/senpi-codemode 2026.9.24 → 2026.9.25
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/CHANGELOG.md +44 -0
- package/README.md +8 -4
- package/package.json +4 -4
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/code-preview.ts +31 -0
- package/src/tool/display-code.ts +34 -131
- package/src/tool/display-js-ast.ts +65 -0
- package/src/tool/display-js-layout.ts +90 -0
- package/src/tool/display-js-mask.ts +178 -0
- package/src/tool/display-js.ts +100 -0
- package/src/tool/display-python-script.ts +127 -0
- package/src/tool/display-python.ts +150 -0
- package/src/tool/eval-request.ts +18 -4
- package/src/tool/eval-tool.ts +1 -1
- package/src/tool/render.ts +9 -25
- package/src/tool/types.ts +16 -9
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,47 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.9.25] - 2026-09-25
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
### Removed
|
|
26
|
+
|
|
27
|
+
## [2026.9.24-3] - 2026-09-24
|
|
28
|
+
|
|
29
|
+
### Breaking Changes
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
### Removed
|
|
38
|
+
|
|
39
|
+
## [2026.9.24-2] - 2026-09-24
|
|
40
|
+
|
|
41
|
+
### Breaking Changes
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Dense JavaScript eval cells are previewed with Bun's built-in printer when the TUI runs on Bun: one statement per line with normal spacing, while every string, number, template, name, and comment keeps its exact text. On Node, JavaScript cells are shown as sent. Dense Python cells are formatted by your own Python (ruff or black when installed, otherwise the standard library for comment-free cells), appearing formatted a moment after the cell is shown. Anything that cannot be shown faithfully is shown as sent, and the code that runs is unchanged. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
48
|
+
|
|
49
|
+
### Fixed
|
|
50
|
+
|
|
51
|
+
- The `eval` tool's run contract is no longer a trap: the schema descriptions now mark `language` and `code` as required for run, and a run call missing either fails with an error that names the enabled languages or the missing cell body instead of a bare "eval run requires language".
|
|
52
|
+
- An omitted `eval` `language` now names only the kernels enabled in this session (`js` and `py` by default), instead of advertising gated Ruby and Julia.
|
|
53
|
+
|
|
54
|
+
### Removed
|
|
55
|
+
|
|
15
56
|
## [2026.9.24] - 2026-09-24
|
|
16
57
|
|
|
17
58
|
### Breaking Changes
|
|
@@ -22,6 +63,9 @@
|
|
|
22
63
|
|
|
23
64
|
### Fixed
|
|
24
65
|
|
|
66
|
+
- Eval code previews no longer show code that means something different from the cell: breaking a long JavaScript array keeps the parentheses around each element, and a Python formatter result that changes string or docstring text (or is not valid Python) is discarded so the cell is shown as sent. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
67
|
+
- Python eval previews give your ruff most of the 5-second formatting budget instead of a fixed 4 seconds, so a slow first ruff start still formats the cell. ([#2076](https://github.com/code-yeongyu/senpi/issues/2076))
|
|
68
|
+
|
|
25
69
|
### Removed
|
|
26
70
|
|
|
27
71
|
## [2026.9.23-5] - 2026-09-23
|
package/README.md
CHANGED
|
@@ -207,15 +207,19 @@ that implements it. Reset only removes kernel variables: save `pool_id` and use
|
|
|
207
207
|
arguments in other languages). An open pool's adapter can be recreated with the
|
|
208
208
|
same name/spec/mode; no worker state is reconstructed in the prelude.
|
|
209
209
|
|
|
210
|
-
## Required
|
|
210
|
+
## Required run fields
|
|
211
211
|
|
|
212
|
-
Every `eval` run call MUST include a `
|
|
212
|
+
Every `eval` run call MUST include a `language` (the kernel that runs the
|
|
213
|
+
cell, one of the enabled languages), the `code` cell body, and a `summary` —
|
|
214
|
+
one line in the language the
|
|
213
215
|
user writes in: a progress update saying what the agent is doing and why, not
|
|
214
216
|
a label for the code. The
|
|
215
217
|
summary is shown in the TUI while the cell runs and in the finished result, so
|
|
216
218
|
you can always tell what is running and why. It has no length limit; a
|
|
217
|
-
collapsed block shows its first three lines.
|
|
218
|
-
`
|
|
219
|
+
collapsed block shows its first three lines. The schema marks all three
|
|
220
|
+
optional only because the control actions (`peek`, `stop`, `list`) share it; a
|
|
221
|
+
run request missing any of them fails with a teaching error that names what to
|
|
222
|
+
add.
|
|
219
223
|
|
|
220
224
|
## Detached cells
|
|
221
225
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.25",
|
|
4
4
|
"description": "Source-only senpi extension package for codemode evaluation tools",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -30,14 +30,14 @@
|
|
|
30
30
|
},
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@babel/parser": "8.0.4",
|
|
33
|
-
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.
|
|
33
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.9.25",
|
|
34
34
|
"typebox": "1.3.34"
|
|
35
35
|
},
|
|
36
36
|
"peerDependencies": {
|
|
37
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
37
|
+
"@code-yeongyu/senpi": "2026.9.25"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
|
-
"@code-yeongyu/senpi": "2026.9.
|
|
40
|
+
"@code-yeongyu/senpi": "2026.9.25"
|
|
41
41
|
},
|
|
42
42
|
"keywords": [
|
|
43
43
|
"senpi",
|
package/src/tool/AGENTS.md
CHANGED
|
@@ -14,6 +14,7 @@ anchor most suites).
|
|
|
14
14
|
| Cell execution, settlement | `cell-handler.ts`, `cell-execution.ts`, `cell-runtime.ts` |
|
|
15
15
|
| Detached cells | `detached-cell-manager.ts` + `detached-cell-{state,snapshot,notification}.ts`, `detached-notification-queue.ts`, `detached-eval-result.ts` |
|
|
16
16
|
| Call/result rendering | `render.ts`, `runtime-label.ts`, `json-tree.ts`, `image.ts`, `tool-widgets.ts` |
|
|
17
|
+
| Code preview layout | `code-preview.ts` -> `display-code.ts` (JS: `display-js*.ts` Bun-only masked `Bun.Transpiler`; Python: `display-python*.ts` user interpreter, background + repaint) |
|
|
17
18
|
| Status events, execution events | `status-events.ts`, `eval-execution-event.ts` |
|
|
18
19
|
| Interrupt, capture | `interrupt-note.ts`, `call-capture.ts` |
|
|
19
20
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { highlightCode, type Theme } from "@code-yeongyu/senpi";
|
|
2
|
+
import { displayCode } from "./display-code.ts";
|
|
3
|
+
import type { EvalLanguage } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
function languageForHighlighter(language: EvalLanguage): "python" | "javascript" | "ruby" | "julia" {
|
|
6
|
+
switch (language) {
|
|
7
|
+
case "py":
|
|
8
|
+
return "python";
|
|
9
|
+
case "js":
|
|
10
|
+
return "javascript";
|
|
11
|
+
case "rb":
|
|
12
|
+
return "ruby";
|
|
13
|
+
case "jl":
|
|
14
|
+
return "julia";
|
|
15
|
+
default: {
|
|
16
|
+
const unreachable: never = language;
|
|
17
|
+
throw new TypeError(`Unhandled eval language: ${String(unreachable)}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function highlightedCode(
|
|
23
|
+
code: string,
|
|
24
|
+
language: EvalLanguage,
|
|
25
|
+
theme: Theme | undefined,
|
|
26
|
+
onFormatted?: () => void,
|
|
27
|
+
): string {
|
|
28
|
+
const normalizedCode = code.trim().length > 0 ? code : "...";
|
|
29
|
+
const lines = highlightCode(displayCode(normalizedCode, language, onFormatted), languageForHighlighter(language));
|
|
30
|
+
return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
|
|
31
|
+
}
|
package/src/tool/display-code.ts
CHANGED
|
@@ -1,145 +1,48 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { bunJsPrinter, prettifyJs } from "./display-js.ts";
|
|
2
|
+
import { pythonDisplay } from "./display-python.ts";
|
|
2
3
|
import type { EvalLanguage } from "./types.ts";
|
|
3
4
|
|
|
4
|
-
// Display-only: models often send a
|
|
5
|
-
//
|
|
6
|
-
// comment verbatim. The tool arguments are never touched (senpi#1472).
|
|
5
|
+
// Display-only (senpi#2050, senpi#2076): models often send a cell as one long line of joined
|
|
6
|
+
// statements. The tool arguments are never touched (senpi#1472).
|
|
7
7
|
const DENSE_LINE_LENGTH = 100;
|
|
8
|
-
const ARRAY_BREAK_LENGTH = 60;
|
|
9
|
-
const INDENT = " ";
|
|
10
8
|
const CACHE_LIMIT = 64;
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
// A whitespace/comma/comment region between two tokens that becomes a line break.
|
|
15
|
-
type Gap = {
|
|
16
|
-
readonly start: number;
|
|
17
|
-
readonly end: number;
|
|
18
|
-
readonly contentDepth: number;
|
|
19
|
-
readonly nextDepth: number;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const cache = new Map<string, string>();
|
|
23
|
-
|
|
24
|
-
function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
25
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function sourceNode(value: unknown): SourceNode | undefined {
|
|
29
|
-
if (!isRecord(value)) return undefined;
|
|
30
|
-
const { type, start, end } = value;
|
|
31
|
-
if (typeof type !== "string" || typeof start !== "number" || typeof end !== "number") return undefined;
|
|
32
|
-
return { type, start, end };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function sourceNodes(value: unknown): SourceNode[] {
|
|
36
|
-
if (!Array.isArray(value)) return [];
|
|
37
|
-
return value.flatMap((item) => sourceNode(item) ?? []);
|
|
38
|
-
}
|
|
10
|
+
const jsCache = new Map<string, string>();
|
|
39
11
|
|
|
40
12
|
function isDense(code: string): boolean {
|
|
41
13
|
return code.split("\n").some((line) => line.length > DENSE_LINE_LENGTH);
|
|
42
14
|
}
|
|
43
15
|
|
|
44
|
-
function
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (error instanceof SyntaxError) return undefined;
|
|
54
|
-
throw error;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function siblingGaps(children: readonly SourceNode[], depth: number): Gap[] {
|
|
59
|
-
return children.slice(1).map((child, index) => ({
|
|
60
|
-
start: children[index]?.end ?? child.start,
|
|
61
|
-
end: child.start,
|
|
62
|
-
contentDepth: depth,
|
|
63
|
-
nextDepth: depth,
|
|
64
|
-
}));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Gaps for a bracketed container whose children move onto their own indented lines.
|
|
68
|
-
function containerGaps(container: SourceNode, children: readonly SourceNode[], depth: number): Gap[] {
|
|
69
|
-
const first = children[0];
|
|
70
|
-
const last = children.at(-1);
|
|
71
|
-
if (first === undefined || last === undefined) return [];
|
|
72
|
-
const inner = depth + 1;
|
|
73
|
-
return [
|
|
74
|
-
{ start: container.start + 1, end: first.start, contentDepth: inner, nextDepth: inner },
|
|
75
|
-
...siblingGaps(children, inner),
|
|
76
|
-
{ start: last.end, end: container.end - 1, contentDepth: inner, nextDepth: depth },
|
|
77
|
-
];
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function breakableChildren(node: SourceNode, record: Readonly<Record<string, unknown>>, code: string) {
|
|
81
|
-
if (node.type === "BlockStatement" || node.type === "StaticBlock") return sourceNodes(record.body);
|
|
82
|
-
if (node.type !== "ArrayExpression") return undefined;
|
|
83
|
-
const elements = sourceNodes(record.elements);
|
|
84
|
-
const text = code.slice(node.start, node.end);
|
|
85
|
-
if (elements.length < 2 || text.length <= ARRAY_BREAK_LENGTH || text.includes("\n")) return undefined;
|
|
86
|
-
return elements;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function collectGaps(value: unknown, depth: number, code: string, gaps: Gap[]): void {
|
|
90
|
-
if (Array.isArray(value)) {
|
|
91
|
-
for (const item of value) collectGaps(item, depth, code, gaps);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (!isRecord(value)) return;
|
|
95
|
-
const node = sourceNode(value);
|
|
96
|
-
const children = node === undefined ? undefined : breakableChildren(node, value, code);
|
|
97
|
-
const breaks = node !== undefined && children !== undefined && children.length > 0;
|
|
98
|
-
if (breaks) gaps.push(...containerGaps(node, children, depth));
|
|
99
|
-
for (const [key, child] of Object.entries(value)) {
|
|
100
|
-
if (key === "loc" || key === "extra" || key.endsWith("Comments")) continue;
|
|
101
|
-
if (child !== null && typeof child === "object") collectGaps(child, breaks ? depth + 1 : depth, code, gaps);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function renderGap(text: string, gap: Gap): string {
|
|
106
|
-
let rest = text.trim();
|
|
107
|
-
let attached = "";
|
|
108
|
-
if (rest.startsWith(",") || rest.startsWith(";")) {
|
|
109
|
-
attached = rest.slice(0, 1);
|
|
110
|
-
rest = rest.slice(1).trim();
|
|
111
|
-
}
|
|
112
|
-
const contentIndent = INDENT.repeat(gap.contentDepth);
|
|
113
|
-
const content = rest
|
|
114
|
-
.split("\n")
|
|
115
|
-
.map((line) => line.trim())
|
|
116
|
-
.filter((line) => line.length > 0)
|
|
117
|
-
.map((line) => `\n${contentIndent}${line}`)
|
|
118
|
-
.join("");
|
|
119
|
-
return `${attached}${content}\n${INDENT.repeat(gap.nextDepth)}`;
|
|
16
|
+
function displayJs(code: string): string {
|
|
17
|
+
const print = bunJsPrinter();
|
|
18
|
+
if (print === null) return code;
|
|
19
|
+
const cached = jsCache.get(code);
|
|
20
|
+
if (cached !== undefined) return cached;
|
|
21
|
+
const formatted = prettifyJs(code, print) ?? code;
|
|
22
|
+
if (jsCache.size >= CACHE_LIMIT) jsCache.delete(jsCache.keys().next().value ?? "");
|
|
23
|
+
jsCache.set(code, formatted);
|
|
24
|
+
return formatted;
|
|
120
25
|
}
|
|
121
26
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
27
|
+
/**
|
|
28
|
+
* The preview text for a cell. Dense JavaScript is laid out by the built-in Bun printer only when
|
|
29
|
+
* the renderer runs on Bun; dense Python is formatted by the user's own interpreter in the
|
|
30
|
+
* background, so the first render shows the cell as sent and `onFormatted` asks for a repaint.
|
|
31
|
+
* Pass `onFormatted` only for complete code; without it no formatter process starts.
|
|
32
|
+
*/
|
|
33
|
+
export function displayCode(code: string, language: EvalLanguage, onFormatted?: () => void): string {
|
|
34
|
+
if (!isDense(code)) return code;
|
|
35
|
+
switch (language) {
|
|
36
|
+
case "js":
|
|
37
|
+
return displayJs(code);
|
|
38
|
+
case "py":
|
|
39
|
+
return pythonDisplay.display(code, onFormatted);
|
|
40
|
+
case "rb":
|
|
41
|
+
case "jl":
|
|
42
|
+
return code;
|
|
43
|
+
default: {
|
|
44
|
+
const unreachable: never = language;
|
|
45
|
+
return unreachable;
|
|
46
|
+
}
|
|
133
47
|
}
|
|
134
|
-
return (output + code.slice(cursor)).trim();
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export function displayCode(code: string, language: EvalLanguage): string {
|
|
138
|
-
if (language !== "js" || !isDense(code)) return code;
|
|
139
|
-
const cached = cache.get(code);
|
|
140
|
-
if (cached !== undefined) return cached;
|
|
141
|
-
const formatted = reformat(code);
|
|
142
|
-
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value ?? "");
|
|
143
|
-
cache.set(code, formatted);
|
|
144
|
-
return formatted;
|
|
145
48
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { parse } from "@babel/parser";
|
|
2
|
+
|
|
3
|
+
export type SourceNode = {
|
|
4
|
+
readonly type: string;
|
|
5
|
+
readonly start: number;
|
|
6
|
+
readonly end: number;
|
|
7
|
+
readonly record: Readonly<Record<string, unknown>>;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type Edit = { readonly start: number; readonly end: number; readonly text: string };
|
|
11
|
+
|
|
12
|
+
export function isRecord(value: unknown): value is Readonly<Record<string, unknown>> {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function sourceNode(value: unknown): SourceNode | undefined {
|
|
17
|
+
if (!isRecord(value)) return undefined;
|
|
18
|
+
const { type, start, end } = value;
|
|
19
|
+
if (typeof type !== "string" || typeof start !== "number" || typeof end !== "number") return undefined;
|
|
20
|
+
return { type, start, end, record: value };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sourceNodes(value: unknown): SourceNode[] {
|
|
24
|
+
if (!Array.isArray(value)) return [];
|
|
25
|
+
return value.flatMap((item) => sourceNode(item) ?? []);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function childNodes(node: SourceNode): SourceNode[] {
|
|
29
|
+
return Object.entries(node.record).flatMap(([key, value]) => {
|
|
30
|
+
if (key === "loc" || key === "extra" || key.endsWith("Comments")) return [];
|
|
31
|
+
return Array.isArray(value) ? sourceNodes(value) : (sourceNode(value) ?? []);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Parses a cell the way the js kernel accepts it: module syntax, top-level `await` and `return`. */
|
|
36
|
+
export function parseFile(code: string): SourceNode | undefined {
|
|
37
|
+
try {
|
|
38
|
+
return sourceNode(
|
|
39
|
+
parse(code, {
|
|
40
|
+
sourceType: "module",
|
|
41
|
+
allowAwaitOutsideFunction: true,
|
|
42
|
+
allowReturnOutsideFunction: true,
|
|
43
|
+
allowImportExportEverywhere: true,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof SyntaxError) return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function programOf(file: SourceNode | undefined): SourceNode | undefined {
|
|
53
|
+
return file === undefined ? undefined : sourceNode(file.record.program);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function applyEdits(code: string, edits: readonly Edit[]): string {
|
|
57
|
+
const sorted = [...edits].sort((left, right) => left.start - right.start);
|
|
58
|
+
let output = "";
|
|
59
|
+
let cursor = 0;
|
|
60
|
+
for (const edit of sorted) {
|
|
61
|
+
output += code.slice(cursor, edit.start) + edit.text;
|
|
62
|
+
cursor = edit.end;
|
|
63
|
+
}
|
|
64
|
+
return output + code.slice(cursor);
|
|
65
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyEdits,
|
|
3
|
+
childNodes,
|
|
4
|
+
type Edit,
|
|
5
|
+
parseFile,
|
|
6
|
+
programOf,
|
|
7
|
+
type SourceNode,
|
|
8
|
+
sourceNode,
|
|
9
|
+
sourceNodes,
|
|
10
|
+
} from "./display-js-ast.ts";
|
|
11
|
+
|
|
12
|
+
const ARRAY_BREAK_LENGTH = 60;
|
|
13
|
+
const INDENT = " ";
|
|
14
|
+
|
|
15
|
+
function forHeaderEdits(code: string, node: SourceNode, edits: Edit[]): void {
|
|
16
|
+
if (node.type === "ForStatement") {
|
|
17
|
+
const body = sourceNode(node.record.body);
|
|
18
|
+
const open = code.indexOf("(", node.start);
|
|
19
|
+
const close = body === undefined ? -1 : code.lastIndexOf(")", body.start);
|
|
20
|
+
if (open >= 0 && close > open) {
|
|
21
|
+
const parts = ["init", "test", "update"].map((key) => {
|
|
22
|
+
const part = sourceNode(node.record[key]);
|
|
23
|
+
return part === undefined ? "" : code.slice(part.start, part.end);
|
|
24
|
+
});
|
|
25
|
+
const header = parts.every((part) => part.length === 0) ? ";;" : parts.join("; ");
|
|
26
|
+
if (code.slice(open + 1, close) !== header) edits.push({ start: open + 1, end: close, text: header });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
for (const child of childNodes(node)) forHeaderEdits(code, child, edits);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lineIndent(code: string, position: number): string {
|
|
33
|
+
const line = code.slice(code.lastIndexOf("\n", position - 1) + 1);
|
|
34
|
+
return line.slice(0, line.length - line.trimStart().length);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Only whitespace in a gap is rewritten: a gap between babel element nodes can also hold the
|
|
38
|
+
// parentheses of a parenthesized element, and those must stay (senpi#2076).
|
|
39
|
+
function gapTokens(code: string, start: number, end: number): string {
|
|
40
|
+
return code.slice(start, end).replace(/\s+/gu, "");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A one-line array longer than ARRAY_BREAK_LENGTH puts each element on its own line (senpi#2050).
|
|
44
|
+
// `forcedIndent` is the indentation of an element line an enclosing break just created.
|
|
45
|
+
function arrayEdits(code: string, node: SourceNode, forcedIndent: string | undefined, edits: Edit[]): void {
|
|
46
|
+
const rawElements = node.record.elements;
|
|
47
|
+
const elements = sourceNodes(rawElements);
|
|
48
|
+
const first = elements[0];
|
|
49
|
+
const last = elements.at(-1);
|
|
50
|
+
const text = code.slice(node.start, node.end);
|
|
51
|
+
const breaks =
|
|
52
|
+
node.type === "ArrayExpression" &&
|
|
53
|
+
Array.isArray(rawElements) &&
|
|
54
|
+
elements.length === rawElements.length &&
|
|
55
|
+
elements.length >= 2 &&
|
|
56
|
+
text.length > ARRAY_BREAK_LENGTH &&
|
|
57
|
+
!text.includes("\n");
|
|
58
|
+
if (!breaks || first === undefined || last === undefined) {
|
|
59
|
+
for (const child of childNodes(node)) arrayEdits(code, child, forcedIndent, edits);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const base = forcedIndent ?? lineIndent(code, node.start);
|
|
63
|
+
const inner = `${base}${INDENT}`;
|
|
64
|
+
edits.push({
|
|
65
|
+
start: node.start + 1,
|
|
66
|
+
end: first.start,
|
|
67
|
+
text: `\n${inner}${gapTokens(code, node.start + 1, first.start)}`,
|
|
68
|
+
});
|
|
69
|
+
elements.slice(1).forEach((element, index) => {
|
|
70
|
+
const start = elements[index]?.end ?? element.start;
|
|
71
|
+
const [before = "", after = ""] = gapTokens(code, start, element.start).split(",");
|
|
72
|
+
edits.push({ start, end: element.start, text: `${before},\n${inner}${after}` });
|
|
73
|
+
});
|
|
74
|
+
edits.push({ start: last.end, end: node.end - 1, text: `${gapTokens(code, last.end, node.end - 1)}\n${base}` });
|
|
75
|
+
for (const element of elements) arrayEdits(code, element, inner, edits);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Printer fix-ups on the restored cell: `for (a; b; c)` headers, then long-array breaks. */
|
|
79
|
+
export function finishLayout(code: string): string | undefined {
|
|
80
|
+
const program = programOf(parseFile(code));
|
|
81
|
+
if (program === undefined) return undefined;
|
|
82
|
+
const headerEdits: Edit[] = [];
|
|
83
|
+
forHeaderEdits(code, program, headerEdits);
|
|
84
|
+
const withHeaders = applyEdits(code, headerEdits);
|
|
85
|
+
const reparsed = programOf(parseFile(withHeaders));
|
|
86
|
+
if (reparsed === undefined) return undefined;
|
|
87
|
+
const breaks: Edit[] = [];
|
|
88
|
+
arrayEdits(withHeaders, reparsed, undefined, breaks);
|
|
89
|
+
return applyEdits(withHeaders, breaks);
|
|
90
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { applyEdits, childNodes, type Edit, type SourceNode, sourceNode } from "./display-js-ast.ts";
|
|
2
|
+
|
|
3
|
+
const LITERAL_TYPES = new Set([
|
|
4
|
+
"StringLiteral",
|
|
5
|
+
"NumericLiteral",
|
|
6
|
+
"BigIntLiteral",
|
|
7
|
+
"RegExpLiteral",
|
|
8
|
+
"BooleanLiteral",
|
|
9
|
+
"NullLiteral",
|
|
10
|
+
"TemplateLiteral",
|
|
11
|
+
"TaggedTemplateExpression",
|
|
12
|
+
"DirectiveLiteral",
|
|
13
|
+
]);
|
|
14
|
+
// One placeholder per spelling, so `break outer` still resolves against `outer:` for Bun's parser.
|
|
15
|
+
const NAME_TYPES = new Set(["Identifier", "PrivateName"]);
|
|
16
|
+
// The grammar requires a string literal under these parents (module specifiers, attributes, and
|
|
17
|
+
// string export names), so the placeholder stays a string there.
|
|
18
|
+
const STRING_SLOT_PARENTS = new Set([
|
|
19
|
+
"ImportDeclaration",
|
|
20
|
+
"ExportAllDeclaration",
|
|
21
|
+
"ExportNamedDeclaration",
|
|
22
|
+
"ImportAttribute",
|
|
23
|
+
"ImportSpecifier",
|
|
24
|
+
"ExportSpecifier",
|
|
25
|
+
"ExportNamespaceSpecifier",
|
|
26
|
+
"ExportDefaultSpecifier",
|
|
27
|
+
]);
|
|
28
|
+
const STATEMENT_LISTS = new Set(["Program", "BlockStatement", "StaticBlock", "ClassBody", "SwitchCase"]);
|
|
29
|
+
|
|
30
|
+
type SourceComment = { readonly start: number; readonly end: number; readonly line: boolean };
|
|
31
|
+
type MaskedRange = SourceNode & { readonly stringSlot: boolean };
|
|
32
|
+
export type Placeholder = { readonly id: string; readonly raw: string; readonly expected: number };
|
|
33
|
+
export type CommentPlaceholder = Placeholder & { readonly trailing: boolean };
|
|
34
|
+
export type Mask = {
|
|
35
|
+
readonly prefix: string;
|
|
36
|
+
readonly masked: string;
|
|
37
|
+
readonly values: ReadonlyMap<string, Placeholder>;
|
|
38
|
+
readonly comments: readonly CommentPlaceholder[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function fileComments(file: SourceNode): SourceComment[] | undefined {
|
|
42
|
+
const comments = file.record.comments;
|
|
43
|
+
if (!Array.isArray(comments)) return undefined;
|
|
44
|
+
const parsed: SourceComment[] = [];
|
|
45
|
+
for (const comment of comments) {
|
|
46
|
+
const node = sourceNode(comment);
|
|
47
|
+
if (node === undefined) return undefined;
|
|
48
|
+
parsed.push({ start: node.start, end: node.end, line: node.type === "CommentLine" });
|
|
49
|
+
}
|
|
50
|
+
return parsed;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function innermostContainer(node: SourceNode, start: number, end: number): SourceNode {
|
|
54
|
+
for (const child of childNodes(node)) {
|
|
55
|
+
if (child.start <= start && end <= child.end) return innermostContainer(child, start, end);
|
|
56
|
+
}
|
|
57
|
+
return node;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sitsInStatementList(code: string, program: SourceNode, comment: SourceComment): boolean {
|
|
61
|
+
const container = innermostContainer(program, comment.start, comment.end);
|
|
62
|
+
if (!STATEMENT_LISTS.has(container.type)) return false;
|
|
63
|
+
if (container.type !== "SwitchCase") return true;
|
|
64
|
+
const test = sourceNode(container.record.test);
|
|
65
|
+
const colon = code.indexOf(":", test?.end ?? container.start);
|
|
66
|
+
return colon >= 0 && colon < comment.start;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function followsCodeOnItsLine(code: string, comment: SourceComment, comments: readonly SourceComment[]): boolean {
|
|
70
|
+
let index = comment.start - 1;
|
|
71
|
+
while (index >= 0 && code[index] !== "\n") {
|
|
72
|
+
const position = index;
|
|
73
|
+
const covering = comments.find((other) => other.start <= position && position < other.end);
|
|
74
|
+
if (covering !== undefined) {
|
|
75
|
+
index = covering.start - 1;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (code[index] !== " " && code[index] !== "\t") return true;
|
|
79
|
+
index -= 1;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function placeholderPrefix(code: string): string {
|
|
85
|
+
let prefix = "$display";
|
|
86
|
+
while (code.includes(prefix)) prefix = `${prefix}_`;
|
|
87
|
+
return prefix;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function collectRanges(node: SourceNode, parent: SourceNode | undefined, ranges: Map<string, MaskedRange>): void {
|
|
91
|
+
if (LITERAL_TYPES.has(node.type) || NAME_TYPES.has(node.type)) {
|
|
92
|
+
const stringSlot = node.type === "StringLiteral" && parent !== undefined && STRING_SLOT_PARENTS.has(parent.type);
|
|
93
|
+
const key = `${node.start}:${node.end}`;
|
|
94
|
+
if (!ranges.has(key)) ranges.set(key, { ...node, stringSlot });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const child of childNodes(node)) collectRanges(child, node, ranges);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Swaps every literal, name, and statement-level comment for an opaque placeholder so a printer
|
|
102
|
+
* can only move them. Undefined when a comment sits where a placeholder statement cannot stand.
|
|
103
|
+
*/
|
|
104
|
+
export function maskSource(code: string, file: SourceNode): Mask | undefined {
|
|
105
|
+
const program = sourceNode(file.record.program);
|
|
106
|
+
const comments = fileComments(file);
|
|
107
|
+
if (program === undefined || comments === undefined) return undefined;
|
|
108
|
+
const ranges = new Map<string, MaskedRange>();
|
|
109
|
+
collectRanges(program, undefined, ranges);
|
|
110
|
+
const prefix = placeholderPrefix(code);
|
|
111
|
+
const edits: Edit[] = [];
|
|
112
|
+
const values = new Map<string, Placeholder>();
|
|
113
|
+
const nameIds = new Map<string, string>();
|
|
114
|
+
let counter = 0;
|
|
115
|
+
for (const range of ranges.values()) {
|
|
116
|
+
const raw = code.slice(range.start, range.end);
|
|
117
|
+
const reused = NAME_TYPES.has(range.type) ? nameIds.get(raw) : undefined;
|
|
118
|
+
const id = reused ?? `${prefix}${counter++}`;
|
|
119
|
+
if (NAME_TYPES.has(range.type)) nameIds.set(raw, id);
|
|
120
|
+
values.set(id, { id, raw, expected: (values.get(id)?.expected ?? 0) + 1 });
|
|
121
|
+
edits.push({ start: range.start, end: range.end, text: range.stringSlot ? `"${id}"` : id });
|
|
122
|
+
}
|
|
123
|
+
const masked: CommentPlaceholder[] = [];
|
|
124
|
+
for (const comment of comments) {
|
|
125
|
+
if (!sitsInStatementList(code, program, comment)) return undefined;
|
|
126
|
+
const id = `${prefix}${counter++}`;
|
|
127
|
+
const trailing = comment.line && followsCodeOnItsLine(code, comment, comments);
|
|
128
|
+
masked.push({ id, raw: code.slice(comment.start, comment.end), expected: 1, trailing });
|
|
129
|
+
edits.push({ start: comment.start, end: comment.end, text: `\n${id};\n` });
|
|
130
|
+
}
|
|
131
|
+
return { prefix, masked: applyEdits(code, edits), values, comments: masked };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function restoreComments(printed: string, comments: readonly CommentPlaceholder[]): string | undefined {
|
|
135
|
+
const lines = printed.split("\n");
|
|
136
|
+
for (const comment of comments) {
|
|
137
|
+
const matches = lines.flatMap((line, index) => (line.trim() === `${comment.id};` ? [index] : []));
|
|
138
|
+
const index = matches[0];
|
|
139
|
+
if (matches.length !== 1 || index === undefined) return undefined;
|
|
140
|
+
const line = lines[index] ?? "";
|
|
141
|
+
let previous = index - 1;
|
|
142
|
+
while (previous >= 0 && (lines[previous] ?? "").trim().length === 0) previous -= 1;
|
|
143
|
+
const target = lines[previous];
|
|
144
|
+
if (comment.trailing && target !== undefined && !target.includes("//")) {
|
|
145
|
+
lines[previous] = `${target} ${comment.raw}`;
|
|
146
|
+
lines.splice(index, 1);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
lines[index] = `${line.slice(0, line.length - line.trimStart().length)}${comment.raw}`;
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function escapeRegExp(text: string): string {
|
|
155
|
+
return text.replace(/[\\^$.*+?()[\]{}|]/gu, "\\$&");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function restoreValues(printed: string, mask: Mask): string | undefined {
|
|
159
|
+
const id = `${escapeRegExp(mask.prefix)}\\d+`;
|
|
160
|
+
const pattern = new RegExp(`"(${id})"|'(${id})'|(?<![\\w$])(${id})(?![\\w$])`, "gu");
|
|
161
|
+
const counts = new Map<string, number>();
|
|
162
|
+
let unknown = false;
|
|
163
|
+
const restored = printed.replace(pattern, (match, double: unknown, single: unknown, bare: unknown) => {
|
|
164
|
+
const key = [double, single, bare].find((group): group is string => typeof group === "string") ?? "";
|
|
165
|
+
const value = mask.values.get(key);
|
|
166
|
+
if (value === undefined) {
|
|
167
|
+
unknown = true;
|
|
168
|
+
return match;
|
|
169
|
+
}
|
|
170
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
171
|
+
return value.raw;
|
|
172
|
+
});
|
|
173
|
+
if (unknown) return undefined;
|
|
174
|
+
for (const value of mask.values.values()) {
|
|
175
|
+
if (counts.get(value.id) !== value.expected) return undefined;
|
|
176
|
+
}
|
|
177
|
+
return restored;
|
|
178
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { isRecord, parseFile, type SourceNode } from "./display-js-ast.ts";
|
|
2
|
+
import { finishLayout } from "./display-js-layout.ts";
|
|
3
|
+
import { maskSource, restoreComments, restoreValues } from "./display-js-mask.ts";
|
|
4
|
+
|
|
5
|
+
// Bun.Transpiler is a transpiler, not a formatter: printed directly it rewrites literals ("a\nb"
|
|
6
|
+
// becomes a multi-line template, 0xff becomes 255, emoji become escapes), folds `typeof
|
|
7
|
+
// undefined`, and drops comments and directives. It only ever lays out a masked cell here, and the
|
|
8
|
+
// original text is substituted back; a cell that cannot be restored exactly is shown as sent.
|
|
9
|
+
const INDENT = " ";
|
|
10
|
+
|
|
11
|
+
export type JsPrinter = (code: string) => string;
|
|
12
|
+
|
|
13
|
+
let bunPrinter: JsPrinter | null | undefined;
|
|
14
|
+
|
|
15
|
+
function resolveBunPrinter(): JsPrinter | null {
|
|
16
|
+
if (typeof process.versions.bun !== "string") return null;
|
|
17
|
+
const bun: unknown = Reflect.get(globalThis, "Bun");
|
|
18
|
+
if (!isRecord(bun) || typeof bun.Transpiler !== "function") return null;
|
|
19
|
+
const transpiler: unknown = Reflect.construct(bun.Transpiler, [
|
|
20
|
+
{ loader: "js", trimUnusedImports: false, deadCodeElimination: false, inline: false },
|
|
21
|
+
]);
|
|
22
|
+
if (!isRecord(transpiler) || typeof transpiler.transformSync !== "function") return null;
|
|
23
|
+
const transformSync = transpiler.transformSync;
|
|
24
|
+
return (code) => {
|
|
25
|
+
const output: unknown = Reflect.apply(transformSync, transpiler, [code]);
|
|
26
|
+
if (typeof output !== "string") throw new TypeError("Bun.Transpiler.transformSync returned a non-string");
|
|
27
|
+
return output;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The built-in Bun printer when this renderer runs on Bun; null on Node. */
|
|
32
|
+
export function bunJsPrinter(): JsPrinter | null {
|
|
33
|
+
if (bunPrinter === undefined) bunPrinter = resolveBunPrinter();
|
|
34
|
+
return bunPrinter;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function layout(masked: string, prefix: string, print: JsPrinter): string | undefined {
|
|
38
|
+
try {
|
|
39
|
+
return print(masked).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
// Module syntax only parses unwrapped; the kernel's top-level `return` only inside a function.
|
|
42
|
+
}
|
|
43
|
+
const header = `async function ${prefix}cell() {`;
|
|
44
|
+
let lines: string[];
|
|
45
|
+
try {
|
|
46
|
+
lines = print(`${header}\n${masked}\n}`).trim().split("\n");
|
|
47
|
+
} catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
if (lines[0] !== header || lines.at(-1) !== "}") return undefined;
|
|
51
|
+
return lines
|
|
52
|
+
.slice(1, -1)
|
|
53
|
+
.map((line) => (line.startsWith(INDENT) ? line.slice(INDENT.length) : line))
|
|
54
|
+
.join("\n");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sameTokens(left: string, right: string): boolean {
|
|
58
|
+
const strip = (text: string) => text.replace(/[\s;,()]/gu, "");
|
|
59
|
+
return strip(left) === strip(right);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const POSITION_KEYS = new Set([
|
|
63
|
+
"start",
|
|
64
|
+
"end",
|
|
65
|
+
"loc",
|
|
66
|
+
"range",
|
|
67
|
+
"extra",
|
|
68
|
+
"leadingComments",
|
|
69
|
+
"trailingComments",
|
|
70
|
+
"innerComments",
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
// Positions, raw spellings, and parenthesization flags aside, the preview must be the same program:
|
|
74
|
+
// the character guard alone cannot see a dropped `(`, which can change how an expression groups.
|
|
75
|
+
function programSignature(file: SourceNode): string {
|
|
76
|
+
return JSON.stringify({ program: file.record.program, comments: file.record.comments }, (key, value: unknown) => {
|
|
77
|
+
if (POSITION_KEYS.has(key)) return undefined;
|
|
78
|
+
if (typeof value === "bigint") return `${value}n`;
|
|
79
|
+
// A stray `;` in a statement list is a no-op the printer drops.
|
|
80
|
+
return Array.isArray(value) ? value.filter((item) => !isRecord(item) || item.type !== "EmptyStatement") : value;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sameProgram(original: SourceNode, preview: string): boolean {
|
|
85
|
+
const reparsed = parseFile(preview);
|
|
86
|
+
return reparsed !== undefined && programSignature(reparsed) === programSignature(original);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The printer's layout of `code` with its literal, name, and comment text intact, or undefined. */
|
|
90
|
+
export function prettifyJs(code: string, print: JsPrinter): string | undefined {
|
|
91
|
+
const file = parseFile(code);
|
|
92
|
+
if (file === undefined) return undefined;
|
|
93
|
+
const mask = maskSource(code, file);
|
|
94
|
+
if (mask === undefined) return undefined;
|
|
95
|
+
const printed = layout(mask.masked, mask.prefix, print);
|
|
96
|
+
const withComments = printed === undefined ? undefined : restoreComments(printed, mask.comments);
|
|
97
|
+
const restored = withComments === undefined ? undefined : restoreValues(withComments, mask);
|
|
98
|
+
const finished = restored === undefined ? undefined : finishLayout(restored);
|
|
99
|
+
return finished !== undefined && sameTokens(finished, code) && sameProgram(file, finished) ? finished : undefined;
|
|
100
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Runs in the user's own Python interpreter: `python -c <script> <ruff timeout s> <strategies...>` with the cell on
|
|
2
|
+
// stdin, printing {"via", "code"} as JSON. A strategy's output is kept only when it parses to the same
|
|
3
|
+
// AST as the source and keeps every string, number, and comment token's text (senpi#2076).
|
|
4
|
+
export const PYTHON_FORMATTER_SCRIPT = [
|
|
5
|
+
"import ast, io, json, re, shutil, subprocess, sys, tokenize",
|
|
6
|
+
"",
|
|
7
|
+
"def emit(via, code):",
|
|
8
|
+
" sys.stdout.write(json.dumps({'via': via, 'code': code}))",
|
|
9
|
+
" sys.exit(0)",
|
|
10
|
+
"",
|
|
11
|
+
"RUFF_TIMEOUT = float(sys.argv[1])",
|
|
12
|
+
"FLAGS = ast.PyCF_ONLY_AST | getattr(ast, 'PyCF_ALLOW_TOP_LEVEL_AWAIT', 0)",
|
|
13
|
+
"",
|
|
14
|
+
"def parse(text):",
|
|
15
|
+
" return compile(text, '<cell>', 'exec', flags=FLAGS, dont_inherit=True)",
|
|
16
|
+
"",
|
|
17
|
+
"def literal_texts(text):",
|
|
18
|
+
" kinds = {tokenize.STRING, tokenize.NUMBER, tokenize.COMMENT} | token_kinds('FSTRING_MIDDLE', 'TSTRING_MIDDLE')",
|
|
19
|
+
" return [t.string for t in tokenize.generate_tokens(io.StringIO(text).readline) if t.type in kinds]",
|
|
20
|
+
"",
|
|
21
|
+
"def equivalent(source, formatted):",
|
|
22
|
+
" try:",
|
|
23
|
+
" same_tree = ast.dump(parse(source)) == ast.dump(parse(formatted))",
|
|
24
|
+
" return same_tree and literal_texts(source) == literal_texts(formatted)",
|
|
25
|
+
" except (SyntaxError, ValueError, tokenize.TokenError):",
|
|
26
|
+
" return False",
|
|
27
|
+
"",
|
|
28
|
+
"def ruff_binary():",
|
|
29
|
+
" found = shutil.which('ruff')",
|
|
30
|
+
" if found:",
|
|
31
|
+
" return found",
|
|
32
|
+
" try:",
|
|
33
|
+
" from ruff.__main__ import find_ruff_bin",
|
|
34
|
+
" return find_ruff_bin()",
|
|
35
|
+
" except Exception:",
|
|
36
|
+
" return None",
|
|
37
|
+
"",
|
|
38
|
+
"def with_ruff(source):",
|
|
39
|
+
" binary = ruff_binary()",
|
|
40
|
+
" if not binary:",
|
|
41
|
+
" return None",
|
|
42
|
+
" args = [binary, 'format', '--quiet', '--no-cache', '--config', \"format.quote-style = 'preserve'\",",
|
|
43
|
+
" '--stdin-filename', 'cell.py', '-']",
|
|
44
|
+
" try:",
|
|
45
|
+
" done = subprocess.run(args, input=source, capture_output=True, text=True, timeout=RUFF_TIMEOUT)",
|
|
46
|
+
" except Exception:",
|
|
47
|
+
" return None",
|
|
48
|
+
" return done.stdout if done.returncode == 0 else None",
|
|
49
|
+
"",
|
|
50
|
+
"def with_black(source):",
|
|
51
|
+
" try:",
|
|
52
|
+
" import black",
|
|
53
|
+
" return black.format_str(source, mode=black.Mode(string_normalization=False))",
|
|
54
|
+
" except Exception:",
|
|
55
|
+
" return None",
|
|
56
|
+
"",
|
|
57
|
+
"def token_kinds(*names):",
|
|
58
|
+
" return {getattr(tokenize, name) for name in names if hasattr(tokenize, name)}",
|
|
59
|
+
"",
|
|
60
|
+
"def literal_spans(source):",
|
|
61
|
+
" tokens = list(tokenize.generate_tokens(io.StringIO(source).readline))",
|
|
62
|
+
" starts = [0]",
|
|
63
|
+
" for line in source.splitlines(keepends=True):",
|
|
64
|
+
" starts.append(starts[-1] + len(line))",
|
|
65
|
+
" offset = lambda position: starts[position[0] - 1] + position[1]",
|
|
66
|
+
" opening, closing = token_kinds('FSTRING_START', 'TSTRING_START'), token_kinds('FSTRING_END', 'TSTRING_END')",
|
|
67
|
+
" spans, depth = [], 0",
|
|
68
|
+
" for token in tokens:",
|
|
69
|
+
" if token.type == tokenize.COMMENT:",
|
|
70
|
+
" return None",
|
|
71
|
+
" if depth:",
|
|
72
|
+
" depth += 1 if token.type in opening else -1 if token.type in closing else 0",
|
|
73
|
+
" if depth == 0:",
|
|
74
|
+
" spans[-1][1] = offset(token.end)",
|
|
75
|
+
" continue",
|
|
76
|
+
" is_string = token.type == tokenize.STRING or token.type in opening",
|
|
77
|
+
" if not is_string and token.type != tokenize.NUMBER:",
|
|
78
|
+
" continue",
|
|
79
|
+
" start, end = offset(token.start), offset(token.end)",
|
|
80
|
+
" joined = spans and spans[-1][2] and is_string and not re.sub(r'[\\s\\\\]', '', source[spans[-1][1]:start])",
|
|
81
|
+
" if joined:",
|
|
82
|
+
" spans[-1][1] = end",
|
|
83
|
+
" else:",
|
|
84
|
+
" spans.append([start, end, is_string])",
|
|
85
|
+
" if token.type in opening:",
|
|
86
|
+
" depth = 1",
|
|
87
|
+
" return spans",
|
|
88
|
+
"",
|
|
89
|
+
"def with_ast(source):",
|
|
90
|
+
" if not hasattr(ast, 'unparse'):",
|
|
91
|
+
" return None",
|
|
92
|
+
" try:",
|
|
93
|
+
" spans = literal_spans(source)",
|
|
94
|
+
" except (tokenize.TokenError, SyntaxError):",
|
|
95
|
+
" return None",
|
|
96
|
+
" if spans is None:",
|
|
97
|
+
" return None",
|
|
98
|
+
" prefix = '_display'",
|
|
99
|
+
" while prefix in source:",
|
|
100
|
+
" prefix += '_'",
|
|
101
|
+
" raws, masked, cursor = {}, [], 0",
|
|
102
|
+
" for index, (start, end, _) in enumerate(spans):",
|
|
103
|
+
" name = prefix + str(index)",
|
|
104
|
+
" raws[name] = source[start:end]",
|
|
105
|
+
" masked.append(source[cursor:start] + name)",
|
|
106
|
+
" cursor = end",
|
|
107
|
+
" masked.append(source[cursor:])",
|
|
108
|
+
" try:",
|
|
109
|
+
" tree = parse(''.join(masked))",
|
|
110
|
+
" except (SyntaxError, ValueError):",
|
|
111
|
+
" return None",
|
|
112
|
+
" seen = []",
|
|
113
|
+
" def restore(match):",
|
|
114
|
+
" seen.append(match.group(0))",
|
|
115
|
+
" return raws.get(match.group(0), match.group(0))",
|
|
116
|
+
" text = re.sub(r'\\b' + re.escape(prefix) + r'\\d+\\b', restore, ast.unparse(tree))",
|
|
117
|
+
" return text if sorted(seen) == sorted(raws) else None",
|
|
118
|
+
"",
|
|
119
|
+
"STRATEGIES = {'ruff': with_ruff, 'black': with_black, 'ast': with_ast}",
|
|
120
|
+
"source = sys.stdin.read()",
|
|
121
|
+
"for name in sys.argv[2:]:",
|
|
122
|
+
" strategy = STRATEGIES.get(name)",
|
|
123
|
+
" formatted = strategy(source) if strategy else None",
|
|
124
|
+
" if formatted is not None and equivalent(source, formatted):",
|
|
125
|
+
" emit(name, formatted.rstrip('\\n'))",
|
|
126
|
+
"emit('none', source)",
|
|
127
|
+
].join("\n");
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createInterpreterDetector } from "../interpreters/detect.ts";
|
|
3
|
+
import { PYTHON_FORMATTER_SCRIPT } from "./display-python-script.ts";
|
|
4
|
+
|
|
5
|
+
export type PythonStrategy = "ruff" | "black" | "ast";
|
|
6
|
+
export type PythonInvocation = { readonly command: string; readonly args: readonly string[] };
|
|
7
|
+
|
|
8
|
+
export interface PythonFormatOptions {
|
|
9
|
+
readonly strategies: readonly PythonStrategy[];
|
|
10
|
+
readonly timeoutMs: number;
|
|
11
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PythonDisplayOptions extends PythonFormatOptions {
|
|
15
|
+
readonly interpreter: () => Promise<PythonInvocation | null>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PythonDisplay {
|
|
19
|
+
/**
|
|
20
|
+
* The formatted cell once it is known, otherwise the cell as sent. With `onFormatted`, a missing
|
|
21
|
+
* result is formatted in the background and `onFormatted` runs when a formatted cell is ready.
|
|
22
|
+
*/
|
|
23
|
+
display(code: string, onFormatted?: () => void): string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const DEFAULT_STRATEGIES: readonly PythonStrategy[] = ["ruff", "black", "ast"];
|
|
27
|
+
const FORMAT_TIMEOUT_MS = 5_000;
|
|
28
|
+
const MAX_CONCURRENT_FORMATTERS = 2;
|
|
29
|
+
const CACHE_LIMIT = 64;
|
|
30
|
+
|
|
31
|
+
function formattedCode(stdout: string): string | undefined {
|
|
32
|
+
try {
|
|
33
|
+
const parsed: unknown = JSON.parse(stdout);
|
|
34
|
+
if (parsed === null || typeof parsed !== "object" || !("code" in parsed)) return undefined;
|
|
35
|
+
return typeof parsed.code === "string" ? parsed.code : undefined;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof SyntaxError) return undefined;
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ruff runs inside the formatter process, so it gets most of the caller's budget and never outlives it.
|
|
43
|
+
function ruffTimeoutSeconds(timeoutMs: number): string {
|
|
44
|
+
return String(Math.max(1, timeoutMs - 1_000) / 1_000);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatPythonCell(
|
|
48
|
+
code: string,
|
|
49
|
+
invocation: PythonInvocation,
|
|
50
|
+
options: PythonFormatOptions,
|
|
51
|
+
): Promise<string | undefined> {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const child = spawn(
|
|
54
|
+
invocation.command,
|
|
55
|
+
[
|
|
56
|
+
...invocation.args,
|
|
57
|
+
"-c",
|
|
58
|
+
PYTHON_FORMATTER_SCRIPT,
|
|
59
|
+
ruffTimeoutSeconds(options.timeoutMs),
|
|
60
|
+
...options.strategies,
|
|
61
|
+
],
|
|
62
|
+
{
|
|
63
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
64
|
+
timeout: options.timeoutMs,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
let stdout = "";
|
|
70
|
+
child.stdout.setEncoding("utf8");
|
|
71
|
+
child.stdout.on("data", (chunk: string) => {
|
|
72
|
+
stdout += chunk;
|
|
73
|
+
});
|
|
74
|
+
child.on("error", () => resolve(undefined));
|
|
75
|
+
child.on("close", (exitCode) => resolve(exitCode === 0 ? formattedCode(stdout) : undefined));
|
|
76
|
+
child.stdin.on("error", () => resolve(undefined));
|
|
77
|
+
child.stdin.end(code);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createPythonDisplay(options: PythonDisplayOptions): PythonDisplay {
|
|
82
|
+
const settled = new Map<string, string>();
|
|
83
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
84
|
+
const queue: string[] = [];
|
|
85
|
+
let running = 0;
|
|
86
|
+
|
|
87
|
+
const remember = (code: string, formatted: string) => {
|
|
88
|
+
if (settled.size >= CACHE_LIMIT) settled.delete(settled.keys().next().value ?? "");
|
|
89
|
+
settled.set(code, formatted);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const format = async (code: string): Promise<string | undefined> => {
|
|
93
|
+
const invocation = await options.interpreter();
|
|
94
|
+
return invocation === null ? undefined : formatPythonCell(code, invocation, options);
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const pump = () => {
|
|
98
|
+
while (running < MAX_CONCURRENT_FORMATTERS) {
|
|
99
|
+
const code = queue.shift();
|
|
100
|
+
if (code === undefined) return;
|
|
101
|
+
running += 1;
|
|
102
|
+
void format(code)
|
|
103
|
+
.catch(() => undefined)
|
|
104
|
+
.then((formatted) => {
|
|
105
|
+
remember(code, formatted ?? code);
|
|
106
|
+
const waiting = listeners.get(code);
|
|
107
|
+
listeners.delete(code);
|
|
108
|
+
if (formatted !== undefined && formatted !== code) for (const listener of waiting ?? []) listener();
|
|
109
|
+
})
|
|
110
|
+
.finally(() => {
|
|
111
|
+
running -= 1;
|
|
112
|
+
pump();
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
display(code, onFormatted) {
|
|
119
|
+
const known = settled.get(code);
|
|
120
|
+
if (known !== undefined || onFormatted === undefined) return known ?? code;
|
|
121
|
+
const waiting = listeners.get(code);
|
|
122
|
+
if (waiting !== undefined) {
|
|
123
|
+
waiting.add(onFormatted);
|
|
124
|
+
return code;
|
|
125
|
+
}
|
|
126
|
+
listeners.set(code, new Set([onFormatted]));
|
|
127
|
+
queue.push(code);
|
|
128
|
+
pump();
|
|
129
|
+
return code;
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function detectedPython(): Promise<PythonInvocation | null> {
|
|
135
|
+
const detected = await createInterpreterDetector().detect("py");
|
|
136
|
+
if (!detected.ok) return null;
|
|
137
|
+
const [command, ...args] = detected.path.split(" ");
|
|
138
|
+
return command === undefined ? null : { command, args };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
let userPython: Promise<PythonInvocation | null> | undefined;
|
|
142
|
+
|
|
143
|
+
export const pythonDisplay: PythonDisplay = createPythonDisplay({
|
|
144
|
+
interpreter: () => {
|
|
145
|
+
userPython ??= detectedPython();
|
|
146
|
+
return userPython;
|
|
147
|
+
},
|
|
148
|
+
strategies: DEFAULT_STRATEGIES,
|
|
149
|
+
timeoutMs: FORMAT_TIMEOUT_MS,
|
|
150
|
+
});
|
package/src/tool/eval-request.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
2
|
-
import
|
|
2
|
+
import {
|
|
3
|
+
type EvalControlInput,
|
|
4
|
+
type EvalLanguage,
|
|
5
|
+
type EvalToolInput,
|
|
6
|
+
type EvalToolRequest,
|
|
7
|
+
evalLanguageOrder,
|
|
8
|
+
} from "./types.ts";
|
|
3
9
|
|
|
4
10
|
const NON_INTERACTIVE_MODES = new Set(["print", "json"]);
|
|
5
11
|
|
|
@@ -11,7 +17,10 @@ export function normalizeEvalSummary(value: unknown): string | undefined {
|
|
|
11
17
|
return normalized.length === 0 ? undefined : normalized;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
|
-
export function parseEvalRequest(
|
|
20
|
+
export function parseEvalRequest(
|
|
21
|
+
params: unknown,
|
|
22
|
+
enabledLanguages: readonly EvalLanguage[] = evalLanguageOrder,
|
|
23
|
+
): EvalToolRequest {
|
|
15
24
|
if (!isRecord(params)) throw new TypeError("eval parameters must be an object");
|
|
16
25
|
if (params.action === "list") return { action: "list" };
|
|
17
26
|
if (params.action === "peek" || params.action === "stop") {
|
|
@@ -21,8 +30,9 @@ export function parseEvalRequest(params: unknown): EvalToolRequest {
|
|
|
21
30
|
}
|
|
22
31
|
if (params.action !== undefined && params.action !== "run")
|
|
23
32
|
throw new TypeError(`Unknown eval action "${String(params.action)}"`);
|
|
24
|
-
if (!isEvalLanguage(params.language)) throw new TypeError(
|
|
25
|
-
if (typeof params.code !== "string")
|
|
33
|
+
if (!isEvalLanguage(params.language)) throw new TypeError(evalRunRequiresLanguageMessage(enabledLanguages));
|
|
34
|
+
if (typeof params.code !== "string")
|
|
35
|
+
throw new TypeError("eval run requires code — the cell body to execute, verbatim");
|
|
26
36
|
const summary = normalizeEvalSummary(params.summary);
|
|
27
37
|
if (summary === undefined)
|
|
28
38
|
throw new TypeError(
|
|
@@ -57,3 +67,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
57
67
|
function isEvalLanguage(value: unknown): value is EvalToolInput["language"] {
|
|
58
68
|
return value === "py" || value === "js" || value === "rb" || value === "jl";
|
|
59
69
|
}
|
|
70
|
+
|
|
71
|
+
function evalRunRequiresLanguageMessage(languages: readonly EvalLanguage[]): string {
|
|
72
|
+
return `eval run requires language — one of ${languages.map((language) => `"${language}"`).join(", ")}`;
|
|
73
|
+
}
|
package/src/tool/eval-tool.ts
CHANGED
|
@@ -73,7 +73,7 @@ export function createEvalTool(options: CreateEvalToolOptions) {
|
|
|
73
73
|
async function execute(
|
|
74
74
|
...[toolCallId, params, signal, onUpdate, ctx]: EvalExecuteArgs<EvalToolRequest>
|
|
75
75
|
): Promise<AgentToolResult<EvalResultDetails>> {
|
|
76
|
-
const request = parseEvalRequest(params);
|
|
76
|
+
const request = parseEvalRequest(params, languages);
|
|
77
77
|
if (isEvalControlRequest(request)) return await executeEvalControl(cellManager, request);
|
|
78
78
|
if (options.proxyExecutor) return await options.proxyExecutor(request, signal);
|
|
79
79
|
if (!languages.includes(request.language))
|
package/src/tool/render.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// allow: SIZE_OK — one eval render pipeline avoids the runtime/render TDZ that motivated this module boundary.
|
|
2
2
|
import {
|
|
3
3
|
type AgentToolResult,
|
|
4
|
-
highlightCode,
|
|
5
4
|
sanitizeTerminalLabel,
|
|
6
5
|
type Theme,
|
|
7
6
|
type ThemeColor,
|
|
@@ -10,6 +9,7 @@ import {
|
|
|
10
9
|
truncateToVisualLines,
|
|
11
10
|
} from "@code-yeongyu/senpi";
|
|
12
11
|
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
12
|
+
import { highlightedCode } from "./code-preview.ts";
|
|
13
13
|
import { displayCode } from "./display-code.ts";
|
|
14
14
|
import { normalizeEvalSummary } from "./eval-request.ts";
|
|
15
15
|
import {
|
|
@@ -26,7 +26,6 @@ import { codePointPrefix, formatDuration, renderToolCallWidget } from "./tool-wi
|
|
|
26
26
|
import type {
|
|
27
27
|
EvalCellResult,
|
|
28
28
|
EvalInputSchema,
|
|
29
|
-
EvalLanguage,
|
|
30
29
|
EvalResultDetails,
|
|
31
30
|
EvalStatusEvent,
|
|
32
31
|
EvalToolDetails,
|
|
@@ -269,6 +268,8 @@ type RenderEnvironment = {
|
|
|
269
268
|
readonly meta: TruncationMeta | undefined;
|
|
270
269
|
/** Render-time clock, injected so elapsed time is deterministic under test. */
|
|
271
270
|
readonly now: number;
|
|
271
|
+
/** Repaints the row once a background-formatted code preview is ready; absent while args stream. */
|
|
272
|
+
readonly repaint?: () => void;
|
|
272
273
|
};
|
|
273
274
|
type CellBadges = {
|
|
274
275
|
readonly reset: boolean;
|
|
@@ -288,27 +289,6 @@ function assertNever(value: never): never {
|
|
|
288
289
|
throw new TypeError(`Unhandled eval render variant: ${String(value)}`);
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
function languageForHighlighter(language: EvalLanguage): "python" | "javascript" | "ruby" | "julia" {
|
|
292
|
-
switch (language) {
|
|
293
|
-
case "py":
|
|
294
|
-
return "python";
|
|
295
|
-
case "js":
|
|
296
|
-
return "javascript";
|
|
297
|
-
case "rb":
|
|
298
|
-
return "ruby";
|
|
299
|
-
case "jl":
|
|
300
|
-
return "julia";
|
|
301
|
-
default:
|
|
302
|
-
return assertNever(language);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
function highlightedCode(code: string, language: EvalLanguage, theme: Theme | undefined): string {
|
|
307
|
-
const normalizedCode = code.trim().length > 0 ? code : "...";
|
|
308
|
-
const lines = highlightCode(displayCode(normalizedCode, language), languageForHighlighter(language));
|
|
309
|
-
return (theme === undefined ? lines.map((line) => line.replace(/\u001b\[[0-9;]*m/gu, "")) : lines).join("\n");
|
|
310
|
-
}
|
|
311
|
-
|
|
312
292
|
function spinner(frame: number | undefined): string {
|
|
313
293
|
return SPINNER_FRAMES.at((frame ?? 0) % SPINNER_FRAMES.length) ?? SPINNER_FRAMES[0];
|
|
314
294
|
}
|
|
@@ -644,7 +624,7 @@ function renderCell(cell: EvalCellResult, environment: RenderEnvironment, badges
|
|
|
644
624
|
}
|
|
645
625
|
const innerWidth = Math.max(1, environment.width - 2);
|
|
646
626
|
const codePreview = previewText(
|
|
647
|
-
highlightedCode(cell.code, cell.language, environment.theme),
|
|
627
|
+
highlightedCode(cell.code, cell.language, environment.theme, environment.repaint),
|
|
648
628
|
environment.expanded ? Number.POSITIVE_INFINITY : CODE_PREVIEW_LINES,
|
|
649
629
|
innerWidth,
|
|
650
630
|
);
|
|
@@ -926,7 +906,9 @@ export function renderEvalCall(
|
|
|
926
906
|
text: style(
|
|
927
907
|
theme,
|
|
928
908
|
"mdCodeBlock",
|
|
929
|
-
args.code.trim().length > 0
|
|
909
|
+
args.code.trim().length > 0
|
|
910
|
+
? displayCode(args.code, args.language, context.argsComplete ? context.invalidate : undefined)
|
|
911
|
+
: "...",
|
|
930
912
|
),
|
|
931
913
|
maxVisualLines: context.expanded ? undefined : CODE_PREVIEW_LINES,
|
|
932
914
|
collapseKind: "code",
|
|
@@ -946,6 +928,7 @@ export function renderEvalCall(
|
|
|
946
928
|
width,
|
|
947
929
|
meta: undefined,
|
|
948
930
|
now: renderNow(context),
|
|
931
|
+
...(context.argsComplete ? { repaint: context.invalidate } : {}),
|
|
949
932
|
};
|
|
950
933
|
const cell: EvalCellResult = {
|
|
951
934
|
index: 0,
|
|
@@ -998,6 +981,7 @@ export function renderEvalResult(
|
|
|
998
981
|
width,
|
|
999
982
|
meta: details.meta,
|
|
1000
983
|
now: renderNow(context),
|
|
984
|
+
repaint: context.invalidate,
|
|
1001
985
|
},
|
|
1002
986
|
args: context.args,
|
|
1003
987
|
showImageFallback: context.showImages && imageProtocol === null,
|
package/src/tool/types.ts
CHANGED
|
@@ -65,6 +65,12 @@ export type EvalControlInput =
|
|
|
65
65
|
|
|
66
66
|
export type EvalToolRequest = EvalToolInput | EvalControlInput;
|
|
67
67
|
|
|
68
|
+
// Like `summary`, `language` and `code` stay optional in the wire schema because control
|
|
69
|
+
// actions share it; the description teaches the requirement and parseEvalRequest enforces it.
|
|
70
|
+
const LANGUAGE_FIELD_DESCRIPTION =
|
|
71
|
+
"REQUIRED for run. Kernel that runs the cell; each language keeps its own persistent state across eval calls.";
|
|
72
|
+
const CODE_FIELD_DESCRIPTION = "REQUIRED for run. Cell body, verbatim.";
|
|
73
|
+
|
|
68
74
|
function evalInputProperties<Language extends TSchema>(languageSchema: Language, deadlines: EvalDeadlineSeconds) {
|
|
69
75
|
return {
|
|
70
76
|
action: Type.Optional(
|
|
@@ -74,7 +80,7 @@ function evalInputProperties<Language extends TSchema>(languageSchema: Language,
|
|
|
74
80
|
}),
|
|
75
81
|
),
|
|
76
82
|
language: Type.Optional(languageSchema),
|
|
77
|
-
code: Type.Optional(Type.String({ description:
|
|
83
|
+
code: Type.Optional(Type.String({ description: CODE_FIELD_DESCRIPTION })),
|
|
78
84
|
summary: Type.Optional(
|
|
79
85
|
Type.String({
|
|
80
86
|
description:
|
|
@@ -96,11 +102,15 @@ function evalInputProperties<Language extends TSchema>(languageSchema: Language,
|
|
|
96
102
|
};
|
|
97
103
|
}
|
|
98
104
|
|
|
105
|
+
function evalLanguageUnion(languages: readonly EvalLanguage[]) {
|
|
106
|
+
return Type.Union(
|
|
107
|
+
languages.map((item) => Type.Literal(item)),
|
|
108
|
+
{ description: LANGUAGE_FIELD_DESCRIPTION },
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
99
112
|
const fullEvalInputSchema = Type.Object(
|
|
100
|
-
evalInputProperties(
|
|
101
|
-
Type.Union([Type.Literal("js"), Type.Literal("py"), Type.Literal("rb"), Type.Literal("jl")]),
|
|
102
|
-
defaultEvalDeadlineSeconds,
|
|
103
|
-
),
|
|
113
|
+
evalInputProperties(evalLanguageUnion(evalLanguageOrder), defaultEvalDeadlineSeconds),
|
|
104
114
|
);
|
|
105
115
|
|
|
106
116
|
/** Runtime accepts a discriminated run/control union. */
|
|
@@ -112,10 +122,7 @@ export function createEvalInputSchema(
|
|
|
112
122
|
): EvalInputSchema {
|
|
113
123
|
const languages = enabledLanguageList(enabled);
|
|
114
124
|
if (languages.length === 0) throw new Error("eval requires at least one enabled language");
|
|
115
|
-
const languageSchema =
|
|
116
|
-
languages.length === 1
|
|
117
|
-
? Type.Union([Type.Literal(languages[0])])
|
|
118
|
-
: Type.Union(languages.map((item) => Type.Literal(item)));
|
|
125
|
+
const languageSchema = evalLanguageUnion(languages);
|
|
119
126
|
return Type.Unsafe<EvalToolRequest>(
|
|
120
127
|
Type.Object(evalInputProperties(languageSchema, deadlines), {
|
|
121
128
|
anyOf: [
|