pi-jar 0.2.3 → 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 +32 -3
- package/docs/DEVELOPMENT.md +2 -1
- package/docs/EFFICIENCY_PLAN.md +3 -0
- package/docs/INTEGRATIONS.md +2 -1
- package/extensions/index.ts +4 -0
- package/package.json +1 -1
- package/src/cache-breaks-view.ts +56 -0
- package/src/cache-breaks.ts +521 -0
- package/src/info-panels.ts +9 -3
- package/src/panel.ts +1 -1
- package/src/profiles.ts +2 -1
- package/src/settings-ui.ts +13 -10
- package/src/settings.ts +4 -1
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ The showcase is captured from a real pi-jar terminal session: the torch-style `
|
|
|
20
20
|
| **Goal mode** | Set an outcome; the agent must break it into tracked tasks and keeps working until they are done, then an **auditor** pass verifies the goal before it can be marked complete. |
|
|
21
21
|
| **Roles** | Named model roles (`default`, `smol`, `slow`, `plan`, `implement`, `advisor`, `moderator`, `scout`, `worker`, `reviewer`, `task`, `commit`, plus your own) with aliases, effort, per-role fallback models and project overrides. |
|
|
22
22
|
| **Advisor** | A second-opinion model: the agent calls `jar_advisor` when you ask or after two failed attempts, `/advisor [focus]` asks on demand, and opt-in automatic gates consult it when the agent repeats the same tool call or keeps failing. |
|
|
23
|
-
| **Usage & context** | `/usage` shows session cost and tokens per model (advisor and commit calls included) and plan-limit bars with reset times; `/context` draws a grid of what fills the context window, with a per-file, per-skill and per-tool breakdown. |
|
|
23
|
+
| **Usage & context** | `/usage` shows session cost and tokens per model (advisor and commit calls included) and plan-limit bars with reset times; `/context` draws a grid of what fills the context window, with a per-file, per-skill and per-tool breakdown; `/cache-breaks` names the tool, system section or message whose change forced a costly prompt-cache rewrite. |
|
|
24
24
|
| **Commit** | `/jar commit [note]` drafts a message for the staged changes with the `commit` role, lets you edit it, then commits (never pushes). |
|
|
25
25
|
| **Tasks & questions** | A Claude/omp-style `jar_todo` checklist with subtasks and per-task progress that the agent maintains itself, and `jar_ask` structured questions with options, multi-select and free-form answers. |
|
|
26
26
|
| **Change review** | Every file the agent or its editing subagents change is remembered as it was; `/diff` (<kbd>Ctrl</kbd>+<kbd>Alt</kbd>+<kbd>D</kbd>) shows changed files (each counted once) next to a colored diff, with accept or revert per file or all at once. |
|
|
@@ -59,6 +59,7 @@ Pi only delivers mouse events in its **fullscreen** TUI mode. In regular mode th
|
|
|
59
59
|
| `/profiles` | Create or switch profiles. `/profiles new` creates (name and theme, then roles), `/profiles <name>` switches. |
|
|
60
60
|
| `/advisor [focus]` | Ask the advisor for a second opinion on the current work; the answer joins the conversation. |
|
|
61
61
|
| `/usage` · `/context` | Usage (cost, tokens per model, plan limits) and context-window breakdown in one tabbed panel. |
|
|
62
|
+
| `/cache-breaks` | The panel's **Cache** tab: costly prompt-cache breaks in this session, what changed, tokens rewritten and the estimated extra cost. See [Cache break diagnostics](#cache-break-diagnostics). |
|
|
62
63
|
| `/jar commit [note]` | Draft a commit message for the staged changes with the `commit` role, edit it, and commit. Offers `git add -A` when nothing is staged. |
|
|
63
64
|
| `/jar` · `/jar settings` | Visual and workflow preferences. |
|
|
64
65
|
| `/jar status` | One-line status summary. |
|
|
@@ -250,10 +251,37 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
|
|
|
250
251
|
|
|
251
252
|
## Usage and context
|
|
252
253
|
|
|
253
|
-
`/usage` and `/
|
|
254
|
+
`/usage`, `/context` and `/cache-breaks` open one tabbed panel (<kbd>Tab</kbd> switches, <kbd>Esc</kbd> closes):
|
|
254
255
|
|
|
255
256
|
- **Usage** — total cost, duration, prompts and responses, tokens (input, output, cache read/write); a per-model breakdown including advisor and commit calls; and, for Anthropic and OpenAI Codex subscriptions, 5-hour and weekly limit bars with reset times (lookups follow `/jar quota on|off`). A failed lookup shows its reason (for example the provider's HTTP 429 rate limit) and when it retries, instead of loading indefinitely.
|
|
256
257
|
- **Context** — a 10×10 grid (each cell ≈ 1% of the window) beside a legend: system prompt, tools, context files, skills, compaction summary, user and assistant messages, tool results, extension messages, free space and the autocompact buffer. Parts are estimated at ~4 characters per token and scaled to the provider-reported total when one is known; context files, skills and tools are listed individually below.
|
|
258
|
+
- **Cache** — the costly prompt-cache breaks of this session with totals by cause; see below.
|
|
259
|
+
|
|
260
|
+
### Cache break diagnostics
|
|
261
|
+
|
|
262
|
+
Providers cache a prompt as an ordered prefix: tools, then the system prompt, then the messages. Change anything early in a request and the provider re-processes, and bills again, everything after it. Pi tells you *that* a call missed the cache; pi-jar tells you *what changed*. It is on by default (**`/jar settings` → Pi → Cache diagnostics**, saved per profile) and only observes: it never modifies a request or a message.
|
|
263
|
+
|
|
264
|
+
- **How it finds the change.** For every provider request pi-jar reduces the final payload (Anthropic Messages, OpenAI Responses and Chat Completions; other shapes are skipped) to an ordered list of segments: the model, each tool, each system-prompt section (`<tools>`, `<skills>`, … or markdown headings and paragraphs) and each message, labelled by role or tool name. A segment keeps only a hash and a size, never content, and the cache markers that move with every call are ignored. The first segment where the previous request is no longer a prefix of this one is the change.
|
|
265
|
+
- **When it matters.** The call's own usage decides. A *costly break* is a call whose cache read fell more than 4,096 tokens short of the previous prompt, or that read nothing from a prompt over 8,192 tokens. Appending messages never is one. The first call of a session, and the first after compaction or tree navigation (which rewrite the history on purpose), have nothing to compare with, and providers that never report cache reads are skipped.
|
|
266
|
+
- **What you see.** One warning per costly break, with the extra cost estimated from the call's own prices, and the **Cache** tab (`/cache-breaks`) with totals by cause:
|
|
267
|
+
|
|
268
|
+
```text
|
|
269
|
+
cache break: tools changed (+jendral_build_get, +17 more) — rewrote 235k tokens (~$1.89)
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
| Cause | Reading it |
|
|
273
|
+
| --- | --- |
|
|
274
|
+
| `tools changed (+a, -b, ~c)` | Tools were added, removed or redefined, or `reordered`. Activating tools mid-session (an MCP server, a tool package) rewrites the whole prompt. |
|
|
275
|
+
| `system prompt changed (~<skills>)` | The named sections changed (`~`), appeared (`+`) or went (`-`). |
|
|
276
|
+
| `message 14/87 shrank (tool result: read, 52k → 98 chars, 73 from the end)` | An earlier message was rewritten, for example by an extension that replaces old tool results with placeholders; everything after it is re-processed. `first change` in the tab gives its position in the ordered prompt. |
|
|
277
|
+
| `history shortened (87 → 40 messages)` | Messages were dropped from the end of the history. |
|
|
278
|
+
| `model changed (a → b)` | A different model or provider has its own cache. |
|
|
279
|
+
| `cache expired after 12m idle` | The prompt is unchanged, but more than about 5 minutes passed since the cache was last warm (Pi's cache warming counts). |
|
|
280
|
+
| `no prompt change found` | The prefix is identical, so the content of the prompt does not explain the miss: provider-side eviction, or a request setting outside the fingerprint such as the thinking level or `tool_choice`. |
|
|
281
|
+
|
|
282
|
+
A change is followed by `, after 12m idle` when the cache had also lapsed.
|
|
283
|
+
- **Where it is kept.** Each costly break is appended as one JSON line to `pi-jar-cache-breaks/<session id>.jsonl` in Pi's agent directory, so it survives `/reload` and resuming the session: `timestamp`, `request` (provider calls pi-jar has seen for the session), `kind`, `summary`, `segment` and `first` (the first changed segment), `model`, `promptTokens`, `cacheReadTokens`, `rewrittenTokens`, `costUsd` and `idleMs`. Hashes and prompt text are never written.
|
|
284
|
+
- **Limits.** pi-jar fingerprints the payload as it sees it; a `before_provider_request` handler of an extension loaded after pi-jar can still change it. Subagent processes do not run the diagnostic.
|
|
257
285
|
|
|
258
286
|
## Tasks, questions and history
|
|
259
287
|
|
|
@@ -286,7 +314,7 @@ This is a pi-jar enhancement: [pi-advisor](https://github.com/philipbrembeck/pi-
|
|
|
286
314
|
|
|
287
315
|
- **Appearance** — accent, motion, rounded composer, Ember mascot, next-prompt suggestions, pi-jar UI, icons (`unicode` default, `nerd` for a [Nerd Font](https://www.nerdfonts.com) terminal like omp's nerd preset, `ascii` for plain labels).
|
|
288
316
|
- **Footer** — field visibility. In fullscreen mode the session name (or `sessions` for an unnamed session) opens the session picker and each subagent/shell row opens the activity view. SoL-Pi's savings status is not repeated in the footer (it already notifies).
|
|
289
|
-
- **Pi** — mouse clicks (Pi fullscreen mode), copy on select, goal auto rounds, **Max subagents** (explicit choice then Save; default 2, existing saved values are kept), advisor, advisor gates (default off), context diet, context budget and its limit, repeated-read stub.
|
|
317
|
+
- **Pi** — mouse clicks (Pi fullscreen mode), copy on select, goal auto rounds, **Max subagents** (explicit choice then Save; default 2, existing saved values are kept), advisor, advisor gates (default off), context diet, cache diagnostics (default on), context budget and its limit, repeated-read stub.
|
|
290
318
|
|
|
291
319
|
pi-jar preferences are saved in `pi-jar-settings.json` in Pi's agent directory; the Pi tab writes Pi's own settings.
|
|
292
320
|
|
|
@@ -301,6 +329,7 @@ pi-jar/
|
|
|
301
329
|
├── extensions/index.ts extension entry: wiring, welcome, footer, commands
|
|
302
330
|
├── src/
|
|
303
331
|
│ ├── context-budget.ts context budget, per-call cost average, topic and cold-cache hints
|
|
332
|
+
│ ├── cache-breaks.ts prompt-cache break diagnostics: payload fingerprints, diff, usage correlation, JSONL log; cache-breaks-view.ts is its tab
|
|
304
333
|
│ ├── flame.ts pixel fire simulation
|
|
305
334
|
│ ├── mascot.ts Ember's moods and sprites
|
|
306
335
|
│ ├── composer.ts rounded composer, ghost text, mouse mapping
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -43,7 +43,7 @@ npm run check # tsc --noEmit
|
|
|
43
43
|
npm test # node:test suites in tests/
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Tests isolate `PI_CODING_AGENT_DIR`, so they never touch your real preferences. Suites cover the flame simulation, welcome layout and hit-testing, composer framing/expansion/ghost text/mouse mapping, the mascot, plan mode (guards, submission, reminders, restore), the plan view, plan parsing, the goal loop, roles v2, suggestions and
|
|
46
|
+
Tests isolate `PI_CODING_AGENT_DIR`, so they never touch your real preferences. Suites cover the flame simulation, welcome layout and hit-testing, composer framing/expansion/ghost text/mouse mapping, the mascot, plan mode (guards, submission, reminders, restore), the plan view, plan parsing, the goal loop, roles v2, suggestions, settings and the cache-break diagnostic (payload fingerprints for Anthropic and OpenAI shapes, diff classification, usage correlation, the `/cache-breaks` tab). `tests/cache-payloads.ts` builds the provider payloads those suites share.
|
|
47
47
|
|
|
48
48
|
Manual smoke test (offline, throwaway agent directory, nothing sent to a model):
|
|
49
49
|
|
|
@@ -61,6 +61,7 @@ Then check:
|
|
|
61
61
|
- `/jar activity`: select a subagent or background shell, scroll its live transcript/output, pause and resume follow, `Tab` into a subagent's transcript and expand/collapse entries, steer it with `s`, and stop/kill only the selected run;
|
|
62
62
|
- `/roles`: the split manager, `n` new role, `a` alias, `s` scope;
|
|
63
63
|
- `/plan`: the status shows `◆ PLAN · read-only` and a directory appears under `$TMPDIR/pi-jar/plans/`.
|
|
64
|
+
- `/cache-breaks`: a fresh session reports no breaks and `/usage` has a **Cache** tab; with a logged-in model, activate an extra tool (or edit an earlier message through an extension) mid-session and expect `cache break: …` after the next call, plus a line in `pi-jar-cache-breaks/<session id>.jsonl` under the temporary agent directory; **Pi → Cache diagnostics** off silences both.
|
|
64
65
|
|
|
65
66
|
For pointer interaction start with `--tui-mode fullscreen` (or enable **Pi → Mouse clicks** and restart). Check that welcome actions fire once per press, that footer activity rows open their matching details, that the footer session indicator opens search, that clicking composer text moves the cursor, that clicking Ember pokes it, that plan-view headings and actions respond to clicks and the wheel, and that selecting text anywhere still copies it. In regular mode use `/jar activity` and `/jar sessions` instead.
|
|
66
67
|
|
package/docs/EFFICIENCY_PLAN.md
CHANGED
|
@@ -48,10 +48,13 @@ Baseline: a 309-call, $6.34 session (`node scripts/session-cost.mjs 01a101ba-…
|
|
|
48
48
|
| Events: ≤400-char summary + changed files, one message per boundary, discussion mail inlined | `src/delegate.ts`, `src/discussion.ts` | Events shrink from ~1.9 KB to ~0.5 KB; no `list` call to read mail; no echoes of own messages |
|
|
49
49
|
| `/roles` warns when `scout`/`reviewer` run on the main model while a cheaper one is available | `src/model-roles.ts` | Subagent calls move to a cheaper model |
|
|
50
50
|
| Advisor gates off by default; loop threshold 4; re-reads, shell polls and test reruns are never loops | `src/advisor.ts` | No side calls from normal fix loops |
|
|
51
|
+
| Cache-break diagnostics: one notice per costly prompt-cache break, `/cache-breaks`, a per-session JSONL log | `src/cache-breaks.ts`, `src/cache-breaks-view.ts` | The tool list, system section or message that forced a rewrite is named instead of guessed |
|
|
51
52
|
| Rules moved into tool descriptions; `jar_plan_submit`/`jar_goal` active only in their mode | all tool modules, `src/tool-activation.ts` | Smaller fixed prompt (below) |
|
|
52
53
|
|
|
53
54
|
Fixed prompt that pi-jar contributes, measured from Pi's built system message in RPC mode (same project, same Pi 1.0.4): the `rules` section went from 7,584 to 1,675 characters and `tools` from 1,773 to 1,476; two schemas (~900 characters) are no longer sent outside their mode. Tool descriptions and schemas grew by ~1.1k characters because the guidance moved there. Net: ~5k characters (~1.3k tokens) less on every call. That is modest next to context growth, so the context budget and shell/read changes carry most of the saving.
|
|
54
55
|
|
|
56
|
+
Cache breaks: an audit of a $30 session found ~68% of the cost was cache rewrites caused by prompt-prefix changes: tools activated mid-session (the cache read fell to 0 and ~230k tokens were rewritten per call), an extension replacing old tool results with placeholders (the read stuck at a mid-history offset), and one unexplained break at a user-turn boundary. Pi reports *that* a call missed the cache, not why. `src/cache-breaks.ts` fingerprints each provider payload (model, tools, system-prompt sections, messages; hashes and sizes only), finds the first segment where the previous request stopped being a prefix of this one, and joins that with the call's usage; a call whose cache read falls more than 4,096 tokens short of the previous prompt is a costly break. It only observes, and is on by default (`cacheDiagnostics`).
|
|
57
|
+
|
|
55
58
|
Not done: Pi's compaction thresholds and the user's own settings are left alone; recommendations are in [INTEGRATIONS.md](./INTEGRATIONS.md#keeping-sessions-cheap-recommendations). The ≤ $3 field target needs a real re-run of a similar task with the new defaults.
|
|
56
59
|
|
|
57
60
|
## Already covered — dropped
|
package/docs/INTEGRATIONS.md
CHANGED
|
@@ -74,7 +74,7 @@ Mode-specific tools are deactivated when their mode is off, so their schema and
|
|
|
74
74
|
|
|
75
75
|
## Commands that overlap other packages
|
|
76
76
|
|
|
77
|
-
pi-jar registers `/usage`, `/context` and `/advisor`. Pi keeps both commands when two extensions register the same name and suffixes the later one (for example `/usage:2`), so remove packages whose commands you no longer need (such as a separate usage, context or advisor extension) to keep the plain names on pi-jar.
|
|
77
|
+
pi-jar registers `/usage`, `/context`, `/cache-breaks` and `/advisor`. Pi keeps both commands when two extensions register the same name and suffixes the later one (for example `/usage:2`), so remove packages whose commands you no longer need (such as a separate usage, context or advisor extension) to keep the plain names on pi-jar.
|
|
78
78
|
|
|
79
79
|
## Keeping sessions cheap (recommendations)
|
|
80
80
|
|
|
@@ -83,6 +83,7 @@ Every model call re-sends the whole context, so cost grows with session length f
|
|
|
83
83
|
- **Compact earlier.** In `~/.pi/agent/settings.json` set `"compaction": { "reserveTokens": 140000, "keepRecentTokens": 20000 }`. With a 272k-window model this compacts around 130k instead of near the limit. pi-jar's context budget (`/jar settings` → Context budget) suggests `/compact` or `/new` at the same point, or compacts at the next finished task when set to `compact`.
|
|
84
84
|
- **Enable heavy tool packages per project.** A package such as `pi-mono-figma` (~20 tools) sends every schema on every call; enable it only in projects that use it.
|
|
85
85
|
- **Load each MCP server once.** Use codebase-memory either through the lazy `mcp` proxy **or** as direct tools, not both.
|
|
86
|
+
- **Activate tools at startup, not mid-session.** Changing the tool list, the system prompt or an earlier message makes the provider re-process the whole prefix after it: an MCP server or tool package switched on halfway through a long session rewrote ~230k tokens on every call until the cache caught up. pi-jar announces costly cache breaks and `/cache-breaks` lists what changed (see [Cache break diagnostics](../README.md#cache-break-diagnostics)).
|
|
86
87
|
- **Review rarely used packages** (for example `i-have-adhd`, `pi-mono-loop`, `pi-mono-btw`) and enable them per project.
|
|
87
88
|
- **One task per session.** Start `/new` when the next request is unrelated; pi-jar suggests it when a finished checklist is followed by a new prompt in a large context.
|
|
88
89
|
- **Measure.** `node scripts/session-cost.mjs <session.jsonl | id>` (in a pi-jar checkout) reports calls, the cost split, context buckets, repeated reads, tool counts and cache misses for one session.
|
package/extensions/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { ModelRoleManager } from "../src/model-roles.ts";
|
|
|
26
26
|
import { jarCommit } from "../src/commit.ts";
|
|
27
27
|
import { registerAdvisor } from "../src/advisor.ts";
|
|
28
28
|
import { registerContextDiet } from "../src/context-diet.ts";
|
|
29
|
+
import { registerCacheBreaks } from "../src/cache-breaks.ts";
|
|
29
30
|
import { registerInfoPanels } from "../src/info-panels.ts";
|
|
30
31
|
import { SideUsage } from "../src/side-model.ts";
|
|
31
32
|
import { PlanMode } from "../src/plan.ts";
|
|
@@ -320,10 +321,13 @@ export default function piJar(pi: ExtensionAPI): void {
|
|
|
320
321
|
const modelRoles = new ModelRoleManager(pi);
|
|
321
322
|
const sideUsage = new SideUsage();
|
|
322
323
|
registerContextDiet(pi, () => visualSettings.contextDiet === true);
|
|
324
|
+
// Observe-only: explains costly prompt-cache breaks; subagent processes have no UI to tell.
|
|
325
|
+
const cacheBreaks = registerCacheBreaks(pi, { enabled: () => !delegatedChild && visualSettings.cacheDiagnostics !== false });
|
|
323
326
|
const advisor = registerAdvisor(pi, modelRoles, { enabled: () => !delegatedChild && visualSettings.advisor, gates: () => !delegatedChild && visualSettings.advisorGates, usage: sideUsage });
|
|
324
327
|
registerInfoPanels(pi, { side: sideUsage, quotaEnabled: () => quotaCache?.enabled ?? false,
|
|
325
328
|
quota: (ctx) => quotaCache?.get(ctx.model?.provider, welcomeStatuses(), Date.now()),
|
|
326
329
|
quotaFailure: (ctx) => quotaCache?.failure(ctx.model?.provider, Date.now()),
|
|
330
|
+
cacheBreaks: (ctx) => cacheBreaks.state(ctx),
|
|
327
331
|
refreshQuota: (ctx) => { quotaCache?.refresh(ctx.model?.provider, welcomeStatuses(), Date.now()); } });
|
|
328
332
|
let followingTheme: string | undefined;
|
|
329
333
|
const profileEntries = (ctx: ExtensionContext): readonly unknown[] => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-jar",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A warm, role-aware Pi terminal workspace with switchable profiles, animated UI, plan and goal workflows, tasks, change review, model roles, background shells and subagents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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
|
+
}
|
package/src/info-panels.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { getAgentDir, SettingsManager, type ExtensionAPI, type ExtensionContext, type Skill } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { cacheBreakLines } from "./cache-breaks-view.ts";
|
|
3
|
+
import type { CacheBreakState } from "./cache-breaks.ts";
|
|
2
4
|
import { contextBreakdown, contextLines } from "./context-view.ts";
|
|
3
|
-
import { openPanel, type PanelTab } from "./panel.ts";
|
|
5
|
+
import { openPanel, type Paint, type PanelTab } from "./panel.ts";
|
|
4
6
|
import type { Quota } from "./quota.ts";
|
|
5
7
|
import type { SideUsage } from "./side-model.ts";
|
|
6
8
|
import { collectUsage, usageLines } from "./usage-view.ts";
|
|
@@ -16,6 +18,8 @@ export interface InfoPanelDeps {
|
|
|
16
18
|
quotaEnabled(): boolean;
|
|
17
19
|
/** Start a limit lookup when nothing fresh is cached: opening `/usage` is an explicit request. */
|
|
18
20
|
refreshQuota(ctx: ExtensionContext): void;
|
|
21
|
+
/** Costly prompt-cache breaks of this session for the Cache tab and `/cache-breaks`; omitted when unavailable. */
|
|
22
|
+
cacheBreaks?(ctx: ExtensionContext): CacheBreakState;
|
|
19
23
|
}
|
|
20
24
|
|
|
21
25
|
function reserveTokens(ctx: ExtensionContext): number {
|
|
@@ -48,7 +52,7 @@ export function contextFor(pi: ExtensionAPI, ctx: ExtensionContext, options?: Pr
|
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
54
|
|
|
51
|
-
/** `/usage` and `/
|
|
55
|
+
/** `/usage`, `/context` and `/cache-breaks`: one tabbed panel, like Claude's usage and context views. */
|
|
52
56
|
export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void {
|
|
53
57
|
let parts: PromptParts | undefined;
|
|
54
58
|
pi.on("before_agent_start", (event) => {
|
|
@@ -76,7 +80,8 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
76
80
|
{ name: "Context", render: (width, fg) => {
|
|
77
81
|
context ??= contextFor(pi, ctx, parts);
|
|
78
82
|
return contextLines(context, width, fg);
|
|
79
|
-
} }
|
|
83
|
+
} },
|
|
84
|
+
...(deps.cacheBreaks ? [{ name: "Cache", render: (width: number, fg: Paint) => cacheBreakLines({ ...deps.cacheBreaks!(ctx), now: Date.now() }, width, fg) }] : [])
|
|
80
85
|
];
|
|
81
86
|
};
|
|
82
87
|
const plain = (_color: string, text: string) => text;
|
|
@@ -90,4 +95,5 @@ export function registerInfoPanels(pi: ExtensionAPI, deps: InfoPanelDeps): void
|
|
|
90
95
|
};
|
|
91
96
|
pi.registerCommand("usage", { description: "Session cost, tokens per model and plan limits (pi-jar)", handler: async (_args, ctx) => open(ctx, 0) });
|
|
92
97
|
pi.registerCommand("context", { description: "Breakdown of what fills the context window (pi-jar)", handler: async (_args, ctx) => open(ctx, 1) });
|
|
98
|
+
if (deps.cacheBreaks) pi.registerCommand("cache-breaks", { description: "Costly prompt-cache breaks in this session and what changed (pi-jar)", handler: async (_args, ctx) => open(ctx, 2) });
|
|
93
99
|
}
|
package/src/panel.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { RowCache } from "./row-cache.ts";
|
|
|
5
5
|
export type Paint = (color: string, text: string) => string;
|
|
6
6
|
export interface PanelTab { name: string; render(width: number, fg: Paint): string[] }
|
|
7
7
|
|
|
8
|
-
/** Tabbed, scrollable info panel (Usage / Context). Tab or click switches tabs; Esc closes. */
|
|
8
|
+
/** Tabbed, scrollable info panel (Usage / Context / Cache). Tab or click switches tabs; Esc closes. */
|
|
9
9
|
export async function openPanel(ctx: ExtensionContext, tabs: readonly PanelTab[], initial = 0): Promise<void> {
|
|
10
10
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
11
11
|
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
package/src/profiles.ts
CHANGED
|
@@ -42,6 +42,7 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
42
42
|
|| typeof settings.composer !== "boolean" || typeof settings.mascot !== "boolean" || typeof settings.suggestions !== "boolean"
|
|
43
43
|
|| typeof settings.advisor !== "boolean" || typeof settings.advisorGates !== "boolean"
|
|
44
44
|
|| (settings.contextDiet !== undefined && typeof settings.contextDiet !== "boolean")
|
|
45
|
+
|| (settings.cacheDiagnostics !== undefined && typeof settings.cacheDiagnostics !== "boolean")
|
|
45
46
|
|| (settings.contextBudget !== undefined && !parseContextBudget(settings.contextBudget))
|
|
46
47
|
|| (settings.readCache !== undefined && typeof settings.readCache !== "boolean") || !Number.isInteger(settings.goalRounds)
|
|
47
48
|
|| (settings.goalRounds as number) < 1 || (settings.goalRounds as number) > 50
|
|
@@ -54,7 +55,7 @@ function normalizeProfile(value: unknown): JarProfile | undefined {
|
|
|
54
55
|
for (const [role, spec] of Object.entries(raw.roles.roles)) if (!isRoleName(role) || typeof spec !== "string" || !normalizeSpec(spec)) return undefined;
|
|
55
56
|
const contextBudget = parseContextBudget(settings.contextBudget) ?? { ...DEFAULT_CONTEXT_BUDGET };
|
|
56
57
|
return { id: raw.id as string, name: raw.name, theme: raw.theme, settings: { ...raw.settings, contextDiet: settings.contextDiet ?? false,
|
|
57
|
-
contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
58
|
+
cacheDiagnostics: settings.cacheDiagnostics ?? true, contextBudget, readCache: settings.readCache ?? false } as unknown as JarVisualSettings, roles: parseRoleConfig(raw.roles) };
|
|
58
59
|
}
|
|
59
60
|
function atomicSave(file: string, document: ProfileDocument): string {
|
|
60
61
|
mkdirSync(join(file, ".."), { recursive: true });
|
package/src/settings-ui.ts
CHANGED
|
@@ -42,9 +42,9 @@ export async function openJarSettings(
|
|
|
42
42
|
name === "default" || ["gray", "pink", "teal", "azure", "violet", "amber"].includes(name))];
|
|
43
43
|
let choosing: "goalRounds" | "maxSubagents" | "contextBudget" | undefined;
|
|
44
44
|
const choices = () => choosing === "maxSubagents" ? MAX_SUBAGENT_CHOICES : choosing === "contextBudget" ? CONTEXT_BUDGET_CHOICES : GOAL_ROUND_CHOICES;
|
|
45
|
-
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ?
|
|
45
|
+
const fields = () => choosing ? choices().length : page === "footer" ? FOOTER_FIELDS.length : page === "pi" ? 11 : page === "profiles" ? 2 : 7;
|
|
46
46
|
// Back on the row that opened the picker.
|
|
47
|
-
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : choosing === "contextBudget" ?
|
|
47
|
+
const finishChoice = () => { selected = choosing === "maxSubagents" ? 3 : choosing === "contextBudget" ? 9 : 2; choosing = undefined; };
|
|
48
48
|
const piError = (error: unknown) => ctx.ui.notify("Could not change Pi setting: " + (error as Error).message, "error");
|
|
49
49
|
const explainPool = () => choosing === "maxSubagents" || (page === "pi" && !choosing && selected === 3);
|
|
50
50
|
const pageSize = () => Math.min(fields(), Math.max(4, (process.stdout.rows ?? 24) - (explainPool() ? 10 : 9)));
|
|
@@ -78,15 +78,16 @@ export async function openJarSettings(
|
|
|
78
78
|
else if (index === 4) update({ ...state, advisor: !state.advisor });
|
|
79
79
|
else if (index === 5) update({ ...state, advisorGates: !state.advisorGates });
|
|
80
80
|
else if (index === 6) update({ ...state, contextDiet: !state.contextDiet });
|
|
81
|
-
else if (index === 7) {
|
|
81
|
+
else if (index === 7) update({ ...state, cacheDiagnostics: !state.cacheDiagnostics });
|
|
82
|
+
else if (index === 8) {
|
|
82
83
|
const at = CONTEXT_BUDGET_ACTIONS.indexOf(state.contextBudget.action);
|
|
83
84
|
update({ ...state, contextBudget: { ...state.contextBudget, action: CONTEXT_BUDGET_ACTIONS[(at + 1) % CONTEXT_BUDGET_ACTIONS.length]! } });
|
|
84
85
|
}
|
|
85
|
-
else if (index ===
|
|
86
|
+
else if (index === 9) {
|
|
86
87
|
choosing = "contextBudget";
|
|
87
88
|
selected = Math.max(0, CONTEXT_BUDGET_CHOICES.indexOf(state.contextBudget.softTokens as never));
|
|
88
89
|
}
|
|
89
|
-
else if (index ===
|
|
90
|
+
else if (index === 10) update({ ...state, readCache: !state.readCache });
|
|
90
91
|
} else if (index === 0) {
|
|
91
92
|
const at = Math.max(0, accents.indexOf(state.accent));
|
|
92
93
|
update({ ...state, accent: accents[(at + 1) % accents.length]! });
|
|
@@ -176,9 +177,10 @@ export async function openJarSettings(
|
|
|
176
177
|
row(4, "Advisor (jar_advisor, /advisor)", state.advisor ? "ON" : "OFF", state.advisor),
|
|
177
178
|
row(5, "Advisor gates (loops, repeated failures)", state.advisorGates ? "ON" : "OFF", state.advisorGates),
|
|
178
179
|
row(6, "Context diet (prior-turn reasoning)", state.contextDiet ? "ON" : "OFF", state.contextDiet),
|
|
179
|
-
row(7, "
|
|
180
|
-
row(8, "Context budget limit",
|
|
181
|
-
row(9, "
|
|
180
|
+
row(7, "Cache diagnostics (explain cache breaks)", state.cacheDiagnostics ? "ON" : "OFF", state.cacheDiagnostics),
|
|
181
|
+
row(8, "Context budget (past the limit)", state.contextBudget.action.toUpperCase(), budgetOn),
|
|
182
|
+
row(9, "Context budget limit", formatTokens(state.contextBudget.softTokens), budgetOn),
|
|
183
|
+
row(10, "Repeated-read stub (unchanged re-reads)", state.readCache ? "ON" : "OFF", state.readCache)
|
|
182
184
|
] : page === "profiles" ? [
|
|
183
185
|
row(0, "Switch profile…", profiles!.activeName(), true),
|
|
184
186
|
row(1, "Create profile…", "OPEN", true)
|
|
@@ -200,8 +202,9 @@ export async function openJarSettings(
|
|
|
200
202
|
line(theme.fg("dim", explainPool() ? " Live pool includes idle/paused subagents"
|
|
201
203
|
: choosing ? " Select a value; Enter/click to save, Esc to cancel"
|
|
202
204
|
: page === "footer" ? ` Footer fields ${start + 1}–${start + rows.length}/${fields()}`
|
|
203
|
-
: page === "pi" ? (selected === 7
|
|
204
|
-
: selected === 9 ? "
|
|
205
|
+
: page === "pi" ? (selected === 7 ? " Names what changed when a call re-sent cached context (/cache-breaks)"
|
|
206
|
+
: selected === 8 || selected === 9 ? " Past the limit: suggest /compact or /new, or compact at a safe point"
|
|
207
|
+
: selected === 10 ? " An unchanged re-read of the same range returns a short stub" : " Fullscreen mode enables clicks and copy-on-select")
|
|
205
208
|
: page === "profiles" ? " Switch only in a fresh, idle session; roles via /roles" : " Accent cycles through loaded themes")),
|
|
206
209
|
...rows,
|
|
207
210
|
line(theme.fg("dim", choosing ? " ↑↓: choose · Enter/click: save · Esc: cancel"
|
package/src/settings.ts
CHANGED
|
@@ -25,6 +25,8 @@ export interface JarVisualSettings {
|
|
|
25
25
|
advisorGates: boolean;
|
|
26
26
|
/** Opt-in provider-context pruning of reasoning from completed earlier user turns. */
|
|
27
27
|
contextDiet: boolean;
|
|
28
|
+
/** Explain prompt-cache breaks: which tool, system section or message changed when a call re-sent cached context. */
|
|
29
|
+
cacheDiagnostics: boolean;
|
|
28
30
|
/** Context-size guard: past softTokens, suggest /compact or /new, or compact at the next safe point. */
|
|
29
31
|
contextBudget: ContextBudget;
|
|
30
32
|
/** Opt-in: an unchanged repeated `read` of the same range returns a short stub instead of the content. */
|
|
@@ -57,7 +59,7 @@ export function isMaxSubagents(value: unknown): value is MaxSubagents {
|
|
|
57
59
|
|
|
58
60
|
export const SETTINGS_FILE = "pi-jar-settings.json";
|
|
59
61
|
export function defaultVisualSettings(footer: FooterSettings = DEFAULT_FOOTER_SETTINGS): JarVisualSettings {
|
|
60
|
-
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 2, advisor: true, advisorGates: false, contextDiet: false, contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, readCache: false, icons: "unicode", footer: { ...footer } };
|
|
62
|
+
return { version: 1, accent: "follow", animations: true, ui: true, composer: true, mascot: true, suggestions: true, goalRounds: 8, maxSubagents: 2, advisor: true, advisorGates: false, contextDiet: false, cacheDiagnostics: true, contextBudget: { ...DEFAULT_CONTEXT_BUDGET }, readCache: false, icons: "unicode", footer: { ...footer } };
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
/** Legacy footer choices are imported only while the new file is absent. Never modify the old file. */
|
|
@@ -91,6 +93,7 @@ export function loadVisualSettings(directory: string): JarVisualSettings {
|
|
|
91
93
|
advisor: typeof value.advisor === "boolean" ? value.advisor : fallback.advisor,
|
|
92
94
|
advisorGates: typeof value.advisorGates === "boolean" ? value.advisorGates : fallback.advisorGates,
|
|
93
95
|
contextDiet: typeof value.contextDiet === "boolean" ? value.contextDiet : fallback.contextDiet,
|
|
96
|
+
cacheDiagnostics: typeof value.cacheDiagnostics === "boolean" ? value.cacheDiagnostics : fallback.cacheDiagnostics,
|
|
94
97
|
contextBudget: parseContextBudget(value.contextBudget) ?? fallback.contextBudget,
|
|
95
98
|
readCache: typeof value.readCache === "boolean" ? value.readCache : fallback.readCache,
|
|
96
99
|
icons: ICON_SETS.some((set) => set === value.icons) ? value.icons as IconSet : fallback.icons,
|