@oh-my-pi/pi-coding-agent 14.9.3 → 14.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/package.json +7 -7
  3. package/src/async/job-manager.ts +66 -9
  4. package/src/capability/rule.ts +20 -0
  5. package/src/cli/setup-cli.ts +14 -161
  6. package/src/cli/stats-cli.ts +56 -2
  7. package/src/cli.ts +0 -1
  8. package/src/config/model-registry.ts +13 -0
  9. package/src/config/model-resolver.ts +8 -2
  10. package/src/config/settings-schema.ts +1 -11
  11. package/src/edit/index.ts +8 -0
  12. package/src/edit/renderer.ts +6 -1
  13. package/src/edit/streaming.ts +53 -2
  14. package/src/eval/eval.lark +30 -10
  15. package/src/eval/js/context-manager.ts +334 -601
  16. package/src/eval/js/shared/helpers.ts +237 -0
  17. package/src/eval/js/shared/indirect-eval.ts +30 -0
  18. package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -2
  19. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  20. package/src/eval/js/shared/runtime.ts +168 -0
  21. package/src/eval/js/shared/types.ts +18 -0
  22. package/src/eval/js/tool-bridge.ts +2 -4
  23. package/src/eval/js/worker-core.ts +146 -0
  24. package/src/eval/js/worker-entry.ts +24 -0
  25. package/src/eval/js/worker-protocol.ts +41 -0
  26. package/src/eval/parse.ts +218 -49
  27. package/src/eval/py/display.ts +71 -0
  28. package/src/eval/py/executor.ts +97 -96
  29. package/src/eval/py/index.ts +2 -2
  30. package/src/eval/py/kernel.ts +472 -900
  31. package/src/eval/py/prelude.py +106 -87
  32. package/src/eval/py/runner.py +879 -0
  33. package/src/eval/py/runtime.ts +3 -16
  34. package/src/eval/py/tool-bridge.ts +137 -0
  35. package/src/export/html/template.css +12 -0
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +113 -7
  38. package/src/extensibility/plugins/loader.ts +31 -6
  39. package/src/extensibility/skills.ts +20 -0
  40. package/src/internal-urls/agent-protocol.ts +63 -52
  41. package/src/internal-urls/artifact-protocol.ts +51 -51
  42. package/src/internal-urls/docs-index.generated.ts +35 -3
  43. package/src/internal-urls/index.ts +6 -19
  44. package/src/internal-urls/local-protocol.ts +49 -7
  45. package/src/internal-urls/mcp-protocol.ts +2 -8
  46. package/src/internal-urls/memory-protocol.ts +89 -59
  47. package/src/internal-urls/router.ts +38 -22
  48. package/src/internal-urls/rule-protocol.ts +2 -20
  49. package/src/internal-urls/skill-protocol.ts +4 -27
  50. package/src/main.ts +1 -1
  51. package/src/mcp/manager.ts +17 -0
  52. package/src/modes/components/session-observer-overlay.ts +2 -2
  53. package/src/modes/components/tool-execution.ts +6 -0
  54. package/src/modes/components/tree-selector.ts +4 -0
  55. package/src/modes/controllers/command-controller.ts +0 -23
  56. package/src/modes/controllers/event-controller.ts +23 -2
  57. package/src/modes/controllers/mcp-command-controller.ts +7 -10
  58. package/src/modes/interactive-mode.ts +2 -2
  59. package/src/modes/theme/theme.ts +27 -27
  60. package/src/modes/types.ts +1 -1
  61. package/src/modes/utils/ui-helpers.ts +14 -9
  62. package/src/prompts/commands/orchestrate.md +1 -0
  63. package/src/prompts/system/project-prompt.md +10 -2
  64. package/src/prompts/system/subagent-system-prompt.md +8 -8
  65. package/src/prompts/system/system-prompt.md +13 -7
  66. package/src/prompts/tools/ask.md +0 -1
  67. package/src/prompts/tools/bash.md +0 -10
  68. package/src/prompts/tools/eval.md +15 -30
  69. package/src/prompts/tools/github.md +6 -5
  70. package/src/prompts/tools/hashline.md +1 -0
  71. package/src/prompts/tools/job.md +14 -6
  72. package/src/prompts/tools/task.md +20 -3
  73. package/src/registry/agent-registry.ts +2 -1
  74. package/src/sdk.ts +87 -89
  75. package/src/session/agent-session.ts +58 -21
  76. package/src/session/artifacts.ts +7 -4
  77. package/src/session/history-storage.ts +77 -19
  78. package/src/session/session-manager.ts +30 -1
  79. package/src/ssh/connection-manager.ts +32 -16
  80. package/src/ssh/sshfs-mount.ts +10 -7
  81. package/src/system-prompt.ts +0 -5
  82. package/src/task/executor.ts +14 -2
  83. package/src/task/index.ts +19 -5
  84. package/src/tool-discovery/tool-index.ts +21 -8
  85. package/src/tools/ast-edit.ts +3 -2
  86. package/src/tools/ast-grep.ts +3 -2
  87. package/src/tools/bash.ts +15 -9
  88. package/src/tools/browser/tab-protocol.ts +4 -0
  89. package/src/tools/browser/tab-supervisor.ts +98 -7
  90. package/src/tools/browser/tab-worker.ts +104 -58
  91. package/src/tools/eval.ts +49 -11
  92. package/src/tools/fetch.ts +1 -1
  93. package/src/tools/gh.ts +140 -4
  94. package/src/tools/index.ts +12 -11
  95. package/src/tools/job.ts +48 -12
  96. package/src/tools/read.ts +5 -4
  97. package/src/tools/search.ts +3 -2
  98. package/src/tools/todo-write.ts +1 -1
  99. package/src/web/scrapers/mastodon.ts +1 -1
  100. package/src/web/scrapers/repology.ts +7 -7
  101. package/src/web/search/index.ts +6 -4
  102. package/src/cli/jupyter-cli.ts +0 -106
  103. package/src/commands/jupyter.ts +0 -32
  104. package/src/eval/py/cancellation.ts +0 -28
  105. package/src/eval/py/gateway-coordinator.ts +0 -424
  106. package/src/internal-urls/jobs-protocol.ts +0 -120
  107. package/src/prompts/system/now-prompt.md +0 -7
  108. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
