@oh-my-pi/pi-coding-agent 16.1.13 → 16.1.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 (129) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/cli.js +5547 -2759
  3. package/dist/types/config/settings-schema.d.ts +44 -14
  4. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +1 -0
  5. package/dist/types/eval/backend-helpers.d.ts +23 -0
  6. package/dist/types/eval/executor-base.d.ts +118 -0
  7. package/dist/types/eval/index.d.ts +2 -0
  8. package/dist/types/eval/jl/executor.d.ts +44 -0
  9. package/dist/types/eval/jl/index.d.ts +11 -0
  10. package/dist/types/eval/jl/kernel.d.ts +28 -0
  11. package/dist/types/eval/jl/prelude.d.ts +1 -0
  12. package/dist/types/eval/jl/runtime.d.ts +22 -0
  13. package/dist/types/eval/kernel-base.d.ts +105 -0
  14. package/dist/types/eval/py/kernel.d.ts +3 -61
  15. package/dist/types/eval/rb/executor.d.ts +77 -0
  16. package/dist/types/eval/rb/index.d.ts +11 -0
  17. package/dist/types/eval/rb/kernel.d.ts +31 -0
  18. package/dist/types/eval/rb/prelude.d.ts +1 -0
  19. package/dist/types/eval/rb/runtime.d.ts +23 -0
  20. package/dist/types/eval/runtime-env.d.ts +24 -0
  21. package/dist/types/eval/types.d.ts +3 -3
  22. package/dist/types/extensibility/extensions/runner.d.ts +2 -15
  23. package/dist/types/extensibility/hooks/loader.d.ts +1 -25
  24. package/dist/types/extensibility/session-handler-types.d.ts +23 -0
  25. package/dist/types/jsonrpc/message-framing.d.ts +35 -0
  26. package/dist/types/mnemopi/embed-client.d.ts +7 -24
  27. package/dist/types/modes/components/chat-transcript-builder.d.ts +1 -1
  28. package/dist/types/modes/components/custom-editor.d.ts +11 -0
  29. package/dist/types/modes/components/selector-helpers.d.ts +53 -0
  30. package/dist/types/modes/interactive-mode.d.ts +0 -2
  31. package/dist/types/modes/theme/theme.d.ts +8 -1
  32. package/dist/types/modes/types.d.ts +0 -2
  33. package/dist/types/modes/utils/copy-targets.d.ts +1 -1
  34. package/dist/types/modes/utils/interactive-context-helpers.d.ts +14 -0
  35. package/dist/types/modes/utils/transcript-render-helpers.d.ts +54 -0
  36. package/dist/types/sdk.d.ts +1 -1
  37. package/dist/types/session/agent-session.d.ts +2 -2
  38. package/dist/types/stt/asr-client.d.ts +3 -29
  39. package/dist/types/subprocess/worker-client.d.ts +149 -0
  40. package/dist/types/subprocess/worker-runtime.d.ts +107 -0
  41. package/dist/types/tiny/title-client.d.ts +14 -34
  42. package/dist/types/tools/eval-backends.d.ts +6 -3
  43. package/dist/types/tools/eval-render.d.ts +6 -5
  44. package/dist/types/tools/eval.d.ts +13 -15
  45. package/dist/types/tools/index.d.ts +3 -3
  46. package/dist/types/tts/tts-client.d.ts +3 -28
  47. package/dist/types/tui/code-cell.d.ts +7 -0
  48. package/dist/types/utils/file-display-mode.d.ts +1 -1
  49. package/dist/types/web/parallel.d.ts +6 -0
  50. package/package.json +12 -12
  51. package/src/config/settings-schema.ts +50 -18
  52. package/src/config/settings.ts +5 -0
  53. package/src/dap/client.ts +13 -107
  54. package/src/eval/__tests__/julia-prelude.test.ts +77 -0
  55. package/src/eval/backend-helpers.ts +48 -0
  56. package/src/eval/executor-base.ts +425 -0
  57. package/src/eval/index.ts +2 -0
  58. package/src/eval/jl/executor.ts +540 -0
  59. package/src/eval/jl/index.ts +54 -0
  60. package/src/eval/jl/kernel.ts +235 -0
  61. package/src/eval/jl/prelude.jl +930 -0
  62. package/src/eval/jl/prelude.ts +3 -0
  63. package/src/eval/jl/runner.jl +634 -0
  64. package/src/eval/jl/runtime.ts +118 -0
  65. package/src/eval/js/index.ts +3 -14
  66. package/src/eval/kernel-base.ts +569 -0
  67. package/src/eval/py/executor.ts +43 -252
  68. package/src/eval/py/index.ts +9 -20
  69. package/src/eval/py/kernel.ts +29 -544
  70. package/src/eval/rb/executor.ts +504 -0
  71. package/src/eval/rb/index.ts +54 -0
  72. package/src/eval/rb/kernel.ts +230 -0
  73. package/src/eval/rb/prelude.rb +721 -0
  74. package/src/eval/rb/prelude.ts +3 -0
  75. package/src/eval/rb/runner.rb +474 -0
  76. package/src/eval/rb/runtime.ts +132 -0
  77. package/src/eval/runtime-env.ts +104 -0
  78. package/src/eval/types.ts +3 -3
  79. package/src/extensibility/extensions/runner.ts +4 -11
  80. package/src/extensibility/hooks/loader.ts +3 -21
  81. package/src/extensibility/session-handler-types.ts +21 -0
  82. package/src/internal-urls/docs-index.generated.txt +1 -1
  83. package/src/jsonrpc/message-framing.ts +142 -0
  84. package/src/lsp/client.ts +13 -109
  85. package/src/mnemopi/embed-client.ts +43 -198
  86. package/src/modes/components/agent-dashboard.ts +17 -40
  87. package/src/modes/components/chat-transcript-builder.ts +18 -102
  88. package/src/modes/components/custom-editor.ts +19 -0
  89. package/src/modes/components/extensions/extension-dashboard.ts +4 -13
  90. package/src/modes/components/extensions/extension-list.ts +16 -44
  91. package/src/modes/components/history-search.ts +4 -16
  92. package/src/modes/components/selector-helpers.ts +129 -0
  93. package/src/modes/components/settings-selector.ts +7 -5
  94. package/src/modes/components/tree-selector.ts +13 -18
  95. package/src/modes/controllers/event-controller.ts +3 -9
  96. package/src/modes/controllers/input-controller.ts +32 -54
  97. package/src/modes/interactive-mode.ts +5 -7
  98. package/src/modes/theme/theme.ts +35 -0
  99. package/src/modes/types.ts +0 -2
  100. package/src/modes/utils/copy-targets.ts +3 -2
  101. package/src/modes/utils/interactive-context-helpers.ts +27 -0
  102. package/src/modes/utils/transcript-render-helpers.ts +157 -0
  103. package/src/modes/utils/ui-helpers.ts +21 -126
  104. package/src/prompts/system/system-prompt.md +10 -10
  105. package/src/prompts/tools/bash.md +0 -1
  106. package/src/prompts/tools/eval.md +6 -4
  107. package/src/prompts/tools/find.md +0 -4
  108. package/src/prompts/tools/read.md +1 -2
  109. package/src/prompts/tools/replace.md +1 -1
  110. package/src/prompts/tools/search.md +0 -1
  111. package/src/sdk.ts +13 -7
  112. package/src/session/agent-session.ts +9 -7
  113. package/src/stt/asr-client.ts +35 -215
  114. package/src/stt/asr-worker.ts +29 -181
  115. package/src/subprocess/worker-client.ts +297 -0
  116. package/src/subprocess/worker-runtime.ts +277 -0
  117. package/src/task/executor.ts +4 -4
  118. package/src/tiny/title-client.ts +53 -219
  119. package/src/tiny/worker.ts +29 -180
  120. package/src/tools/eval-backends.ts +10 -3
  121. package/src/tools/eval-render.ts +17 -8
  122. package/src/tools/eval.ts +187 -22
  123. package/src/tools/index.ts +51 -28
  124. package/src/tts/tts-client.ts +38 -206
  125. package/src/tts/tts-worker.ts +23 -97
  126. package/src/tui/code-cell.ts +12 -1
  127. package/src/utils/file-display-mode.ts +2 -3
  128. package/src/web/parallel.ts +43 -42
  129. package/src/web/search/providers/parallel.ts +10 -99
