1import { isListedThreadLineage, type OrchestrationV2ThreadProjection } from "@t3tools/contracts";
2import { COMPACTION_SUGGESTION_MIN_TOKENS } from "@t3tools/shared/claudeCompaction";
3import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
4import * as DateTime from "effect/DateTime";
5
6// Claude Code's idleCompaction defaults to 200K and won't go below 100K. Below
7// that a cold re-read is cheap, so compacting mostly loses context for little
8// saving.
9const MIN_CONTEXT_TOKENS = COMPACTION_SUGGESTION_MIN_TOKENS;
10// Start this long before the cache expires: enough for the session to resume
11// and send the compaction while the cache is still warm.
12const LEAD_MS = 2 * 60_000;
13
14export type IdleCompactionRecords = Pick<
15 OrchestrationV2ThreadProjection,
16 | "thread"
17 | "runs"
18 | "messages"
19 | "providerThreads"
20 | "providerTurns"
21 | "runtimeRequests"
22 | "turnItems"
23 | "contextTransfers"
24>;
25
26/**
27 * When an idle Claude thread should compact, or null when it should not. The
28 * window ends when the prompt cache of Claude's last request expires (1h on
29 * subscriptions, 5m on API keys); compacting after that pays a full uncached
30 * read.
31 */
32export function idleCompactionWindow(
33 {
34 thread,
35 runs,
36 messages,
37 providerThreads,
38 providerTurns,
39 runtimeRequests,
40 turnItems,
41 contextTransfers,
42 }: IdleCompactionRecords,
43 nowMs: number,
44): {
45 readonly runId: (typeof runs)[number]["id"];
46 readonly startMs: number;
47 readonly endMs: number;
48} | null {
49 if (thread.deletedAt !== null || thread.archivedAt !== null) return null;
50 if (thread.settledAt !== null || thread.settledOverride === "settled") return null;
51 if (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs)
52 return null;
53 if (!isListedThreadLineage(thread.lineage)) return null;
54 // A removed or steered queued message leaves a cancelled run that never started.
55 const latestRun = runs
56 .filter((run) => run.status !== "cancelled" || run.startedAt !== null)
57 .reduce<(typeof runs)[number] | null>(
58 (latest, run) => (latest === null || run.ordinal > latest.ordinal ? run : latest),
59 null,
60 );
61 if (latestRun?.status !== "completed" || latestRun.completedAt === null) return null;
62 const message = messages.find((candidate) => candidate.id === latestRun.userMessageId);
63 // Never compact a compaction, so one idle spell compacts at most once.
64 if (message === undefined || message.text.trim().toLowerCase() === "/compact") return null;
65 if (
66 runtimeRequests.some((request) => request.status === "pending") ||
67 // A pending merge-back belongs to the user's next message.
68 contextTransfers.some(
69 (transfer) =>
70 transfer.type === "merge_back" &&
71 transfer.targetThreadId === thread.id &&
72 transfer.status === "pending",
73 ) ||
74 // Background work reports into the next turn; compaction would swallow it.
75 derivePendingBackgroundWork({
76 latestRun,
77 runs,
78 providerThreads,
79 turnItems,
80 activeProviderThreadId: thread.activeProviderThreadId,
81 }).length > 0
82 )
83 return null;
84 const providerThread = providerThreads.find(
85 (candidate) => candidate.id === thread.activeProviderThreadId,
86 );
87 // The thread's current selection receives the /compact, so it must still be this Claude session.
88 if (
89 providerThread?.driver !== "claudeAgent" ||
90 providerThread.id !== latestRun.providerThreadId ||
91 providerThread.providerInstanceId !== thread.providerInstanceId ||
92 latestRun.providerInstanceId !== thread.providerInstanceId
93 )
94 return null;
95 const usages = providerTurns
96 .filter((turn) => turn.providerThreadId === providerThread.id && turn.tokenUsage !== undefined)
97 .map((turn) => turn.tokenUsage!)
98 .toSorted((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
99 if ((usages[0]?.usedTokens ?? 0) < MIN_CONTEXT_TOKENS) return null;
100 // A request served fully from cache creates no cache entry and reports no
101 // TTL, but it still refreshes the cache. Treat it as unknown and skip.
102 const expiresAt = usages[0]?.promptCacheExpiresAt;
103 const endMs = expiresAt === undefined ? Number.NaN : Date.parse(expiresAt);
104 if (!Number.isFinite(endMs) || nowMs >= endMs) return null;
105 return { runId: latestRun.id, startMs: endMs - LEAD_MS, endMs };
106}