From 3ec9acfaf5bc284ea51a1616fdf9f0cc5e25e59d Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Thu, 22 Oct 2026 11:10:16 +0200 Subject: [PATCH] Chat memory: context meter, Compress button and auto-compress for long chats - Track how full a chat's Claude session is (latest top-level message usage, the main model's context window), saved per chat (migration 100), mirrored for runner chats and pushed live during a run - Composer ring with a popover (tokens, share, auto threshold) and Compress now; a bar above the composer once the chat gets long; Compress on the too-long turn end - /compress is an alias of /compact (also offered in the slash menu); the compaction notice renders as a divider with before/after tokens - Settings: Compress long chats at 30-80% (default 50%), when nearly full or never, via CLAUDE_AUTOCOMPACT_PCT_OVERRIDE / DISABLE_AUTO_COMPACT; open to agents and the phone --- apps/desktop/src/components/chat/composer.tsx | 4 + .../src/components/chat/context-meter.tsx | 195 ++++++++++++++++++ .../src/components/chat/message-blocks.tsx | 13 +- apps/desktop/src/components/chat/turn-end.tsx | 20 +- .../src/components/settings/ai-section.tsx | 26 ++- .../src/pages/chat/chat-conversation.tsx | 40 ++++ packages/core/src/db/migrations.ts | 10 + packages/core/src/mobile/scope.ts | 1 + packages/core/src/remote/mirror.ts | 10 +- packages/core/src/runner/commands.ts | 6 +- packages/core/src/runner/runner.ts | 41 +++- packages/core/src/runner/stream.ts | 37 +++- packages/core/src/services/agentSettings.ts | 2 +- packages/core/src/services/conversations.ts | 11 +- packages/core/src/services/settingShape.ts | 1 + packages/core/src/services/settings.ts | 3 +- packages/core/src/services/settingsPatch.ts | 4 + packages/core/test/fixtures/fake-claude.ts | 8 + packages/core/test/fixtures/runner-harness.ts | 2 + packages/core/test/mcp.test.ts | 1 + packages/core/test/remote-mirror.test.ts | 1 + packages/core/test/slash-commands.test.ts | 42 +++- packages/shared/src/constants.ts | 10 + packages/shared/src/models.ts | 23 ++- 24 files changed, 488 insertions(+), 23 deletions(-) create mode 100644 apps/desktop/src/components/chat/context-meter.tsx diff --git a/apps/desktop/src/components/chat/composer.tsx b/apps/desktop/src/components/chat/composer.tsx index a29b531e..c0e1927b 100644 --- a/apps/desktop/src/components/chat/composer.tsx +++ b/apps/desktop/src/components/chat/composer.tsx @@ -68,6 +68,8 @@ interface ComposerProps { leading?: ReactNode; /** Rendered before the voice and send buttons (e.g. model picker) */ trailing?: ReactNode; + /** Always in the toolbar, also in Easy mode (e.g. the context meter) */ + status?: ReactNode; /** Setup chips (folder, machines, profile…): in the tray after `leading`; Easy mode keeps them and `trailing` behind one Options button. */ options?: ReactNode; size?: "md" | "lg"; @@ -99,6 +101,7 @@ export function Composer({ onRecall, leading, trailing, + status, options, size = "md", draftKey, @@ -498,6 +501,7 @@ export function Composer({ )} + {status} {easy ? (options || trailing) && {options}{trailing} : trailing} diff --git a/apps/desktop/src/components/chat/context-meter.tsx b/apps/desktop/src/components/chat/context-meter.tsx new file mode 100644 index 00000000..9571933f --- /dev/null +++ b/apps/desktop/src/components/chat/context-meter.tsx @@ -0,0 +1,195 @@ +import { Link } from "react-router"; +import { motion } from "motion/react"; +import { ArrowRight, FoldVertical, Loader2, X } from "lucide-react"; +import type { CompactInfo, ConversationContext } from "@godmode/shared"; +import { COMPRESS_MIN_TOKENS, DEFAULT_AUTO_COMPACT_PERCENT } from "@godmode/shared"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useSettings } from "@/lib/hooks"; +import { cn } from "@/lib/utils"; + +/** Claude Code summarizes by itself close to a full window when Godmode sets no threshold. */ +const CLAUDE_DEFAULT_SHARE = 0.9; + +export function tokensLabel(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`; + return String(n); +} + +export interface ContextGauge { + tokens: number; + window: number | null; + /** Share of the window in use, 0..1; null without a window. */ + share: number | null; + /** Where Claude summarizes by itself (share of the window); null = never. */ + autoAt: number | null; + tone: "calm" | "warn" | "full"; + /** Worth a summary at all. */ + compressible: boolean; +} + +export function useContextGauge(context: ConversationContext | null | undefined): ContextGauge | null { + const pct = useSettings().data?.runner.autoCompactPercent; + if (!context) return null; + const autoAt = pct === 0 ? null : pct === null ? CLAUDE_DEFAULT_SHARE : (pct ?? DEFAULT_AUTO_COMPACT_PERCENT) / 100; + const share = context.window ? Math.min(1, context.tokens / context.window) : null; + const limit = autoAt ?? CLAUDE_DEFAULT_SHARE; + const tone = share === null ? "calm" : share >= limit * 0.95 ? "full" : share >= limit * 0.75 ? "warn" : "calm"; + return { tokens: context.tokens, window: context.window, share, autoAt, tone, compressible: context.tokens >= COMPRESS_MIN_TOKENS }; +} + +const TONE = { + calm: { ring: "text-foreground/55", bar: "bg-foreground/70", text: "text-muted-foreground" }, + warn: { ring: "text-warning", bar: "bg-warning", text: "text-warning" }, + full: { ring: "text-destructive", bar: "bg-destructive", text: "text-destructive" }, +} as const; + +function Ring({ share, tone, busy }: { share: number; tone: ContextGauge["tone"]; busy?: boolean }) { + const r = 7; + const c = 2 * Math.PI * r; + return ( + + + + + ); +} + +/** The composer's context ring: how full the chat's session is, with Compress one click away. */ +export function ContextMeter({ + gauge, + agentName, + running, + compressing, + onCompress, + disabled, +}: { + gauge: ContextGauge; + agentName: string; + running: boolean; + compressing: boolean; + onCompress: () => void; + disabled?: boolean; +}) { + const pct = gauge.share === null ? null : Math.round(gauge.share * 100); + const tone = TONE[gauge.tone]; + return ( + + + + + + + + {compressing ? "Compressing the chat…" : pct === null ? `${tokensLabel(gauge.tokens)} tokens in this chat` : `Chat memory ${pct}% full`} + + +
+
Chat memory
+
+
+ {tokensLabel(gauge.tokens)} + {gauge.window ? `of ${tokensLabel(gauge.window)} tokens` : "tokens"} +
+ {pct !== null && {pct}%} +
+ {gauge.share !== null && ( +
+ + {gauge.autoAt !== null && ( + + )} +
+ )} +

+ Compress turns the conversation so far into a short summary. {agentName} keeps the gist, your instructions and its files, and every next step gets faster + and cheaper. +

+
+
+ + {gauge.autoAt === null ? "Auto-compress is off" : `Auto at ${Math.round(gauge.autoAt * 100)}%`} + + +
+
+
+ ); +} + +/** Above the composer once the chat gets long. */ +export function CompressBar({ gauge, agentName, running, onCompress, onDismiss }: { gauge: ContextGauge; agentName: string; running: boolean; onCompress: () => void; onDismiss: () => void }) { + const pct = gauge.share === null ? null : Math.round(gauge.share * 100); + const auto = gauge.autoAt !== null ? Math.round(gauge.autoAt * 100) : null; + return ( +
+ + + + + This chat is getting long + + {" "} + · {tokensLabel(gauge.tokens)} tokens{pct !== null ? ` (${pct}%)` : ""}.{" "} + {auto !== null ? `${agentName} compresses it at ${auto}% by itself.` : "Compress it to keep things quick."} + + + + +
+ ); +} + +/** Where the conversation was summarized: a quiet divider, earlier messages stay readable above it. */ +export function CompactedDivider({ info }: { info: CompactInfo }) { + const saved = info.preTokens && info.postTokens !== null ? Math.max(0, Math.round((1 - info.postTokens / info.preTokens) * 100)) : null; + return ( + + + + + {info.auto ? "Compressed automatically" : "Chat compressed"} + {info.preTokens !== null && info.postTokens !== null && ( + + · {tokensLabel(info.preTokens)} → {tokensLabel(info.postTokens)} + {saved !== null && saved > 0 ? ` (−${saved}%)` : ""} + + )} + + + + ); +} diff --git a/apps/desktop/src/components/chat/message-blocks.tsx b/apps/desktop/src/components/chat/message-blocks.tsx index a44c7af1..cfc079ec 100644 --- a/apps/desktop/src/components/chat/message-blocks.tsx +++ b/apps/desktop/src/components/chat/message-blocks.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { format } from "date-fns"; import { AnimatePresence, motion } from "motion/react"; -import type { Agent, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared"; +import type { Agent, CompactInfo, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared"; import { WORKFLOW_TOOL, isPlaceholderReply } from "@godmode/shared"; import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, Puzzle, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -12,6 +12,7 @@ import { AgentAvatar } from "@/components/common"; import { ThinkingState } from "@/components/aicss/ThinkingState"; import { ThinkingReasoning } from "@/components/aicss/ThinkingReasoning"; import { FileDiff, diffLines, type DiffRow } from "@/components/aicss/FileDiff"; +import { CompactedDivider } from "./context-meter"; import { DrawCheck } from "@/components/aicss/Motion"; import { Orb } from "@/components/aicss/Orb"; import { formatDuration, formatElapsed, formatTokens } from "@/components/runs/run-status"; @@ -42,7 +43,7 @@ type Item = | { kind: "text"; key: string; text: string } | { kind: "thinking"; key: string; text: string } | { kind: "error"; key: string; text: string } - | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string } + | { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string; compacted?: CompactInfo } | { kind: "command"; key: string; name: string; args: string; output: string } | { kind: "user-message"; key: string; block: UserMessageBlock } | { kind: "pause"; key: string; block: PauseBlock } @@ -123,8 +124,10 @@ function buildItems(blocks: MessageBlock[]): Item[] { if (b.type === "text") { if (b.text.trim()) items.push({ kind: "text", key, text: b.text }); } else if (b.type === "error") items.push({ kind: "error", key, text: b.text }); - else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod }); - else if (b.type === "command") items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output }); + else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod, compacted: b.compacted }); + // The compression divider already says what /compact did. + else if (b.type === "command" && !(b.name === "compact" && blocks.some((x) => x.type === "notice" && x.compacted))) + items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output }); else if (b.type === "user_message") items.push({ kind: "user-message", key: b.id, block: b }); // A run that stands still for a question: the card says so. else if (b.type === "pause" && b.reason !== "question") items.push({ kind: "pause", key, block: b }); @@ -213,6 +216,8 @@ export function MessageBlocks({ case "notice": return item.mod ? ( + ) : item.compacted ? ( + ) : ( ); diff --git a/apps/desktop/src/components/chat/turn-end.tsx b/apps/desktop/src/components/chat/turn-end.tsx index 60bb2a5b..8e0b262b 100644 --- a/apps/desktop/src/components/chat/turn-end.tsx +++ b/apps/desktop/src/components/chat/turn-end.tsx @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import type { Agent, ConversationWithMessages, Message, RetryMode, Run } from "@godmode/shared"; import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared"; -import { ArrowUpRight, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react"; +import { ArrowUpRight, FoldVertical, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { useStartAgentChat } from "@/components/agents/agent-actions"; @@ -52,6 +52,11 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver const chat = useStartAgentChat(); const runRoutine = useRunRoutine(); const name = agent?.name ?? "The agent"; + const compress = useMutation({ + mutationFn: () => api.conversations.send(conversation.id, { content: "/compress" }), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.conversation(conversation.id) }), + onError: (err) => toast.error("Couldn't compress the chat", { description: errorMessage(err) }), + }); const retry = useMutation({ mutationFn: () => api.conversations.retry(conversation.id, message.runId!), onSuccess: (res) => { @@ -122,12 +127,15 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver if (!retryHelps(end)) { return ( - - This chat is too long for {name} to go on — type /compact to shorten it, or start fresh. + This chat is too long for {name} to go on. Compress it into a summary and carry on, or start fresh. + + + - ); } diff --git a/apps/desktop/src/components/settings/ai-section.tsx b/apps/desktop/src/components/settings/ai-section.tsx index 035f6c25..26a02948 100644 --- a/apps/desktop/src/components/settings/ai-section.tsx +++ b/apps/desktop/src/components/settings/ai-section.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { Cpu, Gauge, ShieldAlert, SlidersHorizontal, Sparkles, TerminalSquare } from "lucide-react"; import { toast } from "sonner"; -import { DEFAULT_LOOP_REPEATS, DEFAULT_STALL_MINUTES, EFFORT_LABELS, EFFORT_OPTIONS, ULTRACODE_HINT, findModel, type ClaudeModel, type Effort, type Settings } from "@godmode/shared"; +import { DEFAULT_AUTO_COMPACT_PERCENT, DEFAULT_LOOP_REPEATS, DEFAULT_STALL_MINUTES, EFFORT_LABELS, EFFORT_OPTIONS, ULTRACODE_HINT, findModel, type ClaudeModel, type Effort, type Settings } from "@godmode/shared"; import { ReasoningEffort } from "@/components/aicss/ReasoningEffort"; import { AlertDialog, @@ -253,6 +253,30 @@ export function AiSection({ settings }: { settings: Settings }) { v !== null && patch({ runner: { runTimeoutMinutes: v } })} /> + + + (null); + const compress = () => send.mutate({ content: "/compress", queueId: newQueueId() }); + const compressQueued = queue.some((m) => /^\/(compress|compact)\b/.test(m.content.trim())); + const choose = useMutation({ mutationFn: (patch: Partial) => api.conversations.update(conversationId, patch), onMutate: (patch) => { @@ -614,6 +622,26 @@ function ConversationView({ conversationId }: { conversationId: string }) { )} + + {gauge && + gauge.tone !== "calm" && + gauge.compressible && + !compressing && + !compressQueued && + !runnerAway && + (compressHiddenAt === null || gauge.tokens > compressHiddenAt * 1.2) && ( + + setCompressHiddenAt(gauge.tokens)} /> + + )} + {(!conv.runnerId || inGroup) && } + ) + } trailing={ = { "watchdog", "stallMinutes", "loopRepeats", + "autoCompactPercent", "spendTracking", "defaultMaxBudgetUsd", "monthlyBudgetUsd", diff --git a/packages/core/src/remote/mirror.ts b/packages/core/src/remote/mirror.ts index 65b2f501..d6d269a0 100644 --- a/packages/core/src/remote/mirror.ts +++ b/packages/core/src/remote/mirror.ts @@ -12,6 +12,7 @@ * dropped without an answer. Nothing here imports remote/runners.ts; it calls back through `setMirrorHooks`. */ import type { + ConversationContext, AppNotification, Attachment, Conversation, @@ -170,6 +171,13 @@ function readFollowup(v: unknown): ConversationFollowup | null { return { note: text(f.note, MAX_INSTRUCTIONS_LENGTH) ?? "", dueAt, createdAt: isoDate(f.createdAt) ?? dueAt }; } +function readContext(v: unknown): ConversationContext | null { + const c = record(v); + const count = (n: unknown) => (typeof n === "number" && Number.isInteger(n) && n >= 0 && n < 1e9 ? n : null); + const tokens = count(c?.tokens); + return tokens === null ? null : { tokens, window: count(c?.window) }; +} + /** A chat as a runner reports it, reduced to what this computer keeps of it. */ interface Chat { id: string; @@ -214,7 +222,7 @@ function readConversation(v: unknown): Chat | null { const updatedAt = isoDate(c?.updatedAt); const title = text(c?.title, TITLE_MAX); if (!c || !id || !agentId || !origin || !createdAt || !updatedAt || title === null) return null; - const state: RunnerChatState = { running: c.running === true, paused: readPause(c.paused), followup: readFollowup(c.followup) }; + const state: RunnerChatState = { running: c.running === true, paused: readPause(c.paused), followup: readFollowup(c.followup), context: readContext(c.context) }; return { id, agentId, diff --git a/packages/core/src/runner/commands.ts b/packages/core/src/runner/commands.ts index 6307d8ec..d4338914 100644 --- a/packages/core/src/runner/commands.ts +++ b/packages/core/src/runner/commands.ts @@ -127,7 +127,11 @@ function commandsFrom(line: string): SlashCommand[] | null { name, description: builtin ? description : description.replace(/\s*\((?:project|user)\)$/, ""), argumentHint: typeof c.argumentHint === "string" ? c.argumentHint : "", - aliases: Array.isArray(c.aliases) ? c.aliases.filter((a): a is string => typeof a === "string") : [], + aliases: [ + ...(Array.isArray(c.aliases) ? c.aliases.filter((a): a is string => typeof a === "string") : []), + // Godmode's name for it (runner.ts turns /compress into /compact). + ...(builtin && name === "compact" ? ["compress"] : []), + ], builtin, }); } diff --git a/packages/core/src/runner/runner.ts b/packages/core/src/runner/runner.ts index e807b014..ccc694e0 100644 --- a/packages/core/src/runner/runner.ts +++ b/packages/core/src/runner/runner.ts @@ -58,9 +58,11 @@ import { WORKFLOW_TOOL, isModelId, memoryBudget, + compactAlias, modelProvider, parseSlashCommand, type ActivityNames, + type ConversationContext, } from "@godmode/shared"; import { config } from "../config"; import { all, get, insert, run as sql, tx } from "../db"; @@ -413,6 +415,8 @@ interface Job { * reports the total of the whole session, earlier runs of the chat included. */ sessionCostBefore?: number | null; + /** The context the chat was last told about mid-run (tokens), and when. */ + contextShown?: { tokens: number; at: number }; timedOut: boolean; /** The watchdog stopped it: its report (the run's error). */ stalled?: string; @@ -1350,6 +1354,10 @@ export function buildEnv(agent: Agent, inFolder = false, apiKeys = true): Record // `claude -p` stops background subagents still running 10 minutes after the main turn ended, and the agent never // hears back from them. The watchdog and the run timeout decide when a run has gone quiet for too long instead. env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS ??= "0"; + // Long chats are summarized by Claude Code itself once the session fills this share of the window (never above its own threshold). + const compactAt = getSettings().runner.autoCompactPercent; + if (compactAt === 0) env.DISABLE_AUTO_COMPACT = "1"; + else if (compactAt != null) env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = String(compactAt); if (getSettings().memory.backend === "claude-mem" && claudeMemPluginDir()) Object.assign(env, claudeMemEnv(agent)); // API tools that hand their key to runs (Integrations → Tools). if (apiKeys) Object.assign(env, apiToolEnv(agent)); @@ -1789,6 +1797,7 @@ async function spawnClaude( const logged = logLineOf(event, line); if (logged !== null) logSink.write(`${redact(logged)}\n`); if (job.acc.push(event)) scheduleDelta(job); + showContext(job); if (job.acc.goalIdle >= GOAL_IDLE_LIMIT && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) pauseGoal(job, proc); // Claude has read the human's answer the moment it starts replying: settle it now, so a crash later in the run // can't hand the same answer (an approval: "Do it now") to the next turn again. @@ -1910,7 +1919,7 @@ function describeFailure(job: Job, attempt: Attempt): string { */ function bodyOf(job: Job, agent: Agent): string { const resumed = job.resumed; - if (!resumed) return job.prompt; + if (!resumed) return compactAlias(job.prompt); const taken = takeQueued(job.conversationId, agent); for (const { message } of taken) job.acc.addUserMessage(message); if (taken.length) scheduleDelta(job); @@ -2350,7 +2359,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise { + const ctx = contextOf(job).context; + if (!ctx) return; + setConversationState(job.conversationId, { context: ctx }); + emitConversationUpdated(job.conversationId); }); } +/** How full the session is, when the stretch said (a stretch without a model turn leaves it as it was). */ +function contextOf(job: Job): { context?: ConversationContext } { + const acc = job.acc; + if (acc.contextTokens === null) return {}; + const row = acc.contextWindow === null ? get<{ w: number | null }>("SELECT context_window AS w FROM conversations WHERE id = ?", job.conversationId) : null; + return { context: { tokens: acc.contextTokens, window: acc.contextWindow ?? row?.w ?? null } }; +} + /** Claude read the human's answers this stretch carried: they are not owed anymore. */ function settleAnswers(job: Job) { if (!job.acc.answered) return; diff --git a/packages/core/src/runner/stream.ts b/packages/core/src/runner/stream.ts index da7ab9c9..826a1d8e 100644 --- a/packages/core/src/runner/stream.ts +++ b/packages/core/src/runner/stream.ts @@ -171,6 +171,19 @@ function workflowAgents(progress: unknown): ToolTaskAgent[] | null { return agents; } +/** The context window of the model that ran the turn (subagents may use smaller models), from a result's `modelUsage`. */ +function contextWindowOf(modelUsage: unknown, model: string | null): number | null { + if (!isObj(modelUsage)) return null; + const own = model ? modelUsage[model] : undefined; + if (isObj(own) && num(own.contextWindow)) return num(own.contextWindow); + let max: number | null = null; + for (const m of Object.values(modelUsage)) { + const w = isObj(m) ? num(m.contextWindow) : null; + if (w !== null && (max === null || w > max)) max = w; + } + return max; +} + export function addUsage(a: RunUsage | null, b: RunUsage | null): RunUsage | null { if (!a || !b) return a ?? b; return { @@ -202,11 +215,17 @@ export class StreamAccumulator { contextCleared = false; /** The session was compacted: earlier turns are only a summary now. */ compacted = false; + /** Tokens the session sends with a turn, as of its latest top-level message (after a compaction: the summary's). */ + contextTokens: number | null = null; + /** The main model's context window, from the turn's result. */ + contextWindow: number | null = null; /** A Stop hook (`/goal`) kept the turn going this many times in a row while the agent did nothing (no tool call). */ goalIdle = 0; /** The hook's latest verdict, without the goal it restates. */ goalFeedback: string | null = null; private compacting = false; + /** The model of the latest top-level message (subagents may run smaller ones). */ + private mainModel: string | null = null; private toolCalls = 0; private toolCallsAtGoalCheck = 0; @@ -242,6 +261,7 @@ export class StreamAccumulator { return true; case "conversation_reset": this.contextCleared = true; + this.contextTokens = null; this.addNotice("info", "Context cleared — your next message starts a fresh session."); return true; case "rate_limit_event": { @@ -275,7 +295,7 @@ export class StreamAccumulator { const background = this.backgroundWork(); if (this.final && !background) return this.final.isError ? "Failed" : "Done"; if (this.limit) return "Waiting for rate limit…"; - if (this.compacting) return "Compacting conversation…"; + if (this.compacting) return "Compressing the chat…"; const last = this.blocks[this.blocks.length - 1]; if (!last) return "Starting…"; if (background) return background; @@ -344,7 +364,13 @@ export class StreamAccumulator { const meta = isObj(e.compact_metadata) ? e.compact_metadata : {}; const pre = num(meta.pre_tokens); const post = num(meta.post_tokens); - this.addNotice("success", `Conversation compacted${pre !== null && post !== null ? ` · ${kTokens(pre)} → ${kTokens(post)} tokens` : ""}`); + if (post !== null) this.contextTokens = post; + this.blocks.push({ + type: "notice", + level: "success", + text: `Conversation compressed${pre !== null && post !== null ? ` · ${kTokens(pre)} → ${kTokens(post)} tokens` : ""}`, + compacted: { auto: meta.trigger === "auto", preTokens: pre, postTokens: post }, + }); return true; } else if (e.subtype === "task_started" || e.subtype === "task_progress" || e.subtype === "task_updated" || e.subtype === "task_notification") { return this.onTask(e); @@ -586,6 +612,12 @@ export class StreamAccumulator { if (LIMIT_TEXT.test(line)) this.limitLine = line.trim(); } if (str(msg.model) && !synthetic) this.model = str(msg.model); + if (!parent && !synthetic && str(msg.model)) this.mainModel = str(msg.model); + if (!parent && !synthetic && isObj(msg.usage)) { + const u = mapUsage(msg.usage)!; + const tokens = u.inputTokens + u.cacheReadTokens + u.cacheWriteTokens + u.outputTokens; + if (tokens > 0) this.contextTokens = tokens; + } let changed = false; for (const c of msg.content) { @@ -716,6 +748,7 @@ export class StreamAccumulator { // session's so far. const prev = this.final; this.results++; + this.contextWindow = contextWindowOf(e.modelUsage, this.mainModel ?? this.model) ?? this.contextWindow; this.final = { text: resultText !== null && isPlaceholderReply(resultText) ? "" : (resultText ?? this.lastTurnText()), isError: e.is_error === true || (subtype !== null && subtype !== "success"), diff --git a/packages/core/src/services/agentSettings.ts b/packages/core/src/services/agentSettings.ts index e7af212e..1103a992 100644 --- a/packages/core/src/services/agentSettings.ts +++ b/packages/core/src/services/agentSettings.ts @@ -17,7 +17,7 @@ type Sections = Record; */ const AGENT_SETTINGS: Record = { general: ["theme", "mode", "minimizeToTray"], - runner: ["model", "fallbackModel", "effort", "ultracode", "maxConcurrentRuns", "runTimeoutMinutes", "autoContinueOnLimit", "resumeAfterRestart", "stallMinutes", "loopRepeats"], + runner: ["model", "fallbackModel", "effort", "ultracode", "maxConcurrentRuns", "runTimeoutMinutes", "autoContinueOnLimit", "resumeAfterRestart", "stallMinutes", "loopRepeats", "autoCompactPercent"], browser: ["headless", "keepAliveMinutes", "stealth", "muteAudio"], computer: ["agentCursor", "liveViewFps", "screenshotMaxSize"], vm: ["onQuit", "idleStopMinutes"], diff --git a/packages/core/src/services/conversations.ts b/packages/core/src/services/conversations.ts index 292454d6..a3fe06a5 100644 --- a/packages/core/src/services/conversations.ts +++ b/packages/core/src/services/conversations.ts @@ -21,7 +21,7 @@ import type { RunPause, RunTrigger, } from "@godmode/shared"; -import type { ComputerTarget, ConversationPatch, ConversationWithMessages, MessagePage, SendMessageInput, SendMessageResult, StartChatResult } from "@godmode/shared"; +import type { ComputerTarget, ConversationContext, ConversationPatch, ConversationWithMessages, MessagePage, SendMessageInput, SendMessageResult, StartChatResult } from "@godmode/shared"; import { computerTargetLabel, MAX_MESSAGE_ATTACHMENT_BYTES, modelProvider } from "@godmode/shared"; import { liteMessage } from "./liteMessage"; import { all, bool, get, insert, int, run as sql, tx, update } from "../db"; @@ -74,6 +74,8 @@ interface ConversationRow extends PauseQuestionCols { runner_id: string | null; runner_state: string | null; runner_tools_id: string | null; + context_tokens: number | null; + context_window: number | null; pinned: number; archived: number; last_message_at: string | null; @@ -138,6 +140,7 @@ export interface RunnerChatState { running: boolean; paused: RunPause | null; followup: ConversationFollowup | null; + context?: ConversationContext | null; } function toConversation(r: ConversationRow): Conversation { @@ -169,6 +172,7 @@ function toConversation(r: ConversationRow): Conversation { createdAt: r.created_at, updatedAt: r.updated_at, preview: previewOf(r.preview), + context: remote ? (remote.context ?? null) : r.context_tokens != null ? { tokens: r.context_tokens, window: r.context_window ?? null } : null, running: remote ? remoteRunForConversation(r.id) !== null || remote.running === true : activeRunForConversation(r.id) !== null, followup: remote ? (remote.followup ?? null) @@ -580,8 +584,12 @@ export function setConversationState( effort?: Effort | null; ultracode?: boolean | null; archived?: boolean; + /** How full the session is after this turn; null = unknown (a new or cleared session). */ + context?: ConversationContext | null; }, ) { + // A cleared session starts empty. + const context = patch.context !== undefined ? patch.context : patch.claudeSessionId === null ? null : undefined; update("conversations", id, { claude_session_id: patch.claudeSessionId, claude_session_cost_usd: patch.claudeSessionCostUsd !== undefined ? patch.claudeSessionCostUsd : patch.claudeSessionId !== undefined ? null : undefined, @@ -593,6 +601,7 @@ export function setConversationState( ultracode: patch.ultracode === null ? null : int(patch.ultracode), last_message_at: patch.lastMessageAt, title: patch.title, + ...(context !== undefined ? { context_tokens: context?.tokens ?? null, context_window: context?.window ?? null } : {}), updated_at: now(), }); } diff --git a/packages/core/src/services/settingShape.ts b/packages/core/src/services/settingShape.ts index 49efe0b0..cefab928 100644 --- a/packages/core/src/services/settingShape.ts +++ b/packages/core/src/services/settingShape.ts @@ -6,6 +6,7 @@ const SETTING_LIMITS: Record = { "runner.runTimeoutMinutes": [0, 1440], "runner.stallMinutes": [3, 240], "runner.loopRepeats": [3, 50], + "runner.autoCompactPercent": [0, 95], "runner.defaultMaxBudgetUsd": [0.01, 10_000], "runner.monthlyBudgetUsd": [0.01, 1_000_000], "browser.keepAliveMinutes": [0, 1440], diff --git a/packages/core/src/services/settings.ts b/packages/core/src/services/settings.ts index 15527cd5..dbed9af2 100644 --- a/packages/core/src/services/settings.ts +++ b/packages/core/src/services/settings.ts @@ -1,5 +1,5 @@ import type { Settings } from "@godmode/shared"; -import { DEFAULT_CLOUD_SETTINGS, DEFAULT_FALLBACK_MODEL, DEFAULT_LOOP_REPEATS, DEFAULT_MEMORY_CHARS, DEFAULT_MODEL, DEFAULT_PORT, DEFAULT_STALL_MINUTES, MOBILE_DEFAULT_PORT } from "@godmode/shared"; +import { DEFAULT_AUTO_COMPACT_PERCENT, DEFAULT_CLOUD_SETTINGS, DEFAULT_FALLBACK_MODEL, DEFAULT_LOOP_REPEATS, DEFAULT_MEMORY_CHARS, DEFAULT_MODEL, DEFAULT_PORT, DEFAULT_STALL_MINUTES, MOBILE_DEFAULT_PORT } from "@godmode/shared"; import { all, run } from "../db"; import { bus } from "../events/bus"; import { parseJson } from "../util"; @@ -29,6 +29,7 @@ export const DEFAULT_SETTINGS: Settings = { watchdog: true, stallMinutes: DEFAULT_STALL_MINUTES, loopRepeats: DEFAULT_LOOP_REPEATS, + autoCompactPercent: DEFAULT_AUTO_COMPACT_PERCENT, spendTracking: false, defaultMaxBudgetUsd: null, monthlyBudgetUsd: null, diff --git a/packages/core/src/services/settingsPatch.ts b/packages/core/src/services/settingsPatch.ts index e1e15121..6df74475 100644 --- a/packages/core/src/services/settingsPatch.ts +++ b/packages/core/src/services/settingsPatch.ts @@ -36,6 +36,10 @@ export function checkSettingsPatch(patch: Record, needsGrant: ( // Claude stays every agent's default: GLM is picked per agent, chat or delegated task. if (typeof value === "string" && modelProvider(value) !== "anthropic") throw badRequest(`runner.${key} must be a Claude model; pick GLM per agent or chat`); } + const compactAt = (runner as { autoCompactPercent?: unknown } | undefined)?.autoCompactPercent; + if (compactAt !== undefined && compactAt !== null && !(typeof compactAt === "number" && Number.isInteger(compactAt) && compactAt >= 0 && compactAt <= 95)) { + throw badRequest("Auto-compress must be a whole percent between 0 (off) and 95, or empty for Claude Code's own threshold"); + } const budget = (runner as { monthlyBudgetUsd?: unknown } | undefined)?.monthlyBudgetUsd; if (budget !== undefined && budget !== null && !(typeof budget === "number" && Number.isFinite(budget) && budget > 0 && budget <= 1_000_000)) { throw badRequest("The monthly budget must be an amount above 0, or empty for no budget"); diff --git a/packages/core/test/fixtures/fake-claude.ts b/packages/core/test/fixtures/fake-claude.ts index 404fa59a..249af365 100755 --- a/packages/core/test/fixtures/fake-claude.ts +++ b/packages/core/test/fixtures/fake-claude.ts @@ -8,6 +8,7 @@ * USE_TOOL replay stream-tooluse.jsonl → Bash tool + "DONE" * USE_WORKFLOW replay stream-workflow.jsonl → a workflow of two agents in the background, two results * SLEEP emit init, then hang (cancel / timeout tests) + * BIG_CONTEXT one answer whose message fills 421.6k tokens of a 1M window * LOGIN_FAIL answer with a login-failure sentence * AUTH_403 fail like Claude Code when Claude's API refuses the sign-in ("Failed to authenticate. API Error: 403 …") * AUTH_EXPIRED fail like Claude Code when its sign-in ran out ("Failed to authenticate: OAuth session expired …") @@ -302,6 +303,8 @@ appendFileSync( CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD ?? null, CLAUDE_CODE_PLUGIN_DIR_WATCH: process.env.CLAUDE_CODE_PLUGIN_DIR_WATCH ?? null, CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: process.env.CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS ?? null, + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE ?? null, + DISABLE_AUTO_COMPACT: process.env.DISABLE_AUTO_COMPACT ?? null, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL ?? null, ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN ?? null, ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ?? null, @@ -861,6 +864,11 @@ if (slash?.[1] === "clear") { out({ type: "assistant", message: { id: crypto.randomUUID(), model: "", role: "assistant", content: [{ type: "text", text }] }, parent_tool_use_id: null, session_id: sessionId }); out({ type: "result", subtype: "success", is_error: true, api_error_status: 401, result: text, session_id: sessionId, total_cost_usd: 0, duration_ms: 7, num_turns: 1 }); process.exit(1); +} else if (prompt.includes("BIG_CONTEXT")) { + out(init); + const usage = { input_tokens: 2, cache_read_input_tokens: 420_000, cache_creation_input_tokens: 1_000, output_tokens: 600 }; + out({ type: "assistant", message: { id: `msg_${crypto.randomUUID()}`, role: "assistant", model: "claude-opus-5-5", content: [{ type: "text", text: "long" }], usage }, parent_tool_use_id: null, session_id: sessionId }); + result("long", { modelUsage: { "claude-opus-5-5": { contextWindow: 1_000_000 } } }); } else if (prompt.includes("LOGIN_FAIL")) { out(init); const text = "I opened the dashboard. I couldn't log in to example.com because there are no saved credentials for it."; diff --git a/packages/core/test/fixtures/runner-harness.ts b/packages/core/test/fixtures/runner-harness.ts index cba83765..6cc3ceda 100644 --- a/packages/core/test/fixtures/runner-harness.ts +++ b/packages/core/test/fixtures/runner-harness.ts @@ -157,6 +157,8 @@ export interface Invocation { CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: string | null; CLAUDE_CODE_PLUGIN_DIR_WATCH: string | null; CLAUDE_CODE_PRINT_BG_WAIT_CEILING_MS: string | null; + CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: string | null; + DISABLE_AUTO_COMPACT: string | null; ANTHROPIC_BASE_URL: string | null; ANTHROPIC_AUTH_TOKEN: string | null; ANTHROPIC_DEFAULT_HAIKU_MODEL: string | null; diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index eba08404..5a4bce3e 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1495,6 +1495,7 @@ describe("settings tools", () => { "runner.resumeAfterRestart", "runner.stallMinutes", "runner.loopRepeats", + "runner.autoCompactPercent", "browser.headless", "browser.keepAliveMinutes", "browser.stealth", diff --git a/packages/core/test/remote-mirror.test.ts b/packages/core/test/remote-mirror.test.ts index 73041cb9..a70e943a 100644 --- a/packages/core/test/remote-mirror.test.ts +++ b/packages/core/test/remote-mirror.test.ts @@ -562,6 +562,7 @@ describe("the chat the API returns", () => { createdAt: local.createdAt, updatedAt: local.updatedAt, preview: "", + context: null, running: false, followup: null, paused: null, diff --git a/packages/core/test/slash-commands.test.ts b/packages/core/test/slash-commands.test.ts index 657ab320..503eafa3 100644 --- a/packages/core/test/slash-commands.test.ts +++ b/packages/core/test/slash-commands.test.ts @@ -64,16 +64,43 @@ describe("StreamAccumulator — local slash commands", () => { expect(acc.push({ type: "conversation_reset", new_conversation_id: "x", trigger: "clear" })).toBe(true); expect(acc.contextCleared).toBe(true); acc.push({ type: "system", subtype: "status", status: "compacting" }); - expect(acc.activityLabel()).toBe("Compacting conversation…"); + expect(acc.activityLabel()).toBe("Compressing the chat…"); acc.push({ type: "system", subtype: "status", status: null, compact_result: "success" }); expect(acc.compacted).toBe(false); acc.push({ type: "system", subtype: "compact_boundary", compact_metadata: { trigger: "manual", pre_tokens: 58279, post_tokens: 2556 } }); expect(acc.compacted).toBe(true); + expect(acc.contextTokens).toBe(2556); expect(acc.blocks).toEqual([ { type: "notice", level: "info", text: "Context cleared — your next message starts a fresh session." }, - { type: "notice", level: "success", text: "Conversation compacted · 58.3k → 2.6k tokens" }, + { + type: "notice", + level: "success", + text: "Conversation compressed · 58.3k → 2.6k tokens", + compacted: { auto: false, preTokens: 58279, postTokens: 2556 }, + }, ]); }); + + test("the context the session fills: latest top-level message, the main model's window", () => { + const acc = new StreamAccumulator(); + const usage = { input_tokens: 2, cache_read_input_tokens: 133_132, cache_creation_input_tokens: 445, output_tokens: 884 }; + acc.push({ type: "assistant", message: { id: "m1", model: "claude-opus-5-5", content: [{ type: "text", text: "hi" }], usage } }); + acc.push({ type: "assistant", parent_tool_use_id: "t1", message: { id: "m2", model: "claude-haiku-5-5", content: [{ type: "text", text: "sub" }], usage: { input_tokens: 9, output_tokens: 1 } } }); + expect(acc.contextTokens).toBe(134_463); + acc.push({ + type: "result", + subtype: "success", + is_error: false, + result: "hi", + modelUsage: { "claude-haiku-5-5": { contextWindow: 200_000 }, "claude-opus-5-5": { contextWindow: 1_000_000 } }, + }); + expect(acc.contextWindow).toBe(1_000_000); + acc.push({ type: "system", subtype: "compact_boundary", compact_metadata: { trigger: "auto", pre_tokens: 134_463, post_tokens: 9_000 } }); + expect(acc.contextTokens).toBe(9_000); + expect(acc.blocks.at(-1)).toMatchObject({ compacted: { auto: true } }); + acc.push({ type: "conversation_reset", new_conversation_id: "y", trigger: "clear" }); + expect(acc.contextTokens).toBeNull(); + }); }); describe("slash commands in chat runs", () => { @@ -91,6 +118,17 @@ describe("slash commands in chat runs", () => { expect(answer.content).toBe("Ran /goal the report is sent"); }); + test("the chat knows how full its session is; /compress compacts it", async () => { + const big = await send(conversationId, "BIG_CONTEXT"); + expect(big.conv.context).toEqual({ tokens: 421_602, window: 1_000_000 }); + expect(big.inv.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE).toBe("50"); + const { inv, conv } = await send(conversationId, "/compress keep the open tasks"); + expect(inv.prompt).toBe("/compact keep the open tasks"); + expect(conv.messages.at(-2)!.content).toBe("/compress keep the open tasks"); + expect(conv.context).toEqual({ tokens: 100, window: 1_000_000 }); + expect(conv.messages.at(-1)!.blocks).toContainEqual(expect.objectContaining({ type: "notice", compacted: { auto: false, preTokens: 900, postTokens: 100 } })); + }); + test("plain messages keep the context prefix", async () => { const { inv } = await send(conversationId, "/Users/me/file.txt what is in it?"); expect(inv.prompt.startsWith("")).toBe(true); diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 5da9a899..013f9a38 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -108,6 +108,16 @@ export function effortForModel(efforts: readonly Effort[], effort: Effort | null return below.length ? below[below.length - 1]! : efforts[0]!; } +/** A chat is summarized by itself once its session fills this much of the context window (percent). */ +export const DEFAULT_AUTO_COMPACT_PERCENT = 50; +/** Below this a summary saves next to nothing: Compress is not offered. */ +export const COMPRESS_MIN_TOKENS = 20_000; + +/** `/compress` is Godmode's name for Claude Code's `/compact`; the rest of the text (what to keep) goes along. */ +export function compactAlias(text: string): string { + return text.replace(/^(\s*)\/compress(?=\s|$)/, "$1/compact"); +} + /** `/goal ship it` → { name: "goal", args: "ship it" }; null for plain text and paths like `/Users/me`. */ export function parseSlashCommand(text: string): { name: string; args: string } | null { const m = /^\/([\w][\w:.-]*)(?:\s+([\s\S]*))?$/.exec(text.trim()); diff --git a/packages/shared/src/models.ts b/packages/shared/src/models.ts index 8dfcbaa4..96407338 100644 --- a/packages/shared/src/models.ts +++ b/packages/shared/src/models.ts @@ -423,6 +423,15 @@ export interface Conversation { paused?: RunPause | null; /** A chat another agent handed over: who asked, from which chat (null when that chat was deleted) and which run. */ delegatedFrom?: { agentId: ID; conversationId: ID | null; runId: ID } | null; + /** How full the chat's Claude session is, as of its last turn; null = not known yet (no turn, or a fresh session). */ + context?: ConversationContext | null; +} + +export interface ConversationContext { + /** Tokens the session sends with every turn now. */ + tokens: number; + /** The model's context window; null = not reported. */ + window: number | null; } export type ConversationFollowup = Pick; @@ -591,7 +600,7 @@ export type MessageBlock = } | { type: "error"; text: string } /** `mod`: the note comes from a Claude Code mod (its plugin name), not from Godmode. */ - | { type: "notice"; level: "info" | "warning" | "success"; text: string; mod?: string } + | { type: "notice"; level: "info" | "warning" | "success"; text: string; mod?: string; compacted?: CompactInfo } /** Output of a Claude Code slash command that ran locally (e.g. /context, /usage, /model). */ | { type: "command"; name: string; args: string; output: string } /** Marks where the agent continued the chat on its own (the system message of a follow-up run). */ @@ -1307,6 +1316,13 @@ export interface GeneralSettings { language: string; } +/** Where Claude Code summarized the conversation (`/compact`, `/compress` or by itself once the chat got long). */ +export interface CompactInfo { + auto: boolean; + preTokens: number | null; + postTokens: number | null; +} + export interface RunnerSettings { /** Path to claude CLI; empty = auto-detect */ claudePath: string; @@ -1333,6 +1349,11 @@ export interface RunnerSettings { stallMinutes: number; /** The same tool with the same input and the same result this many times in a row = going in circles. */ loopRepeats: number; + /** + * A chat's Claude session is summarized by itself once it fills this share of the model's context window (percent). + * 0 = never by itself (only /compress), null = Claude Code's own threshold (close to full). + */ + autoCompactPercent: number | null; /** * Shows what runs cost in USD and enables budgets. Off (Claude subscription, where Claude Code's costs are API prices * nobody pays): nothing about cost is shown and no budget applies. Costs are recorded either way.