1import {
2 resolveIdleCompaction,
3 type ModelSelection,
4 type OrchestrationV2ProviderTurn,
5 type OrchestrationV2ProviderTurnTokenUsage,
6 type OrchestrationV2ProviderThread,
7 type ServerProvider,
8 type ServerSettings,
9 type ThreadTokenUsageSnapshot,
10} from "@t3tools/contracts";
11import {
12 CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
13 COMPACTION_SUGGESTION_MIN_TOKENS,
14 isClaudeResumeCompactionQuestion,
15} from "@t3tools/shared/claudeCompaction";
16import * as DateTime from "effect/DateTime";
17
18import { resolvePendingHandoff } from "../handoff.ts";
19import type { ThreadStructure, ThreadStructureTurnItem } from "./models.ts";
20
21// Fallback TTL: Claude's 1h cache plus slack.
22const CLAUDE_RESUME_COMPACTION_FALLBACK_MS = 70 * 60_000;
23
24// A typed `/compact`, with or without instructions, is already the compaction. Queueing a
25// bare one ahead of it would run an uninstructed compaction first and charge for two.
26export const COMPACT_COMMAND_PATTERN = /^\/compact(?:\s|$)/i;
27
28function asFiniteNumber(value: unknown): number | null {
29 return typeof value === "number" && Number.isFinite(value) ? value : null;
30}
31
32type NullableContextWindowUsage = {
33 readonly [Key in keyof ThreadTokenUsageSnapshot]: undefined extends ThreadTokenUsageSnapshot[Key]
34 ? Exclude<ThreadTokenUsageSnapshot[Key], undefined> | null
35 : ThreadTokenUsageSnapshot[Key];
36};
37
38export type ContextWindowSnapshot = NullableContextWindowUsage & {
39 readonly remainingTokens: number | null;
40 readonly usedPercentage: number | null;
41 readonly remainingPercentage: number | null;
42 readonly updatedAt: string;
43};
44
45/** Newest report by timestamp; snapshots order turns by provider thread. */
46export function latestProviderTurnTokenUsage(
47 turns: ReadonlyArray<Pick<OrchestrationV2ProviderTurn, "tokenUsage">>,
48 projection?: Pick<ThreadStructure, "thread" | "contextTransfers"> | null,
49 providerThread?: Pick<OrchestrationV2ProviderThread, "contextUsage"> | null,
50): OrchestrationV2ProviderTurnTokenUsage | null {
51 let latest: OrchestrationV2ProviderTurnTokenUsage | null = null;
52 for (const { tokenUsage } of turns) {
53 if (tokenUsage === undefined) continue;
54 // Ties go to the later entry, since live upserts append.
55 if (latest === null || Date.parse(tokenUsage.updatedAt) >= Date.parse(latest.updatedAt)) {
56 latest = tokenUsage;
57 }
58 }
59 if (latest !== null || projection == null || providerThread?.contextUsage != null) return latest;
60 const forkedFrom = projection.thread.forkedFrom;
61 if (forkedFrom?.type !== "run") return null;
62 return (
63 projection.contextTransfers.find(
64 (transfer) =>
65 transfer.type === "fork" &&
66 transfer.targetThreadId === projection.thread.id &&
67 transfer.sourceThreadId === forkedFrom.threadId &&
68 transfer.sourcePoint.runId === forkedFrom.runId &&
69 transfer.sourceProviderInstanceId === projection.thread.modelSelection.instanceId &&
70 (transfer.status === "pending" || transfer.resolution?.strategy === "native_fork"),
71 )?.sourceTokenUsage ?? null
72 );
73}
74
75export function deriveLatestContextWindowSnapshot(
76 entries: ReadonlyArray<{
77 readonly item: ThreadStructureTurnItem;
78 }>,
79 liveUsage?: OrchestrationV2ProviderTurnTokenUsage | null,
80 providerThread?: Pick<OrchestrationV2ProviderThread, "contextUsage" | "updatedAt"> | null,
81): ContextWindowSnapshot | null {
82 if (liveUsage != null) {
83 const usedTokens = Math.max(0, liveUsage.usedTokens);
84 const maxTokens = liveUsage.maxTokens ?? null;
85 const usedPercentage =
86 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
87 const remainingTokens =
88 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
89 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
90 return {
91 usedTokens,
92 totalProcessedTokens: null,
93 maxTokens,
94 remainingTokens,
95 usedPercentage,
96 remainingPercentage,
97 inputTokens: liveUsage.inputTokens ?? null,
98 cachedInputTokens: liveUsage.cachedInputTokens ?? null,
99 outputTokens: liveUsage.outputTokens ?? null,
100 reasoningOutputTokens: liveUsage.reasoningOutputTokens ?? null,
101 lastUsedTokens: null,
102 lastInputTokens: null,
103 lastCachedInputTokens: null,
104 lastOutputTokens: null,
105 lastReasoningOutputTokens: null,
106 toolUses: null,
107 durationMs: null,
108 compactsAutomatically: true,
109 autoCompactThreshold: liveUsage.autoCompactThreshold ?? null,
110 cost: null,
111 updatedAt: liveUsage.updatedAt,
112 };
113 }
114 const providerUsage = providerThread?.contextUsage;
115 const providerUsageUpdatedAt = providerThread?.updatedAt;
116 if (
117 providerUsage !== null &&
118 providerUsage !== undefined &&
119 providerUsageUpdatedAt !== undefined
120 ) {
121 const maxTokens = asFiniteNumber(providerUsage.maxTokens);
122 const usedTokens = providerUsage.usedTokens;
123 const usedPercentage =
124 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
125 return {
126 usedTokens,
127 totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens),
128 maxTokens,
129 remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)),
130 usedPercentage,
131 remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage),
132 inputTokens: asFiniteNumber(providerUsage.inputTokens),
133 cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens),
134 outputTokens: asFiniteNumber(providerUsage.outputTokens),
135 reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens),
136 lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens),
137 lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens),
138 lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens),
139 lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens),
140 lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens),
141 toolUses: asFiniteNumber(providerUsage.toolUses),
142 durationMs: asFiniteNumber(providerUsage.durationMs),
143 compactsAutomatically: providerUsage.compactsAutomatically ?? null,
144 cost: providerUsage.cost ?? null,
145 autoCompactThreshold: providerUsage.autoCompactThreshold ?? null,
146 updatedAt: DateTime.formatIso(providerUsageUpdatedAt),
147 };
148 }
149 for (let index = entries.length - 1; index >= 0; index -= 1) {
150 const entry = entries[index];
151 if (!entry || entry.item.type !== "compaction") {
152 continue;
153 }
154 const payload = entry.item;
155 const usedTokens = asFiniteNumber(payload.afterTokenCount);
156 if (usedTokens === null || usedTokens < 0) {
157 continue;
158 }
159
160 const maxTokens = null;
161 const usedPercentage =
162 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
163 const remainingTokens =
164 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
165 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
166
167 return {
168 usedTokens,
169 totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount),
170 maxTokens,
171 remainingTokens,
172 usedPercentage,
173 remainingPercentage,
174 inputTokens: null,
175 cachedInputTokens: null,
176 outputTokens: null,
177 reasoningOutputTokens: null,
178 lastUsedTokens: null,
179 lastInputTokens: null,
180 lastCachedInputTokens: null,
181 lastOutputTokens: null,
182 lastReasoningOutputTokens: null,
183 toolUses: null,
184 durationMs: null,
185 compactsAutomatically: true,
186 autoCompactThreshold: null,
187 cost: null,
188 updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt),
189 };
190 }
191
192 return null;
193}
194
195export function hasDismissedResumeCompaction(
196 activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
197): boolean {
198 return activities.some((activity) => {
199 if (activity.kind !== "user-input.resolved") return false;
200 const payload = activity.payload;
201 if (!payload || typeof payload !== "object") return false;
202 const answers = (payload as { readonly answers?: unknown }).answers;
203 if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;
204
205 return Object.entries(answers).some(
206 ([question, answer]) =>
207 isClaudeResumeCompactionQuestion(question) &&
208 answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
209 );
210 });
211}
212
213export function shouldOfferResumeCompaction(input: {
214 readonly provider: string | null | undefined;
215 readonly usedTokens: number | null | undefined;
216 readonly updatedAt: string | null | undefined;
217 readonly promptCacheTtlMs?: number | undefined;
218 readonly now: string;
219}): boolean {
220 if (
221 input.provider !== "claudeAgent" ||
222 (input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS
223 ) {
224 return false;
225 }
226
227 const updatedAt = Date.parse(input.updatedAt ?? "");
228 const now = Date.parse(input.now);
229 return (
230 Number.isFinite(updatedAt) &&
231 Number.isFinite(now) &&
232 now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
233 );
234}
235
236/**
237 * The tokens a plain send into an idle thread compacts first, as web's composer does under
238 * "When you return", or null when it sends as is. Web reads the same state for its "Compact and
239 * send" button. A send that hands off to another conversation starts that one fresh, so only the
240 * resumed one counts.
241 */
242export function compactBeforeSendTokens(input: {
243 readonly projection: Pick<
244 ThreadStructure,
245 | "thread"
246 | "contextTransfers"
247 | "providerThreads"
248 | "providerTurns"
249 | "runtimeRequests"
250 | "runs"
251 | "visibleTurnItems"
252 >;
253 readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>;
254 readonly selection: ModelSelection;
255 readonly providers: ReadonlyArray<ServerProvider>;
256 readonly settings: Pick<
257 ServerSettings,
258 "providerInstances" | "compactIdleThreads" | "idleCompaction"
259 >;
260 readonly text: string;
261 readonly threadIdle: boolean;
262 readonly now: string;
263}): number | null {
264 const { projection } = input;
265 const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId);
266 if (
267 provider === undefined ||
268 !input.threadIdle ||
269 COMPACT_COMMAND_PATTERN.test(input.text) ||
270 resolveIdleCompaction(input.settings, provider.instanceId) === "off" ||
271 !provider.slashCommands.some((command) => command.name === "compact") ||
272 resolvePendingHandoff(projection, input.selection, input.providers) !== null ||
273 projection.runtimeRequests.some((request) => request.status === "pending") ||
274 projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) ||
275 (!input.visibleTurnItems.some(
276 ({ item }) =>
277 item.type === "user_message" &&
278 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
279 ) &&
280 !projection.runs.some(
281 (run) =>
282 run.status !== "rolled_back" &&
283 run.startedAt !== null &&
284 !input.visibleTurnItems.some(
285 ({ item }) => item.type === "user_message" && item.runId === run.id,
286 ),
287 )) ||
288 hasDismissedResumeCompaction(
289 projection.runtimeRequests
290 .filter((request) => request.kind === "user_input" && request.status === "resolved")
291 .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })),
292 )
293 ) {
294 return null;
295 }
296 const activeProviderThreadId = projection.thread.activeProviderThreadId;
297 const providerThread = projection.providerThreads.find(
298 (thread) => thread.id === activeProviderThreadId,
299 );
300 const liveUsage = latestProviderTurnTokenUsage(
301 projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId),
302 input.selection.instanceId === projection.thread.modelSelection.instanceId ? projection : null,
303 providerThread,
304 );
305 const contextWindow = deriveLatestContextWindowSnapshot(
306 input.visibleTurnItems,
307 liveUsage,
308 providerThread,
309 );
310 return contextWindow !== null &&
311 shouldOfferResumeCompaction({
312 provider: provider.driver,
313 usedTokens: contextWindow.usedTokens,
314 updatedAt: contextWindow.updatedAt,
315 promptCacheTtlMs: liveUsage?.promptCacheTtlMs,
316 now: input.now,
317 })
318 ? contextWindow.usedTokens
319 : null;
320}