pi-repl-py 0.6.9 → 0.6.11

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.
@@ -138,10 +138,17 @@ only itself) and publishes the result back over a private MIME payload. The host
138
138
  `namespace.snapshot`, keyed to the session file under
139
139
  `~/.pi/agent/pi-repl/state/<session>/`.
140
140
 
141
- When a fresh engine is built, it restores that snapshot. It reports the names of values that
142
- could not be pickled, such as live handles and some runtime objects. If the evaluator was rebuilt mid-session,
143
- the result is prefixed with a `<repl_engine_reset>` block that names what was revived and
144
- what was lost, so the model re-verifies before reusing state that may be gone.
141
+ When a fresh engine is built, it restores that snapshot. Values are pickled entry by entry, and
142
+ functions and classes defined in cells are captured by source and re-executed on restore (plain
143
+ pickle cannot revive them, since they live in `__main__`). Bindings that still fail live
144
+ handles, open resources, source-less functions are reported by name, never dropped silently.
145
+ Entries are capped per-binding and in total (128 MiB default), and the snapshot file is written
146
+ via temp-file-and-rename so a crash cannot corrupt the last good copy; old session snapshot
147
+ directories are pruned to the newest 25. If the evaluator was rebuilt mid-session, the next
148
+ cell's result is prefixed with a `<repl_engine_reset>` block that names what was revived and
149
+ what was lost, so the model re-verifies before reusing state that may be gone. A resumed
150
+ conversation announces the same block on its first cell, but only when the conversation has a
151
+ saved past; a first-ever session starts quiet.
145
152
 
146
153
  ## Failure modes
147
154
 
package/docs/design.md CHANGED
@@ -47,6 +47,7 @@ that failed. A cell that wedges the *whole* kernel instead stops cells from runn
47
47
  the next call notices the dead kernel and rebuilds it from the last completed snapshot.
48
48
  Either way the result carries a `<repl_engine_reset>` notice that names what the rebuild
49
49
  revived and what it lost, so the model re-verifies before trusting state that may be gone.
50
+ A resumed conversation gets the same notice on its first cell when it has a saved past.
50
51
  (How that machinery works is in `ARCHITECTURE.md`.)
51
52
 
52
53
  ## The venv as part of the design
package/index.ts CHANGED
@@ -5,7 +5,7 @@ import { homedir } from "node:os";
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { Type } from "typebox";
7
7
  import { withSkillsBlock } from "./src/extension/skill-hook.js";
8
- import { EngineManager } from "./src/engine/index.js";
8
+ import { EngineManager, pruneSnapshotDirs } from "./src/engine/index.js";
9
9
  import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
10
10
  import { EngineLifecycle } from "./src/extension/session-engine.js";
11
11
  import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
