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 +21 -0
- package/README.md +97 -0
- package/package.json +48 -0
- package/src/auto-compact.ts +43 -0
- package/src/index.ts +88 -0
- package/src/indicator.ts +30 -0
- package/src/model-config.ts +176 -0
- package/src/percentages.ts +92 -0
- package/src/usage.ts +201 -0
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
|
+
}
|
package/src/indicator.ts
ADDED
|
@@ -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
|
+
}
|