pi-repl-py 0.6.10 → 0.6.12
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/docs/ARCHITECTURE.md +23 -4
- package/docs/design.md +1 -0
- package/index.ts +21 -5
- package/package.json +1 -1
- package/src/engine/index.ts +96 -8
- package/src/engine/kernel.ts +143 -39
- package/src/engine/session.ts +7 -1
- package/src/extension/render-core.ts +38 -1
- package/src/extension/session-engine.ts +57 -12
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -102,6 +102,14 @@ backstop for cells wedged in C code (which ignore interrupts), the engine gives
|
|
|
102
102
|
cell up to 20 seconds to settle and keeps the kernel if it does; only a cell that is still
|
|
103
103
|
running after that grace is killed, and the next call rebuilds from the last snapshot.
|
|
104
104
|
|
|
105
|
+
**History is off.** Every execute goes out with `store_history: false`. IPython's `In`/`Out`
|
|
106
|
+
retention keeps every last-expression result object alive in the kernel, and that retention
|
|
107
|
+
cannot be reclaimed from a user cell — deleting `Out` and `_`/`__`/`___` from `user_ns`
|
|
108
|
+
followed by `gc.collect()` leaves the objects alive (measured: 62 MB idle grows past 400 MB
|
|
109
|
+
after two bare big results and never comes back). Disabling history bounds the kernel to at
|
|
110
|
+
most the latest result. The transcript is the record instead, and results still publish over
|
|
111
|
+
iopub: single-mode execution calls `sys.displayhook` regardless of `store_history`.
|
|
112
|
+
|
|
105
113
|
## Helpers loading
|
|
106
114
|
|
|
107
115
|
At boot, the kernel and the host both read the same merged helper list (project
|
|
@@ -138,10 +146,21 @@ only itself) and publishes the result back over a private MIME payload. The host
|
|
|
138
146
|
`namespace.snapshot`, keyed to the session file under
|
|
139
147
|
`~/.pi/agent/pi-repl/state/<session>/`.
|
|
140
148
|
|
|
141
|
-
When a fresh engine is built, it restores that snapshot.
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
When a fresh engine is built, it restores that snapshot. Values are pickled entry by entry, and
|
|
150
|
+
functions and classes defined in cells are captured by source and re-executed on restore (plain
|
|
151
|
+
pickle cannot revive them, since they live in `__main__`). Bindings that still fail — live
|
|
152
|
+
handles, open resources, source-less functions — are reported by name, never dropped silently.
|
|
153
|
+
Entries are capped per-binding and in total (128 MiB default), and the snapshot file is written
|
|
154
|
+
via temp-file-and-rename so a crash cannot corrupt the last good copy; old session snapshot
|
|
155
|
+
directories are pruned to the newest 25, and snapshot dirs whose owning conversation file no
|
|
156
|
+
longer exists in any project session root are swept entirely (deleting a conversation deletes
|
|
157
|
+
its snapshots with it). "ephemeral" and the live session are always exempt. If the evaluator was rebuilt mid-session, the next
|
|
158
|
+
cell's result is prefixed with a `<repl_engine_reset>` block that names what was revived and
|
|
159
|
+
what was lost, so the model re-verifies before reusing state that may be gone. The human gets
|
|
160
|
+
only a terse `ui.notify` toast ("repl kernel rebuilt, 3 names revived") instead of the marker;
|
|
161
|
+
the two are derived from the same restore result, so they never disagree. A resumed
|
|
162
|
+
conversation announces the same pair on its first cell, but only when the conversation has a
|
|
163
|
+
saved past; a first-ever session stays quiet.
|
|
145
164
|
|
|
146
165
|
## Failure modes
|
|
147
166
|
|
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,9 +5,9 @@ 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, pruneOrphanedSnapshotDirs, pruneSnapshotDirs } from "./src/engine/index.js";
|
|
9
9
|
import { ExecuteCellComponent, type ExecuteDetails, type ExecuteRenderState } from "./src/extension/render.js";
|
|
10
|
-
import { EngineLifecycle } from "./src/extension/session-engine.js";
|
|
10
|
+
import { EngineLifecycle, formatResetToast } from "./src/extension/session-engine.js";
|
|
11
11
|
import { EXECUTE_DESCRIPTION, buildExecutePromptGuidelines, EXECUTE_PROMPT_SNIPPET } from "./src/extension/tool-meta.js";
|
|
12
12
|
|
|
13
13
|
const executeSchema = Type.Object({
|
|
@@ -63,6 +63,19 @@ 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
|
+
// --- cascade deletions: if a conversation is deleted, its snapshots die with it.
|
|
72
|
+
// --- sessionFile is sessions/<project-root>/<name>.jsonl, so the sessions root is
|
|
73
|
+
// --- two parent hops up; dirs whose conversation file exists in no project root
|
|
74
|
+
// --- (and that aren't this session or the ephemeral fallback) are swept. ---
|
|
75
|
+
try {
|
|
76
|
+
pruneOrphanedSnapshotDirs(join(stateDir, ".."), sessionFile ? dirname(dirname(sessionFile)) : undefined, sessionKey);
|
|
77
|
+
} catch {}
|
|
78
|
+
}
|
|
66
79
|
return new EngineManager({
|
|
67
80
|
cwd,
|
|
68
81
|
// --- snapshots are keyed to a session file; ephemeral sessions get none ---
|
|
@@ -93,7 +106,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
93
106
|
location = { cwd: ctx.cwd, sessionFile: ctx.sessionManager.getSessionFile() ?? undefined };
|
|
94
107
|
void lifecycle.acquire("startup").catch(() => {
|
|
95
108
|
// --- boot/revive handled on the execute path; swallow so a background warm can never
|
|
96
|
-
// --- surface an unhandled rejection
|
|
109
|
+
// --- surface an unhandled rejection. A resume's notice lands on the first cell. ---
|
|
97
110
|
});
|
|
98
111
|
});
|
|
99
112
|
|
|
@@ -168,8 +181,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
181
|
onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
|
|
169
182
|
},
|
|
170
183
|
});
|
|
171
|
-
// --- reset notice leads so the model reads that its namespace was rebuilt
|
|
172
|
-
|
|
184
|
+
// --- reset notice leads so the model reads that its namespace was rebuilt; the
|
|
185
|
+
// --- human gets a terse notification instead of the marker, fire and forget ---
|
|
186
|
+
const reset = lifecycle.takeResetNotice();
|
|
187
|
+
if (reset?.notice) ctx?.ui?.notify?.(formatResetToast(reset.origin, reset.restore), "info");
|
|
188
|
+
const sections = [reset?.notice, r.stdout, r.stderr, r.result];
|
|
173
189
|
const errorLines = r.error ? composeErrorLines(r.error) : undefined;
|
|
174
190
|
if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
|
|
175
191
|
if (r.status === "aborted") sections.push("[cell aborted]");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-repl-py",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
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": [
|
package/src/engine/index.ts
CHANGED
|
@@ -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,71 @@ 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
|
+
|
|
144
|
+
// --- Orphaned-snapshot sweep: snapshot dirs are keyed by conversation file basename, so when
|
|
145
|
+
// --- an owning conversation is deleted (pi removes the .jsonl), its directory becomes dead
|
|
146
|
+
// --- weight. This drops any state dir whose conversation file exists in NONE of the project
|
|
147
|
+
// --- session roots, so deleting a conversation deletes its snapshots with it. Safety rules:
|
|
148
|
+
// --- only dirs that look like ours (contain a namespace.snapshot manifest) are touched, and
|
|
149
|
+
// --- the live session plus the no-session "ephemeral" fallback dir are always exempt. ---
|
|
150
|
+
export function pruneOrphanedSnapshotDirs(
|
|
151
|
+
stateRoot: string,
|
|
152
|
+
sessionsRoot: string | undefined,
|
|
153
|
+
currentDir?: string,
|
|
154
|
+
): number {
|
|
155
|
+
if (!sessionsRoot || !existsSync(sessionsRoot)) return 0;
|
|
156
|
+
const liveNames = new Set<string>();
|
|
157
|
+
try {
|
|
158
|
+
for (const proj of readdirSync(sessionsRoot, { withFileTypes: true })) {
|
|
159
|
+
if (!proj.isDirectory()) continue;
|
|
160
|
+
for (const f of readdirSync(join(sessionsRoot, proj.name))) {
|
|
161
|
+
if (f.endsWith(".jsonl")) liveNames.add(f.slice(0, -".jsonl".length));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
} catch {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let removed = 0;
|
|
168
|
+
try {
|
|
169
|
+
for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
|
|
170
|
+
if (!entry.isDirectory() || entry.name === currentDir || entry.name === "ephemeral") continue;
|
|
171
|
+
if (liveNames.has(entry.name)) continue;
|
|
172
|
+
if (!existsSync(join(stateRoot, entry.name, "namespace.snapshot"))) continue;
|
|
173
|
+
rmSync(join(stateRoot, entry.name), { recursive: true, force: true });
|
|
174
|
+
removed++;
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// readdir can race a concurrent sweep; give up quietly rather than partial-delete
|
|
178
|
+
}
|
|
179
|
+
return removed;
|
|
180
|
+
}
|
|
181
|
+
|
|
112
182
|
export class EngineManager {
|
|
113
183
|
private readonly options: EngineOptions;
|
|
114
184
|
private kernel?: KernelClient;
|
|
@@ -302,12 +372,16 @@ export class EngineManager {
|
|
|
302
372
|
const config = this.options.snapshot;
|
|
303
373
|
if (!config || this.state !== "running" || !this.kernel) return null;
|
|
304
374
|
try {
|
|
305
|
-
const reply = await this.kernel.snapshot();
|
|
375
|
+
const reply = await this.kernel.snapshot(config.maxBytes ?? DEFAULT_SNAPSHOT_MAX_BYTES);
|
|
306
376
|
// --- an incomplete snapshot must not overwrite the last good file ---
|
|
307
377
|
if (reply.complete === false) return null;
|
|
308
378
|
mkdirSync(dirname(config.path), { recursive: true });
|
|
309
|
-
|
|
310
|
-
|
|
379
|
+
// --- write to a temp file then rename so a crash mid-write can never corrupt
|
|
380
|
+
// --- the last good snapshot (the restore side parses or returns null) ---
|
|
381
|
+
const tmp = `${config.path}.tmp`;
|
|
382
|
+
writeFileSync(tmp, JSON.stringify({ version: 2, entries: reply.entries, failed: reply.failed }));
|
|
383
|
+
renameSync(tmp, config.path);
|
|
384
|
+
return { path: config.path, saved: reply.entries.map((e) => e.name), failed: reply.failed };
|
|
311
385
|
} catch {
|
|
312
386
|
return null;
|
|
313
387
|
}
|
|
@@ -319,15 +393,29 @@ export class EngineManager {
|
|
|
319
393
|
if (!existsSync(config.path)) return null;
|
|
320
394
|
await this.start();
|
|
321
395
|
try {
|
|
322
|
-
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
323
|
-
|
|
324
|
-
|
|
396
|
+
const payload = JSON.parse(readFileSync(config.path, "utf8")) as {
|
|
397
|
+
version?: number;
|
|
398
|
+
entries?: SnapshotEntry[];
|
|
399
|
+
vars?: Record<string, string>;
|
|
400
|
+
};
|
|
401
|
+
// --- version 1 files (pre-source-capture) are still restorable: their vars are plain pickles ---
|
|
402
|
+
const entries: SnapshotEntry[] =
|
|
403
|
+
payload.version === 2
|
|
404
|
+
? (payload.entries ?? [])
|
|
405
|
+
: Object.entries(payload.vars ?? {}).map(([name, b64]) => ({ name, kind: "value", payload: b64 }));
|
|
406
|
+
const reply = await this.kernel!.restore(entries);
|
|
325
407
|
return { path: config.path, restored: reply.restored, failed: reply.failed };
|
|
326
408
|
} catch {
|
|
327
409
|
return null;
|
|
328
410
|
}
|
|
329
411
|
}
|
|
330
412
|
|
|
413
|
+
/** The conversation's state dir exists, so this engine is a resume, not a first run. */
|
|
414
|
+
hasSnapshotHistory(): boolean {
|
|
415
|
+
const config = this.options.snapshot;
|
|
416
|
+
return config ? existsSync(dirname(config.path)) : false;
|
|
417
|
+
}
|
|
418
|
+
|
|
331
419
|
async listNamespaceNames(): Promise<string[] | null> {
|
|
332
420
|
if (this.state !== "running" || !this.kernel) return null;
|
|
333
421
|
try {
|
package/src/engine/kernel.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
109
|
-
const
|
|
110
|
-
.map((
|
|
191
|
+
function restoreCode(entries: SnapshotEntry[]): string {
|
|
192
|
+
const per = entries
|
|
193
|
+
.map(({ name, kind, payload }) => {
|
|
111
194
|
const n = JSON.stringify(name);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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(
|
|
553
|
-
|
|
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 {
|
|
660
|
+
if (payload === undefined) return { entries: [], failed: [], complete: false };
|
|
557
661
|
try {
|
|
558
662
|
const obj = JSON.parse(payload) as {
|
|
559
|
-
|
|
663
|
+
entries?: SnapshotEntry[];
|
|
560
664
|
failed?: { name: string; reason: string }[];
|
|
561
665
|
};
|
|
562
|
-
return {
|
|
666
|
+
return { entries: obj.entries ?? [], failed: obj.failed ?? [], complete: true };
|
|
563
667
|
} catch {
|
|
564
|
-
return {
|
|
668
|
+
return { entries: [], failed: [], complete: false };
|
|
565
669
|
}
|
|
566
670
|
});
|
|
567
671
|
}
|
|
568
672
|
|
|
569
|
-
restore(
|
|
570
|
-
if (
|
|
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(
|
|
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 {
|
package/src/engine/session.ts
CHANGED
|
@@ -123,10 +123,16 @@ export class JupyterSession {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
export function executeRequest(code: string, silent: boolean): Record<string, unknown> {
|
|
126
|
+
// --- store_history is always false: IPython retains every last-expression result in its
|
|
127
|
+
// --- In/Out history, and that retention is NOT reclaimable from user cells (deleting Out
|
|
128
|
+
// --- entries and _/__/___ from user_ns leaves the objects alive). With history off, cells
|
|
129
|
+
// --- stop feeding that growth entirely. The contract does not depend on In/Out: results
|
|
130
|
+
// --- are published over iopub via the display hook (single-mode execution, unaffected by
|
|
131
|
+
// --- store_history) and returned in the cell's transcript. ---
|
|
126
132
|
return {
|
|
127
133
|
code,
|
|
128
134
|
silent,
|
|
129
|
-
store_history:
|
|
135
|
+
store_history: false,
|
|
130
136
|
user_expressions: {},
|
|
131
137
|
allow_stdin: false,
|
|
132
138
|
stop_on_error: true,
|
|
@@ -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(
|
|
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,66 @@ export interface EngineLifecycleDeps<E extends RevivableEngine> {
|
|
|
22
24
|
discard?(engine: E): Promise<void>;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
|
-
/** `startup`
|
|
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
|
-
|
|
30
|
+
// --- Terse TUI toast for the human, separate from the model-facing cell marker: the user
|
|
31
|
+
// --- asked for the classic subtle notification instead of a showy in-cell message. Counts
|
|
32
|
+
// --- come from the same restore the marker describes, so the two never disagree. ---
|
|
33
|
+
export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null): string {
|
|
34
|
+
const resumed = origin === "startup";
|
|
35
|
+
const revived = restore?.restored.length ?? 0;
|
|
36
|
+
const lost = restore?.failed.length ?? 0;
|
|
37
|
+
if (restore && revived > 0) {
|
|
38
|
+
const counts = lost > 0 ? `, ${lost} lost` : "";
|
|
39
|
+
const noun = revived === 1 ? "name" : "names";
|
|
40
|
+
return resumed
|
|
41
|
+
? `repl session resumed, ${revived} ${noun} revived${counts}`
|
|
42
|
+
: `repl kernel rebuilt, ${revived} ${noun} revived${counts}`;
|
|
43
|
+
}
|
|
44
|
+
return resumed
|
|
45
|
+
? restore === null
|
|
46
|
+
? "repl session resumed, nothing saved to revive"
|
|
47
|
+
: "repl session resumed, nothing could be revived"
|
|
48
|
+
: restore === null
|
|
49
|
+
? "repl kernel rebuilt, nothing saved to revive"
|
|
50
|
+
: "repl kernel rebuilt, nothing could be revived";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
|
|
54
|
+
const resumed = origin === "startup";
|
|
29
55
|
const lines = ["<repl_engine_reset>"];
|
|
30
56
|
if (!restore) {
|
|
31
57
|
// --- no snapshot at all: namespace is genuinely empty ---
|
|
32
58
|
lines.push(
|
|
33
|
-
|
|
34
|
-
|
|
59
|
+
resumed
|
|
60
|
+
? "This session's evaluator started fresh, and no saved snapshot was available to revive; the namespace is empty."
|
|
61
|
+
: "The evaluator restarted and its namespace is empty; no snapshot was available to revive.",
|
|
62
|
+
resumed
|
|
63
|
+
? "Names from earlier in this conversation are gone. Rebuild what you need before using it."
|
|
64
|
+
: "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
|
|
35
65
|
);
|
|
36
66
|
} else if (restore.restored.length === 0) {
|
|
37
67
|
// --- a snapshot existed but restored nothing; say why, don't claim "no snapshot" ---
|
|
38
68
|
lines.push(
|
|
39
|
-
|
|
69
|
+
resumed
|
|
70
|
+
? "This session's evaluator started fresh. A saved snapshot was found, but nothing in it could be revived."
|
|
71
|
+
: "The evaluator restarted and a snapshot was found, but nothing in it could be revived.",
|
|
40
72
|
restore.failed.length > 0
|
|
41
73
|
? `Failed to revive (${restore.failed.length}): ${summarizeNames(
|
|
42
74
|
restore.failed.map((f) => f.name),
|
|
43
75
|
20,
|
|
44
76
|
)}`
|
|
45
77
|
: "The snapshot was empty.",
|
|
46
|
-
|
|
78
|
+
resumed
|
|
79
|
+
? "Names from earlier in this conversation are gone. Rebuild what you need before using it."
|
|
80
|
+
: "Every variable from earlier in this session is gone. Rebuild what you need before using it.",
|
|
47
81
|
);
|
|
48
82
|
} else {
|
|
49
83
|
lines.push(
|
|
50
|
-
|
|
84
|
+
resumed
|
|
85
|
+
? "This session's evaluator started fresh and restored the namespace saved by this conversation's last run, so it may be empty or behind."
|
|
86
|
+
: "The evaluator restarted. Its namespace was rebuilt from the last snapshot, so it may be behind.",
|
|
51
87
|
`Revived (${restore.restored.length}): ${summarizeNames(restore.restored, 20)}`,
|
|
52
88
|
);
|
|
53
89
|
if (restore.failed.length > 0) {
|
|
@@ -56,7 +92,7 @@ function formatEngineResetNotice(restore: RestoreResult | null): string {
|
|
|
56
92
|
restore.failed.map((f) => f.name),
|
|
57
93
|
20,
|
|
58
94
|
)}`,
|
|
59
|
-
"
|
|
95
|
+
"Live handles, open resources, and source-less functions cannot be snapshotted; redefine them.",
|
|
60
96
|
);
|
|
61
97
|
}
|
|
62
98
|
lines.push("Anything defined after the last snapshot is also gone.");
|
|
@@ -69,6 +105,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
69
105
|
private engine?: E;
|
|
70
106
|
private revival?: Promise<RestoreResult | null>;
|
|
71
107
|
private pendingNotice?: string;
|
|
108
|
+
private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null };
|
|
72
109
|
private teardown?: Promise<void>;
|
|
73
110
|
/** First-build in progress. */
|
|
74
111
|
private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
|
|
@@ -91,7 +128,12 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
91
128
|
this.engine = engine;
|
|
92
129
|
this.revival = engine.restoreState().catch(() => null);
|
|
93
130
|
const restore = await this.revival;
|
|
94
|
-
|
|
131
|
+
// --- mid-session rebuilds always announce; startup announces only when the
|
|
132
|
+
// --- conversation has a saved past, so a first-ever session stays quiet ---
|
|
133
|
+
if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
|
|
134
|
+
this.pendingNotice = formatEngineResetNotice(restore, origin);
|
|
135
|
+
this.pendingReset = { origin, restore };
|
|
136
|
+
}
|
|
95
137
|
return { engine, restore, created: true };
|
|
96
138
|
})();
|
|
97
139
|
this.acquiring = build;
|
|
@@ -102,11 +144,14 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
102
144
|
}
|
|
103
145
|
}
|
|
104
146
|
|
|
105
|
-
/** Returns the pending reset notice exactly once, then clears it. */
|
|
106
|
-
takeResetNotice(): string | undefined {
|
|
147
|
+
/** Returns the pending reset notice exactly once (alongside its origin and restore result), then clears it. */
|
|
148
|
+
takeResetNotice(): { notice: string; origin: AcquireOrigin; restore: RestoreResult | null } | undefined {
|
|
149
|
+
const reset = this.pendingReset;
|
|
107
150
|
const notice = this.pendingNotice;
|
|
108
151
|
this.pendingNotice = undefined;
|
|
109
|
-
|
|
152
|
+
this.pendingReset = undefined;
|
|
153
|
+
if (!notice || !reset) return undefined;
|
|
154
|
+
return { notice, origin: reset.origin, restore: reset.restore };
|
|
110
155
|
}
|
|
111
156
|
|
|
112
157
|
async shutdown(): Promise<void> {
|