@@ -0,0 +1,721 @@
1
+ # frozen_string_literal: false
2
+ # OMP Ruby prelude helpers (loaded once into the runner's TOPLEVEL_BINDING).
3
+ #
4
+ # Mirrors eval/py/prelude.py: defines the cross-runtime helper surface
5
+ # (display/read/write/append/tree/diff/env/output, the `tool` bridge proxy,
6
+ # completion/agent/parallel/pipeline/log/phase/budget). Host-side helpers reach
7
+ # the coding-agent over the same loopback HTTP tool bridge the Python prelude
8
+ # uses (PI_TOOL_BRIDGE_URL/TOKEN/SESSION). Path helpers honor PI_EVAL_LOCAL_ROOTS
9
+ # so `write("local://x")` lands where `read local://x` resolves.
10
+ #
11
+ # `__omp_*` primitives (emit/present/status/scrub/run_id) are provided by
12
+ # runner.rb; this file only consumes them.
13
+
14
+ unless defined?($__omp_prelude_loaded) && $__omp_prelude_loaded
15
+ $__omp_prelude_loaded = true
16
+
17
+ require "json"
18
+
19
+ # -------------------------------------------------------------------------
20
+ # Internal-URL path resolution
21
+ # -------------------------------------------------------------------------
22
+
23
+ def __omp_url_decode(str)
24
+ str.gsub(/%([0-9A-Fa-f]{2})/) { [Regexp.last_match(1)].pack("H2") }.force_encoding(Encoding::UTF_8)
25
+ end
26
+
27
+ # Map a helper path to a real filesystem path. A `scheme://…` whose scheme has
28
+ # an injected on-disk root (PI_EVAL_LOCAL_ROOTS, e.g. `local://`) is rewritten
29
+ # under that root; plain paths pass through; any other `scheme://` is rejected.
30
+ def __omp_resolve_path(path)
31
+ return path unless path.is_a?(String)
32
+ m = path.match(%r{\A([a-z][a-z0-9+.\-]*)://(.*)\z}i)
33
+ return path unless m
34
+ scheme = m[1].downcase
35
+ roots =
36
+ begin
37
+ raw = ENV["PI_EVAL_LOCAL_ROOTS"]
38
+ raw && !raw.empty? ? JSON.parse(raw) : {}
39
+ rescue StandardError
40
+ {}
41
+ end
42
+ root = roots.is_a?(Hash) ? roots[scheme] : nil
43
+ raise "Protocol paths are not supported by this helper: #{path}" if root.nil? || root.to_s.empty?
44
+ relative = __omp_url_decode(m[2].tr("\\", "/"))
45
+ root_path = File.absolute_path(root.to_s)
46
+ return root_path if relative.empty?
47
+ if relative.start_with?("/") || relative.split("/").include?("..")
48
+ raise "Unsafe #{scheme}:// path (absolute or traversal): #{path}"
49
+ end
50
+ resolved = File.absolute_path(File.join(root_path, relative))
51
+ unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
52
+ raise "#{scheme}:// path escapes its root: #{path}"
53
+ end
54
+ resolved
55
+ end
56
+
57
+ # -------------------------------------------------------------------------
58
+ # Display + status
59
+ # -------------------------------------------------------------------------
60
+
61
+ def display(value)
62
+ __omp_present(value, "display")
63
+ nil
64
+ end
65
+
66
+ # Emit a base64 image as a display output. `mime_type` is "image/png" (default)
67
+ # or "image/jpeg"; the host surfaces it as an inspectable image block.
68
+ def display_image(base64, mime_type: "image/png")
69
+ __omp_emit_display({ mime_type.to_s => base64.to_s })
70
+ nil
71
+ end
72
+
73
+ def env(key = nil, value = nil)
74
+ if key.nil?
75
+ items = ENV.to_h.sort.to_h
76
+ __omp_emit_status("env", "count" => items.size, "keys" => items.keys.first(20))
77
+ return items
78
+ end
79
+ unless value.nil?
80
+ ENV[key.to_s] = value.to_s
81
+ __omp_emit_status("env", "key" => key.to_s, "value" => value.to_s, "action" => "set")
82
+ return value
83
+ end
84
+ val = ENV[key.to_s]
85
+ __omp_emit_status("env", "key" => key.to_s, "value" => val, "action" => "get")
86
+ val
87
+ end
88
+
89
+ # -------------------------------------------------------------------------
90
+ # File helpers
91
+ # -------------------------------------------------------------------------
92
+
93
+ def read(path, offset = 1, limit = nil)
94
+ resolved = __omp_resolve_path(path)
95
+ data = File.read(resolved.to_s, encoding: Encoding::UTF_8)
96
+ if offset > 1 || !limit.nil?
97
+ lines = data.lines
98
+ start = [offset - 1, 0].max
99
+ finish = limit ? start + limit : lines.length
100
+ data = lines[start...finish].to_a.join
101
+ end
102
+ __omp_emit_status("read", "path" => resolved.to_s, "chars" => data.length, "preview" => __omp_scrub(data[0, 500].to_s))
103
+ data
104
+ end
105
+
106
+ def write(path, content)
107
+ resolved = __omp_resolve_path(path)
108
+ require "fileutils"
109
+ FileUtils.mkdir_p(File.dirname(resolved.to_s))
110
+ File.write(resolved.to_s, content.to_s)
111
+ __omp_emit_status("write", "path" => resolved.to_s, "chars" => content.to_s.length)
112
+ resolved.to_s
113
+ end
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
+ # -------------------------------------------------------------------------
301
+ # Task/agent output reader
302
+ # -------------------------------------------------------------------------
303
+
304
+ def __omp_apply_query(data, query)
305
+ return data if query.nil? || query.empty?
306
+ q = query.strip
307
+ q = q[1..] if q.start_with?(".")
308
+ return data if q.empty?
309
+ tokens = []
310
+ buf = +""
311
+ i = 0
312
+ while i < q.length
313
+ ch = q[i]
314
+ if ch == "."
315
+ unless buf.empty?
316
+ tokens << [:key, buf]
317
+ buf = +""
318
+ end
319
+ elsif ch == "["
320
+ unless buf.empty?
321
+ tokens << [:key, buf]
322
+ buf = +""
323
+ end
324
+ j = i + 1
325
+ j += 1 while j < q.length && q[j] != "]"
326
+ inner = q[(i + 1)...j]
327
+ if inner.start_with?('"') && inner.end_with?('"')
328
+ tokens << [:key, inner[1..-2]]
329
+ else
330
+ tokens << [:index, inner.to_i]
331
+ end
332
+ i = j
333
+ else
334
+ buf << ch
335
+ end
336
+ i += 1
337
+ end
338
+ tokens << [:key, buf] unless buf.empty?
339
+
340
+ current = data
341
+ tokens.each do |kind, value|
342
+ if kind == :index
343
+ return nil unless current.is_a?(Array) && value < current.length
344
+ current = current[value]
345
+ else
346
+ return nil unless current.is_a?(Hash) && current.key?(value)
347
+ current = current[value]
348
+ end
349
+ end
350
+ current
351
+ end
352
+
353
+ def output(*ids, format: "raw", query: nil, offset: nil, limit: nil)
354
+ artifacts_dir = ENV["PI_ARTIFACTS_DIR"]
355
+ if artifacts_dir.nil? || artifacts_dir.empty?
356
+ session_file = ENV["PI_SESSION_FILE"]
357
+ if session_file.nil? || session_file.empty?
358
+ __omp_emit_status("output", "error" => "No session file available")
359
+ raise "No session - output artifacts unavailable"
360
+ end
361
+ artifacts_dir = session_file.sub(/\.[^.]*\z/, "")
362
+ end
363
+ unless File.directory?(artifacts_dir)
364
+ __omp_emit_status("output", "error" => "Artifacts directory not found", "path" => artifacts_dir)
365
+ raise "No artifacts directory found: #{artifacts_dir}"
366
+ end
367
+ raise ArgumentError, "At least one output ID is required" if ids.empty?
368
+ if query && (!offset.nil? || !limit.nil?)
369
+ __omp_emit_status("output", "error" => "query cannot be combined with offset/limit")
370
+ raise ArgumentError, "query cannot be combined with offset/limit"
371
+ end
372
+
373
+ results = []
374
+ not_found = []
375
+ ids.each do |output_id|
376
+ path = File.join(artifacts_dir, "#{output_id}.md")
377
+ unless File.exist?(path)
378
+ not_found << output_id
379
+ next
380
+ end
381
+ raw = File.read(path, encoding: Encoding::UTF_8)
382
+ raw_lines = raw.split("\n", -1)
383
+ total_lines = raw_lines.length
384
+ selected = raw
385
+ range_info = nil
386
+
387
+ if query
388
+ json_value =
389
+ begin
390
+ JSON.parse(raw)
391
+ rescue JSON::ParserError => e
392
+ __omp_emit_status("output", "id" => output_id, "error" => "Not valid JSON: #{e.message}")
393
+ raise "Output #{output_id} is not valid JSON: #{e.message}"
394
+ end
395
+ result_value = __omp_apply_query(json_value, query)
396
+ selected =
397
+ begin
398
+ result_value.nil? ? "null" : JSON.pretty_generate(result_value)
399
+ rescue StandardError
400
+ result_value.to_s
401
+ end
402
+ elsif !offset.nil? || !limit.nil?
403
+ start_line = [offset || 1, 1].max
404
+ if start_line > total_lines
405
+ __omp_emit_status("output", "id" => output_id, "error" => "Offset #{start_line} beyond end (#{total_lines} lines)")
406
+ raise "Offset #{start_line} is beyond end of output (#{total_lines} lines) for #{output_id}"
407
+ end
408
+ effective_limit = limit || (total_lines - start_line + 1)
409
+ end_line = [total_lines, start_line + effective_limit - 1].min
410
+ selected = raw_lines[(start_line - 1)...end_line].join("\n")
411
+ range_info = { "start_line" => start_line, "end_line" => end_line, "total_lines" => total_lines }
412
+ end
413
+
414
+ selected = selected.gsub(/\e\[[0-9;]*m/, "") if format == "stripped"
415
+
416
+ if format == "json"
417
+ entry = {
418
+ "id" => output_id,
419
+ "path" => path,
420
+ "line_count" => query ? selected.split("\n").length : total_lines,
421
+ "char_count" => query ? selected.length : raw.length,
422
+ "content" => selected,
423
+ }
424
+ entry["range"] = range_info if range_info
425
+ entry["query"] = query if query
426
+ results << entry
427
+ else
428
+ results << { "id" => output_id, "content" => selected }
429
+ end
430
+ end
431
+
432
+ unless not_found.empty?
433
+ available = Dir.glob(File.join(artifacts_dir, "*.md")).map { |f| File.basename(f, ".md") }.sort
434
+ msg = "Output not found: #{not_found.join(", ")}"
435
+ unless available.empty?
436
+ msg += "\n\nAvailable outputs: #{available.first(20).join(", ")}"
437
+ msg += " (and #{available.length - 20} more)" if available.length > 20
438
+ end
439
+ __omp_emit_status("output", "not_found" => not_found, "available_count" => available.length)
440
+ raise msg
441
+ end
442
+
443
+ if ids.length == 1
444
+ if format == "json"
445
+ __omp_emit_status("output", "id" => ids[0], "chars" => results[0]["char_count"])
446
+ return results[0]
447
+ end
448
+ __omp_emit_status("output", "id" => ids[0], "chars" => results[0]["content"].length)
449
+ return results[0]["content"]
450
+ end
451
+
452
+ if format == "json"
453
+ __omp_emit_status("output", "count" => results.length, "total_chars" => results.sum { |r| r["char_count"] })
454
+ return results
455
+ end
456
+ combined = results.map { |r| { "id" => r["id"], "content" => r["content"] } }
457
+ __omp_emit_status("output", "count" => combined.length, "total_chars" => combined.sum { |r| r["content"].length })
458
+ combined
459
+ end
460
+
461
+ # -------------------------------------------------------------------------
462
+ # Host tool bridge (loopback HTTP) — `tool.<name>(args)`, completion, agent.
463
+ # -------------------------------------------------------------------------
464
+
465
+ module OmpBridge
466
+ INTENT_FIELD = "i"
467
+
468
+ module_function
469
+
470
+ def proxy_env
471
+ base = ENV["PI_TOOL_BRIDGE_URL"]
472
+ token = ENV["PI_TOOL_BRIDGE_TOKEN"]
473
+ session = ENV["PI_TOOL_BRIDGE_SESSION"]
474
+ if base.nil? || base.empty? || token.nil? || token.empty? || session.nil? || session.empty?
475
+ raise "tool bridge is unavailable in this kernel"
476
+ end
477
+ [base.sub(%r{/+\z}, ""), token, session]
478
+ end
479
+
480
+ def call(name, args)
481
+ require "net/http"
482
+ require "uri"
483
+ base, token, session = proxy_env
484
+ uri = URI("#{base}/v1/tool")
485
+ payload = JSON.generate("session" => session, "run" => $__omp_current_rid, "name" => name, "args" => args)
486
+ http = Net::HTTP.new(uri.hostname, uri.port)
487
+ http.open_timeout = 10
488
+ http.read_timeout = 7 * 24 * 3600
489
+ req = Net::HTTP::Post.new(uri)
490
+ req["Content-Type"] = "application/json"
491
+ req["Authorization"] = "Bearer #{token}"
492
+ req.body = payload
493
+ resp = http.request(req)
494
+ data =
495
+ begin
496
+ JSON.parse(resp.body.to_s)
497
+ rescue JSON::ParserError
498
+ raise "bridge call #{name.inspect}: non-JSON response: #{resp.body.to_s[0, 200].inspect}"
499
+ end
500
+ unless data.is_a?(Hash) && data["ok"]
501
+ raise((data.is_a?(Hash) ? data["error"] : nil) || "bridge call #{name.inspect} failed")
502
+ end
503
+ data["value"]
504
+ end
505
+
506
+ def stringify_keys(hash)
507
+ out = {}
508
+ hash.each { |k, v| out[k.to_s] = v }
509
+ out
510
+ end
511
+
512
+ def tool_call(name, positional, kwargs)
513
+ merged =
514
+ if positional.nil?
515
+ {}
516
+ elsif positional.is_a?(Hash)
517
+ stringify_keys(positional)
518
+ else
519
+ raise ArgumentError, "tool.#{name}(...) expects a Hash of arguments (got #{positional.class})"
520
+ end
521
+ merged.merge!(stringify_keys(kwargs)) if kwargs && !kwargs.empty?
522
+ merged[INTENT_FIELD] = "rb prelude" unless merged.key?(INTENT_FIELD)
523
+ call(name, merged)
524
+ end
525
+ end
526
+
527
+ # `tool[:name]` form — a reusable one-tool callable.
528
+ class OmpToolCallable
529
+ def initialize(name)
530
+ @name = name
531
+ end
532
+
533
+ def call(args = nil, **kwargs)
534
+ OmpBridge.tool_call(@name, args, kwargs)
535
+ end
536
+
537
+ def to_proc
538
+ method(:call).to_proc
539
+ end
540
+
541
+ def inspect
542
+ "#<tool.#{@name}>"
543
+ end
544
+ end
545
+
546
+ # `tool.<name>(args)` proxy. BasicObject so helper methods defined on Object
547
+ # (read/write/…) never shadow a tool name — every call routes to the bridge.
548
+ class OmpToolProxy < BasicObject
549
+ def method_missing(name, args = nil, **kwargs)
550
+ ::OmpBridge.tool_call(name.to_s, args, kwargs)
551
+ end
552
+
553
+ def [](name)
554
+ ::OmpToolCallable.new(name.to_s)
555
+ end
556
+
557
+ def respond_to_missing?(_name, _include_private = false)
558
+ true
559
+ end
560
+
561
+ def inspect
562
+ session = ::ENV["PI_TOOL_BRIDGE_SESSION"]
563
+ session ? "#<tool proxy session=#{session}>" : "#<tool proxy unavailable>"
564
+ end
565
+ end
566
+
567
+ def tool
568
+ $__omp_tool_proxy ||= OmpToolProxy.new
569
+ end
570
+
571
+ def completion(prompt, model: "default", system: nil, schema: nil)
572
+ args = { "prompt" => prompt, "model" => model }
573
+ args["system"] = system unless system.nil?
574
+ args["schema"] = schema unless schema.nil?
575
+ res = OmpBridge.call("__completion__", args)
576
+ text = res.is_a?(Hash) ? res["text"] : res
577
+ schema.nil? ? text : JSON.parse(text)
578
+ end
579
+
580
+ def agent(prompt, agent_type: "task", model: nil, label: nil, schema: nil, return_handle: false)
581
+ args = { "prompt" => prompt }
582
+ args["agentType"] = agent_type unless agent_type.nil?
583
+ args["model"] = model unless model.nil?
584
+ args["label"] = label unless label.nil?
585
+ args["schema"] = schema unless schema.nil?
586
+ res = OmpBridge.call("__agent__", args)
587
+ text = res.is_a?(Hash) ? res["text"] : res
588
+ parsed = schema.nil? ? text : JSON.parse(text)
589
+ return parsed unless return_handle
590
+ details = res.is_a?(Hash) ? res["details"] : nil
591
+ if !details.is_a?(Hash) || details["id"].nil?
592
+ return { "text" => text, "output" => text, "handle" => nil, "id" => nil, "agent" => nil }
593
+ end
594
+ node = {
595
+ "text" => text,
596
+ "output" => text,
597
+ "handle" => "agent://#{details["id"]}",
598
+ "id" => details["id"],
599
+ "agent" => details["agent"],
600
+ }
601
+ node["data"] = parsed unless schema.nil?
602
+ node
603
+ end
604
+
605
+ # -------------------------------------------------------------------------
606
+ # Concurrency: parallel / pipeline over a bounded pool (task.maxConcurrency).
607
+ # -------------------------------------------------------------------------
608
+
609
+ def __omp_concurrency_limit
610
+ snap = (OmpBridge.call("__concurrency__", {}) rescue nil) || {}
611
+ n = (snap["limit"] || 0).to_i
612
+ n > 0 ? n : 0
613
+ rescue StandardError
614
+ 0
615
+ end
616
+
617
+ def __omp_pool_map(items)
618
+ arr = items.to_a
619
+ return [] if arr.empty?
620
+ limit = __omp_concurrency_limit
621
+ workers = limit > 0 ? [limit, arr.length].min : arr.length
622
+ results = Array.new(arr.length)
623
+ errors = {}
624
+ emut = Mutex.new
625
+ queue = Queue.new
626
+ arr.each_index { |i| queue << i }
627
+ threads = workers.times.map do
628
+ Thread.new do
629
+ loop do
630
+ idx =
631
+ begin
632
+ queue.pop(true)
633
+ rescue ThreadError
634
+ break
635
+ end
636
+ begin
637
+ results[idx] = yield(arr[idx])
638
+ rescue Exception => e # rubocop:disable Lint/RescueException
639
+ emut.synchronize { errors[idx] = e }
640
+ end
641
+ end
642
+ end
643
+ end
644
+ begin
645
+ threads.each(&:join)
646
+ rescue Exception # rubocop:disable Lint/RescueException
647
+ threads.each { |t| (t.kill rescue nil) }
648
+ raise
649
+ end
650
+ raise errors[errors.keys.min] unless errors.empty?
651
+ results
652
+ end
653
+
654
+ def parallel(thunks)
655
+ list = thunks.to_a
656
+ list.each do |t|
657
+ raise TypeError, "parallel() expects an iterable of zero-arg callables" unless t.respond_to?(:call)
658
+ end
659
+ __omp_pool_map(list) { |t| t.call }
660
+ end
661
+
662
+ def pipeline(items, *stages)
663
+ current = items.to_a
664
+ stages.each do |stage|
665
+ raise TypeError, "pipeline() stages must be callables" unless stage.respond_to?(:call)
666
+ current = __omp_pool_map(current) { |item| stage.call(item) }
667
+ end
668
+ current
669
+ end
670
+
671
+ # -------------------------------------------------------------------------
672
+ # Progress + budget
673
+ # -------------------------------------------------------------------------
674
+
675
+ def log(message)
676
+ __omp_emit_status("log", "message" => message.to_s)
677
+ nil
678
+ end
679
+
680
+ def phase(title)
681
+ $__omp_current_phase = title.to_s
682
+ __omp_emit_status("phase", "title" => title.to_s)
683
+ nil
684
+ end
685
+
686
+ # Live view of the host Goal Mode token budget via the host bridge.
687
+ class OmpBudget
688
+ def total
689
+ snap = (OmpBridge.call("__budget__", {}) || {})
690
+ snap["total"]
691
+ end
692
+
693
+ def hard
694
+ snap = (OmpBridge.call("__budget__", {}) || {})
695
+ snap["hard"] ? true : false
696
+ end
697
+
698
+ def spent
699
+ snap = (OmpBridge.call("__budget__", {}) || {})
700
+ (snap["spent"] || 0).to_i
701
+ end
702
+
703
+ def remaining
704
+ snap = (OmpBridge.call("__budget__", {}) || {})
705
+ total = snap["total"]
706
+ return Float::INFINITY if total.nil?
707
+ [0, total - (snap["spent"] || 0).to_i].max
708
+ end
709
+
710
+ def inspect
711
+ snap = ((OmpBridge.call("__budget__", {}) rescue nil) || {})
712
+ "#<budget total=#{snap["total"].inspect} spent=#{snap["spent"].inspect}>"
713
+ rescue StandardError
714
+ "#<budget unavailable>"
715
+ end
716
+ end
717
+
718
+ def budget
719
+ $__omp_budget ||= OmpBudget.new
720
+ end
721
+ end