@code-yeongyu/senpi-codemode 2026.9.28 → 2026.9.29-3

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 (52) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +13 -1
  3. package/package.json +4 -4
  4. package/src/bridge/memory-protocol.ts +39 -0
  5. package/src/bridge/protocol.ts +18 -0
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/config/memory-settings.ts +149 -0
  8. package/src/config/settings.ts +8 -2
  9. package/src/extension/session-manager-proxy.ts +6 -0
  10. package/src/extension/session-manager.ts +27 -25
  11. package/src/host-sdk.ts +1 -1
  12. package/src/index.ts +4 -0
  13. package/src/kernels/jl/kernel.ts +4 -1
  14. package/src/kernels/js/context-manager.ts +43 -5
  15. package/src/kernels/js/inline-worker.ts +2 -1
  16. package/src/kernels/js/interrupt-bounds.ts +8 -2
  17. package/src/kernels/js/kernel-contract.ts +13 -0
  18. package/src/kernels/js/local-module-loader.ts +2 -0
  19. package/src/kernels/js/webview-host.ts +59 -0
  20. package/src/kernels/js/worker-core.js +32 -3
  21. package/src/kernels/js/worker-global-sizes.js +123 -0
  22. package/src/kernels/js/worker-heap.js +59 -0
  23. package/src/kernels/js/worker-host.ts +2 -1
  24. package/src/kernels/js/worker-memory.js +94 -0
  25. package/src/kernels/js/worker-runtime.js +7 -2
  26. package/src/kernels/js/worker-slot.ts +16 -5
  27. package/src/kernels/js/worker-startup.ts +1 -0
  28. package/src/kernels/js/worker-webview-client.js +79 -0
  29. package/src/kernels/js/worker-webview.js +204 -0
  30. package/src/kernels/py/kernel-contract.ts +3 -0
  31. package/src/kernels/py/kernel.ts +15 -2
  32. package/src/kernels/py/prelude.py +327 -11
  33. package/src/kernels/py/transport.ts +4 -1
  34. package/src/kernels/rb/kernel.ts +4 -1
  35. package/src/kernels/shared/kernel-memory-host.ts +63 -0
  36. package/src/kernels/shared/kernel-memory.ts +136 -0
  37. package/src/kernels/shared/subprocess-contract.ts +10 -1
  38. package/src/kernels/shared/subprocess-kernel.ts +23 -2
  39. package/src/kernels/shared/subprocess-queue.ts +2 -0
  40. package/src/output/streaming-output-buffer.ts +229 -12
  41. package/src/output/streaming-output.ts +2 -2
  42. package/src/prompt/eval-prompt.ts +1 -1
  43. package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
  44. package/src/tool/cell-runtime.ts +52 -10
  45. package/src/tool/detached-cell-contract.ts +4 -0
  46. package/src/tool/detached-cell-manager.ts +16 -1
  47. package/src/tool/detached-cell-notification.ts +10 -1
  48. package/src/tool/image.ts +6 -2
  49. package/src/tool/run-eval-cell.ts +19 -17
  50. package/src/tool/settled-image-spill.ts +108 -0
  51. package/src/tool/terminal-snapshot-store.ts +82 -10
  52. package/src/tool/types.ts +10 -0
@@ -6,6 +6,7 @@ import asyncio # noqa: ANYIO_OK — stdlib-only embedded kernel runner.
6
6
  import base64
7
7
  import codecs
8
8
  import contextlib
9
+ import gc
9
10
  import inspect
10
11
  import io
11
12
  import json
@@ -17,6 +18,7 @@ import subprocess
17
18
  import sys
18
19
  import time
19
20
  import traceback
21
+ import types
20
22
  import urllib.error
21
23
  import urllib.request
22
24
  import uuid
@@ -987,6 +989,313 @@ USER_NS.update(
987
989
  }
988
990
  )
989
991
 