@@ -63,6 +63,12 @@ export default function (pi: ExtensionAPI) {
63
63
  const sessionKey = sessionFile ? basename(sessionFile).replace(/\.jsonl$/, "") : undefined;
64
64
  // --- kernel namespace state lives under ~/.pi/agent/pi-repl, keyed by session, so it never clutters the project ---
65
65
  const stateDir = join(homedir(), ".pi", "agent", "pi-repl", "state", sessionKey ?? "ephemeral");
66
+ // --- keep the state root from growing one dir per session forever; the live dir is exempt ---
67
+ if (sessionKey) {
68
+ try {
69
+ pruneSnapshotDirs(join(stateDir, ".."), 25, sessionKey);
70
+ } catch {}
71
+ }
66
72
  return new EngineManager({
67
73
  cwd,
68
74
  // --- snapshots are keyed to a session file; ephemeral sessions get none ---
@@ -93,7 +99,7 @@ export default function (pi: ExtensionAPI) {
93
99
  location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
94
100
  void lifecycle.acquire("startup").catch(() => {
95
101
  // --- boot/revive handled on the execute path; swallow so a background warm can never
96
- // --- surface an unhandled rejection and the model never needs the restore notice ---
102
+ // --- surface an unhandled rejection. A resume's notice lands on the first cell. ---
97
103
  });
98
104
  });
99
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-repl-py",
3
- "version": "0.6.9",
3
+ "version": "0.6.11",
4
4
  "type": "module",
5
5
  "description": "A pi extension with a single tool: execute, running a TypeScript host with a persistent Python (ipykernel) evaluator and a user-configurable toolbox of functions.",
6
6
  "keywords": [
@@ -2,10 +2,10 @@
2
2
  // --- ZMTP directly (no guest.py middleman). Owns venv resolution, spawn, queue, ---
3
3
  // --- snapshots, abort grace, and teardown — the wire lives in kernel.ts. ---
4
4
 
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
6
6
  import { homedir } from "node:os";
7
7
  import { dirname, join } from "node:path";
8
- import { KernelClient } from "./kernel.js";
8
+ import { KernelClient, type SnapshotEntry } from "./kernel.js";
9
9
 
10
10
  function installVenvPython(): string {
11
11
  return join(homedir(), ".pi", "agent", "pi-repl", "venv", "bin", "python3");
@@ -27,6 +27,9 @@ const DEFAULT_MAX_OUTPUT_CHARS = 46080;
27
27
  export const MAX_OUTPUT_LINE_CHARS = 4096;
28
28
  const ABORT_GRACE_MS = 20_000;
29
29
  const DEFAULT_SNAPSHOT_DEBOUNCE_MS = 1500;
30
+ /** Total snapshot size cap (base64 payload). Per-entry entries are capped at the same
31
+ * bound; larger bindings are reported as skipped names. Mirrors the pi-codex scheme. */
32
+ const DEFAULT_SNAPSHOT_MAX_BYTES = 128 * 1024 * 1024;
30
33
 
31
34
  interface EngineExecuteError {
32
35
  /** Error class name, e.g. "TypeError". */
@@ -76,6 +79,8 @@ export interface EngineOptions {
76
79
  path: string;
77
80
  /** Debounce for the auto-snapshot after each ok cell. Default 1500 ms. */
78
81
  debounceMs?: number;
82
+ /** Total base64 payload cap; also the per-entry cap. Oversized entries are skipped with a reason. Default 128 MiB. */
83
+ maxBytes?: number;
79
84
  };
80
85
  }
81
86
 
@@ -109,6 +114,33 @@ export function capLinesForContext(text: string): { text: string; trimmed: boole
109
114
  return { text: mapped.join("\n"), trimmed };
110
115
  }
111
116
 
117
+ const DEFAULT_KEEP_SNAPSHOTS = 25;
118
+
119
+ /** Scan the state root for per-session snapshot dirs and delete all but the newest `keep`,
120
+ * so a long-lived machine does not accumulate one directory per session forever. The
121
+ * current session's dir is exempt; a snapshot dir without a usable manifest is ignored. */
122
+ export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP_SNAPSHOTS, currentDir?: string): void {
123
+ const entries: { dir: string; mtimeMs: number }[] = [];
124
+ try {
125
+ for (const name of readdirSync(stateRoot, { withFileTypes: true })) {
126
+ if (!name.isDirectory() || name.name === currentDir) continue;
127
+ try {
128
+ const manifest = join(stateRoot, name.name, "namespace.snapshot");
129
+ if (!existsSync(manifest)) continue;
130
+ entries.push({ dir: join(stateRoot, name.name), mtimeMs: statSync(manifest).mtimeMs });
131
+ } catch {}
132
+ }
133
+ } catch {
134
+ return;
135
+ }
136
+ entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
137
+ for (const { dir } of entries.slice(keep)) {
138
+ try {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ } catch {}
141
+ }
142
+ }
143
+
112
144
  export class EngineManager {
113
145
  private readonly options: EngineOptions;
114
146
  private kernel?: KernelClient;
@@ -302,12 +334,16 @@ export class EngineManager {
302
334
  const config = this.options.snapshot;
303
335
  if (!config || this.state !== "running" || !this.kernel) return null;
304
336
  try {
305
- const reply = await this.kernel.snapshot();
337
+ const reply = await this.kernel.snapshot(config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
306
338
  // --- an incomplete snapshot must not overwrite the last good file ---
307
339
  if (reply.complete === false) return null;
308
340
  mkdirSync(dirname(config.path), { recursive: true });
309
- writeFileSync(config.path, JSON.stringify({ version: 1, vars: reply.vars, failed: reply.failed }));
310
- return { path: config.path, saved: Object.keys(reply.vars), failed: reply.failed };
341
+ // --- write to a temp file then rename so a crash mid-write can never corrupt
342
+ // --- the last good snapshot (the restore side parses or returns null) ---
343
+ const tmp = `${config.path}.tmp`;
344
+ writeFileSync(tmp, JSON.stringify({ version: 2, entries: reply.entries, failed: reply.failed }));
345
+ renameSync(tmp, config.path);
346
+ return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
311
347
  } catch {
312
348
  return null;
313
349
  }
@@ -319,15 +355,29 @@ export class EngineManager {
319
355
  if (!existsSync(config.path)) return null;
320
356
  await this.start();
321
357
  try {
322
- const payload = JSON.parse(readFileSync(config.path, "utf8")) as { vars?: Record<string, string> };
323
- const vars = payload.vars ?? {};
324
- const reply = await this.kernel!.restore(vars);
358
+ const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
359
+ version?: number;
360
+ entries?: SnapshotEntry[];
361
+ vars?: Record<string, string>;
362
+ };
363
+ // --- version 1 files (pre-source-capture) are still restorable: their vars are plain pickles ---
364
+ const entries: SnapshotEntry[] =
365
+ payload.version === 2
366
+ ? (payload.entries ?? [])
367
+ : Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
368
+ const reply = await this.kernel!.restore(entries);
325
369
  return { path: config.path, restored: reply.restored, failed: reply.failed };
326
370
  } catch {
327
371
  return null;
328
372
  }
329
373
  }
330
374
 
375
+ /** The conversation's state dir exists, so this engine is a resume, not a first run. */
376
+ hasSnapshotHistory(): boolean {
377
+ const config = this.options.snapshot;
378
+ return config ? existsSync(dirname(config.path)) : false;
379
+ }
380
+
331
381
  async listNamespaceNames(): Promise<string[] | null> {
332
382
  if (this.state !== "running" || !this.kernel) return null;
333
383
  try {
@@ -47,8 +47,15 @@ export interface CellOptions {
47
47
  maxOutputChars?: number;
48
48
  }
49
49
 
50
+ export interface SnapshotEntry {
51
+ name: string;
52
+ /** "value" pickles the object; "def" re-executes captured source (functions and classes). */
53
+ kind: "value" | "def";
54
+ payload: string;
55
+ }
56
+
50
57
  export interface SnapshotReply {
51
- vars: Record<string, string>;
58
+ entries: SnapshotEntry[];
52
59
  failed: { name: string; reason: string }[];
53
60
  complete: boolean;
54
61
  }
@@ -88,39 +95,130 @@ function buildSkipList(helperNames: string[]): string {
88
95
  return JSON.stringify([...names]);
89
96
  }
90
97
 
91
- function snapshotCode(helperNames: string[]): string {
98
+ function snapshotCode(helperNames: string[], maxBytes: number): string {
92
99
  const skip = buildSkipList(helperNames);
93
- return (
94
- "import pickle as _pk, base64 as _b64, json as _js\n" +
95
- `__repl_skip = set(${skip})\n` +
96
- "__repl_v = {}\n__repl_f = []\n" +
97
- "for _k, _v in list(globals().items()):\n" +
98
- " if _k.startswith('_') or _k in __repl_skip:\n" +
99
- " continue\n" +
100
- " try:\n" +
101
- " __repl_v[_k] = _b64.b64encode(_pk.dumps(_v)).decode()\n" +
102
- " except Exception as _e:\n" +
103
- " __repl_f.append({'name': _k, 'reason': str(_e)})\n" +
104
- `get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'vars': __repl_v, 'failed': __repl_f})})\n`
105
- );
100
+ // --- functions and classes defined in cells cannot be pickled by reference, so their
101
+ // --- source is captured instead and re-executed on restore. getsource works for
102
+ // --- functions because the code object carries the cell's filename in linecache; for
103
+ // --- classes inspect's module-file lookup misses, so a class is captured by locating
104
+ // --- its header from a member method's co_firstlineno and dedent-scanning the block.
105
+ // --- fallback pickles the value and reports it if that also fails; per-entry and total
106
+ // --- byte caps mirror the pi-codex scheme: oversized bindings become skipped names.
107
+ return `import pickle as _pk, base64 as _b64, json as _js, inspect as _in, linecache as _lc
108
+ def _repl_class_source(_c):
109
+ _m = getattr(_c, '__init__', None)
110
+ if _m is None or not _in.isfunction(_m):
111
+ for _v in vars(_c).values():
112
+ if _in.isfunction(_v):
113
+ _m = _v
114
+ break
115
+ if _m is None:
116
+ raise ValueError('class has no member methods')
117
+ _start = _m.__code__.co_firstlineno
118
+ _all = _lc.getlines(_m.__code__.co_filename)
119
+ if not _all:
120
+ raise ValueError('source not in linecache')
121
+ _ln = _start - 1
122
+ while _ln > 0:
123
+ _prev = _all[_ln - 1].lstrip()
124
+ if _prev.startswith('class ') and _c.__name__ in _prev:
125
+ break
126
+ _ln -= 1
127
+ if _ln == 0:
128
+ raise ValueError('class header not found')
129
+ _head = _ln - 1
130
+ while _head > 0:
131
+ _p = _all[_head - 1].lstrip()
132
+ if _p == '' or _p.startswith('@'):
133
+ _head -= 1
134
+ else:
135
+ break
136
+ _indent = len(_all[_head]) - len(_all[_head].lstrip())
137
+ _block = [_all[_head]]
138
+ _j = _head + 1
139
+ while _j < len(_all):
140
+ _line = _all[_j]
141
+ if _line.strip() == '':
142
+ _block.append(_line)
143
+ _j += 1
144
+ continue
145
+ if len(_line) - len(_line.lstrip()) > _indent:
146
+ _block.append(_line)
147
+ _j += 1
148
+ else:
149
+ break
150
+ return ''.join(_block)
151
+ __repl_skip = set(${skip})
152
+ __repl_max = ${maxBytes}
153
+ __repl_e = []
154
+ __repl_f = []
155
+ __repl_total = 0
156
+ for _k, _v in list(globals().items()):
157
+ if _k.startswith('_') or _k in __repl_skip:
158
+ continue
159
+ __repl_p = None
160
+ __repl_kind = 'value'
161
+ try:
162
+ if _in.isfunction(_v):
163
+ __repl_src = _in.getsource(_v)
164
+ if __repl_src:
165
+ __repl_p = _b64.b64encode(__repl_src.encode()).decode()
166
+ __repl_kind = 'def'
167
+ elif _in.isclass(_v):
168
+ __repl_src = _repl_class_source(_v)
169
+ if __repl_src:
170
+ __repl_p = _b64.b64encode(__repl_src.encode()).decode()
171
+ __repl_kind = 'def'
172
+ except Exception:
173
+ __repl_p = None
174
+ __repl_kind = 'value'
175
+ try:
176
+ if __repl_p is None:
177
+ __repl_p = _b64.b64encode(_pk.dumps(_v)).decode()
178
+ __repl_b = len(__repl_p)
179
+ if __repl_b > __repl_max:
180
+ __repl_f.append({'name': _k, 'reason': 'exceeds per-entry snapshot cap'})
181
+ elif __repl_total + __repl_b > __repl_max:
182
+ __repl_f.append({'name': _k, 'reason': 'exceeds total snapshot cap'})
183
+ else:
184
+ __repl_e.append({'name': _k, 'kind': __repl_kind, 'payload': __repl_p})
185
+ __repl_total += __repl_b
186
+ except Exception as _e:
187
+ __repl_f.append({'name': _k, 'reason': str(_e)})
188
+ get_ipython().display_pub.publish({${JSON.stringify(SNAPSHOT_MIME)}: _js.dumps({'version': 2, 'entries': __repl_e, 'failed': __repl_f})})`;
106
189
  }
107
190
 
108
- function restoreCode(vars_: Record<string, string>): string {
109
- const entries = Object.entries(vars_)
110
- .map(([name, b64]) => {
191
+ function restoreCode(entries: SnapshotEntry[]): string {
192
+ const per = entries
193
+ .map(({ name, kind, payload }) => {
111
194
  const n = JSON.stringify(name);
112
- return (
113
- `try:\n globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(b64)}))\n __repl_r['restored'].append(${n})\n` +
114
- `except Exception as _e:\n __repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`
115
- );
195
+ const body =
196
+ kind === "def"
197
+ ? // re-execute captured source and register it in linecache under the code
198
+ // object's filename so a later snapshot can capture it as source again;
199
+ // exec also binds the name the source defines.
200
+ `__repl_src = _b64.b64decode(${JSON.stringify(payload)}).decode()
201
+ exec(__repl_src, globals())
202
+ __repl_obj = globals().get(${n})
203
+ if __repl_obj is not None:
204
+ __repl_fname = getattr(getattr(__repl_obj, '__code__', None), 'co_filename', None)
205
+ if __repl_fname is None:
206
+ __repl_init = getattr(__repl_obj, '__init__', None)
207
+ __repl_fname = getattr(getattr(__repl_init, '__code__', None), 'co_filename', None)
208
+ if __repl_fname:
209
+ _lc.cache[__repl_fname] = (len(__repl_src.splitlines()), None, __repl_src.splitlines(True), __repl_fname)`
210
+ : `globals()[${n}] = _pk.loads(_b64.b64decode(${JSON.stringify(payload)}))`;
211
+ return `try:
212
+ ${body}
213
+ __repl_r['restored'].append(${n})
214
+ except Exception as _e:
215
+ __repl_r['failed'].append({'name': ${n}, 'reason': str(_e)})`;
116
216
  })
117
217
  .join("\n");
118
- return (
119
- "import pickle as _pk, base64 as _b64, json as _js\n" +
120
- "__repl_r = {'restored': [], 'failed': []}\n" +
121
- entries +
122
- `\nget_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})\n`
123
- );
218
+ return `import pickle as _pk, base64 as _b64, json as _js, linecache as _lc
219
+ __repl_r = {'restored': [], 'failed': []}
220
+ ${per}
221
+ get_ipython().display_pub.publish({${JSON.stringify(RESTORE_MIME)}: _js.dumps(__repl_r)})`;
124
222
  }
125
223
 
126
224
  function namesCode(helperNames: string[]): string {
@@ -547,29 +645,35 @@ export class KernelClient {
547
645
  this.control?.send(this.session.buildFrames("interrupt_request", {}, null));
548
646
  }
549
647
 
550
- snapshot(): Promise<SnapshotReply> {
648
+ snapshot(maxBytes: number): Promise<SnapshotReply> {
551
649
  return this.enqueue(async () => {
552
- const res = await this.executeCellNow(snapshotCode(this.helperSources.map((h) => h.name)), {
553
- maxOutputChars: 8_000_000,
554
- });
650
+ const res = await this.executeCellNow(
651
+ snapshotCode(
652
+ this.helperSources.map((h) => h.name),
653
+ maxBytes,
654
+ ),
655
+ {
656
+ maxOutputChars: 8_000_000,
657
+ },
658
+ );
555
659
  const payload = res.payloads[SNAPSHOT_MIME];
556
- if (payload === undefined) return { vars: {}, failed: [], complete: false };
660
+ if (payload === undefined) return { entries: [], failed: [], complete: false };
557
661
  try {
558
662
  const obj = JSON.parse(payload) as {
559
- vars?: Record<string, string>;
663
+ entries?: SnapshotEntry[];
560
664
  failed?: { name: string; reason: string }[];
561
665
  };
562
- return { vars: obj.vars ?? {}, failed: obj.failed ?? [], complete: true };
666
+ return { entries: obj.entries ?? [], failed: obj.failed ?? [], complete: true };
563
667
  } catch {
564
- return { vars: {}, failed: [], complete: false };
668
+ return { entries: [], failed: [], complete: false };
565
669
  }
566
670
  });
567
671
  }
568
672
 
569
- restore(vars_: Record<string, string>): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
570
- if (Object.keys(vars_).length === 0) return Promise.resolve({ restored: [], failed: [] });
673
+ restore(entries: SnapshotEntry[]): Promise<{ restored: string[]; failed: { name: string; reason: string }[] }> {
674
+ if (entries.length === 0) return Promise.resolve({ restored: [], failed: [] });
571
675
  return this.enqueue(async () => {
572
- const res = await this.executeCellNow(restoreCode(vars_), { maxOutputChars: 8_000_000 });
676
+ const res = await this.executeCellNow(restoreCode(entries), { maxOutputChars: 8_000_000 });
573
677
  const payload = res.payloads[RESTORE_MIME];
574
678
  if (payload === undefined) return { restored: [], failed: [] };
575
679
  try {
@@ -2,21 +2,21 @@
2
2
  // description = rich short behavior; promptSnippet = one-liner; guidelines = flat bullets.
3
3
 
4
4
  export const executeToolDescription =
5
- "Execute Python cells in a persistent Python shell that is your entire workspace: it is where you read, " +
6
- "write, run, and move, all in one instrument. The state you build, files, and subprocesses survive " +
7
- "from one call to the next. Returns stdout, stderr, and the value of the last expression. Output is " +
8
- "truncated to 45K with a marker.";
5
+ "Execute Python in a Jupyter notebook. Your workspace is one notebook where every cell runs in a " +
6
+ "shared Python environment: variables, functions, imports, classes, and data defined in one cell " +
7
+ "stay available to later cells for the life of the notebook. Cells return their last expression " +
8
+ "(auto-displayed) plus stdout/stderr; output is truncated to 45K with a marker.";
9
9
 
10
- export const executePromptSnippet = "Execute Python in a persistent shell (read, write, run, search, and more)";
10
+ export const executePromptSnippet = "Run Python cells in a Jupyter notebook (read, write, run, search, and more)";
11
11
 
12
12
  // --- the model-facing guidelines, flat bullets like pi's own tool contributions ---
13
13
  export function buildPromptGuidelines(preloaded: string[]): string[] {
14
14
  return [
15
- "Find, filter, fetch, sample: narrow the output in Python, then print only the exact slice you need.",
16
- "Make surgical, precise changes over rewrites or whole-file dumps: a small unique anchor, replace, verify, read the file back before trusting it.",
17
- "Reference what the persistent shell already holds, don't redefine it.",
18
15
  "Write modern idiomatic Python.",
19
- "If output begins with <repl_engine_reset>, the kernel rebuilt; re-verify a revived variable.",
16
+ "Find, filter, fetch, sample: narrow the output in Python, then print only the window that decides the next step (a head, a shape, a slice), not the whole.",
17
+ "Make surgical, precise changes over rewrites or whole-file dumps: a small unique anchor, replace, verify, read the file back before trusting it.",
18
+ "Prefer to reuse existing variables, functions, imports, classes, and data from prior cells/namespaces over recomputing.",
19
+ "If output begins with <repl_engine_reset>, the runtime rebuilt and the notebook was restored from the last snapshot; reverify surviving names before building on them.",
20
20
  ...(preloaded.length
21
21
  ? [
22
22
  [
@@ -278,17 +278,31 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
278
278
  const code = state.code.trimEnd();
279
279
  if (!code) return false;
280
280
  lines.push("");
281
+ let perWidth = codeWrapCache.get(state);
282
+ if (!perWidth) {
283
+ perWidth = new Map();
284
+ codeWrapCache.set(state, perWidth);
285
+ }
286
+ const cached = perWidth.get(width);
287
+ if (cached?.code === code) {
288
+ lines.push(...cached.lines);
289
+ return true;
290
+ }
291
+ const fresh: string[] = [];
281
292
  const highlighted = highlightLines(code, deps);
282
293
  for (const [index, rawLine] of code.split("\n").entries()) {
283
294
  const prefix = index === 0 ? deps.fg("dim", "› ") : deps.fg("dim", " ");
284
295
  const paint = (id: string) => deps.fg("syntaxVariable", id);
285
296
  const hlLine = colorBareIdentifiers(highlighted[index] ?? rawLine, paint);
286
297
  const indent = /^[ \t]*/.exec(rawLine)?.[0] ?? "";
287
- addWrapped(lines, prefix, hlLine, width, deps, {
298
+ addWrapped(fresh, prefix, hlLine, width, deps, {
288
299
  sanitize: false,
289
300
  indentAfter: deps.visibleWidth(indent),
290
301
  });
291
302
  }
303
+ boundWidthCache(perWidth);
304
+ perWidth.set(width, { code, lines: fresh });
305
+ lines.push(...fresh);
292
306
  return true;
293
307
  }
294
308
 
@@ -307,6 +321,28 @@ interface BlobWrapEntry {
307
321
  }
308
322
 
309
323
  const blobWrapCache = new WeakMap<ExecuteRenderState, Map<number, BlobWrapEntry>>();
324
+ /** Wrapped+highlighted rows rebuild only when their inputs (text/width) change; the
325
+ * TUI re-renders bodies on every frame, so these caches turn per-frame work into
326
+ * one build per change. They live on the persistent per-call state, which the host
327
+ * keeps for the session, so a small bound per state keeps resize churn bounded. */
328
+ interface CodeEntry {
329
+ code: string;
330
+ lines: string[];
331
+ }
332
+ const codeWrapCache = new WeakMap<ExecuteRenderState, Map<number, CodeEntry>>();
333
+
334
+ /** Window resizes add a per-width entry per cell; keep a small bound so a long
335
+ * session with resize churn cannot grow the wrap caches without limit. Map
336
+ * iteration order is insertion order, so evicting the first key drops the oldest
337
+ * width rather than the one in use. */
338
+ const MAX_CACHED_WIDTHS_PER_STATE = 3;
339
+ function boundWidthCache(perWidth: Map<number, unknown>): void {
340
+ while (perWidth.size >= MAX_CACHED_WIDTHS_PER_STATE) {
341
+ const oldest = perWidth.keys().next().value as number | undefined;
342
+ if (oldest === undefined) return;
343
+ perWidth.delete(oldest);
344
+ }
345
+ }
310
346
 
311
347
  const CJK_WIDE_RE =
312
348
  /[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u;
@@ -425,6 +461,7 @@ function wrapBlob(state: ExecuteRenderState, width: number, text: string, color:
425
461
  }
426
462
  }
427
463
  const entry: BlobWrapEntry = { text, color, lines, partial };
464
+ boundWidthCache(perWidth);
428
465
  perWidth.set(width, entry);
429
466
  return entry;
430
467
  };
@@ -11,6 +11,8 @@ function summarizeNames(names: readonly string[], limit: number): string {
11
11
  /** The part of EngineManager this lifecycle needs; narrowed so tests can fake it. */
12
12
  export interface RevivableEngine {
13
13
  restoreState(): Promise<RestoreResult | null>;
14
+ /** True when this conversation's state dir already exists, so the engine was resumed. */
15
+ hasSnapshotHistory(): boolean;
14
16
  }
15
17
 
16
18
  export interface EngineLifecycleDeps<E extends RevivableEngine> {
@@ -22,32 +24,43 @@ export interface EngineLifecycleDeps<E extends RevivableEngine> {
22
24
  discard?(engine: E): Promise<void>;
23
25
  }
24
26
 
25
- /** `startup` is announced in the transcript; `cell` means an engine was rebuilt mid-session and needs an in-band notice. */
27
+ /** `startup` restores then announces on the first cell when the conversation has a saved past; `cell` means an engine was rebuilt mid-session and announces immediately. */
26
28
  export type AcquireOrigin = "startup" | "cell";
27
29
 
28
- function formatEngineResetNotice(restore: RestoreResult | null): string {
30
+ function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
31
+ const resumed = origin === "startup";
29
32
  const lines = ["<repl_engine_reset>"];
30
33
  if (!restore) {
31
34
  // --- no snapshot at all: namespace is genuinely empty ---
32
35
  lines.push(
33
- "The evaluator restarted and its namespace is empty; no snapshot was available to revive.",
34
- "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
36
+ resumed
37
+ ? "This session's evaluator started fresh, and no saved snapshot was available to revive; the namespace is empty."
38
+ : "The evaluator restarted and its namespace is empty; no snapshot was available to revive.",
39
+ resumed
40
+ ? "Names from earlier in this conversation are gone. Rebuild what you need before using it."
41
+ : "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
35
42
  );
36
43
  } else if (restore.restored.length === 0) {
37
44
  // --- a snapshot existed but restored nothing; say why, don't claim "no snapshot" ---
38
45
  lines.push(
39
- "The evaluator restarted and a snapshot was found, but nothing in it could be revived.",
46
+ resumed
47
+ ? "This session's evaluator started fresh. A saved snapshot was found, but nothing in it could be revived."
48
+ : "The evaluator restarted and a snapshot was found, but nothing in it could be revived.",
40
49
  restore.failed.length > 0
41
50
  ? `Failed to revive (${restore.failed.length}): ${summarizeNames(
42
51
  restore.failed.map((f) => f.name),
43
52
  20,
44
53
  )}`
45
54
  : "The snapshot was empty.",
46
- "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
55
+ resumed
56
+ ? "Names from earlier in this conversation are gone. Rebuild what you need before using it."
57
+ : "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
47
58
  );
48
59
  } else {
49
60
  lines.push(
50
- "The evaluator restarted. Its namespace was rebuilt from the last snapshot, so it may be behind.",
61
+ resumed
62
+ ? "This session's evaluator started fresh and restored the namespace saved by this conversation's last run, so it may be empty or behind."
63
+ : "The evaluator restarted. Its namespace was rebuilt from the last snapshot, so it may be behind.",
51
64
  `Revived (${restore.restored.length}): ${summarizeNames(restore.restored, 20)}`,
52
65
  );
53
66
  if (restore.failed.length > 0) {
@@ -56,7 +69,7 @@ function formatEngineResetNotice(restore: RestoreResult | null): string {
56
69
  restore.failed.map((f) => f.name),
57
70
  20,
58
71
  )}`,
59
- "Functions, classes, and live handles cannot be snapshotted; redefine them.",
72
+ "Live handles, open resources, and source-less functions cannot be snapshotted; redefine them.",
60
73
  );
61
74
  }
62
75
  lines.push("Anything defined after the last snapshot is also gone.");
@@ -91,7 +104,11 @@ export class EngineLifecycle<E extends RevivableEngine> {
91
104
  this.engine = engine;
92
105
  this.revival = engine.restoreState().catch(() => null);
93
106
  const restore = await this.revival;
94
- if (origin === "cell") this.pendingNotice = formatEngineResetNotice(restore);
107
+ // --- mid-session rebuilds always announce; startup announces only when the
108
+ // --- conversation has a saved past, so a first-ever session stays quiet ---
109
+ if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
110
+ this.pendingNotice = formatEngineResetNotice(restore, origin);
111
+ }
95
112
  return { engine, restore, created: true };
96
113
  })();
97
114
  this.acquiring = build;