pi-repl-py 0.6.13 → 0.7.0
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 +103 -138
- package/docs/helpers.md +13 -0
- package/index.ts +57 -41
- package/package.json +1 -1
- package/scripts/setup-venv.mjs +5 -22
- package/src/engine/helpers-locate.ts +0 -1
- package/src/engine/index.ts +213 -64
- package/src/engine/kernel.ts +55 -46
- package/src/engine/session.ts +7 -16
- package/src/engine/zmtp.ts +1 -13
- package/src/extension/helpers.ts +27 -16
- package/src/extension/preview/candidates.ts +0 -4
- package/src/extension/preview/descriptor.ts +0 -1
- package/src/extension/preview/types.ts +0 -2
- package/src/extension/prompt.ts +7 -11
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +101 -83
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +58 -0
- package/src/extension/tool-meta.ts +2 -9
|
@@ -114,12 +114,7 @@ function marker(state: ExecuteRenderState, deps: RenderDeps): string {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
* highlight.js python emits no scope for plain identifiers, so they arrive as raw (uncolored)
|
|
119
|
-
* text mixed with SGR-colored tokens and bare punctuation. Re-color only whole identifier runs
|
|
120
|
-
* that sit outside any colored span, leaving keywords, strings, numbers, and other already
|
|
121
|
-
* colored tokens untouched.
|
|
122
|
-
*/
|
|
117
|
+
/** highlight.js emits no scope for bare Python identifiers — re-color only raw runs outside colored spans. */
|
|
123
118
|
function colorBareIdentifiers(line: string, paint: (id: string) => string): string {
|
|
124
119
|
if (!line.includes("\x1b") && !/[a-zA-Z_]/.test(line)) return line;
|
|
125
120
|
const out: string[] = [];
|
|
@@ -160,7 +155,6 @@ function colorBareIdentifiers(line: string, paint: (id: string) => string): stri
|
|
|
160
155
|
i = end;
|
|
161
156
|
continue;
|
|
162
157
|
}
|
|
163
|
-
// only raw (uncolored) text may be repainted; colored tokens pass through untouched
|
|
164
158
|
if (colored) out.push(line[i]);
|
|
165
159
|
else pending += line[i];
|
|
166
160
|
i++;
|
|
@@ -242,9 +236,7 @@ function sanitizeTuiOutput(text: string): string {
|
|
|
242
236
|
.replace(/[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]/g, "�");
|
|
243
237
|
}
|
|
244
238
|
|
|
245
|
-
/** Wrap a
|
|
246
|
-
* continuations take indent (and an optional extra indent), each row is
|
|
247
|
-
* truncated to pane width and closed if it ends on an open SGR color. */
|
|
239
|
+
/** Wrap a colored span: first row takes the prefix, continuations the indent; truncate to width and close open SGR colors. */
|
|
248
240
|
function pushWrappedLines(
|
|
249
241
|
lines: string[],
|
|
250
242
|
prefix: string,
|
|
@@ -306,12 +298,7 @@ function renderCode(state: ExecuteRenderState, lines: string[], width: number, d
|
|
|
306
298
|
return true;
|
|
307
299
|
}
|
|
308
300
|
|
|
309
|
-
/**
|
|
310
|
-
* Streaming output is append-only, so a wrapped blob only changes at its tail.
|
|
311
|
-
* The cache lives on the (persistent) state object via a WeakMap, letting an
|
|
312
|
-
* updated body re-wrap just the appended delta: O(chunk) instead of O(output).
|
|
313
|
-
* Fresh states (unit tests, first render) fall back to a full wrap.
|
|
314
|
-
*/
|
|
301
|
+
/** Output is append-only: the cached wrap re-wraps just the tail — O(chunk) instead of O(output). */
|
|
315
302
|
interface BlobWrapEntry {
|
|
316
303
|
text: string;
|
|
317
304
|
color: string;
|
|
@@ -321,20 +308,14 @@ interface BlobWrapEntry {
|
|
|
321
308
|
}
|
|
322
309
|
|
|
323
310
|
const blobWrapCache = new WeakMap<ExecuteRenderState, Map<number, BlobWrapEntry>>();
|
|
324
|
-
/**
|
|
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. */
|
|
311
|
+
/** Row caches keyed by text/width turn per-frame renders into one build per change; bounded per state against resize churn. */
|
|
328
312
|
interface CodeEntry {
|
|
329
313
|
code: string;
|
|
330
314
|
lines: string[];
|
|
331
315
|
}
|
|
332
316
|
const codeWrapCache = new WeakMap<ExecuteRenderState, Map<number, CodeEntry>>();
|
|
333
317
|
|
|
334
|
-
/**
|
|
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. */
|
|
318
|
+
/** Bound per-width cache entries; insertion order evicts the oldest width first. */
|
|
338
319
|
const MAX_CACHED_WIDTHS_PER_STATE = 3;
|
|
339
320
|
function boundWidthCache(perWidth: Map<number, unknown>): void {
|
|
340
321
|
while (perWidth.size >= MAX_CACHED_WIDTHS_PER_STATE) {
|
|
@@ -352,9 +333,7 @@ function widthOf(ch: string): number {
|
|
|
352
333
|
return code < 0x80 ? 1 : CJK_WIDE_RE.test(ch) ? 2 : 1;
|
|
353
334
|
}
|
|
354
335
|
|
|
355
|
-
/** Fast
|
|
356
|
-
* ~45ms on a 45K blob; sanitized output needs no ANSI handling, so this splits
|
|
357
|
-
* at spaces with an O(width) scan per row and hard-breaks overlong words. */
|
|
336
|
+
/** Fast word wrap for sanitized text: pi-tui's ANSI-aware wrap costs ~45ms on a 45K blob. */
|
|
358
337
|
function wrapPlainText(text: string, width: number): string[] {
|
|
359
338
|
if (width <= 0 || text.length <= width) return [text];
|
|
360
339
|
// --- fast path: pure ASCII, break by char index ---
|
|
@@ -430,8 +409,7 @@ function wrapPlainText(text: string, width: number): string[] {
|
|
|
430
409
|
return rows;
|
|
431
410
|
}
|
|
432
411
|
|
|
433
|
-
/**
|
|
434
|
-
* then colorize row-by-row so the color span never bleeds across rows. */
|
|
412
|
+
/** Sanitize → fast wrap → colorize rows, so an SGR span never bleeds across rows. */
|
|
435
413
|
function wrapRawOutputLine(lines: string[], raw: string, color: string, width: number, deps: RenderDeps): string[] {
|
|
436
414
|
const before = lines.length;
|
|
437
415
|
const safe = sanitizeTuiOutput(raw || " ");
|
|
@@ -517,8 +495,7 @@ function renderOutput(
|
|
|
517
495
|
}
|
|
518
496
|
|
|
519
497
|
if (details?.errorStack && details.errorStack.length > 0) {
|
|
520
|
-
// --- a traceback IS output: without this
|
|
521
|
-
// --- stdout/stderr/result, the common error shape) also rendered "no output" below it ---
|
|
498
|
+
// --- a traceback IS output: without this, a pure-traceback error cell also rendered "no output" below it ---
|
|
522
499
|
renderedText = true;
|
|
523
500
|
output.push(` ${OUTPUT_INDENT}${deps.fg("dim", "traceback:")}`);
|
|
524
501
|
for (const line of details.errorStack) {
|
|
@@ -532,10 +509,7 @@ function renderOutput(
|
|
|
532
509
|
addWrapped(output, OUTPUT_INDENT, deps.fg("muted", message), width, deps, { sanitize: false });
|
|
533
510
|
}
|
|
534
511
|
|
|
535
|
-
// ---
|
|
536
|
-
// --- row; traceback and the placeholder have no such newline and would sit flush against
|
|
537
|
-
// --- the panel's bottom edge. Normalize: the panel always ends with one blank painted row.
|
|
538
|
-
// --- (SGR stripped before the blank test; the colored rows themselves stay untouched.) ---
|
|
512
|
+
// --- the panel always ends with one blank row: streams end with a newline, tracebacks and placeholders don't ---
|
|
539
513
|
const lastRow = output[output.length - 1];
|
|
540
514
|
if (lastRow !== undefined && lastRow.replace(SGR_PATTERN, "").trim() !== "") output.push("");
|
|
541
515
|
|
|
@@ -544,7 +518,6 @@ function renderOutput(
|
|
|
544
518
|
lines.push(...output);
|
|
545
519
|
}
|
|
546
520
|
|
|
547
|
-
/** Paint the status-matched panel background across the row, surviving inner SGR resets. */
|
|
548
521
|
export function paintBackground(line: string, width: number, kind: StatusKind, deps: RenderDeps): string {
|
|
549
522
|
const bgAnsi = deps.getBgAnsi(backgroundFor(kind));
|
|
550
523
|
const padded = line + " ".repeat(Math.max(0, width - deps.visibleWidth(line)));
|
package/src/extension/render.ts
CHANGED
|
@@ -30,10 +30,7 @@ function makeDeps(theme: Theme): RenderDeps {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
/** O(1) key
|
|
34
|
-
* the animation frame so the header alone animates while running; the body key
|
|
35
|
-
* excludes it, so a running cell only redraws when output actually changes
|
|
36
|
-
* instead of re-wrapping the whole body every 120ms. */
|
|
33
|
+
/** O(1) body key excludes the spinner frame, so a running cell redraws only when output changes, not every 120ms. */
|
|
37
34
|
function renderVersion(state: ExecuteRenderState, withSpinner: boolean): string {
|
|
38
35
|
const spinner = withSpinner && statusKind(state) === "running" ? Math.floor(Date.now() / 120) % 4 : -1;
|
|
39
36
|
return `${state.version ?? 0}|${state.expanded}|${spinner}`;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Lifecycle: boot, session binding, reset announcements. Recovery is a background quiet-gap job; the notice lands on the first cell after the restore.
|
|
2
2
|
|
|
3
|
-
import type { RestoreResult } from "../engine/index.js";
|
|
3
|
+
import type { HelperLoadResult, RestoreResult } from "../engine/index.js";
|
|
4
4
|
|
|
5
5
|
/** Show enough names to orient, then count the rest (a revive can carry hundreds). */
|
|
6
6
|
function summarizeNames(names: readonly string[], limit: number): string {
|
|
@@ -10,33 +10,32 @@ function summarizeNames(names: readonly string[], limit: number): string {
|
|
|
10
10
|
|
|
11
11
|
/** The part of EngineManager this lifecycle needs; narrowed so tests can fake it. */
|
|
12
12
|
export interface RevivableEngine {
|
|
13
|
-
/** Boot the
|
|
14
|
-
|
|
13
|
+
/** Boot the kernel (and preload helpers), independent of snapshot recovery. */
|
|
14
|
+
start(skipRestore?: boolean): Promise<void>;
|
|
15
|
+
/** Recovery outcome: revived names, or null when nothing to revive / skipped / failed. Never rejects. */
|
|
16
|
+
restoreResult(): Promise<RestoreResult | null>;
|
|
17
|
+
/** True when recovery was deliberately skipped (a prior revive wedged). */
|
|
18
|
+
restoreWasSkipped(): boolean;
|
|
15
19
|
/** True when this conversation's state dir already exists, so the engine was resumed. */
|
|
16
20
|
hasSnapshotHistory(): boolean;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
export interface EngineLifecycleDeps<E extends RevivableEngine> {
|
|
20
|
-
/**
|
|
21
|
-
create(): E;
|
|
24
|
+
/** Fresh engine; skipRestore is the retry after a wedged boot, so a poisoned snapshot can't wedge twice. */
|
|
25
|
+
create(skipRestore?: boolean): E;
|
|
22
26
|
/** Tears the current engine down, flushing its final snapshot. */
|
|
23
27
|
dispose(engine: E): Promise<void>;
|
|
24
28
|
/** Kill-then-rebuild when a wedged engine cannot serve the snapshot flush. */
|
|
25
29
|
discard?(engine: E): Promise<void>;
|
|
26
|
-
/** Boot deadline
|
|
27
|
-
* outlives it is killed and retried fresh. Default 90s. */
|
|
30
|
+
/** Boot deadline (default 90s): an overlong boot is killed and retried. Recovery is outside it — background, bounded by the restore-cell watchdog. */
|
|
28
31
|
bootTimeoutMs?: number;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
|
-
/**
|
|
32
|
-
const BOOT_WEDGED = Symbol("boot wedged");
|
|
33
|
-
|
|
34
|
-
/** `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. */
|
|
34
|
+
/** startup: announce when the conversation has a saved past; cell: a mid-session rebuild announces immediately. */
|
|
35
35
|
export type AcquireOrigin = "startup" | "cell";
|
|
36
36
|
|
|
37
37
|
const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
|
|
38
38
|
|
|
39
|
-
/** Model-facing body for a boot whose snapshot revive wedged and was skipped. */
|
|
40
39
|
function revivedNoticeBody(origin: AcquireOrigin): string {
|
|
41
40
|
const resumed = origin === "startup";
|
|
42
41
|
return resumed
|
|
@@ -44,27 +43,19 @@ function revivedNoticeBody(origin: AcquireOrigin): string {
|
|
|
44
43
|
: "The evaluator wedged while reviving its saved namespace, so the snapshot was skipped and the namespace is empty.";
|
|
45
44
|
}
|
|
46
45
|
|
|
47
|
-
// ---
|
|
48
|
-
|
|
49
|
-
// --- come from the same restore the marker describes, so the two never disagree. ---
|
|
50
|
-
export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null): string {
|
|
46
|
+
// --- human toast, separate from the model marker; counts come from the same restore, so the two never disagree ---
|
|
47
|
+
export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null, wedged = false): string {
|
|
51
48
|
const resumed = origin === "startup";
|
|
49
|
+
const verb = resumed ? "repl session resumed" : "repl kernel rebuilt";
|
|
50
|
+
if (wedged) return `${verb}, snapshot revive skipped (wedged)`;
|
|
52
51
|
const revived = restore?.restored.length ?? 0;
|
|
53
52
|
const lost = restore?.failed.length ?? 0;
|
|
54
53
|
if (restore && revived > 0) {
|
|
55
54
|
const counts = lost > 0 ? `, ${lost} lost` : "";
|
|
56
55
|
const noun = revived === 1 ? "name" : "names";
|
|
57
|
-
return
|
|
58
|
-
? `repl session resumed, ${revived} ${noun} revived${counts}`
|
|
59
|
-
: `repl kernel rebuilt, ${revived} ${noun} revived${counts}`;
|
|
56
|
+
return `${verb}, ${revived} ${noun} revived${counts}`;
|
|
60
57
|
}
|
|
61
|
-
return
|
|
62
|
-
? restore === null
|
|
63
|
-
? "repl session resumed, nothing saved to revive"
|
|
64
|
-
: "repl session resumed, nothing could be revived"
|
|
65
|
-
: restore === null
|
|
66
|
-
? "repl kernel rebuilt, nothing saved to revive"
|
|
67
|
-
: "repl kernel rebuilt, nothing could be revived";
|
|
58
|
+
return restore === null ? `${verb}, nothing saved to revive` : `${verb}, nothing could be revived`;
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireOrigin): string {
|
|
@@ -118,88 +109,113 @@ function formatEngineResetNotice(restore: RestoreResult | null, origin: AcquireO
|
|
|
118
109
|
return lines.join("\n");
|
|
119
110
|
}
|
|
120
111
|
|
|
112
|
+
/** Human toast when a /fork'd conversation inherited its parent's namespace — same counts as the reset marker, named as a fork so it can't pass for a plain resume. */
|
|
113
|
+
export function formatForkToast(restore: RestoreResult | null): string {
|
|
114
|
+
const revived = restore?.restored.length ?? 0;
|
|
115
|
+
if (revived > 0) {
|
|
116
|
+
const noun = revived === 1 ? "name" : "names";
|
|
117
|
+
return `repl fork started — ${revived} ${noun} inherited from the parent session`;
|
|
118
|
+
}
|
|
119
|
+
return restore === null
|
|
120
|
+
? "repl fork started — nothing to inherit"
|
|
121
|
+
: "repl fork started — parent snapshot revived nothing";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- split audience: toast for the human whenever a boot preloaded anything; a marker for the model ONLY when a helper failed (all-good boots are silent) ---
|
|
125
|
+
|
|
126
|
+
export function formatHelperToast(report: readonly HelperLoadResult[]): string {
|
|
127
|
+
const loaded = report.filter((h) => h.ok).map((h) => h.name);
|
|
128
|
+
const failed = report.filter((h) => !h.ok);
|
|
129
|
+
const loadedPart = loaded.length > 0 ? `helpers loaded: ${loaded.join(", ")}` : "no helpers loaded";
|
|
130
|
+
const failedPart =
|
|
131
|
+
failed.length > 0 ? ` · failed: ${failed.map((f) => `${f.name} (${f.error ?? "failed to load"})`).join(", ")}` : "";
|
|
132
|
+
return `repl ${loadedPart}${failedPart}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatHelperFailuresLine(report: readonly HelperLoadResult[] | null): string | undefined {
|
|
136
|
+
if (!report) return undefined;
|
|
137
|
+
const failed = report.filter((h) => !h.ok);
|
|
138
|
+
if (failed.length === 0) return undefined;
|
|
139
|
+
return `<repl_helpers_failed: ${failed.map((f) => `${f.name} (${f.error ?? "failed to load"})`).join(", ")}>`;
|
|
140
|
+
}
|
|
141
|
+
|
|
121
142
|
export class EngineLifecycle<E extends RevivableEngine> {
|
|
122
143
|
private engine?: E;
|
|
123
|
-
private revival?: Promise<RestoreResult | null>;
|
|
124
144
|
private pendingNotice?: string;
|
|
125
|
-
private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null };
|
|
145
|
+
private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean };
|
|
126
146
|
private teardown?: Promise<void>;
|
|
127
|
-
/** First-build in progress. */
|
|
128
147
|
private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
|
|
148
|
+
/** The conversation this engine was built for; a different key on acquire tears it down. */
|
|
149
|
+
private boundKey?: string;
|
|
129
150
|
|
|
130
151
|
constructor(private readonly deps: EngineLifecycleDeps<E>) {}
|
|
131
152
|
|
|
132
|
-
/** Race one boot
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
deadlineMs: number,
|
|
138
|
-
skipRestore: boolean,
|
|
139
|
-
): Promise<RestoreResult | null | typeof BOOT_WEDGED> {
|
|
140
|
-
const work = engine.restoreState(skipRestore).catch(() => null);
|
|
153
|
+
/** Race one boot (kernel start + helpers preload) against the deadline; a failed start is soft — the first cell observes it and rebuilds. */
|
|
154
|
+
private bootOnce(engine: E, deadlineMs: number): Promise<boolean> {
|
|
155
|
+
const work = Promise.resolve()
|
|
156
|
+
.then(() => engine.start(false))
|
|
157
|
+
.catch(() => {});
|
|
141
158
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
142
|
-
const guard = new Promise<
|
|
143
|
-
timer = setTimeout(() => resolve(
|
|
159
|
+
const guard = new Promise<boolean>((resolve) => {
|
|
160
|
+
timer = setTimeout(() => resolve(false), deadlineMs);
|
|
144
161
|
timer.unref?.();
|
|
145
162
|
});
|
|
146
|
-
return Promise.race([work, guard]).finally(() => clearTimeout(timer));
|
|
163
|
+
return Promise.race([work.then(() => true), guard]).finally(() => clearTimeout(timer));
|
|
147
164
|
}
|
|
148
165
|
|
|
149
|
-
/** Built
|
|
150
|
-
async acquire(
|
|
166
|
+
/** Built on demand; acquire() never waits on the background restore (announced on the first cell after it completes). sessionKey: a different conversation's acquire tears the bound engine down, so sessions can't bleed into each other. */
|
|
167
|
+
async acquire(
|
|
168
|
+
origin: AcquireOrigin,
|
|
169
|
+
sessionKey?: string,
|
|
170
|
+
): Promise<{ engine: E; restore: RestoreResult | null; created: boolean }> {
|
|
171
|
+
if (sessionKey !== undefined && this.boundKey !== undefined && sessionKey !== this.boundKey) {
|
|
172
|
+
await this.teardownWith((engine) => this.deps.dispose(engine));
|
|
173
|
+
}
|
|
151
174
|
if (this.engine) {
|
|
152
|
-
return { engine: this.engine, restore:
|
|
175
|
+
return { engine: this.engine, restore: null, created: false };
|
|
153
176
|
}
|
|
154
177
|
if (this.acquiring) return this.acquiring;
|
|
155
178
|
const build = (async () => {
|
|
156
179
|
while (this.teardown) await this.teardown;
|
|
157
180
|
if (this.engine) {
|
|
158
181
|
const held: E = this.engine;
|
|
159
|
-
return { engine: held, restore:
|
|
182
|
+
return { engine: held, restore: null, created: false };
|
|
160
183
|
}
|
|
184
|
+
this.boundKey = sessionKey;
|
|
185
|
+
const deadline = this.deps.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
|
|
161
186
|
let engine = this.deps.create();
|
|
162
187
|
this.engine = engine;
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// --- hang the first cell and every cell after it. ---
|
|
167
|
-
const deadline = this.deps.bootTimeoutMs ?? DEFAULT_BOOT_TIMEOUT_MS;
|
|
168
|
-
let restore = await this.bootOnce(engine, deadline, false);
|
|
169
|
-
if (restore === BOOT_WEDGED) {
|
|
170
|
-
// --- the kernel is alive but stuck; only a kill frees it. Retry once WITHOUT the
|
|
171
|
-
// --- snapshot, so a poisoned pickle cannot wedge the session twice in a row. ---
|
|
188
|
+
let booted = await this.bootOnce(engine, deadline);
|
|
189
|
+
if (!booted) {
|
|
190
|
+
// --- the kernel is stuck, not dead; kill and retry once WITHOUT the snapshot so a poisoned one can't wedge twice ---
|
|
172
191
|
await (this.deps.discard ?? this.deps.dispose)(engine);
|
|
173
|
-
engine = this.deps.create();
|
|
192
|
+
engine = this.deps.create(true);
|
|
174
193
|
this.engine = engine;
|
|
175
|
-
|
|
176
|
-
if (
|
|
194
|
+
booted = await this.bootOnce(engine, deadline);
|
|
195
|
+
if (!booted) {
|
|
177
196
|
await (this.deps.discard ?? this.deps.dispose)(engine);
|
|
178
197
|
this.engine = undefined;
|
|
198
|
+
this.boundKey = undefined;
|
|
179
199
|
throw new Error("evaluator boot timed out twice (kernel/helpers wedged); no session was started");
|
|
180
200
|
}
|
|
181
|
-
// --- the snapshot existed but reviving it is what wedged: say exactly that, not
|
|
182
|
-
// --- "no snapshot available", so the model doesn't go hunting for a missing file ---
|
|
183
|
-
if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
|
|
184
|
-
this.pendingNotice = [
|
|
185
|
-
"<repl_engine_reset>",
|
|
186
|
-
revivedNoticeBody(origin),
|
|
187
|
-
"Re-verify a variable before reusing it, especially inside shell interpolation.",
|
|
188
|
-
"</repl_engine_reset>",
|
|
189
|
-
].join("\n");
|
|
190
|
-
this.pendingReset = { origin, restore: null };
|
|
191
|
-
}
|
|
192
|
-
this.revival = Promise.resolve(null);
|
|
193
|
-
return { engine, restore: null, created: true };
|
|
194
|
-
}
|
|
195
|
-
this.revival = Promise.resolve(restore);
|
|
196
|
-
// --- mid-session rebuilds always announce; startup announces only when the
|
|
197
|
-
// --- conversation has a saved past, so a first-ever session stays quiet ---
|
|
198
|
-
if (origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory())) {
|
|
199
|
-
this.pendingNotice = formatEngineResetNotice(restore, origin);
|
|
200
|
-
this.pendingReset = { origin, restore };
|
|
201
201
|
}
|
|
202
|
-
|
|
202
|
+
// --- the notice lands on the first cell AFTER the restore completes; announce mid-session rebuilds and resumes with a saved past, never first sessions ---
|
|
203
|
+
const announce = origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory());
|
|
204
|
+
void engine.restoreResult().then((restore) => {
|
|
205
|
+
if (this.engine !== engine) return; // a replacement engine took over; no stale notice
|
|
206
|
+
if (!announce) return;
|
|
207
|
+
const wedged = engine.restoreWasSkipped();
|
|
208
|
+
this.pendingNotice = wedged
|
|
209
|
+
? [
|
|
210
|
+
"<repl_engine_reset>",
|
|
211
|
+
revivedNoticeBody(origin),
|
|
212
|
+
"Re-verify a variable before reusing it, especially inside shell interpolation.",
|
|
213
|
+
"</repl_engine_reset>",
|
|
214
|
+
].join("\n")
|
|
215
|
+
: formatEngineResetNotice(restore, origin);
|
|
216
|
+
this.pendingReset = { origin, restore, wedged };
|
|
217
|
+
});
|
|
218
|
+
return { engine, restore: null, created: true };
|
|
203
219
|
})();
|
|
204
220
|
this.acquiring = build;
|
|
205
221
|
try {
|
|
@@ -209,14 +225,16 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
209
225
|
}
|
|
210
226
|
}
|
|
211
227
|
|
|
212
|
-
/**
|
|
213
|
-
takeResetNotice():
|
|
228
|
+
/** The pending reset notice, taken exactly once. */
|
|
229
|
+
takeResetNotice():
|
|
230
|
+
| { notice: string; origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean }
|
|
231
|
+
| undefined {
|
|
214
232
|
const reset = this.pendingReset;
|
|
215
233
|
const notice = this.pendingNotice;
|
|
216
234
|
this.pendingNotice = undefined;
|
|
217
235
|
this.pendingReset = undefined;
|
|
218
236
|
if (!notice || !reset) return undefined;
|
|
219
|
-
return { notice, origin: reset.origin, restore: reset.restore };
|
|
237
|
+
return { notice, origin: reset.origin, restore: reset.restore, wedged: reset.wedged };
|
|
220
238
|
}
|
|
221
239
|
|
|
222
240
|
async shutdown(): Promise<void> {
|
|
@@ -231,7 +249,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
231
249
|
private async teardownWith(run: (engine: E) => Promise<void>): Promise<void> {
|
|
232
250
|
const engine = this.engine;
|
|
233
251
|
this.engine = undefined;
|
|
234
|
-
this.
|
|
252
|
+
this.boundKey = undefined;
|
|
235
253
|
this.pendingNotice = undefined;
|
|
236
254
|
if (!engine) return;
|
|
237
255
|
const teardown = run(engine).finally(() => {
|
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
// --- skills
|
|
2
|
-
// (hasRead), which repl doesn't have. Re-emit them with pi's own formatter in pi's slot. ---
|
|
1
|
+
// --- pi gates skills on the read tool (absent in repl); re-emit them with pi's own formatter in pi's slot ---
|
|
3
2
|
import { formatSkillsForPrompt, type Skill } from "@mariozechner/pi-coding-agent";
|
|
4
3
|
|
|
5
4
|
const CWD_MARKER = "\nCurrent working directory:"; // skills sit just before this, pi's last line
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// --- state lives at ~/.pi/agent/pi-repl/state/<slug>__<conv>: legacy bare-name dirs migrate on the owning conversation's next start, and the sweep knows both formats, so nothing live is swept and a deleted conversation loses its snapshots ---
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function conversationName(sessionFile: string): string {
|
|
6
|
+
return basename(sessionFile).replace(/\.jsonl$/, "");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Slug-keyed dir name: conversations whose files share a basename can never share a snapshot. */
|
|
10
|
+
export function sessionStateDirName(sessionFile: string): string {
|
|
11
|
+
return `${basename(dirname(sessionFile))}__${conversationName(sessionFile)}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function legacyStateDirName(sessionFile: string): string {
|
|
15
|
+
return conversationName(sessionFile);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Resolve the state dir, migrating a legacy bare-name dir on first start — whichever conversation starts first owns the legacy dir; the other starts empty. */
|
|
19
|
+
export function resolveStateDir(stateRoot: string, sessionFile: string): { dir: string; snapshotPath: string } {
|
|
20
|
+
const dir = join(stateRoot, sessionStateDirName(sessionFile));
|
|
21
|
+
const legacy = join(stateRoot, legacyStateDirName(sessionFile));
|
|
22
|
+
const dirSnap = join(dir, "namespace.snapshot");
|
|
23
|
+
const legacySnap = join(legacy, "namespace.snapshot");
|
|
24
|
+
if (legacy !== dir && !existsSync(dirSnap) && existsSync(legacySnap)) {
|
|
25
|
+
try {
|
|
26
|
+
renameSync(legacy, dir); // migrate; a racing rename (another conversation) just falls through
|
|
27
|
+
} catch {
|
|
28
|
+
// the slug dir already exists or the rename raced: keep it; legacy stays until the sweep sees it live or orphaned
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { dir, snapshotPath: join(dir, "namespace.snapshot") };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The /fork header's parentSession -> the parent's own snapshot file, when the fork has no history yet. */
|
|
35
|
+
export function forkParentSnapshot(stateRoot: string, sessionFile: string, snapshotPath: string): string | undefined {
|
|
36
|
+
if (existsSync(snapshotPath)) return undefined; // the fork already built its own history
|
|
37
|
+
let header: { parentSession?: unknown };
|
|
38
|
+
try {
|
|
39
|
+
const first = readFileSync(sessionFile, "utf8").split("\n", 1)[0] ?? "";
|
|
40
|
+
header = JSON.parse(first) as { parentSession?: unknown };
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined; // unreadable / not a session file — not a fork
|
|
43
|
+
}
|
|
44
|
+
if (typeof header.parentSession !== "string" || header.parentSession === "") return undefined;
|
|
45
|
+
const parent = resolveStateDir(stateRoot, header.parentSession);
|
|
46
|
+
return existsSync(parent.snapshotPath) ? parent.snapshotPath : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Copy the parent's last snapshot into the fork's own key once (true when something was inherited); the fork then restores like any resume, and the parent is never touched. */
|
|
50
|
+
export function inheritForkSnapshot(stateRoot: string, sessionFile: string, snapshotPath: string): boolean {
|
|
51
|
+
const parentSnap = forkParentSnapshot(stateRoot, sessionFile, snapshotPath);
|
|
52
|
+
if (parentSnap === undefined) return false;
|
|
53
|
+
mkdirSync(dirname(snapshotPath), { recursive: true });
|
|
54
|
+
const tmp = `${snapshotPath}.tmp`;
|
|
55
|
+
copyFileSync(parentSnap, tmp);
|
|
56
|
+
renameSync(tmp, snapshotPath);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
-
// --- tool-meta: thin surface assembling the execute tool's prompt from pure modules ---
|
|
2
|
-
// --- the model contract lives in prompt.ts; only the helpers wiring stays here ---
|
|
3
|
-
|
|
4
|
-
import { buildHelpersMap, buildHelpersMapForCwd } from "./helpers.js";
|
|
5
1
|
import { buildPromptGuidelines, executePromptSnippet, executeToolDescription } from "./prompt.js";
|
|
6
2
|
|
|
7
3
|
export const EXECUTE_DESCRIPTION = executeToolDescription;
|
|
8
4
|
export const EXECUTE_PROMPT_SNIPPET = executePromptSnippet;
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
const map = cwd ? buildHelpersMapForCwd(cwd) : buildHelpersMap();
|
|
13
|
-
const preloaded = map.length > 0 ? map : [];
|
|
14
|
-
return buildPromptGuidelines(preloaded);
|
|
6
|
+
export function buildExecutePromptGuidelines(): string[] {
|
|
7
|
+
return buildPromptGuidelines([]);
|
|
15
8
|
}
|