@astrosheep/pi-context 0.19.0 → 0.21.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/dist/src/budget.js +65 -0
- package/dist/src/dream/cli.js +83 -0
- package/dist/src/dream/gates.js +22 -0
- package/dist/src/dream/git.js +28 -0
- package/dist/src/dream/lock.js +58 -0
- package/dist/src/dream/runner.js +115 -0
- package/dist/src/history-tools.js +105 -0
- package/dist/src/history.js +215 -0
- package/dist/src/index.js +98 -0
- package/dist/src/notes/address.js +31 -0
- package/dist/src/notes/frontmatter.js +136 -0
- package/dist/src/notes/model.js +101 -0
- package/dist/src/notes/paths.js +58 -0
- package/dist/src/notes/store.js +270 -0
- package/dist/src/notes/tools.js +153 -0
- package/dist/src/prompts.js +81 -0
- package/dist/src/protocol.js +56 -0
- package/dist/src/reset-lifecycle.js +101 -0
- package/dist/src/session-reader.js +1 -0
- package/dist/src/thresholds.js +75 -0
- package/dist/src/tool-output.js +175 -0
- package/dist/src/tool-schema.js +26 -0
- package/dist/src/warning.js +44 -0
- package/dist/test/agent-loop.test.js +214 -0
- package/dist/test/coherence.test.js +375 -0
- package/dist/test/dream.test.js +142 -0
- package/dist/test/history.test.js +26 -0
- package/dist/test/integration.test.js +1766 -0
- package/dist/test/notes.test.js +474 -0
- package/dist/test/pagination.property.test.js +476 -0
- package/dist/test/reset-lifecycle.test.js +199 -0
- package/package.json +13 -7
- package/playbook.md +32 -0
- package/src/budget.ts +11 -9
- package/src/dream/cli.ts +33 -0
- package/src/dream/gates.ts +20 -0
- package/src/dream/git.ts +27 -0
- package/src/dream/lock.ts +39 -0
- package/src/dream/runner.ts +111 -0
- package/src/history-tools.ts +5 -5
- package/src/history.ts +12 -7
- package/src/index.ts +13 -14
- package/src/notes/address.ts +33 -0
- package/src/{memory → notes}/frontmatter.ts +5 -3
- package/src/{notes.ts → notes/model.ts} +2 -2
- package/src/{memory → notes}/paths.ts +6 -1
- package/src/{memory → notes}/store.ts +62 -77
- package/src/notes/tools.ts +132 -0
- package/src/prompts.ts +31 -29
- package/src/protocol.ts +9 -5
- package/src/thresholds.ts +4 -1
- package/src/tool-output.ts +4 -1
- package/src/warning.ts +3 -3
- package/src/memory/tools.ts +0 -166
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** A reset request is session-local. Only this module schedules compaction/continuation. */
|
|
2
|
+
export function registerResetLifecycle(pi, options) {
|
|
3
|
+
let state = { phase: "idle" };
|
|
4
|
+
let handledEntry;
|
|
5
|
+
let active = true;
|
|
6
|
+
const clear = () => {
|
|
7
|
+
state = { phase: "idle" };
|
|
8
|
+
handledEntry = undefined;
|
|
9
|
+
};
|
|
10
|
+
const valid = (request, ctx) => active && options.isEnabled() && state.phase === "compacting" && state.attempt === request && ctx.sessionManager.getSessionId() === request.sessionId;
|
|
11
|
+
// State is intentionally not resumed from a pending request: a loaded session must
|
|
12
|
+
// not execute work from a tool that belonged to a previous runtime or tree branch.
|
|
13
|
+
pi.on("session_start", () => { clear(); active = true; });
|
|
14
|
+
pi.on("session_shutdown", () => { clear(); active = false; });
|
|
15
|
+
pi.on("session_tree", clear);
|
|
16
|
+
pi.on("agent_end", (_event, ctx) => {
|
|
17
|
+
if (!active || !options.isEnabled())
|
|
18
|
+
return;
|
|
19
|
+
if (ctx.signal?.aborted) {
|
|
20
|
+
// Esc cancels the user's run. Do not reset or resurrect it at settled.
|
|
21
|
+
state = { phase: "idle" };
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
26
|
+
if (!active || !options.isEnabled() || state.phase === "compacting" || !ctx.isIdle())
|
|
27
|
+
return;
|
|
28
|
+
if (state.phase !== "requested")
|
|
29
|
+
return;
|
|
30
|
+
// One owner for requested resets. Consume the request before any external call;
|
|
31
|
+
// repeated settled events and reentrant callbacks are harmless.
|
|
32
|
+
const request = { completed: false, sessionId: ctx.sessionManager.getSessionId(), explicit: true };
|
|
33
|
+
state = { phase: "compacting", attempt: request };
|
|
34
|
+
const onError = (error) => {
|
|
35
|
+
if (!valid(request, ctx))
|
|
36
|
+
return;
|
|
37
|
+
state = { phase: "idle" };
|
|
38
|
+
// Do not retry from settled in a tight loop. A later prompt may trigger a
|
|
39
|
+
// native reset or explicitly request one.
|
|
40
|
+
ctx.ui.notify(`pi-context: reset did not complete (${error.message}). The conversation is retained; resume with another prompt.`, "warning");
|
|
41
|
+
};
|
|
42
|
+
try {
|
|
43
|
+
ctx.compact({
|
|
44
|
+
onComplete: () => {
|
|
45
|
+
if (!valid(request, ctx))
|
|
46
|
+
return;
|
|
47
|
+
state = { phase: "idle" };
|
|
48
|
+
// session_compact only confirms the boundary. onComplete runs after
|
|
49
|
+
// Pi clears compaction state; sending inside the hook starts too early.
|
|
50
|
+
// A queued user prompt may already have started at compaction_end.
|
|
51
|
+
if (request.completed && ctx.isIdle() && !ctx.hasPendingMessages()) {
|
|
52
|
+
pi.sendMessage(options.continuation, { triggerTurn: true });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
onError,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
onError(error instanceof Error ? error : new Error(String(error)));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
63
|
+
if (!active || !options.isEnabled())
|
|
64
|
+
return undefined;
|
|
65
|
+
if (event.signal.aborted)
|
|
66
|
+
return { cancel: true };
|
|
67
|
+
// Automatic threshold/overflow compactions reset on the spot — no model turn.
|
|
68
|
+
// The warning steer fired earlier (see warning.ts); what crosses the reserve
|
|
69
|
+
// line now is the wipe itself.
|
|
70
|
+
try {
|
|
71
|
+
return options.buildReset(event, ctx, state.phase === "requested");
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
ctx.ui.notify(`pi-context: could not build reset (${String(error)}).`, "warning");
|
|
75
|
+
return { cancel: true }; // Never fall through to a generated default summary.
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
pi.on("session_compact", (event, ctx) => {
|
|
79
|
+
if (!active || !options.isEnabled() || handledEntry === event.compactionEntry.id)
|
|
80
|
+
return;
|
|
81
|
+
if (!options.isCurrentReset(event.compactionEntry.id, ctx))
|
|
82
|
+
return;
|
|
83
|
+
handledEntry = event.compactionEntry.id;
|
|
84
|
+
if (state.phase === "compacting")
|
|
85
|
+
state.attempt.completed = !event.willRetry;
|
|
86
|
+
else
|
|
87
|
+
state = { phase: "idle" };
|
|
88
|
+
// A native compaction (including overflow retry) owns its own scheduling.
|
|
89
|
+
// Only a reset we requested gets a continuation from our onComplete callback.
|
|
90
|
+
options.onReset(event.compactionEntry.id);
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
request() {
|
|
94
|
+
const pending = state.phase !== "idle";
|
|
95
|
+
if (!pending)
|
|
96
|
+
state = { phase: "requested" };
|
|
97
|
+
return pending ? "rollover_already_pending" : "rollover_requested";
|
|
98
|
+
},
|
|
99
|
+
clear,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PI_CONTEXT_SETTINGS_KEY, DEFAULT_RESERVE_TOKENS, DEFAULT_REMINDER_MARGIN_TOKENS, WARNING_RUNWAY_TOKENS } from "./protocol.js";
|
|
3
|
+
function isSettingsObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
/** Read the raw "pi-context" object from one parsed settings scope. */
|
|
7
|
+
function piContextSettings(settings) {
|
|
8
|
+
if (!isSettingsObject(settings))
|
|
9
|
+
return {};
|
|
10
|
+
const value = settings[PI_CONTEXT_SETTINGS_KEY];
|
|
11
|
+
return isSettingsObject(value) ? value : {};
|
|
12
|
+
}
|
|
13
|
+
/** Merge the global and project "pi-context" objects per key; project wins, mirroring Pi's deep merge. */
|
|
14
|
+
export function mergePiContextSettings(globalSettings, projectSettings) {
|
|
15
|
+
const merged = { ...piContextSettings(globalSettings), ...piContextSettings(projectSettings) };
|
|
16
|
+
return { reminderMarginTokens: merged.reminderMarginTokens };
|
|
17
|
+
}
|
|
18
|
+
/** A margin is usable only as a positive integer; anything else is ignored. */
|
|
19
|
+
function validMargin(raw) {
|
|
20
|
+
if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw <= 0)
|
|
21
|
+
return undefined;
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Pure derivation of the thresholds from Pi's reserve: the reminder fires at reserve
|
|
26
|
+
* plus the pi-context margin, the warning steer at reserve plus WARNING_RUNWAY_TOKENS.
|
|
27
|
+
* An invalid margin degrades to the default and reports one warning. Pi's automatic
|
|
28
|
+
* threshold/overflow compaction itself resets immediately, with no model turn.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveThresholds(reserveTokens, margins) {
|
|
31
|
+
const warnings = [];
|
|
32
|
+
const reminderKey = `${PI_CONTEXT_SETTINGS_KEY}.reminderMarginTokens`;
|
|
33
|
+
let reminderMargin;
|
|
34
|
+
if (margins.reminderMarginTokens === undefined)
|
|
35
|
+
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
36
|
+
else {
|
|
37
|
+
const parsed = validMargin(margins.reminderMarginTokens);
|
|
38
|
+
if (parsed === undefined) {
|
|
39
|
+
warnings.push(`pi-context: ${reminderKey} must be a positive integer; using default ${DEFAULT_REMINDER_MARGIN_TOKENS}.`);
|
|
40
|
+
reminderMargin = DEFAULT_REMINDER_MARGIN_TOKENS;
|
|
41
|
+
}
|
|
42
|
+
else
|
|
43
|
+
reminderMargin = parsed;
|
|
44
|
+
}
|
|
45
|
+
return { thresholds: { reminder: reserveTokens + reminderMargin, reserve: reserveTokens, warning: reserveTokens + WARNING_RUNWAY_TOKENS }, warnings };
|
|
46
|
+
}
|
|
47
|
+
let cached;
|
|
48
|
+
/**
|
|
49
|
+
* Session-level threshold resolution: Pi's compaction reserve plus the settings.json
|
|
50
|
+
* "pi-context" margins. The file-backed read is cached until resetThresholds (called
|
|
51
|
+
* on session_start/session_tree); invalid configuration degrades per offending key
|
|
52
|
+
* with one warning and never throws during session operation.
|
|
53
|
+
*/
|
|
54
|
+
export function thresholdsFor(ctx) {
|
|
55
|
+
if (cached)
|
|
56
|
+
return cached;
|
|
57
|
+
try {
|
|
58
|
+
const settingsManager = SettingsManager.create(ctx.cwd, undefined, { projectTrusted: ctx.isProjectTrusted() });
|
|
59
|
+
// Pass the active model so per-model compaction.modelOverrides resolve (SDK 0.86);
|
|
60
|
+
// on older runtimes the extra argument is ignored and the ordinary setting wins.
|
|
61
|
+
const model = ctx.model;
|
|
62
|
+
const derived = deriveThresholds(settingsManager.getCompactionSettings(model ? { provider: model.provider, id: model.id } : undefined).reserveTokens, mergePiContextSettings(settingsManager.getGlobalSettings(), settingsManager.getProjectSettings()));
|
|
63
|
+
for (const warning of derived.warnings)
|
|
64
|
+
ctx.ui.notify(warning, "warning");
|
|
65
|
+
cached = derived.thresholds;
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
ctx.ui.notify(`pi-context: could not read settings; using defaults (${String(error)}).`, "warning");
|
|
69
|
+
cached = { reminder: DEFAULT_RESERVE_TOKENS + DEFAULT_REMINDER_MARGIN_TOKENS, reserve: DEFAULT_RESERVE_TOKENS, warning: DEFAULT_RESERVE_TOKENS + WARNING_RUNWAY_TOKENS };
|
|
70
|
+
}
|
|
71
|
+
return cached;
|
|
72
|
+
}
|
|
73
|
+
export function resetThresholds() {
|
|
74
|
+
cached = undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
export const TOOL_OUTPUT_MAX_BYTES = 32 * 1024;
|
|
2
|
+
export const DEFAULT_READ_WINDOW_CHARS = 12000;
|
|
3
|
+
export const MAX_READ_WINDOW_CHARS = 50000;
|
|
4
|
+
export const HISTORY_PREVIEW_CHARS = 1200;
|
|
5
|
+
function json(value) {
|
|
6
|
+
return JSON.stringify(value, null, 2);
|
|
7
|
+
}
|
|
8
|
+
/** True when `value` serializes within the same wire budget `output()` enforces. */
|
|
9
|
+
export function withinBudget(value, budget = TOOL_OUTPUT_MAX_BYTES) {
|
|
10
|
+
return Buffer.byteLength(json(value), "utf8") <= budget;
|
|
11
|
+
}
|
|
12
|
+
/** True when `text` fits the wire budget verbatim, for raw payloads with no JSON encoding. */
|
|
13
|
+
export function withinTextBudget(text, budget = TOOL_OUTPUT_MAX_BYTES) {
|
|
14
|
+
return Buffer.byteLength(text, "utf8") <= budget;
|
|
15
|
+
}
|
|
16
|
+
/** Marker standing in for characters elided from the middle of an oversized single unit. */
|
|
17
|
+
export function truncationMarker(removedChars) {
|
|
18
|
+
return `…[truncated ${removedChars} chars]…`;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Middle-truncate `text` until `fits` accepts it, keeping a head and a tail joined by
|
|
22
|
+
* `truncationMarker`. Codex's `truncate_middle` semantics: when one indivisible unit
|
|
23
|
+
* (a note line, a single match, a history item) exceeds the wire budget on its own, it is
|
|
24
|
+
* still returned — visibly truncated — so cursors advance and no page comes back empty.
|
|
25
|
+
* Returns `text` unchanged when it already fits.
|
|
26
|
+
*/
|
|
27
|
+
export function middleTruncate(text, fits) {
|
|
28
|
+
if (fits(text))
|
|
29
|
+
return text;
|
|
30
|
+
const chars = Array.from(text);
|
|
31
|
+
const build = (kept) => {
|
|
32
|
+
const head = Math.ceil(kept / 2);
|
|
33
|
+
return chars.slice(0, head).join("") + truncationMarker(chars.length - kept) + chars.slice(chars.length - (kept - head)).join("");
|
|
34
|
+
};
|
|
35
|
+
// The serialized size is non-decreasing in `kept` (each kept character adds at least one
|
|
36
|
+
// byte while the marker loses at most one digit), so a binary search finds the largest
|
|
37
|
+
// keep count that still fits.
|
|
38
|
+
let low = 0;
|
|
39
|
+
let high = chars.length;
|
|
40
|
+
while (low < high) {
|
|
41
|
+
const mid = Math.ceil((low + high) / 2);
|
|
42
|
+
if (fits(build(mid)))
|
|
43
|
+
low = mid;
|
|
44
|
+
else
|
|
45
|
+
high = mid - 1;
|
|
46
|
+
}
|
|
47
|
+
return build(low);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Longest contiguous prefix of `text` (counted in code points) accepted by `fits`.
|
|
51
|
+
*
|
|
52
|
+
* This is the truncation used by every cursor-bearing payload: the delivered text is
|
|
53
|
+
* always a plain prefix of the original, so a cursor computed from its code-point length
|
|
54
|
+
* addresses exactly the first undelivered character. No marker character is ever appended;
|
|
55
|
+
* the companion `truncated`/`total_chars` fields name what was left out.
|
|
56
|
+
*/
|
|
57
|
+
export function prefixFit(text, fits) {
|
|
58
|
+
if (fits(text))
|
|
59
|
+
return text;
|
|
60
|
+
const chars = Array.from(text);
|
|
61
|
+
// Serialized size is non-decreasing in the kept count, so the largest fitting prefix is
|
|
62
|
+
// found by a monotone binary search instead of a quadratic shrink loop.
|
|
63
|
+
let low = 0;
|
|
64
|
+
let high = chars.length;
|
|
65
|
+
while (low < high) {
|
|
66
|
+
const mid = Math.ceil((low + high) / 2);
|
|
67
|
+
if (fits(chars.slice(0, mid).join("")))
|
|
68
|
+
low = mid;
|
|
69
|
+
else
|
|
70
|
+
high = mid - 1;
|
|
71
|
+
}
|
|
72
|
+
// A candidate's serialized size can dip by a byte or two at the very end (a numeric cursor
|
|
73
|
+
// becoming null), so the predicate is not perfectly monotone at the tail. Back off until the
|
|
74
|
+
// returned prefix provably fits; in the monotone case this loop never runs.
|
|
75
|
+
while (low > 0 && !fits(chars.slice(0, low).join("")))
|
|
76
|
+
low -= 1;
|
|
77
|
+
return chars.slice(0, low).join("");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Read one character window of `text`: the longest contiguous prefix of
|
|
81
|
+
* `chars[resolved, resolved + limit)` that fits the wire budget.
|
|
82
|
+
*
|
|
83
|
+
* `offsetChars` is a code-point offset. A negative value counts back from the end and
|
|
84
|
+
* resolves to `max(0, total_chars + offsetChars)`, so `-N` reaches the tail and any
|
|
85
|
+
* `N >= total_chars` reads from the start; the resolved absolute offset is always echoed.
|
|
86
|
+
* Following `next_offset_chars` reconstructs `text` by plain concatenation, because the
|
|
87
|
+
* payload is always a plain prefix with no marker. `render` builds the exact response for
|
|
88
|
+
* a candidate window, and `measure` decides whether that response fits the wire budget (JSON
|
|
89
|
+
* serialization by default; raw-text renders pass a verbatim byte measure), so the budget is
|
|
90
|
+
* always measured on the bytes that go on the wire.
|
|
91
|
+
*/
|
|
92
|
+
export function readCharacterWindow(text, offsetChars, limitChars, render, measure = withinBudget) {
|
|
93
|
+
const chars = Array.from(text);
|
|
94
|
+
const requested = offsetChars ?? 0;
|
|
95
|
+
const resolved = requested < 0 ? Math.max(0, chars.length + requested) : Math.max(0, requested);
|
|
96
|
+
const windowChars = chars.slice(resolved, resolved + Math.min(limitChars ?? DEFAULT_READ_WINDOW_CHARS, MAX_READ_WINDOW_CHARS));
|
|
97
|
+
const build = (content) => {
|
|
98
|
+
const next = resolved + Array.from(content).length;
|
|
99
|
+
return { offset_chars: resolved, content, total_chars: chars.length, next_offset_chars: next < chars.length ? next : null };
|
|
100
|
+
};
|
|
101
|
+
const content = prefixFit(windowChars.join(""), (candidate) => measure(render(build(candidate))));
|
|
102
|
+
return render(build(content));
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One-line bracketed header preceding a raw character-window payload: the identity, the
|
|
106
|
+
* delivered char range, and either the resume cursor or `end`. `tail` appends extra
|
|
107
|
+
* metadata (notes add their timestamps) inside the same brackets.
|
|
108
|
+
*/
|
|
109
|
+
export function characterWindowHeader(identity, window, tail = "") {
|
|
110
|
+
// The range end is offset + delivered count, never `total_chars`: a read resolved past the
|
|
111
|
+
// end delivers zero characters there, and the header must not render an inverted range.
|
|
112
|
+
const end = window.offset_chars + Array.from(window.content).length;
|
|
113
|
+
const resume = window.next_offset_chars === null ? "end" : `continue at offset_chars=${window.next_offset_chars}`;
|
|
114
|
+
return `[${identity} · chars ${window.offset_chars}-${end} of ${window.total_chars} · ${resume}${tail}]`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Code-point offset of the earliest occurrence of any of `queries` in `text`, or 0 when
|
|
118
|
+
* none occurs. Shared by the two search tools so a match address is computed identically.
|
|
119
|
+
*/
|
|
120
|
+
export function earliestMatchOffsetChars(text, queries) {
|
|
121
|
+
let earliest = -1;
|
|
122
|
+
for (const query of queries) {
|
|
123
|
+
const index = text.indexOf(query);
|
|
124
|
+
if (index < 0)
|
|
125
|
+
continue;
|
|
126
|
+
if (earliest < 0 || index < earliest)
|
|
127
|
+
earliest = index;
|
|
128
|
+
}
|
|
129
|
+
return earliest <= 0 ? 0 : Array.from(text.slice(0, earliest)).length;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Build a page without ever adding an item that would exceed the wire budget.
|
|
133
|
+
*
|
|
134
|
+
* A single item that cannot fit is middle-truncated through the optional `truncate`
|
|
135
|
+
* callback and still included, with `next_cursor` advanced past it. Without that treatment
|
|
136
|
+
* an oversized item would yield an empty page forever: the cursor would keep pointing back
|
|
137
|
+
* at the same index.
|
|
138
|
+
*/
|
|
139
|
+
export function page(items, cursor, key, limit, truncate) {
|
|
140
|
+
const end = Math.min(items.length, cursor + (limit ?? items.length));
|
|
141
|
+
const selected = [];
|
|
142
|
+
let next = end < items.length ? end : null;
|
|
143
|
+
for (let index = cursor; index < end; index++) {
|
|
144
|
+
const candidateNext = index + 1 < end || end < items.length ? index + 1 : null;
|
|
145
|
+
const fits = (list) => withinBudget({ [key]: list, next_cursor: candidateNext });
|
|
146
|
+
if (!fits([...selected, items[index]])) {
|
|
147
|
+
if (selected.length === 0 && truncate) {
|
|
148
|
+
selected.push(truncate(items[index], (candidate) => fits([candidate])));
|
|
149
|
+
next = candidateNext;
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
next = index;
|
|
153
|
+
}
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
selected.push(items[index]);
|
|
157
|
+
}
|
|
158
|
+
return { [key]: selected, next_cursor: next };
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Encode a structured result through the common tool result boundary. `details` is slim
|
|
162
|
+
* metadata for logs/UI (pi convention: never a second copy of the payload) and stays
|
|
163
|
+
* undefined unless the tool has metadata worth persisting.
|
|
164
|
+
*/
|
|
165
|
+
export function output(value, details, terminate = false) {
|
|
166
|
+
return { content: [{ type: "text", text: json(value) }], details, terminate };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Encode a prose payload as raw text: a one-line bracketed metadata header, then the payload
|
|
170
|
+
* verbatim. The model reads the note or history item itself instead of a JSON envelope;
|
|
171
|
+
* `details` carries the slim metadata object and never duplicates the payload.
|
|
172
|
+
*/
|
|
173
|
+
export function outputRaw(header, content, details, terminate = false) {
|
|
174
|
+
return { content: [{ type: "text", text: `${header}\n${content}` }], details, terminate };
|
|
175
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
export const nullableString = () => Type.Optional(Type.Union([Type.String(), Type.Null()]));
|
|
3
|
+
export const positiveInteger = () => Type.Optional(Type.Integer({ minimum: 1 }));
|
|
4
|
+
export const cursor = () => Type.Optional(Type.Integer({ minimum: 0, description: "Continuation cursor: pass the previous next_cursor back unchanged, with the same filters and ordering. Omit to start. next_cursor is null only when the set is exhausted." }));
|
|
5
|
+
export const recentFirst = () => Type.Optional(Type.Boolean({ description: "Return newest-first. Only an explicit false returns oldest-first. Defaults to true." }));
|
|
6
|
+
/** Role filter. `developer` is the known author for this extension's own custom entries. */
|
|
7
|
+
export const role = Type.Union([Type.Literal("user"), Type.Literal("assistant"), Type.Literal("tool_call"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("developer"), Type.Null()], { description: "Filter by the item's role. Exactly six: \"user\" and \"assistant\" are a message's visible text (assistant text never contains tool calls); \"tool_call\" is one tool invocation (tool_name set, content = the call's JSON arguments); \"tool\" is one tool run's output (tool_name set); \"system\" is a native Pi compaction summary; \"developer\" is an entry this extension authored (boot, guidance, warning, continuation messages, reset-window compaction summaries, any pi-context/* entry)." });
|
|
8
|
+
/** Search query parameter: one literal, or several literals combined with OR. */
|
|
9
|
+
export const searchQuery = () => Type.Union([Type.String(), Type.Array(Type.String(), { minItems: 1 })]);
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a search `query` parameter into the literal needles to match.
|
|
12
|
+
* A bare string is a one-element list, so single-query behavior is unchanged.
|
|
13
|
+
* An empty list, a non-string element, or an empty string is refused rather than silently
|
|
14
|
+
* searching for nothing: those are argument errors, not empty result sets. An empty string
|
|
15
|
+
* matches every line and every item, so it can never be what the caller meant.
|
|
16
|
+
*/
|
|
17
|
+
export function searchQueries(query) {
|
|
18
|
+
const candidates = typeof query === "string" ? [query] : query;
|
|
19
|
+
if (!Array.isArray(candidates) || candidates.length === 0)
|
|
20
|
+
throw new Error("query must be a string or a non-empty array of strings");
|
|
21
|
+
if (!candidates.every((candidate) => typeof candidate === "string"))
|
|
22
|
+
throw new Error("query array elements must be strings");
|
|
23
|
+
if (candidates.some((candidate) => candidate === ""))
|
|
24
|
+
throw new Error("query strings must be non-empty: an empty query matches everything");
|
|
25
|
+
return candidates;
|
|
26
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { WARNING_TYPE, GUIDANCE_OPEN_TAG, GUIDANCE_CLOSE_TAG, WARNING_PROMPT } from "./protocol.js";
|
|
2
|
+
import { thresholdsFor, resetThresholds } from "./thresholds.js";
|
|
3
|
+
import { hasWindowMessage, currentWindowId } from "./history.js";
|
|
4
|
+
import { remainingTokens } from "./budget.js";
|
|
5
|
+
/**
|
|
6
|
+
* The final checkpoint warning, steered to the model once per window. Like the early
|
|
7
|
+
* reminder, the steer text is model-facing only (display: false); the human learns
|
|
8
|
+
* about it from the warning-level notify, not from a chat-visible message.
|
|
9
|
+
*/
|
|
10
|
+
/** Trigger: does the steer fire at this remaining-token count? Pure. */
|
|
11
|
+
export function warningDue(remaining, thresholds) {
|
|
12
|
+
return remaining <= thresholds.warning;
|
|
13
|
+
}
|
|
14
|
+
/** Delivery: what happens when it fires. */
|
|
15
|
+
export function steerWarning(pi, ctx, thresholds, remaining) {
|
|
16
|
+
pi.sendMessage({ customType: WARNING_TYPE, content: `${GUIDANCE_OPEN_TAG}\n${WARNING_PROMPT}\n${GUIDANCE_CLOSE_TAG}`, display: false }, { triggerTurn: true });
|
|
17
|
+
ctx.ui.notify(`pi-context: context budget critical (${Math.max(0, remaining - thresholds.reserve)} tokens before reserve) — final checkpoint warning steered to the model.`, "warning");
|
|
18
|
+
}
|
|
19
|
+
/** Registration: once-per-window guard plus trigger+delivery on the context hook. */
|
|
20
|
+
export function registerWarning(pi, isEnabled) {
|
|
21
|
+
let firedInWindow;
|
|
22
|
+
// Threshold resolution is owned by budget.ts; this module only consumes the shared
|
|
23
|
+
// cache (lazily on the context hook) so session_start never warns twice.
|
|
24
|
+
pi.on("session_start", () => { firedInWindow = undefined; });
|
|
25
|
+
pi.on("session_tree", () => { firedInWindow = undefined; resetThresholds(); });
|
|
26
|
+
pi.on("context", (_event, ctx) => {
|
|
27
|
+
const windowId = currentWindowId(ctx);
|
|
28
|
+
if (!isEnabled() || firedInWindow === windowId || hasWindowMessage(ctx, WARNING_TYPE))
|
|
29
|
+
return undefined;
|
|
30
|
+
const remaining = remainingTokens(ctx);
|
|
31
|
+
if (remaining === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
const thresholds = thresholdsFor(ctx);
|
|
34
|
+
if (!warningDue(remaining, thresholds))
|
|
35
|
+
return undefined;
|
|
36
|
+
firedInWindow = windowId;
|
|
37
|
+
// The steer reaches the model at the next sampling step with at most the runway
|
|
38
|
+
// of invisible budget left. After it, the model decides for itself: end the
|
|
39
|
+
// window, or ride it into Pi's automatic compaction, which resets on the spot
|
|
40
|
+
// with no turn (see reset-lifecycle).
|
|
41
|
+
steerWarning(pi, ctx, thresholds, remaining);
|
|
42
|
+
return undefined;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
7
|
+
import { createAgentSession, DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import piContext from "../src/index.js";
|
|
9
|
+
import { WARNING_TYPE, GUIDANCE_TYPE } from "../src/protocol.js";
|
|
10
|
+
for (const mode of ["golden", "write-error", "ignored-warning", "explicit", "uncompactable", "followup", "steering", "repeat", "abort"]) {
|
|
11
|
+
test(`real Pi loop: ${mode} reset preserves history and handles completion`, { timeout: 15000 }, async () => {
|
|
12
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-context-loop-"));
|
|
13
|
+
const previousDir = process.env.PI_CODING_AGENT_DIR;
|
|
14
|
+
const previousNotesRoot = process.env.PI_NOTES_HOME;
|
|
15
|
+
process.env.PI_CODING_AGENT_DIR = dir;
|
|
16
|
+
const notesRoot = mkdtempSync(join(tmpdir(), "pi-context-loop-notes-"));
|
|
17
|
+
process.env.PI_NOTES_HOME = notesRoot;
|
|
18
|
+
let session;
|
|
19
|
+
try {
|
|
20
|
+
const runtime = await ModelRuntime.create({ authPath: join(dir, "auth.json"), modelsPath: null, modelsStorePath: join(dir, "models"), refreshOnCreate: false });
|
|
21
|
+
await runtime.setRuntimeApiKey("openai", "scripted-test-key");
|
|
22
|
+
const base = runtime.getModels("openai")[0];
|
|
23
|
+
assert.ok(base);
|
|
24
|
+
const model = { ...base, contextWindow: 100000, maxTokens: 4096 };
|
|
25
|
+
const usageMode = mode === "golden" || mode === "write-error" || mode === "ignored-warning";
|
|
26
|
+
const expectedResets = mode === "abort" || mode === "uncompactable" ? 0 : 1;
|
|
27
|
+
// 0.86 split-turn cut can still summarize a turn prefix, so keepRecentTokens: 1 no longer
|
|
28
|
+
// makes a reset uncompactable; a keep larger than the whole session keeps everything and does.
|
|
29
|
+
const settings = { compaction: { enabled: usageMode, reserveTokens: 32768, keepRecentTokens: mode === "uncompactable" ? 1_000_000 : 200 }, retry: { enabled: false } };
|
|
30
|
+
writeFileSync(join(dir, "settings.json"), JSON.stringify(settings));
|
|
31
|
+
const settingsManager = SettingsManager.create(dir, dir);
|
|
32
|
+
let resets = 0;
|
|
33
|
+
let settled = 0;
|
|
34
|
+
let targetResets = expectedResets;
|
|
35
|
+
let activeSentinel = "OLD_CONTEXT_SENTINEL";
|
|
36
|
+
let finish;
|
|
37
|
+
let failFinish;
|
|
38
|
+
const finished = new Promise((resolve, reject) => { finish = resolve; failFinish = reject; });
|
|
39
|
+
const finishTimeout = setTimeout(() => failFinish(new Error(`timed out waiting for ${mode} agent settlement`)), 5000);
|
|
40
|
+
const loader = new DefaultResourceLoader({ cwd: dir, agentDir: dir, settingsManager,
|
|
41
|
+
noExtensions: true, noSkills: true, noThemes: true, noPromptTemplates: true,
|
|
42
|
+
systemPromptOverride: () => "Use the tools as requested.", agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
43
|
+
extensionFactories: [piContext, (pi) => {
|
|
44
|
+
pi.on("session_compact", () => { resets++; });
|
|
45
|
+
pi.on("agent_settled", () => {
|
|
46
|
+
settled++;
|
|
47
|
+
if (mode === "abort") {
|
|
48
|
+
if (settled === 1)
|
|
49
|
+
finish();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (resets >= targetResets && requests.length > 0 && !requests.at(-1).includes(activeSentinel))
|
|
53
|
+
finish();
|
|
54
|
+
});
|
|
55
|
+
pi.on("tool_result", () => {
|
|
56
|
+
if (mode === "abort")
|
|
57
|
+
void session.abort();
|
|
58
|
+
});
|
|
59
|
+
pi.on("tool_call", async () => {
|
|
60
|
+
if (mode === "followup")
|
|
61
|
+
await session.followUp("QUEUED_INPUT_SENTINEL");
|
|
62
|
+
if (mode === "steering")
|
|
63
|
+
await session.steer("QUEUED_INPUT_SENTINEL");
|
|
64
|
+
});
|
|
65
|
+
}],
|
|
66
|
+
});
|
|
67
|
+
await loader.reload();
|
|
68
|
+
const sm = SessionManager.inMemory(dir);
|
|
69
|
+
sm.appendMessage({ role: "user", content: "Earlier work to retain in durable history.", timestamp: Date.now() });
|
|
70
|
+
sm.appendMessage({ role: "assistant", api: model.api, provider: model.provider, model: model.id,
|
|
71
|
+
content: [{ type: "text", text: "Earlier result. ".repeat(100) }], stopReason: "stop", timestamp: Date.now(),
|
|
72
|
+
usage: { input: 100, output: 100, cacheRead: 0, cacheWrite: 0, totalTokens: 200, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } });
|
|
73
|
+
({ session } = await createAgentSession({ cwd: dir, agentDir: dir, modelRuntime: runtime, model, settingsManager, sessionManager: sm, resourceLoader: loader, tools: ["new_context", "notes_write", "get_context_remaining"] }));
|
|
74
|
+
const requests = [];
|
|
75
|
+
let checkpointed = false;
|
|
76
|
+
let freshTurns = 0;
|
|
77
|
+
session.agent.streamFunction = (_model, context) => {
|
|
78
|
+
requests.push(JSON.stringify(context.messages));
|
|
79
|
+
const n = requests.length;
|
|
80
|
+
const request = requests[n - 1];
|
|
81
|
+
const fresh = !request.includes("OLD_CONTEXT_SENTINEL");
|
|
82
|
+
if (fresh)
|
|
83
|
+
freshTurns++;
|
|
84
|
+
const sawWarning = request.includes("Your memory is about to be erased");
|
|
85
|
+
const sawGuidance = request.includes("Your brain is almost out of room");
|
|
86
|
+
const explicitReset = (n === 1 && !usageMode && mode !== "uncompactable") || (mode === "repeat" && (n === 1 || n === 3));
|
|
87
|
+
const checkpoint = usageMode && sawWarning && !checkpointed && mode !== "ignored-warning";
|
|
88
|
+
if (checkpoint)
|
|
89
|
+
checkpointed = true;
|
|
90
|
+
// The warning is chosen from the previous turn's usage, so a scripted run has to
|
|
91
|
+
// keep taking turns until it sees the warning (first window) or the next
|
|
92
|
+
// window's reminder. "ignored-warning" keeps working instead of checkpointing.
|
|
93
|
+
const probe = usageMode && !checkpoint && ((!fresh && !sawWarning) || (mode === "ignored-warning" && sawWarning) || (fresh && freshTurns === 2 && !sawGuidance));
|
|
94
|
+
const tokens = usageMode ? (fresh ? (freshTurns === 1 ? 100 : 50000) : sawWarning ? 70000 : n === 1 ? 50000 : 60000) : 100;
|
|
95
|
+
const tool = explicitReset || (mode === "uncompactable" && n === 1);
|
|
96
|
+
const call = probe ? "get_context_remaining" : checkpoint ? "notes_write" : tool ? "new_context" : undefined;
|
|
97
|
+
const message = { role: "assistant", api: model.api, provider: model.provider, model: model.id,
|
|
98
|
+
content: probe ? [{ type: "toolCall", id: "probe-call", name: "get_context_remaining", arguments: {} }]
|
|
99
|
+
: checkpoint ? [{ type: "toolCall", id: "checkpoint-call", name: "notes_write", arguments: { address: mode === "write-error" ? "../invalid.md" : "checkpoint.md", content: "CHECKPOINT_SENTINEL" } }]
|
|
100
|
+
: tool ? [{ type: "toolCall", id: "reset-call", name: "new_context", arguments: {} }]
|
|
101
|
+
: [{ type: "text", text: fresh ? "Resumed." : "Working." }],
|
|
102
|
+
stopReason: call ? "toolUse" : "stop", timestamp: Date.now(),
|
|
103
|
+
usage: { input: tokens, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: tokens + 1, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
104
|
+
};
|
|
105
|
+
const stream = createAssistantMessageEventStream();
|
|
106
|
+
stream.push({ type: "done", reason: message.stopReason, message });
|
|
107
|
+
stream.end();
|
|
108
|
+
return stream;
|
|
109
|
+
};
|
|
110
|
+
await session.bindExtensions({});
|
|
111
|
+
let failure;
|
|
112
|
+
session.subscribe((event) => {
|
|
113
|
+
if (mode === "uncompactable" && event.type === "compaction_end" && event.errorMessage) {
|
|
114
|
+
failure = event.errorMessage;
|
|
115
|
+
finish();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
await session.prompt("OLD_CONTEXT_SENTINEL: save progress and continue the task.");
|
|
119
|
+
await finished;
|
|
120
|
+
clearTimeout(finishTimeout);
|
|
121
|
+
await session.waitForIdle();
|
|
122
|
+
if (mode === "abort") {
|
|
123
|
+
assert.equal(resets, 0, "user cancellation clears pending rollover");
|
|
124
|
+
assert.equal(requests.length, 1, "no continuation resurrects the cancelled run");
|
|
125
|
+
await session.prompt("Resume explicitly after cancellation.");
|
|
126
|
+
assert.equal(requests.length, 2);
|
|
127
|
+
assert.ok(requests[1].includes("Resume explicitly after cancellation."));
|
|
128
|
+
assert.ok(requests[1].includes("OLD_CONTEXT_SENTINEL"), "no reset happened: history is intact");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (mode === "uncompactable") {
|
|
132
|
+
assert.match(failure ?? "", /Nothing to compact/);
|
|
133
|
+
assert.equal(resets, 0);
|
|
134
|
+
assert.equal(requests.length, 1, "failed reset does not loop or resume automatically");
|
|
135
|
+
await session.prompt("Continue after the failed reset.");
|
|
136
|
+
assert.equal(requests.length, 2);
|
|
137
|
+
assert.ok(requests[1].includes("OLD_CONTEXT_SENTINEL"));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
assert.equal(resets, expectedResets);
|
|
141
|
+
if (mode === "golden" || mode === "write-error" || mode === "ignored-warning") {
|
|
142
|
+
const branch = sm.getBranch();
|
|
143
|
+
const guidanceIndices = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE ? [i] : []);
|
|
144
|
+
const warningIndices = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === WARNING_TYPE ? [i] : []);
|
|
145
|
+
const noteFile = join(notesRoot, "pi", "session", sm.getSessionId(), "checkpoint.md");
|
|
146
|
+
const resetIndex = branch.findIndex((entry) => entry.type === "compaction");
|
|
147
|
+
assert.equal(guidanceIndices.length, 1, "one early reminder");
|
|
148
|
+
assert.equal(warningIndices.length, 1, "one final warning steer");
|
|
149
|
+
assert.ok(warningIndices[0] > guidanceIndices[0], "the reminder precedes the warning");
|
|
150
|
+
if (mode === "ignored-warning") {
|
|
151
|
+
assert.equal(existsSync(noteFile), false, "an ignored warning leaves no checkpoint");
|
|
152
|
+
}
|
|
153
|
+
else if (mode === "write-error") {
|
|
154
|
+
assert.equal(existsSync(noteFile), false, "failed write creates no checkpoint");
|
|
155
|
+
assert.ok(branch.some((entry) => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "notes_write" && entry.message.isError));
|
|
156
|
+
assert.ok(!requests.at(-1).includes("checkpoint.md"), "fresh context must not invent a saved note");
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
assert.ok(existsSync(noteFile), "the checkpoint is a real file on disk");
|
|
160
|
+
assert.ok(resetIndex > warningIndices[0], "the warning precedes the wipe");
|
|
161
|
+
assert.ok(requests.at(-1).includes("checkpoint.md"), "fresh boot carries the saved checkpoint as a metadata line");
|
|
162
|
+
}
|
|
163
|
+
assert.ok(!requests.at(-1).includes("Your brain is almost out of room"), "new window excludes old guidance");
|
|
164
|
+
}
|
|
165
|
+
if (mode === "followup" || mode === "steering") {
|
|
166
|
+
assert.ok(requests[1].includes("QUEUED_INPUT_SENTINEL"), "queued user work is delivered before rollover");
|
|
167
|
+
assert.ok(requests[1].includes("OLD_CONTEXT_SENTINEL"), "queue drains in the existing window");
|
|
168
|
+
assert.ok(!requests[2].includes("QUEUED_INPUT_SENTINEL"), "queue is not replayed after rollover");
|
|
169
|
+
const queuedEntries = sm.getBranch().filter((entry) => entry.type === "message" && JSON.stringify(entry.message).includes("QUEUED_INPUT_SENTINEL"));
|
|
170
|
+
assert.equal(queuedEntries.length, 1, "one durable user input");
|
|
171
|
+
}
|
|
172
|
+
if (mode === "repeat") {
|
|
173
|
+
const nextFinished = new Promise((resolve) => { finish = resolve; });
|
|
174
|
+
targetResets = 2;
|
|
175
|
+
activeSentinel = "SECOND_WINDOW_SENTINEL";
|
|
176
|
+
await session.prompt("SECOND_WINDOW_SENTINEL: " + "Additional work. ".repeat(100));
|
|
177
|
+
await nextFinished;
|
|
178
|
+
await session.waitForIdle();
|
|
179
|
+
assert.equal(resets, 2);
|
|
180
|
+
assert.ok(!requests.at(-1).includes("SECOND_WINDOW_SENTINEL"));
|
|
181
|
+
const boundaries = sm.getBranch().filter((entry) => entry.type === "compaction");
|
|
182
|
+
assert.equal(new Set(boundaries.map((entry) => JSON.stringify(entry.details))).size, 2);
|
|
183
|
+
}
|
|
184
|
+
assert.ok(requests[0].includes("OLD_CONTEXT_SENTINEL"));
|
|
185
|
+
assert.ok(!requests.at(-1).includes("OLD_CONTEXT_SENTINEL"));
|
|
186
|
+
assert.ok(requests.at(-1).includes("context_window"));
|
|
187
|
+
assert.ok(JSON.stringify(session.sessionManager.getBranch()).includes("OLD_CONTEXT_SENTINEL"));
|
|
188
|
+
if (mode === "golden") {
|
|
189
|
+
await session.prompt("Keep working in the new window until its reminder threshold.");
|
|
190
|
+
assert.equal(resets, 1);
|
|
191
|
+
const branch = sm.getBranch();
|
|
192
|
+
const boundary = branch.findIndex((entry) => entry.type === "compaction");
|
|
193
|
+
const reminders = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === GUIDANCE_TYPE ? [i] : []);
|
|
194
|
+
assert.equal(reminders.length, 2, "the next window gets its own reminder");
|
|
195
|
+
assert.ok(reminders[1] > boundary, "old messages cannot suppress a new window's reminder");
|
|
196
|
+
const warnings = branch.flatMap((entry, i) => entry.type === "custom_message" && entry.customType === WARNING_TYPE ? [i] : []);
|
|
197
|
+
assert.equal(warnings.length, 1, "the next window has no warning yet");
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
session?.dispose();
|
|
202
|
+
if (previousDir === undefined)
|
|
203
|
+
delete process.env.PI_CODING_AGENT_DIR;
|
|
204
|
+
else
|
|
205
|
+
process.env.PI_CODING_AGENT_DIR = previousDir;
|
|
206
|
+
if (previousNotesRoot === undefined)
|
|
207
|
+
delete process.env.PI_NOTES_HOME;
|
|
208
|
+
else
|
|
209
|
+
process.env.PI_NOTES_HOME = previousNotesRoot;
|
|
210
|
+
rmSync(dir, { recursive: true, force: true });
|
|
211
|
+
rmSync(notesRoot, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|