992
+ # Kernel memory (mirrors src/kernels/js/worker-memory.js; thresholds arrive on `init`, the host applies the
993
+ # notice/ceiling policy in src/kernels/shared/kernel-memory.ts). Names present right after prelude install
994
+ # are never reported as user globals.
995
+ _MEMORY_BASELINE = frozenset(USER_NS)
996
+ _MIB = 1024 * 1024
997
+ _MEMORY_MIN_GROWTH = 64 * _MIB
998
+ _MEMORY_GROWTH_RATIO = 0.25
999
+ _MEMORY_NOTICE_GROWTH_RATIO = 1.25
1000
+ # A collection costs at most 1/20 of the time since the previous one.
1001
+ _MEMORY_COLLECT_RATE_FLOOR = 20
1002
+ _MEMORY_REPORTED_GLOBALS = 5
1003
+ _MEMORY_MIN_REPORTED_BYTES = _MIB
1004
+ _SIZER_SAMPLED = 1_000
1005
+ _SIZER_NODE_BUDGET = 200_000
1006
+ _SIZER_MAX_DEPTH = 64
1007
+ _SIZER_POINTER = 8
1008
+ _SIZER_LEAF_TYPES = (str, bytes, bytearray, int, float, complex, bool, type(None), range, memoryview)
1009
+ _SIZER_OPAQUE_TYPES = (types.ModuleType, type, types.FunctionType, types.BuiltinFunctionType, types.MethodType)
1010
+
1011
+
1012
+ class _KernelMemory:
1013
+ """Post-cell footprint measurement, collection, and largest-globals attribution for this process."""
1014
+
1015
+ def __init__(self) -> None:
1016
+ self.thresholds: dict[str, int] | None = None
1017
+ self.last_live = 0
1018
+ self.notice_ref = 0
1019
+ self.collect_end = float("-inf")
1020
+ self.collect_seconds = 0.0
1021
+ self._reader: Callable[[], tuple[int, bool] | None] | None = None
1022
+ self._trim: Callable[[], None] | None = None
1023
+ self._resolved = False
1024
+
1025
+ def configure(self, thresholds: Any) -> None:
1026
+ if not isinstance(thresholds, dict):
1027
+ return
1028
+ keys = ("gcWatermarkBytes", "noticeBytes", "ceilingBytes")
1029
+ if all(isinstance(thresholds.get(key), int) for key in keys):
1030
+ self.thresholds = {key: int(thresholds[key]) for key in keys}
1031
+
1032
+ def after_cell(self) -> dict[str, Any] | None:
1033
+ if self.thresholds is None:
1034
+ return None
1035
+ reading = self._footprint()
1036
+ if reading is None:
1037
+ return None
1038
+ live, approximate = reading
1039
+ gc_ran = self._needs_collection(live)
1040
+ if gc_ran:
1041
+ live = self._collect(live)
1042
+ notice = self.thresholds["noticeBytes"]
1043
+ if gc_ran and notice > 0 and live >= notice:
1044
+ if self.notice_ref == 0 or live >= self.notice_ref * _MEMORY_NOTICE_GROWTH_RATIO:
1045
+ self.notice_ref = live
1046
+ self.last_live = live
1047
+ report: dict[str, Any] = {"liveBytes": live, "measure": "footprint"}
1048
+ if gc_ran:
1049
+ report["gcRan"] = True
1050
+ if approximate:
1051
+ report["approximate"] = True
1052
+ if gc_ran and self._worth_naming(live):
1053
+ named = _largest_globals(_MEMORY_REPORTED_GLOBALS)
1054
+ if named:
1055
+ report["globals"] = named
1056
+ return report
1057
+
1058
+ def _needs_collection(self, estimate: int) -> bool:
1059
+ assert self.thresholds is not None
1060
+ watermark = self.thresholds["gcWatermarkBytes"]
1061
+ notice = self.thresholds["noticeBytes"]
1062
+ ceiling = self.thresholds["ceilingBytes"]
1063
+ if ceiling > 0 and estimate >= ceiling:
1064
+ return True
1065
+ if watermark > 0 and estimate >= watermark:
1066
+ if estimate > self.last_live + max(_MEMORY_MIN_GROWTH, self.last_live * _MEMORY_GROWTH_RATIO):
1067
+ return True
1068
+ if notice > 0 and estimate >= notice:
1069
+ if self.last_live < notice or self.notice_ref == 0 or estimate >= self.notice_ref * _MEMORY_NOTICE_GROWTH_RATIO:
1070
+ return True
1071
+ # The JS worker's idle collection, run synchronously: a cell that only drops a global allocates
1072
+ # nothing, so cyclic garbage and malloc's free lists would otherwise stay until the next growth.
1073
+ if watermark == 0 or self.last_live < watermark:
1074
+ return False
1075
+ return time.monotonic() - self.collect_end >= _MEMORY_COLLECT_RATE_FLOOR * self.collect_seconds
1076
+
1077
+ def _worth_naming(self, live: int) -> bool:
1078
+ assert self.thresholds is not None
1079
+ notice = self.thresholds["noticeBytes"]
1080
+ ceiling = self.thresholds["ceilingBytes"]
1081
+ return (notice > 0 and live >= notice) or (ceiling > 0 and live >= ceiling)
1082
+
1083
+ def _collect(self, before: int) -> int:
1084
+ started = time.monotonic()
1085
+ gc.collect()
1086
+ if self._trim is not None:
1087
+ self._trim()
1088
+ self.collect_end = time.monotonic()
1089
+ self.collect_seconds = self.collect_end - started
1090
+ reading = self._footprint()
1091
+ live = before if reading is None else reading[0]
1092
+ if self.thresholds is not None and live < self.thresholds["noticeBytes"] / 2:
1093
+ self.notice_ref = 0
1094
+ return live
1095
+
1096
+ def _footprint(self) -> tuple[int, bool] | None:
1097
+ if not self._resolved:
1098
+ self._resolved = True
1099
+ self._reader = _footprint_reader()
1100
+ self._trim = _malloc_trim()
1101
+ return None if self._reader is None else self._reader()
1102
+
1103
+
1104
+ def _footprint_reader() -> Callable[[], tuple[int, bool]] | None:
1105
+ """This process's footprint as src/core/process-footprint.ts reads it, or peak RSS (approximate)."""
1106
+ import ctypes
1107
+
1108
+ if sys.platform == "darwin":
1109
+ with contextlib.suppress(OSError, AttributeError):
1110
+ libsystem = ctypes.CDLL("/usr/lib/libSystem.B.dylib")
1111
+ rusage = libsystem.proc_pid_rusage
1112
+ rusage.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_void_p]
1113
+ rusage.restype = ctypes.c_int
1114
+ buffer = (ctypes.c_uint64 * 32)()
1115
+ pid = os.getpid()
1116
+
1117
+ def darwin() -> tuple[int, bool] | None:
1118
+ # RUSAGE_INFO_V2; ri_phys_footprint is the u64 at index 9 (byte offset 72).
1119
+ return (int(buffer[9]), False) if rusage(pid, 2, ctypes.byref(buffer)) == 0 else None
1120
+
1121
+ if darwin() is not None:
1122
+ return darwin
1123
+ if sys.platform.startswith("linux") and os.path.exists("/proc/self/status"):
1124
+
1125
+ def linux() -> tuple[int, bool] | None:
1126
+ with open("/proc/self/status", encoding="ascii", errors="replace") as status:
1127
+ for line in status:
1128
+ if line.startswith("RssAnon:"):
1129
+ return int(line.split()[1]) * 1024, False
1130
+ return None
1131
+
1132
+ if linux() is not None:
1133
+ return linux
1134
+ if sys.platform == "win32":
1135
+ with contextlib.suppress(OSError, AttributeError):
1136
+ return _windows_private_usage_reader(ctypes)
1137
+ with contextlib.suppress(ImportError):
1138
+ import resource
1139
+
1140
+ # ru_maxrss is a peak (kilobytes on Linux, bytes on macOS): flagged approximate.
1141
+ scale = 1 if sys.platform == "darwin" else 1024
1142
+ return lambda: (int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss) * scale, True)
1143
+ return None
1144
+
1145
+
1146
+ def _windows_private_usage_reader(ctypes: Any) -> Callable[[], tuple[int, bool]] | None:
1147
+ size_t = ctypes.c_size_t
1148
+
1149
+ class Counters(ctypes.Structure):
1150
+ _fields_ = [
1151
+ ("cb", ctypes.c_uint32),
1152
+ ("PageFaultCount", ctypes.c_uint32),
1153
+ *((name, size_t) for name in ("PeakWorkingSetSize", "WorkingSetSize", "QuotaPeakPagedPoolUsage")),
1154
+ *((name, size_t) for name in ("QuotaPagedPoolUsage", "QuotaPeakNonPagedPoolUsage")),
1155
+ *((name, size_t) for name in ("QuotaNonPagedPoolUsage", "PagefileUsage", "PeakPagefileUsage")),
1156
+ ("PrivateUsage", size_t),
1157
+ ]
1158
+
1159
+ kernel32 = ctypes.WinDLL("kernel32")
1160
+ current = kernel32.GetCurrentProcess
1161
+ current.restype = ctypes.c_void_p
1162
+ query = kernel32.K32GetProcessMemoryInfo
1163
+ query.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_uint32]
1164
+ query.restype = ctypes.c_int
1165
+ counters = Counters()
1166
+ counters.cb = ctypes.sizeof(Counters)
1167
+
1168
+ def windows() -> tuple[int, bool] | None:
1169
+ if query(current(), ctypes.byref(counters), counters.cb) == 0:
1170
+ return None
1171
+ return int(counters.PrivateUsage), False
1172
+
1173
+ return windows if windows() is not None else None
1174
+
1175
+
1176
+ def _malloc_trim() -> Callable[[], None] | None:
1177
+ """glibc's malloc_trim(0) returns freed heap pages to the OS; other C libraries have no equivalent."""
1178
+ if not sys.platform.startswith("linux"):
1179
+ return None
1180
+ import ctypes
1181
+
1182
+ with contextlib.suppress(OSError, AttributeError):
1183
+ trim = ctypes.CDLL("libc.so.6").malloc_trim
1184
+ trim.argtypes = [ctypes.c_size_t]
1185
+ trim.restype = ctypes.c_int
1186
+
1187
+ def trim_heap() -> None:
1188
+ trim(0)
1189
+
1190
+ return trim_heap
1191
+ return None
1192
+
1193
+
1194
+ def _largest_globals(limit: int) -> list[dict[str, Any]]:
1195
+ sizer = _GlobalSizer()
1196
+ sized: list[dict[str, Any]] = []
1197
+ for name, value in list(USER_NS.items()):
1198
+ if name in _MEMORY_BASELINE or name.startswith("__") or isinstance(value, _SIZER_OPAQUE_TYPES):
1199
+ continue
1200
+ measured = sizer.measure(value)
1201
+ if measured is None or measured[0] < _MEMORY_MIN_REPORTED_BYTES:
1202
+ continue
1203
+ entry: dict[str, Any] = {"name": name, "bytes": measured[0]}
1204
+ if measured[1]:
1205
+ entry["approximate"] = True
1206
+ sized.append(entry)
1207
+ sized.sort(key=lambda entry: entry["bytes"], reverse=True)
1208
+ return sized[:limit]
1209
+
1210
+
1211
+ class _GlobalSizer:
1212
+ """Estimated retained size of user globals: array/frame buffers from numpy and pandas, containers from
1213
+ their length and up to 1,000 sampled elements, one shared visited set and node budget for the walk."""
1214
+
1215
+ def __init__(self) -> None:
1216
+ self.seen: set[int] = set()
1217
+ self.nodes = 0
1218
+ self.approximate = False
1219
+
1220
+ def measure(self, value: Any) -> tuple[int, bool] | None:
1221
+ self.approximate = False
1222
+ try:
1223
+ return int(self.size(value, 0)), self.approximate
1224
+ except Exception: # noqa: BROAD_EXCEPT_OK — an exotic user value (a raising __len__/__iter__) stays unsized.
1225
+ return None
1226
+
1227
+ def size(self, value: Any, depth: int) -> float:
1228
+ if id(value) in self.seen:
1229
+ return 0
1230
+ if depth >= _SIZER_MAX_DEPTH or self.nodes >= _SIZER_NODE_BUDGET:
1231
+ self.approximate = True
1232
+ return 0
1233
+ self.seen.add(id(value))
1234
+ self.nodes += 1
1235
+ buffer = _buffer_bytes(value)
1236
+ if buffer is not None:
1237
+ return buffer
1238
+ own = sys.getsizeof(value)
1239
+ if isinstance(value, _SIZER_LEAF_TYPES) or isinstance(value, _SIZER_OPAQUE_TYPES):
1240
+ return own
1241
+ if isinstance(value, dict):
1242
+ return own + self.sampled(len(value), iter(value.items()), depth, pairs=True)
1243
+ if isinstance(value, (list, tuple)):
1244
+ return own + self.spaced(value, depth)
1245
+ if isinstance(value, (set, frozenset)) or type(value).__module__ == "collections":
1246
+ return own + self.sampled(len(value), iter(value), depth, pairs=False)
1247
+ attributes = _instance_dict(value)
1248
+ return own if attributes is None else own + self.size(attributes, depth + 1)
1249
+
1250
+ def spaced(self, items: Any, depth: int) -> float:
1251
+ count = len(items)
1252
+ if count <= _SIZER_SAMPLED:
1253
+ return sum(self.size(item, depth + 1) for item in items)
1254
+ self.approximate = True
1255
+ step = count / _SIZER_SAMPLED
1256
+ total = sum(self.size(items[int(index * step)], depth + 1) for index in range(_SIZER_SAMPLED))
1257
+ return total / _SIZER_SAMPLED * count
1258
+
1259
+ def sampled(self, count: int, entries: Any, depth: int, *, pairs: bool) -> float:
1260
+ taken = []
1261
+ for entry in entries:
1262
+ taken.append(entry)
1263
+ if len(taken) >= _SIZER_SAMPLED:
1264
+ break
1265
+ if not taken:
1266
+ return 0
1267
+ if len(taken) < count:
1268
+ self.approximate = True
1269
+ total = 0.0
1270
+ for entry in taken:
1271
+ if pairs:
1272
+ total += self.size(entry[0], depth + 1) + self.size(entry[1], depth + 1)
1273
+ else:
1274
+ total += self.size(entry, depth + 1)
1275
+ return total * count / len(taken)
1276
+
1277
+
1278
+ def _buffer_bytes(value: Any) -> int | None:
1279
+ root = type(value).__module__.split(".", 1)[0]
1280
+ if root == "numpy" and isinstance(getattr(value, "nbytes", None), int):
1281
+ return int(value.nbytes)
1282
+ if root == "pandas" and callable(getattr(value, "memory_usage", None)):
1283
+ usage = value.memory_usage(deep=True)
1284
+ return int(usage.sum()) if hasattr(usage, "sum") else int(usage)
1285
+ return None
1286
+
1287
+
1288
+ def _instance_dict(value: Any) -> dict[str, Any] | None:
1289
+ # object.__getattribute__ never falls back to a user __getattr__.
1290
+ try:
1291
+ attributes = object.__getattribute__(value, "__dict__")
1292
+ except AttributeError:
1293
+ return None
1294
+ return attributes if isinstance(attributes, dict) else None
1295
+
1296
+
1297
+ KERNEL_MEMORY = _KernelMemory()
1298
+
990
1299
  TLA_FLAG = getattr(ast, "PyCF_ALLOW_TOP_LEVEL_AWAIT", 0x2000)
