Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,8 @@ All keys below are currently **ACTIVE**.
- **Default:** `true`
- **Status:** 🟢 ACTIVE
- **Description:** On Pi startup, check the npm registry for a newer version of `billion-context-pi` and auto-install it. Set to `false` to avoid all startup network calls. Can also be disabled via the `ACP_AUTO_UPDATE` environment variable (`ACP_AUTO_UPDATE=0` or `ACP_AUTO_UPDATE=false`), which overrides this setting.
- **Read-only install location:** when the copy's install prefix is not writable (e.g. a root-owned `npm i -g` global prefix), auto-update stops retrying that location after the first `EACCES`/permission failure and shows a one-time hint to run `npm i -g billion-context-pi` (or remove the global copy if you rely on pi's bundled install) instead of looping. The check throttle and the stop-retry marker are keyed per install location, so a healthy copy never suppresses a failing one's checks.
- **Two parallel mechanisms:** this extension-side auto-update is independent of pi's own core update banner — both can appear, and disabling one does not disable the other.

### `modelContextLimit`

Expand Down
2 changes: 2 additions & 0 deletions CONFIGURATION.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,8 @@
- **默认值:** `true`
- **状态:** 🟢 ACTIVE
- **说明:** Pi 启动时检查 npm 是否有更新版本的 `billion-context-pi` 并自动安装。设为 `false` 可避免启动时的所有网络请求。也可通过 `ACP_AUTO_UPDATE` 环境变量(`ACP_AUTO_UPDATE=0` 或 `ACP_AUTO_UPDATE=false`)禁用,该变量优先于此配置。
- **只读安装位置:** 当该副本的安装前缀不可写(如 root 所有的 `npm i -g` 全局前缀)时,自动更新在首次 `EACCES`/权限失败后停止对该位置的重试,并一次性提示运行 `npm i -g billion-context-pi`(或若依赖 pi 自带安装则移除全局副本),而不是无限循环。检查节流与停止重试标记均按安装位置区分,因此健康副本不会压制失败副本的检查。
- **两套并行机制:** 此扩展侧自动更新与 pi 核心自身的更新 banner 相互独立——两者可能同时出现,禁用其中一个不影响另一个。

### `modelContextLimit`

Expand Down
6 changes: 5 additions & 1 deletion src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { defaultCountTokens, parseBlockIdArg, collectBlockContent } from "acp-ke
import { getSystemPromptText } from "./compat.js";
import { collectCoveredMessageIds, estimateTokens, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js";
import { usageAnchorPredatesCompression } from "./floor-stale.js";
import { applyOutputHeadroom } from "./overflow-selfheal.js";
import { buildStatusPanel } from "acp-kernel/panel";
import { getDelegateUsage } from "./delegate-tool.js";
import { openFleetInspector } from "./fleet-inspector.js";
Expand Down Expand Up @@ -142,7 +143,10 @@ export function makeCommands(runtime: AcpRuntime, pi?: ExtensionAPI): Array<{ na

async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext): Promise<string> {
const { state, coreMessages, entries } = await runtime.stateFor(ctx);
const config = runtime.configFor(ctx);
// Measure every panel percentage against the SAME real request limit the live
// context transform uses (window − output headroom), not the full window
// (issue #267).
const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model);
// Use pi's real context usage (anchored on provider usage) only for the
// panel's footer-scale display line; see sentTokens below for arbitration.
const realUsage = ctx.getContextUsage?.();
Expand Down
44 changes: 29 additions & 15 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
} from "./throttle-retry.js";
import { defaultCountTokens } from "acp-kernel";
import { formatSystemPromptForEvent, getSystemPromptText } from "./compat.js";
import { inspectOverflowMessage, reserveOutputHeadroom, shouldReserveOutputHeadroom } from "./overflow-selfheal.js";
import { applyOutputHeadroom, inspectOverflowMessage } from "./overflow-selfheal.js";
import { isOmpHost, OMP_UNSUPPORTED_MESSAGE } from "./omp.js";
import { isBiliProxyBaseUrl, PROXY_STAND_DOWN_MESSAGE } from "./proxy-detect.js";