@@ -1,11 +1,9 @@
1
1
  import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
2
2
  import type { ToolSession } from "../../tools";
3
3
  import { ToolError } from "../../tools/tool-errors";
4
+ import type { JsStatusEvent } from "./shared/types";
4
5
 
5
- export interface JsStatusEvent {
6
- op: string;
7
- [key: string]: unknown;
8
- }
6
+ export type { JsStatusEvent } from "./shared/types";
9
7
 
10
8
  interface ToolBridgeOptions {
11
9
  session: ToolSession;
@@ -0,0 +1,146 @@
1
+ import { ToolError } from "../../tools/tool-errors";
2
+ import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
3
+ import type { RunErrorPayload, SessionSnapshot, ToolReply, Transport, WorkerInbound } from "./worker-protocol";
4
+
5
+ interface PendingTool {
6
+ resolve(value: unknown): void;
7
+ reject(error: Error): void;
8
+ }
9
+
10
+ interface ActiveRun {
11
+ runId: string;
12
+ pendingTools: Map<string, PendingTool>;
13
+ }
14
+
15
+ function errorPayload(error: unknown): RunErrorPayload {
16
+ if (error instanceof Error) {
17
+ return {
18
+ name: error.name,
19
+ message: error.message,
20
+ stack: error.stack,
21
+ isAbort: error.name === "AbortError" || error.name === "ToolAbortError",
22
+ isToolError: error.name === "ToolError" || error instanceof ToolError,
23
+ };
24
+ }
25
+ return { message: String(error) };
26
+ }
27
+
28
+ function errorFromPayload(payload: RunErrorPayload): Error {
29
+ const ctor = payload.isToolError ? ToolError : Error;
30
+ const error = new ctor(payload.message);
31
+ if (payload.name) error.name = payload.name;
32
+ if (payload.stack) error.stack = payload.stack;
33
+ return error;
34
+ }
35
+
36
+ export class WorkerCore {
37
+ #transport: Transport;
38
+ #runtime: JsRuntime | null = null;
39
+ #queue: Promise<void> = Promise.resolve();
40
+ #active: ActiveRun | null = null;
41
+ #unsubscribe: () => void;
42
+
43
+ constructor(transport: Transport) {
44
+ this.#transport = transport;
45
+ this.#unsubscribe = transport.onMessage(msg => this.#handle(msg));
46
+ transport.send({ type: "ready" });
47
+ }
48
+
49
+ #handle(msg: WorkerInbound): void {
50
+ switch (msg.type) {
51
+ case "init":
52
+ this.#ensureRuntime(msg.snapshot);
53
+ return;
54
+ case "run":
55
+ this.#enqueueRun(msg.runId, msg.code, msg.filename, msg.snapshot);
56
+ return;
57
+ case "tool-reply":
58
+ this.#deliverToolReply(msg.id, msg.reply);
59
+ return;
60
+ case "close":
61
+ this.#close();
62
+ return;
63
+ }
64
+ }
65
+
66
+ #ensureRuntime(snapshot: SessionSnapshot): JsRuntime {
67
+ if (this.#runtime) {
68
+ this.#runtime.setCwd(snapshot.cwd);
69
+ return this.#runtime;
70
+ }
71
+ this.#runtime = new JsRuntime({
72
+ initialCwd: snapshot.cwd,
73
+ sessionId: snapshot.sessionId,
74
+ getHooks: () => this.#hooksForCurrentRun(),
75
+ });
76
+ return this.#runtime;
77
+ }
78
+
79
+ #hooksForCurrentRun(): RuntimeHooks | null {
80
+ const active = this.#active;
81
+ if (!active) return null;
82
+ const runId = active.runId;
83
+ return {
84
+ onText: chunk => this.#transport.send({ type: "text", runId, chunk }),
85
+ onDisplay: output => this.#transport.send({ type: "display", runId, output }),
86
+ callTool: (name, args) => this.#callTool(active, name, args),
87
+ };
88
+ }
89
+
90
+ #enqueueRun(runId: string, code: string, filename: string, snapshot: SessionSnapshot): void {
91
+ const previous = this.#queue;
92
+ const next = (async () => {
93
+ await previous.catch(() => undefined);
94
+ await this.#runOne(runId, code, filename, snapshot);
95
+ })();
96
+ this.#queue = next.catch(() => undefined);
97
+ }
98
+
99
+ async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
100
+ const runtime = this.#ensureRuntime(snapshot);
101
+ runtime.setCwd(snapshot.cwd);
102
+ this.#active = { runId, pendingTools: new Map() };
103
+ try {
104
+ const value = await runtime.run(code, filename);
105
+ runtime.displayValue(value);
106
+ this.#transport.send({ type: "result", runId, ok: true });
107
+ } catch (error) {
108
+ this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
109
+ } finally {
110
+ this.#active = null;
111
+ }
112
+ }
113
+
114
+ async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
115
+ const id = `tc-${active.runId}-${crypto.randomUUID()}`;
116
+ const { promise, resolve, reject } = Promise.withResolvers<unknown>();
117
+ active.pendingTools.set(id, { resolve, reject });
118
+ this.#transport.send({ type: "tool-call", id, runId: active.runId, name, args });
119
+ return await promise;
120
+ }
121
+
122
+ #deliverToolReply(id: string, reply: ToolReply): void {
123
+ const active = this.#active;
124
+ if (!active) return;
125
+ const pending = active.pendingTools.get(id);
126
+ if (!pending) return;
127
+ active.pendingTools.delete(id);
128
+ if (reply.ok) pending.resolve(reply.value);
129
+ else pending.reject(errorFromPayload(reply.error));
130
+ }
131
+
132
+ #close(): void {
133
+ const active = this.#active;
134
+ if (active) {
135
+ for (const pending of active.pendingTools.values()) {
136
+ pending.reject(new ToolError("JS worker closed"));
137
+ }
138
+ active.pendingTools.clear();
139
+ }
140
+ this.#active = null;
141
+ this.#runtime = null;
142
+ this.#transport.send({ type: "closed" });
143
+ this.#unsubscribe();
144
+ this.#transport.close();
145
+ }
146
+ }
@@ -0,0 +1,24 @@
1
+ import { parentPort } from "node:worker_threads";
2
+ import { WorkerCore } from "./worker-core";
3
+ import type { Transport, WorkerInbound, WorkerOutbound } from "./worker-protocol";
4
+
5
+ if (!parentPort) throw new Error("js worker-entry: missing parentPort");
6
+
7
+ const port = parentPort;
8
+ const transport: Transport = {
9
+ send: (msg: WorkerOutbound) => port.postMessage(msg),
10
+ onMessage: handler => {
11
+ const wrap = (data: unknown): void => handler(data as WorkerInbound);
12
+ port.on("message", wrap);
13
+ return () => port.off("message", wrap);
14
+ },
15
+ close: () => {
16
+ try {
17
+ port.close();
18
+ } catch {
19
+ // Already closed.
20
+ }
21
+ },
22
+ };
23
+
24
+ new WorkerCore(transport);
@@ -0,0 +1,41 @@
1
+ import type { JsDisplayOutput } from "./shared/types";
2
+
3
+ export type { JsDisplayOutput } from "./shared/types";
4
+
5
+ export interface SessionSnapshot {
6
+ cwd: string;
7
+ sessionId: string;
8
+ }
9
+
10
+ export interface RunErrorPayload {
11
+ name?: string;
12
+ message: string;
13
+ stack?: string;
14
+ isAbort?: boolean;
15
+ isToolError?: boolean;
16
+ }
17
+
18
+ export type ToolReply = { ok: true; value: unknown } | { ok: false; error: RunErrorPayload };
19
+
20
+ export type WorkerInbound =
21
+ | { type: "init"; snapshot: SessionSnapshot }
22
+ | { type: "run"; runId: string; code: string; filename: string; snapshot: SessionSnapshot }
23
+ | { type: "tool-reply"; id: string; reply: ToolReply }
24
+ | { type: "close" };
25
+
26
+ export type WorkerOutbound =
27
+ | { type: "ready" }
28
+ | { type: "init-failed"; error: RunErrorPayload }
29
+ | { type: "text"; runId: string; chunk: string }
30
+ | { type: "display"; runId: string; output: JsDisplayOutput }
31
+ | { type: "tool-call"; id: string; runId: string; name: string; args: unknown }
32
+ | { type: "result"; runId: string; ok: true }
33
+ | { type: "result"; runId: string; ok: false; error: RunErrorPayload }
34
+ | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
35
+ | { type: "closed" };
36
+
37
+ export interface Transport {
38
+ send(msg: WorkerOutbound): void;
39
+ onMessage(handler: (msg: WorkerInbound) => void): () => void;
40
+ close(): void;
41
+ }
package/src/eval/parse.ts CHANGED
@@ -43,16 +43,19 @@ const LANGUAGE_MAP: Record<string, EvalLanguage> = {
43
43
  TYPESCRIPT: "js",
44
44
  };