991
1300
 
992
1301
 
@@ -1039,6 +1348,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1039
1348
  # SIGINT must interrupt user code here; the idle baseline (set between
1040
1349
  # cells) ignores it so a late signal cannot kill the stdin-read loop.
1041
1350
  signal.signal(signal.SIGINT, signal.default_int_handler)
1351
+ result: dict[str, Any]
1042
1352
  try:
1043
1353
  with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr):
1044
1354
  apply_preludes(preludes)
@@ -1047,7 +1357,7 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1047
1357
  value = LOOP.run_until_complete(run_code(expression, True))
1048
1358
  text("stdout", stdout.getvalue())
1049
1359
  text("stderr", stderr.getvalue())
1050
- result: dict[str, Any] = {
1360
+ result = {
1051
1361
  "type": "result",
1052
1362
  "cellId": cell_id,
1053
1363
  "ok": True,
@@ -1055,21 +1365,26 @@ def run_cell(cell_id: str, code: str, preludes: Any = None) -> None:
1055
1365
  }
1056
1366
  if value is not None:
1057
1367
  result["valueRepr"] = repr(value)
1058
- emit(result)
1059
1368
  except BaseException as exc: # noqa: BROAD_EXCEPT_OK — cell boundary serializes user errors and interrupts.
1060
1369
  text("stdout", stdout.getvalue())
1061
1370
  text("stderr", stderr.getvalue())
1062
- emit(
1063
- {
1064
- "type": "result",
1065
- "cellId": cell_id,
1066
- "ok": False,
1067
- "error": bridge_error(exc),
1068
- "durationMs": elapsed(start),
1069
- }
1070
- )
1371
+ result = {
1372
+ "type": "result",
1373
+ "cellId": cell_id,
1374
+ "ok": False,
1375
+ "error": bridge_error(exc),
1376
+ "durationMs": elapsed(start),
1377
+ }
1071
1378
  finally:
1072
1379
  signal.signal(signal.SIGINT, signal.SIG_IGN)
1380
+ try:
1381
+ memory = KERNEL_MEMORY.after_cell()
1382
+ except Exception as exc: # noqa: BROAD_EXCEPT_OK — a measurement failure must not cost the cell its result.
1383
+ memory = None
1384
+ text("stderr", f"[senpi] kernel memory measurement failed: {exc}\n")
1385
+ if memory is not None:
1386
+ result["memory"] = memory
1387
+ emit(result)
1073
1388
 
1074
1389
 
1075
1390
  def elapsed(start: float) -> int:
@@ -1085,6 +1400,7 @@ def handle(message: dict[str, Any]) -> bool:
1085
1400
  emit({"type": "init-failed", "error": {"message": "missing bridge connection"}})
1086
1401
  return True
1087
1402
  CONNECTION = connection
1403
+ KERNEL_MEMORY.configure(message.get("memory"))
1088
1404
  emit({"type": "ready"})
1089
1405
  return True
1090
1406
  if message_type == "run":
@@ -1,5 +1,6 @@
1
1
  import { dirname, join } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
3
4
  import {
4
5
  type BridgeConnectionConfig,
5
6
  decodeBridgeFrame,
@@ -43,6 +44,7 @@ export interface PythonTransportOptions {
43
44
  /** Per-session PI_* values merged into the interpreter environment at spawn. */
44
45
  readonly sessionEnv?: SessionEnvironment;
45
46
  readonly startupTimeoutMs: number;
47
+ readonly memory?: KernelMemoryThresholds;
46
48
  readonly onMessage?: (message: KernelToHostMessage) => void;
47
49
  readonly spawnProcess?: KernelSpawnProcess;
48
50
  readonly isOwned: () => boolean;
@@ -188,7 +190,8 @@ export class PythonKernelTransport {
188
190
  this.#child.stderr.on("data", onStderr);
189
191
  this.#child.on("error", onError);
190
192
  this.#child.on("exit", onExit);
191
- this.#write({ type: "init", sessionId: this.#options.sessionId, connection: this.#options.connection });
193
+ const { sessionId, connection, memory } = this.#options;
194
+ this.#write({ type: "init", sessionId, connection, ...(memory === undefined ? {} : { memory }) });
192
195
  await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
193
196
  }
194
197
 
@@ -2,7 +2,7 @@ import { join } from "node:path";
2
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
3
  import type { SessionEnvironment } from "../session-env.ts";
4
4
  import { type CodemodeRuntimeAssetEnvironment, requireCodemodeRuntimeAsset } from "../shared/runtime-asset.ts";
5
- import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
5
+ import { SubprocessKernel, type SubprocessKernelMemory, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
6
6
 
7
7
  export interface RubyKernelStartOptions {
8
8
  readonly cwd: string;
@@ -13,6 +13,8 @@ export interface RubyKernelStartOptions {
13
13
  readonly command?: string;
14
14
  readonly spawn?: SubprocessSpawn;
15
15
  readonly onMessage?: (message: KernelToHostMessage) => void;
16
+ /** Ceiling-only memory management (no notice or globals list: the runner reports no memory). */
17
+ readonly memory?: SubprocessKernelMemory;
16
18
  }
17
19
 
18
20
  export interface RubyRunnerPathOptions extends CodemodeRuntimeAssetEnvironment {
@@ -38,6 +40,7 @@ export class RubyKernel extends SubprocessKernel {
38
40
  connection: options.connection,
39
41
  spawn: options.spawn,
40
42
  onMessage: options.onMessage,
43
+ memory: options.memory && { language: "rb", ...options.memory },
41
44
  });
42
45
  }
43
46
  }
@@ -0,0 +1,63 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
+ import type { KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import type { EvalLanguage } from "../../tool/types.ts";
4
+ import { KernelMemoryPolicy } from "./kernel-memory.ts";
5
+
6
+ type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
7
+
8
+ /** Reads another process's memory footprint; `readProcessFootprint` from `@code-yeongyu/senpi` in production. */
9
+ export type FootprintReader = (pid: number) => { readonly bytes: number } | undefined;
10
+
11
+ export interface KernelMemoryHostOptions {
12
+ /**
13
+ * Host-measured kernel (rb, jl): the kernel reports no memory itself, so the host reads the
14
+ * interpreter's footprint after each result. Such a kernel gets the ceiling only - no notice, no globals.
15
+ */
16
+ readonly readFootprint?: FootprintReader;
17
+ }
18
+
19
+ /**
20
+ * The kernel-host half of the memory contract for kernels that restart by replacing their process
21
+ * (py, rb, jl): annotates each settled result through the shared {@link KernelMemoryPolicy} and tells the
22
+ * host when an over-ceiling kernel may restart - only once no cell is running or queued on it, so a cell
23
+ * queued behind the offending one still sees its globals and no result is ever lost.
24
+ */
25
+ export class KernelMemoryHost {
26
+ readonly #policy: KernelMemoryPolicy;
27
+ readonly #readFootprint: FootprintReader | undefined;
28
+
29
+ constructor(language: EvalLanguage, thresholds: KernelMemoryThresholds, options: KernelMemoryHostOptions = {}) {
30
+ this.#readFootprint = options.readFootprint;
31
+ this.#policy =
32
+ this.#readFootprint === undefined
33
+ ? new KernelMemoryPolicy(language, thresholds)
34
+ : new KernelMemoryPolicy(language, { ...thresholds, noticeBytes: 0 }, { collects: false });
35
+ }
36
+
37
+ annotate(result: ResultMessage, pid?: number): ResultMessage {
38
+ const report =
39
+ this.#readFootprint === undefined ? result.memory : this.#footprintReport(this.#readFootprint, pid);
40
+ return report === undefined ? result : { ...result, memory: this.#policy.annotate(report) };
41
+ }
42
+
43
+ /**
44
+ * True when the kernel must restart now: it went over its ceiling and `idle` (nothing running or queued).
45
+ * The next result the kernel produces then carries `recycled: true` and the restarted notice.
46
+ */
47
+ claimRecycle(idle: boolean): boolean {
48
+ if (!idle || !this.#policy.recyclePending) return false;
49
+ this.#policy.recycleStarted();
50
+ return true;
51
+ }
52
+
53
+ processReplaced(): void {
54
+ this.#policy.kernelRetired();
55
+ }
56
+
57
+ #footprintReport(read: FootprintReader, pid: number | undefined) {
58
+ if (pid === undefined) return undefined;
59
+ const footprint = read(pid);
60
+ if (footprint === undefined) return undefined;
61
+ return { liveBytes: Math.round(footprint.bytes), measure: "footprint" as const };
62
+ }
63
+ }
@@ -0,0 +1,136 @@
1
+ import type { KernelMemoryGlobal, KernelMemoryReport, KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
2
+ import type { EvalLanguage } from "../../tool/types.ts";
3
+
4
+ const NOTICE_GROWTH_RATIO = 1.25;
5
+ const NOTICED_GLOBALS = 5;
6
+ const MIB = 1024 * 1024;
7
+ const GIB = 1024 * MIB;
8
+
9
+ interface CeilingBreach {
10
+ readonly liveBytes: number;
11
+ readonly globals: readonly KernelMemoryGlobal[];
12
+ }
13
+
14
+ export interface KernelMemoryPolicyOptions {
15
+ /**
16
+ * The kernel collects before it reports (js, py): only readings taken right after a collection
17
+ * (`gcRan`) count toward the notice and the ceiling. A kernel whose footprint the host reads without
18
+ * any collection (rb, jl) sets this to `false`, and every reading counts toward the ceiling.
19
+ */
20
+ readonly collects?: boolean;
21
+ }
22
+
23
+ /** How a cell drops a global in each language, named in the large-globals notice. */
24
+ const DROP_ADVICE: Readonly<Record<EvalLanguage, (name: string) => string>> = {
25
+ js: (name) => `${name} = undefined`,
26
+ py: (name) => `del ${name}`,
27
+ rb: (name) => `${name} = nil`,
28
+ jl: (name) => `${name} = nothing`,
29
+ };
30
+
31
+ /**
32
+ * Per-kernel memory policy: decides which results carry the large-globals notice (hysteresis per
33
+ * language), when live memory crossed the ceiling, and which result announces the restart. Pure state;
34
+ * the kernel host performs the restart once its run queue is empty.
35
+ */
36
+ export class KernelMemoryPolicy {
37
+ readonly #language: EvalLanguage;
38
+ readonly #thresholds: KernelMemoryThresholds;
39
+ readonly #collects: boolean;
40
+ #lastNotifiedLive = 0;
41
+ #lastObservedLive = 0;
42
+ #pendingRecycle: CeilingBreach | null = null;
43
+ #announceRecycle: CeilingBreach | null = null;
44
+
45
+ constructor(language: EvalLanguage, thresholds: KernelMemoryThresholds, options: KernelMemoryPolicyOptions = {}) {
46
+ this.#language = language;
47
+ this.#thresholds = thresholds;
48
+ this.#collects = options.collects ?? true;
49
+ }
50
+
51
+ get thresholds(): KernelMemoryThresholds {
52
+ return this.#thresholds;
53
+ }
54
+
55
+ get recyclePending(): boolean {
56
+ return this.#pendingRecycle !== null;
57
+ }
58
+
59
+ /** Any measurement below half the notice threshold re-arms the notice for a later large global. */
60
+ observeLive(liveBytes: number): void {
61
+ if (liveBytes < this.#thresholds.noticeBytes / 2) this.#lastNotifiedLive = 0;
62
+ this.#lastObservedLive = liveBytes;
63
+ }
64
+
65
+ annotate(report: KernelMemoryReport): KernelMemoryReport {
66
+ const crossedUpward = this.#lastObservedLive < this.#thresholds.noticeBytes;
67
+ this.observeLive(report.liveBytes);
68
+ const announced = this.#announceRecycle;
69
+ if (announced !== null) {
70
+ this.#announceRecycle = null;
71
+ return { ...report, recycled: true, notice: this.#recycledNotice(announced) };
72
+ }
73
+ if (this.#collects && report.gcRan !== true) return report;
74
+ const { ceilingBytes, noticeBytes } = this.#thresholds;
75
+ if (ceilingBytes > 0 && report.liveBytes >= ceilingBytes) return this.#overCeiling(report);
76
+ if (noticeBytes === 0 || report.liveBytes < noticeBytes) return report;
77
+ const grew = report.liveBytes >= this.#lastNotifiedLive * NOTICE_GROWTH_RATIO;
78
+ if (!crossedUpward && !grew) return report;
79
+ this.#lastNotifiedLive = report.liveBytes;
80
+ return { ...report, notice: this.#sizeNotice(report) };
81
+ }
82
+
83
+ /** The host is restarting the kernel; the next result it produces announces the restart. */
84
+ recycleStarted(): void {
85
+ this.#announceRecycle = this.#pendingRecycle;
86
+ this.#pendingRecycle = null;
87
+ this.#lastNotifiedLive = 0;
88
+ this.#lastObservedLive = 0;
89
+ }
90
+
91
+ /** The worker or process is gone for another reason (reset, crash, stop): its memory went with it. */
92
+ kernelRetired(): void {
93
+ this.#pendingRecycle = null;
94
+ this.#lastNotifiedLive = 0;
95
+ this.#lastObservedLive = 0;
96
+ }
97
+
98
+ #overCeiling(report: KernelMemoryReport): KernelMemoryReport {
99
+ this.#lastNotifiedLive = report.liveBytes;
100
+ if (this.#pendingRecycle !== null) return { ...report, overCeiling: true };
101
+ const breach = { liveBytes: report.liveBytes, globals: report.globals ?? [] };
102
+ this.#pendingRecycle = breach;
103
+ return { ...report, overCeiling: true, notice: this.#ceilingNotice(breach) };
104
+ }
105
+
106
+ #sizeNotice(report: KernelMemoryReport): string {
107
+ const globals = report.globals ?? [];
108
+ const drop = DROP_ADVICE[this.#language](globals[0]?.name ?? "name");
109
+ return `[${this.#language} kernel holds ${formatBytes(report.liveBytes)} ${this.#liveMeasure} (notice at ${formatBytes(this.#thresholds.noticeBytes)}).${globalsPhrase("Largest globals", globals)} Drop what you no longer need (${drop}) or run with reset: true.]`;
110
+ }
111
+
112
+ #ceilingNotice(breach: CeilingBreach): string {
113
+ return `[${this.#language} kernel holds ${formatBytes(breach.liveBytes)} ${this.#liveMeasure}, over the ${formatBytes(this.#thresholds.ceilingBytes)} ceiling. It restarts before the next cell you submit and every global is lost.${globalsPhrase("Largest globals", breach.globals)}]`;
114
+ }
115
+
116
+ #recycledNotice(breach: CeilingBreach): string {
117
+ return `[${this.#language} kernel was restarted before this cell: ${formatBytes(breach.liveBytes)} ${this.#liveMeasure} exceeded the ${formatBytes(this.#thresholds.ceilingBytes)} ceiling. Every global from earlier cells is gone.${globalsPhrase("Largest globals were", breach.globals)}]`;
118
+ }
119
+
120
+ get #liveMeasure(): string {
121
+ return this.#collects ? "live after GC" : "in its process footprint";
122
+ }
123
+ }
124
+
125
+ export function formatBytes(bytes: number): string {
126
+ if (bytes >= GIB) return `${Number((bytes / GIB).toFixed(1))} GB`;
127
+ return `${Math.max(1, Math.round(bytes / MIB))} MB`;
128
+ }
129
+
130
+ function globalsPhrase(label: string, globals: readonly KernelMemoryGlobal[]): string {
131
+ if (globals.length === 0) return "";
132
+ const listed = globals
133
+ .slice(0, NOTICED_GLOBALS)
134
+ .map((global) => `${global.name} ${global.approximate === true ? "~" : ""}${formatBytes(global.bytes)}`);
135
+ return ` ${label}: ${listed.join(", ")}.`;
136
+ }
@@ -1,6 +1,8 @@
1
+ import type { KernelMemoryThresholds } from "../../bridge/memory-protocol.ts";
1
2
  import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
2
- import type { EvalKernelRunInput } from "../../tool/types.ts";
3
+ import type { EvalKernelRunInput, EvalLanguage } from "../../tool/types.ts";
3
4
  import type { SessionEnvironment } from "../session-env.ts";
5
+ import type { FootprintReader } from "./kernel-memory-host.ts";
4
6
  import type { SubprocessSpawn } from "./subprocess-process.ts";
5
7
 
6
8
  export type KernelRunInput = EvalKernelRunInput;
@@ -8,6 +10,12 @@ export type KernelRunInput = EvalKernelRunInput;
8
10
  export type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
9
11
  export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
10
12
 
13
+ /** Ceiling-only memory management: the host reads the interpreter footprint after each result. */
14
+ export interface SubprocessKernelMemory {
15
+ readonly thresholds: KernelMemoryThresholds;
16
+ readonly readFootprint: FootprintReader;
17
+ }
18
+
11
19
  export interface SubprocessKernelOptions {
12
20
  readonly command: string;
13
21
  readonly args: readonly string[];
@@ -19,4 +27,5 @@ export interface SubprocessKernelOptions {
19
27
  readonly connection: BridgeConnectionConfig;
20
28
  readonly spawn?: SubprocessSpawn;
21
29
  readonly onMessage?: (message: KernelToHostMessage) => void;
30
+ readonly memory?: SubprocessKernelMemory & { readonly language: EvalLanguage };
22
31
  }