feat(coding-agent): added /context command flow for interactive dispatch
- Added a `/context` slash command flow from registry to interactive-mode command dispatch. - Added `handleContextCommand()` to the mode context interface and command-controller wiring. - Added context usage breakdown utilities, cell allocation, and 20x10 usage rendering for token categories. - Reworked compaction token estimation to use tokenizer counts, role aggregation, image token estimates, and fallback handling. - Exported `resolveThresholdTokens()` as a public compaction helper.
This commit is contained in:
@@ -26,6 +26,7 @@ import {
|
||||
getOpenAIResponsesHistoryPayload,
|
||||
normalizeResponsesToolCallId,
|
||||
} from "@oh-my-pi/pi-ai/utils";
|
||||
import { countTokens } from "@oh-my-pi/pi-natives";
|
||||
import { logger, prompt } from "@oh-my-pi/pi-utils";
|
||||
import compactionShortSummaryPrompt from "../../prompts/compaction/compaction-short-summary.md" with { type: "text" };
|
||||
import compactionSummaryPrompt from "../../prompts/compaction/compaction-summary.md" with { type: "text" };
|
||||
@@ -218,7 +219,7 @@ export function shouldCompact(contextTokens: number, contextWindow: number, sett
|
||||
return contextTokens > thresholdTokens;
|
||||
}
|
||||
|
||||
function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
|
||||
export function resolveThresholdTokens(contextWindow: number, settings: CompactionSettings): number {
|
||||
// Fixed token limit takes priority over percentage
|
||||
const thresholdTokens = settings.thresholdTokens;
|
||||
if (typeof thresholdTokens === "number" && Number.isFinite(thresholdTokens) && thresholdTokens > 0) {
|
||||
@@ -240,67 +241,79 @@ function resolveThresholdTokens(contextWindow: number, settings: CompactionSetti
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using chars/4 heuristic.
|
||||
* This is conservative (overestimates tokens).
|
||||
* Image content has no tokenizer representation; charge a fixed estimate
|
||||
* matching what providers typically bill for inline images.
|
||||
*/
|
||||
const IMAGE_TOKEN_ESTIMATE = 1200;
|
||||
|
||||
/**
|
||||
* Estimate token count for a message using cl100k_base via the native
|
||||
* tokenizer. This is not Claude's first-party tokenizer (Anthropic doesn't
|
||||
* publish one) but is within ~5–10% across English/code text.
|
||||
*/
|
||||
export function estimateTokens(message: AgentMessage): number {
|
||||
let chars = 0;
|
||||
const fragments: string[] = [];
|
||||
let extra = 0;
|
||||
|
||||
switch (message.role) {
|
||||
case "user": {
|
||||
const content = (message as { content: string | Array<{ type: string; text?: string }> }).content;
|
||||
if (typeof content === "string") {
|
||||
chars = content.length;
|
||||
fragments.push(content);
|
||||
} else if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
fragments.push(block.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
break;
|
||||
}
|
||||
case "assistant": {
|
||||
const assistant = message as AssistantMessage;
|
||||
for (const block of assistant.content) {
|
||||
if (block.type === "text") {
|
||||
chars += block.text.length;
|
||||
fragments.push(block.text);
|
||||
} else if (block.type === "thinking") {
|
||||
chars += block.thinking.length;
|
||||
fragments.push(block.thinking);
|
||||
} else if (block.type === "toolCall") {
|
||||
chars += block.name.length + JSON.stringify(block.arguments).length;
|
||||
fragments.push(block.name);
|
||||
fragments.push(JSON.stringify(block.arguments));
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
break;
|
||||
}
|
||||
case "hookMessage":
|
||||
case "toolResult": {
|
||||
if (typeof message.content === "string") {
|
||||
chars = message.content.length;
|
||||
fragments.push(message.content);
|
||||
} else {
|
||||
for (const block of message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
chars += block.text.length;
|
||||
}
|
||||
if (block.type === "image") {
|
||||
chars += 4800; // Estimate images as 4000 chars, or 1200 tokens
|
||||
fragments.push(block.text);
|
||||
} else if (block.type === "image") {
|
||||
extra += IMAGE_TOKEN_ESTIMATE;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Math.ceil(chars / 4);
|
||||
break;
|
||||
}
|
||||
case "bashExecution": {
|
||||
chars = message.command.length + message.output.length;
|
||||
return Math.ceil(chars / 4);
|
||||
fragments.push(message.command);
|
||||
fragments.push(message.output);
|
||||
break;
|
||||
}
|
||||
case "branchSummary":
|
||||
case "compactionSummary": {
|
||||
chars = message.summary.length;
|
||||
return Math.ceil(chars / 4);
|
||||
fragments.push(message.summary);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
if (fragments.length === 0) return extra;
|
||||
return extra + countTokens(fragments);
|
||||
}
|
||||
|
||||
function estimateEntriesTokens(entries: SessionEntry[], startIndex: number, endIndex: number): number {
|
||||
|
||||
Reference in New Issue
Block a user