spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/context/budget.ts4 * Description: Token accounting from real ModelUsage only — pressure thresholds and config-driven context windows (ARCHITECTURE.md §6.2).5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import type { ModelUsage } from "../anthropic/index.js";1112/**13 * Fallback context window when no config override matches the configured14 * model. Not a hard-coded model list — every value here is overridable via15 * `ContextWindowConfig` (CLAUDE.md §6: model identifiers evolve).16 */17export const DEFAULT_CONTEXT_WINDOW = 200_000;1819/** Safety margin kept free below the usable window (ARCHITECTURE.md §6.2). */20export const DEFAULT_COMPACTION_BUFFER_TOKENS = 20_000;2122/** Fraction of the usable window at which cheap pruning starts (prune-before-compress order). */23export const DEFAULT_PRUNE_THRESHOLD_RATIO = 0.85;2425/** Config-driven context-window table — never a hard-coded model-id list. */26export interface ContextWindowConfig {27 /** Fallback window for models absent from `byModel`. Default 200_000. */28 defaultWindow?: number;29 /** Exact model id → context window override (from user/project config). */30 byModel?: Record<string, number>;31}3233/** Resolve the context window for a configured model id. */34export function resolveContextWindow(model: string, config?: ContextWindowConfig): number {35 const override = config?.byModel?.[model];36 if (override !== undefined && Number.isFinite(override) && override > 0) return override;37 const fallback = config?.defaultWindow;38 if (fallback !== undefined && Number.isFinite(fallback) && fallback > 0) return fallback;39 return DEFAULT_CONTEXT_WINDOW;40}4142/**43 * Cheap deterministic token ESTIMATE (~4 chars/token) — used only for44 * `/context` section stats and prune/cut selection, always labeled as an45 * estimate. Real accounting comes exclusively from API usage (Rule #4).46 */47export function estimateTokens(text: string): number {48 return Math.ceil(text.length / 4);49}5051/** Pressure levels: prune first (cheap, deterministic), compress only if still over budget. */52export type BudgetPressureLevel = "ok" | "prune" | "compact";5354export interface ContextBudgetOptions {55 /** The configured main model id (window resolution key). */56 model: string;57 /** Output tokens reserved for the model's reply (config `maxOutputTokens`). */58 reservedOutputTokens: number;59 /** Config-driven window table + fallback. */60 contextWindow?: ContextWindowConfig;61 /** Safety margin below the window. Default 20_000. */62 compactionBufferTokens?: number;63 /** Fraction of the usable window at which pruning triggers. Default 0.85. */64 pruneThresholdRatio?: number;65}6667/**68 * Token-pressure accounting fed exclusively by real API usage69 * (`ModelResponseCompleted.usage`) — never event counts, never estimates70 * (ARCHITECTURE.md §6.2, Absolute Rule #4).71 */72export class ContextBudget {73 readonly modelWindow: number;74 readonly reservedOutputTokens: number;75 readonly compactionBufferTokens: number;76 readonly pruneThresholdRatio: number;7778 #lastTotalTokens = 0;79 #observedTurns = 0;8081 constructor(options: ContextBudgetOptions) {82 this.modelWindow = resolveContextWindow(options.model, options.contextWindow);83 this.reservedOutputTokens = options.reservedOutputTokens;84 this.compactionBufferTokens =85 options.compactionBufferTokens ?? DEFAULT_COMPACTION_BUFFER_TOKENS;86 this.pruneThresholdRatio = options.pruneThresholdRatio ?? DEFAULT_PRUNE_THRESHOLD_RATIO;87 }8889 /** usableWindow = modelWindow − reservedOutput − compactionBuffer (ARCHITECTURE.md §6.2). */90 get usableWindow(): number {91 return Math.max(1, this.modelWindow - this.reservedOutputTokens - this.compactionBufferTokens);92 }9394 /**95 * Record real usage from the last completed response. The context footprint96 * of the next request tracks the full prompt the API just billed:97 * uncached input + cache reads + cache writes, plus the tokens the model98 * just produced (which become history).99 */100 onUsage(usage: ModelUsage): void {101 this.#lastTotalTokens =102 usage.inputTokens + usage.cacheReadTokens + usage.cacheWriteTokens + usage.outputTokens;103 this.#observedTurns += 1;104 }105106 /** Last observed real token total (0 until the first response completes). */107 get lastTotalTokens(): number {108 return this.#lastTotalTokens;109 }110111 /** Whether any real usage has been observed yet. */112 get hasObservedUsage(): boolean {113 return this.#observedTurns > 0;114 }115116 /** Pressure as a fraction of the usable window (0 = empty, ≥1 = over budget). */117 pressure(): number {118 return this.#lastTotalTokens / this.usableWindow;119 }120121 /** Current trigger level: ok → prune (cheap) → compact (LLM summarization). */122 level(): BudgetPressureLevel {123 const p = this.pressure();124 if (p >= 1) return "compact";125 if (p >= this.pruneThresholdRatio) return "prune";126 return "ok";127 }128129 /** Proactive compaction trigger (ARCHITECTURE.md §6.2). */130 shouldCompact(): boolean {131 return this.level() === "compact";132 }133}134