pi-jar 0.2.2 → 0.2.4
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/README.md +46 -14
- package/docs/DEVELOPMENT.md +2 -1
- package/docs/EFFICIENCY_PLAN.md +23 -0
- package/docs/INTEGRATIONS.md +24 -7
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +87 -16
- package/package.json +1 -1
- package/src/advisor.ts +21 -9
- package/src/ask-tool.ts +2 -7
- package/src/cache-breaks-view.ts +56 -0
- package/src/cache-breaks.ts +521 -0
- package/src/compact-tools.ts +148 -8
- package/src/context-budget.ts +226 -0
- package/src/context-diet.ts +158 -17
- package/src/{democracy.ts → council.ts} +19 -22
- package/src/delegate.ts +80 -50
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +3 -0
- package/src/info-panels.ts +15 -3
- package/src/model-roles.ts +26 -2
- package/src/panel.ts +1 -1
- package/src/plan.ts +4 -2
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +8 -3
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +30 -8
- package/src/settings.ts +24 -1
- package/src/shells.ts +77 -15
- package/src/subagent-tools.ts +1 -1
- package/src/suggest.ts +4 -11
- package/src/task-tool.ts +3 -9
- package/src/tool-activation.ts +14 -0
- package/src/usage-view.ts +8 -4
- package/src/usage.ts +4 -2
package/src/compact-tools.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
1
2
|
import {
|
|
2
3
|
createBashToolDefinition,
|
|
3
4
|
createEditToolDefinition,
|
|
4
5
|
createReadToolDefinition,
|
|
5
6
|
createWriteToolDefinition,
|
|
7
|
+
formatSize,
|
|
6
8
|
getAgentDir,
|
|
7
9
|
SettingsManager,
|
|
8
10
|
type ExtensionAPI,
|
|
9
|
-
type ExtensionContext
|
|
11
|
+
type ExtensionContext,
|
|
12
|
+
type ReadToolDetails
|
|
10
13
|
} from "@earendil-works/pi-coding-agent";
|
|
11
14
|
import { Box, Container, Text } from "@earendil-works/pi-tui";
|
|
15
|
+
import { Type } from "typebox";
|
|
16
|
+
import { isReadStub, readRangeKey, resolveToolPath, type ReadStubDetails } from "./context-diet.ts";
|
|
12
17
|
|
|
13
18
|
type ToolResult = { content: Array<{ type: string; text?: string }>; details?: unknown };
|
|
14
19
|
|
|
@@ -152,11 +157,83 @@ function bashFor(ctx: ExtensionContext) {
|
|
|
152
157
|
return tool;
|
|
153
158
|
}
|
|
154
159
|
|
|
160
|
+
/** Over Pi's 50 KB read cap, a read without offset/limit gets this many lines and a note saying so. */
|
|
161
|
+
const AUTO_LIMIT_BYTES = 50 * 1024;
|
|
162
|
+
const AUTO_LIMIT_LINES = 400;
|
|
163
|
+
/** Pi sends these as image attachments and ignores offset/limit for them. */
|
|
164
|
+
const IMAGE_FILE = /\.(?:png|jpe?g|gif|webp|bmp)$/i;
|
|
165
|
+
/** Pi's trailing `[… Use offset=N to continue.]` notice, which is not file text. */
|
|
166
|
+
const READ_NOTICE = /\n\n\[[^\n]*\]$/;
|
|
167
|
+
|
|
168
|
+
/** A read whose output is in context: the file revision it saw, its session ordinal and its line count. */
|
|
169
|
+
interface ReadMark { revision: string; ordinal: number; lines: number }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Per-session record of what `read` put in context, behind the opt-in repeated-read stub. A mark answers
|
|
173
|
+
* only for its exact path, line window and file revision. Anything that may change the file or drop that
|
|
174
|
+
* output from context forgets marks: an edit/write of the path (even a failed edit: the model evidently
|
|
175
|
+
* misremembers the file), a session start or switch, compaction and tree navigation.
|
|
176
|
+
*/
|
|
177
|
+
class ReadMemory {
|
|
178
|
+
#files = new Map<string, Map<string, ReadMark>>();
|
|
179
|
+
#reads = 0;
|
|
180
|
+
/** Invalidation clock: a read that straddles an invalidation of its file is never remembered. */
|
|
181
|
+
#clock = 0;
|
|
182
|
+
#cleared = 0;
|
|
183
|
+
#forgotten = new Map<string, number>();
|
|
184
|
+
|
|
185
|
+
/** The session ordinal of a new read and the clock it starts at. */
|
|
186
|
+
begin(): { ordinal: number; since: number } {
|
|
187
|
+
return { ordinal: ++this.#reads, since: this.#clock };
|
|
188
|
+
}
|
|
189
|
+
find(file: string, range: string, revision: string): ReadMark | undefined {
|
|
190
|
+
const mark = this.#files.get(file)?.get(range);
|
|
191
|
+
return mark?.revision === revision ? mark : undefined;
|
|
192
|
+
}
|
|
193
|
+
remember(file: string, range: string, mark: ReadMark, since: number): void {
|
|
194
|
+
if (this.#cleared > since || (this.#forgotten.get(file) ?? 0) > since) return;
|
|
195
|
+
let ranges = this.#files.get(file);
|
|
196
|
+
if (!ranges) this.#files.set(file, ranges = new Map());
|
|
197
|
+
ranges.set(range, mark);
|
|
198
|
+
}
|
|
199
|
+
forget(file: string): void {
|
|
200
|
+
this.#forgotten.set(file, ++this.#clock);
|
|
201
|
+
this.#files.delete(file);
|
|
202
|
+
}
|
|
203
|
+
/** A new session also restarts read numbering. */
|
|
204
|
+
clear(newSession = false): void {
|
|
205
|
+
this.#cleared = ++this.#clock;
|
|
206
|
+
this.#files.clear();
|
|
207
|
+
this.#forgotten.clear();
|
|
208
|
+
if (newSession) this.#reads = 0;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A regular file's resolved path, size and revision (Pi's own dev:ino:size:mtime:ctime file key). */
|
|
213
|
+
async function inspectFile(path: string, cwd: string): Promise<{ file: string; size: number; revision: string } | undefined> {
|
|
214
|
+
const file = resolveToolPath(path, cwd);
|
|
215
|
+
if (!file) return undefined;
|
|
216
|
+
try {
|
|
217
|
+
const stats = await stat(file, { bigint: true });
|
|
218
|
+
if (!stats.isFile()) return undefined;
|
|
219
|
+
return { file, size: Number(stats.size), revision: `${stats.dev}:${stats.ino}:${stats.size}:${stats.mtimeNs}:${stats.ctimeNs}` };
|
|
220
|
+
} catch {
|
|
221
|
+
// Missing or unreadable: Pi's read reports the error itself (or resolves a macOS name variant), uncached.
|
|
222
|
+
return undefined;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export interface CompactToolOptions {
|
|
227
|
+
/** Opt-in repeated-read stub; consulted per call, so a settings toggle applies to the next read. */
|
|
228
|
+
readCache?: () => boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
155
231
|
/**
|
|
156
|
-
* Keep Pi's execution
|
|
157
|
-
*
|
|
232
|
+
* Keep Pi's execution and schema, replace only the collapsed presentation and keep the per-call prompt
|
|
233
|
+
* rules short (detail lives in tool and parameter descriptions). Expanded cards delegate back to Pi's
|
|
234
|
+
* native renderers. `read` also caps whole reads of large files and can stub unchanged repeats.
|
|
158
235
|
*/
|
|
159
|
-
export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
236
|
+
export function installCompactBuiltinTools(pi: ExtensionAPI, { readCache }: CompactToolOptions = {}): void {
|
|
160
237
|
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
|
|
161
238
|
|
|
162
239
|
const cwd = process.cwd();
|
|
@@ -165,8 +242,58 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
165
242
|
const edit = createEditToolDefinition(cwd);
|
|
166
243
|
const write = createWriteToolDefinition(cwd);
|
|
167
244
|
|
|
168
|
-
|
|
245
|
+
// Without session events a mark could outlive a compaction, so the read cache requires them.
|
|
246
|
+
const memory = typeof (pi as Partial<ExtensionAPI>).on === "function" ? new ReadMemory() : undefined;
|
|
247
|
+
if (memory) {
|
|
248
|
+
pi.on("session_start", () => memory.clear(true));
|
|
249
|
+
pi.on("session_compact", () => memory.clear());
|
|
250
|
+
pi.on("session_tree", () => memory.clear());
|
|
251
|
+
}
|
|
252
|
+
const forget = (path: string, ctx: ExtensionContext) => {
|
|
253
|
+
const file = resolveToolPath(path, ctx.cwd);
|
|
254
|
+
if (file) memory?.forget(file);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const readParameters = Type.Object({
|
|
258
|
+
...read.parameters.properties,
|
|
259
|
+
force: Type.Optional(Type.Boolean({ description: "Re-read even if unchanged since an earlier read." }))
|
|
260
|
+
});
|
|
261
|
+
pi.registerTool<typeof readParameters, ReadToolDetails | ReadStubDetails | undefined>({
|
|
169
262
|
...read,
|
|
263
|
+
description: `${read.description} Without offset/limit, files over ${formatSize(AUTO_LIMIT_BYTES)} stop at ${AUTO_LIMIT_LINES} lines.`,
|
|
264
|
+
promptGuidelines: ["Use read, not cat/sed, to view files.", "Keep plans in jar_todo or the plan tool, not /tmp/*.md files you re-read."],
|
|
265
|
+
parameters: readParameters,
|
|
266
|
+
async execute(id, { force, ...request }, signal, onUpdate, ctx) {
|
|
267
|
+
const cache = readCache?.() === true ? memory : undefined;
|
|
268
|
+
// Taken before the stat: an invalidation from here on keeps this read from being remembered.
|
|
269
|
+
const started = cache?.begin();
|
|
270
|
+
const whole = request.offset === undefined && request.limit === undefined;
|
|
271
|
+
const target = cache || whole ? await inspectFile(request.path, ctx.cwd) : undefined;
|
|
272
|
+
const limited = whole && target !== undefined && target.size > AUTO_LIMIT_BYTES && !IMAGE_FILE.test(target.file);
|
|
273
|
+
const effective = limited ? { ...request, limit: AUTO_LIMIT_LINES } : request;
|
|
274
|
+
const range = readRangeKey(effective.offset, effective.limit);
|
|
275
|
+
const slot = cache && started && target && range !== undefined
|
|
276
|
+
? { cache, ...started, file: target.file, range, revision: target.revision } : undefined;
|
|
277
|
+
if (slot && !force) {
|
|
278
|
+
const mark = slot.cache.find(slot.file, slot.range, slot.revision);
|
|
279
|
+
if (mark) return {
|
|
280
|
+
content: [{ type: "text", text: `[unchanged since read #${mark.ordinal} (${mark.lines} line${mark.lines === 1 ? "" : "s"}); pass force: true to re-read]` }],
|
|
281
|
+
details: { unchangedSinceRead: mark.ordinal }
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const result = await read.execute(id, effective, signal, onUpdate, ctx);
|
|
285
|
+
let text = "";
|
|
286
|
+
for (const part of result.content) {
|
|
287
|
+
// Images ignore offset/limit and are never remembered.
|
|
288
|
+
if (part.type !== "text") return result;
|
|
289
|
+
text += part.text;
|
|
290
|
+
}
|
|
291
|
+
slot?.cache.remember(slot.file, slot.range, { revision: slot.revision, ordinal: slot.ordinal, lines: lineCount(text.replace(READ_NOTICE, "")) }, slot.since);
|
|
292
|
+
if (!limited) return result;
|
|
293
|
+
const note = `\n\n[${formatSize(target.size)} file: a read without offset/limit returns its first ${AUTO_LIMIT_LINES} lines. Use offset/limit to read more.]`;
|
|
294
|
+
const last = result.content.length - 1;
|
|
295
|
+
return { ...result, content: result.content.map((part, index) => index === last && part.type === "text" ? { ...part, text: part.text + note } : part) };
|
|
296
|
+
},
|
|
170
297
|
renderCall(args, theme, context) {
|
|
171
298
|
if (context.expanded && read.renderCall) return read.renderCall(args, theme, context);
|
|
172
299
|
const range = args?.offset || args?.limit
|
|
@@ -175,8 +302,9 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
175
302
|
return new Text(theme.fg("toolTitle", theme.bold("read ")) + theme.fg("accent", str(args?.path)) + range, 0, 0);
|
|
176
303
|
},
|
|
177
304
|
renderResult(result, options, theme, context) {
|
|
178
|
-
if (options.expanded && read.renderResult) return read.renderResult(result, options, theme, context);
|
|
305
|
+
if (options.expanded && read.renderResult) return read.renderResult(result as Parameters<NonNullable<typeof read.renderResult>>[0], options, theme, context);
|
|
179
306
|
if (options.isPartial) return new Text(theme.fg("warning", "reading…"), 0, 0);
|
|
307
|
+
if (isReadStub(result.details)) return new Text(theme.fg("muted", `unchanged since read #${result.details.unchangedSinceRead}${expandHint}`), 0, 0);
|
|
180
308
|
const summary = summarizeResult(result);
|
|
181
309
|
const count = `${summary.lines}${summary.truncated ? "+" : ""}`;
|
|
182
310
|
return new Text(theme.fg("muted", `${count} line${summary.lines === 1 && !summary.truncated ? "" : "s"}${expandHint}`), 0, 0);
|
|
@@ -185,6 +313,8 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
185
313
|
|
|
186
314
|
pi.registerTool({
|
|
187
315
|
...bash,
|
|
316
|
+
description: `${bash.description} PI_* env vars describe the current model and session.`,
|
|
317
|
+
promptGuidelines: [],
|
|
188
318
|
async execute(id, params, signal, onUpdate, ctx) {
|
|
189
319
|
return bashFor(ctx).execute(id, params, signal, onUpdate, ctx);
|
|
190
320
|
},
|
|
@@ -209,19 +339,29 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
209
339
|
|
|
210
340
|
pi.registerTool({
|
|
211
341
|
...edit,
|
|
342
|
+
promptSnippet: "Precise exact-text file edits",
|
|
343
|
+
promptGuidelines: ["Batch all changes to one file into one edit call (several edits[] entries); keep each oldText minimal but unique."],
|
|
344
|
+
execute(id, params, signal, onUpdate, ctx) {
|
|
345
|
+
return edit.execute(id, params, signal, onUpdate, ctx).finally(() => forget(params.path, ctx));
|
|
346
|
+
},
|
|
212
347
|
renderCall(args, theme, context) {
|
|
213
348
|
if (context.expanded && edit.renderCall) return edit.renderCall(args, theme, context);
|
|
214
349
|
const path = str(args?.path) || str((args as { file_path?: unknown } | undefined)?.file_path);
|
|
215
350
|
return compactCard(theme, context, new Text(theme.fg("toolTitle", theme.bold("edit ")) + theme.fg("accent", path), 0, 0));
|
|
216
351
|
},
|
|
217
352
|
renderResult(result, options, theme, context) {
|
|
218
|
-
if (options.expanded && edit.renderResult) return edit.renderResult(result, options, theme, context);
|
|
353
|
+
if (options.expanded && edit.renderResult) return edit.renderResult(result as Parameters<NonNullable<typeof edit.renderResult>>[0], options, theme, context);
|
|
219
354
|
return intoCard(context, editSummary(result, options.isPartial, theme));
|
|
220
355
|
}
|
|
221
356
|
});
|
|
222
357
|
|
|
223
358
|
pi.registerTool({
|
|
224
359
|
...write,
|
|
360
|
+
promptSnippet: "Create new files or complete rewrites; use edit for partial changes",
|
|
361
|
+
promptGuidelines: [],
|
|
362
|
+
execute(id, params, signal, onUpdate, ctx) {
|
|
363
|
+
return write.execute(id, params, signal, onUpdate, ctx).finally(() => forget(params.path, ctx));
|
|
364
|
+
},
|
|
225
365
|
renderCall(args, theme, context) {
|
|
226
366
|
if (context.expanded && write.renderCall) return write.renderCall(args, theme, context);
|
|
227
367
|
const content = str(args?.content);
|
|
@@ -234,7 +374,7 @@ export function installCompactBuiltinTools(pi: ExtensionAPI): void {
|
|
|
234
374
|
+ theme.fg("dim", ` · ${count.lines}${count.truncated ? "+" : ""} line${count.lines === 1 && !count.truncated ? "" : "s"}`), 0, 0);
|
|
235
375
|
},
|
|
236
376
|
renderResult(result, options, theme, context) {
|
|
237
|
-
if (options.expanded && write.renderResult) return write.renderResult(result, options, theme, context);
|
|
377
|
+
if (options.expanded && write.renderResult) return write.renderResult(result as Parameters<NonNullable<typeof write.renderResult>>[0], options, theme, context);
|
|
238
378
|
if (options.isPartial) return new Text(theme.fg("warning", "writing…"), 0, 0);
|
|
239
379
|
const summary = summarizeResult(result);
|
|
240
380
|
const text = summary.sample;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ContextBudget } from "./settings.ts";
|
|
3
|
+
import { cleanText } from "./status.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Context-budget guard. Pi compacts only near the window limit, so a long session re-sends a large,
|
|
7
|
+
* growing prompt on every call. Past `softTokens` the guard either suggests /compact or /new once per
|
|
8
|
+
* crossing, or compacts once at the next safe point:
|
|
9
|
+
* - the end of a turn in which a jar_todo item completed while work remains. Every tool result of that
|
|
10
|
+
* turn is in, so the run stops there (Pi's compact() aborts an active run; it must never cut a tool
|
|
11
|
+
* chain), compacts once settled, then resumes with a hidden continuation;
|
|
12
|
+
* - otherwise the run's own settlement, when nothing is in flight.
|
|
13
|
+
* Decisions here are pure; extensions/index.ts feeds Pi events in and makes the Pi calls.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Calls covered by the footer's average cost per call. */
|
|
17
|
+
export const RECENT_CALLS = 10;
|
|
18
|
+
/** Provider prompt caches (Anthropic's default TTL, OpenAI's inactivity window) lapse after about this long. */
|
|
19
|
+
export const CACHE_IDLE_MS = 5 * 60_000;
|
|
20
|
+
/** Below this, re-sending the context uncached is not worth a notice. */
|
|
21
|
+
export const CACHE_NOTICE_TOKENS = 100_000;
|
|
22
|
+
/** Hidden prompt that resumes a run stopped at a safe point once compaction finished. */
|
|
23
|
+
export const CONTEXT_CONTINUATION = "pi-jar.context-continuation";
|
|
24
|
+
|
|
25
|
+
/** One provider request: its prompt size, its cost and when it was sent. */
|
|
26
|
+
export interface ProviderCall { tokens: number; cost: number; at: number }
|
|
27
|
+
export interface BudgetSettle { compact: boolean; resume: boolean }
|
|
28
|
+
export interface BudgetCompaction { resume: boolean; tokens: number | undefined; softTokens: number; open: readonly string[] }
|
|
29
|
+
|
|
30
|
+
interface UsageLike { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown; cost?: { total?: unknown } }
|
|
31
|
+
const amount = (value: unknown): number => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
32
|
+
|
|
33
|
+
/** Prompt size of one call: fresh input plus cache reads and writes (its output is only re-sent by the next call). */
|
|
34
|
+
export function contextTokens(usage: UsageLike | undefined): number {
|
|
35
|
+
return amount(usage?.input) + amount(usage?.cacheRead) + amount(usage?.cacheWrite);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The provider call behind an assistant message; none when nothing reached the provider (early abort, no usage). */
|
|
39
|
+
export function providerCall(message: unknown, now: number): ProviderCall | undefined {
|
|
40
|
+
if (!message || typeof message !== "object") return undefined;
|
|
41
|
+
const { role, usage, timestamp } = message as { role?: unknown; usage?: UsageLike; timestamp?: unknown };
|
|
42
|
+
if (role !== "assistant") return undefined;
|
|
43
|
+
const tokens = contextTokens(usage);
|
|
44
|
+
if (!tokens) return undefined;
|
|
45
|
+
// The message timestamp is the request time, which is what a provider cache's lifetime counts from.
|
|
46
|
+
return { tokens, cost: amount(usage?.cost?.total), at: typeof timestamp === "number" && Number.isFinite(timestamp) && timestamp > 0 ? timestamp : now };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Seed for a started, navigated or resumed branch: the latest call costs (oldest first) and the newest call,
|
|
51
|
+
* unless a later compaction made its size stale. Walks back only as far as the window needs.
|
|
52
|
+
*/
|
|
53
|
+
export function branchCalls(branch: readonly unknown[], now: number): { costs: number[]; last?: ProviderCall } {
|
|
54
|
+
const costs: number[] = [];
|
|
55
|
+
let last: ProviderCall | undefined;
|
|
56
|
+
let compacted = false;
|
|
57
|
+
for (let index = branch.length - 1; index >= 0 && costs.length < RECENT_CALLS; index--) {
|
|
58
|
+
const entry = branch[index];
|
|
59
|
+
if (!entry || typeof entry !== "object") continue;
|
|
60
|
+
const { type, message } = entry as { type?: unknown; message?: unknown };
|
|
61
|
+
if (type === "compaction" && !costs.length) compacted = true;
|
|
62
|
+
if (type !== "message") continue;
|
|
63
|
+
const call = providerCall(message, now);
|
|
64
|
+
if (!call) continue;
|
|
65
|
+
if (!costs.length && !compacted) last = call;
|
|
66
|
+
costs.push(call.cost);
|
|
67
|
+
}
|
|
68
|
+
return { costs: costs.reverse(), ...(last ? { last } : {}) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `950`, `130k`, `1.2M`. */
|
|
72
|
+
export function formatTokens(tokens: number): string {
|
|
73
|
+
if (tokens < 1000) return String(Math.round(tokens));
|
|
74
|
+
if (tokens < 999_500) return `${Math.round(tokens / 1000)}k`;
|
|
75
|
+
return `${(tokens / 1_000_000).toFixed(1)}M`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function elapsed(ms: number): string {
|
|
79
|
+
const minutes = Math.floor(ms / 60_000);
|
|
80
|
+
return minutes < 120 ? `${minutes}m` : minutes < 2880 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1440)}d`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class ContextBudgetGuard {
|
|
84
|
+
private readonly budget: () => ContextBudget;
|
|
85
|
+
/** Costs of the last RECENT_CALLS calls, oldest first. */
|
|
86
|
+
private costs: number[] = [];
|
|
87
|
+
/** Prompt size of the newest call; unknown before the first call and after a compaction. */
|
|
88
|
+
private tokens: number | undefined;
|
|
89
|
+
/** When the provider cache was last surely warm: the newest call, or a Pi cache refresh since. */
|
|
90
|
+
private warmAt: number | undefined;
|
|
91
|
+
/** A crossing may still notify or compact; re-armed once a call fits the budget again. */
|
|
92
|
+
private armed = true;
|
|
93
|
+
/** Compact mode: this crossing's compaction waits for a safe point. */
|
|
94
|
+
private pending = false;
|
|
95
|
+
/** This guard stopped the run at a safe point, so the settled run resumes after compacting. */
|
|
96
|
+
private stopped = false;
|
|
97
|
+
/** A jar_todo call completed an item during the current turn. */
|
|
98
|
+
private completed = false;
|
|
99
|
+
/** The topic-switch hint was shown for the current finished task list. */
|
|
100
|
+
private topicShown = false;
|
|
101
|
+
|
|
102
|
+
constructor(budget: () => ContextBudget) { this.budget = budget; }
|
|
103
|
+
|
|
104
|
+
/** Footer chip: the newest call's prompt is past the budget. */
|
|
105
|
+
get over(): boolean {
|
|
106
|
+
const { action, softTokens } = this.budget();
|
|
107
|
+
return action !== "off" && this.tokens !== undefined && this.tokens > softTokens;
|
|
108
|
+
}
|
|
109
|
+
/** Average provider cost over the last RECENT_CALLS calls: at most ten additions, no session walk. */
|
|
110
|
+
get perCall(): number | undefined {
|
|
111
|
+
return this.costs.length ? this.costs.reduce((sum, cost) => sum + cost, 0) / this.costs.length : undefined;
|
|
112
|
+
}
|
|
113
|
+
get lastTokens(): number | undefined { return this.tokens; }
|
|
114
|
+
/** Settlement has work: a compaction waits, or a stopped run must resume. */
|
|
115
|
+
get busy(): boolean { return this.pending || this.stopped; }
|
|
116
|
+
|
|
117
|
+
/** A new or re-rooted branch: everything starts over from its seed. */
|
|
118
|
+
reset(seed: { costs?: readonly number[]; last?: ProviderCall } = {}): void {
|
|
119
|
+
this.costs = (seed.costs ?? []).slice(-RECENT_CALLS);
|
|
120
|
+
this.tokens = seed.last?.tokens;
|
|
121
|
+
this.warmAt = seed.last?.at;
|
|
122
|
+
this.armed = true;
|
|
123
|
+
this.pending = this.stopped = this.completed = this.topicShown = false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One provider call ended; returns the suggest-mode notice for a fresh crossing. */
|
|
127
|
+
call(call: ProviderCall | undefined): string | undefined {
|
|
128
|
+
if (!call) return undefined;
|
|
129
|
+
this.costs.push(call.cost);
|
|
130
|
+
if (this.costs.length > RECENT_CALLS) this.costs.shift();
|
|
131
|
+
this.tokens = call.tokens;
|
|
132
|
+
this.warmAt = Math.max(this.warmAt ?? call.at, call.at);
|
|
133
|
+
const { action, softTokens } = this.budget();
|
|
134
|
+
if (call.tokens <= softTokens) { this.armed = true; return undefined; }
|
|
135
|
+
if (!this.armed || action === "off") return undefined;
|
|
136
|
+
this.armed = false;
|
|
137
|
+
if (action === "compact") { this.pending = true; return undefined; }
|
|
138
|
+
return `Context ${formatTokens(call.tokens)} is past the ${formatTokens(softTokens)} budget — /compact, or /new for the next task`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A jar_todo call raised the completed count: a safe point at this turn's end, and a new list for the topic hint. */
|
|
142
|
+
todoCompleted(): void { this.completed = true; this.topicShown = false; }
|
|
143
|
+
|
|
144
|
+
/** Turn end: true when the run should stop here, to compact once settled and then resume. */
|
|
145
|
+
turnEnd(turn: { open: () => number; interrupted: boolean; blocked: boolean }): boolean {
|
|
146
|
+
const completed = this.completed;
|
|
147
|
+
this.completed = false;
|
|
148
|
+
if (this.stopped || !completed || !this.pending || this.budget().action !== "compact"
|
|
149
|
+
|| turn.interrupted || turn.blocked || turn.open() <= 0) return false;
|
|
150
|
+
this.stopped = true;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Run settled. Compacts when due and nothing else holds the session (another compaction, plan mode); a
|
|
156
|
+
* pending compaction otherwise waits for the next settlement. A stopped run resumes unless the session is
|
|
157
|
+
* held, which means the user took over (their own /compact, plan mode).
|
|
158
|
+
*/
|
|
159
|
+
settle(state: { idle: boolean; blocked: boolean }): BudgetSettle | undefined {
|
|
160
|
+
const resume = this.stopped;
|
|
161
|
+
this.stopped = false;
|
|
162
|
+
if (!state.idle || state.blocked) return undefined;
|
|
163
|
+
// A request made under another action (the setting changed since) is dropped here.
|
|
164
|
+
const compact = this.pending && this.budget().action === "compact";
|
|
165
|
+
this.pending = false;
|
|
166
|
+
return compact || resume ? { compact, resume } : undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Any compaction (this guard's, Pi's threshold, /compact): the size is unknown until the next call. */
|
|
170
|
+
compacted(): void { this.tokens = undefined; this.pending = false; }
|
|
171
|
+
|
|
172
|
+
/** Pi refreshed the prompt cache while idle (cache warming), which restarts its lifetime. */
|
|
173
|
+
warmed(at: number): void { if (this.warmAt !== undefined) this.warmAt = Math.max(this.warmAt, at); }
|
|
174
|
+
|
|
175
|
+
/** An interactive prompt to an idle agent: hint /new after a finished task list, and flag a cold cache. */
|
|
176
|
+
prompt(now: number, todos: { done: number; total: number }): string | undefined {
|
|
177
|
+
const { action, softTokens } = this.budget();
|
|
178
|
+
const tokens = this.tokens;
|
|
179
|
+
if (action === "off" || tokens === undefined) return undefined;
|
|
180
|
+
const idle = this.warmAt === undefined ? 0 : now - this.warmAt;
|
|
181
|
+
const cold = idle > CACHE_IDLE_MS && tokens > CACHE_NOTICE_TOKENS;
|
|
182
|
+
const topic = !this.topicShown && todos.total > 0 && todos.done === todos.total && tokens > softTokens / 2;
|
|
183
|
+
if (topic) this.topicShown = true;
|
|
184
|
+
const size = formatTokens(tokens);
|
|
185
|
+
if (topic && cold) return `All tasks are done and the ${size} context is likely uncached (last call ${elapsed(idle)} ago). For a new task, /new starts fresh; /compact carries a summary over instead.`;
|
|
186
|
+
if (topic) return `All tasks are done (context ${size}). For an unrelated task, /new starts fresh; /compact carries a summary over instead.`;
|
|
187
|
+
if (cold) return `Last model call was ${elapsed(idle)} ago: the provider cache has likely expired, so this prompt re-sends ${size} uncached. After a break, /compact first.`;
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** At most six titles of 80 characters: compaction instructions and the continuation stay small. */
|
|
193
|
+
const titles = (open: readonly string[]) => open.slice(0, 6).map((title) => cleanText(title, 80)).join("; ")
|
|
194
|
+
+ (open.length > 6 ? `; +${open.length - 6} more` : "");
|
|
195
|
+
|
|
196
|
+
/** Continue a run stopped at a safe point; the message is hidden, so the transcript shows only the work. */
|
|
197
|
+
export function resumeAfterBudget(pi: Pick<ExtensionAPI, "sendMessage">, ctx: Pick<ExtensionContext, "ui">, open: readonly string[]): void {
|
|
198
|
+
const content = "[pi-jar] Context was compacted at a safe point after a task completed; the request is not finished. "
|
|
199
|
+
+ `Continue the open jar_todo tasks${open.length ? ": " + titles(open) : ""}. Use jar_todo to check or update them.`;
|
|
200
|
+
try { pi.sendMessage({ customType: CONTEXT_CONTINUATION, content, display: false }, { triggerTurn: true }); }
|
|
201
|
+
catch (error) { ctx.ui.notify(`Could not resume after compaction: ${(error as Error).message}. Send a message to continue.`, "error"); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Compact once, then resume a run this guard stopped unless the user cancelled. Await it from agent_settled:
|
|
206
|
+
* Pi defers the resumed turn until settlement handlers return, so print/json runs wait for it and no other
|
|
207
|
+
* wake-up starts a run mid-compaction. `current` turns false when the session was replaced meanwhile.
|
|
208
|
+
*/
|
|
209
|
+
export async function compactForBudget(pi: Pick<ExtensionAPI, "sendMessage">, ctx: Pick<ExtensionContext, "compact" | "ui">,
|
|
210
|
+
job: BudgetCompaction, current: () => boolean): Promise<void> {
|
|
211
|
+
const size = job.tokens === undefined ? "" : " " + formatTokens(job.tokens);
|
|
212
|
+
ctx.ui.notify(`Context${size} is past the ${formatTokens(job.softTokens)} budget: compacting at a safe point…`, "info");
|
|
213
|
+
const keep = "Preserve completed work, verification results, key decisions, file paths and open questions.";
|
|
214
|
+
// Executor form: the project's TS lib target predates Promise.withResolvers.
|
|
215
|
+
const error = await new Promise<Error | undefined>((resolve) => {
|
|
216
|
+
try {
|
|
217
|
+
ctx.compact({ customInstructions: job.open.length ? `${keep} Work continues on these open jar_todo tasks: ${titles(job.open)}.` : keep,
|
|
218
|
+
onComplete: () => resolve(undefined), onError: resolve });
|
|
219
|
+
} catch (thrown) { resolve(thrown instanceof Error ? thrown : new Error(String(thrown))); }
|
|
220
|
+
});
|
|
221
|
+
if (!current()) return;
|
|
222
|
+
// Only the user interrupts a compaction (Esc); Pi's other refusals and failures leave the context usable.
|
|
223
|
+
const userCancelled = error !== undefined && (error.name === "AbortError" || /\b(cancel|abort)/i.test(error.message));
|
|
224
|
+
if (error && !userCancelled) ctx.ui.notify(`Context compaction failed: ${error.message}${job.resume ? " · continuing without it" : ""}`, "warning");
|
|
225
|
+
if (job.resume && !userCancelled) resumeAfterBudget(pi, ctx, job.open);
|
|
226
|
+
}
|