45
45
 
46
- // Markers are case-insensitive, accept ≥2 leading stars (so `**Begin` and
47
- // `*** Begin` both work), and tolerate any whitespace (including tabs)
46
+ // Markers are case-insensitive, accept ≥2 leading stars (so `**Cell` and
47
+ // `*** Cell` both work), and tolerate any whitespace (including tabs)
48
48
  // between tokens. Models that can't constrain-sample frequently emit minor
49
- // variations like `**End`, `*** end py`, or `***\tTitle: foo`.
49
+ // variations like `**End` or `*** cell py`.
50
50
  const STARS = String.raw`\*{2,}`;
51
- const BEGIN_RE = new RegExp(`^${STARS}\\s*Begin\\b\\s*(\\S+)?\\s*$`, "i");
51
+ // Cell header: `*** Cell <attrs...>`. The remainder of the line is captured
52
+ // and tokenized separately so we can handle quoted values.
53
+ const CELL_RE = new RegExp(`^${STARS}\\s*Cell\\b\\s*(.*)$`, "i");
54
+ // `*** End` is a tolerated cell/file terminator. Documented as required at
55
+ // the file level in the lark grammar (the trailing `*** End` quirks GPT-
56
+ // trained models naturally produce), but optional at the parser level.
52
57
  const END_RE = new RegExp(`^${STARS}\\s*End\\b.*$`, "i");
