pi-repl-py 0.6.14 → 0.7.1
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 +4 -2
- package/docs/helpers.md +13 -0
- package/index.ts +40 -38
- 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 +93 -95
- 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 +9 -13
- package/src/extension/render-core.ts +9 -36
- package/src/extension/render.ts +1 -4
- package/src/extension/session-engine.ts +42 -40
- package/src/extension/skill-hook.ts +1 -2
- package/src/extension/state-layout.ts +30 -17
- 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,8 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
// background engine job (the quiet-gap restore): the first tool call waits only for the
|
|
3
|
-
// kernel to come up, and the reset notice lands on the first cell AFTER the restore lands.
|
|
1
|
+
// Lifecycle: boot, session binding, reset announcements. Recovery is a background quiet-gap job; the notice lands on the first cell after the restore.
|
|
4
2
|
|
|
5
|
-
import type { RestoreResult } from "../engine/index.js";
|
|
3
|
+
import type { HelperLoadResult, RestoreResult } from "../engine/index.js";
|
|
6
4
|
|
|
7
5
|
/** Show enough names to orient, then count the rest (a revive can carry hundreds). */
|
|
8
6
|
function summarizeNames(names: readonly string[], limit: number): string {
|
|
@@ -14,8 +12,7 @@ function summarizeNames(names: readonly string[], limit: number): string {
|
|
|
14
12
|
export interface RevivableEngine {
|
|
15
13
|
/** Boot the kernel (and preload helpers), independent of snapshot recovery. */
|
|
16
14
|
start(skipRestore?: boolean): Promise<void>;
|
|
17
|
-
/**
|
|
18
|
-
* nothing to revive, recovery was skipped, or recovery failed. Never rejects. */
|
|
15
|
+
/** Recovery outcome: revived names, or null when nothing to revive / skipped / failed. Never rejects. */
|
|
19
16
|
restoreResult(): Promise<RestoreResult | null>;
|
|
20
17
|
/** True when recovery was deliberately skipped (a prior revive wedged). */
|
|
21
18
|
restoreWasSkipped(): boolean;
|
|
@@ -24,25 +21,21 @@ export interface RevivableEngine {
|
|
|
24
21
|
}
|
|
25
22
|
|
|
26
23
|
export interface EngineLifecycleDeps<E extends RevivableEngine> {
|
|
27
|
-
/**
|
|
28
|
-
* true on the retry after a wedged boot, so the poisoned snapshot cannot wedge twice. */
|
|
24
|
+
/** Fresh engine; skipRestore is the retry after a wedged boot, so a poisoned snapshot can't wedge twice. */
|
|
29
25
|
create(skipRestore?: boolean): E;
|
|
30
26
|
/** Tears the current engine down, flushing its final snapshot. */
|
|
31
27
|
dispose(engine: E): Promise<void>;
|
|
32
28
|
/** Kill-then-rebuild when a wedged engine cannot serve the snapshot flush. */
|
|
33
29
|
discard?(engine: E): Promise<void>;
|
|
34
|
-
/** Boot deadline
|
|
35
|
-
* and retried fresh. Default 90s. Recovery is NOT inside this deadline: it runs in the
|
|
36
|
-
* background and is bounded by the engine's own restore-cell watchdog. */
|
|
30
|
+
/** Boot deadline (default 90s): an overlong boot is killed and retried. Recovery is outside it — background, bounded by the restore-cell watchdog. */
|
|
37
31
|
bootTimeoutMs?: number;
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
/**
|
|
34
|
+
/** startup: announce when the conversation has a saved past; cell: a mid-session rebuild announces immediately. */
|
|
41
35
|
export type AcquireOrigin = "startup" | "cell";
|
|
42
36
|
|
|
43
37
|
const DEFAULT_BOOT_TIMEOUT_MS = 90_000;
|
|
44
38
|
|
|
45
|
-
/** Model-facing body for a boot whose snapshot revive wedged and was skipped. */
|
|
46
39
|
function revivedNoticeBody(origin: AcquireOrigin): string {
|
|
47
40
|
const resumed = origin === "startup";
|
|
48
41
|
return resumed
|
|
@@ -50,9 +43,7 @@ function revivedNoticeBody(origin: AcquireOrigin): string {
|
|
|
50
43
|
: "The evaluator wedged while reviving its saved namespace, so the snapshot was skipped and the namespace is empty.";
|
|
51
44
|
}
|
|
52
45
|
|
|
53
|
-
// ---
|
|
54
|
-
// --- asked for the classic subtle notification instead of a showy in-cell message. Counts
|
|
55
|
-
// --- come from the same restore the marker describes, so the two never disagree. ---
|
|
46
|
+
// --- human toast, separate from the model marker; counts come from the same restore, so the two never disagree ---
|
|
56
47
|
export function formatResetToast(origin: AcquireOrigin, restore: RestoreResult | null, wedged = false): string {
|
|
57
48
|
const resumed = origin === "startup";
|
|
58
49
|
const verb = resumed ? "repl session resumed" : "repl kernel rebuilt";
|
|
@@ -118,22 +109,48 @@ 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
144
|
private pendingNotice?: string;
|
|
124
145
|
private pendingReset?: { origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean };
|
|
125
146
|
private teardown?: Promise<void>;
|
|
126
|
-
/** First-build in progress. */
|
|
127
147
|
private acquiring?: Promise<{ engine: E; restore: RestoreResult | null; created: boolean }>;
|
|
128
148
|
/** The conversation this engine was built for; a different key on acquire tears it down. */
|
|
129
149
|
private boundKey?: string;
|
|
130
150
|
|
|
131
151
|
constructor(private readonly deps: EngineLifecycleDeps<E>) {}
|
|
132
152
|
|
|
133
|
-
/** Race one
|
|
134
|
-
* deliberately outside this race: it runs as a background quiet-gap job and is bounded by the
|
|
135
|
-
* engine's own restore-cell watchdog. A failed start is soft — the first cell observes it and
|
|
136
|
-
* the caller rebuilds. */
|
|
153
|
+
/** Race one boot (kernel start + helpers preload) against the deadline; a failed start is soft — the first cell observes it and rebuilds. */
|
|
137
154
|
private bootOnce(engine: E, deadlineMs: number): Promise<boolean> {
|
|
138
155
|
const work = Promise.resolve()
|
|
139
156
|
.then(() => engine.start(false))
|
|
@@ -146,16 +163,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
146
163
|
return Promise.race([work.then(() => true), guard]).finally(() => clearTimeout(timer));
|
|
147
164
|
}
|
|
148
165
|
|
|
149
|
-
/**
|
|
150
|
-
* Built and booted on demand; the snapshot restore proceeds in the background, so acquire()
|
|
151
|
-
* NEVER waits on it. The engine's revive is announced (reset notice + toast) on the first
|
|
152
|
-
* cell after it completes.
|
|
153
|
-
*
|
|
154
|
-
* `sessionKey` guards against sessions bleeding into each other: pi tears the old session
|
|
155
|
-
* down before starting the next, but a missed or out-of-order shutdown must never serve one
|
|
156
|
-
* conversation's engine and namespace to another — acquire for a different key tears the
|
|
157
|
-
* bound engine down (flushing its snapshot) before building the new one.
|
|
158
|
-
*/
|
|
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. */
|
|
159
167
|
async acquire(
|
|
160
168
|
origin: AcquireOrigin,
|
|
161
169
|
sessionKey?: string,
|
|
@@ -179,8 +187,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
179
187
|
this.engine = engine;
|
|
180
188
|
let booted = await this.bootOnce(engine, deadline);
|
|
181
189
|
if (!booted) {
|
|
182
|
-
// --- the kernel is
|
|
183
|
-
// --- snapshot, so a poisoned snapshot cannot wedge the session twice in a row. ---
|
|
190
|
+
// --- the kernel is stuck, not dead; kill and retry once WITHOUT the snapshot so a poisoned one can't wedge twice ---
|
|
184
191
|
await (this.deps.discard ?? this.deps.dispose)(engine);
|
|
185
192
|
engine = this.deps.create(true);
|
|
186
193
|
this.engine = engine;
|
|
@@ -192,11 +199,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
192
199
|
throw new Error("evaluator boot timed out twice (kernel/helpers wedged); no session was started");
|
|
193
200
|
}
|
|
194
201
|
}
|
|
195
|
-
// ---
|
|
196
|
-
// --- the first quiet gap and the notice lands on the first cell AFTER it completes
|
|
197
|
-
// --- (index.ts takes it with takeResetNotice after the next execute). announce when
|
|
198
|
-
// --- mid-session rebuilds happen, or on startup for a conversation with a saved past;
|
|
199
|
-
// --- a first-ever session stays quiet. ---
|
|
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 ---
|
|
200
203
|
const announce = origin === "cell" || (origin === "startup" && engine.hasSnapshotHistory());
|
|
201
204
|
void engine.restoreResult().then((restore) => {
|
|
202
205
|
if (this.engine !== engine) return; // a replacement engine took over; no stale notice
|
|
@@ -222,8 +225,7 @@ export class EngineLifecycle<E extends RevivableEngine> {
|
|
|
222
225
|
}
|
|
223
226
|
}
|
|
224
227
|
|
|
225
|
-
/**
|
|
226
|
-
* whether the restore was skipped), then clears it. */
|
|
228
|
+
/** The pending reset notice, taken exactly once. */
|
|
227
229
|
takeResetNotice():
|
|
228
230
|
| { notice: string; origin: AcquireOrigin; restore: RestoreResult | null; wedged: boolean }
|
|
229
231
|
| undefined {
|
|
@@ -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
|
|
@@ -1,34 +1,21 @@
|
|
|
1
|
-
// ---
|
|
2
|
-
|
|
3
|
-
// --- conversation name. Pre-slug legacy dirs (bare name) migrate to the slug key on the
|
|
4
|
-
// --- owning conversation's next start; the orphan sweep still recognizes both formats,
|
|
5
|
-
// --- so nothing live is ever swept and a deleted conversation loses all its snapshots. ---
|
|
6
|
-
import { existsSync, renameSync } from "node:fs";
|
|
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";
|
|
7
3
|
import { basename, dirname, join } from "node:path";
|
|
8
4
|
|
|
9
|
-
/** The conversation's own name: the session file's basename without .jsonl (unique per conversation). */
|
|
10
5
|
export function conversationName(sessionFile: string): string {
|
|
11
6
|
return basename(sessionFile).replace(/\.jsonl$/, "");
|
|
12
7
|
}
|
|
13
8
|
|
|
14
|
-
/** Slug-keyed
|
|
15
|
-
* conversations whose files happen to share a basename (copied/renamed session files) can never
|
|
16
|
-
* share a snapshot. */
|
|
9
|
+
/** Slug-keyed dir name: conversations whose files share a basename can never share a snapshot. */
|
|
17
10
|
export function sessionStateDirName(sessionFile: string): string {
|
|
18
11
|
return `${basename(dirname(sessionFile))}__${conversationName(sessionFile)}`;
|
|
19
12
|
}
|
|
20
13
|
|
|
21
|
-
/** The pre-slug dir name; still honored when migrating or scanning live conversations. */
|
|
22
14
|
function legacyStateDirName(sessionFile: string): string {
|
|
23
15
|
return conversationName(sessionFile);
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
/**
|
|
27
|
-
* Resolve a conversation's state dir and snapshot file, migrating a legacy bare-name dir to the
|
|
28
|
-
* slug key on first start. Two conversations whose files share a basename therefore never share a
|
|
29
|
-
* snapshot file: whichever starts first migrates the legacy dir to its own key; the other starts
|
|
30
|
-
* empty rather than bleeding into the first's namespace.
|
|
31
|
-
*/
|
|
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. */
|
|
32
19
|
export function resolveStateDir(stateRoot: string, sessionFile: string): { dir: string; snapshotPath: string } {
|
|
33
20
|
const dir = join(stateRoot, sessionStateDirName(sessionFile));
|
|
34
21
|
const legacy = join(stateRoot, legacyStateDirName(sessionFile));
|
|
@@ -43,3 +30,29 @@ export function resolveStateDir(stateRoot: string, sessionFile: string): { dir:
|
|
|
43
30
|
}
|
|
44
31
|
return { dir, snapshotPath: join(dir, "namespace.snapshot") };
|
|
45
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
|
}
|