pi-jar 0.2.9 → 0.3.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/README.md +20 -21
- package/docs/DIFF-REVIEW.md +5 -4
- package/docs/EFFICIENCY_PLAN.md +8 -9
- package/docs/FLAME.md +19 -16
- package/docs/INTEGRATIONS.md +1 -1
- package/docs/SHOWCASE.md +43 -1
- package/docs/SUBAGENT-TOOLS.md +6 -0
- package/extensions/index.ts +6 -60
- package/package.json +1 -1
- package/src/cache-breaks.ts +12 -1
- package/src/compact-tools.ts +23 -1
- package/src/composer.ts +51 -38
- package/src/delegate.ts +21 -7
- package/src/diff-inline.ts +14 -6
- package/src/diff-view.ts +32 -9
- package/src/footer.ts +0 -4
- package/src/mascot.ts +134 -22
- package/src/profiles.ts +5 -6
- package/src/settings-ui.ts +13 -30
- package/src/settings.ts +1 -21
- package/src/subagent-tools.ts +28 -4
- package/src/task-tool.ts +2 -1
- package/src/context-budget.ts +0 -226
- package/src/context-diet.ts +0 -235
package/src/context-budget.ts
DELETED
|
@@ -1,226 +0,0 @@
|
|
|
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
|
-
}
|
package/src/context-diet.ts
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import { homedir } from "node:os";
|
|
2
|
-
import { join, resolve } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
|
|
6
|
-
/** Character counts are diagnostics, NOT token estimates: signatures can be opaque ciphertext. */
|
|
7
|
-
export interface ThinkingDietStats {
|
|
8
|
-
removedThinkingParts: number;
|
|
9
|
-
removedVisibleChars: number;
|
|
10
|
-
removedSignatureChars: number;
|
|
11
|
-
}
|
|
12
|
-
/** Superseded `read` results replaced by a stub, and the characters of file text they carried. */
|
|
13
|
-
export interface ReadDietStats {
|
|
14
|
-
stubbedReads: number;
|
|
15
|
-
stubbedReadChars: number;
|
|
16
|
-
}
|
|
17
|
-
export type ContextDietStats = ThinkingDietStats & ReadDietStats;
|
|
18
|
-
|
|
19
|
-
export interface DietMessage {
|
|
20
|
-
role: string;
|
|
21
|
-
content?: unknown;
|
|
22
|
-
toolCallId?: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
const emptyStats = (): ContextDietStats => ({ removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0, stubbedReads: 0, stubbedReadChars: 0 });
|
|
26
|
-
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
27
|
-
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* `details` of a read-cache stub (src/compact-tools.ts). Such a result points at an earlier read
|
|
31
|
-
* instead of carrying file text, so it never supersedes that read.
|
|
32
|
-
*/
|
|
33
|
-
export interface ReadStubDetails { unchangedSinceRead: number }
|
|
34
|
-
export const isReadStub = (details: unknown): details is ReadStubDetails => typeof record(details)?.unchangedSinceRead === "number";
|
|
35
|
-
|
|
36
|
-
const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
37
|
-
/**
|
|
38
|
-
* Pi's read/edit/write path resolution (odd spaces, `@path`, `~`, file URLs) without probing the
|
|
39
|
-
* filesystem. Undefined for a file URL Pi itself rejects; callers then just don't match the path.
|
|
40
|
-
*/
|
|
41
|
-
export function resolveToolPath(path: string, cwd: string): string | undefined {
|
|
42
|
-
let value = path.replace(UNICODE_SPACES, " ");
|
|
43
|
-
if (value.startsWith("@")) value = value.slice(1);
|
|
44
|
-
if (value === "~" || value.startsWith("~/")) value = join(homedir(), value.slice(2));
|
|
45
|
-
else if (value.startsWith("file://")) {
|
|
46
|
-
try { value = fileURLToPath(value); } catch { return undefined; }
|
|
47
|
-
}
|
|
48
|
-
return resolve(cwd, value);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The line window a read returns; Pi treats a missing offset and offsets ≤ 1 alike. Undefined when the
|
|
53
|
-
* arguments are not plain numbers, so an unvalidated request never matches another.
|
|
54
|
-
*/
|
|
55
|
-
export function readRangeKey(offset: unknown, limit: unknown): string | undefined {
|
|
56
|
-
const start = offset === undefined ? 1 : typeof offset === "number" ? Math.max(offset, 1) : undefined;
|
|
57
|
-
if (start === undefined || (limit !== undefined && typeof limit !== "number")) return undefined;
|
|
58
|
-
return `${start}:${limit ?? ""}`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Completed prior user turns span [firstUser, boundary); every call there has its result there. */
|
|
62
|
-
interface History { firstUser: number; boundary: number; results: Map<string, number> }
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* The completed prior-user-turn span a projection may rewrite; the complete current reasoning/tool
|
|
66
|
-
* chain is never eligible. Ambiguous, unresolved or boundary-crossing historical tool calls make it
|
|
67
|
-
* undefined, so every projection is a no-op: reducing context is less important than preserving
|
|
68
|
-
* provider sequencing constraints.
|
|
69
|
-
*/
|
|
70
|
-
function completedHistory(messages: readonly DietMessage[]): History | undefined {
|
|
71
|
-
let boundary = -1;
|
|
72
|
-
for (let index = messages.length - 1; index >= 0; index--) {
|
|
73
|
-
if (messages[index]!.role === "user") { boundary = index; break; }
|
|
74
|
-
}
|
|
75
|
-
const firstUser = messages.findIndex(message => message.role === "user");
|
|
76
|
-
if (boundary <= 0 || firstUser === boundary) return undefined;
|
|
77
|
-
|
|
78
|
-
const calls = new Map<string, number>();
|
|
79
|
-
const results = new Map<string, number>();
|
|
80
|
-
for (let index = 0; index < messages.length; index++) {
|
|
81
|
-
const message = messages[index]!;
|
|
82
|
-
if (message.role === "toolResult") {
|
|
83
|
-
const id = message.toolCallId;
|
|
84
|
-
if (typeof id !== "string" || !id || results.has(id)) return undefined;
|
|
85
|
-
results.set(id, index);
|
|
86
|
-
}
|
|
87
|
-
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
88
|
-
for (const value of message.content) {
|
|
89
|
-
const part = record(value);
|
|
90
|
-
if (part?.type !== "toolCall") continue;
|
|
91
|
-
const id = part.id;
|
|
92
|
-
if (typeof id !== "string" || !id || calls.has(id)) return undefined;
|
|
93
|
-
calls.set(id, index);
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
for (const [id, index] of calls) {
|
|
97
|
-
if (index >= boundary) continue;
|
|
98
|
-
const result = results.get(id);
|
|
99
|
-
if (result === undefined || result <= index || result >= boundary) return undefined;
|
|
100
|
-
}
|
|
101
|
-
// A detached historical result means the original reasoning sequence is not fully known.
|
|
102
|
-
for (const [id, index] of results) {
|
|
103
|
-
if (index < boundary && !calls.has(id)) return undefined;
|
|
104
|
-
}
|
|
105
|
-
return { firstUser, boundary, results };
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Opt-in provider-context projection; never edits the session or mutates its messages.
|
|
110
|
-
* Only prior user turns are eligible (see completedHistory).
|
|
111
|
-
*/
|
|
112
|
-
export function trimCompletedThinking<T extends DietMessage>(messages: T[]): { messages: T[]; stats: ThinkingDietStats } {
|
|
113
|
-
const stats: ThinkingDietStats = { removedThinkingParts: 0, removedVisibleChars: 0, removedSignatureChars: 0 };
|
|
114
|
-
const history = completedHistory(messages);
|
|
115
|
-
if (!history) return { messages, stats };
|
|
116
|
-
const { firstUser, boundary } = history;
|
|
117
|
-
|
|
118
|
-
let changed = false;
|
|
119
|
-
const projected = messages.map((message, index) => {
|
|
120
|
-
if (index < firstUser || index >= boundary || message.role !== "assistant" || !Array.isArray(message.content)) return message;
|
|
121
|
-
const kept = message.content.filter(value => record(value)?.type !== "thinking");
|
|
122
|
-
// Retain thinking-only rows unchanged rather than manufacturing empty assistant messages.
|
|
123
|
-
if (!kept.length || kept.length === message.content.length) return message;
|
|
124
|
-
for (const value of message.content) {
|
|
125
|
-
const part = record(value);
|
|
126
|
-
if (part?.type !== "thinking") continue;
|
|
127
|
-
stats.removedThinkingParts++;
|
|
128
|
-
if (typeof part.thinking === "string") stats.removedVisibleChars += part.thinking.length;
|
|
129
|
-
if (typeof part.thinkingSignature === "string") stats.removedSignatureChars += part.thinkingSignature.length;
|
|
130
|
-
}
|
|
131
|
-
changed = true;
|
|
132
|
-
return { ...message, content: kept } as T;
|
|
133
|
-
});
|
|
134
|
-
return { messages: changed ? projected : messages, stats };
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/** Total text of an all-text result; undefined when it holds images or unknown parts. */
|
|
138
|
-
function textLength(content: unknown): number | undefined {
|
|
139
|
-
if (!Array.isArray(content) || !content.length) return undefined;
|
|
140
|
-
let chars = 0;
|
|
141
|
-
for (const value of content) {
|
|
142
|
-
const part = record(value);
|
|
143
|
-
if (part?.type !== "text" || typeof part.text !== "string") return undefined;
|
|
144
|
-
chars += part.text.length;
|
|
145
|
-
}
|
|
146
|
-
return chars;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
interface OpenRead { result: number; path: string; chars: number }
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Opt-in projection, under the same eligibility as trimCompletedThinking: a `read` result that a later
|
|
153
|
-
* read of the same file and line window, or a successful edit/write of the file, superseded becomes a
|
|
154
|
-
* one-line stub; the toolResult keeps its id, name and position. Only operations inside the read's own
|
|
155
|
-
* completed user turn count. A result's projection is therefore fixed when its turn completes and
|
|
156
|
-
* changes together with the thinking trim, never later: re-projecting an older message after newer
|
|
157
|
-
* ones arrive would re-bill the whole prefix after it (src/workflow-context.ts). Operations issued in
|
|
158
|
-
* the read's own assistant message may have run in parallel with it, so they never supersede it.
|
|
159
|
-
* Read-cache stubs, errors, images and results answered outside their turn are left alone.
|
|
160
|
-
*/
|
|
161
|
-
export function stubSupersededReads<T extends DietMessage>(messages: T[], cwd: string): { messages: T[]; stats: ReadDietStats } {
|
|
162
|
-
const stats: ReadDietStats = { stubbedReads: 0, stubbedReadChars: 0 };
|
|
163
|
-
const history = completedHistory(messages);
|
|
164
|
-
if (!history) return { messages, stats };
|
|
165
|
-
const { firstUser, boundary, results } = history;
|
|
166
|
-
|
|
167
|
-
const stubs = new Map<number, string>();
|
|
168
|
-
const supersede = (read: OpenRead, by: string) => {
|
|
169
|
-
const stub = `[superseded by a later ${by} of ${read.path}]`;
|
|
170
|
-
if (stubs.has(read.result) || stub.length >= read.chars) return;
|
|
171
|
-
stubs.set(read.result, stub);
|
|
172
|
-
stats.stubbedReads++;
|
|
173
|
-
stats.stubbedReadChars += read.chars;
|
|
174
|
-
};
|
|
175
|
-
for (let start = firstUser; start < boundary;) {
|
|
176
|
-
// The boundary is a user message, so every completed turn ends at one.
|
|
177
|
-
let end = start + 1;
|
|
178
|
-
while (messages[end]!.role !== "user") end++;
|
|
179
|
-
/** File → line window → newest read of this turn not yet superseded. */
|
|
180
|
-
const open = new Map<string, Map<string, OpenRead>>();
|
|
181
|
-
for (let index = start + 1; index < end; index++) {
|
|
182
|
-
const message = messages[index]!;
|
|
183
|
-
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
184
|
-
const added: Array<{ file: string; range: string; read: OpenRead }> = [];
|
|
185
|
-
for (const value of message.content) {
|
|
186
|
-
const part = record(value);
|
|
187
|
-
const args = record(part?.arguments);
|
|
188
|
-
const name = part?.name;
|
|
189
|
-
if (part?.type !== "toolCall" || (name !== "read" && name !== "edit" && name !== "write") || typeof args?.path !== "string") continue;
|
|
190
|
-
const result = results.get(part.id as string)!;
|
|
191
|
-
const outcome = record(messages[result]);
|
|
192
|
-
const file = resolveToolPath(args.path, cwd);
|
|
193
|
-
if (result >= end || !file || outcome?.isError === true) continue;
|
|
194
|
-
if (name !== "read") {
|
|
195
|
-
for (const read of open.get(file)?.values() ?? []) supersede(read, name);
|
|
196
|
-
open.delete(file);
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
const range = readRangeKey(args.offset, args.limit);
|
|
200
|
-
const chars = range === undefined || isReadStub(outcome?.details) ? undefined : textLength(outcome?.content);
|
|
201
|
-
if (range === undefined || chars === undefined) continue;
|
|
202
|
-
const prior = open.get(file)?.get(range);
|
|
203
|
-
if (prior) supersede(prior, "read");
|
|
204
|
-
added.push({ file, range, read: { result, path: args.path, chars } });
|
|
205
|
-
}
|
|
206
|
-
for (const { file, range, read } of added) {
|
|
207
|
-
let ranges = open.get(file);
|
|
208
|
-
if (!ranges) open.set(file, ranges = new Map());
|
|
209
|
-
ranges.set(range, read);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
start = end;
|
|
213
|
-
}
|
|
214
|
-
if (!stubs.size) return { messages, stats };
|
|
215
|
-
const projected = messages.map((message, index) => {
|
|
216
|
-
const stub = stubs.get(index);
|
|
217
|
-
return stub === undefined ? message : { ...message, content: [{ type: "text", text: stub }] } as T;
|
|
218
|
-
});
|
|
219
|
-
return { messages: projected, stats };
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/** Register a runtime-toggleable diet; caller owns its persisted, opt-in setting. */
|
|
223
|
-
export function registerContextDiet(pi: ExtensionAPI, enabled: () => boolean): { stats: () => ContextDietStats } {
|
|
224
|
-
let latest = emptyStats();
|
|
225
|
-
pi.on("context", (event, ctx) => {
|
|
226
|
-
latest = emptyStats();
|
|
227
|
-
if (!enabled()) return;
|
|
228
|
-
const thinking = trimCompletedThinking(event.messages);
|
|
229
|
-
const reads = stubSupersededReads(thinking.messages, ctx.cwd);
|
|
230
|
-
latest = { ...thinking.stats, ...reads.stats };
|
|
231
|
-
if (reads.messages !== event.messages) return { messages: reads.messages };
|
|
232
|
-
});
|
|
233
|
-
pi.on("session_start", () => { latest = emptyStats(); });
|
|
234
|
-
return { stats: () => ({ ...latest }) };
|
|
235
|
-
}
|