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/recall.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* or limited to a line window, truncated like Pi's `read` tool
|
|
2
|
+
* What the `recall` tool returns: the original tool output (with its images), optionally filtered by a
|
|
3
|
+
* regex or limited to a line window, truncated like Pi's `read` tool; or an omitted exchange as a
|
|
4
|
+
* transcript (images in its user messages attached, tool-result images named by their entry id).
|
|
4
5
|
*/
|
|
5
6
|
import vm from "node:vm";
|
|
6
7
|
import { MARKER } from "./render.ts";
|
|
7
|
-
import {
|
|
8
|
+
import { isBranchSteering } from "./run.ts";
|
|
9
|
+
import { type Block, type SourceEntryLike, textOf } from "./types.ts";
|
|
8
10
|
|
|
9
11
|
export const RECALL_MAX_LINES = 2_000;
|
|
10
12
|
export const RECALL_MAX_BYTES = 50 * 1024;
|
|
@@ -15,6 +17,9 @@ export const RECALL_MATCH_CHARS = 4_000;
|
|
|
15
17
|
/** Wall-clock limit for filtering by `pattern` (ms); stops catastrophic backtracking. */
|
|
16
18
|
export const RECALL_PATTERN_TIMEOUT_MS = 1_000;
|
|
17
19
|
|
|
20
|
+
/** Most images one recall returns (an exchange's user messages can hold more; the transcript says so). */
|
|
21
|
+
export const RECALL_MAX_IMAGES = 6;
|
|
22
|
+
|
|
18
23
|
export interface RecallParams {
|
|
19
24
|
entryId: string;
|
|
20
25
|
pattern?: string;
|
|
@@ -22,10 +27,97 @@ export interface RecallParams {
|
|
|
22
27
|
limit?: number;
|
|
23
28
|
}
|
|
24
29
|
|
|
25
|
-
|
|
30
|
+
type RecallBranchEntry = { id?: string; type?: string; message?: { role?: string; content?: unknown; toolName?: string } & Record<string, unknown>; customType?: string; content?: unknown };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The original text behind a context-guard stub: a tool result's output, or, for the user prompt of an
|
|
34
|
+
* omitted exchange, the whole exchange from the branch (prompt, assistant texts, tool calls and results,
|
|
35
|
+
* custom messages, steering messages) up to the next prompt.
|
|
36
|
+
*/
|
|
37
|
+
export function originalOutput(entry: SourceEntryLike | undefined, entryId: string, branch: readonly RecallBranchEntry[] = [], omittedIds?: readonly string[]): string {
|
|
38
|
+
return originalContent(entry, entryId, branch, omittedIds).text;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const imagesOf = (content: unknown): Block[] => (Array.isArray(content) ? (content as Block[]).filter((b) => b?.type === "image") : []);
|
|
42
|
+
|
|
43
|
+
/** `originalOutput` plus the images that go with it: a tool result's images, or those of an exchange's user messages. */
|
|
44
|
+
export function originalContent(
|
|
45
|
+
entry: SourceEntryLike | undefined,
|
|
46
|
+
entryId: string,
|
|
47
|
+
branch: readonly RecallBranchEntry[] = [],
|
|
48
|
+
omittedIds?: readonly string[],
|
|
49
|
+
): { text: string; images: Block[] } {
|
|
26
50
|
const message = entry?.type === "message" ? entry.message : undefined;
|
|
27
|
-
if (message?.role
|
|
28
|
-
|
|
51
|
+
if (message?.role === "toolResult") return { text: textOf(message), images: imagesOf(message.content) };
|
|
52
|
+
if (message?.role === "user") {
|
|
53
|
+
const start = branch.findIndex((e) => e.id === entryId);
|
|
54
|
+
if (start >= 0) {
|
|
55
|
+
const images: Block[] = [];
|
|
56
|
+
const text = exchangeTranscript(branch, start, omittedIds, images);
|
|
57
|
+
return { text, images };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
throw new Error(`No tool result or omitted exchange with entry id ${entryId}. Use the id from a "${MARKER}" note.`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The entry ids an omission removed, from the latest `context-guard` record that omitted the exchange
|
|
65
|
+
* starting at `entryId` (its `omitted` list). Undefined when no record has one.
|
|
66
|
+
*/
|
|
67
|
+
export function omittedIdsFor(branch: readonly (RecallBranchEntry & { data?: unknown })[], entryId: string, customType: string): string[] | undefined {
|
|
68
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
69
|
+
const e = branch[i];
|
|
70
|
+
if (e.type !== "custom" || e.customType !== customType) continue;
|
|
71
|
+
const results = (e.data as { results?: { kind?: string; entryId?: string; outcome?: string; omitted?: unknown }[] } | undefined)?.results ?? [];
|
|
72
|
+
const hit = results.find((r) => r.kind === "exchange" && r.entryId === entryId && r.outcome === "removed" && Array.isArray(r.omitted));
|
|
73
|
+
if (hit) return hit.omitted as string[];
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Plain-text transcript of the exchange that starts at branch[start] (a user message): with
|
|
80
|
+
* `omittedIds`, exactly the prompt and those entries (what the omission removed); otherwise everything up
|
|
81
|
+
* to the next user message that is not a steering message. Images: a tool result's are named by its entry
|
|
82
|
+
* id (recall it to see them); a user message's are appended to `images` (up to RECALL_MAX_IMAGES).
|
|
83
|
+
*/
|
|
84
|
+
export function exchangeTranscript(branch: readonly RecallBranchEntry[], start: number, omittedIds?: readonly string[], images: Block[] = []): string {
|
|
85
|
+
const out: string[] = [];
|
|
86
|
+
const listed = omittedIds ? new Set(omittedIds) : undefined;
|
|
87
|
+
for (let i = start; i < branch.length; i++) {
|
|
88
|
+
const e = branch[i];
|
|
89
|
+
if (listed) {
|
|
90
|
+
if (i > start && !listed.has(e.id ?? "")) continue;
|
|
91
|
+
} else if (i > start && e.type === "message" && e.message?.role === "user" && !isBranchSteering(branch, i)) break;
|
|
92
|
+
if (e.type === "custom_message") {
|
|
93
|
+
out.push(`## custom message (${e.customType ?? "custom"})`, textOf({ role: "custom", content: (e.content ?? "") as string }));
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (e.type !== "message" || !e.message) continue;
|
|
97
|
+
const m = e.message;
|
|
98
|
+
const content = m.content as string | Block[];
|
|
99
|
+
const pictures = imagesOf(content);
|
|
100
|
+
if (m.role === "user") {
|
|
101
|
+
out.push("## user", textOf({ role: "user", content }));
|
|
102
|
+
if (pictures.length > 0) {
|
|
103
|
+
const room = Math.max(0, RECALL_MAX_IMAGES - images.length);
|
|
104
|
+
images.push(...pictures.slice(0, room));
|
|
105
|
+
const shown = Math.min(room, pictures.length);
|
|
106
|
+
out.push(shown === pictures.length ? `[${pictures.length} image(s), attached below]` : `[${pictures.length} image(s), ${shown} attached below]`);
|
|
107
|
+
}
|
|
108
|
+
} else if (m.role === "assistant") {
|
|
109
|
+
out.push("## assistant");
|
|
110
|
+
const text = textOf({ role: "assistant", content });
|
|
111
|
+
if (text.trim()) out.push(text);
|
|
112
|
+
if (Array.isArray(content)) {
|
|
113
|
+
for (const block of content) if (block.type === "toolCall") out.push(`[tool call] ${String(block.name)} ${JSON.stringify(block.arguments ?? {})}`);
|
|
114
|
+
}
|
|
115
|
+
} else if (m.role === "toolResult") {
|
|
116
|
+
out.push(`## tool result (${m.toolName ?? "tool"})`, textOf({ role: "toolResult", content }));
|
|
117
|
+
if (pictures.length > 0) out.push(`[${pictures.length} image(s): recall({"entryId":"${e.id}"}) shows them]`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return out.join("\n");
|
|
29
121
|
}
|
|
30
122
|
|
|
31
123
|
export function recallText(original: string, params: Omit<RecallParams, "entryId">): string {
|
package/src/render.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { Candidate } from "./run.ts";
|
|
|
8
8
|
|
|
9
9
|
/** Every replacement starts with this; it also marks results as already distilled. */
|
|
10
10
|
export const MARKER = "[context-guard]";
|
|
11
|
+
/** Name of our recall tool. Its outputs are never judged mid-run: the model just asked for them. */
|
|
12
|
+
export const RECALL_TOOL = "recall";
|
|
11
13
|
|
|
12
14
|
export const formatChars = (chars: number): string =>
|
|
13
15
|
chars >= 1_000_000 ? `${(chars / 1_000_000).toFixed(1)}M` : chars >= 1_000 ? `${(chars / 1_000).toFixed(1)}k` : String(chars);
|
package/src/run.ts
CHANGED
|
@@ -61,57 +61,168 @@ export const HISTORY_FIRST_REQUEST_LIMIT = 1_000;
|
|
|
61
61
|
/** Question text used when the prompt that started the run has no text (e.g. only an image). */
|
|
62
62
|
export const NO_TEXT_QUESTION = "[the user sent only an image]";
|
|
63
63
|
|
|
64
|
+
const hasUser = (entry: ProjectedEntryLike): boolean => entry.messages.some((m) => m.role === "user");
|
|
65
|
+
|
|
66
|
+
/** An assistant message that handed over to tools (not stopped by an abort or error). */
|
|
67
|
+
export const endsInToolCall = (message: { stopReason?: unknown; content?: unknown }): boolean =>
|
|
68
|
+
message.stopReason !== "aborted" && message.stopReason !== "error" && Array.isArray(message.content) && message.content.some((b) => b?.type === "toolCall");
|
|
69
|
+
|
|
70
|
+
/** Index of the entry holding the last user message (prompt or steering message), or -1. */
|
|
71
|
+
export function lastUserIndex(entries: readonly ProjectedEntryLike[], before = entries.length): number {
|
|
72
|
+
let index = Math.min(before, entries.length) - 1;
|
|
73
|
+
while (index >= 0 && !hasUser(entries[index])) index--;
|
|
74
|
+
return index;
|
|
75
|
+
}
|
|
76
|
+
|
|
64
77
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
78
|
+
* A user message sent while the agent was still working: the message before it (custom notices and
|
|
79
|
+
* omitted entries aside) is a tool result, or an assistant message with tool calls that did not stop on an
|
|
80
|
+
* abort or error. It continues the run. (A prompt typed after aborting a running tool looks the same and
|
|
81
|
+
* is treated as steering too: it usually redirects the same work.)
|
|
68
82
|
*/
|
|
69
|
-
export function
|
|
70
|
-
let
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
export function isSteering(entries: readonly ProjectedEntryLike[], index: number): boolean {
|
|
84
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
85
|
+
const message = entries[i].messages.at(-1);
|
|
86
|
+
if (!message || message.role === "custom") continue;
|
|
87
|
+
if (message.role === "toolResult") return true;
|
|
88
|
+
if (message.role === "assistant") return endsInToolCall(message);
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
73
93
|
|
|
94
|
+
type BranchMessageEntry = { type?: string; message?: { role?: string; stopReason?: unknown; content?: unknown } };
|
|
95
|
+
|
|
96
|
+
/** `isSteering` on raw branch entries: is the user message at branch[index] a steering message? */
|
|
97
|
+
export function isBranchSteering(branch: readonly BranchMessageEntry[], index: number): boolean {
|
|
98
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
99
|
+
const e = branch[i];
|
|
100
|
+
if (e.type !== "message" || !e.message) continue;
|
|
101
|
+
if (e.message.role === "toolResult") return true;
|
|
102
|
+
if (e.message.role === "assistant") return endsInToolCall(e.message);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Index of the entry holding the user message that starts the current run, or -1. Steering messages
|
|
110
|
+
* (sent during the run) do not start a run: the span goes back to the prompt the agent started from, so
|
|
111
|
+
* tool results from before a steering message are still judged (against the prompt and every steering
|
|
112
|
+
* message, see `runQuestion`).
|
|
113
|
+
*/
|
|
114
|
+
export function findRunStart(entries: readonly ProjectedEntryLike[]): number {
|
|
115
|
+
let start = lastUserIndex(entries);
|
|
116
|
+
while (start > 0 && isSteering(entries, start)) {
|
|
117
|
+
const previous = lastUserIndex(entries, start);
|
|
118
|
+
if (previous < 0) break;
|
|
119
|
+
start = previous;
|
|
120
|
+
}
|
|
121
|
+
return start;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The text of the user message at `start`. */
|
|
125
|
+
export function questionAt(entries: readonly ProjectedEntryLike[], start: number): string {
|
|
74
126
|
const questionMessage = entries[start].messages.findLast((m) => m.role === "user");
|
|
75
127
|
const questionText = questionMessage ? textOf(questionMessage).trim() : "";
|
|
76
|
-
|
|
77
|
-
|
|
128
|
+
return questionText ? textOf(questionMessage as MessageLike) : NO_TEXT_QUESTION;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export const STEERING_PREFIX = "[The user added during the run]";
|
|
132
|
+
|
|
133
|
+
/** The run's question: the prompt at `start`, then every steering message of the run in order. */
|
|
134
|
+
export function runQuestion(entries: readonly ProjectedEntryLike[], start: number): string {
|
|
135
|
+
const parts = [questionAt(entries, start)];
|
|
136
|
+
for (let i = start + 1; i < entries.length; i++) {
|
|
137
|
+
if (!hasUser(entries[i])) continue;
|
|
138
|
+
const text = textOf(entries[i].messages.findLast((m) => m.role === "user") as MessageLike).trim();
|
|
139
|
+
parts.push(`${STEERING_PREFIX} ${text || NO_TEXT_QUESTION}`);
|
|
140
|
+
}
|
|
141
|
+
return parts.join("\n\n");
|
|
142
|
+
}
|
|
78
143
|
|
|
79
|
-
|
|
80
|
-
|
|
144
|
+
export interface ToolCallInfo {
|
|
145
|
+
name: string;
|
|
146
|
+
args: Record<string, unknown> | undefined;
|
|
147
|
+
/** 0-based index of the assistant message (turn) in the run that made the call. */
|
|
148
|
+
turn: number;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Tool calls of a run span by id, and the assistant texts in order (with their turn). */
|
|
152
|
+
export function indexSpan(span: readonly ProjectedEntryLike[]): { toolCalls: Map<string, ToolCallInfo>; assistantTexts: { turn: number; text: string }[]; turns: number } {
|
|
153
|
+
const toolCalls = new Map<string, ToolCallInfo>();
|
|
154
|
+
const assistantTexts: { turn: number; text: string }[] = [];
|
|
155
|
+
let turn = -1;
|
|
81
156
|
for (const entry of span) {
|
|
82
157
|
for (const message of entry.messages) {
|
|
83
|
-
if (message.role !== "assistant"
|
|
158
|
+
if (message.role !== "assistant") continue;
|
|
159
|
+
turn++;
|
|
160
|
+
if (typeof message.content === "string") continue;
|
|
84
161
|
const text = textOf(message).trim();
|
|
85
|
-
if (text) assistantTexts.push(text);
|
|
162
|
+
if (text) assistantTexts.push({ turn, text });
|
|
86
163
|
for (const block of message.content) {
|
|
87
164
|
if (block.type === "toolCall" && typeof block.id === "string") {
|
|
88
165
|
const args = block.arguments && typeof block.arguments === "object" ? (block.arguments as Record<string, unknown>) : undefined;
|
|
89
|
-
toolCalls.set(block.id, { name: String(block.name ?? ""), args });
|
|
166
|
+
toolCalls.set(block.id, { name: String(block.name ?? ""), args, turn });
|
|
90
167
|
}
|
|
91
168
|
}
|
|
92
169
|
}
|
|
93
170
|
}
|
|
94
|
-
|
|
95
|
-
|
|
171
|
+
return { toolCalls, assistantTexts, turns: turn + 1 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The candidate rules (see docs/DESIGN.md → Candidates) for one projected entry, or undefined. */
|
|
175
|
+
export function candidateOf(
|
|
176
|
+
entry: ProjectedEntryLike,
|
|
177
|
+
toolCalls: ReadonlyMap<string, ToolCallInfo>,
|
|
178
|
+
options: CollectOptions,
|
|
179
|
+
): (Candidate & { toolCallId?: string; turn?: number }) | undefined {
|
|
180
|
+
const message = entry.messages[0];
|
|
181
|
+
if (entry.messages.length !== 1 || message?.role !== "toolResult") return undefined;
|
|
182
|
+
if ((message.isError && !options.distillErrors) || !isTextOnly(message)) return undefined;
|
|
183
|
+
const call = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
184
|
+
const toolName = message.toolName ?? call?.name ?? "tool";
|
|
185
|
+
if (options.excludeTools.includes(toolName)) return undefined;
|
|
186
|
+
const text = textOf(message);
|
|
187
|
+
if (text.length < options.minResultChars || text.startsWith(MARKER)) return undefined;
|
|
188
|
+
// Another extension already edited this result: recall returns the raw entry, so whatever
|
|
189
|
+
// that edit added could not be recovered after distillation. Leave it alone.
|
|
190
|
+
const raw = entry.sourceEntry.message;
|
|
191
|
+
if (raw && textOf(raw) !== text) return undefined;
|
|
192
|
+
return {
|
|
193
|
+
entryId: entry.sourceEntry.id,
|
|
194
|
+
toolName,
|
|
195
|
+
args: call?.args,
|
|
196
|
+
text,
|
|
197
|
+
...(message.isError ? { isError: true } : {}),
|
|
198
|
+
toolCallId: message.toolCallId,
|
|
199
|
+
turn: call?.turn,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The run starts after the user message that started the agent (see `findRunStart`: steering messages
|
|
205
|
+
* sent during the run belong to it).
|
|
206
|
+
*
|
|
207
|
+
* `skip`: entry ids not to collect.
|
|
208
|
+
*/
|
|
209
|
+
export function collectRun(entries: readonly ProjectedEntryLike[], options: CollectOptions, skip?: ReadonlySet<string>): RunInfo | undefined {
|
|
210
|
+
const start = findRunStart(entries);
|
|
211
|
+
if (start < 0) return undefined;
|
|
212
|
+
const question = runQuestion(entries, start);
|
|
213
|
+
const span = entries.slice(start + 1);
|
|
214
|
+
const { toolCalls, assistantTexts } = indexSpan(span);
|
|
215
|
+
const answer = assistantTexts.at(-1)?.text ?? "";
|
|
216
|
+
const notes = assistantTexts.slice(0, -1).map((t) => t.text).join("\n\n");
|
|
96
217
|
|
|
97
|
-
const exclude = new Set(options.excludeTools);
|
|
98
218
|
const candidates: Candidate[] = [];
|
|
99
219
|
let toolResults = 0;
|
|
100
220
|
for (const entry of span) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const toolName = message.toolName ?? call?.name ?? "tool";
|
|
107
|
-
if (exclude.has(toolName)) continue;
|
|
108
|
-
const text = textOf(message);
|
|
109
|
-
if (text.length < options.minResultChars || text.startsWith(MARKER)) continue;
|
|
110
|
-
// Another extension already edited this result: recall returns the raw entry, so whatever
|
|
111
|
-
// that edit added could not be recovered after distillation. Leave it alone.
|
|
112
|
-
const raw = entry.sourceEntry.message;
|
|
113
|
-
if (raw && textOf(raw) !== text) continue;
|
|
114
|
-
candidates.push({ entryId: entry.sourceEntry.id, toolName, args: call?.args, text, ...(message.isError ? { isError: true } : {}) });
|
|
221
|
+
if (entry.messages.length === 1 && entry.messages[0]?.role === "toolResult") toolResults++;
|
|
222
|
+
const candidate = candidateOf(entry, toolCalls, options);
|
|
223
|
+
if (!candidate || skip?.has(candidate.entryId)) continue;
|
|
224
|
+
const { toolCallId: _id, turn: _turn, ...plain } = candidate;
|
|
225
|
+
candidates.push(plain);
|
|
115
226
|
}
|
|
116
227
|
const history = collectHistory(entries.slice(0, start), options.historyExchanges);
|
|
117
228
|
return { question, answer, notes, candidates, toolResults, history };
|
|
@@ -125,7 +236,7 @@ export const hasHistory = (history: RunHistory | undefined): history is RunHisto
|
|
|
125
236
|
* Tool calls and tool results are left out; every user message (steering included) starts a
|
|
126
237
|
* new exchange, whose assistant part is the last assistant text before the next user message.
|
|
127
238
|
*/
|
|
128
|
-
function collectHistory(before: readonly ProjectedEntryLike[], count: number): RunHistory {
|
|
239
|
+
export function collectHistory(before: readonly ProjectedEntryLike[], count: number): RunHistory {
|
|
129
240
|
if (count <= 0) return { exchanges: [] };
|
|
130
241
|
let summary: string | undefined;
|
|
131
242
|
const all: { user: string; assistant: string; hasText: boolean }[] = [];
|
package/src/size.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Size estimates for model context, in "text characters": the unit tool output is measured in, so the
|
|
3
|
+
* break-even rule (checkpoint.ts → paysOff) compares like with like.
|
|
4
|
+
*
|
|
5
|
+
* Blocks that are not plain text must not be counted by their serialized length:
|
|
6
|
+
* - images are base64 (a 1280×960 JPEG is ~110k characters) but cost about w×h/750 tokens on Anthropic
|
|
7
|
+
* (measured on a real Opus session: 1280×960 → ~1.6k tokens), so they are counted by their pixels, at
|
|
8
|
+
* 4 characters per token (Pi's own chars/4 estimate, so the footer shows image savings as tokens);
|
|
9
|
+
* - thinking blocks carry an encrypted signature that grows with the hidden reasoning; it is counted at
|
|
10
|
+
* half its length (measured: ~0.25 tokens per signature character vs ~0.4 per character of tool text).
|
|
11
|
+
*/
|
|
12
|
+
import { Buffer } from "node:buffer";
|
|
13
|
+
import type { Block } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Text characters per image token: Pi's chars/4 estimate (tool output itself runs at ~2.5–3). */
|
|
16
|
+
export const IMAGE_CHARS_PER_TOKEN = 4;
|
|
17
|
+
/** Anthropic's image token estimate: width × height / 750. */
|
|
18
|
+
export const IMAGE_PIXELS_PER_TOKEN = 750;
|
|
19
|
+
/** Tokens assumed for an image whose size cannot be read (about a 1.15-megapixel image). */
|
|
20
|
+
export const DEFAULT_IMAGE_TOKENS = 1_600;
|
|
21
|
+
/** Upper bound for one image (a 2576-pixel long edge at 4:3). */
|
|
22
|
+
export const MAX_IMAGE_TOKENS = 6_700;
|
|
23
|
+
/** Base64 characters decoded to find the image header (JPEG headers can sit behind EXIF data). */
|
|
24
|
+
const HEADER_BASE64_CHARS = 65_536;
|
|
25
|
+
|
|
26
|
+
/** Width and height from the header of a PNG, JPEG, GIF or WebP image, or undefined. */
|
|
27
|
+
export function imageSize(bytes: Uint8Array): { width: number; height: number } | undefined {
|
|
28
|
+
const b = bytes;
|
|
29
|
+
const u16be = (i: number) => (b[i] << 8) | b[i + 1];
|
|
30
|
+
const u16le = (i: number) => b[i] | (b[i + 1] << 8);
|
|
31
|
+
const u32be = (i: number) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3];
|
|
32
|
+
if (b.length >= 24 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return { width: u32be(16), height: u32be(20) };
|
|
33
|
+
if (b.length >= 10 && b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46) return { width: u16le(6), height: u16le(8) };
|
|
34
|
+
if (b.length >= 30 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
|
|
35
|
+
const chunk = String.fromCharCode(b[12], b[13], b[14], b[15]);
|
|
36
|
+
if (chunk === "VP8X") return { width: 1 + (b[24] | (b[25] << 8) | (b[26] << 16)), height: 1 + (b[27] | (b[28] << 8) | (b[29] << 16)) };
|
|
37
|
+
if (chunk === "VP8L") return { width: 1 + (u16le(21) & 0x3fff), height: 1 + (((b[22] >> 6) | (b[23] << 2) | (b[24] << 10)) & 0x3fff) };
|
|
38
|
+
if (chunk === "VP8 ") return { width: u16le(26) & 0x3fff, height: u16le(28) & 0x3fff };
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) {
|
|
42
|
+
let i = 2;
|
|
43
|
+
while (i + 9 < b.length) {
|
|
44
|
+
if (b[i] !== 0xff) {
|
|
45
|
+
i++;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const marker = b[i + 1];
|
|
49
|
+
if (marker === 0xff) {
|
|
50
|
+
i++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
// SOF0–SOF15 except DHT (C4), JPG (C8) and DAC (CC).
|
|
54
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) return { width: u16be(i + 7), height: u16be(i + 5) };
|
|
55
|
+
if (marker === 0xd8 || marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
56
|
+
i += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
i += 2 + u16be(i + 2);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ImageInfo {
|
|
66
|
+
width?: number;
|
|
67
|
+
height?: number;
|
|
68
|
+
/** Estimated input tokens. */
|
|
69
|
+
tokens: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const imageInfoCache = new WeakMap<object, ImageInfo>();
|
|
73
|
+
|
|
74
|
+
/** Size (when the header is readable) and estimated input tokens of an image block (`{ type: "image", data: <base64> }`). */
|
|
75
|
+
export function imageInfo(block: Block): ImageInfo {
|
|
76
|
+
const cached = imageInfoCache.get(block);
|
|
77
|
+
if (cached) return cached;
|
|
78
|
+
let info: ImageInfo = { tokens: DEFAULT_IMAGE_TOKENS };
|
|
79
|
+
if (typeof block.data === "string") {
|
|
80
|
+
const head = block.data.slice(0, HEADER_BASE64_CHARS);
|
|
81
|
+
const size = imageSize(Buffer.from(head.slice(0, head.length - (head.length % 4)), "base64"));
|
|
82
|
+
if (size && size.width > 0 && size.height > 0) {
|
|
83
|
+
info = { ...size, tokens: Math.min(MAX_IMAGE_TOKENS, Math.max(1, Math.ceil((size.width * size.height) / IMAGE_PIXELS_PER_TOKEN))) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
imageInfoCache.set(block, info);
|
|
87
|
+
return info;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Estimated input tokens of an image block. */
|
|
91
|
+
export const imageTokens = (block: Block): number => imageInfo(block).tokens;
|
|
92
|
+
|
|
93
|
+
/** Estimated size of one content block in text characters. */
|
|
94
|
+
export function blockChars(block: Block): number {
|
|
95
|
+
switch (block.type) {
|
|
96
|
+
case "text":
|
|
97
|
+
return typeof block.text === "string" ? block.text.length : 0;
|
|
98
|
+
case "image":
|
|
99
|
+
return imageTokens(block) * IMAGE_CHARS_PER_TOKEN;
|
|
100
|
+
case "thinking": {
|
|
101
|
+
const thinking = typeof block.thinking === "string" ? block.thinking.length : 0;
|
|
102
|
+
const signature = typeof block.thinkingSignature === "string" ? block.thinkingSignature.length : 0;
|
|
103
|
+
return thinking + Math.ceil(signature / 2);
|
|
104
|
+
}
|
|
105
|
+
case "toolCall":
|
|
106
|
+
return String(block.name ?? "").length + JSON.stringify(block.arguments ?? {}).length;
|
|
107
|
+
default:
|
|
108
|
+
return JSON.stringify(block).length;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Estimated size of message content in text characters. */
|
|
113
|
+
export const contentChars = (content: string | readonly Block[] | undefined): number =>
|
|
114
|
+
typeof content === "string" ? content.length : (content ?? []).reduce((n, block) => n + blockChars(block), 0);
|
package/src/stats.ts
CHANGED
|
@@ -3,17 +3,25 @@
|
|
|
3
3
|
* `/tree` navigation, reloads and compaction:
|
|
4
4
|
*
|
|
5
5
|
* - active savings: for every tool result in the current model context whose projected text
|
|
6
|
-
* is one of our replacements, original
|
|
7
|
-
* -
|
|
6
|
+
* is one of our replacements, original size minus replacement size (size.ts: images by pixels);
|
|
7
|
+
* - lifetime savings: summed from our `context-guard` records on the active branch. Compaction drops
|
|
8
|
+
* earlier edits from the context, so after one the footer shows both (`🛡 0 · Σ−79k`);
|
|
9
|
+
* - Jev usage: summed from the same records.
|
|
8
10
|
*/
|
|
11
|
+
import { imagesRemoved } from "./images.ts";
|
|
9
12
|
import { formatChars, MARKER } from "./render.ts";
|
|
10
|
-
import {
|
|
13
|
+
import { EXCHANGE_STUB_PREFIX } from "./items.ts";
|
|
14
|
+
import { isBranchSteering } from "./run.ts";
|
|
15
|
+
import { contentChars } from "./size.ts";
|
|
16
|
+
import { type MessageLike, type ProjectedEntryLike, textOf } from "./types.ts";
|
|
11
17
|
|
|
12
18
|
export const CUSTOM_TYPE = "context-guard";
|
|
13
19
|
|
|
14
20
|
/** Data stored in each `context-guard` custom entry (one per distilled run). */
|
|
15
21
|
export interface RunRecordData {
|
|
16
22
|
v: 1;
|
|
23
|
+
/** "mid-run" for checkpoints; a record without it counts as run end. */
|
|
24
|
+
phase?: "mid-run" | "run-end";
|
|
17
25
|
model: string;
|
|
18
26
|
savedChars: number;
|
|
19
27
|
requests: number;
|
|
@@ -22,6 +30,10 @@ export interface RunRecordData {
|
|
|
22
30
|
ms: number;
|
|
23
31
|
timedOut: boolean;
|
|
24
32
|
results: {
|
|
33
|
+
/** Absent in records that only had large outputs. */
|
|
34
|
+
kind?: "large" | "small" | "exchange" | "image";
|
|
35
|
+
/** Omitted exchanges: the entry ids that got `replacement: null`. */
|
|
36
|
+
omitted?: string[];
|
|
25
37
|
entryId: string;
|
|
26
38
|
tool: string;
|
|
27
39
|
label: string;
|
|
@@ -35,13 +47,24 @@ export interface RunRecordData {
|
|
|
35
47
|
}
|
|
36
48
|
|
|
37
49
|
export interface GuardStats {
|
|
38
|
-
/** Tool results currently distilled in model context. */
|
|
50
|
+
/** Tool results currently distilled or omitted in model context (large and small outputs). */
|
|
39
51
|
distilledResults: number;
|
|
52
|
+
/** Tool results whose images are currently removed from model context. */
|
|
53
|
+
imageResults: number;
|
|
54
|
+
/** Old exchanges currently omitted from model context. */
|
|
55
|
+
omittedExchanges: number;
|
|
40
56
|
/** Characters currently kept out of model context. */
|
|
41
57
|
savedChars: number;
|
|
42
58
|
/** Rough token equivalent (Pi's own estimate: chars / 4). */
|
|
43
59
|
savedTokens: number;
|
|
60
|
+
/** Characters saved by every record on the branch, including edits a compaction has since dropped. */
|
|
61
|
+
lifetimeChars: number;
|
|
62
|
+
lifetimeTokens: number;
|
|
63
|
+
/** A compaction came after a record that saved something: `lifetime` is worth showing. */
|
|
64
|
+
compacted: boolean;
|
|
44
65
|
runs: number;
|
|
66
|
+
/** Mid-run checkpoints among `runs`. */
|
|
67
|
+
checkpoints: number;
|
|
45
68
|
requests: number;
|
|
46
69
|
costUsd: number;
|
|
47
70
|
last?: RunRecordData;
|
|
@@ -49,29 +72,65 @@ export interface GuardStats {
|
|
|
49
72
|
|
|
50
73
|
export const estimateTokens = (chars: number): number => Math.round(chars / 4);
|
|
51
74
|
|
|
52
|
-
|
|
53
|
-
|
|
75
|
+
type StatsBranchEntry = { type?: string; id?: string; customType?: string; data?: unknown; message?: MessageLike };
|
|
76
|
+
|
|
77
|
+
const messageChars = (message: MessageLike): number => contentChars(message.content);
|
|
78
|
+
|
|
79
|
+
export function computeStats(projection: readonly ProjectedEntryLike[], branch: readonly StatsBranchEntry[]): GuardStats {
|
|
80
|
+
const stats: GuardStats = {
|
|
81
|
+
distilledResults: 0,
|
|
82
|
+
imageResults: 0,
|
|
83
|
+
omittedExchanges: 0,
|
|
84
|
+
savedChars: 0,
|
|
85
|
+
savedTokens: 0,
|
|
86
|
+
lifetimeChars: 0,
|
|
87
|
+
lifetimeTokens: 0,
|
|
88
|
+
compacted: false,
|
|
89
|
+
runs: 0,
|
|
90
|
+
checkpoints: 0,
|
|
91
|
+
requests: 0,
|
|
92
|
+
costUsd: 0,
|
|
93
|
+
};
|
|
94
|
+
// Pi keeps omitted entries in the projection with `messages: []`: count what the model sees.
|
|
95
|
+
const visibleMessages = new Map(projection.map((entry) => [entry.sourceEntry.id, entry.messages.length]));
|
|
54
96
|
for (const entry of projection) {
|
|
55
97
|
const projected = entry.messages[0];
|
|
56
98
|
const raw = entry.sourceEntry.message;
|
|
99
|
+
if (entry.messages.length === 1 && projected?.role === "user" && raw && textOf(projected).startsWith(EXCHANGE_STUB_PREFIX) && !textOf(raw).startsWith(MARKER)) {
|
|
100
|
+
// An omitted exchange: its raw entries up to the next user message that the model no longer sees.
|
|
101
|
+
const start = branch.findIndex((e) => e.id === entry.sourceEntry.id);
|
|
102
|
+
let before = messageChars(raw);
|
|
103
|
+
for (let i = start + 1; start >= 0 && i < branch.length; i++) {
|
|
104
|
+
const e = branch[i];
|
|
105
|
+
if (e.type === "message" && e.message?.role === "user" && !isBranchSteering(branch, i)) break;
|
|
106
|
+
if (e.message && e.id && (visibleMessages.get(e.id) ?? 0) === 0) before += messageChars(e.message);
|
|
107
|
+
}
|
|
108
|
+
stats.omittedExchanges++;
|
|
109
|
+
stats.savedChars += Math.max(0, before - messageChars(projected));
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
57
112
|
if (entry.messages.length !== 1 || projected?.role !== "toolResult" || !raw) continue;
|
|
58
|
-
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
stats.distilledResults++;
|
|
63
|
-
stats.savedChars += Math.max(0, before.length - now.length);
|
|
113
|
+
if (!textOf(projected).startsWith(MARKER) || textOf(raw).startsWith(MARKER)) continue;
|
|
114
|
+
if (imagesRemoved(projected, raw)) stats.imageResults++;
|
|
115
|
+
else stats.distilledResults++;
|
|
116
|
+
stats.savedChars += Math.max(0, messageChars(raw) - messageChars(projected));
|
|
64
117
|
}
|
|
65
118
|
stats.savedTokens = estimateTokens(stats.savedChars);
|
|
119
|
+
let saving = false;
|
|
66
120
|
for (const entry of branch) {
|
|
121
|
+
if (entry.type === "compaction" && saving) stats.compacted = true;
|
|
67
122
|
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
|
|
68
123
|
const data = entry.data as Partial<RunRecordData> | undefined;
|
|
69
124
|
if (!data || data.v !== 1) continue;
|
|
70
125
|
stats.runs++;
|
|
126
|
+
if (data.phase === "mid-run") stats.checkpoints++;
|
|
71
127
|
stats.requests += data.requests ?? 0;
|
|
72
128
|
stats.costUsd += data.costUsd ?? 0;
|
|
129
|
+
stats.lifetimeChars += Math.max(0, data.savedChars ?? 0);
|
|
130
|
+
if ((data.savedChars ?? 0) > 0) saving = true;
|
|
73
131
|
stats.last = data as RunRecordData;
|
|
74
132
|
}
|
|
133
|
+
stats.lifetimeTokens = estimateTokens(stats.lifetimeChars);
|
|
75
134
|
return stats;
|
|
76
135
|
}
|
|
77
136
|
|
|
@@ -88,9 +147,12 @@ export function formatStatus(stats: GuardStats, state: StatusState, color: Color
|
|
|
88
147
|
if (state === "off") return color("dim", `${icon} guard off`);
|
|
89
148
|
if (state === "busy") return color("accent", `${icon} distilling…`);
|
|
90
149
|
if (state === "problem") return color("warning", `${icon} ${problem ?? "guard unavailable"}`);
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return `${color("
|
|
150
|
+
// After a compaction dropped earlier edits: "· Σ−79k", every saving of the session so far.
|
|
151
|
+
const lifetime = stats.compacted && stats.lifetimeTokens > stats.savedTokens ? color("dim", ` · Σ−${formatTokens(stats.lifetimeTokens)}`) : "";
|
|
152
|
+
if (stats.savedTokens === 0) return `${color("dim", `${icon} ${lifetime ? "0" : "0 saved"}`)}${lifetime}`;
|
|
153
|
+
const count = stats.distilledResults + stats.imageResults + stats.omittedExchanges;
|
|
154
|
+
// "🛡 −4.2k · 1": ≈ tokens kept out of context · edited items.
|
|
155
|
+
return `${color("success", `${icon} −${formatTokens(stats.savedTokens)}`)}${color("dim", ` · ${count}`)}${lifetime}`;
|
|
94
156
|
}
|
|
95
157
|
|
|
96
158
|
export const formatCost = (usd: number): string => (usd === 0 ? "$0" : usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`);
|
package/src/types.ts
CHANGED
|
@@ -16,6 +16,8 @@ export interface MessageLike {
|
|
|
16
16
|
toolName?: string;
|
|
17
17
|
toolCallId?: string;
|
|
18
18
|
isError?: boolean;
|
|
19
|
+
/** Assistant messages: why the model stopped ("toolUse", "stop", "aborted", "error", …). */
|
|
20
|
+
stopReason?: string;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
export interface SourceEntryLike {
|
|
@@ -63,6 +65,13 @@ export interface ContextEditDraft {
|
|
|
63
65
|
replacement: { content: { type: "text"; text: string }[] };
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** A context edit that omits its target from model context (`replacement: null`). */
|
|
69
|
+
export interface OmitDraft {
|
|
70
|
+
type: "context_edit";
|
|
71
|
+
targetId: string;
|
|
72
|
+
replacement: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
66
75
|
/** Draft accepted by `agent_before_settle` (Pi's `CustomEntryDraft`). */
|
|
67
76
|
export interface CustomDraft {
|
|
68
77
|
type: "custom";
|