pi-plus 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bing Tong
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # pi-plus
2
+
3
+ Claude Code-exact context usage detection for [pi](https://github.com/earendil-works/pi).
4
+
5
+ This extension measures pi's context usage with the **same arithmetic as Claude Code** —
6
+ usage-based prefix counting plus rough tail estimation, CC's context-window resolution,
7
+ and CC's warning thresholds — and renders CC's `TokenWarning` indicator as a footer
8
+ status line. Optionally, it can also take over auto-compaction and fire `ctx.compact()`
9
+ at CC's auto-compact threshold.
10
+
11
+ ## What it replicates (from Claude Code's source)
12
+
13
+ | Behavior | CC source | Here |
14
+ |---|---|---|
15
+ | Context window resolution (env override → `[1m]` suffix → capability ≥100k → 200k default) | `utils/context.ts` | `src/model-config.ts` |
16
+ | Max output tokens per model + 8k slot-reservation cap + env override | `utils/context.ts`, `services/api/claude.ts` | `src/model-config.ts` |
17
+ | Effective window = window − min(maxOutput, 20k) | `services/compact/autoCompact.ts` | `src/model-config.ts` |
18
+ | Auto-compact threshold = effective − 13,000 | `services/compact/autoCompact.ts` | `src/model-config.ts` |
19
+ | Context measurement: last API usage (input+cacheWrite+cacheRead+output) + chars/4 tail estimate; images/PDFs flat 2000 | `utils/tokens.ts`, `services/tokenEstimation.ts` | `src/usage.ts` |
20
+ | Statusline percentage excludes output tokens; raw window denominator | `utils/context.ts` | `src/percentages.ts` |
21
+ | Warning/error/blocking thresholds (20k/20k/3k buffers) | `services/compact/autoCompact.ts` | `src/percentages.ts` |
22
+ | Indicator text: dim `N% until auto-compact`, `Context low (N% remaining) · Run /compact to compact & continue` | `components/TokenWarning.tsx` | `src/indicator.ts` |
23
+
24
+ On a real session the plugin's token count matches pi's own `ctx.getContextUsage().tokens`
25
+ exactly for the Anthropic-style usage shape (pi's `cacheWrite` already includes the 1h
26
+ cache subset and `reasoning` ⊂ `output`, so the field-for-field sums coincide).
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ # quick try
32
+ pi -e ./src/index.ts
33
+
34
+ # permanent: symlink into pi's extension directory
35
+ ln -s "$PWD/src" ~/.pi/agent/extensions/pi-plus
36
+ # (or copy src/ there)
37
+ ```
38
+
39
+ ## Auto-compact takeover (optional)
40
+
41
+ pi's built-in auto-compaction fires at `contextWindow − 16,384` tokens, which is
42
+ *earlier* than CC's threshold (`effective window − 13,000`, i.e. `window − 21,000`
43
+ with the 8k output cap). For the CC threshold to actually govern, disable pi's
44
+ built-in trigger in `~/.pi/agent/settings.json`:
45
+
46
+ ```json
47
+ { "compaction": { "enabled": false } }
48
+ ```
49
+
50
+ The plugin checks the overflow against CC's threshold on **every tool task
51
+ finish** (`tool_execution_end`) and calls `ctx.compact()` the moment the
52
+ threshold is crossed — pi performs the compaction between agent steps and
53
+ continues the turn with the compacted context (the same window pi's own
54
+ auto-compact and CC's autocompact use). The trigger is edge-triggered: it
55
+ fires once per crossing and re-arms after post-compact usage drops below the
56
+ threshold. Manual `/compact` keeps working.
57
+
58
+ Set `PI_CC_CONTEXT_NO_AUTO_COMPACT=1` to disable the plugin's trigger (indicator only).
59
+
60
+ ## Environment variables
61
+
62
+ All CC variables are honored with CC's semantics:
63
+
64
+ | Variable | Effect |
65
+ |---|---|
66
+ | `CLAUDE_CODE_MAX_CONTEXT_TOKENS` | Hard context-window override (highest precedence) |
67
+ | `CLAUDE_CODE_DISABLE_1M_CONTEXT` | Clamp windows >200k to 200k; ignore `[1m]` |
68
+ | `CLAUDE_CODE_AUTO_COMPACT_WINDOW` | Cap the window before threshold math |
69
+ | `CLAUDE_CODE_MAX_OUTPUT_TOKENS` | Override max output tokens (bounded by model's limit) |
70
+ | `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE` | Auto-compact threshold as % of effective window (testing) |
71
+ | `CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE` | Override the blocking limit |
72
+ | `DISABLE_COMPACT` / `DISABLE_AUTO_COMPACT` | CC-style auto-compact off (switches indicator to "Context low … Run /compact") |
73
+
74
+ Plugin-specific:
75
+
76
+ | Variable | Effect |
77
+ |---|---|
78
+ | `PI_CC_CONTEXT_DISABLE_OTK_CAP` | Disable the 8k slot-reservation cap on max output tokens |
79
+ | `PI_CC_CONTEXT_NO_AUTO_COMPACT` | Never call `ctx.compact()` from the plugin |
80
+
81
+ ## Known limits
82
+
83
+ - pi's built-in footer keeps showing its own `percent%` (different math: includes
84
+ output tokens). This plugin adds its own status line; it does not replace the footer.
85
+ - pi core owns the compaction cut point (keeps last ~20k tokens) — only the trigger
86
+ threshold is CC-exact.
87
+ - Exactness is guaranteed for Anthropic-style usage fields; other providers may map
88
+ usage differently.
89
+ - CC's `percentLeft` is not clamped to 100; neither is this port.
90
+
91
+ ## Development
92
+
93
+ ```bash
94
+ npm install
95
+ npm test # vitest — pure-function ports against CC fixtures
96
+ npm run typecheck
97
+ ```
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "pi-plus",
3
+ "version": "0.1.1",
4
+ "description": "Claude Code-exact context usage detection for pi (extension)",
5
+ "type": "module",
6
+ "pi": {
7
+ "extensions": [
8
+ "./src/index.ts"
9
+ ]
10
+ },
11
+ "scripts": {
12
+ "typecheck": "tsc --noEmit",
13
+ "test": "vitest run",
14
+ "test:watch": "vitest"
15
+ },
16
+ "devDependencies": {
17
+ "@earendil-works/pi-ai": "0.85.1",
18
+ "@earendil-works/pi-coding-agent": "0.85.1",
19
+ "typescript": "^5.6.0",
20
+ "vitest": "^2.1.9"
21
+ },
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/sherocktong/pi-plus.git"
26
+ },
27
+ "homepage": "https://github.com/sherocktong/pi-plus#readme",
28
+ "bugs": {
29
+ "url": "https://github.com/sherocktong/pi-plus/issues"
30
+ },
31
+ "keywords": [
32
+ "pi-package",
33
+ "pi",
34
+ "pi-extension",
35
+ "claude-code",
36
+ "context-usage",
37
+ "context-window"
38
+ ],
39
+ "files": [
40
+ "src",
41
+ "README.md",
42
+ "LICENSE"
43
+ ],
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*"
47
+ }
48
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Edge-triggered auto-compact firing.
3
+ *
4
+ * pi's built-in auto-compaction uses its own threshold (contextWindow −
5
+ * reserveTokens, default reserve 16384). To make Claude Code's threshold
6
+ * (effective window − 13000) govern, disable pi's built-in trigger in
7
+ * settings.json (`"compaction": { "enabled": false }`) and let this plugin
8
+ * call ctx.compact() at the CC threshold instead.
9
+ *
10
+ * Trigger policy: the overflow check runs on every tool task finish
11
+ * (tool_execution_end). When the CC threshold is crossed, ctx.compact() fires
12
+ * immediately — pi inserts the compaction between agent steps and continues
13
+ * the turn with the compacted context (the same window pi's own auto-compact
14
+ * and CC's autocompact use), so no work is deferred until idle.
15
+ *
16
+ * Edge-triggered: fires once per threshold crossing, re-arms after the
17
+ * post-compaction count drops below the threshold.
18
+ */
19
+
20
+ export interface AutoCompactTriggerState {
21
+ /** True while usage is below the threshold (re-armed after compaction). */
22
+ below: boolean;
23
+ }
24
+
25
+ export function initialTriggerState(): AutoCompactTriggerState {
26
+ return { below: true };
27
+ }
28
+
29
+ /**
30
+ * Edge trigger: fires exactly once per threshold crossing. After firing, stays
31
+ * disarmed until the token count drops below the threshold again (the
32
+ * post-compaction count is small, so this re-arms naturally).
33
+ */
34
+ export function shouldFireAutoCompact(
35
+ state: AutoCompactTriggerState,
36
+ tokenCount: number,
37
+ threshold: number,
38
+ ): boolean {
39
+ const below = tokenCount < threshold;
40
+ const fire = !below && state.below;
41
+ state.below = below;
42
+ return fire;
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * pi-plus: Claude Code-exact context usage detection.
3
+ *
4
+ * A pi extension that measures context usage with the same arithmetic as
5
+ * Claude Code (usage-based prefix count + rough tail estimation, CC's context
6
+ * window resolution and warning thresholds) and renders CC's TokenWarning
7
+ * indicator as a footer status line. When pi's built-in auto-compaction is
8
+ * disabled, the plugin takes over and calls ctx.compact() at CC's
9
+ * auto-compact threshold (effective window − 13,000 tokens).
10
+ *
11
+ * Install: copy/symlink this repo's src into ~/.pi/agent/extensions/pi-plus/
12
+ * or run `pi -e ./src/index.ts`.
13
+ */
14
+
15
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
16
+ import { initialTriggerState, shouldFireAutoCompact, type AutoCompactTriggerState } from "./auto-compact.ts";
17
+ import { getAutoCompactThreshold } from "./model-config.ts";
18
+ import { isAutoCompactEnabled, calculateTokenWarningState } from "./percentages.ts";
19
+ import { renderTokenWarning } from "./indicator.ts";
20
+ import { tokenCountWithEstimation, type RoughMessage } from "./usage.ts";
21
+
22
+ const STATUS_KEY = "cc-context";
23
+
24
+ function branchMessages(ctx: ExtensionContext): RoughMessage[] {
25
+ return ctx.sessionManager
26
+ .getBranch()
27
+ .filter((entry) => entry.type === "message")
28
+ .map((entry) => entry.message as unknown as RoughMessage);
29
+ }
30
+
31
+ export default function (pi: ExtensionAPI) {
32
+ // Edge-trigger state for the CC-threshold auto-compact. Reset per session;
33
+ // re-arms automatically once post-compact usage drops below the threshold.
34
+ let compactTrigger: AutoCompactTriggerState = initialTriggerState();
35
+
36
+ function updateIndicator(ctx: ExtensionContext): void {
37
+ if (!ctx.hasUI) return;
38
+ const model = ctx.model;
39
+ if (!model) {
40
+ ctx.ui.setStatus(STATUS_KEY, undefined);
41
+ return;
42
+ }
43
+ const tokenCount = tokenCountWithEstimation(branchMessages(ctx));
44
+ const state = calculateTokenWarningState(tokenCount, model.id, model.contextWindow, model.maxTokens);
45
+ ctx.ui.setStatus(STATUS_KEY, renderTokenWarning(state, isAutoCompactEnabled(), ctx.ui.theme));
46
+ }
47
+
48
+ /**
49
+ * Check the token overflow against CC's auto-compact threshold and fire
50
+ * ctx.compact() on a fresh crossing. Called on every tool task finish
51
+ * (tool_execution_end) — pi performs the compaction between agent steps
52
+ * and continues the turn with the compacted context.
53
+ *
54
+ * Set PI_CC_CONTEXT_NO_AUTO_COMPACT=1 to disable, and disable pi's
55
+ * built-in auto-compaction (settings.json "compaction": {"enabled": false})
56
+ * so this threshold governs instead of pi's window − 16,384.
57
+ */
58
+ function maybeAutoCompact(ctx: ExtensionContext): void {
59
+ const model = ctx.model;
60
+ if (!model) return;
61
+ if (!isAutoCompactEnabled()) return;
62
+ if (process.env.PI_CC_CONTEXT_NO_AUTO_COMPACT !== undefined) return;
63
+ const tokenCount = tokenCountWithEstimation(branchMessages(ctx));
64
+ const threshold = getAutoCompactThreshold(model.id, model.contextWindow, model.maxTokens);
65
+ if (!shouldFireAutoCompact(compactTrigger, tokenCount, threshold)) return;
66
+ try {
67
+ ctx.compact();
68
+ } catch (error) {
69
+ ctx.ui.notify?.(`pi-plus: auto-compact failed to start: ${String(error)}`, "warning");
70
+ }
71
+ }
72
+
73
+ pi.on("session_start", (_event, ctx) => {
74
+ compactTrigger = initialTriggerState();
75
+ updateIndicator(ctx);
76
+ maybeAutoCompact(ctx);
77
+ });
78
+ pi.on("turn_end", (_event, ctx) => updateIndicator(ctx));
79
+ pi.on("agent_settled", (_event, ctx) => updateIndicator(ctx));
80
+ // Check token overflow on every tool task finish.
81
+ pi.on("tool_execution_end", (_event, ctx) => {
82
+ updateIndicator(ctx);
83
+ maybeAutoCompact(ctx);
84
+ });
85
+ pi.on("message_end", (_event, ctx) => updateIndicator(ctx));
86
+ pi.on("model_select", (_event, ctx) => updateIndicator(ctx));
87
+ pi.on("session_compact", (_event, ctx) => updateIndicator(ctx));
88
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Port of Claude Code's TokenWarning indicator (src/components/TokenWarning.tsx).
3
+ *
4
+ * Rendering rules (auto-compact reactive/collapse modes omitted — ant-only):
5
+ * - below the warning threshold: render nothing
6
+ * - auto-compact on: dim "N% until auto-compact"
7
+ * - auto-compact off: "Context low (N% remaining) · Run /compact to compact & continue",
8
+ * warning color, or error color once past the error threshold / blocking limit
9
+ */
10
+
11
+ import type { TokenWarningState } from "./percentages.ts";
12
+
13
+ /** Structural subset of pi's Theme — avoids a hard pi-tui dependency. */
14
+ export interface ThemeLike {
15
+ fg(color: string, text: string): string;
16
+ }
17
+
18
+ export function renderTokenWarning(
19
+ state: TokenWarningState,
20
+ autoCompactEnabled: boolean,
21
+ theme: ThemeLike,
22
+ ): string | undefined {
23
+ if (!state.isAboveWarningThreshold) return undefined;
24
+
25
+ if (autoCompactEnabled) {
26
+ return theme.fg("dim", `${state.percentLeft}% until auto-compact`);
27
+ }
28
+ const color = state.isAboveErrorThreshold || state.isAtBlockingLimit ? "error" : "warning";
29
+ return theme.fg(color, `Context low (${state.percentLeft}% remaining) · Run /compact to compact & continue`);
30
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Ports of Claude Code's context-window and max-output-token resolution.
3
+ *
4
+ * Sources (claude-code repo):
5
+ * - src/utils/context.ts — getContextWindowForModel, getModelMaxOutputTokens
6
+ * - src/services/api/claude.ts — getMaxOutputTokensForModel (slot-reservation cap)
7
+ * - src/services/compact/autoCompact.ts — getEffectiveContextWindowSize, getAutoCompactThreshold
8
+ */
9
+
10
+ export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000;
11
+ export const COMPACT_MAX_OUTPUT_TOKENS = 20_000;
12
+ export const CAPPED_DEFAULT_MAX_TOKENS = 8_000;
13
+ export const AUTOCOMPACT_BUFFER_TOKENS = 13_000;
14
+
15
+ const MAX_OUTPUT_TOKENS_DEFAULT = 32_000;
16
+ const MAX_OUTPUT_TOKENS_UPPER_LIMIT = 64_000;
17
+
18
+ const ONE_MILLION = 1_000_000;
19
+
20
+ function isEnvTruthy(value: string | undefined): boolean {
21
+ return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
22
+ }
23
+
24
+ function parsePositiveInt(raw: string | undefined): number | undefined {
25
+ if (raw === undefined || raw === "") return undefined;
26
+ const parsed = parseInt(raw, 10);
27
+ return !Number.isNaN(parsed) && parsed > 0 ? parsed : undefined;
28
+ }
29
+
30
+ export function is1mContextDisabled(): boolean {
31
+ return isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT);
32
+ }
33
+
34
+ /** CC: has1mContext — explicit `[1m]` model suffix opt-in. */
35
+ export function has1mContext(modelId: string): boolean {
36
+ return !is1mContextDisabled() && /\[1m\]/i.test(modelId);
37
+ }
38
+
39
+ /**
40
+ * CC: getContextWindowForModel.
41
+ * Resolution order: CLAUDE_CODE_MAX_CONTEXT_TOKENS env override → `[1m]` suffix →
42
+ * model capability (pi's registry `contextWindow` plays the role of CC's
43
+ * `max_input_tokens` capability, with the same >= 100k gate) → 200k default.
44
+ */
45
+ export function resolveContextWindow(modelId: string, modelContextWindow?: number): number {
46
+ const override = parsePositiveInt(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS);
47
+ if (override) return override;
48
+
49
+ if (has1mContext(modelId)) return ONE_MILLION;
50
+
51
+ if (modelContextWindow && modelContextWindow >= 100_000) {
52
+ if (modelContextWindow > MODEL_CONTEXT_WINDOW_DEFAULT && is1mContextDisabled()) {
53
+ return MODEL_CONTEXT_WINDOW_DEFAULT;
54
+ }
55
+ return modelContextWindow;
56
+ }
57
+ return MODEL_CONTEXT_WINDOW_DEFAULT;
58
+ }
59
+
60
+ /**
61
+ * CC: getModelMaxOutputTokens — hardcoded per-model table, overridden by the
62
+ * model capability's max_tokens (pi's registry `maxTokens`) when >= 4096.
63
+ */
64
+ export function getModelMaxOutputTokens(
65
+ modelId: string,
66
+ modelMaxTokens?: number,
67
+ ): { default: number; upperLimit: number } {
68
+ const m = modelId.toLowerCase();
69
+ let defaultTokens: number;
70
+ let upperLimit: number;
71
+
72
+ if (m.includes("opus-4-6")) {
73
+ defaultTokens = 64_000;
74
+ upperLimit = 128_000;
75
+ } else if (m.includes("sonnet-4-6")) {
76
+ defaultTokens = 32_000;
77
+ upperLimit = 128_000;
78
+ } else if (m.includes("opus-4-5") || m.includes("sonnet-4") || m.includes("haiku-4")) {
79
+ defaultTokens = 32_000;
80
+ upperLimit = 64_000;
81
+ } else if (m.includes("opus-4-1") || m.includes("opus-4")) {
82
+ defaultTokens = 32_000;
83
+ upperLimit = 32_000;
84
+ } else if (m.includes("claude-3-opus")) {
85
+ defaultTokens = 4_096;
86
+ upperLimit = 4_096;
87
+ } else if (m.includes("claude-3-sonnet")) {
88
+ defaultTokens = 8_192;
89
+ upperLimit = 8_192;
90
+ } else if (m.includes("claude-3-haiku")) {
91
+ defaultTokens = 4_096;
92
+ upperLimit = 4_096;
93
+ } else if (m.includes("3-5-sonnet") || m.includes("3-5-haiku")) {
94
+ defaultTokens = 8_192;
95
+ upperLimit = 8_192;
96
+ } else if (m.includes("3-7-sonnet")) {
97
+ defaultTokens = 32_000;
98
+ upperLimit = 64_000;
99
+ } else {
100
+ defaultTokens = MAX_OUTPUT_TOKENS_DEFAULT;
101
+ upperLimit = MAX_OUTPUT_TOKENS_UPPER_LIMIT;
102
+ }
103
+
104
+ if (modelMaxTokens && modelMaxTokens >= 4_096) {
105
+ upperLimit = modelMaxTokens;
106
+ defaultTokens = Math.min(defaultTokens, upperLimit);
107
+ }
108
+
109
+ return { default: defaultTokens, upperLimit };
110
+ }
111
+
112
+ /**
113
+ * CC: getMaxOutputTokensForModel — applies the slot-reservation cap
114
+ * (tengu_otk_slot_v1, live in current production builds: default output
115
+ * dropped to 8k, over-limit requests get one clean retry at 64k), then the
116
+ * CLAUDE_CODE_MAX_OUTPUT_TOKENS bounded env override.
117
+ */
118
+ export function getMaxOutputTokensForModel(modelId: string, modelMaxTokens?: number): number {
119
+ const { default: modelDefault, upperLimit } = getModelMaxOutputTokens(modelId, modelMaxTokens);
120
+ const capDisabled = isEnvTruthy(process.env.PI_CC_CONTEXT_DISABLE_OTK_CAP);
121
+ // Math.min keeps models with lower native defaults (e.g. claude-3-opus at
122
+ // 4k) at their native value.
123
+ const defaultTokens = capDisabled ? modelDefault : Math.min(modelDefault, CAPPED_DEFAULT_MAX_TOKENS);
124
+
125
+ const raw = process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
126
+ if (raw !== undefined) {
127
+ const parsed = parseInt(raw, 10);
128
+ if (!Number.isNaN(parsed) && parsed > 0) return Math.min(parsed, upperLimit);
129
+ }
130
+ return defaultTokens;
131
+ }
132
+
133
+ /**
134
+ * CC: getEffectiveContextWindowSize — context window minus the reserved
135
+ * output tokens (min(maxOutput, 20k)), optionally capped by
136
+ * CLAUDE_CODE_AUTO_COMPACT_WINDOW.
137
+ */
138
+ export function getEffectiveContextWindowSize(
139
+ modelId: string,
140
+ modelContextWindow?: number,
141
+ modelMaxTokens?: number,
142
+ ): number {
143
+ const reservedTokensForSummary = Math.min(
144
+ getMaxOutputTokensForModel(modelId, modelMaxTokens),
145
+ COMPACT_MAX_OUTPUT_TOKENS,
146
+ );
147
+ let contextWindow = resolveContextWindow(modelId, modelContextWindow);
148
+ const autoCompactWindow = parsePositiveInt(process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW);
149
+ if (autoCompactWindow) {
150
+ contextWindow = Math.min(contextWindow, autoCompactWindow);
151
+ }
152
+ return contextWindow - reservedTokensForSummary;
153
+ }
154
+
155
+ /**
156
+ * CC: getAutoCompactThreshold — effective window minus the 13k autocompact
157
+ * buffer. CLAUDE_AUTOCOMPACT_PCT_OVERRIDE (1–100) lowers it for testing.
158
+ */
159
+ export function getAutoCompactThreshold(
160
+ modelId: string,
161
+ modelContextWindow?: number,
162
+ modelMaxTokens?: number,
163
+ ): number {
164
+ const effective = getEffectiveContextWindowSize(modelId, modelContextWindow, modelMaxTokens);
165
+ const autocompactThreshold = effective - AUTOCOMPACT_BUFFER_TOKENS;
166
+
167
+ const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
168
+ if (envPercent !== undefined) {
169
+ const parsed = parseFloat(envPercent);
170
+ if (!Number.isNaN(parsed) && parsed > 0 && parsed <= 100) {
171
+ const percentageThreshold = Math.floor(effective * (parsed / 100));
172
+ return Math.min(percentageThreshold, autocompactThreshold);
173
+ }
174
+ }
175
+ return autocompactThreshold;
176
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Ports of Claude Code's statusline percentage and token-warning thresholds.
3
+ *
4
+ * Sources (claude-code repo):
5
+ * - src/utils/context.ts — calculateContextPercentages
6
+ * - src/services/compact/autoCompact.ts — calculateTokenWarningState, isAutoCompactEnabled
7
+ */
8
+
9
+ import {
10
+ getAutoCompactThreshold,
11
+ getEffectiveContextWindowSize,
12
+ } from "./model-config.ts";
13
+ import type { UsageSnapshot } from "./usage.ts";
14
+
15
+ export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000;
16
+ export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000;
17
+ export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000;
18
+
19
+ /**
20
+ * CC: isAutoCompactEnabled — DISABLE_COMPACT / DISABLE_AUTO_COMPACT env vars.
21
+ * (CC also consults a user-config flag; pi has no extension-readable
22
+ * equivalent, so this port is env-only and defaults to enabled.)
23
+ */
24
+ export function isAutoCompactEnabled(): boolean {
25
+ const raw = (name: string) => process.env[name];
26
+ const truthy = (v: string | undefined) => v !== undefined && v !== "" && v !== "0" && v.toLowerCase() !== "false";
27
+ if (truthy(raw("DISABLE_COMPACT"))) return false;
28
+ if (truthy(raw("DISABLE_AUTO_COMPACT"))) return false;
29
+ return true;
30
+ }
31
+
32
+ /**
33
+ * CC: calculateContextPercentages — the statusline percentage. Numerator is
34
+ * input + cache creation + cache read (output EXCLUDED), denominator is the
35
+ * raw context window (not the effective one). pi's `cacheWrite` includes the
36
+ * 1h cache subset, matching CC's cache_creation_input_tokens.
37
+ */
38
+ export function calculateContextPercentages(
39
+ currentUsage: Pick<UsageSnapshot, "input" | "cacheRead" | "cacheWrite"> | null,
40
+ contextWindowSize: number,
41
+ ): { used: number | null; remaining: number | null } {
42
+ if (!currentUsage) return { used: null, remaining: null };
43
+ const totalInputTokens = currentUsage.input + currentUsage.cacheWrite + currentUsage.cacheRead;
44
+ const usedPercentage = Math.round((totalInputTokens / contextWindowSize) * 100);
45
+ const clampedUsed = Math.min(100, Math.max(0, usedPercentage));
46
+ return { used: clampedUsed, remaining: 100 - clampedUsed };
47
+ }
48
+
49
+ export interface TokenWarningState {
50
+ percentLeft: number;
51
+ isAboveWarningThreshold: boolean;
52
+ isAboveErrorThreshold: boolean;
53
+ isAboveAutoCompactThreshold: boolean;
54
+ isAtBlockingLimit: boolean;
55
+ }
56
+
57
+ /**
58
+ * CC: calculateTokenWarningState — percent left until the (auto-compact or
59
+ * effective) threshold and the warning/error/blocking threshold crossings.
60
+ * Warning and error buffers are both 20k in CC, so the two crossings are
61
+ * identical unless env overrides differ.
62
+ */
63
+ export function calculateTokenWarningState(
64
+ tokenUsage: number,
65
+ modelId: string,
66
+ modelContextWindow?: number,
67
+ modelMaxTokens?: number,
68
+ ): TokenWarningState {
69
+ const autoCompact = isAutoCompactEnabled();
70
+ const autoCompactThreshold = getAutoCompactThreshold(modelId, modelContextWindow, modelMaxTokens);
71
+ const effectiveContextWindow = getEffectiveContextWindowSize(modelId, modelContextWindow, modelMaxTokens);
72
+ const threshold = autoCompact ? autoCompactThreshold : effectiveContextWindow;
73
+
74
+ const percentLeft = Math.max(0, Math.round(((threshold - tokenUsage) / threshold) * 100));
75
+
76
+ const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS;
77
+ const errorThreshold = threshold - ERROR_THRESHOLD_BUFFER_TOKENS;
78
+
79
+ const defaultBlockingLimit = effectiveContextWindow - MANUAL_COMPACT_BUFFER_TOKENS;
80
+ const parsedOverride = process.env.CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE
81
+ ? parseInt(process.env.CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE, 10)
82
+ : NaN;
83
+ const blockingLimit = !Number.isNaN(parsedOverride) && parsedOverride > 0 ? parsedOverride : defaultBlockingLimit;
84
+
85
+ return {
86
+ percentLeft,
87
+ isAboveWarningThreshold: tokenUsage >= warningThreshold,
88
+ isAboveErrorThreshold: tokenUsage >= errorThreshold,
89
+ isAboveAutoCompactThreshold: autoCompact && tokenUsage >= autoCompactThreshold,
90
+ isAtBlockingLimit: tokenUsage >= blockingLimit,
91
+ };
92
+ }
package/src/usage.ts ADDED
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Ports of Claude Code's usage-based context measurement and rough tail
3
+ * estimation.
4
+ *
5
+ * Sources (claude-code repo):
6
+ * - src/utils/tokens.ts — tokenCountWithEstimation, getTokenCountFromUsage
7
+ * - src/services/tokenEstimation.ts — roughTokenCountEstimationForBlock
8
+ * - src/services/compact/microCompact.ts — IMAGE_MAX_TOKEN_SIZE = 2000
9
+ *
10
+ * pi's message model differs from CC's: tool results are separate
11
+ * `toolResult` messages (CC: `user` messages containing tool_result blocks)
12
+ * and assistant tool calls use the `toolCall` block type (CC: `tool_use`).
13
+ * The estimation below maps those 1:1 so the arithmetic matches CC.
14
+ */
15
+
16
+ /** Structural shape of pi's Usage we rely on (pi-ai Usage). */
17
+ export interface UsageSnapshot {
18
+ input: number;
19
+ output: number;
20
+ cacheRead: number;
21
+ cacheWrite: number;
22
+ }
23
+
24
+ /** Structural minimal message shape — accepts pi AgentMessage without a
25
+ * hard runtime dependency on pi-ai. */
26
+ export interface RoughMessage {
27
+ role?: string;
28
+ content?: unknown;
29
+ usage?: Partial<UsageSnapshot> | null;
30
+ stopReason?: string;
31
+ timestamp?: number;
32
+ toolsAdded?: unknown[];
33
+ toolsRemoved?: unknown[];
34
+ }
35
+
36
+ /** CC: images and PDFs are billed ~2000 tokens after client-side resizing;
37
+ * a 1MB base64 PDF must NOT fall through to the stringify catch-all. */
38
+ const IMAGE_DOCUMENT_TOKEN_ESTIMATE = 2000;
39
+ const CHARS_PER_TOKEN = 4;
40
+
41
+ /**
42
+ * CC: getTokenCountFromUsage — the full context size at the time of that API
43
+ * call: input + cache creation (5m and 1h) + cache read + output.
44
+ * pi's `cacheWrite` already includes the 1h subset, and `reasoning` is a
45
+ * subset of `output`, so summing the four fields is exactly CC's formula.
46
+ */
47
+ export function getTokenCountFromUsage(usage: UsageSnapshot): number {
48
+ return usage.input + usage.cacheWrite + usage.cacheRead + usage.output;
49
+ }
50
+
51
+ function hasPositiveTokenCount(usage: Partial<UsageSnapshot> | null | undefined): usage is UsageSnapshot {
52
+ if (!usage) return false;
53
+ const total = getTokenCountFromUsage({
54
+ input: usage.input ?? 0,
55
+ output: usage.output ?? 0,
56
+ cacheRead: usage.cacheRead ?? 0,
57
+ cacheWrite: usage.cacheWrite ?? 0,
58
+ });
59
+ return total > 0;
60
+ }
61
+
62
+ function safeJsonStringify(value: unknown): string {
63
+ try {
64
+ return JSON.stringify(value) ?? "undefined";
65
+ } catch {
66
+ return "[unserializable]";
67
+ }
68
+ }
69
+
70
+ /** CC: roughTokenCountEstimation — characters / 4, rounded. */
71
+ export function roughTokenCountEstimation(content: string, bytesPerToken: number = CHARS_PER_TOKEN): number {
72
+ return Math.round(content.length / bytesPerToken);
73
+ }
74
+
75
+ /**
76
+ * Find the last assistant message whose usage validly describes the current
77
+ * context prefix. Port of pi's getLastAssistantUsageInfo (estimate.ts), which
78
+ * matches CC's getTokenUsage behavior: skip aborted/error responses, skip
79
+ * responses older than a newer prefix message (e.g. a compaction summary
80
+ * inserted afterwards), skip zero-usage records.
81
+ */
82
+ export function findLastUsageInfo(
83
+ messages: readonly RoughMessage[],
84
+ ): { usage: UsageSnapshot; index: number } | undefined {
85
+ let latestPrefixTimestamp = Number.NEGATIVE_INFINITY;
86
+ let usageInfo: { usage: UsageSnapshot; index: number } | undefined;
87
+
88
+ for (let i = 0; i < messages.length; i++) {
89
+ const message = messages[i];
90
+ if (message.role === "assistant" && hasPositiveTokenCount(message.usage)) {
91
+ const usageAppliesToPrefix = (message.timestamp ?? 0) >= latestPrefixTimestamp;
92
+ if (usageAppliesToPrefix && message.stopReason !== "aborted" && message.stopReason !== "error") {
93
+ usageInfo = {
94
+ usage: {
95
+ input: message.usage.input ?? 0,
96
+ output: message.usage.output ?? 0,
97
+ cacheRead: message.usage.cacheRead ?? 0,
98
+ cacheWrite: message.usage.cacheWrite ?? 0,
99
+ },
100
+ index: i,
101
+ };
102
+ }
103
+ }
104
+ if (message.timestamp !== undefined) {
105
+ latestPrefixTimestamp = Math.max(latestPrefixTimestamp, message.timestamp);
106
+ }
107
+ }
108
+ return usageInfo;
109
+ }
110
+
111
+ /** CC: roughTokenCountEstimationForBlock — per content block. */
112
+ function estimateBlockTokens(block: unknown): number {
113
+ if (typeof block === "string") return roughTokenCountEstimation(block);
114
+ if (!block || typeof block !== "object") return 0;
115
+ const b = block as Record<string, unknown>;
116
+ switch (b.type) {
117
+ case "text":
118
+ return roughTokenCountEstimation(String(b.text ?? ""));
119
+ case "thinking":
120
+ return roughTokenCountEstimation(String(b.thinking ?? ""));
121
+ case "image":
122
+ case "document":
123
+ return IMAGE_DOCUMENT_TOKEN_ESTIMATE;
124
+ // pi toolResult message content (CC: tool_result block inside a user message)
125
+ case "toolResult":
126
+ return estimateContentTokens(b.content);
127
+ // pi assistant tool call (CC: tool_use block — name + serialized input)
128
+ case "toolCall":
129
+ return roughTokenCountEstimation(String(b.name ?? "") + safeJsonStringify(b.arguments ?? {}));
130
+ default:
131
+ // server_tool_use, web_search_tool_result, mcp_tool_use, etc. — text-like
132
+ // payloads where the stringify length tracks the serialized form.
133
+ return roughTokenCountEstimation(safeJsonStringify(block));
134
+ }
135
+ }
136
+
137
+ function estimateContentTokens(content: unknown): number {
138
+ if (!content) return 0;
139
+ if (typeof content === "string") return roughTokenCountEstimation(content);
140
+ if (!Array.isArray(content)) return roughTokenCountEstimation(safeJsonStringify(content));
141
+ let total = 0;
142
+ for (const block of content) total += estimateBlockTokens(block);
143
+ return total;
144
+ }
145
+
146
+ /**
147
+ * CC: roughTokenCountEstimationForMessage — CC counts only assistant and user
148
+ * message content (tool results ride inside user messages there). pi carries
149
+ * tool results as their own `toolResult` messages, estimated via their content
150
+ * blocks. pi also keeps `system` messages in the branch (CC has none in the
151
+ * tail), so those are counted as chars/4 plus their tool definitions.
152
+ */
153
+ export function estimateMessageTokens(message: RoughMessage): number {
154
+ if (message.role === "system") {
155
+ let chars = 0;
156
+ const content = message.content;
157
+ if (typeof content === "string") {
158
+ chars += content.length;
159
+ } else if (Array.isArray(content)) {
160
+ for (const block of content) {
161
+ if (block && typeof block === "object" && "text" in block) {
162
+ chars += String((block as { text: unknown }).text ?? "").length;
163
+ }
164
+ }
165
+ }
166
+ const toolsAdded = message.toolsAdded?.length ? safeJsonStringify(message.toolsAdded).length : 0;
167
+ const toolsRemoved = message.toolsRemoved?.length ? safeJsonStringify(message.toolsRemoved).length : 0;
168
+ chars += toolsAdded + toolsRemoved;
169
+ return Math.round(chars / CHARS_PER_TOKEN);
170
+ }
171
+ if (
172
+ (message.role === "assistant" || message.role === "user" || message.role === "toolResult") &&
173
+ message.content != null
174
+ ) {
175
+ return estimateContentTokens(message.content);
176
+ }
177
+ return 0;
178
+ }
179
+
180
+ /**
181
+ * CC: tokenCountWithEstimation — THE canonical context measurement for
182
+ * threshold checks. Last valid API usage (exact tokens for everything up to
183
+ * that response) plus a rough estimate of everything appended since.
184
+ *
185
+ * Note: CC walks back past sibling assistant records split from the same API
186
+ * response (shared message.id, interleaved tool_results). pi does not split
187
+ * assistant records per content block, so no walk-back is needed.
188
+ */
189
+ export function tokenCountWithEstimation(messages: readonly RoughMessage[]): number {
190
+ const usageInfo = findLastUsageInfo(messages);
191
+ if (usageInfo) {
192
+ let trailingTokens = 0;
193
+ for (let i = usageInfo.index + 1; i < messages.length; i++) {
194
+ trailingTokens += estimateMessageTokens(messages[i]);
195
+ }
196
+ return getTokenCountFromUsage(usageInfo.usage) + trailingTokens;
197
+ }
198
+ let tokens = 0;
199
+ for (const message of messages) tokens += estimateMessageTokens(message);
200
+ return tokens;
201
+ }