pi-context-guard-jev 0.1.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 +98 -253
- package/docs/CACHE.md +139 -3
- package/docs/DESIGN.md +263 -39
- package/docs/JEV.md +95 -27
- package/docs/REFERENCE.md +164 -0
- package/docs/RESULTS.md +146 -0
- package/docs/TESTING.md +116 -27
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +459 -0
- package/src/config.ts +63 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/images.ts +89 -0
- package/src/index.ts +299 -58
- package/src/items.ts +446 -0
- package/src/process.ts +213 -0
- package/src/recall.ts +98 -6
- package/src/render.ts +2 -0
- package/src/run.ts +143 -32
- package/src/size.ts +114 -0
- package/src/stats.ts +77 -15
- package/src/types.ts +9 -0
package/src/index.ts
CHANGED
|
@@ -6,18 +6,41 @@
|
|
|
6
6
|
* entries then replace each output with the kept chunks, verbatim. The raw output stays in the
|
|
7
7
|
* session; the `recall` tool returns it. The footer shows how much context is being saved.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* During long runs, mid-run checkpoints judge older outputs at turn boundaries (turn_end), in
|
|
10
|
+
* batches, with a checkpoint-specific Jev request (docs/DESIGN.md → Mid-run checkpoints).
|
|
11
|
+
*
|
|
12
|
+
* Also places Anthropic cache breakpoints (the previous user question, and after edits a read point
|
|
13
|
+
* before the first edited output), so the distilled history still hits the prompt cache (docs/CACHE.md).
|
|
11
14
|
*/
|
|
12
15
|
import { join } from "node:path";
|
|
13
16
|
import { Type } from "@earendil-works/pi-ai";
|
|
14
17
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import { pinQuestionBreakpoint } from "./cache-pin.ts";
|
|
18
|
+
import { markedToolResults, normalizeToolUseId, pinQuestionBreakpoint, placeGuardBreakpoints } from "./cache-pin.ts";
|
|
19
|
+
import {
|
|
20
|
+
type CheckpointCandidate,
|
|
21
|
+
collectCheckpoint,
|
|
22
|
+
anthropicRewriteChars,
|
|
23
|
+
BREAK_EVEN_FACTOR,
|
|
24
|
+
CACHE_TTL_MS,
|
|
25
|
+
cacheAnchors,
|
|
26
|
+
exchangeMemo,
|
|
27
|
+
charsFrom,
|
|
28
|
+
contextChars,
|
|
29
|
+
memoFromBranch,
|
|
30
|
+
paysOff,
|
|
31
|
+
pendingEdits,
|
|
32
|
+
refreshOnReadThrough,
|
|
33
|
+
runBaseline,
|
|
34
|
+
type WrittenEntry,
|
|
35
|
+
} from "./checkpoint.ts";
|
|
36
|
+
import { buildCheckpointRequest } from "./decide.ts";
|
|
16
37
|
import { type GuardConfig, loadConfig, parseModelRef, saveConfigPatch } from "./config.ts";
|
|
17
|
-
import { type
|
|
18
|
-
import {
|
|
19
|
-
import { formatChars, MARKER } from "./render.ts";
|
|
20
|
-
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";
|
|
41
|
+
import { collectExchanges, collectSmall, type ExchangeItem, type SmallItem, type WorkContext } from "./items.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";
|
|
21
44
|
import {
|
|
22
45
|
CUSTOM_TYPE,
|
|
23
46
|
type Colorize,
|
|
@@ -40,6 +63,33 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
40
63
|
let busy = false;
|
|
41
64
|
let problem: string | undefined;
|
|
42
65
|
let warned = new Set<string>();
|
|
66
|
+
// No checkpoint state is kept in memory: the memo and the read point are derived from the entries
|
|
67
|
+
// persisted on the active branch at every boundary and request (checkpoint.ts).
|
|
68
|
+
const branchOf = (ctx: ExtensionContext) => ctx.sessionManager.getBranch() as unknown as Parameters<typeof pendingEdits>[0];
|
|
69
|
+
/**
|
|
70
|
+
* Cache entries this process wrote: the tool-result breakpoints it saw in the Anthropic requests it
|
|
71
|
+
* sent (after its own changes). The only source for read points; cleared on session start or reload,
|
|
72
|
+
* so anything uncertain falls back to the question pin.
|
|
73
|
+
*/
|
|
74
|
+
let written: WrittenEntry[] = [];
|
|
75
|
+
const recordWritten = (branch: ReturnType<typeof branchOf>, payload: unknown, model: string, now: number) => {
|
|
76
|
+
const leafId = branch.at(-1)?.id;
|
|
77
|
+
if (!leafId) return;
|
|
78
|
+
const byToolUseId = new Map<string, string>();
|
|
79
|
+
for (const e of branch) {
|
|
80
|
+
const id = e.message?.role === "toolResult" ? e.message.toolCallId : undefined;
|
|
81
|
+
if (id && e.id) byToolUseId.set(normalizeToolUseId(id), e.id);
|
|
82
|
+
}
|
|
83
|
+
const fresh = written.filter((w) => now - w.time <= CACHE_TTL_MS);
|
|
84
|
+
for (const toolUseId of markedToolResults(payload)) {
|
|
85
|
+
const entryId = byToolUseId.get(toolUseId);
|
|
86
|
+
if (entryId) fresh.push({ entryId, model, time: now, leafId });
|
|
87
|
+
}
|
|
88
|
+
written = fresh.slice(-200);
|
|
89
|
+
};
|
|
90
|
+
const modelKey = (ctx: ExtensionContext) => `${ctx.model?.provider}/${ctx.model?.id}`;
|
|
91
|
+
/** The last Anthropic request this process saw, to match its response in message_end. */
|
|
92
|
+
let lastRequest: { model: string; time: number } | undefined;
|
|
43
93
|
|
|
44
94
|
const warnOnce = (ctx: ExtensionContext, key: string, message: string) => {
|
|
45
95
|
if (warned.has(key)) return;
|
|
@@ -97,6 +147,7 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
97
147
|
warned = new Set();
|
|
98
148
|
busy = false;
|
|
99
149
|
reload(ctx);
|
|
150
|
+
written = [];
|
|
100
151
|
if (config.enabled) resolveClassifier(ctx);
|
|
101
152
|
refreshStatus(ctx);
|
|
102
153
|
});
|
|
@@ -104,77 +155,259 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
104
155
|
pi.on("session_compact", async (_event, ctx) => refreshStatus(ctx));
|
|
105
156
|
pi.on("agent_settled", async (_event, ctx) => refreshStatus(ctx));
|
|
106
157
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
*/
|
|
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
|
+
};
|
|
112
181
|
|
|
182
|
+
const judgeWithStatus = async (
|
|
183
|
+
ctx: ExtensionContext,
|
|
184
|
+
run: RunInfo,
|
|
185
|
+
small: SmallItem[],
|
|
186
|
+
exchanges: ExchangeItem[],
|
|
187
|
+
work: WorkContext,
|
|
188
|
+
options: ProcessOptions,
|
|
189
|
+
): Promise<ProcessOutcome | undefined> => {
|
|
113
190
|
const classify = resolveClassifier(ctx);
|
|
114
191
|
if (!classify) {
|
|
115
192
|
refreshStatus(ctx);
|
|
116
|
-
return;
|
|
193
|
+
return undefined;
|
|
117
194
|
}
|
|
118
|
-
|
|
119
195
|
busy = true;
|
|
120
196
|
refreshStatus(ctx);
|
|
121
|
-
if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling
|
|
122
|
-
let outcome: DistillOutcome;
|
|
197
|
+
if (ctx.hasUI) ctx.ui.setWorkingMessage("context-guard: distilling old context with Jev…");
|
|
123
198
|
try {
|
|
124
|
-
|
|
199
|
+
return await processItems(run, small, exchanges, work, classify, options, ctx.signal);
|
|
125
200
|
} catch (err) {
|
|
126
201
|
warnOnce(ctx, "distill", `distillation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
-
return;
|
|
202
|
+
return undefined;
|
|
128
203
|
} finally {
|
|
129
204
|
busy = false;
|
|
130
205
|
if (ctx.hasUI) ctx.ui.setWorkingMessage();
|
|
131
206
|
refreshStatus(ctx);
|
|
132
207
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const recordOf = (outcome: ProcessOutcome, phase: "mid-run" | "run-end"): CustomDraft => ({
|
|
211
|
+
type: "custom",
|
|
212
|
+
customType: CUSTOM_TYPE,
|
|
213
|
+
data: {
|
|
214
|
+
v: 1,
|
|
215
|
+
phase,
|
|
216
|
+
model: config.model,
|
|
217
|
+
savedChars: outcome.savedChars,
|
|
218
|
+
requests: outcome.requests,
|
|
219
|
+
inputTokens: outcome.inputTokens,
|
|
220
|
+
costUsd: outcome.costUsd,
|
|
221
|
+
ms: outcome.ms,
|
|
222
|
+
timedOut: outcome.timedOut,
|
|
223
|
+
results: outcome.records.map(({ kind, entryId, tool, label, outcome, reason, beforeChars, afterChars, jev, omitted }) => ({
|
|
224
|
+
kind,
|
|
225
|
+
...(omitted ? { omitted } : {}),
|
|
226
|
+
entryId,
|
|
227
|
+
tool,
|
|
228
|
+
label,
|
|
229
|
+
outcome,
|
|
230
|
+
reason,
|
|
231
|
+
beforeChars,
|
|
232
|
+
afterChars,
|
|
233
|
+
jev,
|
|
234
|
+
})),
|
|
235
|
+
} satisfies RunRecordData,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
/** Checkpoint batch size for the current main model (OpenAI Codex loses its whole cache on every edit). */
|
|
239
|
+
const batchCharsFor = (ctx: ExtensionContext): number =>
|
|
240
|
+
ctx.model?.provider === "openai-codex" ? config.midRunBatchCharsOpenAI : config.midRunBatchChars;
|
|
241
|
+
|
|
242
|
+
/** Characters the next request would write to the cache again if `ids` (entry ids) were edited. */
|
|
243
|
+
const rewriteCharsFor = (ctx: ExtensionContext, entries: ProjectedEntryLike[], ids: ReadonlySet<string>): number => {
|
|
244
|
+
// OpenAI Codex: the whole context. Anthropic: everything after the read point it would get, or after
|
|
245
|
+
// the question when every edit comes after it, else everything (with pinning off: everything).
|
|
246
|
+
// Other providers: everything from the earliest edited entry.
|
|
247
|
+
if (ctx.model?.provider === "openai-codex") return contextChars(entries);
|
|
248
|
+
if (ctx.model?.api === "anthropic-messages") {
|
|
249
|
+
return config.pinAnthropicCache ? anthropicRewriteChars(entries, branchOf(ctx), written, modelKey(ctx), Date.now(), ids) : contextChars(entries);
|
|
250
|
+
}
|
|
251
|
+
return charsFrom(entries, ids);
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run-end gate for exchange omissions (checkpoint.ts → paysOff). Their marginal cache cost is the
|
|
256
|
+
* rewrite with them minus the rewrite the pass's other edits cause anyway (on OpenAI Codex any edit
|
|
257
|
+
* already costs the whole context, so with other edits the omissions are free). When that is above
|
|
258
|
+
* zero, they must save at least `exchangeMinSavingChars` and pay off over as many later requests as
|
|
259
|
+
* the session has had so far.
|
|
260
|
+
*/
|
|
261
|
+
const exchangesPayOff = (ctx: ExtensionContext, entries: ProjectedEntryLike[], outcome: ProcessOutcome): boolean => {
|
|
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 => {
|
|
268
|
+
if (ids.size === 0) return true;
|
|
269
|
+
const others = new Set(outcome.edits.map((e) => e.targetId).filter((id) => !ids.has(id)));
|
|
270
|
+
const rewriteWith = rewriteCharsFor(ctx, entries, new Set([...others, ...ids]));
|
|
271
|
+
const rewriteWithout = others.size > 0 ? rewriteCharsFor(ctx, entries, others) : 0;
|
|
272
|
+
const marginal = rewriteWith - rewriteWithout;
|
|
273
|
+
if (marginal <= 0) return true;
|
|
274
|
+
if (savedChars < minSavingChars) return false;
|
|
275
|
+
const requests = entries.filter((e) => e.messages[0]?.role === "assistant").length;
|
|
276
|
+
const factor = ctx.model?.provider === "openai-codex" ? BREAK_EVEN_FACTOR.full : BREAK_EVEN_FACTOR.prefix;
|
|
277
|
+
return paysOff(savedChars, requests, marginal, factor);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const imageSaving = (images: readonly ImageItem[]): number => images.reduce((n, i) => n + i.beforeChars - i.afterChars, 0);
|
|
281
|
+
|
|
282
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
283
|
+
if (!config.enabled || !config.midRun || event.outcome !== "completed") return;
|
|
284
|
+
// The final turn of a run (no tool calls) is left to run-end distillation.
|
|
285
|
+
const message = event.message as { role?: string; content?: unknown };
|
|
286
|
+
if (message.role !== "assistant" || !Array.isArray(message.content) || !message.content.some((b: { type?: string }) => b?.type === "toolCall")) return;
|
|
287
|
+
const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
|
|
288
|
+
const branch = branchOf(ctx);
|
|
289
|
+
const memo = memoFromBranch(branch, CUSTOM_TYPE);
|
|
290
|
+
const info = collectCheckpoint(entries, config, memo);
|
|
291
|
+
if (!info) return;
|
|
292
|
+
const runStart = findRunStart(entries);
|
|
293
|
+
const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, memo, info.currentTurn - config.midRunMinAgeTurns) : [];
|
|
294
|
+
let exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branch, CUSTOM_TYPE)) : [];
|
|
295
|
+
let images = collectImages(entries, config.imageKeepTurns);
|
|
296
|
+
const outputChars = info.pendingChars + small.reduce((n, item) => n + item.text.length, 0);
|
|
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;
|
|
321
|
+
|
|
322
|
+
const run: RunInfo = { question: info.question, answer: "", notes: info.notes, candidates: info.candidates, toolResults: 0, history: info.history };
|
|
323
|
+
const work: WorkContext = { question: info.question, history: info.history, latest: info.latest, notes: info.notes };
|
|
324
|
+
const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, {
|
|
325
|
+
...config,
|
|
326
|
+
minRunChars: 0,
|
|
327
|
+
chunkKeepThreshold: config.midRunChunkKeepThreshold,
|
|
328
|
+
requestBuilder: (candidate, segment, segmentIndex, segmentCount, totalLines) =>
|
|
329
|
+
buildCheckpointRequest(info, candidate as CheckpointCandidate, segment, segmentIndex, segmentCount, totalLines),
|
|
330
|
+
});
|
|
331
|
+
if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
|
|
332
|
+
|
|
333
|
+
if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
|
|
160
334
|
// The runner REPLACES the draft list with our return value: keep other extensions' drafts.
|
|
161
|
-
|
|
335
|
+
// Never ask for `continue`: the run goes on by itself.
|
|
336
|
+
// The memo only advances once Pi has committed this record to the branch.
|
|
337
|
+
return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "mid-run")] };
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
pi.on("agent_before_settle", async (event, ctx) => {
|
|
341
|
+
if (!config.enabled || event.outcome !== "completed") return;
|
|
342
|
+
const entries = event.context.contextEntries as unknown as ProjectedEntryLike[];
|
|
343
|
+
// Outputs kept at a mid-run checkpoint are judged again, now with the final answer; distilled ones
|
|
344
|
+
// carry the marker and are skipped by the candidate rules.
|
|
345
|
+
const run = collectRun(entries, config);
|
|
346
|
+
if (!run) return;
|
|
347
|
+
const largeChars = run.candidates.reduce((sum, c) => sum + c.text.length, 0);
|
|
348
|
+
if (largeChars < config.minRunChars) run.candidates = [];
|
|
349
|
+
const runStart = findRunStart(entries);
|
|
350
|
+
const small = config.smallResultMinChars > 0 ? collectSmall(entries.slice(runStart + 1), config, new Set()) : [];
|
|
351
|
+
const exchanges = config.pruneExchanges ? collectExchanges(entries, runStart, config.keepRecentExchanges, exchangeMemo(branchOf(ctx), CUSTOM_TYPE)) : [];
|
|
352
|
+
const images = collectImages(entries, config.imageKeepTurns);
|
|
353
|
+
if (run.candidates.length === 0 && small.length === 0 && exchanges.length === 0 && images.length === 0) return;
|
|
354
|
+
const work: WorkContext = { question: run.question, history: run.history ?? { exchanges: [] }, answer: run.answer };
|
|
355
|
+
|
|
356
|
+
const outcome = await processWithStatus(ctx, run, small, exchanges, images, work, config);
|
|
357
|
+
if (!outcome || (outcome.requests === 0 && outcome.edits.length === 0)) return;
|
|
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;
|
|
365
|
+
if (outcome.timedOut) warnOnce(ctx, "timeout", `Jev did not finish within ${config.timeoutMs} ms; some items were left as they are.`);
|
|
366
|
+
// The runner REPLACES the draft list with our return value: keep other extensions' drafts.
|
|
367
|
+
return { entries: [...event.entries, ...(outcome.edits as never[]), recordOf(outcome, "run-end")] };
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
// Refresh logged cache entries that the response shows were read through (checkpoint.ts →
|
|
371
|
+
// refreshOnReadThrough). Without a clear hit beyond the system prompt and question, nothing changes.
|
|
372
|
+
pi.on("message_end", async (event, ctx) => {
|
|
373
|
+
const message = event.message as { role?: string; provider?: string; model?: string; usage?: { cacheRead?: number } };
|
|
374
|
+
const request = lastRequest;
|
|
375
|
+
if (message.role !== "assistant" || !request || written.length === 0 || !ctx.sessionManager) return;
|
|
376
|
+
lastRequest = undefined;
|
|
377
|
+
if (`${message.provider}/${message.model}` !== request.model) return;
|
|
378
|
+
const branch = branchOf(ctx);
|
|
379
|
+
const baseline = runBaseline(branch);
|
|
380
|
+
if (baseline === undefined) return;
|
|
381
|
+
const projection = ctx.sessionManager.buildSessionProjection().entries as unknown as ProjectedEntryLike[];
|
|
382
|
+
refreshOnReadThrough(projection, branch, written, request.model, request.time, message.usage?.cacheRead ?? 0, baseline);
|
|
162
383
|
});
|
|
163
384
|
|
|
164
385
|
pi.on("before_provider_request", (event, ctx) => {
|
|
165
386
|
if (!config.enabled || !config.pinAnthropicCache) return;
|
|
166
387
|
if (ctx.model?.api !== "anthropic-messages") return;
|
|
167
|
-
|
|
388
|
+
// Mutates in place; returning undefined keeps the payload. The previous question is always pinned
|
|
389
|
+
// (the floor). The first request after edits (checkpoint, run end or another extension) also gets
|
|
390
|
+
// a read point, only at an entry this process saw itself write (`written`), and a write anchor
|
|
391
|
+
// after the first edited batch (checkpoint.ts → cacheAnchors).
|
|
392
|
+
const model = modelKey(ctx);
|
|
393
|
+
const branch = ctx.sessionManager ? branchOf(ctx) : [];
|
|
394
|
+
const now = Date.now();
|
|
395
|
+
lastRequest = { model, time: now };
|
|
396
|
+
const anchors = cacheAnchors(branch, written, model, now);
|
|
397
|
+
if (anchors.read || anchors.write) {
|
|
398
|
+
placeGuardBreakpoints(event.payload, { anchors: anchors.read ? [anchors.read] : [], writeAnchors: anchors.write ? [anchors.write] : [], pinQuestion: true });
|
|
399
|
+
} else pinQuestionBreakpoint(event.payload);
|
|
400
|
+
recordWritten(branch, event.payload, model, now);
|
|
168
401
|
});
|
|
169
402
|
|
|
170
403
|
pi.registerTool({
|
|
171
|
-
name:
|
|
404
|
+
name: RECALL_TOOL,
|
|
172
405
|
label: "Recall",
|
|
173
406
|
description:
|
|
174
|
-
`Return the original
|
|
175
|
-
"Pass the entryId from that
|
|
407
|
+
`Return the original of something context-guard distilled or omitted (its text starts with "${MARKER}"): a tool output, ` +
|
|
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), " +
|
|
176
409
|
"and `offset`/`limit` (1-based) to page through a long output; with a `pattern` they count matching lines.",
|
|
177
|
-
promptSnippet: `recall: fetch the full original
|
|
410
|
+
promptSnippet: `recall: fetch the full original of a tool output or earlier exchange marked "${MARKER}"`,
|
|
178
411
|
parameters: Type.Object({
|
|
179
412
|
entryId: Type.String({ description: "Entry id from the context-guard header" }),
|
|
180
413
|
pattern: Type.Optional(Type.String({ description: "JavaScript regex; return only matching lines" })),
|
|
@@ -182,9 +415,14 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
182
415
|
limit: Type.Optional(Type.Number({ description: "Most lines to return (matching lines when pattern is set)" })),
|
|
183
416
|
}),
|
|
184
417
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
|
|
418
|
+
const branch = ctx.sessionManager.getBranch() as never;
|
|
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 };
|
|
188
426
|
},
|
|
189
427
|
});
|
|
190
428
|
|
|
@@ -213,15 +451,18 @@ export default function contextGuard(pi: ExtensionAPI) {
|
|
|
213
451
|
function describe(config: GuardConfig, stats: GuardStats, problem: string | undefined): string {
|
|
214
452
|
const lines = [
|
|
215
453
|
`context-guard is ${config.enabled ? "on" : "off"}${problem && config.enabled ? ` (${problem})` : ""} · model ${config.model}`,
|
|
216
|
-
`Saving ~${formatTokens(stats.savedTokens)} tokens (${formatChars(stats.savedChars)} chars)
|
|
217
|
-
|
|
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.`] : []),
|
|
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.`,
|
|
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"}.`,
|
|
218
458
|
];
|
|
219
459
|
const last = stats.last;
|
|
220
460
|
if (last) {
|
|
221
|
-
|
|
461
|
+
const kinds = ["large", "small", "exchange", "image"].map((kind) => `${last.results.filter((r) => (r.kind ?? "large") === kind).length} ${kind}`).join(", ");
|
|
462
|
+
lines.push(`Last ${last.phase === "mid-run" ? "checkpoint" : "run"} (${(last.ms / 1000).toFixed(1)} s${last.timedOut ? ", timed out" : ""}; ${kinds}):`);
|
|
222
463
|
for (const result of last.results) {
|
|
223
464
|
const sizes = result.outcome === "kept" ? formatChars(result.beforeChars) : `${formatChars(result.beforeChars)} → ${formatChars(result.afterChars)}`;
|
|
224
|
-
lines.push(` ${result.outcome.padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
|
|
465
|
+
lines.push(` ${result.outcome.padEnd(9)} ${(result.kind ?? "large").padEnd(9)} ${sizes.padEnd(13)} ${result.label}${result.reason ? ` (${result.reason})` : ""}`);
|
|
225
466
|
for (const trace of result.jev ?? []) lines.push(` jev: ${trace}`);
|
|
226
467
|
}
|
|
227
468
|
}
|