pi-context-guard-jev 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -409
- package/docs/CACHE.md +5 -1
- package/docs/DESIGN.md +60 -24
- package/docs/REFERENCE.md +164 -0
- package/docs/RESULTS.md +146 -0
- package/docs/TESTING.md +29 -10
- package/package.json +2 -2
- package/src/checkpoint.ts +39 -16
- package/src/config.ts +8 -1
- package/src/images.ts +89 -0
- package/src/index.ts +87 -34
- package/src/items.ts +13 -14
- package/src/process.ts +37 -0
- package/src/recall.ts +44 -11
- package/src/render.ts +2 -0
- package/src/run.ts +71 -11
- package/src/size.ts +114 -0
- package/src/stats.ts +50 -18
- package/src/types.ts +2 -0
package/src/checkpoint.ts
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
* entries), never kept in memory: a boundary's drafts can be replaced by a later handler or rejected
|
|
11
11
|
* as a whole, and /tree or compaction change the branch.
|
|
12
12
|
*/
|
|
13
|
-
import { toolLabel } from "./render.ts";
|
|
14
|
-
import { type Candidate, type CollectOptions, candidateOf, collectHistory, findRunStart, indexSpan,
|
|
13
|
+
import { RECALL_TOOL, toolLabel } from "./render.ts";
|
|
14
|
+
import { type Candidate, type CollectOptions, candidateOf, collectHistory, findRunStart, isBranchSteering, indexSpan, lastUserIndex, type RunHistory, runQuestion, type ToolCallInfo } from "./run.ts";
|
|
15
|
+
import { contentChars } from "./size.ts";
|
|
15
16
|
import type { ProjectedEntryLike } from "./types.ts";
|
|
16
17
|
|
|
17
18
|
export interface CheckpointOptions extends CollectOptions {
|
|
@@ -116,7 +117,8 @@ export function collectCheckpoint(entries: readonly ProjectedEntryLike[], option
|
|
|
116
117
|
for (const entry of span) {
|
|
117
118
|
const candidate = candidateOf(entry, toolCalls, options);
|
|
118
119
|
if (!candidate || candidate.turn === undefined || judged.has(candidate.entryId)) continue;
|
|
119
|
-
|
|
120
|
+
// A recall output was asked for in this run: judging it again mid-run made models recall the recall.
|
|
121
|
+
if (candidate.turn > youngest || candidate.toolName === RECALL_TOOL) continue;
|
|
120
122
|
const turn = candidate.turn;
|
|
121
123
|
const superseded = supersededBy({ toolName: candidate.toolName, args: candidate.args, turn }, calls);
|
|
122
124
|
candidates.push({ ...candidate, turn, laterCalls: laterCallsFor(turn, calls), ...(superseded ? { superseded } : {}) });
|
|
@@ -124,7 +126,7 @@ export function collectCheckpoint(entries: readonly ProjectedEntryLike[], option
|
|
|
124
126
|
|
|
125
127
|
const latest = assistantTexts.at(-1)?.text ?? "";
|
|
126
128
|
return {
|
|
127
|
-
question:
|
|
129
|
+
question: runQuestion(entries, start),
|
|
128
130
|
history: collectHistory(entries.slice(0, start), options.historyExchanges),
|
|
129
131
|
latest,
|
|
130
132
|
notes: assistantTexts
|
|
@@ -156,14 +158,16 @@ export function paysOff(pendingChars: number, turnsSoFar: number, rewriteChars:
|
|
|
156
158
|
return pendingChars * Math.max(1, turnsSoFar) >= factor * rewriteChars;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Size of the projected context in text characters (an estimate, see size.ts: images count by their
|
|
163
|
+
* pixels, not their base64 length).
|
|
164
|
+
*/
|
|
160
165
|
export function contextChars(entries: readonly ProjectedEntryLike[]): number {
|
|
161
166
|
let n = 0;
|
|
162
167
|
for (const entry of entries) {
|
|
163
168
|
for (const message of entry.messages) {
|
|
164
169
|
if (typeof message.summary === "string") n += message.summary.length;
|
|
165
|
-
|
|
166
|
-
else for (const block of message.content ?? []) n += typeof block.text === "string" ? block.text.length : JSON.stringify(block).length;
|
|
170
|
+
n += contentChars(message.content);
|
|
167
171
|
}
|
|
168
172
|
}
|
|
169
173
|
return n;
|
|
@@ -187,7 +191,7 @@ export type BranchEntry = {
|
|
|
187
191
|
customType?: string;
|
|
188
192
|
data?: unknown;
|
|
189
193
|
targetId?: string;
|
|
190
|
-
message?: { role?: string; stopReason?: string; toolCallId?: string };
|
|
194
|
+
message?: { role?: string; stopReason?: string; toolCallId?: string; content?: unknown };
|
|
191
195
|
};
|
|
192
196
|
|
|
193
197
|
const isAnswered = (entry: BranchEntry): boolean =>
|
|
@@ -337,7 +341,8 @@ export function refreshOnReadThrough(
|
|
|
337
341
|
baseline: number,
|
|
338
342
|
): string[] {
|
|
339
343
|
if (!(cacheRead > 0) || !(baseline > 0) || cacheRead <= baseline) return [];
|
|
340
|
-
|
|
344
|
+
// The baseline is measured from the last user message (prompt or steering message, see runBaseline).
|
|
345
|
+
const start = lastUserIndex(projection);
|
|
341
346
|
if (start < 0) return [];
|
|
342
347
|
const position = positionsOf(branch);
|
|
343
348
|
const refreshed: string[] = [];
|
|
@@ -391,10 +396,12 @@ export function anthropicRewriteChars(
|
|
|
391
396
|
const read = Number.isFinite(first) ? trustedReadPoint(branch, log, model, now, first) : undefined;
|
|
392
397
|
const readIndex = read ? projection.findIndex((entry) => entry.sourceEntry.id === read.id) : -1;
|
|
393
398
|
if (readIndex >= 0) return contextChars(projection.slice(readIndex + 1));
|
|
394
|
-
// The question pin
|
|
395
|
-
|
|
399
|
+
// The question pin (on the last user message with text: after steering, the steering message) can be
|
|
400
|
+
// read only if every edit comes after it (an omitted old exchange, or an output from before a steering
|
|
401
|
+
// message, comes before).
|
|
402
|
+
const pin = lastUserIndex(projection);
|
|
396
403
|
const earliest = projection.findIndex((entry) => candidateIds.has(entry.sourceEntry.id));
|
|
397
|
-
const from = earliest >= 0 && earliest <=
|
|
404
|
+
const from = earliest >= 0 && earliest <= pin ? 0 : pin + 1;
|
|
398
405
|
return contextChars(projection.slice(Math.max(0, from)));
|
|
399
406
|
}
|
|
400
407
|
|
|
@@ -416,12 +423,28 @@ export function memoFromBranch(branch: readonly BranchEntry[], customType: strin
|
|
|
416
423
|
}
|
|
417
424
|
|
|
418
425
|
/**
|
|
419
|
-
*
|
|
420
|
-
*
|
|
426
|
+
* Index of the branch entry with the user message that started the current run (steering messages
|
|
427
|
+
* skipped, as in run.ts → findRunStart), or -1.
|
|
421
428
|
*/
|
|
422
|
-
export function
|
|
429
|
+
export function branchRunStart(branch: readonly BranchEntry[]): number {
|
|
430
|
+
const isUser = (e: BranchEntry) => e.type === "message" && e.message?.role === "user";
|
|
423
431
|
let start = branch.length - 1;
|
|
424
|
-
while (start >= 0 && !(branch[start]
|
|
432
|
+
while (start >= 0 && !isUser(branch[start])) start--;
|
|
433
|
+
while (start > 0 && isBranchSteering(branch, start)) {
|
|
434
|
+
let p = start - 1;
|
|
435
|
+
while (p >= 0 && !isUser(branch[p])) p--;
|
|
436
|
+
if (p < 0) break;
|
|
437
|
+
start = p;
|
|
438
|
+
}
|
|
439
|
+
return start;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Old exchanges judged during the current run (records of any phase after the user message that started
|
|
444
|
+
* the run on the branch). An exchange is judged again in a later run, against that run's work.
|
|
445
|
+
*/
|
|
446
|
+
export function exchangeMemo(branch: readonly BranchEntry[], customType: string): Set<string> {
|
|
447
|
+
const start = branchRunStart(branch);
|
|
425
448
|
const judged = new Set<string>();
|
|
426
449
|
for (const entry of branch.slice(start + 1)) {
|
|
427
450
|
if (entry.type !== "custom" || entry.customType !== customType) continue;
|
package/src/config.ts
CHANGED
|
@@ -80,6 +80,11 @@ export interface GuardConfig {
|
|
|
80
80
|
exchangeBreakEven: boolean;
|
|
81
81
|
/** A pass omits exchanges only if they save at least this many characters together (when that costs a cache rewrite). */
|
|
82
82
|
exchangeMinSavingChars: number;
|
|
83
|
+
/**
|
|
84
|
+
* Remove images from tool results once they are this many turns old (assistant messages after the
|
|
85
|
+
* result); `recall` shows them again. Images in user messages are always kept. 0 turns this off.
|
|
86
|
+
*/
|
|
87
|
+
imageKeepTurns: number;
|
|
83
88
|
}
|
|
84
89
|
|
|
85
90
|
export const DEFAULT_CONFIG: GuardConfig = {
|
|
@@ -114,6 +119,7 @@ export const DEFAULT_CONFIG: GuardConfig = {
|
|
|
114
119
|
exchangeOmitThreshold: 0.2,
|
|
115
120
|
exchangeBreakEven: true,
|
|
116
121
|
exchangeMinSavingChars: 8_000,
|
|
122
|
+
imageKeepTurns: 3,
|
|
117
123
|
};
|
|
118
124
|
|
|
119
125
|
const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
|
|
@@ -138,9 +144,10 @@ const NUMBER_RANGES: Partial<Record<keyof GuardConfig, [number, number]>> = {
|
|
|
138
144
|
keepRecentExchanges: [0, 100],
|
|
139
145
|
exchangeOmitThreshold: [0, 1],
|
|
140
146
|
exchangeMinSavingChars: [0, 10_000_000],
|
|
147
|
+
imageKeepTurns: [0, 1_000],
|
|
141
148
|
};
|
|
142
149
|
|
|
143
|
-
const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars"]);
|
|
150
|
+
const INTEGER_KEYS = new Set<keyof GuardConfig>(["minResultChars", "minRunChars", "timeoutMs", "concurrency", "maxSegmentChars", "maxChunksPerSegment", "historyExchanges", "midRunMinAgeTurns", "midRunBatchChars", "midRunBatchCharsOpenAI", "smallResultMinChars", "keepRecentExchanges", "exchangeMinSavingChars", "imageKeepTurns"]);
|
|
144
151
|
|
|
145
152
|
/** Merge untrusted JSON over the defaults, keeping only well-typed, in-range values. */
|
|
146
153
|
export function normalizeConfig(raw: unknown): GuardConfig {
|
package/src/images.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Old images (docs/DESIGN.md → Images). A model looks at a screenshot once: it describes or acts on it in
|
|
3
|
+
* its next message and goes on. Every later request still sends the image (about w×h/750 tokens each), so
|
|
4
|
+
* long runs with screenshots pile up context. Images in tool results are removed once they are
|
|
5
|
+
* `imageKeepTurns` turns old (assistant messages after the result). No Jev request is needed: Jev reads
|
|
6
|
+
* text only, and age is what decides. The result's text stays; a stub in front says what was removed, and
|
|
7
|
+
* `recall` returns the images. Images in user messages are always kept.
|
|
8
|
+
*/
|
|
9
|
+
import { MARKER, formatChars, toolLabel } from "./render.ts";
|
|
10
|
+
import { indexSpan } from "./run.ts";
|
|
11
|
+
import { contentChars, imageInfo } from "./size.ts";
|
|
12
|
+
import { type Block, type MessageLike, type ProjectedEntryLike, textOf } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
export interface ImageItem {
|
|
15
|
+
/** Session entry id of the tool result. */
|
|
16
|
+
entryId: string;
|
|
17
|
+
toolName: string;
|
|
18
|
+
args?: Record<string, unknown>;
|
|
19
|
+
/** Position in the projection. */
|
|
20
|
+
index: number;
|
|
21
|
+
/** Assistant messages after the result. */
|
|
22
|
+
age: number;
|
|
23
|
+
images: number;
|
|
24
|
+
/** Estimated input tokens of the removed images. */
|
|
25
|
+
imageTokens: number;
|
|
26
|
+
/** Model-visible size before and after, in text characters (size.ts). */
|
|
27
|
+
beforeChars: number;
|
|
28
|
+
afterChars: number;
|
|
29
|
+
/** The new content: the stub, then the result's text blocks. */
|
|
30
|
+
replacement: { type: "text"; text: string }[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const imageBlocks = (message: MessageLike | undefined): Block[] => (Array.isArray(message?.content) ? message.content.filter((b) => b.type === "image") : []);
|
|
34
|
+
|
|
35
|
+
/** True for a tool result whose images were removed by us (the raw entry has images, the model sees none). */
|
|
36
|
+
export const imagesRemoved = (projected: MessageLike | undefined, raw: MessageLike | undefined): boolean =>
|
|
37
|
+
projected?.role === "toolResult" && imageBlocks(raw).length > 0 && imageBlocks(projected).length === 0 && textOf(projected).startsWith(MARKER);
|
|
38
|
+
|
|
39
|
+
export function imageStub(item: Pick<ImageItem, "entryId" | "images" | "imageTokens" | "age">, sizes: string[]): string {
|
|
40
|
+
const what = item.images === 1 ? "1 image" : `${item.images} images`;
|
|
41
|
+
const dims = sizes.length > 0 ? `${sizes.slice(0, 3).join(", ")}${sizes.length > 3 ? ", …" : ""}; ` : "";
|
|
42
|
+
const them = item.images === 1 ? "it" : "them";
|
|
43
|
+
return `${MARKER} Removed ${what} from this output (${dims}~${formatChars(item.imageTokens)} tokens), ${item.age} turns old. recall({"entryId":"${item.entryId}"}) shows ${them} again.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Tool results in `entries` (the whole projection, earlier runs included) with images that are at least
|
|
48
|
+
* `keepTurns` turns old. Skipped: results another extension changed (recall returns the raw entry, so
|
|
49
|
+
* their edit could not be restored) and results without images in the model's view.
|
|
50
|
+
*/
|
|
51
|
+
export function collectImages(entries: readonly ProjectedEntryLike[], keepTurns: number): ImageItem[] {
|
|
52
|
+
if (keepTurns <= 0) return [];
|
|
53
|
+
const { toolCalls } = indexSpan(entries);
|
|
54
|
+
// Assistant messages after each position.
|
|
55
|
+
const after = new Array<number>(entries.length + 1).fill(0);
|
|
56
|
+
for (let i = entries.length - 1; i >= 0; i--) after[i] = after[i + 1] + entries[i].messages.filter((m) => m.role === "assistant").length;
|
|
57
|
+
const items: ImageItem[] = [];
|
|
58
|
+
entries.forEach((entry, index) => {
|
|
59
|
+
const message = entry.messages[0];
|
|
60
|
+
if (entry.messages.length !== 1 || message?.role !== "toolResult" || !Array.isArray(message.content)) return;
|
|
61
|
+
const images = imageBlocks(message);
|
|
62
|
+
if (images.length === 0) return;
|
|
63
|
+
const age = after[index + 1];
|
|
64
|
+
if (age < keepTurns) return;
|
|
65
|
+
const raw = entry.sourceEntry.message;
|
|
66
|
+
if (raw && raw !== message && (textOf(raw) !== textOf(message) || imageBlocks(raw).length !== images.length)) return;
|
|
67
|
+
const call = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
68
|
+
const infos = images.map(imageInfo);
|
|
69
|
+
const item = {
|
|
70
|
+
entryId: entry.sourceEntry.id,
|
|
71
|
+
toolName: message.toolName ?? call?.name ?? "tool",
|
|
72
|
+
...(call?.args ? { args: call.args } : {}),
|
|
73
|
+
index,
|
|
74
|
+
age,
|
|
75
|
+
images: images.length,
|
|
76
|
+
imageTokens: infos.reduce((n, i) => n + i.tokens, 0),
|
|
77
|
+
};
|
|
78
|
+
const sizes = infos.flatMap((i) => (i.width && i.height ? [`${i.width}×${i.height}`] : []));
|
|
79
|
+
const replacement = [
|
|
80
|
+
{ type: "text" as const, text: imageStub(item, sizes) },
|
|
81
|
+
...message.content.filter((b) => b.type === "text").map((b) => ({ type: "text" as const, text: b.text ?? "" })),
|
|
82
|
+
];
|
|
83
|
+
items.push({ ...item, beforeChars: contentChars(message.content), afterChars: contentChars(replacement), replacement });
|
|
84
|
+
});
|
|
85
|
+
return items;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const imageLabel = (item: Pick<ImageItem, "toolName" | "args" | "images">): string =>
|
|
89
|
+
`${toolLabel({ toolName: item.toolName, args: item.args })} (${item.images === 1 ? "1 image" : `${item.images} images`})`;
|
package/src/index.ts
CHANGED
|
@@ -35,11 +35,12 @@ import {
|
|
|
35
35
|
} from "./checkpoint.ts";
|
|
36
36
|
import { buildCheckpointRequest } from "./decide.ts";
|
|
37
37
|
import { type GuardConfig, loadConfig, parseModelRef, saveConfigPatch } from "./config.ts";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
38
|
+
import { collectImages, type ImageItem } from "./images.ts";
|
|
39
|
+
import { omittedIdsFor, originalContent, RECALL_MAX_IMAGES, recallText } from "./recall.ts";
|
|
40
|
+
import { formatChars, MARKER, RECALL_TOOL } from "./render.ts";
|
|
40
41
|
import { collectExchanges, collectSmall, type ExchangeItem, type SmallItem, type WorkContext } from "./items.ts";
|
|
41
|
-
import { deferExchanges, exchangeOmissions, type ProcessOptions, type ProcessOutcome, processItems } from "./process.ts";
|
|
42
|
-
import { collectRun, findRunStart, type RunInfo } from "./run.ts";
|
|
42
|
+
import { addImages, deferExchanges, dropImages, emptyOutcome, exchangeOmissions, type ProcessOptions, type ProcessOutcome, processItems } from "./process.ts";
|
|
43
|
+
import { collectRun, findRunStart, lastUserIndex, type RunInfo } from "./run.ts";
|
|
43
44
|
import {
|
|
44
45
|
CUSTOM_TYPE,
|
|
45
46
|
type Colorize,
|
|
@@ -154,8 +155,31 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
154
155
|
pi.on("session_compact", async (_event, ctx) => refreshStatus(ctx));
|
|
155
156
|
pi.on("agent_settled", async (_event, ctx) => refreshStatus(ctx));
|
|
156
157
|
|
|
157
|
-
/**
|
|
158
|
+
/**
|
|
159
|
+
* Process every kind of item with the busy status shown. Old images need no Jev request: with nothing
|
|
160
|
+
* else to judge, or when Jev is unavailable, the outcome holds just them. Undefined when there is
|
|
161
|
+
* nothing to do or distillation failed.
|
|
162
|
+
*/
|
|
158
163
|
const processWithStatus = async (
|
|
164
|
+
ctx: ExtensionContext,
|
|
165
|
+
run: RunInfo,
|
|
166
|
+
small: SmallItem[],
|
|
167
|
+
exchanges: ExchangeItem[],
|
|
168
|
+
images: ImageItem[],
|
|
169
|
+
work: WorkContext,
|
|
170
|
+
options: ProcessOptions,
|
|
171
|
+
): Promise<ProcessOutcome | undefined> => {
|
|
172
|
+
const withImages = (outcome: ProcessOutcome | undefined): ProcessOutcome | undefined => {
|
|
173
|
+
if (images.length === 0) return outcome;
|
|
174
|
+
const result = outcome ?? emptyOutcome();
|
|
175
|
+
addImages(result, images);
|
|
176
|
+
return result;
|
|
177
|
+
};
|
|
178
|
+
if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0) return withImages(undefined);
|
|
179
|
+
return withImages(await judgeWithStatus(ctx, run, small, exchanges, work, options));
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const judgeWithStatus = async (
|
|
159
183
|
ctx: ExtensionContext,
|
|
160
184
|
run: RunInfo,
|
|
161
185
|
small: SmallItem[],
|
|
@@ -236,18 +260,25 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
236
260
|
*/
|
|
237
261
|
const exchangesPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome): boolean => {
|
|
238
262
|
const { savedChars, ids } = exchangeOmissions(outcome);
|
|
263
|
+
return earlyEditsPayOff(ctx, entries, outcome, ids, savedChars, config.exchangeMinSavingChars);
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
/** The same marginal-cost gate for edits `ids` that save `savedChars` (exchanges, images of earlier runs). */
|
|
267
|
+
const earlyEditsPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome, ids: ReadonlySet<string>, savedChars: number, minSavingChars: number): boolean => {
|
|
239
268
|
if (ids.size === 0) return true;
|
|
240
269
|
const others = new Set(outcome.edits.map((e) => e.targetId).filter((id) => !ids.has(id)));
|
|
241
270
|
const rewriteWith = rewriteCharsFor(ctx, entries, new Set([...others, ...ids]));
|
|
242
271
|
const rewriteWithout = others.size > 0 ? rewriteCharsFor(ctx, entries, others) : 0;
|
|
243
272
|
const marginal = rewriteWith - rewriteWithout;
|
|
244
273
|
if (marginal <= 0) return true;
|
|
245
|
-
if (savedChars <
|
|
274
|
+
if (savedChars < minSavingChars) return false;
|
|
246
275
|
const requests = entries.filter((e) => e.messages[0]?.role === "assistant").length;
|
|
247
276
|
const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
|
|
248
277
|
return paysOff(savedChars, requests, marginal, factor);
|
|
249
278
|
};
|
|
250
279
|
|
|
280
|
+
const imageSaving = (images: readonly ImageItem[]): number => images.reduce((n, i) => n + i.beforeChars - i.afterChars, 0);
|
|
281
|
+
|
|
251
282
|
pi.on("turn_end", async (event, ctx) => {
|
|
252
283
|
if (!config.enabled || !config.midRun || event.outcome !== "completed") return;
|
|
253
284
|
// The final turn of a run (no tool calls) is left to run-end distillation.
|
|
@@ -261,33 +292,43 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
261
292
|
const runStart = findRunStart(entries);
|
|
262
293
|
const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, memo, info.currentTurn - config.midRunMinAgeTurns) : [];
|
|
263
294
|
let exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branch, CUSTOM_TYPE)) : [];
|
|
295
|
+
let images = collectImages(entries, config.imageKeepTurns);
|
|
264
296
|
const outputChars = info.pendingChars + small.reduce((n, item) => n + item.text.length, 0);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
297
|
+
// Only checkpoint when the batch is big enough and its one-time cache rewrite is likely to pay off
|
|
298
|
+
// (checkpoint.ts → paysOff). Old exchanges and images of earlier runs come early, so they move the
|
|
299
|
+
// rewrite start back: if the whole batch does not pay off, try without exchanges, then also without
|
|
300
|
+
// images from before the last user message.
|
|
301
|
+
const pin = lastUserIndex(entries);
|
|
302
|
+
const recentImages = images.filter((i) => i.index > pin);
|
|
303
|
+
const tiers = [
|
|
304
|
+
{ exchanges, images },
|
|
305
|
+
{ exchanges: [], images },
|
|
306
|
+
{ exchanges: [], images: recentImages },
|
|
307
|
+
];
|
|
308
|
+
const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
|
|
309
|
+
const outputIds = [...info.candidates.map((c) => c.entryId), ...small.map((s) => s.entryId)];
|
|
310
|
+
const turns = info.currentTurn + 1;
|
|
311
|
+
const chosen = tiers.find((tier) => {
|
|
312
|
+
const chars = outputChars + imageSaving(tier.images) + tier.exchanges.reduce((n, item) => n + item.chars, 0);
|
|
313
|
+
if (chars < batchCharsFor(ctx)) return false;
|
|
314
|
+
if (!config.midRunBreakEven) return true;
|
|
315
|
+
const ids = new Set([...outputIds, ...tier.images.map((i) => i.entryId), ...tier.exchanges.map((e) => e.entryId)]);
|
|
316
|
+
return paysOff(chars, turns, rewriteCharsFor(ctx, entries, ids), factor);
|
|
317
|
+
});
|
|
318
|
+
if (!chosen) return;
|
|
319
|
+
exchanges = chosen.exchanges;
|
|
320
|
+
images = chosen.images;
|
|
280
321
|
|
|
281
322
|
const run: RunInfo = { question: info.question, answer: "", notes: info.notes, candidates: info.candidates, toolResults: 0, history: info.history };
|
|
282
323
|
const work: WorkContext = { question: info.question, history: info.history, latest: info.latest, notes: info.notes };
|
|
283
|
-
const outcome = await processWithStatus(ctx, run, small, exchanges, work, {
|
|
324
|
+
const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, {
|
|
284
325
|
...config,
|
|
285
326
|
minRunChars: 0,
|
|
286
327
|
chunkKeepThreshold: config.midRunChunkKeepThreshold,
|
|
287
328
|
requestBuilder: (candidate, segment, segmentIndex, segmentCount, totalLines) =>
|
|
288
329
|
buildCheckpointRequest(info, candidate as CheckpointCandidate, segment, segmentIndex, segmentCount, totalLines),
|
|
289
330
|
});
|
|
290
|
-
if (!outcome || outcome.requests === 0) return;
|
|
331
|
+
if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
|
|
291
332
|
|
|
292
333
|
if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
|
|
293
334
|
// The runner REPLACES the draft list with our return value: keep other extensions' drafts.
|
|
@@ -308,12 +349,19 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
308
349
|
const runStart = findRunStart(entries);
|
|
309
350
|
const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, new Set()) : [];
|
|
310
351
|
const exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branchOf(ctx), CUSTOM_TYPE)) : [];
|
|
311
|
-
|
|
352
|
+
const images = collectImages(entries, config.imageKeepTurns);
|
|
353
|
+
if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0 && images.length === 0) return;
|
|
312
354
|
const work: WorkContext = { question: run.question, history: run.history ?? { exchanges: [] }, answer: run.answer };
|
|
313
355
|
|
|
314
|
-
const outcome = await processWithStatus(ctx, run, small, exchanges, work, config);
|
|
315
|
-
if (!outcome || outcome.requests === 0) return;
|
|
356
|
+
const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, config);
|
|
357
|
+
if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
|
|
316
358
|
if (config.exchangeBreakEven && !exchangesPayOff(ctx, entries, outcome)) deferExchanges(outcome);
|
|
359
|
+
// Images of earlier runs (before this run's prompt) move the cache rewrite back like exchanges do.
|
|
360
|
+
const early = images.filter((i) => i.index < runStart);
|
|
361
|
+
if (config.exchangeBreakEven && !earlyEditsPayOff(ctx, entries, outcome, new Set(early.map((i) => i.entryId)), imageSaving(early), 0)) {
|
|
362
|
+
dropImages(outcome, new Set(early.map((i) => i.entryId)));
|
|
363
|
+
}
|
|
364
|
+
if (outcome.requests === 0 && outcome.edits.length === 0) return;
|
|
317
365
|
if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
|
|
318
366
|
// The runner REPLACES the draft list with our return value: keep other extensions' drafts.
|
|
319
367
|
return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "run-end")] };
|
|
@@ -353,11 +401,11 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
353
401
|
});
|
|
354
402
|
|
|
355
403
|
pi.registerTool({
|
|
356
|
-
name:
|
|
404
|
+
name: RECALL_TOOL,
|
|
357
405
|
label: "Recall",
|
|
358
406
|
description:
|
|
359
407
|
`Return the original of something context-guard distilled or omitted (its text starts with "${MARKER}"): a tool output, ` +
|
|
360
|
-
"or a whole earlier exchange (prompt, replies, tool calls and results). Pass the entryId from that note. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
|
|
408
|
+
"or a whole earlier exchange (prompt, replies, tool calls and results), or images removed from an output (they come back as images). Pass the entryId from that note. Optionally pass a regex `pattern` to get only matching lines (with line numbers), " +
|
|
361
409
|
"and `offset`/`limit` (1-based) to page through a long output; with a `pattern` they count matching lines.",
|
|
362
410
|
promptSnippet: `recall: fetch the full original of a tool output or earlier exchange marked "${MARKER}"`,
|
|
363
411
|
parameters: Type.Object({
|
|
@@ -368,9 +416,13 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
368
416
|
}),
|
|
369
417
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
370
418
|
const branch = ctx.sessionManager.getBranch() as never;
|
|
371
|
-
const original =
|
|
372
|
-
const text = recallText(original, params);
|
|
373
|
-
|
|
419
|
+
const original = originalContent(ctx.sessionManager.getEntry(params.entryId) as never, params.entryId, branch, omittedIdsFor(branch, params.entryId, CUSTOM_TYPE));
|
|
420
|
+
const text = recallText(original.text, params);
|
|
421
|
+
// Images come with the first page of a plain recall, not with a pattern search.
|
|
422
|
+
const withImages = !params.pattern && !(params.offset && params.offset > 1);
|
|
423
|
+
const images = withImages ? original.images.slice(0, RECALL_MAX_IMAGES) : [];
|
|
424
|
+
const note = original.images.length > images.length ? `\n[${original.images.length} image(s) in the original${images.length > 0 ? `, ${images.length} attached` : ""}${withImages ? "" : "; recall without pattern/offset to see them"}]` : "";
|
|
425
|
+
return { content: [{ type: "text", text: text + note }, ...(images as never[])], details: undefined };
|
|
374
426
|
},
|
|
375
427
|
});
|
|
376
428
|
|
|
@@ -399,13 +451,14 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
399
451
|
function describe(config: GuardConfig, stats: GuardStats, problem: string | undefined): string {
|
|
400
452
|
const lines = [
|
|
401
453
|
`context-guard is ${config.enabled ? "on" : "off"}${problem && config.enabled ? ` (${problem})` : ""} · model ${config.model}`,
|
|
402
|
-
`Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars): ${stats.distilledResults} distilled tool output(s), ${stats.omittedExchanges} omitted exchange(s).`,
|
|
454
|
+
`Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars): ${stats.distilledResults} distilled tool output(s), ${stats.imageResults} output(s) without their images, ${stats.omittedExchanges} omitted exchange(s).`,
|
|
455
|
+
...(stats.compacted ? [`Σ ~${formatTokens(stats.lifetimeTokens)} tokens saved on this branch in total; a compaction has since dropped the earlier edits from context.`] : []),
|
|
403
456
|
`Jev: ${stats.runs - stats.checkpoints} run(s) and ${stats.checkpoints} mid-run checkpoint(s), ${stats.requests} request(s), ${formatCost(stats.costUsd)} on this branch.`,
|
|
404
|
-
`Mid-run checkpoints are ${config.midRun ? `on (outputs ≥ ${config.midRunMinAgeTurns} turns old, batches of ≥ ${formatChars(config.midRunBatchChars)} chars)` : "off"}; old exchanges are ${config.pruneExchanges ? `judged (all but the last ${config.keepRecentExchanges})` : "kept"}.`,
|
|
457
|
+
`Mid-run checkpoints are ${config.midRun ? `on (outputs ≥ ${config.midRunMinAgeTurns} turns old, batches of ≥ ${formatChars(config.midRunBatchChars)} chars)` : "off"}; old exchanges are ${config.pruneExchanges ? `judged (all but the last ${config.keepRecentExchanges})` : "kept"}; tool-output images are ${config.imageKeepTurns > 0 ? `removed after ${config.imageKeepTurns} turns` : "kept"}.`,
|
|
405
458
|
];
|
|
406
459
|
const last = stats.last;
|
|
407
460
|
if (last) {
|
|
408
|
-
const kinds = ["large", "small", "exchange"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
|
|
461
|
+
const kinds = ["large", "small", "exchange", "image"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
|
|
409
462
|
lines.push(`Last ${last.phase === "mid-run" ? "checkpoint" : "run"} (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}; ${kinds}):`);
|
|
410
463
|
for (const result of last.results) {
|
|
411
464
|
const sizes = result.outcome === "kept" ? formatChars(result.beforeChars) : `${formatChars(result.beforeChars)} → ${formatChars(result.afterChars)}`;
|
package/src/items.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* output becomes a one-line stub; an omitted exchange keeps a stub on its user prompt and omits every
|
|
5
5
|
* other entry (`replacement: null`).
|
|
6
6
|
*/
|
|
7
|
-
import { MARKER, formatChars, toolLabel } from "./render.ts";
|
|
7
|
+
import { MARKER, RECALL_TOOL, formatChars, toolLabel } from "./render.ts";
|
|
8
8
|
import { supersededBy } from "./checkpoint.ts";
|
|
9
|
-
import {
|
|
9
|
+
import { contentChars } from "./size.ts";
|
|
10
|
+
import { type Candidate, type CollectOptions, candidateOf, indexSpan, isSteering, type RunHistory } from "./run.ts";
|
|
10
11
|
import {
|
|
11
12
|
type ClassifierQuestion,
|
|
12
13
|
type ClassifierRequest,
|
|
@@ -18,7 +19,7 @@ import {
|
|
|
18
19
|
textOf,
|
|
19
20
|
} from "./types.ts";
|
|
20
21
|
|
|
21
|
-
export type ItemKind = "large" | "small" | "exchange";
|
|
22
|
+
export type ItemKind = "large" | "small" | "exchange" | "image";
|
|
22
23
|
|
|
23
24
|
// ---------------------------------------------------------------------------------------------
|
|
24
25
|
// Small outputs
|
|
@@ -38,7 +39,8 @@ export interface SmallOptions extends CollectOptions {
|
|
|
38
39
|
/**
|
|
39
40
|
* Tool results of the run span `span` (entries after the run's user message) between
|
|
40
41
|
* `smallResultMinChars` and `minResultChars`, under the same candidate rules as large outputs. With
|
|
41
|
-
* `youngest`, only outputs whose call was made in a turn ≤ youngest (the mid-run age rule)
|
|
42
|
+
* `youngest`, only outputs whose call was made in a turn ≤ youngest (the mid-run age rule), and no
|
|
43
|
+
* recall outputs.
|
|
42
44
|
*/
|
|
43
45
|
export function collectSmall(span: readonly ProjectedEntryLike[], options: SmallOptions, judged: ReadonlySet<string>, youngest = Number.POSITIVE_INFINITY): SmallItem[] {
|
|
44
46
|
const { toolCalls } = indexSpan(span);
|
|
@@ -49,6 +51,8 @@ export function collectSmall(span: readonly ProjectedEntryLike[], options: Small
|
|
|
49
51
|
if (!candidate || candidate.text.length >= options.minResultChars || judged.has(candidate.entryId)) continue;
|
|
50
52
|
const turn = candidate.turn ?? 0;
|
|
51
53
|
if (turn > youngest) continue;
|
|
54
|
+
// Mid-run (`youngest` given): recall outputs are left alone, see checkpoint.ts → collectCheckpoint.
|
|
55
|
+
if (Number.isFinite(youngest) && candidate.toolName === RECALL_TOOL) continue;
|
|
52
56
|
const { toolCallId: _id, turn: _turn, ...plain } = candidate;
|
|
53
57
|
// Skip outputs whose stub would not be shorter.
|
|
54
58
|
if (smallStub(plain).length >= plain.text.length) continue;
|
|
@@ -91,17 +95,11 @@ const isEditable = (entry: ProjectedEntryLike): boolean => {
|
|
|
91
95
|
return role === "user" || role === "assistant" || role === "toolResult";
|
|
92
96
|
};
|
|
93
97
|
|
|
94
|
-
const entryChars = (entry: ProjectedEntryLike): number =>
|
|
95
|
-
entry.messages.reduce((n, m) => n + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content ?? "").length), 0);
|
|
98
|
+
const entryChars = (entry: ProjectedEntryLike): number => entry.messages.reduce((n, m) => n + contentChars(m.content), 0);
|
|
96
99
|
|
|
97
100
|
/** Exchanges smaller than this (model-visible characters) are never judged: the saving is not worth an edit. */
|
|
98
101
|
export const EXCHANGE_MIN_CHARS = 2_000;
|
|
99
102
|
|
|
100
|
-
const hasImage = (entry: ProjectedEntryLike): boolean =>
|
|
101
|
-
[...entry.messages, ...(entry.sourceEntry.message ? [entry.sourceEntry.message] : [])].some(
|
|
102
|
-
(m) => Array.isArray(m.content) && m.content.some((b) => b.type === "image"),
|
|
103
|
-
);
|
|
104
|
-
|
|
105
103
|
/**
|
|
106
104
|
* True when the model sees something other than the raw entry and the difference is not ours: another
|
|
107
105
|
* extension (or Pi's overflow recovery) replaced or omitted it. `recall` returns raw entries, so whatever
|
|
@@ -126,7 +124,8 @@ const editedByOthers = (entry: ProjectedEntryLike): boolean => {
|
|
|
126
124
|
*/
|
|
127
125
|
export function collectExchanges(entries: readonly ProjectedEntryLike[], runStart: number, keepRecent: number, judged: ReadonlySet<string>): ExchangeItem[] {
|
|
128
126
|
const starts: number[] = [];
|
|
129
|
-
|
|
127
|
+
// Steering messages belong to the exchange they were sent in.
|
|
128
|
+
for (let i = 0; i < runStart; i++) if (entries[i].messages.some((m) => m.role === "user") && !isSteering(entries, i)) starts.push(i);
|
|
130
129
|
const exchanges: ExchangeItem[] = [];
|
|
131
130
|
starts.slice(0, Math.max(0, starts.length - keepRecent)).forEach((start, k) => {
|
|
132
131
|
const end = k + 1 < starts.length ? starts[k + 1] : runStart;
|
|
@@ -135,8 +134,8 @@ export function collectExchanges(entries: readonly ProjectedEntryLike[], runStar
|
|
|
135
134
|
if (!isEditable(user) || judged.has(user.sourceEntry.id)) return;
|
|
136
135
|
const promptText = textOf(user.messages[0]);
|
|
137
136
|
if (promptText.startsWith(MARKER)) return; // already omitted
|
|
138
|
-
// Recoverability: recall returns raw entries
|
|
139
|
-
if (span.some(
|
|
137
|
+
// Recoverability: recall returns raw entries (with their images, see recall.ts), so no foreign edits.
|
|
138
|
+
if (span.some(editedByOthers)) return;
|
|
140
139
|
const assistants = span.filter((e) => e.messages[0]?.role === "assistant");
|
|
141
140
|
const lastAssistant = assistants.at(-1)?.messages[0];
|
|
142
141
|
if (!lastAssistant || (Array.isArray(lastAssistant.content) && lastAssistant.content.some((b) => b.type === "toolCall"))) return;
|
package/src/process.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
smallStub,
|
|
21
21
|
type WorkContext,
|
|
22
22
|
} from "./items.ts";
|
|
23
|
+
import { type ImageItem, imageLabel } from "./images.ts";
|
|
23
24
|
import { toolLabel } from "./render.ts";
|
|
24
25
|
import type { RunInfo } from "./run.ts";
|
|
25
26
|
import type { ClassifyFn, ContextEditDraft, OmitDraft } from "./types.ts";
|
|
@@ -143,6 +144,42 @@ export async function processItems(
|
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
|
|
147
|
+
export const emptyOutcome = (): ProcessOutcome => ({ edits: [], records: [], savedChars: 0, requests: 0, inputTokens: 0, costUsd: 0, ms: 0, timedOut: false });
|
|
148
|
+
|
|
149
|
+
/** Add the removal of old images (no Jev request; images.ts) to `outcome`. */
|
|
150
|
+
export function addImages(outcome: ProcessOutcome, items: readonly ImageItem[]): void {
|
|
151
|
+
for (const item of items) {
|
|
152
|
+
outcome.edits.push({ type: "context_edit", targetId: item.entryId, replacement: { content: item.replacement } });
|
|
153
|
+
outcome.records.push({
|
|
154
|
+
kind: "image",
|
|
155
|
+
entryId: item.entryId,
|
|
156
|
+
tool: item.toolName,
|
|
157
|
+
label: imageLabel(item),
|
|
158
|
+
outcome: "removed",
|
|
159
|
+
reason: `${item.age} turns old`,
|
|
160
|
+
beforeChars: item.beforeChars,
|
|
161
|
+
afterChars: item.afterChars,
|
|
162
|
+
keptLines: 0,
|
|
163
|
+
totalLines: 0,
|
|
164
|
+
chunks: 1,
|
|
165
|
+
requests: 0,
|
|
166
|
+
jev: [],
|
|
167
|
+
});
|
|
168
|
+
outcome.savedChars += item.beforeChars - item.afterChars;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Take image removals for `ids` back out of `outcome` (they stay eligible for a later pass). */
|
|
173
|
+
export function dropImages(outcome: ProcessOutcome, ids: ReadonlySet<string>): void {
|
|
174
|
+
if (ids.size === 0) return;
|
|
175
|
+
outcome.edits = outcome.edits.filter((e) => !ids.has(e.targetId));
|
|
176
|
+
outcome.records = outcome.records.filter((r) => {
|
|
177
|
+
if (r.kind !== "image" || !ids.has(r.entryId)) return true;
|
|
178
|
+
outcome.savedChars -= r.beforeChars - r.afterChars;
|
|
179
|
+
return false;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
146
183
|
/** Net characters the outcome's exchange omissions remove, and the entry ids they edit. */
|
|
147
184
|
export function exchangeOmissions(outcome: ProcessOutcome): { savedChars: number; ids: Set<string> } {
|
|
148
185
|
const ids = new Set<string>();
|