pi-context-guard-jev 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +198 -55
- package/docs/CACHE.md +135 -3
- package/docs/DESIGN.md +218 -30
- package/docs/JEV.md +95 -27
- package/docs/TESTING.md +95 -25
- package/package.json +2 -2
- package/src/cache-pin.ts +170 -0
- package/src/checkpoint.ts +436 -0
- package/src/config.ts +56 -2
- package/src/decide.ts +74 -0
- package/src/distill.ts +9 -2
- package/src/index.ts +243 -55
- package/src/items.ts +447 -0
- package/src/process.ts +176 -0
- package/src/recall.ts +63 -4
- package/src/run.ts +78 -27
- package/src/stats.ts +35 -5
- package/src/types.ts +7 -0
package/src/recall.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import vm from "node:vm";
|
|
6
6
|
import { MARKER } from "./render.ts";
|
|
7
|
-
import { type SourceEntryLike, textOf } from "./types.ts";
|
|
7
|
+
import { type Block, type SourceEntryLike, textOf } from "./types.ts";
|
|
8
8
|
|
|
9
9
|
export const RECALL_MAX_LINES = 2_000;
|
|
10
10
|
export const RECALL_MAX_BYTES = 50 * 1024;
|
|
@@ -22,10 +22,69 @@ export interface RecallParams {
|
|
|
22
22
|
limit?: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
type RecallBranchEntry = { id?: string; type?: string; message?: { role?: string; content?: unknown; toolName?: string } & Record<string, unknown>; customType?: string; content?: unknown };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The original text behind a context-guard stub: a tool result's output, or, for the user prompt of an
|
|
29
|
+
* omitted exchange, the whole exchange from the branch (prompt, assistant texts, tool calls and results,
|
|
30
|
+
* custom messages) up to the next user message.
|
|
31
|
+
*/
|
|
32
|
+
export function originalOutput(entry: SourceEntryLike | undefined, entryId: string, branch: readonly RecallBranchEntry[] = [], omittedIds?: readonly string[]): string {
|
|
26
33
|
const message = entry?.type === "message" ? entry.message : undefined;
|
|
27
|
-
if (message?.role
|
|
28
|
-
|
|
34
|
+
if (message?.role === "toolResult") return textOf(message);
|
|
35
|
+
if (message?.role === "user") {
|
|
36
|
+
const start = branch.findIndex((e) => e.id === entryId);
|
|
37
|
+
if (start >= 0) return exchangeTranscript(branch, start, omittedIds);
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`No tool result or omitted exchange with entry id ${entryId}. Use the id from a "${MARKER}" note.`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The entry ids an omission removed, from the latest `context-guard` record that omitted the exchange
|
|
44
|
+
* starting at `entryId` (its `omitted` list). Undefined when no record has one.
|
|
45
|
+
*/
|
|
46
|
+
export function omittedIdsFor(branch: readonly (RecallBranchEntry & { data?: unknown })[], entryId: string, customType: string): string[] | undefined {
|
|
47
|
+
for (let i = branch.length - 1; i >= 0; i--) {
|
|
48
|
+
const e = branch[i];
|
|
49
|
+
if (e.type !== "custom" || e.customType !== customType) continue;
|
|
50
|
+
const results = (e.data as { results?: { kind?: string; entryId?: string; outcome?: string; omitted?: unknown }[] } | undefined)?.results ?? [];
|
|
51
|
+
const hit = results.find((r) => r.kind === "exchange" && r.entryId === entryId && r.outcome === "removed" && Array.isArray(r.omitted));
|
|
52
|
+
if (hit) return hit.omitted as string[];
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Plain-text transcript of the exchange that starts at branch[start] (a user message): with
|
|
59
|
+
* `omittedIds`, exactly the prompt and those entries (what the omission removed); otherwise everything up
|
|
60
|
+
* to the next user message.
|
|
61
|
+
*/
|
|
62
|
+
export function exchangeTranscript(branch: readonly RecallBranchEntry[], start: number, omittedIds?: readonly string[]): string {
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
const listed = omittedIds ? new Set(omittedIds) : undefined;
|
|
65
|
+
for (let i = start; i < branch.length; i++) {
|
|
66
|
+
const e = branch[i];
|
|
67
|
+
if (listed) {
|
|
68
|
+
if (i > start && !listed.has(e.id ?? "")) continue;
|
|
69
|
+
} else if (i > start && e.type === "message" && e.message?.role === "user") break;
|
|
70
|
+
if (e.type === "custom_message") {
|
|
71
|
+
out.push(`## custom message (${e.customType ?? "custom"})`, textOf({ role: "custom", content: (e.content ?? "") as string }));
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (e.type !== "message" || !e.message) continue;
|
|
75
|
+
const m = e.message;
|
|
76
|
+
const content = m.content as string | Block[];
|
|
77
|
+
if (m.role === "user") out.push("## user", textOf({ role: "user", content }));
|
|
78
|
+
else if (m.role === "assistant") {
|
|
79
|
+
out.push("## assistant");
|
|
80
|
+
const text = textOf({ role: "assistant", content });
|
|
81
|
+
if (text.trim()) out.push(text);
|
|
82
|
+
if (Array.isArray(content)) {
|
|
83
|
+
for (const block of content) if (block.type === "toolCall") out.push(`[tool call] ${String(block.name)} ${JSON.stringify(block.arguments ?? {})}`);
|
|
84
|
+
}
|
|
85
|
+
} else if (m.role === "toolResult") out.push(`## tool result (${m.toolName ?? "tool"})`, textOf({ role: "toolResult", content }));
|
|
86
|
+
}
|
|
87
|
+
return out.join("\n");
|
|
29
88
|
}
|
|
30
89
|
|
|
31
90
|
export function recallText(original: string, params: Omit<RecallParams, "entryId">): string {
|
package/src/run.ts
CHANGED
|
@@ -66,52 +66,103 @@ export const NO_TEXT_QUESTION = "[the user sent only an image]";
|
|
|
66
66
|
* during a run are user messages too, so they start a new span; tool results before them are
|
|
67
67
|
* left for later (they stay unpruned, which is the safe direction).
|
|
68
68
|
*/
|
|
69
|
-
|
|
69
|
+
/** Index of the entry holding the user message that starts the current run, or -1. */
|
|
70
|
+
export function findRunStart(entries: readonly ProjectedEntryLike[]): number {
|
|
70
71
|
let start = entries.length - 1;
|
|
71
72
|
while (start >= 0 && !entries[start].messages.some((m) => m.role === "user")) start--;
|
|
72
|
-
|
|
73
|
+
return start;
|
|
74
|
+
}
|
|
73
75
|
|
|
76
|
+
/** The run's question: the text of the user message at `start`. */
|
|
77
|
+
export function questionAt(entries: readonly ProjectedEntryLike[], start: number): string {
|
|
74
78
|
const questionMessage = entries[start].messages.findLast((m) => m.role === "user");
|
|
75
79
|
const questionText = questionMessage ? textOf(questionMessage).trim() : "";
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
return questionText ? textOf(questionMessage as MessageLike) : NO_TEXT_QUESTION;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface ToolCallInfo {
|
|
84
|
+
name: string;
|
|
85
|
+
args: Record<string, unknown> | undefined;
|
|
86
|
+
/** 0-based index of the assistant message (turn) in the run that made the call. */
|
|
87
|
+
turn: number;
|
|
88
|
+
}
|
|
78
89
|
|
|
79
|
-
|
|
80
|
-
|
|
90
|
+
/** Tool calls of a run span by id, and the assistant texts in order (with their turn). */
|
|
91
|
+
export function indexSpan(span: readonly ProjectedEntryLike[]): { toolCalls: Map<string, ToolCallInfo>; assistantTexts: { turn: number; text: string }[]; turns: number } {
|
|
92
|
+
const toolCalls = new Map<string, ToolCallInfo>();
|
|
93
|
+
const assistantTexts: { turn: number; text: string }[] = [];
|
|
94
|
+
let turn = -1;
|
|
81
95
|
for (const entry of span) {
|
|
82
96
|
for (const message of entry.messages) {
|
|
83
|
-
if (message.role !== "assistant"
|
|
97
|
+
if (message.role !== "assistant") continue;
|
|
98
|
+
turn++;
|
|
99
|
+
if (typeof message.content === "string") continue;
|
|
84
100
|
const text = textOf(message).trim();
|
|
85
|
-
if (text) assistantTexts.push(text);
|
|
101
|
+
if (text) assistantTexts.push({ turn, text });
|
|
86
102
|
for (const block of message.content) {
|
|
87
103
|
if (block.type === "toolCall" && typeof block.id === "string") {
|
|
88
104
|
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 });
|
|
105
|
+
toolCalls.set(block.id, { name: String(block.name ?? ""), args, turn });
|
|
90
106
|
}
|
|
91
107
|
}
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
|
-
|
|
95
|
-
|
|
110
|
+
return { toolCalls, assistantTexts, turns: turn + 1 };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The candidate rules (see docs/DESIGN.md → Candidates) for one projected entry, or undefined. */
|
|
114
|
+
export function candidateOf(
|
|
115
|
+
entry: ProjectedEntryLike,
|
|
116
|
+
toolCalls: ReadonlyMap<string, ToolCallInfo>,
|
|
117
|
+
options: CollectOptions,
|
|
118
|
+
): (Candidate & { toolCallId?: string; turn?: number }) | undefined {
|
|
119
|
+
const message = entry.messages[0];
|
|
120
|
+
if (entry.messages.length !== 1 || message?.role !== "toolResult") return undefined;
|
|
121
|
+
if ((message.isError && !options.distillErrors) || !isTextOnly(message)) return undefined;
|
|
122
|
+
const call = message.toolCallId ? toolCalls.get(message.toolCallId) : undefined;
|
|
123
|
+
const toolName = message.toolName ?? call?.name ?? "tool";
|
|
124
|
+
if (options.excludeTools.includes(toolName)) return undefined;
|
|
125
|
+
const text = textOf(message);
|
|
126
|
+
if (text.length < options.minResultChars || text.startsWith(MARKER)) return undefined;
|
|
127
|
+
// Another extension already edited this result: recall returns the raw entry, so whatever
|
|
128
|
+
// that edit added could not be recovered after distillation. Leave it alone.
|
|
129
|
+
const raw = entry.sourceEntry.message;
|
|
130
|
+
if (raw && textOf(raw) !== text) return undefined;
|
|
131
|
+
return {
|
|
132
|
+
entryId: entry.sourceEntry.id,
|
|
133
|
+
toolName,
|
|
134
|
+
args: call?.args,
|
|
135
|
+
text,
|
|
136
|
+
...(message.isError ? { isError: true } : {}),
|
|
137
|
+
toolCallId: message.toolCallId,
|
|
138
|
+
turn: call?.turn,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The run starts after the last user message, with or without text. Steering messages sent
|
|
144
|
+
* during a run are user messages too, so they start a new span; tool results before them are
|
|
145
|
+
* left for later (they stay unpruned, which is the safe direction).
|
|
146
|
+
*
|
|
147
|
+
* `skip`: entry ids not to collect.
|
|
148
|
+
*/
|
|
149
|
+
export function collectRun(entries: readonly ProjectedEntryLike[], options: CollectOptions, skip?: ReadonlySet<string>): RunInfo | undefined {
|
|
150
|
+
const start = findRunStart(entries);
|
|
151
|
+
if (start < 0) return undefined;
|
|
152
|
+
const question = questionAt(entries, start);
|
|
153
|
+
const span = entries.slice(start + 1);
|
|
154
|
+
const { toolCalls, assistantTexts } = indexSpan(span);
|
|
155
|
+
const answer = assistantTexts.at(-1)?.text ?? "";
|
|
156
|
+
const notes = assistantTexts.slice(0, -1).map((t) => t.text).join("\n\n");
|
|
96
157
|
|
|
97
|
-
const exclude = new Set(options.excludeTools);
|
|
98
158
|
const candidates: Candidate[] = [];
|
|
99
159
|
let toolResults = 0;
|
|
100
160
|
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 } : {}) });
|
|
161
|
+
if (entry.messages.length === 1 && entry.messages[0]?.role === "toolResult") toolResults++;
|
|
162
|
+
const candidate = candidateOf(entry, toolCalls, options);
|
|
163
|
+
if (!candidate || skip?.has(candidate.entryId)) continue;
|
|
164
|
+
const { toolCallId: _id, turn: _turn, ...plain } = candidate;
|
|
165
|
+
candidates.push(plain);
|
|
115
166
|
}
|
|
116
167
|
const history = collectHistory(entries.slice(0, start), options.historyExchanges);
|
|
117
168
|
return { question, answer, notes, candidates, toolResults, history };
|
|
@@ -125,7 +176,7 @@ export const hasHistory = (history: RunHistory | undefined): history is RunHisto
|
|
|
125
176
|
* Tool calls and tool results are left out; every user message (steering included) starts a
|
|
126
177
|
* new exchange, whose assistant part is the last assistant text before the next user message.
|
|
127
178
|
*/
|
|
128
|
-
function collectHistory(before: readonly ProjectedEntryLike[], count: number): RunHistory {
|
|
179
|
+
export function collectHistory(before: readonly ProjectedEntryLike[], count: number): RunHistory {
|
|
129
180
|
if (count <= 0) return { exchanges: [] };
|
|
130
181
|
let summary: string | undefined;
|
|
131
182
|
const all: { user: string; assistant: string; hasText: boolean }[] = [];
|
package/src/stats.ts
CHANGED
|
@@ -7,13 +7,16 @@
|
|
|
7
7
|
* - Jev usage: summed from our `context-guard` custom entries on the active branch.
|
|
8
8
|
*/
|
|
9
9
|
import { formatChars, MARKER } from "./render.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { EXCHANGE_STUB_PREFIX } from "./items.ts";
|
|
11
|
+
import { type MessageLike, type ProjectedEntryLike, textOf } from "./types.ts";
|
|
11
12
|
|
|
12
13
|
export const CUSTOM_TYPE = "context-guard";
|
|
13
14
|
|
|
14
15
|
/** Data stored in each `context-guard` custom entry (one per distilled run). */
|
|
15
16
|
export interface RunRecordData {
|
|
16
17
|
v: 1;
|
|
18
|
+
/** "mid-run" for checkpoints; a record without it counts as run end. */
|
|
19
|
+
phase?: "mid-run" | "run-end";
|
|
17
20
|
model: string;
|
|
18
21
|
savedChars: number;
|
|
19
22
|
requests: number;
|
|
@@ -22,6 +25,10 @@ export interface RunRecordData {
|
|
|
22
25
|
ms: number;
|
|
23
26
|
timedOut: boolean;
|
|
24
27
|
results: {
|
|
28
|
+
/** Absent in records that only had large outputs. */
|
|
29
|
+
kind?: "large" | "small" | "exchange";
|
|
30
|
+
/** Omitted exchanges: the entry ids that got `replacement: null`. */
|
|
31
|
+
omitted?: string[];
|
|
25
32
|
entryId: string;
|
|
26
33
|
tool: string;
|
|
27
34
|
label: string;
|
|
@@ -35,13 +42,17 @@ export interface RunRecordData {
|
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export interface GuardStats {
|
|
38
|
-
/** Tool results currently distilled in model context. */
|
|
45
|
+
/** Tool results currently distilled or omitted in model context (large and small outputs). */
|
|
39
46
|
distilledResults: number;
|
|
47
|
+
/** Old exchanges currently omitted from model context. */
|
|
48
|
+
omittedExchanges: number;
|
|
40
49
|
/** Characters currently kept out of model context. */
|
|
41
50
|
savedChars: number;
|
|
42
51
|
/** Rough token equivalent (Pi's own estimate: chars / 4). */
|
|
43
52
|
savedTokens: number;
|
|
44
53
|
runs: number;
|
|
54
|
+
/** Mid-run checkpoints among `runs`. */
|
|
55
|
+
checkpoints: number;
|
|
45
56
|
requests: number;
|
|
46
57
|
costUsd: number;
|
|
47
58
|
last?: RunRecordData;
|
|
@@ -49,11 +60,28 @@ export interface GuardStats {
|
|
|
49
60
|
|
|
50
61
|
export const estimateTokens = (chars: number): number => Math.round(chars / 4);
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
63
|
+
type StatsBranchEntry = { type?: string; id?: string; customType?: string; data?: unknown; message?: MessageLike };
|
|
64
|
+
|
|
65
|
+
export function computeStats(projection: readonly ProjectedEntryLike[], branch: readonly StatsBranchEntry[]): GuardStats {
|
|
66
|
+
const stats: GuardStats = { distilledResults: 0, omittedExchanges: 0, savedChars: 0, savedTokens: 0, runs: 0, checkpoints: 0, requests: 0, costUsd: 0 };
|
|
67
|
+
// Pi keeps omitted entries in the projection with `messages: []`: count what the model sees.
|
|
68
|
+
const visibleMessages = new Map(projection.map((entry) => [entry.sourceEntry.id, entry.messages.length]));
|
|
54
69
|
for (const entry of projection) {
|
|
55
70
|
const projected = entry.messages[0];
|
|
56
71
|
const raw = entry.sourceEntry.message;
|
|
72
|
+
if (entry.messages.length === 1 && projected?.role === "user" && raw && textOf(projected).startsWith(EXCHANGE_STUB_PREFIX) && !textOf(raw).startsWith(MARKER)) {
|
|
73
|
+
// An omitted exchange: its raw entries up to the next user message that the model no longer sees.
|
|
74
|
+
const start = branch.findIndex((e) => e.id === entry.sourceEntry.id);
|
|
75
|
+
let before = textOf(raw).length;
|
|
76
|
+
for (let i = start + 1; start >= 0 && i < branch.length; i++) {
|
|
77
|
+
const e = branch[i];
|
|
78
|
+
if (e.type === "message" && e.message?.role === "user") break;
|
|
79
|
+
if (e.message && e.id && (visibleMessages.get(e.id) ?? 0) === 0) before += textOf(e.message).length;
|
|
80
|
+
}
|
|
81
|
+
stats.omittedExchanges++;
|
|
82
|
+
stats.savedChars += Math.max(0, before - textOf(projected).length);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
57
85
|
if (entry.messages.length !== 1 || projected?.role !== "toolResult" || !raw) continue;
|
|
58
86
|
const now = textOf(projected);
|
|
59
87
|
if (!now.startsWith(MARKER)) continue;
|
|
@@ -68,6 +96,7 @@ export function computeStats(projection: readonly ProjectedEntryLike[], branch:
|
|
|
68
96
|
const data = entry.data as Partial<RunRecordData> | undefined;
|
|
69
97
|
if (!data || data.v !== 1) continue;
|
|
70
98
|
stats.runs++;
|
|
99
|
+
if (data.phase === "mid-run") stats.checkpoints++;
|
|
71
100
|
stats.requests += data.requests ?? 0;
|
|
72
101
|
stats.costUsd += data.costUsd ?? 0;
|
|
73
102
|
stats.last = data as RunRecordData;
|
|
@@ -89,8 +118,9 @@ export function formatStatus(stats: GuardStats, state: StatusState, color: Color
|
|
|
89
118
|
if (state === "busy") return color("accent", `${icon} distilling…`);
|
|
90
119
|
if (state === "problem") return color("warning", `${icon} ${problem ?? "guard unavailable"}`);
|
|
91
120
|
if (stats.savedTokens === 0) return color("dim", `${icon} 0 saved`);
|
|
121
|
+
const count = stats.distilledResults + stats.omittedExchanges;
|
|
92
122
|
// "🛡 −4.2k · 1": ≈ tokens kept out of context · distilled results.
|
|
93
|
-
return `${color("success", `${icon} −${formatTokens(stats.savedTokens)}`)}${color("dim", ` · ${
|
|
123
|
+
return `${color("success", `${icon} −${formatTokens(stats.savedTokens)}`)}${color("dim", ` · ${count}`)}`;
|
|
94
124
|
}
|
|
95
125
|
|
|
96
126
|
export const formatCost = (usd: number): string => (usd === 0 ? "$0" : usd < 0.01 ? `$${usd.toFixed(4)}` : `$${usd.toFixed(2)}`);
|
package/src/types.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface ContextEditDraft {
|
|
|
63
63
|
replacement: { content: { type: "text"; text: string }[] };
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/** A context edit that omits its target from model context (`replacement: null`). */
|
|
67
|
+
export interface OmitDraft {
|
|
68
|
+
type: "context_edit";
|
|
69
|
+
targetId: string;
|
|
70
|
+
replacement: null;
|
|
71
|
+
}
|
|
72
|
+
|
|
66
73
|
/** Draft accepted by `agent_before_settle` (Pi's `CustomEntryDraft`). */
|
|
67
74
|
export interface CustomDraft {
|
|
68
75
|
type: "custom";
|