@tekmidian/pai 0.35.2 → 0.36.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{auto-route-DVM3U2ZY.mjs → auto-route-Byf8ENXj.mjs} +4 -4
- package/dist/{auto-route-DVM3U2ZY.mjs.map → auto-route-Byf8ENXj.mjs.map} +1 -1
- package/dist/{checkpoint-block-D3rm4dAJ.mjs → checkpoint-block-DKYxCkBL.mjs} +7 -6
- package/dist/checkpoint-block-DKYxCkBL.mjs.map +1 -0
- package/dist/cli/index.mjs +25 -17
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/probe2.mjs +2 -0
- package/dist/cli/program.d.mts.map +1 -1
- package/dist/cli/program.mjs +16 -267
- package/dist/{clusters-CzGxefB7.mjs → clusters-wZgTCYCB.mjs} +2 -2
- package/dist/{clusters-CzGxefB7.mjs.map → clusters-wZgTCYCB.mjs.map} +1 -1
- package/dist/{config-BSkVcvfq.mjs → config-B64vFg14.mjs} +3 -12
- package/dist/{config-BSkVcvfq.mjs.map → config-B64vFg14.mjs.map} +1 -1
- package/dist/config-C_ErGddD.mjs +3 -0
- package/dist/daemon/index.mjs +19 -19
- package/dist/{daemon-B8L2f5vy.mjs → daemon--N2JFnUs.mjs} +33 -38
- package/dist/daemon--N2JFnUs.mjs.map +1 -0
- package/dist/daemon-DJEFqV84.mjs +20 -0
- package/dist/daemon-mcp/index.mjs +2 -2
- package/dist/{db-BtuN768f.mjs → db-Ca5qfsMC.mjs} +2 -4
- package/dist/{db-BtuN768f.mjs.map → db-Ca5qfsMC.mjs.map} +1 -1
- package/dist/db-O-cyAPfS.mjs +3 -0
- package/dist/db-XEwJbuGO.mjs +3 -0
- package/dist/{db-CYmBWcjh.mjs → db-a1ixZQjr.mjs} +2 -4
- package/dist/{db-CYmBWcjh.mjs.map → db-a1ixZQjr.mjs.map} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs → detect-CdaA48EI.mjs} +1 -1
- package/dist/{detect-Bf2z-oKB.mjs.map → detect-CdaA48EI.mjs.map} +1 -1
- package/dist/{detector-BU-bsDXs.mjs → detector--Gg5JRN5.mjs} +3 -5
- package/dist/{detector-BU-bsDXs.mjs.map → detector--Gg5JRN5.mjs.map} +1 -1
- package/dist/detector-DGAk1iBR.mjs +5 -0
- package/dist/embeddings-CEBGrzwu.mjs +3 -0
- package/dist/{embeddings-Bn86ssxR.mjs → embeddings-DOLZnT1X.mjs} +2 -12
- package/dist/{embeddings-Bn86ssxR.mjs.map → embeddings-DOLZnT1X.mjs.map} +1 -1
- package/dist/{factory-vPTPoBR7.mjs → factory-Bsp7xOpO.mjs} +9 -12
- package/dist/{factory-vPTPoBR7.mjs.map → factory-Bsp7xOpO.mjs.map} +1 -1
- package/dist/factory-CrokPMk2.mjs +3 -0
- package/dist/{helpers-crDEr6S2.mjs → helpers-IjZkXBhj.mjs} +1 -1
- package/dist/{helpers-crDEr6S2.mjs.map → helpers-IjZkXBhj.mjs.map} +1 -1
- package/dist/hooks/context-compression-hook.mjs +209 -70
- package/dist/hooks/context-compression-hook.mjs.map +4 -4
- package/dist/index.mjs +10 -10
- package/dist/{indexer-backend-Bg7VDpGt.mjs → indexer-backend-nQZuEx6N.mjs} +3 -3
- package/dist/{indexer-backend-Bg7VDpGt.mjs.map → indexer-backend-nQZuEx6N.mjs.map} +1 -1
- package/dist/{ipc-client-aVKVERjJ.mjs → ipc-client-BmypMNYk.mjs} +13 -7
- package/dist/ipc-client-BmypMNYk.mjs.map +1 -0
- package/dist/{kg-entity-r8duqhi9.mjs → kg-entity-DbOMPdF9.mjs} +1 -1
- package/dist/{kg-entity-r8duqhi9.mjs.map → kg-entity-DbOMPdF9.mjs.map} +1 -1
- package/dist/{latent-ideas-BL9m2HF9.mjs → latent-ideas-Bn6A5-5P.mjs} +4 -4
- package/dist/{latent-ideas-BL9m2HF9.mjs.map → latent-ideas-Bn6A5-5P.mjs.map} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs → link-boost-fYjUnxCN.mjs} +1 -1
- package/dist/{link-boost-QFLrJwD6.mjs.map → link-boost-fYjUnxCN.mjs.map} +1 -1
- package/dist/{main-resolver-CNSqU8wo.mjs → main-resolver-BAbhKpeX.mjs} +12 -14
- package/dist/main-resolver-BAbhKpeX.mjs.map +1 -0
- package/dist/main-resolver-Dxh444GO.mjs +4 -0
- package/dist/{migrate-fLD6rAdO.mjs → migrate-Cjzeefn9.mjs} +2 -2
- package/dist/{migrate-fLD6rAdO.mjs.map → migrate-Cjzeefn9.mjs.map} +1 -1
- package/dist/{neighborhood-BX89_nty.mjs → neighborhood-DpaEM991.mjs} +2 -2
- package/dist/{neighborhood-BX89_nty.mjs.map → neighborhood-DpaEM991.mjs.map} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs → note-context-DrcY4cWm.mjs} +1 -1
- package/dist/{note-context-d1wT_-GA.mjs.map → note-context-DrcY4cWm.mjs.map} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs → pai-marker-CHtbJMwJ.mjs} +1 -1
- package/dist/{pai-marker-B20KqhA8.mjs.map → pai-marker-CHtbJMwJ.mjs.map} +1 -1
- package/dist/{postgres-CI3FjGq0.mjs → postgres-BVme6qX0.mjs} +2 -2
- package/dist/{postgres-CI3FjGq0.mjs.map → postgres-BVme6qX0.mjs.map} +1 -1
- package/dist/{pick-DatgS3Nk.mjs → program-BnMNFb4O.mjs} +1171 -223
- package/dist/program-BnMNFb4O.mjs.map +1 -0
- package/dist/query-feedback-BBMBp96K.mjs +3 -0
- package/dist/{query-feedback-D4U56Hz6.mjs → query-feedback-C1T6kS18.mjs} +2 -4
- package/dist/{query-feedback-D4U56Hz6.mjs.map → query-feedback-C1T6kS18.mjs.map} +1 -1
- package/dist/reranker-CwTCNsgA.mjs +3 -0
- package/dist/{reranker-CMNZcfVx.mjs → reranker-xPm04PXx.mjs} +2 -8
- package/dist/{reranker-CMNZcfVx.mjs.map → reranker-xPm04PXx.mjs.map} +1 -1
- package/dist/router-BMkOb62X.mjs +3 -0
- package/dist/{router-i9S19Usg.mjs → router-CsDm7HvK.mjs} +2 -4
- package/dist/{router-i9S19Usg.mjs.map → router-CsDm7HvK.mjs.map} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs → runtime-paths-rni52zHX.mjs} +1 -1
- package/dist/{runtime-paths-B0P1TvUr.mjs.map → runtime-paths-rni52zHX.mjs.map} +1 -1
- package/dist/search-CfPpJAWQ.mjs +4 -0
- package/dist/{search-C32zQ0V0.mjs → search-Rpk1cSBC.mjs} +4 -15
- package/dist/{search-C32zQ0V0.mjs.map → search-Rpk1cSBC.mjs.map} +1 -1
- package/dist/{sources-BDwN0B8i.mjs → sources-D8ZdNfvK.mjs} +2 -2
- package/dist/{sources-BDwN0B8i.mjs.map → sources-D8ZdNfvK.mjs.map} +1 -1
- package/dist/{sqlite-C6FHnMkn.mjs → sqlite-D1IaR8Am.mjs} +3 -3
- package/dist/{sqlite-C6FHnMkn.mjs.map → sqlite-D1IaR8Am.mjs.map} +1 -1
- package/dist/state-WaXhLr6R.mjs +70 -0
- package/dist/{state-DTvy-jRB.mjs.map → state-WaXhLr6R.mjs.map} +1 -1
- package/dist/state-qtmrBWCm.mjs +3 -0
- package/dist/{stop-words-BaMEGVeY.mjs → stop-words-Hfu8u22w.mjs} +1 -1
- package/dist/{stop-words-BaMEGVeY.mjs.map → stop-words-Hfu8u22w.mjs.map} +1 -1
- package/dist/{sync--BoxBBok.mjs → sync-BWbe8JTg.mjs} +3 -3
- package/dist/{sync--BoxBBok.mjs.map → sync-BWbe8JTg.mjs.map} +1 -1
- package/dist/{themes-BObEGMWn.mjs → themes-XPkj_bfP.mjs} +3 -3
- package/dist/{themes-BObEGMWn.mjs.map → themes-XPkj_bfP.mjs.map} +1 -1
- package/dist/tools-DEt6YPfc.mjs +5 -0
- package/dist/{tools-C1lCHerL.mjs → tools-ceiy7ANX.mjs} +28 -65
- package/dist/tools-ceiy7ANX.mjs.map +1 -0
- package/dist/{trace-h23JCcFD.mjs → trace-DfyGmMG_.mjs} +1 -1
- package/dist/{trace-h23JCcFD.mjs.map → trace-DfyGmMG_.mjs.map} +1 -1
- package/dist/{utils-BAxjW3j8.mjs → utils-9Err2RBW.mjs} +2 -22
- package/dist/{utils-BAxjW3j8.mjs.map → utils-9Err2RBW.mjs.map} +1 -1
- package/dist/utils-DhMex3Ox.mjs +3 -0
- package/dist/{vault-indexer-CUF9edbW.mjs → vault-indexer-CFvlPUMB.mjs} +2 -2
- package/dist/{vault-indexer-CUF9edbW.mjs.map → vault-indexer-CFvlPUMB.mjs.map} +1 -1
- package/dist/{work-queue-worker-BcDGAcF3.mjs → work-queue-worker-228XjABm.mjs} +234 -14
- package/dist/work-queue-worker-228XjABm.mjs.map +1 -0
- package/dist/work-queue-worker-LRA9Fj9z.mjs +11 -0
- package/dist/{zettelkasten-W-h8G2is.mjs → zettelkasten-CvjmMghT.mjs} +4 -4
- package/dist/{zettelkasten-W-h8G2is.mjs.map → zettelkasten-CvjmMghT.mjs.map} +1 -1
- package/package.json +1 -1
- package/src/hooks/ts/lib/context-fill.test.ts +515 -0
- package/src/hooks/ts/lib/context-fill.ts +585 -0
- package/src/hooks/ts/lib/context-handover-cache.ts +46 -0
- package/src/hooks/ts/lib/transcript-text.test.ts +125 -0
- package/src/hooks/ts/lib/transcript-text.ts +71 -0
- package/src/hooks/ts/pre-compact/context-compression-hook.ts +112 -30
- package/statusline-command.sh +16 -0
- package/dist/checkpoint-block-D3rm4dAJ.mjs.map +0 -1
- package/dist/daemon-B8L2f5vy.mjs.map +0 -1
- package/dist/ipc-client-aVKVERjJ.mjs.map +0 -1
- package/dist/main-resolver-CNSqU8wo.mjs.map +0 -1
- package/dist/pick-DatgS3Nk.mjs.map +0 -1
- package/dist/rolldown-runtime-95iHPtFO.mjs +0 -18
- package/dist/state-DTvy-jRB.mjs +0 -102
- package/dist/tools-C1lCHerL.mjs.map +0 -1
- package/dist/work-queue-worker-BcDGAcF3.mjs.map +0 -1
- /package/dist/{indexer-AEcT8wHf.mjs → indexer-D7MvSQPY.mjs} +0 -0
|
@@ -0,0 +1,585 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-fill.ts — "how full is this session's context window, right now?"
|
|
3
|
+
*
|
|
4
|
+
* No hook payload carries this number directly (verified: PreCompact,
|
|
5
|
+
* UserPromptSubmit and PostToolUse stdin never include `context_window`).
|
|
6
|
+
* Two sources can reconstruct it, in order of preference:
|
|
7
|
+
*
|
|
8
|
+
* 1. STATUSLINE STATE FILE — statusline-command.sh receives the exact
|
|
9
|
+
* figure from Claude Code (`.context_window.used_percentage` /
|
|
10
|
+
* `.context_window.context_window_size`) on every render and persists
|
|
11
|
+
* it to `${TMPDIR}/pai-context-<session_id>.json`. This is authoritative
|
|
12
|
+
* but depends on the status line having rendered recently — a session
|
|
13
|
+
* whose terminal isn't drawing a status line (headless, backgrounded)
|
|
14
|
+
* leaves this file missing or stale.
|
|
15
|
+
*
|
|
16
|
+
* 2. TRANSCRIPT USAGE — every `message.usage` entry in the session's own
|
|
17
|
+
* .jsonl transcript already reports the token accounting for that one
|
|
18
|
+
* API call: `input_tokens + cache_read_input_tokens +
|
|
19
|
+
* cache_creation_input_tokens` on the MOST RECENT such entry *is* the
|
|
20
|
+
* current context fill, because each request resends the full context.
|
|
21
|
+
* This is why it must be the last entry's fields summed, never a sum
|
|
22
|
+
* across entries — summing across entries is a running token-spend
|
|
23
|
+
* total, not a fill reading (see calculateSessionTokens, a different
|
|
24
|
+
* metric answering a different question).
|
|
25
|
+
*
|
|
26
|
+
* When neither source is available, the answer is UNKNOWN, not zero. A zero
|
|
27
|
+
* reads as "plenty of room" and would silently suppress every downstream
|
|
28
|
+
* decision that depends on this number (the compaction handover chief among
|
|
29
|
+
* them) for the entire session.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
33
|
+
import { homedir, tmpdir } from "node:os";
|
|
34
|
+
import { join } from "node:path";
|
|
35
|
+
|
|
36
|
+
/** Fallback window size when nothing on hand reports one. Matches the
|
|
37
|
+
* default statusline-command.sh already falls back to. */
|
|
38
|
+
export const DEFAULT_CONTEXT_WINDOW = 200_000;
|
|
39
|
+
|
|
40
|
+
/** How many trailing lines of the transcript to scan for a usage entry.
|
|
41
|
+
* A single turn is rarely more than a handful of JSONL lines (assistant
|
|
42
|
+
* text + tool_use/tool_result pairs), so 40 comfortably covers one turn
|
|
43
|
+
* even a busy one, without reading the whole file on every check. */
|
|
44
|
+
const TRANSCRIPT_TAIL_LINES = 40;
|
|
45
|
+
|
|
46
|
+
/** A statusline reading older than this is treated as not there at all —
|
|
47
|
+
* a stale number is worse than none, because it looks confident. */
|
|
48
|
+
const STATUSLINE_STALE_MS = 5 * 60 * 1000; // 5 minutes
|
|
49
|
+
|
|
50
|
+
export type ContextFillSource = "statusline" | "transcript" | "unknown";
|
|
51
|
+
|
|
52
|
+
export interface ContextFillReading {
|
|
53
|
+
status: "ok" | "unknown";
|
|
54
|
+
/** Tokens currently occupying the context window, or null when unknown. */
|
|
55
|
+
usedTokens: number | null;
|
|
56
|
+
windowSize: number;
|
|
57
|
+
/** usedTokens / windowSize, or null when unknown. Not clamped to [0,1] —
|
|
58
|
+
* callers that need a clamped display value do that themselves so the
|
|
59
|
+
* raw reading (which can legitimately exceed 1 for a moment) is never
|
|
60
|
+
* silently rewritten here. */
|
|
61
|
+
fraction: number | null;
|
|
62
|
+
source: ContextFillSource;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function unknownReading(windowSize: number): ContextFillReading {
|
|
66
|
+
return { status: "unknown", usedTokens: null, windowSize, fraction: null, source: "unknown" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Source 1 — statusline state file
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
export function statuslineStateFilePath(sessionId: string): string {
|
|
74
|
+
return join(tmpdir(), `pai-context-${sessionId}.json`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface StatuslineState {
|
|
78
|
+
used_percentage?: number;
|
|
79
|
+
context_window_size?: number;
|
|
80
|
+
session_id?: string;
|
|
81
|
+
timestamp?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read the state file statusline-command.sh writes on every render.
|
|
86
|
+
* Returns null when the file is missing, unparsable, or older than
|
|
87
|
+
* STATUSLINE_STALE_MS — all three mean "not a source right now".
|
|
88
|
+
*/
|
|
89
|
+
export function readStatuslineFill(sessionId: string, now = Date.now()): ContextFillReading | null {
|
|
90
|
+
if (!sessionId) return null;
|
|
91
|
+
const path = statuslineStateFilePath(sessionId);
|
|
92
|
+
if (!existsSync(path)) return null;
|
|
93
|
+
|
|
94
|
+
let raw: StatuslineState;
|
|
95
|
+
try {
|
|
96
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof raw.timestamp !== "number" || now - raw.timestamp > STATUSLINE_STALE_MS) {
|
|
102
|
+
return null; // stale — fall through to the transcript source
|
|
103
|
+
}
|
|
104
|
+
if (typeof raw.used_percentage !== "number" || typeof raw.context_window_size !== "number") {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const windowSize = raw.context_window_size;
|
|
109
|
+
const usedTokens = Math.round((raw.used_percentage / 100) * windowSize);
|
|
110
|
+
return {
|
|
111
|
+
status: "ok",
|
|
112
|
+
usedTokens,
|
|
113
|
+
windowSize,
|
|
114
|
+
fraction: raw.used_percentage / 100,
|
|
115
|
+
source: "statusline",
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Source 2 — transcript usage (fallback)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
interface UsageEntry {
|
|
124
|
+
input_tokens?: number;
|
|
125
|
+
cache_read_input_tokens?: number;
|
|
126
|
+
cache_creation_input_tokens?: number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sum the three context-carrying fields of ONE usage object — never across
|
|
131
|
+
* usage objects. A single `message.usage` already reports what that one API
|
|
132
|
+
* call sent as context; adding another entry's numbers to it produces a
|
|
133
|
+
* cumulative spend figure, not a fill reading, and can exceed the window
|
|
134
|
+
* size many times over on a long session (the exact defect this helper
|
|
135
|
+
* exists to not repeat — see the PreCompact header fix in the same change).
|
|
136
|
+
*/
|
|
137
|
+
function usageTotal(usage: UsageEntry): number {
|
|
138
|
+
return (
|
|
139
|
+
(usage.input_tokens || 0) +
|
|
140
|
+
(usage.cache_read_input_tokens || 0) +
|
|
141
|
+
(usage.cache_creation_input_tokens || 0)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Derive context fill from the tail of a .jsonl transcript: the most recent
|
|
147
|
+
* `message.usage` entry, read from the end backwards so a trailing line with
|
|
148
|
+
* no usage field (a plain text turn, a tool_result) doesn't hide one just
|
|
149
|
+
* before it.
|
|
150
|
+
*/
|
|
151
|
+
export function contextFillFromTranscript(
|
|
152
|
+
transcriptPath: string,
|
|
153
|
+
windowSize = DEFAULT_CONTEXT_WINDOW
|
|
154
|
+
): ContextFillReading {
|
|
155
|
+
if (!transcriptPath || !existsSync(transcriptPath)) return unknownReading(windowSize);
|
|
156
|
+
|
|
157
|
+
let raw: string;
|
|
158
|
+
try {
|
|
159
|
+
raw = readFileSync(transcriptPath, "utf-8");
|
|
160
|
+
} catch {
|
|
161
|
+
return unknownReading(windowSize);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const lines = raw.trim().split("\n").filter((l) => l.trim());
|
|
165
|
+
const tail = lines.slice(-TRANSCRIPT_TAIL_LINES);
|
|
166
|
+
|
|
167
|
+
for (let i = tail.length - 1; i >= 0; i--) {
|
|
168
|
+
let entry: { message?: { usage?: UsageEntry } };
|
|
169
|
+
try {
|
|
170
|
+
entry = JSON.parse(tail[i]);
|
|
171
|
+
} catch {
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const usage = entry?.message?.usage;
|
|
175
|
+
if (usage && typeof usage === "object") {
|
|
176
|
+
const usedTokens = usageTotal(usage);
|
|
177
|
+
return {
|
|
178
|
+
status: "ok",
|
|
179
|
+
usedTokens,
|
|
180
|
+
windowSize,
|
|
181
|
+
fraction: usedTokens / windowSize,
|
|
182
|
+
source: "transcript",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return unknownReading(windowSize);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Precedence — statusline (fresh) > transcript > unknown
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export function getContextFill(
|
|
195
|
+
input: { sessionId?: string; transcriptPath?: string; windowSize?: number },
|
|
196
|
+
now = Date.now()
|
|
197
|
+
): ContextFillReading {
|
|
198
|
+
const windowSize = input.windowSize ?? DEFAULT_CONTEXT_WINDOW;
|
|
199
|
+
|
|
200
|
+
if (input.sessionId) {
|
|
201
|
+
const fromStatusline = readStatuslineFill(input.sessionId, now);
|
|
202
|
+
if (fromStatusline) return fromStatusline;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (input.transcriptPath) {
|
|
206
|
+
return contextFillFromTranscript(input.transcriptPath, windowSize);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return unknownReading(windowSize);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Display helper — clamp-and-flag rather than ever print an absurd number
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
export interface FillDisplay {
|
|
217
|
+
/** e.g. "63k" or "unknown". Never a number larger than the window. */
|
|
218
|
+
text: string;
|
|
219
|
+
/** True when the raw reading exceeded the window size and had to be
|
|
220
|
+
* clamped — that reading was a bug, not a fill, and callers may want to
|
|
221
|
+
* log it even though the displayed text is already safe. */
|
|
222
|
+
flagged: boolean;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export function formatContextFill(reading: ContextFillReading): FillDisplay {
|
|
226
|
+
if (reading.status !== "ok" || reading.usedTokens === null) {
|
|
227
|
+
return { text: "unknown", flagged: false };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let used = reading.usedTokens;
|
|
231
|
+
let flagged = false;
|
|
232
|
+
if (used > reading.windowSize) {
|
|
233
|
+
flagged = true;
|
|
234
|
+
used = reading.windowSize;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const text = used > 1000 ? `${Math.round(used / 1000)}k` : String(used);
|
|
238
|
+
return { text: flagged ? `${text}+ (clamped — reading exceeded window)` : text, flagged };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// Threshold-triggered handover — WHEN to warm up / refresh the model-written
|
|
243
|
+
// summary ahead of a compaction.
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
//
|
|
246
|
+
// THE CONFIGURED VALUE DOES NOT PREDICT THE TRIGGER. CLAUDE_AUTOCOMPACT_
|
|
247
|
+
// PCT_OVERRIDE was 80 throughout, on this machine, across BOTH of the
|
|
248
|
+
// following regimes — the same configured number, two different truths:
|
|
249
|
+
//
|
|
250
|
+
// 2026-08-16 .. 2026-09-10 preTokens ~ 993,096 – 1,002,500 (~100% of a 1M window)
|
|
251
|
+
// 2026-09-12 .. 2026-09-15 preTokens ~ 782,981 – 791,995 (~78-79% of a 1M window)
|
|
252
|
+
//
|
|
253
|
+
// A threshold derived only from the configured override was wrong for the
|
|
254
|
+
// second regime and would be wrong again the next time it moves — the
|
|
255
|
+
// config is not ground truth, it is what Claude Code claims it will honor.
|
|
256
|
+
//
|
|
257
|
+
// GROUND TRUTH IS ON DISK ALREADY: every real compaction writes a
|
|
258
|
+
// `compact_boundary` system event to the transcript with
|
|
259
|
+
// `compactMetadata.preTokens` — the platform's own count of context tokens
|
|
260
|
+
// immediately before IT compacted. `measureCompactionTrigger` scans a
|
|
261
|
+
// project's own transcripts for these and takes the MINIMUM of the most
|
|
262
|
+
// recent three (minimum, not mean: warm-up must fire before the earliest
|
|
263
|
+
// plausible boundary, not the average one). A project that has compacted
|
|
264
|
+
// before learns its own trigger and needs no code change when the regime
|
|
265
|
+
// moves again — the same derivation produced 998,508 pre-09-12 and ~784k
|
|
266
|
+
// after, from one unchanged formula.
|
|
267
|
+
//
|
|
268
|
+
// The configured chain — env override, then a default — is the fallback,
|
|
269
|
+
// used ONLY when a project has no compaction history yet:
|
|
270
|
+
//
|
|
271
|
+
// effectiveTrigger = measured ?? (windowSize * (override ?? 80) / 100)
|
|
272
|
+
//
|
|
273
|
+
// DEFAULT IS 80, NOT 100, WHEN NOTHING IS KNOWN. 100 was considered — it
|
|
274
|
+
// matches the FIRST regime above — and rejected: the costs are asymmetric.
|
|
275
|
+
// Warming up too early wastes one summary; cheap, invisible. Warming up too
|
|
276
|
+
// late produces exactly the degraded successor session this feature exists
|
|
277
|
+
// to prevent. 80 is the conservative default until a project has its own
|
|
278
|
+
// measured history to correct it.
|
|
279
|
+
//
|
|
280
|
+
// Margins below the effective trigger are absolute tokens, sized against the
|
|
281
|
+
// largest single-turn context jump measured: 62,383 tokens (517,952 →
|
|
282
|
+
// 580,335, about 27 seconds). warmup sits 100k below the trigger —
|
|
283
|
+
// comfortably more than that jump, so a session cannot leap clean over
|
|
284
|
+
// warmup straight into a compaction in one turn; refresh at 40k below
|
|
285
|
+
// catches a session that sat above warmup for a while; fireNow at 15k below
|
|
286
|
+
// is "no time left for a clean crossing" and fires immediately rather than
|
|
287
|
+
// waiting for one.
|
|
288
|
+
export const DEFAULT_AUTOCOMPACT_PCT = 80;
|
|
289
|
+
|
|
290
|
+
/** How many of the most recent compact_boundary events to consider, and to
|
|
291
|
+
* take the minimum of. */
|
|
292
|
+
const MEASURED_TRIGGER_SAMPLE_SIZE = 3;
|
|
293
|
+
|
|
294
|
+
/** How many of a project's most-recently-modified transcripts to scan for
|
|
295
|
+
* compact_boundary events. compact_boundary events cluster in whichever
|
|
296
|
+
* files were touched most recently — a full-history scan would cost a lot
|
|
297
|
+
* for a long-lived project and buy nothing this doesn't already get from
|
|
298
|
+
* the last handful of files. */
|
|
299
|
+
const MEASURED_TRIGGER_MAX_FILES = 8;
|
|
300
|
+
|
|
301
|
+
export const THRESHOLD_MARGIN_TOKENS = {
|
|
302
|
+
warmup: 100_000,
|
|
303
|
+
refresh: 40_000,
|
|
304
|
+
immediate: 15_000,
|
|
305
|
+
} as const;
|
|
306
|
+
|
|
307
|
+
// ---------------------------------------------------------------------------
|
|
308
|
+
// Measured trigger — scan a project's own transcript history for the
|
|
309
|
+
// platform's own compact_boundary events, ground truth over configuration.
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
//
|
|
312
|
+
// Deliberately re-implements the tiny bit of path encoding it needs (below)
|
|
313
|
+
// rather than importing project-utils/paths.ts's encodePath: that module
|
|
314
|
+
// chain ends in pai-paths.ts, which calls process.exit(1) if PAI_DIR does
|
|
315
|
+
// not resolve to an existing directory. context-fill.ts is specifically the
|
|
316
|
+
// module a hook falls back on when its environment is unreliable — pulling
|
|
317
|
+
// in a dependency that can kill the process on import would defeat that.
|
|
318
|
+
// Claude Code's transcript directory is a fixed, home-relative convention
|
|
319
|
+
// (~/.claude/projects/<encoded-cwd>/), not something PAI_DIR governs, so
|
|
320
|
+
// resolving it directly here is also just the more correct dependency, not
|
|
321
|
+
// only the safer one.
|
|
322
|
+
|
|
323
|
+
/** Real default — overridable per-call so tests never touch the user's
|
|
324
|
+
* actual ~/.claude/projects/ (a live directory this very session writes to). */
|
|
325
|
+
export const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
326
|
+
|
|
327
|
+
function encodeProjectPath(cwd: string): string {
|
|
328
|
+
return cwd.replace(/[/\s.-]/g, "-");
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
interface CompactBoundarySample {
|
|
332
|
+
preTokens: number;
|
|
333
|
+
timestampMs: number;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function listProjectTranscripts(cwd: string, projectsDir: string): string[] {
|
|
337
|
+
const projectDir = join(projectsDir, encodeProjectPath(cwd));
|
|
338
|
+
if (!existsSync(projectDir)) return [];
|
|
339
|
+
|
|
340
|
+
const candidates: Array<{ path: string; mtimeMs: number }> = [];
|
|
341
|
+
const collect = (dir: string): void => {
|
|
342
|
+
if (!existsSync(dir)) return;
|
|
343
|
+
let entries: string[];
|
|
344
|
+
try {
|
|
345
|
+
entries = readdirSync(dir);
|
|
346
|
+
} catch {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
if (!entry.endsWith(".jsonl")) continue;
|
|
351
|
+
const full = join(dir, entry);
|
|
352
|
+
try {
|
|
353
|
+
candidates.push({ path: full, mtimeMs: statSync(full).mtimeMs });
|
|
354
|
+
} catch {
|
|
355
|
+
// Unreadable — skip.
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
collect(projectDir);
|
|
360
|
+
collect(join(projectDir, "sessions"));
|
|
361
|
+
|
|
362
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
363
|
+
return candidates.slice(0, MEASURED_TRIGGER_MAX_FILES).map((c) => c.path);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Every compact_boundary sample found in a project's most-recently-modified
|
|
368
|
+
* transcripts, newest first.
|
|
369
|
+
*/
|
|
370
|
+
function readCompactBoundarySamples(cwd: string, projectsDir: string): CompactBoundarySample[] {
|
|
371
|
+
const samples: CompactBoundarySample[] = [];
|
|
372
|
+
|
|
373
|
+
for (const path of listProjectTranscripts(cwd, projectsDir)) {
|
|
374
|
+
let raw: string;
|
|
375
|
+
try {
|
|
376
|
+
raw = readFileSync(path, "utf-8");
|
|
377
|
+
} catch {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
for (const line of raw.split("\n")) {
|
|
382
|
+
if (!line.trim()) continue;
|
|
383
|
+
let entry: {
|
|
384
|
+
type?: string;
|
|
385
|
+
subtype?: string;
|
|
386
|
+
timestamp?: string;
|
|
387
|
+
compactMetadata?: { preTokens?: number };
|
|
388
|
+
};
|
|
389
|
+
try {
|
|
390
|
+
entry = JSON.parse(line);
|
|
391
|
+
} catch {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (entry.type !== "system" || entry.subtype !== "compact_boundary") continue;
|
|
395
|
+
const preTokens = entry.compactMetadata?.preTokens;
|
|
396
|
+
if (typeof preTokens !== "number" || !Number.isFinite(preTokens)) continue;
|
|
397
|
+
const timestampMs = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
|
|
398
|
+
if (!Number.isFinite(timestampMs)) continue;
|
|
399
|
+
samples.push({ preTokens, timestampMs });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
samples.sort((a, b) => b.timestampMs - a.timestampMs);
|
|
404
|
+
return samples;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* The measured compaction trigger for a project, or null when it has no
|
|
409
|
+
* compaction history yet (a brand-new project, or one whose transcripts
|
|
410
|
+
* this process cannot read). Minimum of the most recent
|
|
411
|
+
* MEASURED_TRIGGER_SAMPLE_SIZE compact_boundary events — see the module
|
|
412
|
+
* comment above for why minimum, not mean.
|
|
413
|
+
*/
|
|
414
|
+
export function measureCompactionTrigger(
|
|
415
|
+
cwd: string,
|
|
416
|
+
projectsDir: string = CLAUDE_PROJECTS_DIR
|
|
417
|
+
): number | null {
|
|
418
|
+
if (!cwd) return null;
|
|
419
|
+
const samples = readCompactBoundarySamples(cwd, projectsDir).slice(0, MEASURED_TRIGGER_SAMPLE_SIZE);
|
|
420
|
+
if (samples.length === 0) return null;
|
|
421
|
+
return Math.min(...samples.map((s) => s.preTokens));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Read CLAUDE_AUTOCOMPACT_PCT_OVERRIDE from the environment. Absent →
|
|
426
|
+
* DEFAULT_AUTOCOMPACT_PCT. Present but not a finite number in (0, 100] →
|
|
427
|
+
* also DEFAULT_AUTOCOMPACT_PCT, logged, so a typo in the override degrades
|
|
428
|
+
* to the documented default instead of silently producing nonsense
|
|
429
|
+
* thresholds (0, negative, or a fraction so large no session ever reaches
|
|
430
|
+
* it).
|
|
431
|
+
*/
|
|
432
|
+
export function resolveAutocompactPct(env: NodeJS.ProcessEnv = process.env): number {
|
|
433
|
+
const raw = env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE;
|
|
434
|
+
if (raw === undefined || raw === "") return DEFAULT_AUTOCOMPACT_PCT;
|
|
435
|
+
|
|
436
|
+
const parsed = Number(raw);
|
|
437
|
+
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 100) {
|
|
438
|
+
console.error(
|
|
439
|
+
`[context-fill] CLAUDE_AUTOCOMPACT_PCT_OVERRIDE="${raw}" is not a usable percentage — ` +
|
|
440
|
+
`falling back to the default ${DEFAULT_AUTOCOMPACT_PCT}.`
|
|
441
|
+
);
|
|
442
|
+
return DEFAULT_AUTOCOMPACT_PCT;
|
|
443
|
+
}
|
|
444
|
+
return parsed;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* The two thresholds that actually fire a handover job. "immediate" is not a
|
|
449
|
+
* third job — see `isImmediate` below — it only changes how urgently a
|
|
450
|
+
* crossing of these two is treated.
|
|
451
|
+
*/
|
|
452
|
+
export type ThresholdName = "warmup" | "refresh";
|
|
453
|
+
|
|
454
|
+
/** Which basis actually produced effectiveTriggerTokens — reported so the
|
|
455
|
+
* number is checkable rather than trusted. */
|
|
456
|
+
export type TriggerSource = "measured" | "configured";
|
|
457
|
+
|
|
458
|
+
export interface ContextFillThresholds {
|
|
459
|
+
warmupTokens: number;
|
|
460
|
+
refreshTokens: number;
|
|
461
|
+
immediateTokens: number;
|
|
462
|
+
/** The derived compaction trigger these were measured back from. */
|
|
463
|
+
effectiveTriggerTokens: number;
|
|
464
|
+
/** The autocompact percentage from the configured chain — computed and
|
|
465
|
+
* reported even when `triggerSource` is "measured" (in which case it
|
|
466
|
+
* describes what the fallback WOULD have used, not what was used). */
|
|
467
|
+
autocompactPct: number;
|
|
468
|
+
/** "measured" when this project has compact_boundary history and that was
|
|
469
|
+
* used; "configured" when it fell back to the env-override/default chain. */
|
|
470
|
+
triggerSource: TriggerSource;
|
|
471
|
+
/** True when these were computed against a window size Claude Code itself
|
|
472
|
+
* reported (the statusline source). False when the window is only an
|
|
473
|
+
* assumed default (the transcript-fallback source never learns the real
|
|
474
|
+
* window size) — callers should log this: a threshold silently computed
|
|
475
|
+
* against the wrong basis is the same class of fault as printing a token
|
|
476
|
+
* count larger than the window. */
|
|
477
|
+
windowConfirmed: boolean;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export interface ContextFillThresholdOpts {
|
|
481
|
+
/** The session's project directory, used to look up its own compaction
|
|
482
|
+
* history for the measured trigger. Omit when unknown — falls back to
|
|
483
|
+
* the configured chain, same as a project with no history yet. */
|
|
484
|
+
cwd?: string;
|
|
485
|
+
/** Override the measured-trigger lookup instead of scanning transcripts —
|
|
486
|
+
* primarily for tests. `null` forces the configured fallback even when
|
|
487
|
+
* `cwd` is given; `undefined` (the default) does the real lookup. */
|
|
488
|
+
measuredTrigger?: number | null;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Derive warmup/refresh/immediate thresholds from a fill reading, preferring
|
|
493
|
+
* this project's own measured compaction history over the configured
|
|
494
|
+
* override chain — see the module comment above for why. Margins are
|
|
495
|
+
* clamped at 0 (and logged) for a window small enough that a margin would
|
|
496
|
+
* otherwise go negative — a pathological input should degrade to "fire
|
|
497
|
+
* immediately", never to a threshold below zero.
|
|
498
|
+
*/
|
|
499
|
+
export function contextFillThresholds(
|
|
500
|
+
reading: ContextFillReading,
|
|
501
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
502
|
+
opts: ContextFillThresholdOpts = {}
|
|
503
|
+
): ContextFillThresholds {
|
|
504
|
+
const windowConfirmed = reading.source === "statusline";
|
|
505
|
+
const autocompactPct = resolveAutocompactPct(env);
|
|
506
|
+
const configuredTriggerTokens = Math.round(reading.windowSize * (autocompactPct / 100));
|
|
507
|
+
|
|
508
|
+
const measured = opts.measuredTrigger !== undefined
|
|
509
|
+
? opts.measuredTrigger
|
|
510
|
+
: opts.cwd
|
|
511
|
+
? measureCompactionTrigger(opts.cwd)
|
|
512
|
+
: null;
|
|
513
|
+
|
|
514
|
+
const triggerSource: TriggerSource = measured !== null ? "measured" : "configured";
|
|
515
|
+
const effectiveTriggerTokens = measured !== null ? measured : configuredTriggerTokens;
|
|
516
|
+
|
|
517
|
+
if (triggerSource === "measured") {
|
|
518
|
+
console.error(
|
|
519
|
+
`[context-fill] trigger source: MEASURED — ${effectiveTriggerTokens} tokens ` +
|
|
520
|
+
`(minimum of the most recent ${MEASURED_TRIGGER_SAMPLE_SIZE} compact_boundary events for ` +
|
|
521
|
+
`this project; the configured chain would have given ${configuredTriggerTokens}).`
|
|
522
|
+
);
|
|
523
|
+
} else {
|
|
524
|
+
console.error(
|
|
525
|
+
`[context-fill] trigger source: CONFIGURED — no compaction history for this project yet, ` +
|
|
526
|
+
`using ${effectiveTriggerTokens} tokens (${autocompactPct}% of a ${reading.windowSize}-token window).`
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const clamp = (name: string, value: number): number => {
|
|
531
|
+
if (value >= 0) return value;
|
|
532
|
+
console.error(
|
|
533
|
+
`[context-fill] ${name} threshold went negative (${value}) for a ` +
|
|
534
|
+
`${reading.windowSize}-token window — clamping to 0.`
|
|
535
|
+
);
|
|
536
|
+
return 0;
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
warmupTokens: clamp("warmup", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.warmup),
|
|
541
|
+
refreshTokens: clamp("refresh", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.refresh),
|
|
542
|
+
immediateTokens: clamp("immediate", effectiveTriggerTokens - THRESHOLD_MARGIN_TOKENS.immediate),
|
|
543
|
+
effectiveTriggerTokens,
|
|
544
|
+
autocompactPct,
|
|
545
|
+
triggerSource,
|
|
546
|
+
windowConfirmed,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Which named thresholds `usedTokens` has newly crossed, given the ones that
|
|
552
|
+
* have already fired this session. Ascending order (warmup before refresh).
|
|
553
|
+
*
|
|
554
|
+
* A single check can return more than one name — e.g. a session first
|
|
555
|
+
* observed at 99% of its window crosses warmup and refresh in the same tick,
|
|
556
|
+
* because there was no earlier clean crossing to catch it at. That is the
|
|
557
|
+
* "fire immediately" case: the caller enqueues one handover and marks every
|
|
558
|
+
* newly-crossed name fired, rather than waiting for a crossing that already
|
|
559
|
+
* happened.
|
|
560
|
+
*/
|
|
561
|
+
export function crossedThresholds(
|
|
562
|
+
usedTokens: number,
|
|
563
|
+
thresholds: ContextFillThresholds,
|
|
564
|
+
alreadyFired: ThresholdName[]
|
|
565
|
+
): ThresholdName[] {
|
|
566
|
+
const fired = new Set(alreadyFired);
|
|
567
|
+
const ordered: Array<[ThresholdName, number]> = [
|
|
568
|
+
["warmup", thresholds.warmupTokens],
|
|
569
|
+
["refresh", thresholds.refreshTokens],
|
|
570
|
+
];
|
|
571
|
+
return ordered.filter(([name, tokens]) => !fired.has(name) && usedTokens >= tokens).map(([name]) => name);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* True once a session is at or above the "no time left for a clean crossing"
|
|
576
|
+
* floor (0.985 of the window). Purely informational for callers — it never
|
|
577
|
+
* gates whether `crossedThresholds` fires (a poll-based check already fires
|
|
578
|
+
* any unfired threshold the moment `usedTokens` reaches it, on whatever tick
|
|
579
|
+
* observes it) — but it distinguishes "this crossed on schedule" from "this
|
|
580
|
+
* was first observed already almost out of room", which is worth a different
|
|
581
|
+
* log line and, for a caller that queues work, a higher priority.
|
|
582
|
+
*/
|
|
583
|
+
export function isImmediate(usedTokens: number, thresholds: ContextFillThresholds): boolean {
|
|
584
|
+
return usedTokens >= thresholds.immediateTokens;
|
|
585
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* context-handover-cache.ts — the well-known file where the threshold-
|
|
3
|
+
* triggered pre-compaction handover (see ../../../daemon/context-handover-
|
|
4
|
+
* worker.ts) is written, and where context-compression-hook.ts looks for it
|
|
5
|
+
* at compaction time.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately dependency-light (fs/os/path only). The daemon worker that
|
|
8
|
+
* WRITES this file needs the heavier machinery in session-summary-worker.ts
|
|
9
|
+
* (spawning Claude, git log, etc.), but the PreCompact hook that READS it
|
|
10
|
+
* runs as a short-lived process on every compaction and must not drag in
|
|
11
|
+
* daemon state, database pools, or anything else that module graph carries —
|
|
12
|
+
* this file is the shared seam so neither side has to.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { tmpdir } from "node:os";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export type HandoverThreshold = "warmup" | "refresh";
|
|
20
|
+
|
|
21
|
+
export interface ContextHandoverCache {
|
|
22
|
+
sessionId: string;
|
|
23
|
+
cwd: string;
|
|
24
|
+
threshold: HandoverThreshold;
|
|
25
|
+
generatedAt: string; // ISO timestamp
|
|
26
|
+
model: string;
|
|
27
|
+
summary: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function contextHandoverCachePath(sessionId: string): string {
|
|
31
|
+
return join(tmpdir(), `pai-context-handover-${sessionId}.json`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readContextHandoverCache(sessionId: string): ContextHandoverCache | null {
|
|
35
|
+
const path = contextHandoverCachePath(sessionId);
|
|
36
|
+
if (!existsSync(path)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(readFileSync(path, "utf-8")) as ContextHandoverCache;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function writeContextHandoverCache(cache: ContextHandoverCache): void {
|
|
45
|
+
writeFileSync(contextHandoverCachePath(cache.sessionId), JSON.stringify(cache, null, 2), "utf-8");
|
|
46
|
+
}
|