Expand Down Expand Up @@ -232,6 +232,7 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
});
pi.on("session_shutdown", (_event, ctx) => {
runtime.clearDeadCompress(ctx.sessionManager.getSessionId());
runtime.dropTokenScale(ctx.sessionManager.getSessionId());
delegateStatusWidget.dispose();
closeLogStream();
});
Expand Down Expand Up @@ -273,20 +274,13 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
// Output headroom: reserve the model's max output budget from the window
// so the kernel's nudge/truncate bands sit below (window - maxTokens) —
// the context then always leaves room for the model's reply, preventing
// the "context + output > window" overflow on a small window. maxTokens is
// the model's max output capability (ctx.model.maxTokens). Applied to the
// (possibly re-centered) window above; never mutates the shared config.
// Anthropic is exempt — its input limit is enforced independently of
// max_tokens, so reserving would shift every band down by maxTokens with
// no safety gain (see shouldReserveOutputHeadroom).
const maxOutput = (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0;
if (shouldReserveOutputHeadroom((ctx.model as { api?: string } | undefined)?.api)) {
const reservedWindow = reserveOutputHeadroom(config.modelContextLimit, maxOutput);
if (reservedWindow !== config.modelContextLimit) {
const before = config.modelContextLimit;
config = { ...config, modelContextLimit: reservedWindow };
logInfo("overflow-selfheal", { sid, event: "output-headroom", before, after: reservedWindow, maxOutput });
}
// the "context + output > window" overflow on a small window. Applied to
// the (possibly re-centered) window above; never mutates the shared
// config. Anthropic is exempt (see applyOutputHeadroom).
const beforeHeadroom = config.modelContextLimit;
config = applyOutputHeadroom(config, ctx.model);
if (config.modelContextLimit !== beforeHeadroom) {
logInfo("overflow-selfheal", { sid, event: "output-headroom", before: beforeHeadroom, after: config.modelContextLimit, maxOutput: (ctx.model as { maxTokens?: number } | undefined)?.maxTokens ?? 0 });
}
const coveredIds = collectCoveredMessageIds(state);
// Nudge arbitration on the SENT-VIEW scale: CJK-aware estimate over the
Expand Down Expand Up @@ -329,6 +323,26 @@ function wireContextTransform(pi: ExtensionAPI, runtime: AcpRuntime, standDownIf
logInfo("turn", { sid, event: "view-recount", prelim: sentTokens, viewTokens: view.viewTokens, tokenCount });
}
}
// Growth scale guard (issue #267): the meter switches rulers when the
// anchor flips stale↔not-stale (estimate ↔ provider). A growth delta
// spanning that switch is a false artifact, not real growth, so reset the
// growth baselines on the flip: the T1 growth reference (lastNudgeShownTokens
// / lastPerMessageNudgeTokens) AND the per-tier cadence baselines
// (lastShownByTier, kernel 0.0.55: cadence = tokenCount - lastShownByTier[t]
// >= growthFloor) — an old-scale lastShown subtracted from a new-scale
// tokenCount is exactly the false "+35k growth" artifact from the issue.
// The extension-side re-inject stamps (#269 / PR #316) are reset too:
// growth for the same-turn re-inject is tokenCount - nudgeShownTokensFor(turnKey)
// and an old-scale stamp would fake a full-floor growth after the flip.
// The usage bands above keep the floor-stale behavior untouched — only
// the growth references are re-anchored.
if (runtime.noteTokenScale(sid, !hostFloorActive)) {
state.nudge.lastNudgeShownTokens = 0;
state.nudge.lastPerMessageNudgeTokens = 0;
state.nudge.lastShownByTier = {};
runtime.clearNudgeTokenStamps();
logInfo("growth-scale", { sid, event: "scale-flip-reset", anchorStale: !hostFloorActive });
}
debug.event("context-in", {
sid,
modelId,
Expand Down
21 changes: 21 additions & 0 deletions src/overflow-selfheal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ export function shouldReserveOutputHeadroom(api: string | undefined): boolean {
return api !== "anthropic-messages";
}

/**
* Apply the output-headroom reservation to a resolved config's modelContextLimit
* (see reserveOutputHeadroom / shouldReserveOutputHeadroom). Returns a NEW config
* (never mutates the input) so the shared resolved config stays untouched. Used
* by BOTH the live context transform and the read-only panel surfaces (/acp,
* acp_status) so every percentage is measured against the SAME real request
* limit — otherwise the panel reports against the full window while the nudge
* bands run against (window − maxOutput) (issue #267).
*/
export function applyOutputHeadroom<T extends { modelContextLimit: number }>(
config: T,
model: { maxTokens?: number; api?: string } | undefined,
): T {
const maxOutput = model?.maxTokens ?? 0;
if (shouldReserveOutputHeadroom(model?.api)) {
const reserved = reserveOutputHeadroom(config.modelContextLimit, maxOutput);
if (reserved !== config.modelContextLimit) return { ...config, modelContextLimit: reserved };
}
return config;
}

// Per-session overflow self-heal state. Keyed by session id so concurrent
// sessions in one extension instance cannot share a learned window or an
// armed emergency (same rationale as the throttle episode).
Expand Down
24 changes: 23 additions & 1 deletion src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export interface AcpRuntime {
* pending kick sleep and releases the map entry so a long-lived process
* that cycles through many sessions doesn't accumulate them. */
throttleDrop: (sid: string) => void;
/** Per-session tokenCount scale tracker (estimate vs provider). Returns true
* when the scale just flipped (stale↔not-stale) so the caller can reset the
* growth baseline — a cross-scale delta is a false artifact, not real growth
* (issue #267). The first observation for a session never reports a flip. */
noteTokenScale: (sid: string, stale: boolean) => boolean;
/** Drop a session's token-scale tracker (session_shutdown). */
dropTokenScale: (sid: string) => void;
store: SessionStateStore;
adapter: AdapterConfig;
setAdapter(adapter: AdapterConfig): void;
Expand All @@ -68,6 +75,8 @@ export interface AcpRuntime {
nudgeShownFor(turnKey: string): boolean;
/** tokenCount at the last actual nudge injection for this turn, for growth-aware re-inject (issue #269). */
nudgeShownTokensFor(turnKey: string): number | undefined;
/** Clears the token-count stamps recorded by markNudgeShown — used on a token-scale flip (issue #267) so the same-turn re-inject floor (#269 / PR #316) is not computed against an old-scale stamp. */
clearNudgeTokenStamps(): void;
/** Process compress toolResults for the CURRENT user turn only (the caller
* scopes the list — see collectCompressOutcomes in src/index.ts); idempotent
* per toolCallId. Outcome classes: isError or noop (0-block panel) →
Expand Down Expand Up @@ -295,6 +304,19 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {
throttleEpisodes.delete(sid);
}

// Per-session tokenCount scale (estimate vs provider). When the anchor flips
// stale↔not-stale the meter switches rulers; a growth delta spanning that
// switch is a false artifact (issue #267), so the caller resets the baseline.
const tokenScaleStale = new Map<string, boolean>();
function noteTokenScale(sid: string, stale: boolean): boolean {
const prev = tokenScaleStale.get(sid);
tokenScaleStale.set(sid, stale);
return prev !== undefined && prev !== stale;
}
function dropTokenScale(sid: string): void {
tokenScaleStale.delete(sid);
}

// Compress-failure tracking (see wireContextTransform): counts FAILED/no-op
// compress calls per user turn so the nudge circuit breaker can stop
// re-injecting the nudge at a model that answers every nudge with another
Expand Down Expand Up @@ -435,4 +457,4 @@ export function createRuntime(adapter: AdapterConfig): AcpRuntime {

let refused = false;
let refusalMessage: string | null = null;
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop };}
return { core, store, get refused() { return refused; }, set refused(v: boolean) { refused = v; }, get refusalMessage() { return refusalMessage; }, set refusalMessage(v: string | null) { refusalMessage = v; }, get adapter() { return adapterRef; }, setAdapter: (a) => { adapterRef = a; }, get prompts() { return promptsRef; }, setPrompts: (p) => { promptsRef = p; }, markNudgeShown: (k, t) => { nudgeShownTurns.add(k); if (t !== undefined) nudgeShownTokens.set(k, t); }, nudgeShownFor: (k) => nudgeShownTurns.has(k), nudgeShownTokensFor: (k) => nudgeShownTokens.get(k), clearNudgeTracking: () => { nudgeShownTurns.clear(); nudgeShownTokens.clear(); }, clearNudgeTokenStamps: () => nudgeShownTokens.clear(), noteCompressOutcomes, compressRetryCappedFor, clearCompressRetryTracking, liveContextLimit, configFor, reloadConfig, stateFor, save, acquireLock, overflowFor, overflowDrop, noteDeadCompress, clearDeadCompress, throttleFor, throttleDrop , noteTokenScale, dropTokenScale };}
5 changes: 4 additions & 1 deletion src/status-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AcpRuntime } from "./runtime.js";
import { buildStatusReport, defaultCountTokens, formatRanges, viableRanges } from "acp-kernel";
import { estimateTokens, collectCoveredMessageIds, collectImageTokens, modelSupportsImages, adjustedTokenCount } from "./tokens.js";
import { usageAnchorPredatesCompression } from "./floor-stale.js";
import { applyOutputHeadroom } from "./overflow-selfheal.js";
import { getSystemPromptText } from "./compat.js";
import { logThrow } from "./log.js";
import { getDelegateUsage } from "./delegate-tool.js";
Expand Down Expand Up @@ -49,7 +50,9 @@ export function makeStatusTool(runtime: AcpRuntime): ToolDefinition<typeof Statu

async function handleStatus(args: StatusArgs, runtime: AcpRuntime, ctx: ExtensionContext): Promise<string> {
const { state, coreMessages, entries } = await runtime.stateFor(ctx);
const config = runtime.configFor(ctx);
// Same real request limit (window − output headroom) as the live context
// transform, so the reported percentages match the nudge bands (issue #267).
const config = applyOutputHeadroom(runtime.configFor(ctx), ctx.model);
// Run the same pipeline (assign-refs → prune → hide-compress-calls → ...) that
// the context transform runs, so what acp_status reports matches what the
// model actually receives. Without this, consumed/hidden compress calls and
Expand Down
Loading
Loading