SPB Git

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%
5.0 KB · 134 lines typescript
Raw Blame History
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