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
55 changes: 23 additions & 32 deletions lib/commands/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { sendIgnoredMessage } from "../ui/notification"
import { formatTokenCount } from "../ui/utils"
import { loadAllSessionStats, type AggregatedStats } from "../state/persistence"
import { getCurrentParams } from "../strategies/utils"
import { sumAutoSupersede } from "../state/stats-utils"
import packageJson from "../../package.json" with { type: "json" }

export interface StatsCommandContext {
Expand Down Expand Up @@ -42,20 +43,9 @@ function formatStatsMessage(
lines.push("Strategy Effectiveness:")
lines.push("─".repeat(60))

// Calculate auto-supersede totals
// Calculate auto-supersede totals (dynamically sums all categories)
const autoSupersede = strategyStats.autoSupersede
const autoSupersedeTotal = {
count:
autoSupersede.hash.count +
autoSupersede.file.count +
autoSupersede.todo.count +
autoSupersede.context.count,
tokens:
autoSupersede.hash.tokens +
autoSupersede.file.tokens +
autoSupersede.todo.tokens +
autoSupersede.context.tokens,
}
const autoSupersedeTotal = sumAutoSupersede(autoSupersede)

// Calculate manual discard totals (new nested structure)
const manualDiscard = strategyStats.manualDiscard
Expand Down Expand Up @@ -87,25 +77,26 @@ function formatStatsMessage(

// Show sub-breakdown for Auto-Supersede
if (strat.breakdown === "autoSupersede") {
if (autoSupersede.hash.count > 0) {
lines.push(
` 🔄 hash ${autoSupersede.hash.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.hash.tokens)}`,
)
}
if (autoSupersede.file.count > 0) {
lines.push(
` 📁 file ${autoSupersede.file.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.file.tokens)}`,
)
}
if (autoSupersede.todo.count > 0) {
lines.push(
` ✅ todo ${autoSupersede.todo.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.todo.tokens)}`,
)
}
if (autoSupersede.context.count > 0) {
lines.push(
` 🔧 context ${autoSupersede.context.count.toString().padStart(3)} prunes, ~${formatTokenCount(autoSupersede.context.tokens)}`,
)
const autoEntries: Array<{
icon: string
label: string
data: { count: number; tokens: number }
}> = [
{ icon: "🔄", label: "hash", data: autoSupersede.hash },
{ icon: "📁", label: "file", data: autoSupersede.file },
{ icon: "✅", label: "todo", data: autoSupersede.todo },
{ icon: "🔧", label: "context", data: autoSupersede.context },
{ icon: "🌐", label: "url", data: autoSupersede.url },
{ icon: "🔍", label: "stateQuery", data: autoSupersede.stateQuery },
{ icon: "📸", label: "snapshot", data: autoSupersede.snapshot },
{ icon: "🔁", label: "retry", data: autoSupersede.retry },
]
for (const entry of autoEntries) {
if (entry.data.count > 0) {
lines.push(
` ${entry.icon} ${entry.label.padEnd(14)}${entry.data.count.toString().padStart(3)} prunes, ~${formatTokenCount(entry.data.tokens)}`,
)
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion lib/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ export const DEFAULT_CONFIG: PluginConfig = {
initialTurns: 5,
repeatTurns: 4,
stuckTaskTurns: 12,
maxContextTokens: 100000,
fallbackContextWindow: 200000,
warningThresholdPercent: 0.7,
},
automataMode: {
enabled: true,
Expand Down
12 changes: 9 additions & 3 deletions lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,17 @@ export const TodoReminderSchema = z.object({
.positive()
.default(12)
.describe("Number of turns a task can be in_progress before suggesting breakdown"),
maxContextTokens: z
fallbackContextWindow: z
.number()
.positive()
.default(100000)
.describe("Maximum context token threshold for todo reminders"),
.default(200000)
.describe("Fallback context window size when model is unknown (tokens)"),
warningThresholdPercent: z
.number()
.min(0)
.max(1)
.default(0.7)
.describe("Percentage of effective input that triggers context warning (0.7 = 70%)"),
})

export const AutomataModeSchema = z.object({
Expand Down
40 changes: 40 additions & 0 deletions lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import { sendUnifiedNotification } from "./ui/notification"
import { getCurrentParams } from "./strategies/utils"
import { saveSessionState } from "./state/persistence"
import { isSyntheticMessage } from "./shared-utils"
import {
calculateTotalContextTokens,
getEffectiveContextThreshold,
getContextStatus,
} from "./strategies/utils"

type Strategy = (
state: SessionState,
Expand Down Expand Up @@ -80,6 +85,41 @@ export function createChatMessageTransformHandler(
): Promise<void> => {
await syncSessionState(client, state, config, logger, output.messages)

// Compute context pressure metrics once per turn
safeExecute(
() => {
const { modelId } = getCurrentParams(state, output.messages, logger)
const threshold = getEffectiveContextThreshold(modelId, {
fallbackContextWindow: config.tools.todoReminder.fallbackContextWindow,
warningThresholdPercent: config.tools.todoReminder.warningThresholdPercent,
})
const currentTokens = calculateTotalContextTokens(state, output.messages)
const status = getContextStatus(currentTokens, threshold.rawWindow)

state.contextPressure = {
contextTokens: currentTokens,
effectiveLimit: threshold.rawWindow,
contextPercent: status.percent,
statusLabel: status.label,
statusEmoji: status.emoji,
modelMatch: threshold.modelMatch,
totalSaved: state.stats.totalPruneTokens,
remaining: status.remaining,
}

logger.debug("Context pressure computed", {
modelId: modelId ?? "unknown",
modelMatch: threshold.modelMatch ?? "fallback",
rawWindow: threshold.rawWindow,
currentTokens,
percent: status.percent,
status: status.label,
})
},
logger,
"computeContextPressure",
)

if (state.isSubAgent) {
return
}
Expand Down
65 changes: 54 additions & 11 deletions lib/messages/todo-reminder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { SessionState, WithParts } from "../state/types"
import type { PluginConfig } from "../config"
import type { Logger } from "../logger"
import { isMessageCompacted } from "../shared-utils"
import { calculateTotalContextTokens } from "../strategies/utils"

/**
* Format token count for display (e.g., 1234 -> "1.2K", 12345 -> "12.3K")
Expand All @@ -15,8 +14,7 @@ function formatTokens(tokens: number): string {
const REMINDER_TEMPLATE = `::synth::
---
## 🔖 Checkpoint
{context_pressure}
I've noticed your todo list hasn't been updated for {turns} turns. Before continuing:
{context_section}

### 1. Reflect — What changed? Any new risks or blockers?
### 2. Update — Call \`todowrite\` to sync progress
Expand All @@ -26,6 +24,16 @@ Use prunable_hash values from \`<acp:tool>\`, \`<acp:message>\`, \`<acp:reasonin
---
`

const CONTEXT_SECTION_TEMPLATE = `
⚡ **Context: {percent}% {status_emoji} {status_label}** — {remaining} tokens remaining
📋 {current_tokens} / {raw_window} ({raw_window} context window)
{model_line}
{savings_line}
`

const MODEL_LINE_TEMPLATE = `🤖 Model: {model_name} ({raw_window} context)`
const SAVINGS_LINE_TEMPLATE = `💾 Savings: {saved_tokens} tokens reclaimed via pruning`

const STUCK_TASK_GUIDANCE = `
### ⚠️ Stuck Task Detected

Expand All @@ -43,6 +51,43 @@ Use \`todowrite\` to split the task or update its status.
const REMINDER_REGEX =
/(?:^|\n)(?:::synth::\n)?---\n## 🔖 Checkpoint\n\nI've noticed your todo list hasn't been updated for \d+ turns\. Before continuing:\n\n### 1\. Reflect — What changed\? Any new risks or blockers\?\n### 2\. Update — Call `todowrite` to sync progress\n### 3\. Prune — Call `context` to discard\/distill noise\n(?:\n\*\*Prunable Outputs:\*\*\n(?:[a-z]+: [^\n]+\n)+)?\n?(?:### ⚠️ Stuck Task Detected\n\nI've noticed a task has been in progress for \d+ turns\. If you're finding it difficult to complete, consider:\n- Breaking it into smaller, more specific subtasks\n- Identifying blockers or dependencies that need resolution first\n- Marking it as blocked and moving to another task\n\nUse `todowrite` to split the task or update its status\.\n)?---\n?/g

/**
* Build the context pressure section for the reminder.
* Uses pre-computed state.contextPressure from hooks.ts.
*/
function buildContextSection(state: SessionState, config: PluginConfig): string {
const cp = state.contextPressure
const todoConfig = config.tools.todoReminder

// Get raw window size for display
const rawWindow = cp.effectiveLimit > 0 ? cp.effectiveLimit : todoConfig.fallbackContextWindow

// Build model line (only if we detected a model)
let modelLine = ""
if (cp.modelMatch) {
modelLine = MODEL_LINE_TEMPLATE.replace("{model_name}", cp.modelMatch).replace(
"{raw_window}",
formatTokens(rawWindow),
)
}

// Build savings line (only if we've saved tokens)
let savingsLine = ""
if (cp.totalSaved > 0) {
savingsLine = SAVINGS_LINE_TEMPLATE.replace("{saved_tokens}", formatTokens(cp.totalSaved))
}

return CONTEXT_SECTION_TEMPLATE.replace("{percent}", String(cp.contextPercent))
.replace("{status_emoji}", cp.statusEmoji)
.replace("{status_label}", cp.statusLabel)
.replace("{remaining}", formatTokens(cp.remaining))
.replace("{current_tokens}", formatTokens(cp.contextTokens))
.replace("{raw_window}", formatTokens(rawWindow))
.replace("{model_line}", modelLine)
.replace("{savings_line}", savingsLine)
.trim()
}

/**
* Remove any todo reminder from messages.
* Called when todowrite is detected to clean up the reminder.
Expand Down Expand Up @@ -144,11 +189,8 @@ export function injectTodoReminder(
// Remove any existing reminder messages first (ensure only one exists)
removeTodoReminder(state, messages, logger)

// Calculate context pressure
const currentTokens = calculateTotalContextTokens(state, messages)
const maxTokens = config.tools.todoReminder.maxContextTokens ?? 100000 // Default 100K
const pressurePercent = Math.min(100, Math.round((currentTokens / maxTokens) * 100))
const contextPressure = `\n⚡ **Context: ${pressurePercent}%** (${formatTokens(currentTokens)}/${formatTokens(maxTokens)} tokens)\n`
// Build context pressure section from pre-computed state
const contextSection = buildContextSection(state, config)

// Detect stuck tasks (in_progress for too long)
const stuckTaskTurns = config.tools.todoReminder.stuckTaskTurns ?? 12
Expand All @@ -172,9 +214,10 @@ export function injectTodoReminder(
}

// Create reminder content
const reminderContent = REMINDER_TEMPLATE.replace("{turns}", String(turnsSinceTodo))
.replace("{context_pressure}", contextPressure)
.replace("{stuck_task_guidance}", stuckTaskSection)
const reminderContent = REMINDER_TEMPLATE.replace("{context_section}", contextSection).replace(
"{stuck_task_guidance}",
stuckTaskSection,
)

// Create a new user message with the reminder
const reminderMessage: WithParts = {
Expand Down
1 change: 1 addition & 0 deletions lib/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export * from "./state"
export * from "./tool-cache"
export * from "./types"
export * from "./persistence"
export * from "./stats-utils"
10 changes: 10 additions & 0 deletions lib/state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,16 @@ export function createSessionState(): SessionState {
},
},
todos: [],
contextPressure: {
contextTokens: 0,
effectiveLimit: 0,
contextPercent: 0,
statusLabel: "Nominal",
statusEmoji: "🟢",
modelMatch: null,
totalSaved: 0,
remaining: 0,
},
}
}

Expand Down
52 changes: 52 additions & 0 deletions lib/state/stats-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Shared utility for summing prune statistics across all strategy categories.
* Single source of truth — used by both formatStatsHeader() and /acp stats command.
*/

import type { SessionStats } from "./types"

/**
* Sum all auto-supersede categories dynamically.
* Uses Object.values() so new categories added to SessionStats.autoSupersede
* are automatically included without code changes.
*
* Guards against non-counter properties by checking for numeric .count/.tokens fields.
*/
export function sumAutoSupersede(autoSupersede: SessionStats["strategyStats"]["autoSupersede"]): {
count: number
tokens: number
} {
let count = 0
let tokens = 0
for (const entry of Object.values(autoSupersede)) {
if (
entry &&
typeof entry === "object" &&
typeof entry.count === "number" &&
typeof entry.tokens === "number"
) {
count += entry.count
tokens += entry.tokens
}
}
return { count, tokens }
}

/**
* Sum all tool-related prune stats: manual tool discards + all auto-supersede + purge errors.
* This is the "⚙️" line in the status bar.
*/
export function sumToolPruneStats(strategyStats: SessionStats["strategyStats"]): {
count: number
tokens: number
} {
const auto = sumAutoSupersede(strategyStats.autoSupersede)
return {
count:
strategyStats.manualDiscard.tool.count + auto.count + strategyStats.purgeErrors.count,
tokens:
strategyStats.manualDiscard.tool.tokens +
auto.tokens +
strategyStats.purgeErrors.tokens,
}
}
19 changes: 19 additions & 0 deletions lib/state/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ export interface TodoItem {
inProgressSince?: number // Turn when task became in_progress (for stuck task detection)
}

/**
* Transient context pressure metrics.
* Computed once per turn from model info and current context size.
* NOT persisted - recalculated on every turn.
*/
export interface ContextPressure {
contextTokens: number // Current token count in context
effectiveLimit: number // Effective input limit after output buffer
contextPercent: number // Current usage percentage (0-100)
statusLabel: string // Status label (Nominal, Elevated, High, Critical)
statusEmoji: string // Status emoji (🟢, 🟡, 🟠, 🔴)
modelMatch: string | null // Detected model name (or null if unknown)
totalSaved: number // Total tokens saved via pruning
remaining: number // Tokens remaining before effective limit
}

/**
* Transient runtime cache for O(1) lookups.
* NOT persisted - rebuilt on demand from arrays.
Expand Down Expand Up @@ -166,6 +182,9 @@ export interface SessionState {

todos: TodoItem[] // Current todo list state

// Transient context pressure - NOT persisted, recomputed each turn
contextPressure: ContextPressure

// Transient runtime cache - NOT persisted, rebuilt on demand
_cache?: RuntimeCache
}
Loading
Loading