pi-repl-py 0.6.11 → 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.
@@ -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
@@ -144,11 +152,15 @@ pickle cannot revive them, since they live in `__main__`). Bindings that still f
144
152
  handles, open resources, source-less functions — are reported by name, never dropped silently.
145
153
  Entries are capped per-binding and in total (128 MiB default), and the snapshot file is written
146
154
  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
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
148
158
  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.
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.
152
164
 
153
165
  ## Failure modes
154
166
 
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, pruneSnapshotDirs } 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({
@@ -68,6 +68,13 @@ export default function (pi: ExtensionAPI) {
68
68
  try {
69
69
  pruneSnapshotDirs(join(stateDir, ".."), 25, sessionKey);
70
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 {}
71
78
  }
72
79
  return new EngineManager({
73
80
  cwd,
@@ -174,8 +181,11 @@ export default function (pi: ExtensionAPI) {
174
181
  onUpdate?.({ content: [{ type: "text", text: streamed }], details: {} });
175
182
  },
176
183
  });
177
- // --- reset notice leads so the model reads that its namespace was rebuilt ---
178
- const sections = [lifecycle.takeResetNotice(), r.stdout, r.stderr, r.result];
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];
179
189
  const errorLines = r.error ? composeErrorLines(r.error) : undefined;
180
190
  if (r.status === "error" && errorLines) sections.push(errorLines.join("\n"));
181
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.11",
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": [
@@ -141,6 +141,44 @@ export function pruneSnapshotDirs(stateRoot: string, keep: number = DEFAULT_KEEP
141
141
  }
142
142
  }
143
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
+
144
182
  export class EngineManager {
145
183
  private readonly options: EngineOptions;
146
184
  private kernel?: KernelClient;
@@ -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: !silent,
135
+ store_history: false,
130
136
  user_expressions: {},
131
137
  allow_stdin: false,
132
138
  stop_on_error: true,
@@ -27,6 +27,29 @@ export interface EngineLifecycleDeps<E extends RevivableEngine> {
27
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. */
28
28
  export type AcquireOrigin = "startup" | "cell";
29
29
 
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
+
30
53
  function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
31
54
  const resumed = origin === "startup";
32
55
  const lines = ["<repl_engine_reset>"];
@@ -82,6 +105,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
82
105
  private engine?: E;
83
106
  private revival?: Promise<RestoreResult | null>;
84
107
  private pendingNotice?: string;
108
+ private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null };
85
109
  private teardown?: Promise<void>;
86
110
  /** First-build in progress. */
87
111
  private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
@@ -108,6 +132,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
108
132
  // --- conversation has a saved past, so a first-ever session stays quiet ---
109
133
  if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
110
134
  this.pendingNotice = formatEngineResetNotice(restore, origin);
135
+ this.pendingReset = { origin, restore };
111
136
  }
112
137
  return { engine, restore, created: true };
113
138
  })();
@@ -119,11 +144,14 @@ export class EngineLifecycle<E extends RevivableEngine> {
119
144
  }
120
145
  }
121
146
 
122
- /** Returns the pending reset notice exactly once, then clears it. */
123
- 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;
124
150
  const notice = this.pendingNotice;
125
151
  this.pendingNotice = undefined;
126
- return notice;
152
+ this.pendingReset = undefined;
153
+ if (!notice || !reset) return undefined;
154
+ return { notice, origin: reset.origin, restore: reset.restore };
127
155
  }
128
156
 
129
157
  async shutdown(): Promise<void> {