@oh-my-pi/pi-coding-agent 14.5.12 → 14.5.14

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 (112) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/commit/pipeline.ts +4 -3
  5. package/src/config/model-equivalence.ts +49 -16
  6. package/src/config/model-registry.ts +100 -25
  7. package/src/config/model-resolver.ts +29 -15
  8. package/src/config/settings-schema.ts +20 -6
  9. package/src/config/settings.ts +9 -8
  10. package/src/config.ts +18 -6
  11. package/src/eval/backend.ts +43 -0
  12. package/src/eval/eval.lark +43 -0
  13. package/src/eval/index.ts +5 -0
  14. package/src/eval/js/context-manager.ts +717 -0
  15. package/src/eval/js/executor.ts +131 -0
  16. package/src/eval/js/index.ts +46 -0
  17. package/src/eval/js/prelude.ts +2 -0
  18. package/src/eval/js/prelude.txt +84 -0
  19. package/src/eval/js/tool-bridge.ts +124 -0
  20. package/src/eval/parse.ts +337 -0
  21. package/src/{ipy → eval/py}/executor.ts +2 -180
  22. package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
  23. package/src/eval/py/index.ts +58 -0
  24. package/src/{ipy → eval/py}/kernel.ts +9 -45
  25. package/src/{ipy → eval/py}/prelude.py +39 -227
  26. package/src/eval/types.ts +48 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +8 -10
  29. package/src/extensibility/extensions/types.ts +2 -3
  30. package/src/internal-urls/docs-index.generated.ts +5 -5
  31. package/src/lsp/client.ts +9 -0
  32. package/src/lsp/index.ts +395 -0
  33. package/src/lsp/types.ts +15 -4
  34. package/src/main.ts +35 -14
  35. package/src/mcp/manager.ts +22 -0
  36. package/src/mcp/oauth-flow.ts +1 -1
  37. package/src/memories/index.ts +1 -1
  38. package/src/modes/acp/acp-event-mapper.ts +1 -1
  39. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  40. package/src/modes/components/login-dialog.ts +1 -1
  41. package/src/modes/components/oauth-selector.ts +2 -1
  42. package/src/modes/components/tool-execution.ts +3 -4
  43. package/src/modes/controllers/command-controller.ts +28 -8
  44. package/src/modes/controllers/input-controller.ts +4 -4
  45. package/src/modes/controllers/selector-controller.ts +2 -1
  46. package/src/modes/interactive-mode.ts +4 -5
  47. package/src/modes/rpc/rpc-client.ts +9 -0
  48. package/src/modes/rpc/rpc-mode.ts +6 -0
  49. package/src/modes/rpc/rpc-types.ts +9 -0
  50. package/src/modes/types.ts +3 -3
  51. package/src/modes/utils/ui-helpers.ts +2 -2
  52. package/src/prompts/system/system-prompt.md +3 -3
  53. package/src/prompts/tools/eval.md +92 -0
  54. package/src/prompts/tools/lsp.md +7 -3
  55. package/src/sdk.ts +64 -35
  56. package/src/session/agent-session.ts +152 -46
  57. package/src/session/messages.ts +1 -1
  58. package/src/slash-commands/builtin-registry.ts +1 -1
  59. package/src/system-prompt.ts +34 -66
  60. package/src/task/agents.ts +4 -5
  61. package/src/task/executor.ts +5 -9
  62. package/src/tools/archive-reader.ts +9 -3
  63. package/src/tools/browser/launch.ts +22 -0
  64. package/src/tools/browser/readable.ts +11 -6
  65. package/src/tools/browser/registry.ts +25 -244
  66. package/src/tools/browser/render.ts +1 -1
  67. package/src/tools/browser/tab-protocol.ts +101 -0
  68. package/src/tools/browser/tab-supervisor.ts +429 -0
  69. package/src/tools/browser/tab-worker-entry.ts +21 -0
  70. package/src/tools/browser/tab-worker.ts +1006 -0
  71. package/src/tools/browser.ts +17 -32
  72. package/src/tools/checkpoint.ts +2 -2
  73. package/src/tools/{python.ts → eval.ts} +324 -315
  74. package/src/tools/exit-plan-mode.ts +1 -1
  75. package/src/tools/image-gen.ts +2 -2
  76. package/src/tools/index.ts +62 -100
  77. package/src/tools/read.ts +0 -6
  78. package/src/tools/recipe/runners/pkg.ts +34 -32
  79. package/src/tools/renderers.ts +2 -2
  80. package/src/tools/resolve.ts +7 -2
  81. package/src/tools/todo-write.ts +0 -1
  82. package/src/tools/tool-timeouts.ts +2 -2
  83. package/src/tools/write.ts +8 -1
  84. package/src/utils/markit.ts +15 -7
  85. package/src/utils/tools-manager.ts +5 -5
  86. package/src/web/scrapers/crossref.ts +3 -3
  87. package/src/web/scrapers/devto.ts +1 -1
  88. package/src/web/scrapers/discourse.ts +5 -5
  89. package/src/web/scrapers/firefox-addons.ts +1 -1
  90. package/src/web/scrapers/flathub.ts +2 -2
  91. package/src/web/scrapers/gitlab.ts +1 -1
  92. package/src/web/scrapers/go-pkg.ts +2 -2
  93. package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
  94. package/src/web/scrapers/mastodon.ts +9 -9
  95. package/src/web/scrapers/mdn.ts +11 -7
  96. package/src/web/scrapers/pub-dev.ts +1 -1
  97. package/src/web/scrapers/rawg.ts +3 -3
  98. package/src/web/scrapers/readthedocs.ts +1 -1
  99. package/src/web/scrapers/spdx.ts +1 -1
  100. package/src/web/scrapers/stackoverflow.ts +2 -2
  101. package/src/web/scrapers/types.ts +53 -39
  102. package/src/web/scrapers/w3c.ts +1 -1
  103. package/src/web/search/index.ts +5 -5
  104. package/src/web/search/provider.ts +121 -39
  105. package/src/web/search/providers/gemini.ts +4 -4
  106. package/src/web/search/render.ts +2 -2
  107. package/src/ipy/modules.ts +0 -144
  108. package/src/prompts/tools/python.md +0 -57
  109. package/src/tools/browser/vm.ts +0 -792
  110. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  111. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  112. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -1,17 +1,16 @@