53
- const TITLE_RE = new RegExp(`^${STARS}\\s*Title\\s*:\\s*(.+?)\\s*$`, "i");
54
- const TIMEOUT_RE = new RegExp(`^${STARS}\\s*Timeout\\s*:\\s*(\\S+)\\s*$`, "i");
55
- const RESET_RE = new RegExp(`^${STARS}\\s*Reset\\s*$`, "i");
58
+ // `*** Abort` is the harmony-leak recovery sentinel; see ABORT_WARNING.
56
59
  const ABORT_RE = new RegExp(`^${STARS}\\s*Abort\\s*$`, "i");
57
60
 
58
61
  /**
@@ -62,6 +65,7 @@ const ABORT_RE = new RegExp(`^${STARS}\\s*Abort\\s*$`, "i");
62
65
  */
63
66
  export const ABORT_WARNING =
64
67
  "Tool stream truncated mid-call due to detected output corruption. Earlier cells (if any) executed normally; their state persists. Re-issue the aborted cell.";
68
+
65
69
  const DURATION_RE = /^(\d+)(ms|s|m)?$/i;
66
70
 
67
71
  function resolveLang(token: string | undefined): EvalLanguage | undefined {
@@ -88,7 +92,7 @@ const FENCE_OPEN_RE = /^```\s*([A-Za-z]\w*)?\s*$/;
88
92
  const FENCE_CLOSE_RE = /^```\s*$/;
89
93
 
90
94
  /**
91
- * Last-resort fallback when the input has no recognizable `*** Begin` header.
95
+ * Last-resort fallback when the input has no recognizable `*** Cell` header.
92
96
  * Models that can't constrain-sample sometimes pass bare code or wrap it in
93
97
  * a markdown fence (```py / ```python / bare ```). Treat the whole input as
94
98
  * a single implicit cell, sniffing the language from the body.
@@ -122,6 +126,187 @@ function parseImplicitCell(lines: string[]): ParsedEvalCell {
122
126
  };
123
127
  }
124
128
 
129
+ /**
130
+ * Tokenize a `*** Cell` header's attribute list while preserving quoted
131
+ * segments (`id:"some title"`, `py:"hi"`, single quotes too) as single
132
+ * tokens. Outer whitespace separates tokens; the quote characters
133
+ * themselves are kept verbatim so attribute parsing can strip them later.
134
+ */
135
+ function tokenizeCellAttrs(input: string): string[] {
136
+ const tokens: string[] = [];
137
+ let i = 0;
138
+ while (i < input.length) {
139
+ while (i < input.length && /\s/.test(input[i])) i++;
140
+ if (i >= input.length) break;
141
+ let token = "";
142
+ while (i < input.length && !/\s/.test(input[i])) {
143
+ const ch = input[i];
144
+ if (ch === '"' || ch === "'") {
145
+ token += ch;
146
+ i++;
147
+ while (i < input.length && input[i] !== ch) {
148
+ token += input[i];
149
+ i++;
150
+ }
151
+ if (i < input.length) {
152
+ token += input[i];
153
+ i++;
154
+ }
155
+ } else {
156
+ token += ch;
157
+ i++;
158
+ }
159
+ }
160
+ tokens.push(token);
161
+ }
162
+ return tokens;
163
+ }
164
+
165
+ interface CellHeader {
166
+ language: EvalLanguage | undefined;
167
+ languageOrigin: EvalLanguageOrigin;
168
+ title: string | undefined;
169
+ timeoutMs: number | undefined;
170
+ reset: boolean;
171
+ }
172
+
173
+ /**
174
+ * Map an attribute key (from `key:value` or bare `key`) to one of the three
175
+ * canonical roles. Canonical keys: `id`, `t`, `rst`. Fallback aliases —
176
+ * accepted but not advertised in the prompt — cover common synonyms LLMs
177
+ * reach for instead of the short canonical.
178
+ */
179
+ const ID_KEYS = new Set(["id", "title", "name", "cell", "file", "label"]);
180
+ const T_KEYS = new Set(["t", "timeout", "duration", "time"]);
181
+ const RST_KEYS = new Set(["rst", "reset"]);
182
+
183
+ function classifyAttrKey(key: string): "id" | "t" | "rst" | null {
184
+ if (ID_KEYS.has(key)) return "id";
185
+ if (T_KEYS.has(key)) return "t";
186
+ if (RST_KEYS.has(key)) return "rst";
187
+ return null;
188
+ }
189
+
190
+ // `key:value` form. `value` may be `"..."`, `'...'`, or a bare run.
191
+ const ATTR_TOKEN_RE = /^([a-zA-Z][\w-]*)(?::(?:"([^"]*)"|'([^']*)'|(.*)))?$/;
192
+ // Bare positional duration (lenient — `t:` is canonical).
193
+ const DURATION_TOKEN_RE = /^\d+(?:ms|s|m)?$/;
194
+
195
+ function parseBooleanFlag(value: string): boolean | undefined {
196
+ const v = value.trim().toLowerCase();
197
+ if (v === "true" || v === "1" || v === "yes" || v === "on") return true;
198
+ if (v === "false" || v === "0" || v === "no" || v === "off") return false;
199
+ return undefined;
200
+ }
201
+
202
+ /**
203
+ * Decode a `*** Cell` header's attribute list into language, title,
204
+ * timeout, and reset flag.
205
+ *
206
+ * Token forms (all optional, any order):
207
+ * - `py` / `js` / `ts` bare language
208
+ * - `py:"..."` / `js:"..."` / `ts:"..."` language + title shorthand
209
+ * - `id:"..."` cell title (canonical)
210
+ * - `t:<duration>` per-cell timeout (canonical)
211
+ * - `<duration>` (e.g. `30s`) bare positional duration
212
+ * - `rst` reset flag (canonical)
213
+ * - `rst:true|false|1|0|yes|no|on|off` reset flag with explicit value
214
+ *
215
+ * Fallback aliases (accepted but not advertised in the prompt):
216
+ * - id: title, name, cell, file, label
217
+ * - t: timeout, duration, time
218
+ * - rst: reset
219
+ *
220
+ * Quotes may be `"` or `'`. Truly unknown keys are silently dropped. First
221
+ * occurrence wins when a key is repeated (canonical or alias). Anything
222
+ * that doesn't classify accumulates as a positional title fragment joined
223
+ * by spaces.
224
+ */
225
+ function parseCellHeader(rest: string, lineNumber: number): CellHeader {
226
+ const tokens = tokenizeCellAttrs(rest);
227
+ let language: EvalLanguage | undefined;
228
+ let titleAttr: string | undefined;
229
+ let positionalDurationMs: number | undefined;
230
+ let tAttr: string | undefined;
231
+ let rstAttr: string | undefined;
232
+ let bareReset = false;
233
+ const titleParts: string[] = [];
234
+
235
+ for (const token of tokens) {
236
+ // Bare reset flag (canonical or alias).
237
+ if (RST_KEYS.has(token.toLowerCase())) {
238
+ bareReset = true;
239
+ continue;
240
+ }
241
+
242
+ const attrMatch = ATTR_TOKEN_RE.exec(token);
243
+ if (attrMatch && token.includes(":")) {
244
+ const key = attrMatch[1].toLowerCase();
245
+ const value = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
246
+
247
+ // Language-with-title shorthand: `py:"foo"`, `js:'bar'`, etc.
248
+ const langCandidate = resolveLang(key);
249
+ if (langCandidate) {
250
+ if (language === undefined) language = langCandidate;
251
+ if (titleAttr === undefined && value !== "") titleAttr = value;
252
+ continue;
253
+ }
254
+
255
+ const role = classifyAttrKey(key);
256
+ if (role === "id" && titleAttr === undefined) titleAttr = value;
257
+ else if (role === "t" && tAttr === undefined) tAttr = value;
258
+ else if (role === "rst" && rstAttr === undefined) rstAttr = value;
259
+ // unknown / repeated keys silently dropped
260
+ continue;
261
+ }
262
+
263
+ // Bare language token (no colon).
264
+ const lang = resolveLang(token);
265
+ if (lang && language === undefined) {
266
+ language = lang;
267
+ continue;
268
+ }
269
+
270
+ // Bare positional duration (lenient — `t:` is canonical).
271
+ if (positionalDurationMs === undefined && DURATION_TOKEN_RE.test(token)) {
272
+ positionalDurationMs = parseDurationMs(token, lineNumber);
273
+ continue;
274
+ }
275
+
276
+ titleParts.push(token);
277
+ }
278
+
279
+ const explicitTitle = (titleAttr ?? "").trim();
280
+ const positionalTitle = titleParts.join(" ").trim();
281
+ const title = explicitTitle.length > 0 ? explicitTitle : positionalTitle.length > 0 ? positionalTitle : undefined;
282
+
283
+ let timeoutMs: number | undefined;
284
+ if (tAttr !== undefined) {
285
+ timeoutMs = parseDurationMs(tAttr, lineNumber);
286
+ } else if (positionalDurationMs !== undefined) {
287
+ timeoutMs = positionalDurationMs;
288
+ }
289
+
290
+ let reset = false;
291
+ if (rstAttr !== undefined) {
292
+ const parsed = parseBooleanFlag(rstAttr);
293
+ if (parsed === undefined) {
294
+ throw new Error(`Eval line ${lineNumber}: invalid rst value \`${rstAttr}\`; use true or false.`);
295
+ }
296
+ reset = parsed;
297
+ } else if (bareReset) {
298
+ reset = true;
299
+ }
300
+
301
+ return {
302
+ language,
303
+ languageOrigin: language ? "header" : "default",
304
+ title,
305
+ timeoutMs,
306
+ reset,
307
+ };
308
+ }
309
+
125
310
  export function parseEvalInput(input: string): ParsedEvalInput {
126
311
  const normalized = input.replace(/\r\n?/g, "\n");
127
312
  const lines = normalized.split("\n");
@@ -134,10 +319,10 @@ export function parseEvalInput(input: string): ParsedEvalInput {
134
319
  // Skip leading blank lines.
135
320
  while (i < lines.length && lines[i].trim() === "") i++;
136
321
 
137
- // Lenient fallback: if the input has no recognizable begin marker, treat
322
+ // Lenient fallback: if the input has no recognizable cell header, treat
138
323
  // the entire input as one implicit cell — unless that content contains
139
324
  // `*** Abort`, in which case the body is incomplete/unsafe and we drop it.
140
- if (i < lines.length && !BEGIN_RE.test(lines[i])) {
325
+ if (i < lines.length && !CELL_RE.test(lines[i])) {
141
326
  const tail = lines.slice(i);
142
327
  if (tail.some(line => ABORT_RE.test(line))) {
143
328
  return { cells, aborted: true };
@@ -148,42 +333,26 @@ export function parseEvalInput(input: string): ParsedEvalInput {
148
333
  }
149
334
 
150
335
  while (i < lines.length) {
151
- const beginMatch = BEGIN_RE.exec(lines[i])!;
152
- const langToken = beginMatch[1];
153
- const explicitLanguage = resolveLang(langToken);
154
- i++;
155
-
156
- let title: string | undefined;
157
- let timeoutMs: number | undefined;
158
- let reset = false;
159
-
160
- while (i < lines.length) {
161
- const line = lines[i];
162
- const lineNumber = i + 1;
163
- const titleMatch = TITLE_RE.exec(line);
164
- if (titleMatch) {
165
- if (title === undefined) title = titleMatch[1];
166
- i++;
167
- continue;
168
- }
169
- const timeoutMatch = TIMEOUT_RE.exec(line);
170
- if (timeoutMatch) {
171
- if (timeoutMs === undefined) timeoutMs = parseDurationMs(timeoutMatch[1], lineNumber);
172
- i++;
173
- continue;
174
- }
175
- if (RESET_RE.test(line)) {
176
- reset = true;
177
- i++;
178
- continue;
336
+ const headerLine = lines[i];
337
+ const cellMatch = CELL_RE.exec(headerLine);
338
+ if (!cellMatch) {
339
+ // Stray content between/after cells (blank lines were already
340
+ // consumed). `*** Abort` here terminates parsing; `*** End` is
341
+ // the optional file-level terminator (silently consumed). Anything
342
+ // else typically a harmony-leak fragment — is skipped.
343
+ if (ABORT_RE.test(headerLine)) {
344
+ aborted = true;
345
+ break;
179
346
  }
180
- break;
347
+ i++;
348
+ continue;
181
349
  }
350
+ const header = parseCellHeader(cellMatch[1] ?? "", i + 1);
351
+ i++;
182
352
 
183
- // Collect cell body. Close on `*** End` OR on the next `*** Begin`
184
- // (implicit end leniency for models that drop end markers between
185
- // back-to-back cells). `*** Abort` (recovery sentinel) drops the
186
- // in-progress cell entirely: its body is partial and unsafe to run.
353
+ // Collect cell body. Close on `*** End` (any form), the next
354
+ // `*** Cell` header, or `*** Abort` (which drops the in-progress
355
+ // cell as its body is partial and unsafe to run).
187
356
  const codeLines: string[] = [];
188
357
  let cellAborted = false;
189
358
  while (i < lines.length) {
@@ -198,7 +367,7 @@ export function parseEvalInput(input: string): ParsedEvalInput {
198
367
  i++;
199
368
  break;
200
369
  }
201
- if (BEGIN_RE.test(line)) break;
370
+ if (CELL_RE.test(line)) break;
202
371
  codeLines.push(line);
203
372
  i++;
204
373
  }
@@ -212,17 +381,17 @@ export function parseEvalInput(input: string): ParsedEvalInput {
212
381
  }
213
382
  const code = codeLines.join("\n");
214
383
 
215
- const language = explicitLanguage ?? sniffEvalLanguage(code) ?? DEFAULT_LANGUAGE;
216
- const languageOrigin: EvalLanguageOrigin = explicitLanguage ? "header" : "default";
384
+ const language = header.language ?? sniffEvalLanguage(code) ?? DEFAULT_LANGUAGE;
385
+ const languageOrigin: EvalLanguageOrigin = header.language ? "header" : "default";
217
386
 
218
387
  cells.push({
219
388
  index: cells.length,
220
- title,
389
+ title: header.title,
221
390
  code,
222
391
  language,
223
392
  languageOrigin,
224
- timeoutMs: timeoutMs ?? DEFAULT_TIMEOUT_MS,
225
- reset,
393
+ timeoutMs: header.timeoutMs ?? DEFAULT_TIMEOUT_MS,
394
+ reset: header.reset,
226
395
  });
227
396
 
228
397
  // Skip blank separator lines between cells; an `*** Abort` here
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Display bundle rendering shared between the Python runner output and the
3
+ * legacy Jupyter MIME conventions. Pure function, no kernel coupling.
4
+ */
5
+ import { htmlToBasicMarkdown } from "../../web/scrapers/types";
6
+
7
+ /** Status event emitted by prelude helpers for TUI rendering. */
8
+ export interface PythonStatusEvent {
9
+ /** Operation name (e.g., "find", "read", "write") */
10
+ op: string;
11
+ /** Additional data fields (count, path, pattern, etc.) */
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export type KernelDisplayOutput =
16
+ | { type: "json"; data: unknown }
17
+ | { type: "image"; data: string; mimeType: string }
18
+ | { type: "markdown" }
19
+ | { type: "status"; event: PythonStatusEvent };
20
+
21
+ function normalizeDisplayText(text: string): string {
22
+ return text.endsWith("\n") ? text : `${text}\n`;
23
+ }
24
+
25
+ /** Render a MIME bundle into text + structured outputs. */
26
+ export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
27
+ text: string;
28
+ outputs: KernelDisplayOutput[];
29
+ }> {
30
+ // Accept both raw bundles ({"text/plain": ...}) and Jupyter-style
31
+ // content envelopes ({ data: {...} }) so callers don't need to unwrap.
32
+ const data =
33
+ (content.data as Record<string, unknown> | undefined) ?? (content as Record<string, unknown> | undefined);
34
+ if (!data) return { text: "", outputs: [] };
35
+
36
+ const outputs: KernelDisplayOutput[] = [];
37
+
38
+ // Status events bypass the text path entirely — they exist only for TUI hooks.
39
+ if (data["application/x-omp-status"] !== undefined) {
40
+ const statusData = data["application/x-omp-status"];
41
+ if (statusData && typeof statusData === "object" && "op" in statusData) {
42
+ outputs.push({ type: "status", event: statusData as PythonStatusEvent });
43
+ }
44
+ return { text: "", outputs };
45
+ }
46
+
47
+ if (typeof data["image/png"] === "string") {
48
+ outputs.push({ type: "image", data: data["image/png"] as string, mimeType: "image/png" });
49
+ }
50
+ if (typeof data["image/jpeg"] === "string") {
51
+ outputs.push({ type: "image", data: data["image/jpeg"] as string, mimeType: "image/jpeg" });
52
+ }
53
+ if (data["application/json"] !== undefined) {
54
+ outputs.push({ type: "json", data: data["application/json"] });
55
+ }
56
+
57
+ // text/markdown takes precedence over text/plain (Markdown objects expose both
58
+ // where text/plain is just the repr).
59
+ if (typeof data["text/markdown"] === "string") {
60
+ outputs.push({ type: "markdown" });
61
+ return { text: normalizeDisplayText(String(data["text/markdown"])), outputs };
62
+ }
63
+ if (typeof data["text/plain"] === "string") {
64
+ return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
65
+ }
66
+ if (data["text/html"] !== undefined) {
67
+ const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
68
+ return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
69
+ }
70
+ return { text: "", outputs };
71
+ }