@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.
- package/CHANGELOG.md +45 -0
- package/package.json +18 -10
- package/src/cli/jupyter-cli.ts +1 -1
- package/src/commit/pipeline.ts +4 -3
- package/src/config/model-equivalence.ts +49 -16
- package/src/config/model-registry.ts +100 -25
- package/src/config/model-resolver.ts +29 -15
- package/src/config/settings-schema.ts +20 -6
- package/src/config/settings.ts +9 -8
- package/src/config.ts +18 -6
- package/src/eval/backend.ts +43 -0
- package/src/eval/eval.lark +43 -0
- package/src/eval/index.ts +5 -0
- package/src/eval/js/context-manager.ts +717 -0
- package/src/eval/js/executor.ts +131 -0
- package/src/eval/js/index.ts +46 -0
- package/src/eval/js/prelude.ts +2 -0
- package/src/eval/js/prelude.txt +84 -0
- package/src/eval/js/tool-bridge.ts +124 -0
- package/src/eval/parse.ts +337 -0
- package/src/{ipy → eval/py}/executor.ts +2 -180
- package/src/{ipy → eval/py}/gateway-coordinator.ts +2 -2
- package/src/eval/py/index.ts +58 -0
- package/src/{ipy → eval/py}/kernel.ts +9 -45
- package/src/{ipy → eval/py}/prelude.py +39 -227
- package/src/eval/types.ts +48 -0
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +8 -10
- package/src/extensibility/extensions/types.ts +2 -3
- package/src/internal-urls/docs-index.generated.ts +5 -5
- package/src/lsp/client.ts +9 -0
- package/src/lsp/index.ts +395 -0
- package/src/lsp/types.ts +15 -4
- package/src/main.ts +35 -14
- package/src/mcp/manager.ts +22 -0
- package/src/mcp/oauth-flow.ts +1 -1
- package/src/memories/index.ts +1 -1
- package/src/modes/acp/acp-event-mapper.ts +1 -1
- package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
- package/src/modes/components/login-dialog.ts +1 -1
- package/src/modes/components/oauth-selector.ts +2 -1
- package/src/modes/components/tool-execution.ts +3 -4
- package/src/modes/controllers/command-controller.ts +28 -8
- package/src/modes/controllers/input-controller.ts +4 -4
- package/src/modes/controllers/selector-controller.ts +2 -1
- package/src/modes/interactive-mode.ts +4 -5
- package/src/modes/rpc/rpc-client.ts +9 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/rpc/rpc-types.ts +9 -0
- package/src/modes/types.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +2 -2
- package/src/prompts/system/system-prompt.md +3 -3
- package/src/prompts/tools/eval.md +92 -0
- package/src/prompts/tools/lsp.md +7 -3
- package/src/sdk.ts +64 -35
- package/src/session/agent-session.ts +152 -46
- package/src/session/messages.ts +1 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/system-prompt.ts +34 -66
- package/src/task/agents.ts +4 -5
- package/src/task/executor.ts +5 -9
- package/src/tools/archive-reader.ts +9 -3
- package/src/tools/browser/launch.ts +22 -0
- package/src/tools/browser/readable.ts +11 -6
- package/src/tools/browser/registry.ts +25 -244
- package/src/tools/browser/render.ts +1 -1
- package/src/tools/browser/tab-protocol.ts +101 -0
- package/src/tools/browser/tab-supervisor.ts +429 -0
- package/src/tools/browser/tab-worker-entry.ts +21 -0
- package/src/tools/browser/tab-worker.ts +1006 -0
- package/src/tools/browser.ts +17 -32
- package/src/tools/checkpoint.ts +2 -2
- package/src/tools/{python.ts → eval.ts} +324 -315
- package/src/tools/exit-plan-mode.ts +1 -1
- package/src/tools/image-gen.ts +2 -2
- package/src/tools/index.ts +62 -100
- package/src/tools/read.ts +0 -6
- package/src/tools/recipe/runners/pkg.ts +34 -32
- package/src/tools/renderers.ts +2 -2
- package/src/tools/resolve.ts +7 -2
- package/src/tools/todo-write.ts +0 -1
- package/src/tools/tool-timeouts.ts +2 -2
- package/src/tools/write.ts +8 -1
- package/src/utils/markit.ts +15 -7
- package/src/utils/tools-manager.ts +5 -5
- package/src/web/scrapers/crossref.ts +3 -3
- package/src/web/scrapers/devto.ts +1 -1
- package/src/web/scrapers/discourse.ts +5 -5
- package/src/web/scrapers/firefox-addons.ts +1 -1
- package/src/web/scrapers/flathub.ts +2 -2
- package/src/web/scrapers/gitlab.ts +1 -1
- package/src/web/scrapers/go-pkg.ts +2 -2
- package/src/web/scrapers/jetbrains-marketplace.ts +1 -1
- package/src/web/scrapers/mastodon.ts +9 -9
- package/src/web/scrapers/mdn.ts +11 -7
- package/src/web/scrapers/pub-dev.ts +1 -1
- package/src/web/scrapers/rawg.ts +3 -3
- package/src/web/scrapers/readthedocs.ts +1 -1
- package/src/web/scrapers/spdx.ts +1 -1
- package/src/web/scrapers/stackoverflow.ts +2 -2
- package/src/web/scrapers/types.ts +53 -39
- package/src/web/scrapers/w3c.ts +1 -1
- package/src/web/search/index.ts +5 -5
- package/src/web/search/provider.ts +121 -39
- package/src/web/search/providers/gemini.ts +4 -4
- package/src/web/search/render.ts +2 -2
- package/src/ipy/modules.ts +0 -144
- package/src/prompts/tools/python.md +0 -57
- package/src/tools/browser/vm.ts +0 -792
- /package/src/{ipy → eval/py}/cancellation.ts +0 -0
- /package/src/{ipy → eval/py}/prelude.ts +0 -0
- /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 "
|
|
4
|
-
import { htmlToBasicMarkdown } from "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
212
|
-
"""Grep a single file. Returns
|
|
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 = [
|
|
241
|
-
_emit_status("grep", pattern=pattern, path=str(p), count=len(match_lines), hits=
|
|
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[
|
|
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[
|
|
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=
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
+
}
|