@code-yeongyu/senpi-codemode 2026.7.25-2
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 +250 -0
- package/LICENSE +22 -0
- package/README.md +161 -0
- package/package.json +58 -0
- package/src/bridge/http-server.ts +236 -0
- package/src/bridge/protocol.ts +198 -0
- package/src/bridge/reserved.ts +9 -0
- package/src/bridges/agent-bridge.ts +197 -0
- package/src/bridges/output-bridge.ts +96 -0
- package/src/bridges/schema-injection.ts +3 -0
- package/src/codemode/runtime.ts +258 -0
- package/src/codemode/tools.ts +106 -0
- package/src/completion/handler.ts +192 -0
- package/src/completion/tool-bridge.ts +55 -0
- package/src/config/settings.ts +215 -0
- package/src/extension/runtime-factory.ts +114 -0
- package/src/extension/session-manager-proxy.ts +116 -0
- package/src/extension/session-manager.ts +215 -0
- package/src/host-sdk.ts +1 -0
- package/src/index.ts +181 -0
- package/src/interpreters/detect.ts +161 -0
- package/src/kernels/jl/kernel.ts +37 -0
- package/src/kernels/jl/prelude.jl +283 -0
- package/src/kernels/jl/runner.jl +327 -0
- package/src/kernels/js/context-manager.ts +296 -0
- package/src/kernels/js/inline-worker-entry.js +23 -0
- package/src/kernels/js/inline-worker.ts +15 -0
- package/src/kernels/js/kernel-contract.ts +38 -0
- package/src/kernels/js/local-module-loader.ts +108 -0
- package/src/kernels/js/prelude.ts +15 -0
- package/src/kernels/js/rewrite-imports.ts +164 -0
- package/src/kernels/js/run-queue.ts +82 -0
- package/src/kernels/js/worker-core.d.ts +18 -0
- package/src/kernels/js/worker-core.js +94 -0
- package/src/kernels/js/worker-entry.js +23 -0
- package/src/kernels/js/worker-host.ts +117 -0
- package/src/kernels/js/worker-indirect-eval.js +88 -0
- package/src/kernels/js/worker-runtime.js +401 -0
- package/src/kernels/py/kernel-contract.ts +32 -0
- package/src/kernels/py/kernel.ts +290 -0
- package/src/kernels/py/prelude.py +954 -0
- package/src/kernels/py/process.ts +119 -0
- package/src/kernels/py/transport.ts +237 -0
- package/src/kernels/rb/kernel.ts +26 -0
- package/src/kernels/rb/prelude.rb +270 -0
- package/src/kernels/rb/runner.rb +204 -0
- package/src/kernels/shared/subprocess-contract.ts +22 -0
- package/src/kernels/shared/subprocess-kernel.ts +266 -0
- package/src/kernels/shared/subprocess-process.ts +174 -0
- package/src/kernels/shared/subprocess-queue.ts +101 -0
- package/src/kernels/shared/subprocess-run.ts +98 -0
- package/src/output/output-meta.ts +89 -0
- package/src/output/streaming-output.ts +296 -0
- package/src/prompt/eval-prompt.ts +319 -0
- package/src/timeouts/bridge-timeout.ts +16 -0
- package/src/timeouts/idle-timeout.ts +84 -0
- package/src/tool/cell-handler.ts +279 -0
- package/src/tool/eval-tool.ts +285 -0
- package/src/tool/image.ts +274 -0
- package/src/tool/json-tree.ts +247 -0
- package/src/tool/render.ts +876 -0
- package/src/tool/status-events.ts +12 -0
- package/src/tool/types.ts +114 -0
|
@@ -0,0 +1,954 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
# noqa: SIZE_OK — this dependency-free subprocess prelude must ship as one file.
|
|
4
|
+
import ast
|
|
5
|
+
import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
|
|
6
|
+
import base64
|
|
7
|
+
import codecs
|
|
8
|
+
import contextlib
|
|
9
|
+
import inspect
|
|
10
|
+
import io
|
|
11
|
+
import json
|
|
12
|
+
import locale
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
import traceback
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from collections.abc import Iterable
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from threading import Lock
|
|
26
|
+
from typing import Any, Callable
|
|
27
|
+
from urllib.parse import unquote
|
|
28
|
+
|
|
29
|
+
SESSION_ID = ""
|
|
30
|
+
CONNECTION: dict[str, Any] = {}
|
|
31
|
+
USER_NS: dict[str, Any] = {"__name__": "__main__", "__doc__": None, "__builtins__": __builtins__}
|
|
32
|
+
LOOP = asyncio.new_event_loop()
|
|
33
|
+
asyncio.set_event_loop(LOOP)
|
|
34
|
+
EMIT_LOCK = Lock()
|
|
35
|
+
|
|
36
|
+
# Mirrors src/bridge/reserved.ts; this standalone subprocess asset cannot import TypeScript.
|
|
37
|
+
RESERVED_AGENT_TOOL = "__agent__"
|
|
38
|
+
RESERVED_OUTPUT_TOOL = "__output__"
|
|
39
|
+
TIMEOUT_PAUSE_OP = "timeout-pause"
|
|
40
|
+
TIMEOUT_RESUME_OP = "timeout-resume"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class PreludeRuntimeError(RuntimeError):
|
|
44
|
+
"""Host bridge or magic execution failed."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class PreludeValueError(ValueError):
|
|
48
|
+
"""A helper received an invalid value."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PreludeTypeError(TypeError):
|
|
52
|
+
"""A helper received an invalid value type."""
|
|
53
|
+
|
|
54
|
+
_INTERNAL_URL_RE = re.compile(r"^([a-z][a-z0-9+.-]*)://(.*)$", re.IGNORECASE)
|
|
55
|
+
_ASSIGN_LINE_RE = re.compile(
|
|
56
|
+
r"^(?P<indent>[ \t]*)(?P<lhs>[A-Za-z_][A-Za-z_0-9.\[\], ]*?)\s*=\s*(?P<rhs>.+)$"
|
|
57
|
+
)
|
|
58
|
+
_SHELL_READ_CHUNK_BYTES = 8192
|
|
59
|
+
_SHELL_CAPTURE_MAX_BYTES = 1024 * 1024
|
|
60
|
+
_SHELL_CAPTURE_MAX_LINES = 3000
|
|
61
|
+
_SHELL_TRUNCATION_NOTICE = (
|
|
62
|
+
f"[output truncated: shell helper exceeded {_SHELL_CAPTURE_MAX_BYTES} bytes "
|
|
63
|
+
f"or {_SHELL_CAPTURE_MAX_LINES} lines; remaining output discarded]\n"
|
|
64
|
+
)
|
|
65
|
+
os.environ.setdefault("MPLBACKEND", "Agg")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def emit(frame: dict[str, Any]) -> None:
|
|
69
|
+
encoded = json.dumps(frame, ensure_ascii=False, default=repr) + "\n"
|
|
70
|
+
with EMIT_LOCK:
|
|
71
|
+
sys.__stdout__.write(encoded)
|
|
72
|
+
sys.__stdout__.flush()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def bridge_error(exc: BaseException) -> dict[str, str]:
|
|
76
|
+
return {
|
|
77
|
+
"name": type(exc).__name__,
|
|
78
|
+
"message": str(exc),
|
|
79
|
+
"stack": "".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def text(stream: str, data: str) -> None:
|
|
84
|
+
if data:
|
|
85
|
+
emit({"type": "text", "stream": stream, "data": data})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def b64_text(value: str) -> str:
|
|
89
|
+
return base64.b64encode(value.encode("utf-8")).decode("ascii")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _emit_display(mime_type: str, data: Any) -> None:
|
|
93
|
+
if isinstance(data, (bytes, bytearray)):
|
|
94
|
+
encoded = base64.b64encode(bytes(data)).decode("ascii")
|
|
95
|
+
elif mime_type.startswith("image/"):
|
|
96
|
+
if isinstance(data, str):
|
|
97
|
+
encoded = data
|
|
98
|
+
else:
|
|
99
|
+
encoded = base64.b64encode(repr(data).encode("utf-8")).decode("ascii")
|
|
100
|
+
elif mime_type == "application/json":
|
|
101
|
+
encoded = b64_text(json.dumps(data, ensure_ascii=False, default=repr))
|
|
102
|
+
else:
|
|
103
|
+
encoded = b64_text(str(data))
|
|
104
|
+
emit({"type": "display", "mimeType": mime_type, "dataBase64": encoded})
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _display_bundle(bundle: dict[str, Any]) -> bool:
|
|
108
|
+
for mime_type in (
|
|
109
|
+
"image/png",
|
|
110
|
+
"image/jpeg",
|
|
111
|
+
"application/json",
|
|
112
|
+
"text/markdown",
|
|
113
|
+
"text/html",
|
|
114
|
+
"image/svg+xml",
|
|
115
|
+
"text/latex",
|
|
116
|
+
"text/plain",
|
|
117
|
+
):
|
|
118
|
+
if mime_type in bundle:
|
|
119
|
+
_emit_display(mime_type, bundle[mime_type])
|
|
120
|
+
return True
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _is_matplotlib_figure(value: Any) -> bool:
|
|
125
|
+
figure_module = sys.modules.get("matplotlib.figure")
|
|
126
|
+
figure_class = getattr(figure_module, "Figure", None)
|
|
127
|
+
if isinstance(figure_class, type) and isinstance(value, figure_class):
|
|
128
|
+
return True
|
|
129
|
+
value_type = type(value)
|
|
130
|
+
return value_type.__module__ == "matplotlib.figure" and value_type.__name__ == "Figure"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _matplotlib_png(value: Any) -> bytes | None:
|
|
134
|
+
if not _is_matplotlib_figure(value):
|
|
135
|
+
return None
|
|
136
|
+
savefig = getattr(value, "savefig", None)
|
|
137
|
+
if not callable(savefig):
|
|
138
|
+
return None
|
|
139
|
+
try:
|
|
140
|
+
buffer = io.BytesIO()
|
|
141
|
+
savefig(buffer, format="png", bbox_inches="tight")
|
|
142
|
+
return buffer.getvalue()
|
|
143
|
+
except Exception: # noqa: BROAD_EXCEPT_OK — user-defined rendering hooks are isolated fallbacks.
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _rich_bundle(value: Any) -> dict[str, Any]:
|
|
148
|
+
bundle: dict[str, Any] = {}
|
|
149
|
+
mime_bundle = getattr(value, "_repr_mimebundle_", None)
|
|
150
|
+
if callable(mime_bundle):
|
|
151
|
+
try:
|
|
152
|
+
data = mime_bundle()
|
|
153
|
+
if isinstance(data, tuple):
|
|
154
|
+
data = data[0]
|
|
155
|
+
if isinstance(data, dict):
|
|
156
|
+
bundle.update({str(key): item for key, item in data.items()})
|
|
157
|
+
except Exception: # noqa: BROAD_EXCEPT_OK — a broken repr must fall through to the next representation.
|
|
158
|
+
bundle.clear()
|
|
159
|
+
|
|
160
|
+
for attribute, mime_type in (
|
|
161
|
+
("_repr_markdown_", "text/markdown"),
|
|
162
|
+
("_repr_png_", "image/png"),
|
|
163
|
+
("_repr_jpeg_", "image/jpeg"),
|
|
164
|
+
("_repr_html_", "text/html"),
|
|
165
|
+
("_repr_json_", "application/json"),
|
|
166
|
+
("_repr_svg_", "image/svg+xml"),
|
|
167
|
+
("_repr_latex_", "text/latex"),
|
|
168
|
+
):
|
|
169
|
+
if mime_type in bundle:
|
|
170
|
+
continue
|
|
171
|
+
representation = getattr(value, attribute, None)
|
|
172
|
+
if not callable(representation):
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
data = representation()
|
|
176
|
+
except Exception: # noqa: BROAD_EXCEPT_OK — a broken repr must fall through to the next representation.
|
|
177
|
+
continue
|
|
178
|
+
if data is not None:
|
|
179
|
+
bundle[mime_type] = data
|
|
180
|
+
|
|
181
|
+
if "image/png" not in bundle:
|
|
182
|
+
figure_png = _matplotlib_png(value)
|
|
183
|
+
if figure_png is not None:
|
|
184
|
+
bundle["image/png"] = figure_png
|
|
185
|
+
return bundle
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def display(value: Any) -> None:
|
|
189
|
+
if isinstance(value, (dict, list, tuple)):
|
|
190
|
+
_emit_display("application/json", value)
|
|
191
|
+
return
|
|
192
|
+
if isinstance(value, (bytes, bytearray)):
|
|
193
|
+
_emit_display("application/octet-stream", bytes(value))
|
|
194
|
+
return
|
|
195
|
+
bundle = _rich_bundle(value)
|
|
196
|
+
if bundle and _display_bundle(bundle):
|
|
197
|
+
return
|
|
198
|
+
_emit_display("text/plain", str(value))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _status_events_enabled() -> bool:
|
|
202
|
+
return CONNECTION.get("statusEvents", True) is not False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def emit_status(op: str, *, force: bool = False, **data: Any) -> None:
|
|
206
|
+
if force or _status_events_enabled():
|
|
207
|
+
emit({"type": "status", "event": {"op": op, **data}})
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def log(message: Any) -> None:
|
|
211
|
+
emit({"type": "log", "message": str(message)})
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def phase(title: Any) -> None:
|
|
215
|
+
emit({"type": "phase", "title": str(title)})
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def env(key: str | None = None, value: str | None = None) -> Any:
|
|
219
|
+
if key is None:
|
|
220
|
+
items = dict(sorted(os.environ.items()))
|
|
221
|
+
emit_status("env", count=len(items), keys=list(items.keys())[:20])
|
|
222
|
+
return items
|
|
223
|
+
if value is not None:
|
|
224
|
+
os.environ[key] = value
|
|
225
|
+
emit_status("env", key=key, value=value, action="set")
|
|
226
|
+
return value
|
|
227
|
+
resolved = os.environ.get(key)
|
|
228
|
+
emit_status("env", key=key, value=resolved, action="get")
|
|
229
|
+
return resolved
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _resolve_helper_path(path: str | Path) -> Path:
|
|
233
|
+
if not isinstance(path, str):
|
|
234
|
+
return Path(path)
|
|
235
|
+
match = _INTERNAL_URL_RE.match(path)
|
|
236
|
+
if not match:
|
|
237
|
+
return Path(path)
|
|
238
|
+
scheme = match.group(1).lower()
|
|
239
|
+
roots = CONNECTION.get("localRoots")
|
|
240
|
+
root = roots.get(scheme) if isinstance(roots, dict) else None
|
|
241
|
+
if not isinstance(root, str) or not root:
|
|
242
|
+
raise PreludeValueError(f"Protocol paths are not supported by this helper: {path}")
|
|
243
|
+
relative = unquote(match.group(2).replace("\\", "/"))
|
|
244
|
+
root_path = os.path.abspath(root)
|
|
245
|
+
if relative == "":
|
|
246
|
+
return Path(root_path)
|
|
247
|
+
relative_path = Path(relative)
|
|
248
|
+
if relative_path.is_absolute() or ".." in relative_path.parts:
|
|
249
|
+
raise PreludeValueError(f"Unsafe {scheme}:// path (absolute or traversal): {path}")
|
|
250
|
+
resolved = os.path.abspath(os.path.join(root_path, relative))
|
|
251
|
+
if resolved != root_path and not resolved.startswith(root_path + os.sep):
|
|
252
|
+
raise PreludeValueError(f"{scheme}:// path escapes its root: {path}")
|
|
253
|
+
return Path(resolved)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def read(path: str | Path, offset: int = 1, limit: int | None = None) -> str:
|
|
257
|
+
target = _resolve_helper_path(path)
|
|
258
|
+
data = target.read_text(encoding="utf-8")
|
|
259
|
+
if offset > 1 or limit is not None:
|
|
260
|
+
lines = data.splitlines(keepends=True)
|
|
261
|
+
start = max(0, offset - 1)
|
|
262
|
+
end = start + limit if limit is not None else len(lines)
|
|
263
|
+
data = "".join(lines[start:end])
|
|
264
|
+
emit_status("read", path=str(target), chars=len(data), preview=data[:500])
|
|
265
|
+
return data
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def write(path: str | Path, content: str) -> Path:
|
|
269
|
+
target = _resolve_helper_path(path)
|
|
270
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
271
|
+
target.write_text(content, encoding="utf-8")
|
|
272
|
+
emit_status("write", path=str(target), chars=len(content))
|
|
273
|
+
return target
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def bridge_post(path: str, payload: dict[str, Any]) -> Any:
|
|
277
|
+
port = CONNECTION.get("port")
|
|
278
|
+
token = CONNECTION.get("token")
|
|
279
|
+
if not isinstance(port, int) or not isinstance(token, str):
|
|
280
|
+
raise PreludeRuntimeError("Python tool bridge is not initialized")
|
|
281
|
+
request_data = json.dumps(payload, ensure_ascii=False, default=repr).encode("utf-8")
|
|
282
|
+
request = urllib.request.Request(
|
|
283
|
+
f"http://127.0.0.1:{port}{path}",
|
|
284
|
+
data=request_data,
|
|
285
|
+
headers={"authorization": f"Bearer {token}", "content-type": "application/json"},
|
|
286
|
+
method="POST",
|
|
287
|
+
)
|
|
288
|
+
emit_status(TIMEOUT_PAUSE_OP, force=True)
|
|
289
|
+
try:
|
|
290
|
+
try:
|
|
291
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
292
|
+
response_data = response.read()
|
|
293
|
+
except urllib.error.HTTPError as exc:
|
|
294
|
+
response_data = exc.read()
|
|
295
|
+
finally:
|
|
296
|
+
emit_status(TIMEOUT_RESUME_OP, force=True)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
body = json.loads(response_data.decode("utf-8"))
|
|
300
|
+
except json.JSONDecodeError as exc:
|
|
301
|
+
raise PreludeRuntimeError(f"Bridge returned invalid JSON: {response_data[:200]!r}") from exc
|
|
302
|
+
if isinstance(body, dict) and body.get("ok") is True:
|
|
303
|
+
return body.get("value")
|
|
304
|
+
error = body.get("error") if isinstance(body, dict) else body
|
|
305
|
+
if isinstance(error, dict):
|
|
306
|
+
raise PreludeRuntimeError(str(error.get("message", error)))
|
|
307
|
+
raise PreludeRuntimeError(str(error))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class ToolCallable:
|
|
311
|
+
__slots__ = ("_name",)
|
|
312
|
+
|
|
313
|
+
def __init__(self, name: str) -> None:
|
|
314
|
+
self._name = name
|
|
315
|
+
|
|
316
|
+
def __repr__(self) -> str:
|
|
317
|
+
return f"<tool.{self._name}>"
|
|
318
|
+
|
|
319
|
+
def __call__(self, args: Any = None, /, **kwargs: Any) -> Any:
|
|
320
|
+
if args is None:
|
|
321
|
+
merged: dict[str, Any] = {}
|
|
322
|
+
elif isinstance(args, dict):
|
|
323
|
+
merged = dict(args)
|
|
324
|
+
else:
|
|
325
|
+
raise PreludeTypeError(
|
|
326
|
+
f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})"
|
|
327
|
+
)
|
|
328
|
+
merged.update(kwargs)
|
|
329
|
+
return bridge_post(
|
|
330
|
+
"/call",
|
|
331
|
+
{"callId": f"py-{uuid.uuid4()}", "toolName": self._name, "args": merged},
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class ToolProxy:
|
|
336
|
+
__slots__ = ()
|
|
337
|
+
|
|
338
|
+
def __getattr__(self, name: str) -> ToolCallable:
|
|
339
|
+
if name.startswith("_"):
|
|
340
|
+
raise AttributeError(name)
|
|
341
|
+
return ToolCallable(name)
|
|
342
|
+
|
|
343
|
+
def __getitem__(self, name: str) -> ToolCallable:
|
|
344
|
+
return ToolCallable(name)
|
|
345
|
+
|
|
346
|
+
def __repr__(self) -> str:
|
|
347
|
+
return "<tool proxy>"
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
tool = ToolProxy()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def completion(
|
|
354
|
+
prompt: str,
|
|
355
|
+
model: str = "default",
|
|
356
|
+
system: str | None = None,
|
|
357
|
+
schema: dict[str, Any] | None = None,
|
|
358
|
+
**kwargs: Any,
|
|
359
|
+
) -> Any:
|
|
360
|
+
options: dict[str, Any] = {}
|
|
361
|
+
if model != "default":
|
|
362
|
+
options["model"] = model
|
|
363
|
+
options.update(kwargs)
|
|
364
|
+
if system is not None:
|
|
365
|
+
options["system"] = system
|
|
366
|
+
if schema is not None:
|
|
367
|
+
options["schema"] = schema
|
|
368
|
+
response = bridge_post("/completion", {"prompt": prompt, "opts": options})
|
|
369
|
+
if not isinstance(response, dict):
|
|
370
|
+
return response
|
|
371
|
+
if "value" in response:
|
|
372
|
+
return response["value"]
|
|
373
|
+
return response.get("text", response)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def output(
|
|
377
|
+
*ids: str,
|
|
378
|
+
format: str = "raw",
|
|
379
|
+
offset: int | None = None,
|
|
380
|
+
limit: int | None = None,
|
|
381
|
+
) -> Any:
|
|
382
|
+
if not ids:
|
|
383
|
+
raise PreludeValueError("At least one output ID is required")
|
|
384
|
+
if format not in ("raw", "tail"):
|
|
385
|
+
raise PreludeValueError("output() format must be 'raw' or 'tail'")
|
|
386
|
+
args: dict[str, Any] = {"ids": list(ids), "format": format}
|
|
387
|
+
if offset is not None:
|
|
388
|
+
args["offset"] = offset
|
|
389
|
+
if limit is not None:
|
|
390
|
+
args["limit"] = limit
|
|
391
|
+
return bridge_post(
|
|
392
|
+
"/call",
|
|
393
|
+
{"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_OUTPUT_TOOL, "args": args},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def agent(
|
|
398
|
+
prompt: str,
|
|
399
|
+
*,
|
|
400
|
+
agent: str | None = "task",
|
|
401
|
+
model: str | None = None,
|
|
402
|
+
label: str | None = None,
|
|
403
|
+
schema: dict[str, Any] | None = None,
|
|
404
|
+
isolated: bool | None = None,
|
|
405
|
+
apply: bool | None = None,
|
|
406
|
+
merge: bool | None = None,
|
|
407
|
+
handle: bool = False,
|
|
408
|
+
) -> Any:
|
|
409
|
+
args: dict[str, Any] = {"prompt": prompt}
|
|
410
|
+
if agent is not None:
|
|
411
|
+
args["agent"] = agent
|
|
412
|
+
if model is not None:
|
|
413
|
+
args["model"] = model
|
|
414
|
+
if label is not None:
|
|
415
|
+
args["label"] = label
|
|
416
|
+
if schema is not None:
|
|
417
|
+
args["schema"] = schema
|
|
418
|
+
if isolated is not None:
|
|
419
|
+
args["isolated"] = bool(isolated)
|
|
420
|
+
if apply is not None:
|
|
421
|
+
args["apply"] = bool(apply)
|
|
422
|
+
if merge is not None:
|
|
423
|
+
args["merge"] = bool(merge)
|
|
424
|
+
if handle:
|
|
425
|
+
args["handle"] = True
|
|
426
|
+
|
|
427
|
+
response = bridge_post(
|
|
428
|
+
"/call",
|
|
429
|
+
{"callId": f"py-{uuid.uuid4()}", "toolName": RESERVED_AGENT_TOOL, "args": args},
|
|
430
|
+
)
|
|
431
|
+
response_record = response if isinstance(response, dict) else {}
|
|
432
|
+
text_value = response_record.get("text", response)
|
|
433
|
+
parsed = response_record.get("data")
|
|
434
|
+
if schema is not None and "data" not in response_record:
|
|
435
|
+
parsed = json.loads(str(text_value))
|
|
436
|
+
elif schema is None:
|
|
437
|
+
parsed = text_value
|
|
438
|
+
if not handle:
|
|
439
|
+
return parsed
|
|
440
|
+
|
|
441
|
+
agent_id = response_record.get("id")
|
|
442
|
+
handle_value = response_record.get("handle")
|
|
443
|
+
if handle_value is None and agent_id is not None:
|
|
444
|
+
handle_value = f"agent://{agent_id}"
|
|
445
|
+
node: dict[str, Any] = {
|
|
446
|
+
"text": text_value,
|
|
447
|
+
"output": text_value,
|
|
448
|
+
"handle": handle_value,
|
|
449
|
+
"id": agent_id,
|
|
450
|
+
"agent": response_record.get("agent", agent),
|
|
451
|
+
}
|
|
452
|
+
if schema is not None:
|
|
453
|
+
node["data"] = parsed
|
|
454
|
+
for key in (
|
|
455
|
+
"isolated",
|
|
456
|
+
"patch_path",
|
|
457
|
+
"branch_name",
|
|
458
|
+
"nested_patches",
|
|
459
|
+
"changes_applied",
|
|
460
|
+
"isolation_summary",
|
|
461
|
+
):
|
|
462
|
+
if key in response_record:
|
|
463
|
+
node[key] = response_record[key]
|
|
464
|
+
return node
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _pool_map(items: Iterable[Any], function: Callable[[Any], Any]) -> list[Any]:
|
|
468
|
+
values = list(items)
|
|
469
|
+
if not values:
|
|
470
|
+
return []
|
|
471
|
+
configured_width = CONNECTION.get("parallelPoolWidth", 4)
|
|
472
|
+
width = (
|
|
473
|
+
int(configured_width)
|
|
474
|
+
if isinstance(configured_width, (int, float)) and not isinstance(configured_width, bool)
|
|
475
|
+
else 4
|
|
476
|
+
)
|
|
477
|
+
workers = min(max(1, width), len(values))
|
|
478
|
+
results: list[Any] = [None] * len(values)
|
|
479
|
+
errors: dict[int, BaseException] = {}
|
|
480
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
481
|
+
futures = {pool.submit(function, value): index for index, value in enumerate(values)}
|
|
482
|
+
for future in as_completed(futures):
|
|
483
|
+
index = futures[future]
|
|
484
|
+
try:
|
|
485
|
+
results[index] = future.result()
|
|
486
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — preserve user thunk failures for deterministic re-raise.
|
|
487
|
+
errors[index] = exc
|
|
488
|
+
if errors:
|
|
489
|
+
raise errors[min(errors)]
|
|
490
|
+
return results
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def parallel(callables: Iterable[Callable[[], Any]]) -> list[Any]:
|
|
494
|
+
thunks = list(callables)
|
|
495
|
+
for thunk in thunks:
|
|
496
|
+
if not callable(thunk):
|
|
497
|
+
raise PreludeTypeError("parallel() expects an iterable of zero-arg callables")
|
|
498
|
+
return _pool_map(thunks, lambda thunk: thunk())
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def pipeline(items: Iterable[Any], *stages: Callable[[Any], Any]) -> list[Any]:
|
|
502
|
+
values = list(items)
|
|
503
|
+
for stage in stages:
|
|
504
|
+
if not callable(stage):
|
|
505
|
+
raise PreludeTypeError("pipeline() stages must be callables")
|
|
506
|
+
values = _pool_map(values, stage)
|
|
507
|
+
return values
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _fold_continuations(lines: list[str], start: int) -> tuple[str, int]:
|
|
511
|
+
parts: list[str] = []
|
|
512
|
+
index = start
|
|
513
|
+
while index < len(lines):
|
|
514
|
+
line = lines[index]
|
|
515
|
+
if line.endswith("\\"):
|
|
516
|
+
parts.append(line[:-1])
|
|
517
|
+
index += 1
|
|
518
|
+
continue
|
|
519
|
+
parts.append(line)
|
|
520
|
+
index += 1
|
|
521
|
+
break
|
|
522
|
+
return "".join(parts), index - start
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _quote_arg(text_value: str) -> str:
|
|
526
|
+
return json.dumps(text_value, ensure_ascii=False)
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def _split_magic_head(text_value: str) -> tuple[str, str]:
|
|
530
|
+
stripped = text_value.lstrip()
|
|
531
|
+
if not stripped:
|
|
532
|
+
return "", ""
|
|
533
|
+
match = re.match(r"([A-Za-z_][A-Za-z_0-9]*)(?:\s+(.*))?$", stripped)
|
|
534
|
+
if not match:
|
|
535
|
+
return "", stripped
|
|
536
|
+
return match.group(1), (match.group(2) or "").rstrip()
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _is_escaped(text_value: str, index: int) -> bool:
|
|
540
|
+
backslashes = 0
|
|
541
|
+
cursor = index - 1
|
|
542
|
+
while cursor >= 0 and text_value[cursor] == "\\":
|
|
543
|
+
backslashes += 1
|
|
544
|
+
cursor -= 1
|
|
545
|
+
return backslashes % 2 == 1
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _advance_triple_quote_state(line: str, active_quote: str | None) -> str | None:
|
|
549
|
+
index = 0
|
|
550
|
+
quote = active_quote
|
|
551
|
+
while index < len(line):
|
|
552
|
+
if quote is not None:
|
|
553
|
+
closing = line.find(quote, index)
|
|
554
|
+
if closing < 0:
|
|
555
|
+
return quote
|
|
556
|
+
if _is_escaped(line, closing):
|
|
557
|
+
index = closing + 1
|
|
558
|
+
continue
|
|
559
|
+
quote = None
|
|
560
|
+
index = closing + 3
|
|
561
|
+
continue
|
|
562
|
+
|
|
563
|
+
character = line[index]
|
|
564
|
+
if character == "#":
|
|
565
|
+
return None
|
|
566
|
+
if character not in ("'", '"'):
|
|
567
|
+
index += 1
|
|
568
|
+
continue
|
|
569
|
+
triple = character * 3
|
|
570
|
+
if line.startswith(triple, index):
|
|
571
|
+
quote = triple
|
|
572
|
+
index += 3
|
|
573
|
+
continue
|
|
574
|
+
index += 1
|
|
575
|
+
while index < len(line):
|
|
576
|
+
if line[index] == character and not _is_escaped(line, index):
|
|
577
|
+
index += 1
|
|
578
|
+
break
|
|
579
|
+
index += 1
|
|
580
|
+
return quote
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def transform_cell(source: str) -> str:
|
|
584
|
+
if "%" not in source and "!" not in source:
|
|
585
|
+
return source
|
|
586
|
+
|
|
587
|
+
lines = source.splitlines()
|
|
588
|
+
transformed: list[str] = []
|
|
589
|
+
index = 0
|
|
590
|
+
triple_quote: str | None = None
|
|
591
|
+
while index < len(lines):
|
|
592
|
+
line = lines[index]
|
|
593
|
+
protected = triple_quote is not None
|
|
594
|
+
stripped = line.lstrip()
|
|
595
|
+
indent = line[: len(line) - len(stripped)]
|
|
596
|
+
|
|
597
|
+
if not protected and stripped.startswith("%%"):
|
|
598
|
+
name, args = _split_magic_head(stripped[2:])
|
|
599
|
+
body = "\n".join(lines[index + 1 :])
|
|
600
|
+
transformed.append(
|
|
601
|
+
f"{indent}__senpi_magic_cell({_quote_arg(name)}, {_quote_arg(args)}, {_quote_arg(body)})"
|
|
602
|
+
)
|
|
603
|
+
return "\n".join(transformed)
|
|
604
|
+
|
|
605
|
+
if not protected and stripped.startswith("%"):
|
|
606
|
+
folded, consumed = _fold_continuations(lines, index)
|
|
607
|
+
folded_stripped = folded.lstrip()
|
|
608
|
+
folded_indent = folded[: len(folded) - len(folded_stripped)]
|
|
609
|
+
name, args = _split_magic_head(folded_stripped[1:])
|
|
610
|
+
transformed.append(f"{folded_indent}__senpi_magic({_quote_arg(name)}, {_quote_arg(args)})")
|
|
611
|
+
index += consumed
|
|
612
|
+
continue
|
|
613
|
+
|
|
614
|
+
if not protected and stripped.startswith("!"):
|
|
615
|
+
folded, consumed = _fold_continuations(lines, index)
|
|
616
|
+
folded_stripped = folded.lstrip()
|
|
617
|
+
folded_indent = folded[: len(folded) - len(folded_stripped)]
|
|
618
|
+
command = folded_stripped[1:].strip()
|
|
619
|
+
transformed.append(f"{folded_indent}__senpi_shell({_quote_arg(command)})")
|
|
620
|
+
index += consumed
|
|
621
|
+
continue
|
|
622
|
+
|
|
623
|
+
if not protected:
|
|
624
|
+
assignment = _ASSIGN_LINE_RE.match(line)
|
|
625
|
+
if assignment:
|
|
626
|
+
right_hand_side = assignment.group("rhs").strip()
|
|
627
|
+
if right_hand_side.startswith("!"):
|
|
628
|
+
command = right_hand_side[1:].strip()
|
|
629
|
+
transformed.append(
|
|
630
|
+
f"{assignment.group('indent')}{assignment.group('lhs').rstrip()} = "
|
|
631
|
+
f"__senpi_shell({_quote_arg(command)})"
|
|
632
|
+
)
|
|
633
|
+
index += 1
|
|
634
|
+
continue
|
|
635
|
+
if right_hand_side.startswith("%") and not right_hand_side.startswith("%%"):
|
|
636
|
+
name, args = _split_magic_head(right_hand_side[1:])
|
|
637
|
+
transformed.append(
|
|
638
|
+
f"{assignment.group('indent')}{assignment.group('lhs').rstrip()} = "
|
|
639
|
+
f"__senpi_magic({_quote_arg(name)}, {_quote_arg(args)})"
|
|
640
|
+
)
|
|
641
|
+
index += 1
|
|
642
|
+
continue
|
|
643
|
+
|
|
644
|
+
transformed.append(line)
|
|
645
|
+
triple_quote = _advance_triple_quote_state(line, triple_quote)
|
|
646
|
+
index += 1
|
|
647
|
+
return "\n".join(transformed)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _magic_cd(args: str) -> str:
|
|
651
|
+
path = os.path.expanduser(args.strip()) or os.path.expanduser("~")
|
|
652
|
+
os.chdir(path)
|
|
653
|
+
cwd = os.getcwd()
|
|
654
|
+
emit_status("cd", path=cwd)
|
|
655
|
+
return cwd
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _magic_env(args: str) -> Any:
|
|
659
|
+
stripped = args.strip()
|
|
660
|
+
if not stripped:
|
|
661
|
+
return env()
|
|
662
|
+
if "=" in stripped:
|
|
663
|
+
key, value = stripped.split("=", 1)
|
|
664
|
+
return env(key.strip(), value.strip())
|
|
665
|
+
return env(stripped)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
_LINE_MAGICS: dict[str, Callable[[str], Any]] = {
|
|
669
|
+
"cd": _magic_cd,
|
|
670
|
+
"env": _magic_env,
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _magic(name: str, args: str) -> Any:
|
|
675
|
+
handler = _LINE_MAGICS.get(name)
|
|
676
|
+
if handler is None:
|
|
677
|
+
raise PreludeRuntimeError(f"Unsupported line magic: %{name}")
|
|
678
|
+
return handler(args)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def _magic_cell(name: str, args: str, body: str) -> Any:
|
|
682
|
+
if name in ("bash", "sh"):
|
|
683
|
+
command = "\n".join(part for part in (args, body) if part)
|
|
684
|
+
return _shell(command)
|
|
685
|
+
raise PreludeRuntimeError(f"Unsupported cell magic: %%{name}")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _take_prefix_by_lines(value: str, max_lines: int) -> str:
|
|
689
|
+
if max_lines <= 0:
|
|
690
|
+
return ""
|
|
691
|
+
cursor = 0
|
|
692
|
+
for _ in range(max_lines):
|
|
693
|
+
newline = value.find("\n", cursor)
|
|
694
|
+
if newline < 0:
|
|
695
|
+
return value
|
|
696
|
+
cursor = newline + 1
|
|
697
|
+
return value[:cursor]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _take_prefix_by_encoded_bytes(value: str, max_bytes: int, encoding: str) -> str:
|
|
701
|
+
if max_bytes <= 0:
|
|
702
|
+
return ""
|
|
703
|
+
if len(value.encode(encoding, errors="replace")) <= max_bytes:
|
|
704
|
+
return value
|
|
705
|
+
low = 0
|
|
706
|
+
high = len(value)
|
|
707
|
+
while low < high:
|
|
708
|
+
middle = (low + high + 1) // 2
|
|
709
|
+
if len(value[:middle].encode(encoding, errors="replace")) <= max_bytes:
|
|
710
|
+
low = middle
|
|
711
|
+
else:
|
|
712
|
+
high = middle - 1
|
|
713
|
+
return value[:low]
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
class _ShellOutputLimiter:
|
|
717
|
+
def __init__(self, *, max_bytes: int, max_lines: int, encoding: str) -> None:
|
|
718
|
+
self._remaining_bytes = max_bytes
|
|
719
|
+
self._remaining_lines = max_lines
|
|
720
|
+
self._encoding = encoding
|
|
721
|
+
self._truncated = False
|
|
722
|
+
self._at_line_start = True
|
|
723
|
+
|
|
724
|
+
def write(self, value: str) -> None:
|
|
725
|
+
if not value or self._truncated:
|
|
726
|
+
return
|
|
727
|
+
line_limited = _take_prefix_by_lines(value, self._remaining_lines)
|
|
728
|
+
truncated = line_limited != value
|
|
729
|
+
byte_limited = _take_prefix_by_encoded_bytes(
|
|
730
|
+
line_limited,
|
|
731
|
+
self._remaining_bytes,
|
|
732
|
+
self._encoding,
|
|
733
|
+
)
|
|
734
|
+
truncated = truncated or byte_limited != line_limited
|
|
735
|
+
if byte_limited:
|
|
736
|
+
text("stdout", byte_limited)
|
|
737
|
+
self._remaining_bytes -= len(
|
|
738
|
+
byte_limited.encode(self._encoding, errors="replace")
|
|
739
|
+
)
|
|
740
|
+
self._remaining_lines -= byte_limited.count("\n")
|
|
741
|
+
self._at_line_start = byte_limited.endswith("\n")
|
|
742
|
+
if truncated:
|
|
743
|
+
self._emit_truncation_notice()
|
|
744
|
+
|
|
745
|
+
def _emit_truncation_notice(self) -> None:
|
|
746
|
+
if self._truncated:
|
|
747
|
+
return
|
|
748
|
+
prefix = "" if self._at_line_start else "\n"
|
|
749
|
+
text("stdout", prefix + _SHELL_TRUNCATION_NOTICE)
|
|
750
|
+
self._truncated = True
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
class _BoundedTextCapture:
|
|
754
|
+
def __init__(self, max_bytes: int, max_lines: int, encoding: str) -> None:
|
|
755
|
+
self._remaining_bytes = max_bytes
|
|
756
|
+
self._remaining_lines = max_lines
|
|
757
|
+
self._encoding = encoding
|
|
758
|
+
self._parts: list[str] = []
|
|
759
|
+
|
|
760
|
+
def add(self, value: str) -> None:
|
|
761
|
+
if self._remaining_bytes <= 0 or self._remaining_lines <= 0:
|
|
762
|
+
return
|
|
763
|
+
line_limited = _take_prefix_by_lines(value, self._remaining_lines)
|
|
764
|
+
part = _take_prefix_by_encoded_bytes(
|
|
765
|
+
line_limited,
|
|
766
|
+
self._remaining_bytes,
|
|
767
|
+
self._encoding,
|
|
768
|
+
)
|
|
769
|
+
if not part:
|
|
770
|
+
return
|
|
771
|
+
self._parts.append(part)
|
|
772
|
+
self._remaining_bytes -= len(part.encode(self._encoding, errors="replace"))
|
|
773
|
+
self._remaining_lines -= part.count("\n")
|
|
774
|
+
|
|
775
|
+
def value(self) -> str:
|
|
776
|
+
return "".join(self._parts)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
class ShellResult(list[str]):
|
|
780
|
+
def __init__(self, lines: list[str], returncode: int) -> None:
|
|
781
|
+
super().__init__(lines)
|
|
782
|
+
self.returncode = returncode
|
|
783
|
+
|
|
784
|
+
@property
|
|
785
|
+
def n(self) -> str:
|
|
786
|
+
return "\n".join(self)
|
|
787
|
+
|
|
788
|
+
@property
|
|
789
|
+
def s(self) -> str:
|
|
790
|
+
return " ".join(self)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _shell(command: str) -> ShellResult:
|
|
794
|
+
process = subprocess.Popen(
|
|
795
|
+
command,
|
|
796
|
+
shell=True,
|
|
797
|
+
stdout=subprocess.PIPE,
|
|
798
|
+
stderr=subprocess.STDOUT,
|
|
799
|
+
)
|
|
800
|
+
if process.stdout is None:
|
|
801
|
+
return ShellResult([], process.wait())
|
|
802
|
+
|
|
803
|
+
encoding = locale.getpreferredencoding(False) or "utf-8"
|
|
804
|
+
decoder = codecs.getincrementaldecoder(encoding)(errors="replace")
|
|
805
|
+
limiter = _ShellOutputLimiter(
|
|
806
|
+
max_bytes=_SHELL_CAPTURE_MAX_BYTES,
|
|
807
|
+
max_lines=_SHELL_CAPTURE_MAX_LINES,
|
|
808
|
+
encoding=encoding,
|
|
809
|
+
)
|
|
810
|
+
capture = _BoundedTextCapture(
|
|
811
|
+
_SHELL_CAPTURE_MAX_BYTES,
|
|
812
|
+
_SHELL_CAPTURE_MAX_LINES,
|
|
813
|
+
encoding,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
def consume(chunk_text: str) -> None:
|
|
817
|
+
if not chunk_text:
|
|
818
|
+
return
|
|
819
|
+
limiter.write(chunk_text)
|
|
820
|
+
capture.add(chunk_text)
|
|
821
|
+
|
|
822
|
+
while True:
|
|
823
|
+
raw_chunk = os.read(process.stdout.fileno(), _SHELL_READ_CHUNK_BYTES)
|
|
824
|
+
if not raw_chunk:
|
|
825
|
+
break
|
|
826
|
+
consume(decoder.decode(raw_chunk))
|
|
827
|
+
consume(decoder.decode(b"", final=True))
|
|
828
|
+
return ShellResult(capture.value().splitlines(), process.wait())
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
USER_NS.update(
|
|
832
|
+
{
|
|
833
|
+
"display": display,
|
|
834
|
+
"print": print,
|
|
835
|
+
"log": log,
|
|
836
|
+
"phase": phase,
|
|
837
|
+
"env": env,
|
|
838
|
+
"read": read,
|
|
839
|
+
"write": write,
|
|
840
|
+
"parallel": parallel,
|
|
841
|
+
"pipeline": pipeline,
|
|
842
|
+
"tool": tool,
|
|
843
|
+
"completion": completion,
|
|
844
|
+
"agent": agent,
|
|
845
|
+
"output": output,
|
|
846
|
+
"__senpi_magic": _magic,
|
|
847
|
+
"__senpi_magic_cell": _magic_cell,
|
|
848
|
+
"__senpi_shell": _shell,
|
|
849
|
+
}
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def compile_cell(source: str) -> tuple[Any | None, Any | None]:
|
|
856
|
+
module = ast.parse(transform_cell(source), mode="exec")
|
|
857
|
+
if not module.body:
|
|
858
|
+
return None, None
|
|
859
|
+
last = module.body[-1]
|
|
860
|
+
if isinstance(last, ast.Expr):
|
|
861
|
+
body = ast.Module(body=module.body[:-1], type_ignores=[])
|
|
862
|
+
expression = ast.Expression(body=last.value)
|
|
863
|
+
ast.copy_location(expression, last)
|
|
864
|
+
return compile(body, "<cell>", "exec", flags=TLA_FLAG), compile(
|
|
865
|
+
expression,
|
|
866
|
+
"<cell>",
|
|
867
|
+
"eval",
|
|
868
|
+
flags=TLA_FLAG,
|
|
869
|
+
)
|
|
870
|
+
return compile(module, "<cell>", "exec", flags=TLA_FLAG), None
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
async def run_code(code: Any, want_value: bool) -> Any:
|
|
874
|
+
if code is None:
|
|
875
|
+
return None
|
|
876
|
+
if code.co_flags & inspect.CO_COROUTINE:
|
|
877
|
+
result = await eval(code, USER_NS)
|
|
878
|
+
return result if want_value else None
|
|
879
|
+
if want_value:
|
|
880
|
+
return eval(code, USER_NS)
|
|
881
|
+
exec(code, USER_NS)
|
|
882
|
+
return None
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def run_cell(cell_id: str, code: str) -> None:
|
|
886
|
+
start = time.monotonic()
|
|
887
|
+
stdout = io.StringIO()
|
|
888
|
+
stderr = io.StringIO()
|
|
889
|
+
try:
|
|
890
|
+
with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
|
|
891
|
+
body, expression = compile_cell(code)
|
|
892
|
+
LOOP.run_until_complete(run_code(body, False))
|
|
893
|
+
value = LOOP.run_until_complete(run_code(expression, True))
|
|
894
|
+
text("stdout", stdout.getvalue())
|
|
895
|
+
text("stderr", stderr.getvalue())
|
|
896
|
+
result: dict[str, Any] = {
|
|
897
|
+
"type": "result",
|
|
898
|
+
"cellId": cell_id,
|
|
899
|
+
"ok": True,
|
|
900
|
+
"durationMs": elapsed(start),
|
|
901
|
+
}
|
|
902
|
+
if value is not None:
|
|
903
|
+
result["valueRepr"] = repr(value)
|
|
904
|
+
emit(result)
|
|
905
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — cell boundary serializes user errors and interrupts.
|
|
906
|
+
text("stdout", stdout.getvalue())
|
|
907
|
+
text("stderr", stderr.getvalue())
|
|
908
|
+
emit(
|
|
909
|
+
{
|
|
910
|
+
"type": "result",
|
|
911
|
+
"cellId": cell_id,
|
|
912
|
+
"ok": False,
|
|
913
|
+
"error": bridge_error(exc),
|
|
914
|
+
"durationMs": elapsed(start),
|
|
915
|
+
}
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def elapsed(start: float) -> int:
|
|
920
|
+
return max(0, int((time.monotonic() - start) * 1000))
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def handle(message: dict[str, Any]) -> bool:
|
|
924
|
+
global SESSION_ID, CONNECTION
|
|
925
|
+
message_type = message.get("type")
|
|
926
|
+
if message_type == "init":
|
|
927
|
+
SESSION_ID = str(message.get("sessionId", ""))
|
|
928
|
+
connection = message.get("connection")
|
|
929
|
+
if not isinstance(connection, dict):
|
|
930
|
+
emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
|
|
931
|
+
return True
|
|
932
|
+
CONNECTION = connection
|
|
933
|
+
emit({"type": "ready"})
|
|
934
|
+
return True
|
|
935
|
+
if message_type == "run":
|
|
936
|
+
run_cell(str(message.get("cellId", "")), str(message.get("code", "")))
|
|
937
|
+
return True
|
|
938
|
+
if message_type == "close":
|
|
939
|
+
emit({"type": "closed"})
|
|
940
|
+
return False
|
|
941
|
+
return True
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def main() -> None:
|
|
945
|
+
for raw in sys.stdin:
|
|
946
|
+
try:
|
|
947
|
+
if not handle(json.loads(raw)):
|
|
948
|
+
break
|
|
949
|
+
except BaseException as exc: # noqa: BROAD_EXCEPT_OK — process boundary serializes malformed input and interrupts.
|
|
950
|
+
emit({"type": "init-failed", "error": bridge_error(exc)})
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
if __name__ == "__main__":
|
|
954
|
+
main()
|