pi-jar 0.2.2 → 0.2.4
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 +46 -14
- package/docs/DEVELOPMENT.md +2 -1
- package/docs/EFFICIENCY_PLAN.md +23 -0
- package/docs/INTEGRATIONS.md +24 -7
- package/docs/SUBAGENT-TOOLS.md +1 -1
- package/docs/WORKFLOWS.md +2 -2
- package/extensions/index.ts +87 -16
- package/package.json +1 -1
- package/src/advisor.ts +21 -9
- package/src/ask-tool.ts +2 -7
- package/src/cache-breaks-view.ts +56 -0
- package/src/cache-breaks.ts +521 -0
- package/src/compact-tools.ts +148 -8
- package/src/context-budget.ts +226 -0
- package/src/context-diet.ts +158 -17
- package/src/{democracy.ts → council.ts} +19 -22
- package/src/delegate.ts +80 -50
- package/src/discussion-hub.ts +9 -6
- package/src/discussion.ts +31 -22
- package/src/footer.ts +5 -1
- package/src/goal-loop.ts +3 -0
- package/src/info-panels.ts +15 -3
- package/src/model-roles.ts +26 -2
- package/src/panel.ts +1 -1
- package/src/plan.ts +4 -2
- package/src/profile-ui.ts +21 -6
- package/src/profiles.ts +8 -3
- package/src/quota.ts +71 -49
- package/src/roles-ui.ts +4 -2
- package/src/settings-ui.ts +30 -8
- package/src/settings.ts +24 -1
- package/src/shells.ts +77 -15
- package/src/subagent-tools.ts +1 -1
- package/src/suggest.ts +4 -11
- package/src/task-tool.ts +3 -9
- package/src/tool-activation.ts +14 -0
- package/src/usage-view.ts +8 -4
- package/src/usage.ts +4 -2
package/src/advisor.ts
CHANGED
|
@@ -12,7 +12,7 @@ export const ADVISOR_MESSAGE = "pi-jar.advisor";
|
|
|
12
12
|
const TRANSCRIPT_LIMIT = 24_000;
|
|
13
13
|
const GIT_LIMIT = 4_000;
|
|
14
14
|
/** Identical tool calls (same tool and input) within the recent window that count as a loop. */
|
|
15
|
-
export const LOOP_REPEATS =
|
|
15
|
+
export const LOOP_REPEATS = 4;
|
|
16
16
|
/** Consecutive failing tool results that count as stuck. */
|
|
17
17
|
export const FAILURE_STREAK = 3;
|
|
18
18
|
/** Automatic consultations allowed per user prompt. */
|
|
@@ -95,6 +95,22 @@ export function callKey(tool: string, input: unknown): string {
|
|
|
95
95
|
return tool + " " + (json.length <= KEY_INPUT_CHARS ? json : json.slice(0, 200) + "…#" + createHash("sha1").update(json).digest("hex"));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
/** Shell status polls that legitimately repeat with identical input. */
|
|
99
|
+
const POLL_ACTIONS: Record<string, true> = { output: true, wait: true, peek: true };
|
|
100
|
+
/** Test runners: rerunning the same suite between edits is the normal fix loop, not a stuck one. */
|
|
101
|
+
const TEST_COMMAND = /(?:^|[\s;&|(])(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test(?::\S+)?|node\s+(?:\S+\s+)*--test|(?:npx\s+)?(?:pytest|vitest|jest|mocha|phpunit|rspec|playwright\s+test)|(?:go|cargo|dotnet|deno|swift|mix)\s+test|python3?\s+-m\s+(?:pytest|unittest)|(?:\.\/)?(?:gradlew|gradle|mvnw|mvn)\s+(?:\S+\s+)*test|make\s+(?:\S+\s+)*test)(?=$|[\s;&|)])/;
|
|
102
|
+
/**
|
|
103
|
+
* Calls that repeat identically during normal work never count toward a loop: re-reads, shell
|
|
104
|
+
* output/wait/peek polls and test reruns. They still count toward failure streaks.
|
|
105
|
+
*/
|
|
106
|
+
export function loopExempt(tool: string, input: unknown): boolean {
|
|
107
|
+
if (tool === "read") return true;
|
|
108
|
+
const fields = input && typeof input === "object" ? input as Record<string, unknown> : {};
|
|
109
|
+
if (tool === "jar_shell" && typeof fields.action === "string" && Object.hasOwn(POLL_ACTIONS, fields.action)) return true;
|
|
110
|
+
return (tool === "bash" || (tool === "jar_shell" && fields.action === "start"))
|
|
111
|
+
&& typeof fields.command === "string" && TEST_COMMAND.test(fields.command);
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
/** Tracks repeated calls and failure streaks within one user prompt. */
|
|
99
115
|
export class StuckDetector {
|
|
100
116
|
private recent: string[] = [];
|
|
@@ -328,13 +344,9 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
328
344
|
});
|
|
329
345
|
if (typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool === "function") pi.registerTool({
|
|
330
346
|
name: ADVISOR_TOOL, label: "advisor",
|
|
331
|
-
description: "Start a background second opinion and return its request ID immediately.
|
|
332
|
-
promptSnippet: "Use jar_advisor
|
|
333
|
-
|
|
334
|
-
"Call jar_advisor before committing to a risky approach, after two failed attempts, and before declaring complex work complete. Form a candidate first and pass it as draft.",
|
|
335
|
-
"Start returns immediately, not approval. Continue useful work; collect completed advice with get/wait before the consequential decision. A pending receipt is not a completed review.",
|
|
336
|
-
"Background completions reach the model at a safe agent boundary; they cannot interrupt an in-flight model response. Use bounded wait when no useful work remains."
|
|
337
|
-
], parameters: Parameters,
|
|
347
|
+
description: "Start a background second opinion and return its request ID immediately. Start is a pending receipt, not approval: continue useful work and collect the advice with get, or wait (bounded to 30 seconds) when no useful work remains, before the consequential decision. Completions also arrive at a safe agent boundary; they cannot interrupt an in-flight response. status/cancel manage requests.",
|
|
348
|
+
promptSnippet: "Use jar_advisor only when the user asks or after two failed attempts at the same problem; pass your candidate approach as draft and collect the advice before deciding.",
|
|
349
|
+
parameters: Parameters,
|
|
338
350
|
async execute(_id, params, signal, _onUpdate, ctx) {
|
|
339
351
|
const action = params.action ?? "start";
|
|
340
352
|
if (action === "status") {
|
|
@@ -371,7 +383,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
371
383
|
pi.on("agent_before_settle", event => { interrupted = event.outcome !== "completed"; return boundary(event); });
|
|
372
384
|
pi.on("agent_settled", (_event, ctx) => { active = false; if (ctx) deliveryContext = ctx; notify(); });
|
|
373
385
|
pi.on("tool_call", (event, ctx) => {
|
|
374
|
-
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates()) return;
|
|
386
|
+
if (event.toolName === ADVISOR_TOOL || !options.enabled() || !options.gates() || loopExempt(event.toolName, event.input)) return;
|
|
375
387
|
const trigger = stuck.call(callKey(event.toolName, event.input)); if (!trigger) return;
|
|
376
388
|
try { const job = start(ctx, { trigger }, ctx.signal); return { block: true, reason: `Loop detected: ${trigger}. Advisor ${job.id} is reviewing in the background. Change approach and collect its result with jar_advisor get/wait.` }; }
|
|
377
389
|
catch (error) { toast(ctx, "Advisor gate: " + (error instanceof Error ? error.message : String(error)), true); }
|
package/src/ask-tool.ts
CHANGED
|
@@ -159,13 +159,8 @@ export function registerAskTool(pi: ExtensionAPI): void {
|
|
|
159
159
|
pi.registerTool({
|
|
160
160
|
name: "jar_ask",
|
|
161
161
|
label: "ask user",
|
|
162
|
-
description: "Ask the user one or more structured clarification questions with described choices, multi-select checkboxes, custom text, or a chat-about-this path.",
|
|
163
|
-
promptSnippet: "Use jar_ask when
|
|
164
|
-
promptGuidelines: [
|
|
165
|
-
"Use jar_ask instead of writing ad-hoc numbered questions when the user needs to choose between options or provide clarification.",
|
|
166
|
-
"Use multi=true only when several answers may be selected. Include short descriptions when labels alone are ambiguous.",
|
|
167
|
-
"If a jar_ask result contains chat, respond to that discussion before asking for a final choice again."
|
|
168
|
-
],
|
|
162
|
+
description: "Ask the user one or more structured clarification questions with described choices, multi-select checkboxes (multi=true only when several answers may apply), custom text, or a chat-about-this path. If a result contains chat, respond to that discussion before asking for a final choice again.",
|
|
163
|
+
promptSnippet: "Use jar_ask instead of ad-hoc numbered questions when the user must choose or clarify; add short option descriptions when labels are ambiguous.",
|
|
169
164
|
parameters: Parameters,
|
|
170
165
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
171
166
|
const questions = params.questions as AskQuestion[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { BREAK_SHORTFALL_TOKENS, summarizeBreaks, type BreakKind, type CacheBreakState } from "./cache-breaks.ts";
|
|
4
|
+
import { money, row, tokens, type Paint } from "./panel.ts";
|
|
5
|
+
|
|
6
|
+
const CAUSES: Record<BreakKind, string> = {
|
|
7
|
+
model: "model switch", tools: "tool list changed", system: "system prompt changed", message: "message changed",
|
|
8
|
+
truncated: "history shortened", idle: "cache expired (idle)", unknown: "no prompt change found"
|
|
9
|
+
};
|
|
10
|
+
const HOW = `A break is a call whose cache read fell more than ${BREAK_SHORTFALL_TOKENS.toLocaleString("en-US")} tokens short of the previous prompt.`;
|
|
11
|
+
|
|
12
|
+
const pad = (value: number) => String(value).padStart(2, "0");
|
|
13
|
+
|
|
14
|
+
/** `10:42:03` today, `10-07 10:42` on an earlier day, in local time. */
|
|
15
|
+
function when(timestamp: string, now: number): string {
|
|
16
|
+
const date = new Date(timestamp);
|
|
17
|
+
if (Number.isNaN(date.getTime())) return "?";
|
|
18
|
+
const clock = `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
19
|
+
return new Date(now).toDateString() === date.toDateString() ? `${clock}:${pad(date.getSeconds())}` : `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${clock}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The Cache tab of /usage and /context, and `/cache-breaks`: costly prompt-cache breaks of the session, totals first. */
|
|
23
|
+
export function cacheBreakLines(view: CacheBreakState & { now: number }, width: number, fg: Paint): string[] {
|
|
24
|
+
const inner = Math.max(20, width - 2);
|
|
25
|
+
const heading = (text: string) => fg("accent", text);
|
|
26
|
+
const extra = (amount: number) => fg("warning", "~" + money(amount));
|
|
27
|
+
const wrap = (text: string, color: string | undefined, indent = ""): string[] =>
|
|
28
|
+
wrapTextWithAnsi(text, Math.max(10, inner - indent.length)).map((line) => indent + (color ? fg(color, line) : line));
|
|
29
|
+
const totals = summarizeBreaks(view.breaks);
|
|
30
|
+
const lines: string[] = [];
|
|
31
|
+
if (!view.enabled) lines.push(...wrap("Cache diagnostics are off. Turn them on in /jar settings → Pi.", "warning"), "");
|
|
32
|
+
lines.push(heading("Session"), " " + row("Calls checked", String(view.calls), inner - 2), " " + row("Costly cache breaks", String(totals.count), inner - 2));
|
|
33
|
+
if (!totals.count) {
|
|
34
|
+
const none = view.calls ? "None so far." : "No call checked yet (Anthropic and OpenAI requests are read).";
|
|
35
|
+
return [...lines, ...wrap(none, "dim", " "), "", ...wrap(HOW, "dim")];
|
|
36
|
+
}
|
|
37
|
+
lines.push(" " + row("Tokens rewritten", tokens(totals.rewritten), inner - 2));
|
|
38
|
+
if (totals.cost > 0) lines.push(" " + row("Estimated extra cost", extra(totals.cost), inner - 2));
|
|
39
|
+
|
|
40
|
+
lines.push("", heading("By cause"));
|
|
41
|
+
for (const [kind, sum] of [...totals.kinds].sort((a, b) => b[1].cost - a[1].cost || b[1].rewritten - a[1].rewritten)) {
|
|
42
|
+
lines.push(" " + row(CAUSES[kind], `${sum.count} · ${tokens(sum.rewritten)}${sum.cost > 0 ? " · " + extra(sum.cost) : ""}`, inner - 2));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
lines.push("", heading("Breaks (oldest first)"));
|
|
46
|
+
for (const entry of view.breaks) {
|
|
47
|
+
lines.push(` #${entry.request} ${when(entry.timestamp, view.now)} ${fg("warning", tokens(entry.rewrittenTokens) + " tokens")}${entry.costUsd !== undefined ? " · " + extra(entry.costUsd) : ""}`);
|
|
48
|
+
lines.push(...wrap(entry.summary, undefined, " "));
|
|
49
|
+
if (entry.first !== undefined) lines.push(...wrap(`first change: ${entry.first} (segment ${entry.segment})`, "dim", " "));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const home = homedir();
|
|
53
|
+
if (view.file) lines.push("", ...wrap("Log: " + (home && view.file.startsWith(home) ? "~" + view.file.slice(home.length) : view.file), "dim"));
|
|
54
|
+
lines.push(...wrap(HOW, "dim"));
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { CACHE_IDLE_MS, contextTokens, formatTokens } from "./context-budget.ts";
|
|
6
|
+
import { duration, money } from "./panel.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Prompt-cache break diagnostics. Providers cache a prompt as an ordered prefix (tools, then system
|
|
10
|
+
* prompt, then messages), so changing anything early in a request makes the provider re-process, and
|
|
11
|
+
* bill again, everything after it. Pi tells you that a call missed the cache; this module says what changed.
|
|
12
|
+
*
|
|
13
|
+
* - before_provider_request: the final payload is reduced to an ordered list of {@link Segment}s (model,
|
|
14
|
+
* each tool, each system-prompt section, each message), each a hash and a size: no content is kept. The
|
|
15
|
+
* payload is only read, never replaced.
|
|
16
|
+
* - Comparing a request with the previous completed one finds the first segment where the earlier
|
|
17
|
+
* sequence is no longer a prefix ({@link diffSegments}).
|
|
18
|
+
* - message_end: the call's usage says whether the provider actually missed ({@link judgeCall}). Only a
|
|
19
|
+
* costly miss is a break: it is classified, logged to JSONL and announced once.
|
|
20
|
+
* Decisions here are pure; {@link registerCacheBreaks} feeds Pi events in.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A call is a costly break when its cache read falls short of the previous prompt by more than this... */
|
|
24
|
+
export const BREAK_SHORTFALL_TOKENS = 4096;
|
|
25
|
+
/** ...or reads nothing from the cache although its prompt is larger than this. */
|
|
26
|
+
export const BREAK_COLD_TOKENS = 8192;
|
|
27
|
+
/** Breaks kept in memory for /cache-breaks; the JSONL log keeps every one. */
|
|
28
|
+
const MAX_BREAKS = 200;
|
|
29
|
+
/** A system prompt is split into at most this many sections; the remainder becomes one `rest` section. */
|
|
30
|
+
const MAX_SECTIONS = 128;
|
|
31
|
+
|
|
32
|
+
export type SegmentKind = "model" | "tool" | "system" | "message";
|
|
33
|
+
/** Providers read the prompt in this order, so an earlier region changing invalidates every later one. */
|
|
34
|
+
const KIND_ORDER: Record<SegmentKind, number> = { model: 0, tool: 1, system: 2, message: 3 };
|
|
35
|
+
|
|
36
|
+
/** One ordered part of a provider prompt: what it is, a hash of its content, and its size in characters. */
|
|
37
|
+
export interface Segment { kind: SegmentKind; label: string; hash: string; size: number }
|
|
38
|
+
|
|
39
|
+
const record = (value: unknown): Record<string, unknown> | undefined =>
|
|
40
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
41
|
+
const count = (value: unknown): number => typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
42
|
+
/** Provider cache breakpoints move to the newest block on every call: markers, not prompt content. */
|
|
43
|
+
const dropMarkers = (key: string, value: unknown): unknown => key === "cache_control" ? undefined : value;
|
|
44
|
+
|
|
45
|
+
/** Plain text of a string or of an array of text blocks; other blocks count by their JSON. */
|
|
46
|
+
function textOf(value: unknown): string {
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
if (!Array.isArray(value)) return "";
|
|
49
|
+
return value.map((part) => {
|
|
50
|
+
if (typeof part === "string") return part;
|
|
51
|
+
const block = record(part);
|
|
52
|
+
return typeof block?.text === "string" ? block.text : JSON.stringify(part, dropMarkers) ?? "";
|
|
53
|
+
}).join("\n\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const TAG_OPEN = /^<([A-Za-z][\w.-]*)(?:\s[^>]*)?>\s*$/;
|
|
57
|
+
const HEADING = /^#{1,3}\s+(\S.*?)\s*$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A system prompt as named sections, so a change can be located: top-level `<tag>…</tag>` blocks (how Pi
|
|
61
|
+
* assembles its prompt), markdown headings, and paragraphs for the text in between. Blank lines belong to
|
|
62
|
+
* the section after them, so appending a section leaves the earlier ones unchanged; trailing blank lines
|
|
63
|
+
* are ignored. Apart from those, sections tile the text, so any edit changes at least one section's hash.
|
|
64
|
+
*/
|
|
65
|
+
export function systemSections(text: string): { label: string; text: string }[] {
|
|
66
|
+
const lines = text.split("\n");
|
|
67
|
+
const spans: { label: string | undefined; from: number; to: number }[] = [];
|
|
68
|
+
let from = 0;
|
|
69
|
+
let label: string | undefined;
|
|
70
|
+
let closing: string | undefined;
|
|
71
|
+
const end = (to: number) => {
|
|
72
|
+
if (to > from) spans.push({ label, from, to });
|
|
73
|
+
from = to;
|
|
74
|
+
};
|
|
75
|
+
lines.forEach((line, index) => {
|
|
76
|
+
if (closing !== undefined) {
|
|
77
|
+
if (line.trim() !== closing) return;
|
|
78
|
+
end(index + 1);
|
|
79
|
+
label = closing = undefined;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const tag = TAG_OPEN.exec(line);
|
|
83
|
+
const heading = tag ? null : HEADING.exec(line);
|
|
84
|
+
if (!tag && !heading) return;
|
|
85
|
+
end(index);
|
|
86
|
+
label = tag ? `<${tag[1]}>` : `# ${heading![1]!.slice(0, 48)}`;
|
|
87
|
+
if (tag) closing = `</${tag[1]}>`;
|
|
88
|
+
});
|
|
89
|
+
end(lines.length);
|
|
90
|
+
|
|
91
|
+
const sections: { label: string; text: string }[] = [];
|
|
92
|
+
const blanks: string[] = [];
|
|
93
|
+
let paragraph = 0;
|
|
94
|
+
const add = (label: string, body: string[]) => sections.push({ label, text: [...blanks.splice(0), ...body].join("\n") });
|
|
95
|
+
for (const span of spans) {
|
|
96
|
+
const body = lines.slice(span.from, span.to);
|
|
97
|
+
if (span.label !== undefined) {
|
|
98
|
+
let keep = body.length;
|
|
99
|
+
while (keep > 1 && body[keep - 1]!.trim() === "") keep--;
|
|
100
|
+
add(span.label, body.slice(0, keep));
|
|
101
|
+
blanks.push(...body.slice(keep));
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Loose text: a block is one paragraph together with the blank lines before it.
|
|
105
|
+
const blocks: string[][] = [[]];
|
|
106
|
+
let words = false;
|
|
107
|
+
for (const line of body) {
|
|
108
|
+
const blank = line.trim() === "";
|
|
109
|
+
if (blank && words) { blocks.push([]); words = false; }
|
|
110
|
+
blocks[blocks.length - 1]!.push(line);
|
|
111
|
+
if (!blank) words = true;
|
|
112
|
+
}
|
|
113
|
+
for (const block of blocks) {
|
|
114
|
+
if (block.every((line) => line.trim() === "")) blanks.push(...block);
|
|
115
|
+
else add(sections.length === 0 ? "preamble" : `text ${++paragraph}`, block);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (sections.length > MAX_SECTIONS) {
|
|
119
|
+
const rest = sections.splice(MAX_SECTIONS - 1);
|
|
120
|
+
sections.push({ label: "rest", text: rest.map((section) => section.text).join("\n") });
|
|
121
|
+
}
|
|
122
|
+
const seen = new Map<string, number>();
|
|
123
|
+
return sections.map((section) => {
|
|
124
|
+
const times = (seen.get(section.label) ?? 0) + 1;
|
|
125
|
+
seen.set(section.label, times);
|
|
126
|
+
return times === 1 ? section : { ...section, label: `${section.label} #${times}` };
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Remember which tool each call id belongs to, so a result can be labelled with its tool's name. */
|
|
131
|
+
function rememberCalls(message: Record<string, unknown>, calls: Map<string, string>): void {
|
|
132
|
+
const remember = (id: unknown, name: unknown) => { if (typeof id === "string" && typeof name === "string") calls.set(id, name); };
|
|
133
|
+
remember(message.call_id, message.name); // Responses: function_call / custom_tool_call items
|
|
134
|
+
if (Array.isArray(message.content)) {
|
|
135
|
+
for (const block of message.content) {
|
|
136
|
+
const part = record(block);
|
|
137
|
+
if (part?.type === "tool_use") remember(part.id, part.name); // Anthropic
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (Array.isArray(message.tool_calls)) {
|
|
141
|
+
for (const call of message.tool_calls) {
|
|
142
|
+
const entry = record(call);
|
|
143
|
+
remember(entry?.id, record(entry?.function)?.name ?? entry?.name); // Chat Completions
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** `user`, `assistant`, `tool result: bash`, `tool call: bash`, `reasoning`, … for one message or input item. */
|
|
149
|
+
function messageLabel(message: Record<string, unknown>, calls: ReadonlyMap<string, string>): string {
|
|
150
|
+
const type = message.type;
|
|
151
|
+
if (type === "function_call" || type === "custom_tool_call") return `tool call: ${typeof message.name === "string" ? message.name : "?"}`;
|
|
152
|
+
if (type === "function_call_output" || type === "custom_tool_call_output") return `tool result: ${calls.get(String(message.call_id)) ?? "?"}`;
|
|
153
|
+
if (message.role === "tool") return `tool result: ${calls.get(String(message.tool_call_id)) ?? (typeof message.name === "string" ? message.name : "?")}`;
|
|
154
|
+
if (Array.isArray(message.content)) {
|
|
155
|
+
const names = new Set<string>();
|
|
156
|
+
for (const block of message.content) {
|
|
157
|
+
const part = record(block);
|
|
158
|
+
if (part?.type === "tool_result") names.add(calls.get(String(part.tool_use_id)) ?? "?");
|
|
159
|
+
}
|
|
160
|
+
if (names.size) return `tool result: ${[...names].slice(0, 3).join(", ")}${names.size > 3 ? ` +${names.size - 3}` : ""}`;
|
|
161
|
+
}
|
|
162
|
+
return typeof message.role === "string" ? message.role : typeof type === "string" ? type : "message";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The ordered segments of a provider payload: the model, each tool, each system-prompt section, then each
|
|
167
|
+
* message. Understands Anthropic Messages (`system`, `messages`) and OpenAI Responses and Chat Completions
|
|
168
|
+
* (`instructions`, `input` / `messages`, with the system prompt as the leading system or developer item);
|
|
169
|
+
* any other shape is `undefined`. Cache markers are ignored. The payload is never modified.
|
|
170
|
+
*/
|
|
171
|
+
export function fingerprintPayload(payload: unknown, provider?: string): Segment[] | undefined {
|
|
172
|
+
const body = record(payload);
|
|
173
|
+
if (!body || typeof body.model !== "string") return undefined;
|
|
174
|
+
const input: unknown[] | undefined = Array.isArray(body.messages) ? body.messages : Array.isArray(body.input) ? body.input
|
|
175
|
+
: typeof body.input === "string" ? [{ role: "user", content: body.input }] : undefined;
|
|
176
|
+
if (!input) return undefined;
|
|
177
|
+
|
|
178
|
+
const segments: Segment[] = [];
|
|
179
|
+
const add = (kind: SegmentKind, label: string, value: unknown) => {
|
|
180
|
+
const text = typeof value === "string" ? value : JSON.stringify(value, dropMarkers) ?? "";
|
|
181
|
+
segments.push({ kind, label, hash: createHash("sha1").update(text).digest("base64url").slice(0, 12), size: text.length });
|
|
182
|
+
};
|
|
183
|
+
const model = provider ? `${provider}/${body.model}` : body.model;
|
|
184
|
+
add("model", model, model);
|
|
185
|
+
|
|
186
|
+
if (Array.isArray(body.tools)) {
|
|
187
|
+
body.tools.forEach((tool: unknown, index) => {
|
|
188
|
+
const definition = record(tool);
|
|
189
|
+
const inner = record(definition?.function);
|
|
190
|
+
const name = typeof definition?.name === "string" ? definition.name : typeof inner?.name === "string" ? inner.name
|
|
191
|
+
: typeof definition?.type === "string" ? definition.type : `tool ${index + 1}`;
|
|
192
|
+
add("tool", name, tool);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The system prompt travels as `system` (Anthropic), `instructions` (Responses) or leading system/developer items.
|
|
197
|
+
const prompts = [textOf(body.system), textOf(body.instructions)];
|
|
198
|
+
let leading = 0;
|
|
199
|
+
for (; leading < input.length; leading++) {
|
|
200
|
+
const item = record(input[leading]);
|
|
201
|
+
if (!item || (item.role !== "system" && item.role !== "developer") || (item.type !== undefined && item.type !== "message")) break;
|
|
202
|
+
prompts.push(textOf(item.content));
|
|
203
|
+
}
|
|
204
|
+
const texts = prompts.filter((text) => text !== "");
|
|
205
|
+
texts.forEach((text, index) => {
|
|
206
|
+
for (const section of systemSections(text)) add("system", texts.length > 1 ? `#${index + 1} ${section.label}` : section.label, section.text);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const calls = new Map<string, string>();
|
|
210
|
+
for (const item of input.slice(leading)) {
|
|
211
|
+
const message = record(item);
|
|
212
|
+
if (message) rememberCalls(message, calls);
|
|
213
|
+
// A provider turns the newest message's string content into a text block to carry its cache marker.
|
|
214
|
+
const content = message && typeof message.content === "string" ? { ...message, content: [{ type: "text", text: message.content }] } : item;
|
|
215
|
+
add("message", message ? messageLabel(message, calls) : "message", content);
|
|
216
|
+
}
|
|
217
|
+
return segments;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Names that differ between two sections of a prompt (tools, or system sections). */
|
|
221
|
+
interface Named { added: string[]; removed: string[]; changed: string[]; reordered: boolean }
|
|
222
|
+
|
|
223
|
+
export type PromptChange = { segment: number; first: string } & (
|
|
224
|
+
| { kind: "model"; from: string; to: string }
|
|
225
|
+
| ({ kind: "tools" } & Named)
|
|
226
|
+
| ({ kind: "system" } & Named)
|
|
227
|
+
| { kind: "message"; index: number; total: number; label: string; before: number; after: number; dropped: number }
|
|
228
|
+
| { kind: "truncated"; from: number; to: number });
|
|
229
|
+
|
|
230
|
+
function diffNamed(before: readonly Segment[], after: readonly Segment[]): Named {
|
|
231
|
+
const was = new Map(before.map((segment) => [segment.label, segment.hash]));
|
|
232
|
+
const now = new Map(after.map((segment) => [segment.label, segment.hash]));
|
|
233
|
+
const added = [...now.keys()].filter((label) => !was.has(label));
|
|
234
|
+
const removed = [...was.keys()].filter((label) => !now.has(label));
|
|
235
|
+
const changed = [...now].filter(([label, hash]) => was.has(label) && was.get(label) !== hash).map(([label]) => label);
|
|
236
|
+
return { added, removed, changed, reordered: !added.length && !removed.length && !changed.length };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Where `current` stops extending `previous`: the first segment that differs, classified by the earliest
|
|
241
|
+
* prompt region it falls in. `undefined` when `previous` is a prefix of `current` (an identical prompt, or
|
|
242
|
+
* only messages appended), the normal case in which the provider cache is read up to the old end.
|
|
243
|
+
*/
|
|
244
|
+
export function diffSegments(previous: readonly Segment[], current: readonly Segment[]): PromptChange | undefined {
|
|
245
|
+
const shared = Math.min(previous.length, current.length);
|
|
246
|
+
let at = 0;
|
|
247
|
+
while (at < shared && previous[at]!.kind === current[at]!.kind && previous[at]!.hash === current[at]!.hash) at++;
|
|
248
|
+
if (at === previous.length) return undefined;
|
|
249
|
+
const old = previous[at]!;
|
|
250
|
+
const now = current[at];
|
|
251
|
+
const kind = now && KIND_ORDER[now.kind] < KIND_ORDER[old.kind] ? now.kind : old.kind;
|
|
252
|
+
// Name the segment of the changed region itself: a removed tool is `old`, an added one is `now`.
|
|
253
|
+
const first = `${kind} ${(now?.kind === kind ? now : old).label}`;
|
|
254
|
+
const region = (segments: readonly Segment[]) => segments.filter((segment) => segment.kind === kind);
|
|
255
|
+
if (kind === "model") return { kind, segment: at, first, from: old.label, to: now?.label ?? "" };
|
|
256
|
+
if (kind === "tool") return { kind: "tools", segment: at, first, ...diffNamed(region(previous), region(current)) };
|
|
257
|
+
if (kind === "system") return { kind, segment: at, first, ...diffNamed(region(previous), region(current)) };
|
|
258
|
+
const before = region(previous).length;
|
|
259
|
+
const total = region(current).length;
|
|
260
|
+
if (!now) return { kind: "truncated", segment: at, first, from: before, to: total };
|
|
261
|
+
return { kind: "message", segment: at, first, index: at - previous.findIndex((segment) => segment.kind === "message"), total,
|
|
262
|
+
label: now.label, before: old.size, after: now.size, dropped: Math.max(0, before - total) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `+added, -removed, ~changed`, at most three entries before `+N more`. */
|
|
266
|
+
function nameList(change: Named): string {
|
|
267
|
+
const entries = [...change.added.map((name) => `+${name}`), ...change.removed.map((name) => `-${name}`), ...change.changed.map((name) => `~${name}`)];
|
|
268
|
+
if (!entries.length) return "reordered";
|
|
269
|
+
return (entries.length > 3 ? [...entries.slice(0, 2), `+${entries.length - 2} more`] : entries).join(", ");
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** One clause naming what changed: `tools changed (+jendral_build_get, +17 more)`. */
|
|
273
|
+
function describeChange(change: PromptChange): string {
|
|
274
|
+
switch (change.kind) {
|
|
275
|
+
case "model": return `model changed (${change.from} → ${change.to})`;
|
|
276
|
+
case "tools": return `tools changed (${nameList(change)})`;
|
|
277
|
+
case "system": return `system prompt changed (${nameList(change)})`;
|
|
278
|
+
case "truncated": return `history shortened (${change.from} → ${change.to} messages)`;
|
|
279
|
+
case "message": {
|
|
280
|
+
const size = change.after < change.before ? `, ${formatTokens(change.before)} → ${formatTokens(change.after)} chars` : "";
|
|
281
|
+
const dropped = change.dropped ? `; ${change.dropped} message${change.dropped === 1 ? "" : "s"} dropped` : "";
|
|
282
|
+
return `message ${change.index + 1}/${change.total} ${change.after < change.before ? "shrank" : "changed"} (${change.label}${size}, ${change.total - 1 - change.index} from the end)${dropped}`;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The usage a provider reported for one call. */
|
|
288
|
+
export interface CallUsage {
|
|
289
|
+
input?: unknown; cacheRead?: unknown; cacheWrite?: unknown;
|
|
290
|
+
cost?: { input?: unknown; cacheRead?: unknown; cacheWrite?: unknown; total?: unknown };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Whether a call's cache read fits the previous prompt. `rewritten` is the part of the previous prompt (or
|
|
295
|
+
* of this one, when smaller) that was billed again instead of read from the cache; a call is costly when
|
|
296
|
+
* that exceeds {@link BREAK_SHORTFALL_TOKENS}, or when nothing was read from a prompt over {@link BREAK_COLD_TOKENS}.
|
|
297
|
+
*/
|
|
298
|
+
export function judgeCall(previousPrompt: number, usage: CallUsage | undefined): { rewritten: number; costly: boolean } {
|
|
299
|
+
const prompt = contextTokens(usage);
|
|
300
|
+
const cacheRead = count(usage?.cacheRead);
|
|
301
|
+
const rewritten = Math.max(0, Math.min(previousPrompt, prompt) - cacheRead);
|
|
302
|
+
return { rewritten, costly: rewritten > BREAK_SHORTFALL_TOKENS || (cacheRead === 0 && prompt > BREAK_COLD_TOKENS) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Extra dollars the rewritten tokens cost over reading them from the cache, from the call's own cost split:
|
|
307
|
+
* its average price per fresh token (input plus cache write), minus its cache-read price (taken from the
|
|
308
|
+
* call, else `readPerMTok`, the model's price per million tokens, else free). Undefined without a cost.
|
|
309
|
+
*/
|
|
310
|
+
export function rewriteCost(usage: CallUsage | undefined, rewritten: number, readPerMTok?: number): number | undefined {
|
|
311
|
+
const fresh = count(usage?.input) + count(usage?.cacheWrite);
|
|
312
|
+
const freshRate = fresh > 0 ? (count(usage?.cost?.input) + count(usage?.cost?.cacheWrite)) / fresh : 0;
|
|
313
|
+
if (!(freshRate > 0) || rewritten <= 0) return undefined;
|
|
314
|
+
const read = count(usage?.cacheRead);
|
|
315
|
+
const readRate = read > 0 && count(usage?.cost?.cacheRead) > 0 ? count(usage?.cost?.cacheRead) / read : count(readPerMTok) / 1_000_000;
|
|
316
|
+
return Math.round(rewritten * Math.max(0, freshRate - readRate) * 10_000) / 10_000;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export type BreakKind = PromptChange["kind"] | "idle" | "unknown";
|
|
320
|
+
|
|
321
|
+
/** One costly break, as listed by /cache-breaks and appended to the session's JSONL log. */
|
|
322
|
+
export interface CacheBreakRecord {
|
|
323
|
+
version: 1;
|
|
324
|
+
/** When the call was sent (ISO 8601). */
|
|
325
|
+
timestamp: string;
|
|
326
|
+
/** Provider calls observed for this session, counted from the first one pi-jar saw. */
|
|
327
|
+
request: number;
|
|
328
|
+
/** What changed: a prompt region, an expired cache (`idle`) or `unknown` when nothing in the prompt changed. */
|
|
329
|
+
kind: BreakKind;
|
|
330
|
+
summary: string;
|
|
331
|
+
/** Index of the first changed segment in the ordered prompt (model, tools, system sections, messages), and what it is. */
|
|
332
|
+
segment?: number;
|
|
333
|
+
first?: string;
|
|
334
|
+
model: string;
|
|
335
|
+
promptTokens: number;
|
|
336
|
+
cacheReadTokens: number;
|
|
337
|
+
/** Tokens of the previous prompt that were billed again instead of read from the cache. */
|
|
338
|
+
rewrittenTokens: number;
|
|
339
|
+
/** Estimated extra cost in USD, when the call reported its cost. */
|
|
340
|
+
costUsd?: number;
|
|
341
|
+
/** Time since the cache was last known warm. */
|
|
342
|
+
idleMs: number;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
interface Baseline { segments: readonly Segment[]; prompt: number; at: number }
|
|
346
|
+
interface Pending { segments: readonly Segment[]; change: PromptChange | undefined }
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Correlates provider requests with the calls' usage. Feed it `request` for every provider payload and
|
|
350
|
+
* `complete` for every assistant message; the baseline is the last completed call, so a failed or aborted
|
|
351
|
+
* request neither moves it nor can be mistaken for a break.
|
|
352
|
+
*/
|
|
353
|
+
export class CacheBreakTracker {
|
|
354
|
+
private baseline: Baseline | undefined;
|
|
355
|
+
private pending: Pending | undefined;
|
|
356
|
+
private warmAt = 0;
|
|
357
|
+
private cacheSeen = false;
|
|
358
|
+
private calls = 0;
|
|
359
|
+
private offset = 0;
|
|
360
|
+
private log: CacheBreakRecord[] = [];
|
|
361
|
+
|
|
362
|
+
/** Calls checked since this session was loaded. */
|
|
363
|
+
get checked(): number { return this.calls; }
|
|
364
|
+
get breaks(): readonly CacheBreakRecord[] { return this.log; }
|
|
365
|
+
|
|
366
|
+
/** The prompt about to be sent; `undefined` when the payload could not be fingerprinted. */
|
|
367
|
+
request(segments: readonly Segment[] | undefined): void {
|
|
368
|
+
this.pending = segments ? { segments, change: this.baseline ? diffSegments(this.baseline.segments, segments) : undefined } : undefined;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** A deliberate change of history (compaction, tree navigation, disabling): the next call has nothing to compare with. */
|
|
372
|
+
rebase(): void {
|
|
373
|
+
this.baseline = undefined;
|
|
374
|
+
this.pending = undefined;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Pi refreshed the provider cache while idle, which restarts its lifetime. */
|
|
378
|
+
warmed(at: number): void { this.warmAt = Math.max(this.warmAt, at); }
|
|
379
|
+
|
|
380
|
+
/** A new session: forget everything, then continue from the breaks an earlier run of it logged. */
|
|
381
|
+
reset(earlier: readonly CacheBreakRecord[] = []): void {
|
|
382
|
+
this.rebase();
|
|
383
|
+
this.warmAt = this.calls = 0;
|
|
384
|
+
this.cacheSeen = false;
|
|
385
|
+
this.log = earlier.slice(-MAX_BREAKS);
|
|
386
|
+
this.offset = earlier.reduce((last, entry) => Math.max(last, entry.request), 0);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** An assistant message ended; the record when its call was a costly break. */
|
|
390
|
+
complete(call: { usage: CallUsage | undefined; at: number; model: string; readPerMTok?: number }): CacheBreakRecord | undefined {
|
|
391
|
+
const { pending, baseline } = this;
|
|
392
|
+
this.pending = undefined;
|
|
393
|
+
const prompt = contextTokens(call.usage);
|
|
394
|
+
if (!prompt) return undefined; // Nothing reached the provider: keep the last good baseline.
|
|
395
|
+
if (pending) this.calls++; // Only calls whose prompt was fingerprinted can be checked.
|
|
396
|
+
this.cacheSeen ||= count(call.usage?.cacheRead) + count(call.usage?.cacheWrite) > 0;
|
|
397
|
+
this.baseline = pending ? { segments: pending.segments, prompt, at: call.at } : undefined;
|
|
398
|
+
// Providers that never report caching make every call look cold, so wait for the first sign of it.
|
|
399
|
+
if (!pending || !baseline || !this.cacheSeen) return undefined;
|
|
400
|
+
const { rewritten, costly } = judgeCall(baseline.prompt, call.usage);
|
|
401
|
+
if (!costly) return undefined;
|
|
402
|
+
const idleMs = Math.max(0, call.at - Math.max(baseline.at, this.warmAt));
|
|
403
|
+
const idle = idleMs >= CACHE_IDLE_MS;
|
|
404
|
+
const { change } = pending;
|
|
405
|
+
const cost = rewriteCost(call.usage, rewritten, call.readPerMTok);
|
|
406
|
+
const entry: CacheBreakRecord = {
|
|
407
|
+
version: 1, timestamp: new Date(call.at).toISOString(), request: this.offset + this.calls,
|
|
408
|
+
kind: change?.kind ?? (idle ? "idle" : "unknown"),
|
|
409
|
+
summary: change ? describeChange(change) + (idle ? `, after ${duration(idleMs)} idle` : "")
|
|
410
|
+
: idle ? `cache expired after ${duration(idleMs)} idle` : "no prompt change found",
|
|
411
|
+
...(change ? { segment: change.segment, first: change.first } : {}),
|
|
412
|
+
model: call.model, promptTokens: prompt, cacheReadTokens: count(call.usage?.cacheRead), rewrittenTokens: rewritten,
|
|
413
|
+
...(cost !== undefined ? { costUsd: cost } : {}), idleMs
|
|
414
|
+
};
|
|
415
|
+
this.log = [...this.log, entry].slice(-MAX_BREAKS);
|
|
416
|
+
return entry;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export interface BreakTotals { count: number; rewritten: number; cost: number }
|
|
421
|
+
|
|
422
|
+
/** Totals over breaks, overall and per cause. */
|
|
423
|
+
export function summarizeBreaks(breaks: readonly CacheBreakRecord[]): BreakTotals & { kinds: Map<BreakKind, BreakTotals> } {
|
|
424
|
+
const total: BreakTotals = { count: 0, rewritten: 0, cost: 0 };
|
|
425
|
+
const kinds = new Map<BreakKind, BreakTotals>();
|
|
426
|
+
for (const entry of breaks) {
|
|
427
|
+
const bucket = kinds.get(entry.kind) ?? { count: 0, rewritten: 0, cost: 0 };
|
|
428
|
+
kinds.set(entry.kind, bucket);
|
|
429
|
+
for (const sum of [total, bucket]) { sum.count++; sum.rewritten += entry.rewrittenTokens; sum.cost += entry.costUsd ?? 0; }
|
|
430
|
+
}
|
|
431
|
+
return { ...total, kinds };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Per-session JSONL log: `<agent dir>/pi-jar-cache-breaks/<session id>.jsonl`. */
|
|
435
|
+
export function cacheBreakFile(sessionId: string | undefined, directory = join(getAgentDir(), "pi-jar-cache-breaks")): string {
|
|
436
|
+
return join(directory, `${(sessionId ?? "").replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 64) || "session"}.jsonl`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Breaks logged for a session so far; unreadable files and malformed lines are skipped. */
|
|
440
|
+
export function readCacheBreaks(file: string): CacheBreakRecord[] {
|
|
441
|
+
let text: string;
|
|
442
|
+
try { text = readFileSync(file, "utf8"); } catch { return []; }
|
|
443
|
+
const breaks: CacheBreakRecord[] = [];
|
|
444
|
+
for (const line of text.split("\n")) {
|
|
445
|
+
if (!line.trim()) continue;
|
|
446
|
+
let parsed: Record<string, unknown> | undefined;
|
|
447
|
+
try { parsed = record(JSON.parse(line)); } catch { continue; }
|
|
448
|
+
if (parsed?.version === 1 && typeof parsed.kind === "string" && typeof parsed.summary === "string" && typeof parsed.timestamp === "string"
|
|
449
|
+
&& Number.isFinite(parsed.request) && Number.isFinite(parsed.rewrittenTokens) && Number.isFinite(parsed.promptTokens)) breaks.push(parsed as unknown as CacheBreakRecord);
|
|
450
|
+
}
|
|
451
|
+
return breaks.slice(-MAX_BREAKS);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** What the /cache-breaks tab shows. */
|
|
455
|
+
export interface CacheBreakState { enabled: boolean; calls: number; breaks: readonly CacheBreakRecord[]; file: string | undefined }
|
|
456
|
+
|
|
457
|
+
/** The one thing needed from Pi's context to find a session's log. */
|
|
458
|
+
export interface SessionRef { sessionManager: { getSessionId(): string } }
|
|
459
|
+
|
|
460
|
+
export interface CacheBreakOptions {
|
|
461
|
+
/** Runtime-toggleable setting; the caller owns its persisted value. */
|
|
462
|
+
enabled(): boolean;
|
|
463
|
+
/** Directory of the per-session logs; `pi-jar-cache-breaks` in Pi's agent directory by default (tests pass their own). */
|
|
464
|
+
directory?: string;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Watch provider requests and calls for costly cache breaks, announce each one and log it. Observes only:
|
|
469
|
+
* the payload and the messages are never modified. A fingerprint of a payload that another extension
|
|
470
|
+
* rewrites afterwards is of the payload as this extension saw it.
|
|
471
|
+
*/
|
|
472
|
+
export function registerCacheBreaks(pi: ExtensionAPI, options: CacheBreakOptions): { state(ctx: SessionRef): CacheBreakState } {
|
|
473
|
+
const tracker = new CacheBreakTracker();
|
|
474
|
+
const logFile = (ctx: SessionRef) => cacheBreakFile(ctx.sessionManager.getSessionId(), options.directory);
|
|
475
|
+
|
|
476
|
+
pi.on("session_start", (_event, ctx) => {
|
|
477
|
+
let earlier: CacheBreakRecord[] = [];
|
|
478
|
+
try { earlier = readCacheBreaks(logFile(ctx)); } catch { /* The log is optional. */ }
|
|
479
|
+
tracker.reset(earlier);
|
|
480
|
+
});
|
|
481
|
+
// Compaction and navigating the tree rewrite the history on purpose; the next call starts a new baseline.
|
|
482
|
+
pi.on("session_compact", () => tracker.rebase());
|
|
483
|
+
pi.on("session_tree", () => tracker.rebase());
|
|
484
|
+
pi.on("cache_warming_decision", (event) => { if (event.action === "warm") tracker.warmed(Date.now()); });
|
|
485
|
+
|
|
486
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
487
|
+
if (!options.enabled()) { tracker.rebase(); return; }
|
|
488
|
+
let segments: Segment[] | undefined;
|
|
489
|
+
try { segments = fingerprintPayload(event.payload, ctx.model?.provider); } catch { /* Not a JSON-shaped payload. */ }
|
|
490
|
+
tracker.request(segments);
|
|
491
|
+
// No return value: Pi keeps the payload as it is.
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
pi.on("message_end", (event, ctx) => {
|
|
495
|
+
const message = event.message;
|
|
496
|
+
if (message.role !== "assistant" || !options.enabled()) return;
|
|
497
|
+
try {
|
|
498
|
+
const model = ctx.model?.provider === message.provider && ctx.model.id === message.model ? ctx.model : ctx.modelRegistry?.find?.(message.provider, message.model);
|
|
499
|
+
const found = tracker.complete({
|
|
500
|
+
usage: message.usage, model: `${message.provider}/${message.model}`, readPerMTok: model?.cost?.cacheRead,
|
|
501
|
+
at: Number.isFinite(message.timestamp) && message.timestamp > 0 ? message.timestamp : Date.now()
|
|
502
|
+
});
|
|
503
|
+
if (!found) return;
|
|
504
|
+
try {
|
|
505
|
+
const file = logFile(ctx);
|
|
506
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
507
|
+
appendFileSync(file, JSON.stringify(found) + "\n", { mode: 0o600 });
|
|
508
|
+
} catch { /* The notice and the tab still report it. */ }
|
|
509
|
+
const cost = found.costUsd !== undefined ? ` (~${money(found.costUsd)})` : "";
|
|
510
|
+
ctx.ui.notify(`cache break: ${found.summary} — rewrote ${formatTokens(found.rewrittenTokens)} tokens${cost}`, "warning");
|
|
511
|
+
} catch { /* Diagnostics must never disturb a run. */ }
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
state(ctx) {
|
|
516
|
+
let file: string | undefined;
|
|
517
|
+
try { file = logFile(ctx); } catch { /* No session id yet. */ }
|
|
518
|
+
return { enabled: options.enabled(), calls: tracker.checked, breaks: tracker.breaks, file };
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
}
|