@oh-my-pi/pi-coding-agent 16.1.14 → 16.1.16
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 +69 -0
- package/dist/cli.js +3705 -4015
- package/dist/types/advisor/runtime.d.ts +3 -0
- package/dist/types/cli/args.d.ts +2 -5
- package/dist/types/cli/flag-tables.d.ts +2 -2
- package/dist/types/cli/session-picker.d.ts +4 -2
- package/dist/types/commands/launch.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +32 -1
- package/dist/types/eval/agent-bridge.d.ts +19 -0
- package/dist/types/eval/js/shared/helpers.d.ts +1 -13
- package/dist/types/eval/js/shared/types.d.ts +1 -1
- package/dist/types/eval/js/worker-protocol.d.ts +1 -1
- package/dist/types/eval/py/executor.d.ts +1 -1
- package/dist/types/export/share.d.ts +8 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -1
- package/dist/types/main.d.ts +2 -0
- package/dist/types/mcp/transports/stdio.d.ts +12 -1
- package/dist/types/modes/components/plugin-settings.d.ts +5 -0
- package/dist/types/modes/components/session-selector.d.ts +25 -0
- package/dist/types/modes/components/status-line/context-thresholds.d.ts +4 -3
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/secrets/obfuscator.d.ts +3 -3
- package/dist/types/task/isolation-runner.d.ts +128 -0
- package/dist/types/task/worktree.d.ts +14 -1
- package/dist/types/thinking.d.ts +15 -0
- package/dist/types/tools/eval-render.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +11 -17
- package/dist/types/tools/todo.d.ts +26 -28
- package/dist/types/tui/output-block.d.ts +8 -0
- package/dist/types/utils/image-resize.d.ts +2 -0
- package/dist/types/utils/shell-snapshot.d.ts +10 -0
- package/dist/types/web/search/providers/exa.d.ts +2 -0
- package/dist/types/web/search/providers/perplexity.d.ts +17 -3
- package/package.json +12 -12
- package/scripts/build-binary.ts +18 -4
- package/src/advisor/__tests__/advisor.test.ts +114 -0
- package/src/advisor/runtime.ts +129 -1
- package/src/cli/args.ts +4 -5
- package/src/cli/flag-tables.ts +3 -3
- package/src/cli/gallery-fixtures/interaction.ts +6 -9
- package/src/cli/gallery-fixtures/shell.ts +15 -23
- package/src/cli/session-picker.ts +17 -3
- package/src/commands/launch.ts +3 -3
- package/src/config/model-registry.ts +12 -4
- package/src/config/settings-schema.ts +37 -1
- package/src/edit/renderer.ts +34 -12
- package/src/eval/__tests__/agent-bridge.test.ts +462 -3
- package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
- package/src/eval/__tests__/julia-prelude.test.ts +1 -30
- package/src/eval/__tests__/prelude-agent.test.ts +42 -8
- package/src/eval/agent-bridge.ts +301 -71
- package/src/eval/jl/prelude.jl +32 -227
- package/src/eval/jl/runner.jl +38 -12
- package/src/eval/js/shared/helpers.ts +1 -114
- package/src/eval/js/shared/prelude.txt +13 -27
- package/src/eval/js/shared/runtime.ts +0 -6
- package/src/eval/js/shared/types.ts +1 -1
- package/src/eval/js/worker-protocol.ts +1 -1
- package/src/eval/py/__tests__/prelude.test.ts +13 -0
- package/src/eval/py/executor.ts +1 -1
- package/src/eval/py/prelude.py +47 -105
- package/src/eval/py/runner.py +0 -6
- package/src/eval/rb/prelude.rb +21 -189
- package/src/eval/rb/runner.rb +116 -9
- package/src/exec/bash-executor.ts +44 -0
- package/src/export/html/tool-views.generated.js +29 -29
- package/src/export/share.ts +51 -28
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/internal-urls/local-protocol.ts +100 -53
- package/src/main.ts +15 -4
- package/src/mcp/transports/stdio.ts +20 -4
- package/src/modes/acp/acp-event-mapper.ts +7 -2
- package/src/modes/components/custom-editor.test.ts +22 -0
- package/src/modes/components/custom-editor.ts +10 -1
- package/src/modes/components/footer.ts +4 -3
- package/src/modes/components/plugin-settings.ts +7 -1
- package/src/modes/components/session-selector.ts +143 -29
- package/src/modes/components/status-line/component.ts +5 -1
- package/src/modes/components/status-line/context-thresholds.ts +11 -3
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/controllers/command-controller.ts +6 -0
- package/src/modes/rpc/rpc-mode.ts +6 -0
- package/src/modes/utils/copy-targets.ts +7 -2
- package/src/prompts/system/system-prompt.md +6 -6
- package/src/prompts/system/workflow-notice.md +3 -3
- package/src/prompts/tools/bash.md +18 -2
- package/src/prompts/tools/eval.md +19 -19
- package/src/prompts/tools/search.md +1 -0
- package/src/prompts/tools/todo.md +1 -1
- package/src/secrets/obfuscator.ts +3 -9
- package/src/session/agent-session.ts +264 -52
- package/src/slash-commands/builtin-registry.ts +2 -1
- package/src/task/index.ts +61 -207
- package/src/task/isolation-runner.ts +354 -0
- package/src/task/worktree.ts +46 -9
- package/src/thinking.ts +20 -0
- package/src/tools/ask.ts +44 -38
- package/src/tools/bash.ts +9 -2
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/eval-render.ts +34 -27
- package/src/tools/eval.ts +100 -103
- package/src/tools/index.ts +8 -1
- package/src/tools/read.ts +136 -60
- package/src/tools/todo.ts +60 -64
- package/src/tui/code-cell.ts +1 -1
- package/src/tui/output-block.ts +11 -0
- package/src/utils/image-resize.ts +30 -0
- package/src/utils/shell-snapshot.ts +63 -1
- package/src/web/search/providers/exa.ts +85 -1
- package/src/web/search/providers/perplexity.ts +18 -6
package/src/eval/py/prelude.py
CHANGED
|
@@ -115,103 +115,6 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
115
115
|
_emit_status("write", path=str(p), chars=len(content))
|
|
116
116
|
return p
|
|
117
117
|
|
|
118
|
-
def append(path: str | Path, content: str) -> Path:
|
|
119
|
-
"""Append to file."""
|
|
120
|
-
p = _resolve_omp_path(path)
|
|
121
|
-
p.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
-
with p.open("a", encoding="utf-8") as f:
|
|
123
|
-
f.write(content)
|
|
124
|
-
_emit_status("append", path=str(p), chars=len(content))
|
|
125
|
-
return p
|
|
126
|
-
|
|
127
|
-
def sort(text: str, *, reverse: bool = False, unique: bool = False) -> str:
|
|
128
|
-
"""Sort lines of text."""
|
|
129
|
-
lines = text.splitlines()
|
|
130
|
-
if unique:
|
|
131
|
-
lines = list(dict.fromkeys(lines))
|
|
132
|
-
lines = sorted(lines, reverse=reverse)
|
|
133
|
-
out = "\n".join(lines)
|
|
134
|
-
_emit_status("sort", lines=len(lines), unique=unique, reverse=reverse)
|
|
135
|
-
return out
|
|
136
|
-
|
|
137
|
-
def uniq(text: str, *, count: bool = False) -> str | list[tuple[int, str]]:
|
|
138
|
-
"""Remove duplicate adjacent lines (like uniq)."""
|
|
139
|
-
lines = text.splitlines()
|
|
140
|
-
if not lines:
|
|
141
|
-
_emit_status("uniq", groups=0)
|
|
142
|
-
return [] if count else ""
|
|
143
|
-
groups: list[tuple[int, str]] = []
|
|
144
|
-
current = lines[0]
|
|
145
|
-
current_count = 1
|
|
146
|
-
for line in lines[1:]:
|
|
147
|
-
if line == current:
|
|
148
|
-
current_count += 1
|
|
149
|
-
continue
|
|
150
|
-
groups.append((current_count, current))
|
|
151
|
-
current = line
|
|
152
|
-
current_count = 1
|
|
153
|
-
groups.append((current_count, current))
|
|
154
|
-
_emit_status("uniq", groups=len(groups), count_mode=count)
|
|
155
|
-
if count:
|
|
156
|
-
return groups
|
|
157
|
-
return "\n".join(line for _, line in groups)
|
|
158
|
-
|
|
159
|
-
def counter(
|
|
160
|
-
items: str | list,
|
|
161
|
-
*,
|
|
162
|
-
limit: int | None = None,
|
|
163
|
-
reverse: bool = True,
|
|
164
|
-
) -> list[tuple[int, str]]:
|
|
165
|
-
"""Count occurrences and sort by frequency. Like sort | uniq -c | sort -rn.
|
|
166
|
-
|
|
167
|
-
items: text (splits into lines) or list of strings
|
|
168
|
-
reverse: True for descending (most common first), False for ascending
|
|
169
|
-
Returns: [(count, item), ...] sorted by count
|
|
170
|
-
"""
|
|
171
|
-
from collections import Counter
|
|
172
|
-
if isinstance(items, str):
|
|
173
|
-
items = items.splitlines()
|
|
174
|
-
counts = Counter(items)
|
|
175
|
-
sorted_items = sorted(counts.items(), key=lambda x: (x[1], x[0]), reverse=reverse)
|
|
176
|
-
if limit is not None:
|
|
177
|
-
sorted_items = sorted_items[:limit]
|
|
178
|
-
result = [(count, item) for item, count in sorted_items]
|
|
179
|
-
_emit_status("counter", unique=len(counts), total=sum(counts.values()), top=result[:10])
|
|
180
|
-
return result
|
|
181
|
-
def tree(path: str | Path = ".", *, max_depth: int = 3, show_hidden: bool = False) -> str:
|
|
182
|
-
"""Return directory tree."""
|
|
183
|
-
base = Path(path)
|
|
184
|
-
lines = []
|
|
185
|
-
def walk(p: Path, prefix: str, depth: int):
|
|
186
|
-
if depth > max_depth:
|
|
187
|
-
return
|
|
188
|
-
items = sorted(p.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower()))
|
|
189
|
-
items = [i for i in items if show_hidden or not i.name.startswith(".")]
|
|
190
|
-
for i, item in enumerate(items):
|
|
191
|
-
is_last = i == len(items) - 1
|
|
192
|
-
connector = "└── " if is_last else "├── "
|
|
193
|
-
suffix = "/" if item.is_dir() else ""
|
|
194
|
-
lines.append(f"{prefix}{connector}{item.name}{suffix}")
|
|
195
|
-
if item.is_dir():
|
|
196
|
-
ext = " " if is_last else "│ "
|
|
197
|
-
walk(item, prefix + ext, depth + 1)
|
|
198
|
-
lines.append(str(base) + "/")
|
|
199
|
-
walk(base, "", 1)
|
|
200
|
-
out = "\n".join(lines)
|
|
201
|
-
_emit_status("tree", path=str(base), entries=len(lines) - 1, preview=out[:1000])
|
|
202
|
-
return out
|
|
203
|
-
|
|
204
|
-
def diff(a: str | Path, b: str | Path) -> str:
|
|
205
|
-
"""Compare two files, return unified diff."""
|
|
206
|
-
import difflib
|
|
207
|
-
path_a, path_b = Path(a), Path(b)
|
|
208
|
-
lines_a = path_a.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
209
|
-
lines_b = path_b.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
210
|
-
result = difflib.unified_diff(lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b))
|
|
211
|
-
out = "".join(result)
|
|
212
|
-
_emit_status("diff", file_a=str(path_a), file_b=str(path_b), identical=not out, preview=out[:500])
|
|
213
|
-
return out
|
|
214
|
-
|
|
215
118
|
def output(
|
|
216
119
|
*ids: str,
|
|
217
120
|
format: str = "raw",
|
|
@@ -520,37 +423,66 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
520
423
|
text = res.get("text") if isinstance(res, dict) else res
|
|
521
424
|
return json.loads(text) if schema is not None else text
|
|
522
425
|
|
|
523
|
-
def agent(prompt, *,
|
|
426
|
+
def agent(prompt, *, agent="task", model=None, label=None, schema=None, isolated=None, apply=None, merge=None, handle=False):
|
|
524
427
|
"""Run a subagent and return its final output.
|
|
525
428
|
|
|
526
|
-
`
|
|
429
|
+
`agent` selects the subagent definition (default "task"). Pass
|
|
527
430
|
`model` to override that agent's model, `label` for the output artifact
|
|
528
431
|
id, and `schema` to request structured JSON output; when `schema` is
|
|
529
432
|
supplied the parsed object is returned. Share background by writing a
|
|
530
433
|
local:// file and referencing it in the prompt.
|
|
531
434
|
|
|
532
|
-
|
|
435
|
+
Pass `isolated=True` to run the subagent inside an isolation worktree
|
|
436
|
+
(copy-on-write of the parent repo) so parallel `agent()` spawns can
|
|
437
|
+
edit overlapping files safely. Strict opt-in, mirroring the `task`
|
|
438
|
+
tool: the default is non-isolated regardless of `task.isolation.mode`.
|
|
439
|
+
`isolated=True` while the setting is `"none"` errors out instead of
|
|
440
|
+
silently downgrading.
|
|
441
|
+
|
|
442
|
+
When isolated, `apply=False` keeps captured changes inside the
|
|
443
|
+
worktree and surfaces the root patch path, branch name, and nested
|
|
444
|
+
repository patches through the DAG node dict (combine with
|
|
445
|
+
`handle=True` to receive them — see below; the bare return type
|
|
446
|
+
stays bytes/string/parsed object and has nowhere to expose artifacts).
|
|
447
|
+
`merge=False` forces patch mode even when `task.isolation.merge` is
|
|
448
|
+
`"branch"`, avoiding the per-call git lock + repo mutation that branch
|
|
449
|
+
mode performs.
|
|
450
|
+
|
|
451
|
+
Set `handle=True` to receive a DAG node dict instead of bare
|
|
533
452
|
text: ``{"text", "output", "handle", "id", "agent"}`` where ``handle``
|
|
534
453
|
is the spawned agent's recoverable ``agent://<id>`` URI. A downstream
|
|
535
454
|
``pipeline``/``parallel`` stage embeds that ``handle`` (or ``output``)
|
|
536
455
|
in its prompt so a large transcript flows through the graph by
|
|
537
456
|
reference, never re-inlined. When ``schema`` is also set the parsed
|
|
538
|
-
object lands under ``"data"``.
|
|
539
|
-
|
|
457
|
+
object lands under ``"data"``. When the spawn ran isolated the node
|
|
458
|
+
also carries ``"isolated"`` and, when present, ``"patch_path"``,
|
|
459
|
+
``"branch_name"``, ``"nested_patches"``, ``"changes_applied"``
|
|
460
|
+
(``True``/``False``/``None`` — ``None`` means ``apply=False``), and
|
|
461
|
+
``"isolation_summary"``. If
|
|
462
|
+
the bridge returns no recoverable id the node still resolves with
|
|
463
|
+
``handle=None`` — the helper never throws.
|
|
540
464
|
"""
|
|
541
465
|
args = {"prompt": prompt}
|
|
542
|
-
if
|
|
543
|
-
args["
|
|
466
|
+
if agent is not None:
|
|
467
|
+
args["agent"] = agent
|
|
544
468
|
if model is not None:
|
|
545
469
|
args["model"] = model
|
|
546
470
|
if label is not None:
|
|
547
471
|
args["label"] = label
|
|
548
472
|
if schema is not None:
|
|
549
473
|
args["schema"] = schema
|
|
474
|
+
if isolated is not None:
|
|
475
|
+
args["isolated"] = bool(isolated)
|
|
476
|
+
if apply is not None:
|
|
477
|
+
args["apply"] = bool(apply)
|
|
478
|
+
if merge is not None:
|
|
479
|
+
args["merge"] = bool(merge)
|
|
480
|
+
if handle:
|
|
481
|
+
args["handle"] = True
|
|
550
482
|
res = _bridge_call("__agent__", args)
|
|
551
483
|
text = res.get("text") if isinstance(res, dict) else res
|
|
552
484
|
parsed = json.loads(text) if schema is not None else text
|
|
553
|
-
if not
|
|
485
|
+
if not handle:
|
|
554
486
|
return parsed
|
|
555
487
|
details = res.get("details") if isinstance(res, dict) else None
|
|
556
488
|
if not isinstance(details, dict) or details.get("id") is None:
|
|
@@ -564,6 +496,16 @@ if "__omp_prelude_loaded__" not in globals():
|
|
|
564
496
|
}
|
|
565
497
|
if schema is not None:
|
|
566
498
|
node["data"] = parsed
|
|
499
|
+
for src_key, dst_key in (
|
|
500
|
+
("isolated", "isolated"),
|
|
501
|
+
("patchPath", "patch_path"),
|
|
502
|
+
("branchName", "branch_name"),
|
|
503
|
+
("nestedPatches", "nested_patches"),
|
|
504
|
+
("changesApplied", "changes_applied"),
|
|
505
|
+
("isolationSummary", "isolation_summary"),
|
|
506
|
+
):
|
|
507
|
+
if src_key in details:
|
|
508
|
+
node[dst_key] = details[src_key]
|
|
567
509
|
return node
|
|
568
510
|
|
|
569
511
|
def _concurrency_limit():
|
package/src/eval/py/runner.py
CHANGED
|
@@ -557,12 +557,6 @@ def _magic_run(args: str) -> None:
|
|
|
557
557
|
def _magic_cell_bash(args: str, body: str) -> int:
|
|
558
558
|
return _run_shell_body(body, shell_arg="/bin/bash")
|
|
559
559
|
|
|
560
|
-
|
|
561
|
-
@cell_magic("sh")
|
|
562
|
-
def _magic_cell_sh(args: str, body: str) -> int:
|
|
563
|
-
return _run_shell_body(body, shell_arg="/bin/sh")
|
|
564
|
-
|
|
565
|
-
|
|
566
560
|
@cell_magic("capture")
|
|
567
561
|
def _magic_cell_capture(args: str, body: str) -> str:
|
|
568
562
|
"""Capture stdout/stderr of body; bind to ``args`` (a name) if provided."""
|
package/src/eval/rb/prelude.rb
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# OMP Ruby prelude helpers (loaded once into the runner's TOPLEVEL_BINDING).
|
|
3
3
|
#
|
|
4
4
|
# Mirrors eval/py/prelude.py: defines the cross-runtime helper surface
|
|
5
|
-
# (display/read/write/
|
|
5
|
+
# (display/read/write/env/output, the `tool` bridge proxy,
|
|
6
6
|
# completion/agent/parallel/pipeline/log/phase/budget). Host-side helpers reach
|
|
7
7
|
# the coding-agent over the same loopback HTTP tool bridge the Python prelude
|
|
8
8
|
# uses (PI_TOOL_BRIDGE_URL/TOKEN/SESSION). Path helpers honor PI_EVAL_LOCAL_ROOTS
|
|
@@ -112,191 +112,6 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
|
|
|
112
112
|
resolved.to_s
|
|
113
113
|
end
|
|
114
114
|
|
|
115
|
-
def append(path, content)
|
|
116
|
-
resolved = __omp_resolve_path(path)
|
|
117
|
-
require "fileutils"
|
|
118
|
-
FileUtils.mkdir_p(File.dirname(resolved.to_s))
|
|
119
|
-
File.open(resolved.to_s, "a") { |f| f.write(content.to_s) }
|
|
120
|
-
__omp_emit_status("append", "path" => resolved.to_s, "chars" => content.to_s.length)
|
|
121
|
-
resolved.to_s
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def tree(path = ".", max_depth: 3, show_hidden: false)
|
|
125
|
-
base = path.to_s
|
|
126
|
-
lines = []
|
|
127
|
-
walk = lambda do |dir, prefix, depth|
|
|
128
|
-
return if depth > max_depth
|
|
129
|
-
entries = (Dir.children(dir) rescue [])
|
|
130
|
-
entries = entries.reject { |e| e.start_with?(".") } unless show_hidden
|
|
131
|
-
entries = entries.sort_by { |e| [File.directory?(File.join(dir, e)) ? 0 : 1, e.downcase] }
|
|
132
|
-
entries.each_with_index do |name, i|
|
|
133
|
-
full = File.join(dir, name)
|
|
134
|
-
is_last = i == entries.length - 1
|
|
135
|
-
is_dir = File.directory?(full)
|
|
136
|
-
lines << "#{prefix}#{is_last ? "└── " : "├── "}#{name}#{is_dir ? "/" : ""}"
|
|
137
|
-
walk.call(full, prefix + (is_last ? " " : "│ "), depth + 1) if is_dir
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
lines << "#{base}/"
|
|
141
|
-
walk.call(base, "", 1)
|
|
142
|
-
out = lines.join("\n")
|
|
143
|
-
__omp_emit_status("tree", "path" => base, "entries" => lines.length - 1, "preview" => __omp_scrub(out[0, 1000].to_s))
|
|
144
|
-
out
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def diff(a, b)
|
|
148
|
-
pa = a.to_s
|
|
149
|
-
pb = b.to_s
|
|
150
|
-
lines_a = File.read(pa, encoding: Encoding::UTF_8).lines
|
|
151
|
-
lines_b = File.read(pb, encoding: Encoding::UTF_8).lines
|
|
152
|
-
out = __omp_unified_diff(lines_a, lines_b, pa, pb)
|
|
153
|
-
__omp_emit_status("diff", "file_a" => pa, "file_b" => pb, "identical" => out.empty?, "preview" => __omp_scrub(out[0, 500].to_s))
|
|
154
|
-
out
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
# LCS-based op list ([:equal/:delete/:insert, aIndex, bIndex]) per consumed line.
|
|
158
|
-
def __omp_diff_ops(a, b)
|
|
159
|
-
n = a.length
|
|
160
|
-
m = b.length
|
|
161
|
-
if n * m > 4_000_000
|
|
162
|
-
# Too large for the DP table — fall back to a coarse all-delete/all-insert.
|
|
163
|
-
ops = []
|
|
164
|
-
n.times { |i| ops << [:delete, i, 0] }
|
|
165
|
-
m.times { |j| ops << [:insert, n, j] }
|
|
166
|
-
return ops
|
|
167
|
-
end
|
|
168
|
-
dp = Array.new(n + 1) { Array.new(m + 1, 0) }
|
|
169
|
-
(n - 1).downto(0) do |i|
|
|
170
|
-
row = dp[i]
|
|
171
|
-
nrow = dp[i + 1]
|
|
172
|
-
(m - 1).downto(0) do |j|
|
|
173
|
-
row[j] = a[i] == b[j] ? nrow[j + 1] + 1 : (nrow[j] >= row[j + 1] ? nrow[j] : row[j + 1])
|
|
174
|
-
end
|
|
175
|
-
end
|
|
176
|
-
ops = []
|
|
177
|
-
i = 0
|
|
178
|
-
j = 0
|
|
179
|
-
while i < n && j < m
|
|
180
|
-
if a[i] == b[j]
|
|
181
|
-
ops << [:equal, i, j]; i += 1; j += 1
|
|
182
|
-
elsif dp[i + 1][j] >= dp[i][j + 1]
|
|
183
|
-
ops << [:delete, i, j]; i += 1
|
|
184
|
-
else
|
|
185
|
-
ops << [:insert, i, j]; j += 1
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
ops << [:delete, i, j].tap { i += 1 } while i < n
|
|
189
|
-
ops << [:insert, i, j].tap { j += 1 } while j < m
|
|
190
|
-
ops
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
def __omp_unified_diff(a, b, from_file, to_file, context = 3)
|
|
194
|
-
ops = __omp_diff_ops(a, b)
|
|
195
|
-
return "" unless ops.any? { |tag, _, _| tag != :equal }
|
|
196
|
-
|
|
197
|
-
entries = ops.map do |tag, ai, bi|
|
|
198
|
-
{ tag: tag, ai: ai, bi: bi, text: (tag == :insert ? b[bi] : a[ai]) }
|
|
199
|
-
end
|
|
200
|
-
changed = entries.each_index.select { |k| entries[k][:tag] != :equal }
|
|
201
|
-
|
|
202
|
-
groups = []
|
|
203
|
-
start = nil
|
|
204
|
-
prev = nil
|
|
205
|
-
changed.each do |k|
|
|
206
|
-
if start.nil?
|
|
207
|
-
start = k
|
|
208
|
-
prev = k
|
|
209
|
-
elsif k - prev <= (2 * context) + 1
|
|
210
|
-
prev = k
|
|
211
|
-
else
|
|
212
|
-
groups << [start, prev]
|
|
213
|
-
start = k
|
|
214
|
-
prev = k
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
groups << [start, prev] unless start.nil?
|
|
218
|
-
|
|
219
|
-
out = +""
|
|
220
|
-
out << "--- #{from_file}\n"
|
|
221
|
-
out << "+++ #{to_file}\n"
|
|
222
|
-
groups.each do |gs, ge|
|
|
223
|
-
lo = [gs - context, 0].max
|
|
224
|
-
hi = [ge + context, entries.length - 1].min
|
|
225
|
-
slice = entries[lo..hi]
|
|
226
|
-
a_start = nil
|
|
227
|
-
a_count = 0
|
|
228
|
-
b_start = nil
|
|
229
|
-
b_count = 0
|
|
230
|
-
slice.each do |e|
|
|
231
|
-
if e[:tag] != :insert
|
|
232
|
-
a_start ||= e[:ai]
|
|
233
|
-
a_count += 1
|
|
234
|
-
end
|
|
235
|
-
if e[:tag] != :delete
|
|
236
|
-
b_start ||= e[:bi]
|
|
237
|
-
b_count += 1
|
|
238
|
-
end
|
|
239
|
-
end
|
|
240
|
-
out << "@@ -#{(a_start || 0) + 1},#{a_count} +#{(b_start || 0) + 1},#{b_count} @@\n"
|
|
241
|
-
slice.each do |e|
|
|
242
|
-
prefix = e[:tag] == :equal ? " " : (e[:tag] == :delete ? "-" : "+")
|
|
243
|
-
text = e[:text].to_s
|
|
244
|
-
text = "#{text}\n" unless text.end_with?("\n")
|
|
245
|
-
out << "#{prefix}#{text}"
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
out
|
|
249
|
-
end
|
|
250
|
-
|
|
251
|
-
# -------------------------------------------------------------------------
|
|
252
|
-
# Text helpers (sort / uniq / counter)
|
|
253
|
-
# -------------------------------------------------------------------------
|
|
254
|
-
|
|
255
|
-
def sort(text, reverse: false, unique: false)
|
|
256
|
-
lines = text.to_s.lines.map(&:chomp)
|
|
257
|
-
lines = lines.uniq if unique
|
|
258
|
-
lines = lines.sort
|
|
259
|
-
lines = lines.reverse if reverse
|
|
260
|
-
out = lines.join("\n")
|
|
261
|
-
__omp_emit_status("sort", "lines" => lines.length, "unique" => unique, "reverse" => reverse)
|
|
262
|
-
out
|
|
263
|
-
end
|
|
264
|
-
|
|
265
|
-
def uniq(text, count: false)
|
|
266
|
-
lines = text.to_s.lines.map(&:chomp)
|
|
267
|
-
if lines.empty?
|
|
268
|
-
__omp_emit_status("uniq", "groups" => 0)
|
|
269
|
-
return count ? [] : ""
|
|
270
|
-
end
|
|
271
|
-
groups = []
|
|
272
|
-
current = lines[0]
|
|
273
|
-
run = 1
|
|
274
|
-
lines[1..].each do |line|
|
|
275
|
-
if line == current
|
|
276
|
-
run += 1
|
|
277
|
-
else
|
|
278
|
-
groups << [run, current]
|
|
279
|
-
current = line
|
|
280
|
-
run = 1
|
|
281
|
-
end
|
|
282
|
-
end
|
|
283
|
-
groups << [run, current]
|
|
284
|
-
__omp_emit_status("uniq", "groups" => groups.length, "count_mode" => count)
|
|
285
|
-
count ? groups : groups.map { |_, l| l }.join("\n")
|
|
286
|
-
end
|
|
287
|
-
|
|
288
|
-
def counter(items, limit: nil, reverse: true)
|
|
289
|
-
arr = items.is_a?(String) ? items.lines.map(&:chomp) : items.to_a
|
|
290
|
-
counts = Hash.new(0)
|
|
291
|
-
arr.each { |i| counts[i] += 1 }
|
|
292
|
-
sorted = counts.sort_by { |item, c| [c, item] }
|
|
293
|
-
sorted = sorted.reverse if reverse
|
|
294
|
-
sorted = sorted.first(limit) if limit
|
|
295
|
-
result = sorted.map { |item, c| [c, item] }
|
|
296
|
-
__omp_emit_status("counter", "unique" => counts.size, "total" => arr.length, "top" => result.first(10))
|
|
297
|
-
result
|
|
298
|
-
end
|
|
299
|
-
|
|
300
115
|
# -------------------------------------------------------------------------
|
|
301
116
|
# Task/agent output reader
|
|
302
117
|
# -------------------------------------------------------------------------
|
|
@@ -577,16 +392,23 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
|
|
|
577
392
|
schema.nil? ? text : JSON.parse(text)
|
|
578
393
|
end
|
|
579
394
|
|
|
580
|
-
def agent(prompt,
|
|
395
|
+
def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
|
|
581
396
|
args = { "prompt" => prompt }
|
|
582
|
-
args["
|
|
397
|
+
args["agent"] = agent unless agent.nil?
|
|
583
398
|
args["model"] = model unless model.nil?
|
|
584
399
|
args["label"] = label unless label.nil?
|
|
585
400
|
args["schema"] = schema unless schema.nil?
|
|
401
|
+
# Isolation knobs mirror the `task` tool: strict opt-in via `isolated`,
|
|
402
|
+
# with `apply`/`merge` controlling the post-run patch/branch merge.
|
|
403
|
+
args["isolated"] = !!isolated unless isolated.nil?
|
|
404
|
+
args["apply"] = !!apply unless apply.nil?
|
|
405
|
+
args["merge"] = !!merge unless merge.nil?
|
|
406
|
+
# Tell the bridge a handle is wanted so it preserves the backing artifacts.
|
|
407
|
+
args["handle"] = true if handle
|
|
586
408
|
res = OmpBridge.call("__agent__", args)
|
|
587
409
|
text = res.is_a?(Hash) ? res["text"] : res
|
|
588
410
|
parsed = schema.nil? ? text : JSON.parse(text)
|
|
589
|
-
return parsed unless
|
|
411
|
+
return parsed unless handle
|
|
590
412
|
details = res.is_a?(Hash) ? res["details"] : nil
|
|
591
413
|
if !details.is_a?(Hash) || details["id"].nil?
|
|
592
414
|
return { "text" => text, "output" => text, "handle" => nil, "id" => nil, "agent" => nil }
|
|
@@ -599,6 +421,16 @@ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
|
|
|
599
421
|
"agent" => details["agent"],
|
|
600
422
|
}
|
|
601
423
|
node["data"] = parsed unless schema.nil?
|
|
424
|
+
{
|
|
425
|
+
"isolated" => "isolated",
|
|
426
|
+
"patchPath" => "patch_path",
|
|
427
|
+
"branchName" => "branch_name",
|
|
428
|
+
"nestedPatches" => "nested_patches",
|
|
429
|
+
"changesApplied" => "changes_applied",
|
|
430
|
+
"isolationSummary" => "isolation_summary",
|
|
431
|
+
}.each do |src_key, dst_key|
|
|
432
|
+
node[dst_key] = details[src_key] if details.key?(src_key)
|
|
433
|
+
end
|
|
602
434
|
node
|
|
603
435
|
end
|
|
604
436
|
|
package/src/eval/rb/runner.rb
CHANGED
|
@@ -129,10 +129,122 @@ def __omp_emit_status(op, data = {})
|
|
|
129
129
|
__omp_emit_display({ "application/x-omp-status" => status }, "display")
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
OMP_IMAGE_MIMES = %w[image/png image/jpeg].freeze
|
|
133
|
+
|
|
134
|
+
# True when `str` already looks like base64 text (ASCII, base64 alphabet, length
|
|
135
|
+
# a multiple of 4). Raw image blobs (PNG/JPEG bytes) contain high bytes, so they
|
|
136
|
+
# fail the ASCII check and get encoded instead of passed through unchanged.
|
|
137
|
+
def __omp_base64?(str)
|
|
138
|
+
s = str.to_s
|
|
139
|
+
# ascii_only? is safe on any encoding (no regex over invalid bytes). Raw image
|
|
140
|
+
# blobs carry high bytes and fail here, so they get encoded rather than scanned.
|
|
141
|
+
return false unless s.ascii_only?
|
|
142
|
+
stripped = s.gsub(/\s+/, "")
|
|
143
|
+
return false if stripped.empty? || (stripped.bytesize % 4) != 0
|
|
144
|
+
stripped.match?(%r{\A[A-Za-z0-9+/]*={0,2}\z})
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Coerce an image payload to the base64 ASCII the host renders. IRuby-style
|
|
148
|
+
# `to_iruby` hands back raw binary blobs (Gruff#to_blob, ChunkyPNG, RMagick),
|
|
149
|
+
# which would also break JSON.generate; strict-encode them unless already base64.
|
|
150
|
+
def __omp_image_payload(content)
|
|
151
|
+
require "base64"
|
|
152
|
+
s = content.to_s
|
|
153
|
+
return s.gsub(/\s+/, "") if __omp_base64?(s)
|
|
154
|
+
Base64.strict_encode64(s.b)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Detect a host-renderable image MIME from a binary blob's magic bytes. Lets us
|
|
158
|
+
# treat the generic `to_blob` (Gruff/RMagick/ChunkyPNG/Vips) as an image only
|
|
159
|
+
# when it really is one, avoiding false positives on unrelated `to_blob` methods.
|
|
160
|
+
def __omp_sniff_image_mime(bytes)
|
|
161
|
+
b = bytes.to_s.b
|
|
162
|
+
return "image/png" if b.start_with?("\x89PNG\r\n\x1a\n".b)
|
|
163
|
+
return "image/jpeg" if b.start_with?("\xFF\xD8\xFF".b)
|
|
164
|
+
nil
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Stringify keys, base64-encode image payloads, and scrub text payloads so the
|
|
168
|
+
# bundle is always JSON-safe before it reaches __omp_emit.
|
|
169
|
+
def __omp_normalize_bundle(hash)
|
|
170
|
+
bundle = {}
|
|
171
|
+
hash.each do |key, val|
|
|
172
|
+
k = key.to_s
|
|
173
|
+
bundle[k] =
|
|
174
|
+
if OMP_IMAGE_MIMES.include?(k)
|
|
175
|
+
__omp_image_payload(val)
|
|
176
|
+
elsif val.is_a?(String)
|
|
177
|
+
__omp_scrub(val)
|
|
178
|
+
else
|
|
179
|
+
val
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
bundle
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Guarantee a text/plain entry so the model always sees a textual hint, even for
|
|
186
|
+
# image-only bundles (mirrors the Python runner).
|
|
187
|
+
def __omp_finalize_bundle(bundle, value)
|
|
188
|
+
bundle["text/plain"] ||= __omp_scrub((value.inspect rescue value.class.name))
|
|
189
|
+
bundle
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Rich-display resolution for non-collection objects. Honors the repo
|
|
193
|
+
# `to_omp_mime` convention first, then the IRuby protocol
|
|
194
|
+
# (`to_iruby_mimebundle` -> [data, metadata], `to_iruby` -> [mime, data]) so plot
|
|
195
|
+
# and image objects (gruff, rubyplot, gnuplotrb, chunky_png, daru, ...) render
|
|
196
|
+
# inline — the Ruby analog of IPython's _repr_*_ methods. Returns nil when the
|
|
197
|
+
# value advertises no rich representation.
|
|
198
|
+
def __omp_rich_mime_bundle(value)
|
|
199
|
+
if value.respond_to?(:to_omp_mime)
|
|
200
|
+
mime = (value.to_omp_mime rescue nil)
|
|
201
|
+
return __omp_finalize_bundle(__omp_normalize_bundle(mime), value) if mime.is_a?(Hash) && !mime.empty?
|
|
202
|
+
end
|
|
203
|
+
if value.respond_to?(:to_iruby_mimebundle)
|
|
204
|
+
data =
|
|
205
|
+
begin
|
|
206
|
+
value.to_iruby_mimebundle
|
|
207
|
+
rescue ArgumentError
|
|
208
|
+
(value.to_iruby_mimebundle(include: []) rescue nil)
|
|
209
|
+
rescue StandardError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
data = data.first if data.is_a?(Array)
|
|
213
|
+
return __omp_finalize_bundle(__omp_normalize_bundle(data), value) if data.is_a?(Hash) && !data.empty?
|
|
214
|
+
end
|
|
215
|
+
if value.respond_to?(:to_iruby)
|
|
216
|
+
pair = (value.to_iruby rescue nil)
|
|
217
|
+
if pair.is_a?(Array) && pair.size == 2 && !pair[0].nil?
|
|
218
|
+
return __omp_finalize_bundle(__omp_normalize_bundle({ pair[0].to_s => pair[1] }), value)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
# Last resort: probe well-known image emitters. Named methods (to_png/to_jpeg)
|
|
222
|
+
# are trusted; the generic to_blob is accepted only when its bytes sniff as an
|
|
223
|
+
# image. Covers gems that render via IRuby's registry rather than to_iruby
|
|
224
|
+
# (Gruff#to_blob, ChunkyPNG#to_blob, RMagick, Vips, ...).
|
|
225
|
+
if value.respond_to?(:to_png)
|
|
226
|
+
png = (value.to_png rescue nil)
|
|
227
|
+
return __omp_finalize_bundle({ "image/png" => __omp_image_payload(png) }, value) if png
|
|
228
|
+
end
|
|
229
|
+
jpeg_method = %i[to_jpeg to_jpg].find { |m| value.respond_to?(m) }
|
|
230
|
+
if jpeg_method
|
|
231
|
+
jpg = (value.public_send(jpeg_method) rescue nil)
|
|
232
|
+
return __omp_finalize_bundle({ "image/jpeg" => __omp_image_payload(jpg) }, value) if jpg
|
|
233
|
+
end
|
|
234
|
+
if value.respond_to?(:to_blob)
|
|
235
|
+
blob = (value.to_blob rescue nil)
|
|
236
|
+
if blob.is_a?(String) && (mime = __omp_sniff_image_mime(blob))
|
|
237
|
+
return __omp_finalize_bundle({ mime => __omp_image_payload(blob) }, value)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
nil
|
|
242
|
+
end
|
|
243
|
+
|
|
132
244
|
# Build a Jupyter-style MIME bundle for a value. Strings render as plain text,
|
|
133
|
-
# Hash/Array render as JSON (plus a text/plain repr) so the model sees structure
|
|
134
|
-
#
|
|
135
|
-
#
|
|
245
|
+
# Hash/Array render as JSON (plus a text/plain repr) so the model sees structure.
|
|
246
|
+
# Other objects may expose a rich representation via `to_omp_mime` or the IRuby
|
|
247
|
+
# protocol (`to_iruby`/`to_iruby_mimebundle`); otherwise they fall back to inspect.
|
|
136
248
|
def __omp_mime_bundle(value)
|
|
137
249
|
case value
|
|
138
250
|
when String
|
|
@@ -151,12 +263,7 @@ def __omp_mime_bundle(value)
|
|
|
151
263
|
when nil
|
|
152
264
|
{ "text/plain" => "nil" }
|
|
153
265
|
else
|
|
154
|
-
|
|
155
|
-
mime = (value.to_omp_mime rescue nil)
|
|
156
|
-
mime.is_a?(Hash) ? mime : { "text/plain" => __omp_scrub(value.inspect) }
|
|
157
|
-
else
|
|
158
|
-
{ "text/plain" => __omp_scrub(value.inspect) }
|
|
159
|
-
end
|
|
266
|
+
__omp_rich_mime_bundle(value) || { "text/plain" => __omp_scrub(value.inspect) }
|
|
160
267
|
end
|
|
161
268
|
end
|
|
162
269
|
|
|
@@ -59,6 +59,42 @@ const shellSessionQuarantines = new Map<string, Promise<unknown>>();
|
|
|
59
59
|
/** Session keys with a command currently in flight on the persistent Shell. */
|
|
60
60
|
const shellSessionsInUse = new Set<string>();
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Shells retained past their turn because a background (`nohup`/`&`) job is
|
|
64
|
+
* still running. A per-call `:async:` Shell is normally dropped at teardown,
|
|
65
|
+
* which SIGKILLs its children via kill-on-drop. Keeping the reference alive lets
|
|
66
|
+
* the process survive across turns; the Shell is dropped once its last
|
|
67
|
+
* background job exits (reaped by the poll loop below). Children stay
|
|
68
|
+
* kill-on-drop, so they still die when the harness tears the Shell down on exit.
|
|
69
|
+
*/
|
|
70
|
+
const retainedShells = new Set<Shell>();
|
|
71
|
+
const RETAIN_REAP_INTERVAL_MS = 5_000;
|
|
72
|
+
|
|
73
|
+
async function retainShellWithLiveBackgroundJobs(shell: Shell): Promise<void> {
|
|
74
|
+
let live: number;
|
|
75
|
+
try {
|
|
76
|
+
live = await shell.liveBackgroundJobCount();
|
|
77
|
+
} catch {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (live <= 0) return;
|
|
81
|
+
retainedShells.add(shell);
|
|
82
|
+
const interval = setInterval(() => {
|
|
83
|
+
void shell
|
|
84
|
+
.liveBackgroundJobCount()
|
|
85
|
+
.then(remaining => {
|
|
86
|
+
if (remaining > 0) return;
|
|
87
|
+
clearInterval(interval);
|
|
88
|
+
retainedShells.delete(shell);
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
clearInterval(interval);
|
|
92
|
+
retainedShells.delete(shell);
|
|
93
|
+
});
|
|
94
|
+
}, RETAIN_REAP_INTERVAL_MS);
|
|
95
|
+
interval.unref?.();
|
|
96
|
+
}
|
|
97
|
+
|
|
62
98
|
function quarantineShellSession(
|
|
63
99
|
sessionKey: string,
|
|
64
100
|
runPromise: Promise<ShellRunResult>,
|
|
@@ -411,6 +447,14 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
411
447
|
// `:async:` keys are per-job (jobId is unique), so the Shell would
|
|
412
448
|
// otherwise stay in the process-global map forever after completion.
|
|
413
449
|
shellSessions.delete(sessionKey);
|
|
450
|
+
// Dropping the only reference to a per-call `:async:` Shell SIGKILLs
|
|
451
|
+
// any `nohup`/`&` children (kill-on-drop). If the command left a live
|
|
452
|
+
// background job, retain the Shell so the process survives across
|
|
453
|
+
// turns; it is reaped once its last job exits and still dies with the
|
|
454
|
+
// harness. Skip on resetSession (cancel/error) — those tear down.
|
|
455
|
+
if (!resetSession && shellSession) {
|
|
456
|
+
await retainShellWithLiveBackgroundJobs(shellSession);
|
|
457
|
+
}
|
|
414
458
|
}
|
|
415
459
|
}
|
|
416
460
|
}
|