1
1
  import { $env, $flag, isBunTestRuntime, logger, Snowflake } from "@oh-my-pi/pi-utils";
2
2
  import { $ } from "bun";
3
- import { Settings } from "../config/settings";
4
- import { htmlToBasicMarkdown } from "../web/scrapers/types";
3
+ import { Settings } from "../../config/settings";
4
+ import { htmlToBasicMarkdown } from "../../web/scrapers/types";
5
5
  import { createCancellationError, getAbortReason, getExecutionCancellationError } from "./cancellation";
6
6
  import { acquireSharedGateway, releaseSharedGateway, shutdownSharedGateway } from "./gateway-coordinator";
7
- import { loadPythonModules } from "./modules";
7
+
8
8
  import { PYTHON_PRELUDE } from "./prelude";
9
9
  import { filterEnv, resolvePythonRuntime } from "./runtime";
10
10
 
11
11
  const TEXT_ENCODER = new TextEncoder();
12
12
  const TEXT_DECODER = new TextDecoder();
13
13
  const TRACE_IPC = $flag("PI_PYTHON_IPC_TRACE");
14
- const PRELUDE_INTROSPECTION_SNIPPET = "import json\nprint(json.dumps(__omp_prelude_docs__()))";
15
14
 
16
15
  class SharedGatewayCreateError extends Error {
17
16
  constructor(
@@ -179,13 +178,6 @@ export interface KernelExecuteResult {
179
178
  stdinRequested: boolean;
180
179
  }
181
180
 
182
- export interface PreludeHelper {
183
- name: string;
184
- signature: string;
185
- docstring: string;
186
- category: string;
187
- }
188
-
189
181
  interface KernelStartOptions extends KernelLifecycleOptions {
190
182
  cwd: string;
191
183
  env?: Record<string, string | undefined>;
@@ -279,10 +271,10 @@ function normalizeDisplayText(text: string): string {
279
271
  }
280
272
 
281
273
  /** Renders a Jupyter display_data message into text and structured outputs. */
282
- export function renderKernelDisplay(content: Record<string, unknown>): {
274
+ export async function renderKernelDisplay(content: Record<string, unknown>): Promise<{
283
275
  text: string;
284
276
  outputs: KernelDisplayOutput[];
285
- } {
277
+ }> {
286
278
  const data = content.data as Record<string, unknown> | undefined;
287
279
  if (!data) return { text: "", outputs: [] };
288
280
 
@@ -315,7 +307,7 @@ export function renderKernelDisplay(content: Record<string, unknown>): {
315
307
  return { text: normalizeDisplayText(String(data["text/plain"])), outputs };
316
308
  }
317
309
  if (data["text/html"] !== undefined) {
318
- const markdown = htmlToBasicMarkdown(String(data["text/html"])) || "";
310
+ const markdown = (await htmlToBasicMarkdown(String(data["text/html"]))) || "";
319
311
  return { text: markdown ? normalizeDisplayText(markdown) : "", outputs };
320
312
  }
321
313
  return { text: "", outputs };
@@ -536,7 +528,7 @@ export class PythonKernel {
536
528
  preludeOptions.signal,
537
529
  "Failed to initialize Python kernel prelude",
538
530
  );
539
- await loadPythonModules(kernel, { cwd, signal: startup.signal, deadlineMs: startup.deadlineMs });
531
+
540
532
  return kernel;
541
533
  } catch (err: unknown) {
542
534
  await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
@@ -607,11 +599,7 @@ export class PythonKernel {
607
599
  preludeOptions.signal,
608
600
  "Failed to initialize Python kernel prelude",
609
601
  );
610
- await logger.time("startWithSharedGateway:loadModules", loadPythonModules, kernel, {
611
- cwd,
612
- signal: startup.signal,
613
- deadlineMs: startup.deadlineMs,
614
- });
602
+
615
603
  return kernel;
616
604
  } catch (err: unknown) {
617
605
  await kernel.shutdown({ timeoutMs: getStartupCleanupTimeoutMs(startup.deadlineMs) });
@@ -884,7 +872,7 @@ export class PythonKernel {
884
872
  }
885
873
  case "execute_result":
886
874
  case "display_data": {
887
- const { text, outputs } = renderKernelDisplay(response.content);
875
+ const { text, outputs } = await renderKernelDisplay(response.content);
888
876
  if (text && options?.onChunk) {
889
877
  await options.onChunk(text);
890
878
  }
@@ -953,30 +941,6 @@ export class PythonKernel {
953
941
  return promise;
954
942
  }
955
943
 
956
- async introspectPrelude(options: Pick<KernelExecuteOptions, "signal" | "timeoutMs"> = {}): Promise<PreludeHelper[]> {
957
- let output = "";
958
- const result = await this.execute(PRELUDE_INTROSPECTION_SNIPPET, {
959
- silent: false,
960
- storeHistory: false,
961
- signal: options.signal,
962
- timeoutMs: options.timeoutMs,
963
- onChunk: text => {
964
- output += text;
965
- },
966
- });
967
- if (result.cancelled || result.status === "error") {
968
- throw new Error("Failed to introspect Python prelude");
969
- }
970
- const trimmed = output.trim();
971
- if (!trimmed) return [];
972
- try {
973
- return JSON.parse(trimmed) as PreludeHelper[];
974
- } catch (err: unknown) {
975
- const message = err instanceof Error ? err.message : String(err);
976
- throw new Error(`Failed to parse Python prelude docs: ${message}`);
977
- }
978
- }
979
-
980
944
  async interrupt(): Promise<void> {
981
945
  try {
982
946
  await fetch(`${this.gatewayUrl}/api/kernels/${this.kernelId}/interrupt`, {
@@ -3,22 +3,39 @@ from __future__ import annotations
3
3
  if "__omp_prelude_loaded__" not in globals():
4
4
  __omp_prelude_loaded__ = True
5
5
  from pathlib import Path
6
- import os, re, json, shutil, subprocess, inspect
6
+ import os, re, json, shutil, subprocess
7
7
  from datetime import datetime
8
- from IPython.display import display
8
+ from IPython.display import display as _ipy_display, JSON
9
+
10
+ _PRESENTABLE_REPRS = (
11
+ "_repr_mimebundle_",
12
+ "_repr_html_",
13
+ "_repr_json_",
14
+ "_repr_markdown_",
15
+ "_repr_png_",
16
+ "_repr_jpeg_",
17
+ "_repr_svg_",
18
+ "_repr_latex_",
19
+ )
20
+
21
+ def display(value):
22
+ """Render a value. Wraps plain dict/list values as interactive JSON."""
23
+ if any(hasattr(value, attr) for attr in _PRESENTABLE_REPRS):
24
+ _ipy_display(value)
25
+ return
26
+ if isinstance(value, (dict, list, tuple)):
27
+ try:
28
+ _ipy_display(JSON(value))
29
+ return
30
+ except Exception:
31
+ pass
32
+ _ipy_display(value)
9
33
 
10
34
  def _emit_status(op: str, **data):
11
35
  """Emit structured status event for TUI rendering."""
12
- display({"application/x-omp-status": {"op": op, **data}}, raw=True)
36
+ _ipy_display({"application/x-omp-status": {"op": op, **data}}, raw=True)
13
37
 
14
- def _category(cat: str):
15
- """Decorator to tag a prelude function with its category."""
16
- def decorator(fn):
17
- fn._omp_category = cat
18
- return fn
19
- return decorator
20
38
 
21
- @_category("Shell")
22
39
  def env(key: str | None = None, value: str | None = None):
23
40
  """Get/set environment variables."""
24
41
  if key is None:
@@ -33,7 +50,6 @@ if "__omp_prelude_loaded__" not in globals():
33
50
  _emit_status("env", key=key, value=val, action="get")
34
51
  return val
35
52
 
36
- @_category("File I/O")
37
53
  def read(path: str | Path, *, offset: int = 1, limit: int | None = None) -> str:
38
54
  """Read file contents. offset/limit are 1-indexed line numbers."""
39
55
  p = Path(path)
@@ -48,7 +64,6 @@ if "__omp_prelude_loaded__" not in globals():
48
64
  _emit_status("read", path=str(p), chars=len(data), preview=preview)
49
65
  return data
50
66
 
51
- @_category("File I/O")
52
67
  def write(path: str | Path, content: str) -> Path:
53
68
  """Write file contents (create parents)."""
54
69
  p = Path(path)
@@ -57,7 +72,6 @@ if "__omp_prelude_loaded__" not in globals():
57
72
  _emit_status("write", path=str(p), chars=len(content))
58
73
  return p
59
74
 
60
- @_category("File I/O")
61
75
  def append(path: str | Path, content: str) -> Path:
62
76
  """Append to file."""
63
77
  p = Path(path)
@@ -66,47 +80,6 @@ if "__omp_prelude_loaded__" not in globals():
66
80
  f.write(content)
67
81
  _emit_status("append", path=str(p), chars=len(content))
68
82
  return p
69
-
70
- @_category("File ops")
71
- def rm(path: str | Path, *, recursive: bool = False) -> None:
72
- """Delete file or directory (recursive optional)."""
73
- p = Path(path)
74
- if p.is_dir():
75
- if recursive:
76
- shutil.rmtree(p)
77
- _emit_status("rm", path=str(p), recursive=True)
78
- return
79
- _emit_status("rm", path=str(p), error="directory, use recursive=True")
80
- return
81
- if p.exists():
82
- p.unlink()
83
- _emit_status("rm", path=str(p))
84
- else:
85
- _emit_status("rm", path=str(p), error="missing")
86
-
87
- @_category("File ops")
88
- def mv(src: str | Path, dst: str | Path) -> Path:
89
- """Move or rename a file/directory."""
90
- src_p = Path(src)
91
- dst_p = Path(dst)
92
- dst_p.parent.mkdir(parents=True, exist_ok=True)
93
- shutil.move(str(src_p), str(dst_p))
94
- _emit_status("mv", src=str(src_p), dst=str(dst_p))
95
- return dst_p
96
-
97
- @_category("File ops")
98
- def cp(src: str | Path, dst: str | Path) -> Path:
99
- """Copy a file or directory."""
100
- src_p = Path(src)
101
- dst_p = Path(dst)
102
- dst_p.parent.mkdir(parents=True, exist_ok=True)
103
- if src_p.is_dir():
104
- shutil.copytree(src_p, dst_p, dirs_exist_ok=True)
105
- else:
106
- shutil.copy2(src_p, dst_p)
107
- _emit_status("cp", src=str(src_p), dst=str(dst_p))
108
- return dst_p
109
-
110
83
  def _load_gitignore_patterns(base: Path) -> list[str]:
111
84
  """Load .gitignore patterns from base directory and parents."""
112
85
  patterns: list[str] = []
@@ -152,7 +125,6 @@ if "__omp_prelude_loaded__" not in globals():
152
125
  return True
153
126
  return False
154
127
 
155
- @_category("Search")
156
128
  def find(
157
129
  pattern: str,
158
130
  path: str | Path = ".",
@@ -200,7 +172,6 @@ if "__omp_prelude_loaded__" not in globals():
200
172
  _emit_status("find", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
201
173
  return matches
202
174
 
203
- @_category("Search")
204
175
  def grep(
205
176
  pattern: str,
206
177
  path: str | Path,
@@ -208,8 +179,8 @@ if "__omp_prelude_loaded__" not in globals():
208
179
  ignore_case: bool = False,
209
180
  literal: bool = False,
210
181
  context: int = 0,
211
- ) -> list[tuple[int, str]]:
212
- """Grep a single file. Returns (line_number, text) tuples."""
182
+ ) -> list[dict]:
183
+ """Grep a single file. Returns dicts with line/text fields."""
213
184
  p = Path(path)
214
185
  lines = p.read_text(encoding="utf-8").splitlines()
215
186
  if literal:
@@ -237,11 +208,10 @@ if "__omp_prelude_loaded__" not in globals():
237
208
  else:
238
209
  output_lines = sorted(match_lines)
239
210
 
240
- hits = [(ln, lines[ln - 1]) for ln in output_lines]
241
- _emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=[{"line": h[0], "text": h[1][:100]} for h in hits[:10]])
211
+ hits = [{"line": ln, "text": lines[ln - 1]} for ln in output_lines]
212
+ _emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=hits[:10])
242
213
  return hits
243
214
 
244
- @_category("Search")
245
215
  def rgrep(
246
216
  pattern: str,
247
217
  path: str | Path = ".",
@@ -251,8 +221,8 @@ if "__omp_prelude_loaded__" not in globals():
251
221
  literal: bool = False,
252
222
  limit: int = 100,
253
223
  hidden: bool = False,
254
- ) -> list[tuple[Path, int, str]]:
255
- """Recursive grep across files matching glob_pattern. Respects .gitignore."""
224
+ ) -> list[dict]:
225
+ """Recursive grep across files matching glob_pattern. Returns dicts with file/line/text fields. Respects .gitignore."""
256
226
  if literal:
257
227
  if ignore_case:
258
228
  match_fn = lambda line: pattern.lower() in line.lower()
@@ -265,7 +235,7 @@ if "__omp_prelude_loaded__" not in globals():
265
235
 
266
236
  base = Path(path)
267
237
  ignore_patterns = _load_gitignore_patterns(base)
268
- hits: list[tuple[Path, int, str]] = []
238
+ hits: list[dict] = []
269
239
  for file_path in base.rglob(glob_pattern):
270
240
  if len(hits) >= limit:
271
241
  break
@@ -285,24 +255,9 @@ if "__omp_prelude_loaded__" not in globals():
285
255
  if len(hits) >= limit:
286
256
  break
287
257
  if match_fn(line):
288
- hits.append((file_path, i, line))
289
- _emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=[{"file": str(h[0]), "line": h[1], "text": h[2][:80]} for h in hits[:10]])
258
+ hits.append({"file": str(file_path), "line": i, "text": line})
259
+ _emit_status("rgrep", pattern=pattern, path=str(base), count=len(hits), hits=hits[:10])
290
260
  return hits
291
-
292
- @_category("Find/Replace")
293
- def replace(path: str | Path, pattern: str, repl: str, *, regex: bool = False) -> int:
294
- """Replace text in a file (regex optional)."""
295
- p = Path(path)
296
- data = p.read_text(encoding="utf-8")
297
- if regex:
298
- new, count = re.subn(pattern, repl, data)
299
- else:
300
- new = data.replace(pattern, repl)
301
- count = data.count(pattern)
302
- p.write_text(new, encoding="utf-8")
303
- _emit_status("replace", path=str(p), count=count)
304
- return count
305
-
306
261
  class ShellResult:
307
262
  """Result from shell command execution."""
308
263
  __slots__ = ("args", "stdout", "stderr", "returncode")
@@ -371,25 +326,22 @@ if "__omp_prelude_loaded__" not in globals():
371
326
  result = subprocess.CompletedProcess(args, proc.returncode, stdout, stderr)
372
327
  return _make_shell_result(result, cmd)
373
328
 
374
- @_category("Shell")
375
329
  def run(cmd: str, *, cwd: str | Path | None = None, timeout: int | None = None) -> ShellResult:
376
330
  """Run a shell command. Returns ShellResult with stdout/stderr and returncode/exit_code fields."""
377
331
  shell_path = shutil.which("bash") or shutil.which("sh") or "/bin/sh"
378
332
  args = [shell_path, "-c", cmd]
379
333
  return _run_with_interrupt(args, str(cwd) if cwd else None, timeout, cmd)
380
334
 
381
- @_category("Text")
382
- def sort_lines(text: str, *, reverse: bool = False, unique: bool = False) -> str:
335
+ def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
383
336
  """Sort lines of text."""
384
337
  lines = text.splitlines()
385
338
  if unique:
386
339
  lines = list(dict.fromkeys(lines))
387
340
  lines = sorted(lines, reverse=reverse)
388
341
  out = "\n".join(lines)
389
- _emit_status("sort_lines", lines=len(lines), unique=unique, reverse=reverse)
342
+ _emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
390
343
  return out
391
344
 
392
- @_category("Text")
393
345
  def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
394
346
  """Remove duplicate adjacent lines (like uniq)."""
395
347
  lines = text.splitlines()
@@ -412,7 +364,6 @@ if "__omp_prelude_loaded__" not in globals():
412
364
  return groups
413
365
  return "\n".join(line for _, line in groups)
414
366
 
415
- @_category("Text")
416
367
  def counter(
417
368
  items: str | list,
418
369
  *,
@@ -435,20 +386,6 @@ if "__omp_prelude_loaded__" not in globals():
435
386
  result = [(count, item) for item, count in sorted_items]
436
387
  _emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
437
388
  return result
438
-
439
- @_category("Text")
440
- def cols(text: str, *indices: int, sep: str | None = None) -> str:
441
- """Extract columns from text (0-indexed). Like cut."""
442
- result_lines = []
443
- for line in text.splitlines():
444
- parts = line.split(sep) if sep else line.split()
445
- selected = [parts[i] for i in indices if i < len(parts)]
446
- result_lines.append(" ".join(selected))
447
- out = "\n".join(result_lines)
448
- _emit_status("cols", lines=len(result_lines), columns=list(indices))
449
- return out
450
-
451
- @_category("Navigation")
452
389
  def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
453
390
  """Return directory tree."""
454
391
  base = Path(path)
@@ -472,7 +409,6 @@ if "__omp_prelude_loaded__" not in globals():
472
409
  _emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
473
410
  return out
474
411
 
475
- @_category("Navigation")
476
412
  def stat(path: str | Path) -> dict:
477
413
  """Get file/directory info."""
478
414
  p = Path(path)
@@ -483,12 +419,10 @@ if "__omp_prelude_loaded__" not in globals():
483
419
  "is_file": p.is_file(),
484
420
  "is_dir": p.is_dir(),
485
421
  "mtime": datetime.fromtimestamp(s.st_mtime).isoformat(),
486
- "mode": oct(s.st_mode),
487
422
  }
488
423
  _emit_status("stat", path=str(p), size=s.st_size, is_dir=p.is_dir(), mtime=info["mtime"])
489
424
  return info
490
425
 
491
- @_category("Batch")
492
426
  def diff(a: str | Path, b: str | Path) -> str:
493
427
  """Compare two files, return unified diff."""
494
428
  import difflib
@@ -500,8 +434,7 @@ if "__omp_prelude_loaded__" not in globals():
500
434
  _emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
501
435
  return out
502
436
 
503
- @_category("Search")
504
- def glob_files(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[Path]:
437
+ def glob(pattern: str, path: str | Path = ".", *, hidden: bool = False) -> list[str]:
505
438
  """Non-recursive glob (use find() for recursive). Respects .gitignore."""
506
439
  p = Path(path)
507
440
  ignore_patterns = _load_gitignore_patterns(p)
@@ -518,7 +451,6 @@ if "__omp_prelude_loaded__" not in globals():
518
451
  _emit_status("glob", pattern=pattern, path=str(p), count=len(matches), matches=[str(m) for m in matches[:20]])
519
452
  return matches
520
453
 
521
- @_category("Find/Replace")
522
454
  def sed(path: str | Path, pattern: str, repl: str, *, flags: int = 0) -> int:
523
455
  """Regex replace in file (like sed -i). Returns count."""
524
456
  p = Path(path)
@@ -527,110 +459,6 @@ if "__omp_prelude_loaded__" not in globals():
527
459
  p.write_text(new, encoding="utf-8")
528
460
  _emit_status("sed", path=str(p), count=count)
529
461
  return count
530
-
531
- @_category("Find/Replace")
532
- def rsed(
533
- pattern: str,
534
- repl: str,
535
- path: str | Path = ".",
536
- *,
537
- glob_pattern: str = "*",
538
- flags: int = 0,
539
- hidden: bool = False,
540
- ) -> int:
541
- """Recursive sed across files matching glob_pattern. Respects .gitignore."""
542
- base = Path(path)
543
- ignore_patterns = _load_gitignore_patterns(base)
544
- total = 0
545
- files_changed = 0
546
- changed_files = []
547
- for file_path in base.rglob(glob_pattern):
548
- if file_path.is_dir():
549
- continue
550
- # Skip hidden files unless requested
551
- if not hidden and any(part.startswith(".") for part in file_path.parts):
552
- continue
553
- # Skip gitignored paths
554
- if _match_gitignore(file_path, ignore_patterns, base):
555
- continue
556
- try:
557
- data = file_path.read_text(encoding="utf-8")
558
- new, count = re.subn(pattern, repl, data, flags=flags)
559
- if count > 0:
560
- file_path.write_text(new, encoding="utf-8")
561
- total += count
562
- files_changed += 1
563
- if len(changed_files) < 10:
564
- changed_files.append({"file": str(file_path), "count": count})
565
- except Exception:
566
- continue
567
- _emit_status("rsed", path=str(base), count=total, files=files_changed, changed=changed_files)
568
- return total
569
-
570
- @_category("Line ops")
571
- def lines(path: str | Path, start: int = 1, end: int | None = None) -> str:
572
- """Extract line range from file (1-indexed, inclusive). Like sed -n 'N,Mp'."""
573
- p = Path(path)
574
- all_lines = p.read_text(encoding="utf-8").splitlines()
575
- if end is None:
576
- end = len(all_lines)
577
- start = max(1, start)
578
- end = min(len(all_lines), end)
579
- selected = all_lines[start - 1 : end]
580
- out = "\n".join(selected)
581
- _emit_status("lines", path=str(p), start=start, end=end, count=len(selected), preview=out[:500])
582
- return out
583
-
584
- @_category("Line ops")
585
- def delete_lines(path: str | Path, start: int, end: int | None = None) -> int:
586
- """Delete line range from file (1-indexed, inclusive). Like sed -i 'N,Md'."""
587
- p = Path(path)
588
- all_lines = p.read_text(encoding="utf-8").splitlines()
589
- if end is None:
590
- end = start
591
- start = max(1, start)
592
- end = min(len(all_lines), end)
593
- count = end - start + 1
594
- new_lines = all_lines[: start - 1] + all_lines[end:]
595
- p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
596
- _emit_status("delete_lines", path=str(p), start=start, end=end, count=count)
597
- return count
598
-
599
- @_category("Line ops")
600
- def delete_matching(path: str | Path, pattern: str, *, regex: bool = True) -> int:
601
- """Delete lines matching pattern. Like sed -i '/pattern/d'."""
602
- p = Path(path)
603
- all_lines = p.read_text(encoding="utf-8").splitlines()
604
- if regex:
605
- rx = re.compile(pattern)
606
- new_lines = [l for l in all_lines if not rx.search(l)]
607
- else:
608
- new_lines = [l for l in all_lines if pattern not in l]
609
- count = len(all_lines) - len(new_lines)
610
- p.write_text("\n".join(new_lines) + ("\n" if all_lines else ""), encoding="utf-8")
611
- _emit_status("delete_matching", path=str(p), pattern=pattern, count=count)
612
- return count
613
-
614
- @_category("Line ops")
615
- def insert_at(path: str | Path, line_num: int, text: str, *, after: bool = True) -> Path:
616
- """Insert text at line. after=True (sed 'Na\\'), after=False (sed 'Ni\\')."""
617
- p = Path(path)
618
- all_lines = p.read_text(encoding="utf-8").splitlines()
619
- new_lines = text.splitlines()
620
- line_num = max(1, min(len(all_lines) + 1, line_num))
621
- if after:
622
- idx = min(line_num, len(all_lines))
623
- all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
624
- pos = "after"
625
- else:
626
- idx = line_num - 1
627
- all_lines = all_lines[:idx] + new_lines + all_lines[idx:]
628
- pos = "before"
629
- p.write_text("\n".join(all_lines) + "\n", encoding="utf-8")
630
- _emit_status("insert_at", path=str(p), line=line_num, lines_inserted=len(new_lines), position=pos)
631
- return p
632
-
633
- @_category("Agent")
634
462
  def output(
635
463
  *ids: str,
636
464
  format: str = "raw",
@@ -831,19 +659,3 @@ if "__omp_prelude_loaded__" not in globals():
831
659
 
832
660
  return current
833
661
 
834
- def __omp_prelude_docs__() -> list[dict[str, str]]:
835
- """Return prelude helper docs for templating. Discovers functions by _omp_category attribute."""
836
- helpers: list[dict[str, str]] = []
837
- for name, obj in globals().items():
838
- if not callable(obj) or not hasattr(obj, "_omp_category"):
839
- continue
840
- signature = str(inspect.signature(obj))
841
- doc = inspect.getdoc(obj) or ""
842
- docline = doc.splitlines()[0] if doc else ""
843
- helpers.append({
844
- "name": name,
845
- "signature": signature,
846
- "docstring": docline,
847
- "category": obj._omp_category,
848
- })
849
- return sorted(helpers, key=lambda h: (h["category"], h["name"]))
@@ -0,0 +1,48 @@
1
+ /** Runtime backend that an eval cell dispatches to. */
2
+ export type EvalLanguage = "python" | "js";
3
+
4
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
5
+ import type { OutputMeta } from "../tools/output-meta";
6
+
7
+ /** Status event emitted by prelude helpers (python or js) for TUI rendering. */
8
+ export interface EvalStatusEvent {
9
+ op: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ /** Display output captured during eval execution. Union of python and js shapes. */
14
+ export type EvalDisplayOutput =
15
+ | { type: "json"; data: unknown }
16
+ | { type: "image"; data: string; mimeType: string }
17
+ | { type: "markdown"; text?: string }
18
+ | { type: "status"; event: EvalStatusEvent };
19
+
20
+ /** Per-cell execution result for transcript rendering. */
21
+ export interface EvalCellResult {
22
+ index: number;
23
+ title?: string;
24
+ code: string;
25
+ language?: EvalLanguage;
26
+ output: string;
27
+ status: "pending" | "running" | "complete" | "error";
28
+ durationMs?: number;
29
+ exitCode?: number;
30
+ statusEvents?: EvalStatusEvent[];
31
+ hasMarkdown?: boolean;
32
+ }
33
+
34
+ /** Tool result detail object surfaced to the UI/transcript. */
35
+ export interface EvalToolDetails {
36
+ cells?: EvalCellResult[];
37
+ jsonOutputs?: unknown[];
38
+ images?: ImageContent[];
39
+ statusEvents?: EvalStatusEvent[];
40
+ isError?: boolean;
41
+ meta?: OutputMeta;
42
+ /** First backend that produced cells. Kept for transcript compatibility. */
43
+ language?: EvalLanguage;
44
+ /** Backends that produced cells in this call, in first-use order. */
45
+ languages?: EvalLanguage[];
46
+ /** Optional human-readable notice (e.g. fallback explanation). */
47
+ notice?: string;
48
+ }