@code-yeongyu/senpi-codemode 2026.9.23 → 2026.9.24-2

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.
@@ -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
+ });
@@ -1,22 +1,26 @@
1
1
  import type { ExtensionContext } from "@code-yeongyu/senpi";
2
- import { EVAL_SUMMARY_MAX_LENGTH, type EvalControlInput, type EvalToolInput, type EvalToolRequest } from "./types.ts";
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
 
6
- const ELLIPSIS = "...";
7
-
8
- // Harness-side enforcement of the schema maxLength: the tool advertises the limit, but an
9
- // over-limit value is force-truncated here (prepareArguments runs before schema validation)
10
- // instead of failing the call.
11
- export function clampEvalSummary(value: unknown): string | undefined {
12
+ // A summary is one line of any length: whitespace (including newlines) collapses to single
13
+ // spaces, and a blank value counts as absent. The TUI bounds how much of it a collapsed block shows.
14
+ export function normalizeEvalSummary(value: unknown): string | undefined {
12
15
  if (typeof value !== "string") return undefined;
13
16
  const normalized = value.trim().replace(/\s+/gu, " ");
14
- if (normalized.length === 0) return undefined;
15
- if (normalized.length <= EVAL_SUMMARY_MAX_LENGTH) return normalized;
16
- return `${normalized.slice(0, EVAL_SUMMARY_MAX_LENGTH - ELLIPSIS.length)}${ELLIPSIS}`;
17
+ return normalized.length === 0 ? undefined : normalized;
17
18
  }
18
19
 
19
- export function parseEvalRequest(params: unknown): EvalToolRequest {
20
+ export function parseEvalRequest(
21
+ params: unknown,
22
+ enabledLanguages: readonly EvalLanguage[] = evalLanguageOrder,
23
+ ): EvalToolRequest {
20
24
  if (!isRecord(params)) throw new TypeError("eval parameters must be an object");
21
25
  if (params.action === "list") return { action: "list" };
22
26
  if (params.action === "peek" || params.action === "stop") {
@@ -26,12 +30,13 @@ export function parseEvalRequest(params: unknown): EvalToolRequest {
26
30
  }
27
31
  if (params.action !== undefined && params.action !== "run")
28
32
  throw new TypeError(`Unknown eval action "${String(params.action)}"`);
29
- if (!isEvalLanguage(params.language)) throw new TypeError("eval run requires language");
30
- if (typeof params.code !== "string") throw new TypeError("eval run requires code");
31
- const summary = clampEvalSummary(params.summary);
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");
36
+ const summary = normalizeEvalSummary(params.summary);
32
37
  if (summary === undefined)
33
38
  throw new TypeError(
34
- "eval run requires summary — one line in the user's language: what this cell does and for what purpose",
39
+ "eval run requires summary — one line in the user's language: what you are working on and for what purpose",
35
40
  );
36
41
  if (params.on_timeout !== undefined && params.on_timeout !== "detach" && params.on_timeout !== "error")
37
42
  throw new TypeError(`Unknown eval on_timeout value "${String(params.on_timeout)}"`);
@@ -62,3 +67,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
62
67
  function isEvalLanguage(value: unknown): value is EvalToolInput["language"] {
63
68
  return value === "py" || value === "js" || value === "rb" || value === "jl";
64
69
  }
70
+
71
+ function evalRunRequiresLanguageMessage(languages: readonly EvalLanguage[]): string {
72
+ return `eval run requires language — one of ${languages.map((language) => `"${language}"`).join(", ")}`;
73
+ }
@@ -8,7 +8,7 @@ import {
8
8
  import { buildEvalPrompt } from "../prompt/eval-prompt.ts";
9
9
  import { EvalDetachedCellManager } from "./detached-cell-manager.ts";
10
10
  import { executeEvalControl } from "./detached-eval-result.ts";
11
- import { clampEvalSummary, isEvalControlRequest, parseEvalRequest } from "./eval-request.ts";
11
+ import { isEvalControlRequest, normalizeEvalSummary, parseEvalRequest } from "./eval-request.ts";
12
12
  import type { CreateEvalToolOptions } from "./eval-tool-options.ts";
13
13
  import { runEvalCell } from "./run-eval-cell.ts";
14
14
  import {
@@ -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))
@@ -109,7 +109,7 @@ export function createEvalTool(options: CreateEvalToolOptions) {
109
109
  const record = args as Record<string, unknown>;
110
110
  if (record.action === "peek" || record.action === "stop" || record.action === "list")
111
111
  return args as EvalToolRequest;
112
- const summary = clampEvalSummary(record.summary);
112
+ const summary = normalizeEvalSummary(record.summary);
113
113
  if (summary === undefined) delete record.summary;
114
114
  else record.summary = summary;
115
115
  return args as EvalToolRequest;