Skip to content
Source
Sign inSign up

contextWindow.ts

323 lines12.4 KB

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;
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 );
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 breakdown: liveUsage.breakdown ?? null,
111 cost: null,
112 updatedAt: liveUsage.updatedAt,
113 };
114 }
115 const providerUsage = providerThread?.contextUsage;
116 const providerUsageUpdatedAt = providerThread?.updatedAt;
117 if (
118 providerUsage !== null &&
119 providerUsage !== undefined &&
120 providerUsageUpdatedAt !== undefined
121 ) {
122 const maxTokens = asFiniteNumber(providerUsage.maxTokens);
123 const usedTokens = providerUsage.usedTokens;
124 const usedPercentage =
125 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
126 return {
127 usedTokens,
128 totalProcessedTokens: asFiniteNumber(providerUsage.totalProcessedTokens),
129 maxTokens,
130 remainingTokens: maxTokens === null ? null : Math.max(0, Math.round(maxTokens - usedTokens)),
131 usedPercentage,
132 remainingPercentage: usedPercentage === null ? null : Math.max(0, 100 - usedPercentage),
133 inputTokens: asFiniteNumber(providerUsage.inputTokens),
134 cachedInputTokens: asFiniteNumber(providerUsage.cachedInputTokens),
135 outputTokens: asFiniteNumber(providerUsage.outputTokens),
136 reasoningOutputTokens: asFiniteNumber(providerUsage.reasoningOutputTokens),
137 lastUsedTokens: asFiniteNumber(providerUsage.lastUsedTokens),
138 lastInputTokens: asFiniteNumber(providerUsage.lastInputTokens),
139 lastCachedInputTokens: asFiniteNumber(providerUsage.lastCachedInputTokens),
140 lastOutputTokens: asFiniteNumber(providerUsage.lastOutputTokens),
141 lastReasoningOutputTokens: asFiniteNumber(providerUsage.lastReasoningOutputTokens),
142 toolUses: asFiniteNumber(providerUsage.toolUses),
143 durationMs: asFiniteNumber(providerUsage.durationMs),
144 compactsAutomatically: providerUsage.compactsAutomatically ?? null,
145 cost: providerUsage.cost ?? null,
146 autoCompactThreshold: providerUsage.autoCompactThreshold ?? null,
147 breakdown: providerUsage.breakdown ?? null,
148 updatedAt: DateTime.formatIso(providerUsageUpdatedAt),
149 };
150 }
151 for (let index = entries.length - 1; index >= 0; index -= 1) {
152 const entry = entries[index];
153 if (!entry || entry.item.type !== "compaction") {
154 continue;
155 }
156 const payload = entry.item;
157 const usedTokens = asFiniteNumber(payload.afterTokenCount);
158 if (usedTokens === null || usedTokens < 0) {
159 continue;
160 }
161​
162 const maxTokens = null;
163 const usedPercentage =
164 maxTokens !== null && maxTokens > 0 ? Math.min(100, (usedTokens / maxTokens) * 100) : null;
165 const remainingTokens =
166 maxTokens !== null ? Math.max(0, Math.round(maxTokens - usedTokens)) : null;
167 const remainingPercentage = usedPercentage !== null ? Math.max(0, 100 - usedPercentage) : null;
168​
169 return {
170 usedTokens,
171 totalProcessedTokens: asFiniteNumber(payload.beforeTokenCount),
172 maxTokens,
173 remainingTokens,
174 usedPercentage,
175 remainingPercentage,
176 inputTokens: null,
177 cachedInputTokens: null,
178 outputTokens: null,
179 reasoningOutputTokens: null,
180 lastUsedTokens: null,
181 lastInputTokens: null,
182 lastCachedInputTokens: null,
183 lastOutputTokens: null,
184 lastReasoningOutputTokens: null,
185 toolUses: null,
186 durationMs: null,
187 compactsAutomatically: true,
188 autoCompactThreshold: null,
189 breakdown: null,
190 cost: null,
191 updatedAt: DateTime.formatIso(payload.startedAt ?? payload.updatedAt),
192 };
193 }
194​
195 return null;
197​
198export function hasDismissedResumeCompaction(
199 activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }>,
200): boolean {
201 return activities.some((activity) => {
202 if (activity.kind !== "user-input.resolved") return false;
203 const payload = activity.payload;
204 if (!payload || typeof payload !== "object") return false;
205 const answers = (payload as { readonly answers?: unknown }).answers;
206 if (!answers || typeof answers !== "object" || Array.isArray(answers)) return false;
207​
208 return Object.entries(answers).some(
209 ([question, answer]) =>
210 isClaudeResumeCompactionQuestion(question) &&
211 answer === CLAUDE_RESUME_COMPACTION_NEVER_ANSWER,
212 );
213 });
215​
216export function shouldOfferResumeCompaction(input: {
217 readonly provider: string | null | undefined;
218 readonly usedTokens: number | null | undefined;
219 readonly updatedAt: string | null | undefined;
220 readonly promptCacheTtlMs?: number | undefined;
221 readonly now: string;
222}): boolean {
223 if (
224 input.provider !== "claudeAgent" ||
225 (input.usedTokens ?? 0) < COMPACTION_SUGGESTION_MIN_TOKENS
226 ) {
227 return false;
228 }
229​
230 const updatedAt = Date.parse(input.updatedAt ?? "");
231 const now = Date.parse(input.now);
232 return (
233 Number.isFinite(updatedAt) &&
234 Number.isFinite(now) &&
235 now - updatedAt >= (input.promptCacheTtlMs ?? CLAUDE_RESUME_COMPACTION_FALLBACK_MS)
236 );
238​
239/**
240 * The tokens a plain send into an idle thread compacts first, as web's composer does under
241 * "When you return", or null when it sends as is. Web reads the same state for its "Compact and
242 * send" button. A send that hands off to another conversation starts that one fresh, so only the
243 * resumed one counts.
244 */
245export function compactBeforeSendTokens(input: {
246 readonly projection: Pick<
247 ThreadStructure,
248 | "thread"
249 | "contextTransfers"
250 | "providerThreads"
251 | "providerTurns"
252 | "runtimeRequests"
253 | "runs"
254 | "visibleTurnItems"
255 >;
256 readonly visibleTurnItems: ReadonlyArray<{ readonly item: ThreadStructureTurnItem }>;
257 readonly selection: ModelSelection;
258 readonly providers: ReadonlyArray<ServerProvider>;
259 readonly settings: Pick<
260 ServerSettings,
261 "providerInstances" | "compactIdleThreads" | "idleCompaction"
262 >;
263 readonly text: string;
264 readonly threadIdle: boolean;
265 readonly now: string;
266}): number | null {
267 const { projection } = input;
268 const provider = input.providers.find((entry) => entry.instanceId === input.selection.instanceId);
269 if (
270 provider === undefined ||
271 !input.threadIdle ||
272 COMPACT_COMMAND_PATTERN.test(input.text) ||
273 resolveIdleCompaction(input.settings, provider.instanceId) === "off" ||
274 !provider.slashCommands.some((command) => command.name === "compact") ||
275 resolvePendingHandoff(projection, input.selection, input.providers) !== null ||
276 projection.runtimeRequests.some((request) => request.status === "pending") ||
277 projection.runs.some((run) => run.status === "queued" && run.queueHeld === true) ||
278 (!input.visibleTurnItems.some(
279 ({ item }) =>
280 item.type === "user_message" &&
281 (item.text.trim().toLowerCase() !== "/compact" || item.attachments.length > 0),
282 ) &&
283 !projection.runs.some(
284 (run) =>
285 run.status !== "rolled_back" &&
286 run.startedAt !== null &&
287 !input.visibleTurnItems.some(
288 ({ item }) => item.type === "user_message" && item.runId === run.id,
289 ),
290 )) ||
291 hasDismissedResumeCompaction(
292 projection.runtimeRequests
293 .filter((request) => request.kind === "user_input" && request.status === "resolved")
294 .map((request) => ({ kind: "user-input.resolved", payload: { answers: request.answers } })),
295 )
296 ) {
297 return null;
298 }
299 const activeProviderThreadId = projection.thread.activeProviderThreadId;
300 const providerThread = projection.providerThreads.find(
301 (thread) => thread.id === activeProviderThreadId,
302 );
303 const liveUsage = latestProviderTurnTokenUsage(
304 projection.providerTurns.filter((turn) => turn.providerThreadId === activeProviderThreadId),
305 input.selection.instanceId === projection.thread.modelSelection.instanceId ? projection : null,
306 providerThread,
307 );
308 const contextWindow = deriveLatestContextWindowSnapshot(
309 input.visibleTurnItems,
310 liveUsage,
311 providerThread,
312 );
313 return contextWindow !== null &&
314 shouldOfferResumeCompaction({
315 provider: provider.driver,
316 usedTokens: contextWindow.usedTokens,
317 updatedAt: contextWindow.updatedAt,
318 promptCacheTtlMs: liveUsage?.promptCacheTtlMs,
319 now: input.now,
320 })
321 ? contextWindow.usedTokens
322 : null;