opencode-context-indicator 1.1.4 → 1.1.6
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/README.md +4 -3
- package/index.js +28 -2794
- package/lib/cache.js +161 -0
- package/lib/commands.js +489 -0
- package/lib/dedup.js +12 -3
- package/lib/estimate.js +232 -0
- package/lib/limits.js +403 -0
- package/lib/state.js +595 -0
- package/lib/v1.js +957 -0
- package/lib/v2.js +692 -0
- package/package.json +4 -4
- package/tui.tsx +1 -1
package/lib/state.js
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
// lib/state.js
|
|
2
|
+
//
|
|
3
|
+
// Filesystem layer for the context-indicator plugin: the log / state / event /
|
|
4
|
+
// dedup paths, the best-effort state lock, the atomic temp+rename writers, the
|
|
5
|
+
// TUI state bridge (writeStateFile), the state.json reader, and the claimed
|
|
6
|
+
// final-summary log. It reads the in-memory caches from ./cache.js and the model
|
|
7
|
+
// limits from ./limits.js (a call-time-only import cycle). Extracted 1:1 (code
|
|
8
|
+
// + WHY comments) from index.js.
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
appendFileSync,
|
|
12
|
+
existsSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
renameSync,
|
|
16
|
+
statSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} from "node:fs";
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { claimOnce } from "./dedup.js";
|
|
24
|
+
import {
|
|
25
|
+
finitePos,
|
|
26
|
+
isSafeSessionKey,
|
|
27
|
+
oneLine,
|
|
28
|
+
safeCount,
|
|
29
|
+
safeCountOrNull,
|
|
30
|
+
} from "./estimate.js";
|
|
31
|
+
import {
|
|
32
|
+
breakdownCache,
|
|
33
|
+
MAX_TRACKED_SESSIONS,
|
|
34
|
+
sessionAgentCache,
|
|
35
|
+
sessionParentCache,
|
|
36
|
+
} from "./cache.js";
|
|
37
|
+
import { getModelLimit, getUsableContext } from "./limits.js";
|
|
38
|
+
|
|
39
|
+
const MAX_FINAL_SUMMARIES = 50; // bound the in-memory final-summary log
|
|
40
|
+
|
|
41
|
+
// --- DEBUG instrumentation (behaviour probing on Desktop) ---
|
|
42
|
+
// When true, every incoming event.type is appended to %TEMP%\context-events.log
|
|
43
|
+
// (full JSON payload for session.error, sessionID for session.compacted).
|
|
44
|
+
// The file is truncated once it exceeds ~1MB. Flip to false to disable.
|
|
45
|
+
const DEBUG_EVENTS = false;
|
|
46
|
+
const EVENT_LOG_MAX_BYTES = 1024 * 1024; // ~1MB truncate threshold
|
|
47
|
+
|
|
48
|
+
const LOG_FILE = join(tmpdir(), "context-breakdown.log");
|
|
49
|
+
|
|
50
|
+
// Machine-readable snapshot consumed by the optional TUI sidebar (./tui.tsx,
|
|
51
|
+
// package entry "./tui"). Written next to each log update — same moment, same
|
|
52
|
+
// data. See writeStateFile() below.
|
|
53
|
+
// State file path — overridable via env for test isolation.
|
|
54
|
+
// In production the env is never set, so behaviour is unchanged.
|
|
55
|
+
// In tests: export OPENCODE_CONTEXT_INDICATOR_STATE_FILE=/tmp/test-state.json
|
|
56
|
+
// and the harness writes a controlled seed; no live file is touched.
|
|
57
|
+
const STATE_FILE =
|
|
58
|
+
process.env.OPENCODE_CONTEXT_INDICATOR_STATE_FILE ||
|
|
59
|
+
join(tmpdir(), "opencode-context-indicator-state.json");
|
|
60
|
+
|
|
61
|
+
// Separate tap log for raw events (written only when DEBUG_EVENTS is true).
|
|
62
|
+
const EVENT_LOG_FILE = join(tmpdir(), "context-events.log");
|
|
63
|
+
|
|
64
|
+
// Cross-instance dedup markers for the context-indicator (see ./lib/dedup.js).
|
|
65
|
+
// Used for final summaries / compacted / error notes: the live snapshot itself
|
|
66
|
+
// is an idempotent rewrite and needs no claim.
|
|
67
|
+
const DEDUP_DIR = join(tmpdir(), "context-indicator.dedup");
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// State-file locking (best-effort) + atomic rename.
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// The state file is a SHARED read-merge-write target: Desktop, the TUI sidebar
|
|
73
|
+
// and every loaded plugin instance may write it concurrently. A temp+rename is
|
|
74
|
+
// atomic, but it does NOT prevent lost updates between the read and the write.
|
|
75
|
+
// A short-lived exclusive lock file (`<STATE_FILE>.lock`) serialises the
|
|
76
|
+
// read-merge-write critical section. This is best-effort by design: if the lock
|
|
77
|
+
// cannot be taken, the write is SKIPPED (the caller keeps its value in memory and
|
|
78
|
+
// it lands on the next cycle) rather than risking a torn merge.
|
|
79
|
+
//
|
|
80
|
+
// Lock ownership: each acquisition writes a random token INTO the lock file and
|
|
81
|
+
// only releases while the file still holds that token, so a late release can
|
|
82
|
+
// never unlink a lock another instance has since taken. Reclaim of an abandoned
|
|
83
|
+
// lock uses an atomic rename, not unlink, so two waiters racing on a stale lock
|
|
84
|
+
// cannot delete each other's freshly re-created lock.
|
|
85
|
+
//
|
|
86
|
+
// Retry budget is deliberately small: this blocks the event loop synchronously
|
|
87
|
+
// (Atomics.wait), contention is rare, and 6 x 20 ms caps the worst-case stall at
|
|
88
|
+
// ~120 ms — a reasonable trade-off between surviving a transient holder and not
|
|
89
|
+
// freezing a plugin tick.
|
|
90
|
+
const STATE_LOCK_FILE = `${STATE_FILE}.lock`;
|
|
91
|
+
const LOCK_MAX_ATTEMPTS = 6;
|
|
92
|
+
const LOCK_RETRY_MS = 20;
|
|
93
|
+
const LOCK_STALE_MS = 5000; // a lock older than this is presumed abandoned
|
|
94
|
+
|
|
95
|
+
// Bounded list of final summaries written on session.idle
|
|
96
|
+
const finalSummaries = [];
|
|
97
|
+
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
// Logging helpers (all fault-tolerant; never throw into the caller).
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function logErr(msg, err) {
|
|
103
|
+
try {
|
|
104
|
+
// Bound/sanitise the message (untrusted values are interpolated into it);
|
|
105
|
+
// keep structured objects untouched so their diagnostics survive.
|
|
106
|
+
const detail =
|
|
107
|
+
err === undefined || err === null
|
|
108
|
+
? ""
|
|
109
|
+
: err instanceof Error
|
|
110
|
+
? oneLine(err.message)
|
|
111
|
+
: typeof err === "string"
|
|
112
|
+
? oneLine(err)
|
|
113
|
+
: err;
|
|
114
|
+
console.error(`[context-indicator] ${oneLine(msg)}:`, detail);
|
|
115
|
+
} catch {
|
|
116
|
+
/* ignore */
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Build the full file content: header + live snapshot + final summaries.
|
|
121
|
+
function renderFile(liveLines) {
|
|
122
|
+
const lines = [];
|
|
123
|
+
lines.push(
|
|
124
|
+
"# context-breakdown.log",
|
|
125
|
+
`# generated ${new Date().toISOString()} by context-indicator plugin`,
|
|
126
|
+
"# estimates: unicode heuristic (cyrillic 2.5, CJK 1.5, other 4 chars/token) — estimate, not a tokenizer",
|
|
127
|
+
"# live snapshot is rewritten; final summaries are preserved (bounded)",
|
|
128
|
+
"",
|
|
129
|
+
"## live snapshot",
|
|
130
|
+
...liveLines,
|
|
131
|
+
"",
|
|
132
|
+
"## final summaries",
|
|
133
|
+
);
|
|
134
|
+
if (finalSummaries.length === 0) {
|
|
135
|
+
lines.push("(none yet)");
|
|
136
|
+
} else {
|
|
137
|
+
for (const s of finalSummaries) lines.push(s);
|
|
138
|
+
}
|
|
139
|
+
return lines.join("\n") + "\n";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Atomically rewrite the whole log file (bounded by design). The temp+rename
|
|
143
|
+
// makes concurrent writers from multiple plugin instances safe: a reader sees
|
|
144
|
+
// either the previous or the new file, never a half-written one.
|
|
145
|
+
function writeLog(liveLines) {
|
|
146
|
+
try {
|
|
147
|
+
const content = renderFile(liveLines);
|
|
148
|
+
// Random temp suffix + exclusive create ("wx"): a predictable name could be
|
|
149
|
+
// pre-created as a symlink for us to follow (CWE-377). "wx" fails instead.
|
|
150
|
+
const tmp = `${LOG_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
151
|
+
// Create BEFORE the finally-guarded section (mirrors writeStateFile): on an
|
|
152
|
+
// (astronomically unlikely) EEXIST collision with a peer's temp, THEIR file
|
|
153
|
+
// must not be cleaned up by us — the write fails and is logged instead.
|
|
154
|
+
writeFileSync(tmp, content, { encoding: "utf8", flag: "wx" });
|
|
155
|
+
try {
|
|
156
|
+
renameWithRetry(tmp, LOG_FILE);
|
|
157
|
+
} finally {
|
|
158
|
+
// Same hygiene as writeStateFile: a failed rename must not litter %TEMP%
|
|
159
|
+
// with stale `<hex>.tmp` files.
|
|
160
|
+
try {
|
|
161
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
162
|
+
} catch {
|
|
163
|
+
/* ignore */
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} catch (err) {
|
|
167
|
+
logErr("writeLog failed", err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Append a single line to the file (used for final summaries / notes).
|
|
172
|
+
function appendLogLine(line) {
|
|
173
|
+
try {
|
|
174
|
+
ensureRegularFile(LOG_FILE); // refuse to follow a symlink planted at the path
|
|
175
|
+
appendFileSync(LOG_FILE, line + "\n", "utf8");
|
|
176
|
+
} catch (err) {
|
|
177
|
+
logErr("appendLogLine failed", err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Synchronous sleep for the lock retry loop. Atomics.wait is the documented
|
|
182
|
+
// non-busy way to block this thread; fall back to a bounded spin if unavailable.
|
|
183
|
+
function sleepSync(ms) {
|
|
184
|
+
try {
|
|
185
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
186
|
+
} catch {
|
|
187
|
+
const end = Date.now() + ms;
|
|
188
|
+
while (Date.now() < end) {
|
|
189
|
+
/* bounded spin fallback */
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Best-effort: if `path` exists but is NOT a regular file (symlink / FIFO /
|
|
195
|
+
// device), remove it so the following create writes a real file instead of
|
|
196
|
+
// following the link (CWE-377 / symlink redirection). Never throws.
|
|
197
|
+
//
|
|
198
|
+
// Residual TOCTOU (lstat here vs. the caller's append/create): accepted. This is
|
|
199
|
+
// a defence-in-depth guard, not a sandbox — a local attacker able to write into
|
|
200
|
+
// %TEMP% already holds broader powers (they can swap any path this process
|
|
201
|
+
// touches). Closing the race would need O_NOFOLLOW-style semantics that the
|
|
202
|
+
// callers' appendFileSync/writeFileSync API cannot express, for no real gain.
|
|
203
|
+
function ensureRegularFile(path) {
|
|
204
|
+
try {
|
|
205
|
+
const st = lstatSync(path);
|
|
206
|
+
if (!st.isFile()) unlinkSync(path);
|
|
207
|
+
} catch {
|
|
208
|
+
/* missing -> the following create makes a fresh regular file */
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// True when the lock file exists and looks abandoned (older than LOCK_STALE_MS).
|
|
213
|
+
// Callers wrap it in try/catch: ENOENT (lock vanished) lands there and retries.
|
|
214
|
+
function isStaleLock() {
|
|
215
|
+
return Date.now() - statSync(STATE_LOCK_FILE).mtimeMs > LOCK_STALE_MS;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Returns `{ ok: true, token }` when the lock was taken (caller MUST release with
|
|
219
|
+
// that token), `{ ok: false, token: null }` when it is busy (skip the write), or
|
|
220
|
+
// `null` on an unexpected error — fail-open: the caller writes WITHOUT the lock
|
|
221
|
+
// and must NOT release (nothing was acquired).
|
|
222
|
+
function acquireStateLock() {
|
|
223
|
+
const token = randomBytes(8).toString("hex"); // 16 hex chars, per acquisition
|
|
224
|
+
for (let i = 0; i < LOCK_MAX_ATTEMPTS; i++) {
|
|
225
|
+
try {
|
|
226
|
+
// "wx" = exclusive create; fails EEXIST if held, and creates+writes the
|
|
227
|
+
// token in ONE atomic step (no empty-file-then-write gap to race).
|
|
228
|
+
writeFileSync(STATE_LOCK_FILE, token, { flag: "wx" });
|
|
229
|
+
return { ok: true, token };
|
|
230
|
+
} catch (err) {
|
|
231
|
+
if (err && err.code === "EEXIST") {
|
|
232
|
+
// Held by someone. Reclaim it if it looks abandoned (holder crashed);
|
|
233
|
+
// otherwise wait a little and retry.
|
|
234
|
+
try {
|
|
235
|
+
// Stale check TWICE: a peer may reclaim the stale lock and yet another
|
|
236
|
+
// process re-create a fresh one between our first stat and the rename
|
|
237
|
+
// — renaming then steals a FRESH lock (two holders, torn merge). Two
|
|
238
|
+
// consecutive checks narrow the window to the stat->rename gap
|
|
239
|
+
// (microseconds); the lock stays best-effort: the worst case is a
|
|
240
|
+
// stale single-session entry, self-healed on the next write.
|
|
241
|
+
if (isStaleLock() && isStaleLock()) {
|
|
242
|
+
// Atomic reclaim: only ONE waiter can rename the stale file away;
|
|
243
|
+
// the loser gets ENOENT and keeps waiting — neither can unlink a
|
|
244
|
+
// lock a peer just re-created.
|
|
245
|
+
const stale = `${STATE_LOCK_FILE}.stale.${randomBytes(4).toString("hex")}`;
|
|
246
|
+
renameSync(STATE_LOCK_FILE, stale);
|
|
247
|
+
unlinkSync(stale);
|
|
248
|
+
continue; // retry immediately to claim the now-free lock
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
/* vanished / already reclaimed by a peer -> just retry */
|
|
252
|
+
}
|
|
253
|
+
sleepSync(LOCK_RETRY_MS);
|
|
254
|
+
} else {
|
|
255
|
+
// Unexpected error (permissions, tmpdir issue): do not block forever —
|
|
256
|
+
// fail-open so the state file still gets written.
|
|
257
|
+
logErr("state lock acquire error (proceeding unlocked)", err);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return { ok: false, token: null }; // busy: could not acquire in the budget
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Ownership-safe release: unlink ONLY while the lock still holds OUR token. A
|
|
266
|
+
// peer that reclaimed an abandoned lock (or a later acquisition) is left alone.
|
|
267
|
+
function releaseStateLock(token) {
|
|
268
|
+
try {
|
|
269
|
+
if (readFileSync(STATE_LOCK_FILE, "utf8") === token) {
|
|
270
|
+
unlinkSync(STATE_LOCK_FILE);
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
/* already gone / never created -> nothing to release */
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Rename with a couple of retries: on Windows renameSync can throw EPERM/EBUSY
|
|
278
|
+
// while a reader (the TUI polls every second) or an AV scanner holds the
|
|
279
|
+
// destination open. A short backoff lets the handle close.
|
|
280
|
+
function renameWithRetry(from, to, attempts = 3, delayMs = 50) {
|
|
281
|
+
for (let i = 0; i < attempts; i++) {
|
|
282
|
+
try {
|
|
283
|
+
renameSync(from, to);
|
|
284
|
+
return true;
|
|
285
|
+
} catch (err) {
|
|
286
|
+
if (i === attempts - 1) {
|
|
287
|
+
logErr(`rename failed after ${attempts} attempts (${to})`, err);
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
sleepSync(delayMs);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// TUI state bridge (opencode-context-indicator)
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Persist a tiny machine-readable snapshot of the CURRENT breakdown for one
|
|
300
|
+
// session so the optional TUI sidebar (./tui.tsx, package entry "./tui") can
|
|
301
|
+
// render it without talking to the server. The file is one JSON object keyed by
|
|
302
|
+
// sessionID, rewritten atomically (temp + rename) at every log write — same
|
|
303
|
+
// moment, same data. Strictly additive: any failure is swallowed and must never
|
|
304
|
+
// affect the log / toast / event paths.
|
|
305
|
+
function writeStateFile(
|
|
306
|
+
sessionID,
|
|
307
|
+
modelID,
|
|
308
|
+
providerID,
|
|
309
|
+
inputTokens,
|
|
310
|
+
ctxTokens,
|
|
311
|
+
reasoningTokens,
|
|
312
|
+
) {
|
|
313
|
+
try {
|
|
314
|
+
if (!sessionID) return;
|
|
315
|
+
// A poisoned/odd sessionID ("__proto__", "constructor", …) must never be
|
|
316
|
+
// used as an object key: plain assignment through it would mutate the
|
|
317
|
+
// prototype instead of adding an entry (CWE-1321).
|
|
318
|
+
if (!isSafeSessionKey(sessionID)) {
|
|
319
|
+
logErr(`writeStateFile skipped unsafe session key ${oneLine(sessionID)}`);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const lock = acquireStateLock();
|
|
323
|
+
// {ok:false} => busy: skip (value stays in memory, lands next cycle), as
|
|
324
|
+
// before. null => unexpected error: fail-open, write WITHOUT the lock and do
|
|
325
|
+
// NOT release (nothing was acquired — releasing could nuke a peer's lock).
|
|
326
|
+
if (lock && !lock.ok) {
|
|
327
|
+
logErr(
|
|
328
|
+
`writeStateFile skipped (state lock busy) session=${oneLine(sessionID)}`,
|
|
329
|
+
);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
try {
|
|
333
|
+
const bd = breakdownCache.get(sessionID) || null;
|
|
334
|
+
const limit = getModelLimit(providerID, modelID);
|
|
335
|
+
const usableInfo = getUsableContext(providerID, modelID);
|
|
336
|
+
const denom =
|
|
337
|
+
usableInfo != null
|
|
338
|
+
? usableInfo.usable
|
|
339
|
+
: limit != null && limit > 0
|
|
340
|
+
? limit
|
|
341
|
+
: null;
|
|
342
|
+
const total =
|
|
343
|
+
typeof ctxTokens === "number" && ctxTokens > 0
|
|
344
|
+
? ctxTokens
|
|
345
|
+
: inputTokens || 0;
|
|
346
|
+
|
|
347
|
+
// Read the existing file FIRST: when THIS instance has no fresh breakdown
|
|
348
|
+
// (the context hook did not fire here — e.g. the request was served by
|
|
349
|
+
// another plugin instance whose event stream is mirrored to us), keep the
|
|
350
|
+
// values already persisted instead of clobbering them with nulls/zeros.
|
|
351
|
+
let rawAll = {};
|
|
352
|
+
try {
|
|
353
|
+
rawAll = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
354
|
+
} catch {
|
|
355
|
+
rawAll = {}; // missing / empty / corrupt -> start fresh
|
|
356
|
+
}
|
|
357
|
+
if (!rawAll || typeof rawAll !== "object" || Array.isArray(rawAll)) {
|
|
358
|
+
rawAll = {};
|
|
359
|
+
}
|
|
360
|
+
// Drop prototype-pollution keys from the shared file so a poisoned
|
|
361
|
+
// snapshot is neither consulted via the prototype chain nor re-persisted.
|
|
362
|
+
let all = {};
|
|
363
|
+
for (const [k, v] of Object.entries(rawAll)) {
|
|
364
|
+
if (isSafeSessionKey(k)) all[k] = v;
|
|
365
|
+
}
|
|
366
|
+
const prevEntry = Object.prototype.hasOwnProperty.call(all, sessionID)
|
|
367
|
+
? all[sessionID]
|
|
368
|
+
: null;
|
|
369
|
+
const pc = prevEntry?.categories || null;
|
|
370
|
+
|
|
371
|
+
// Same model AND provider? Only then is a previously persisted denominator
|
|
372
|
+
// meaningful — a session that switched model (or provider, even with the
|
|
373
|
+
// same modelID) must not inherit the old window.
|
|
374
|
+
const sameModelAsPrev =
|
|
375
|
+
prevEntry != null &&
|
|
376
|
+
prevEntry.model === (modelID || "unknown") &&
|
|
377
|
+
(prevEntry.providerID || "") === (providerID || "");
|
|
378
|
+
|
|
379
|
+
// Coerce every persisted category through the safe numeric helpers: the
|
|
380
|
+
// snapshot is untrusted input and must never inject non-numbers.
|
|
381
|
+
const catUser = bd ? safeCount(bd.userChars) : safeCount(pc?.user);
|
|
382
|
+
const catAssistant = bd
|
|
383
|
+
? safeCount(bd.assistantChars)
|
|
384
|
+
: safeCount(pc?.assistant);
|
|
385
|
+
const catReasoning = bd
|
|
386
|
+
? safeCount(bd.reasoningChars)
|
|
387
|
+
: safeCount(pc?.reasoning);
|
|
388
|
+
const catToolArgs = bd
|
|
389
|
+
? safeCount(bd.toolArgsChars)
|
|
390
|
+
: safeCount(pc?.toolArgs);
|
|
391
|
+
// system / tool schemas are hook-only; a fallback breakdown has them null
|
|
392
|
+
// and must not erase a value the hook already persisted.
|
|
393
|
+
const catSystem =
|
|
394
|
+
bd && bd.systemChars != null
|
|
395
|
+
? safeCount(bd.systemChars)
|
|
396
|
+
: safeCountOrNull(pc?.system);
|
|
397
|
+
const catToolSchemas =
|
|
398
|
+
bd && bd.toolSchemasChars != null
|
|
399
|
+
? safeCount(bd.toolSchemasChars)
|
|
400
|
+
: safeCountOrNull(pc?.toolSchemas);
|
|
401
|
+
|
|
402
|
+
const sumEstimates =
|
|
403
|
+
catUser +
|
|
404
|
+
catAssistant +
|
|
405
|
+
catReasoning +
|
|
406
|
+
catToolArgs +
|
|
407
|
+
(catSystem || 0) +
|
|
408
|
+
(catToolSchemas || 0);
|
|
409
|
+
const other = Math.max(0, (inputTokens || 0) - sumEstimates);
|
|
410
|
+
// Parent link for the /context-breakdown command. Prefer the live cache;
|
|
411
|
+
// fall back to the previously persisted value (backward compatible with
|
|
412
|
+
// snapshots written before parentID existed -> null). role mirrors it.
|
|
413
|
+
const parentID =
|
|
414
|
+
sessionParentCache.has(sessionID)
|
|
415
|
+
? sessionParentCache.get(sessionID) ?? null
|
|
416
|
+
: prevEntry?.parentID ?? null;
|
|
417
|
+
const entry = {
|
|
418
|
+
sessionID,
|
|
419
|
+
parentID,
|
|
420
|
+
role: parentID ? "sub" : "main",
|
|
421
|
+
agent: sessionAgentCache.get(sessionID) || prevEntry?.agent || null,
|
|
422
|
+
model: modelID || "unknown",
|
|
423
|
+
providerID: providerID || "",
|
|
424
|
+
ctx: total,
|
|
425
|
+
input: inputTokens || 0,
|
|
426
|
+
// Do NOT clobber a known denominator with null: multiple plugin instances
|
|
427
|
+
// share this file and one of them may lack modelLimits (its ctx.model.list
|
|
428
|
+
// returned nothing). Keep the previous model's known value when the live
|
|
429
|
+
// lookup came back empty, so a transient miss never becomes permanent.
|
|
430
|
+
usable:
|
|
431
|
+
denom != null
|
|
432
|
+
? denom
|
|
433
|
+
: sameModelAsPrev && finitePos(prevEntry.usable)
|
|
434
|
+
? prevEntry.usable
|
|
435
|
+
: null,
|
|
436
|
+
limit:
|
|
437
|
+
limit != null
|
|
438
|
+
? limit
|
|
439
|
+
: sameModelAsPrev && finitePos(prevEntry.limit)
|
|
440
|
+
? prevEntry.limit
|
|
441
|
+
: null,
|
|
442
|
+
reasoning: safeCount(reasoningTokens),
|
|
443
|
+
categories: {
|
|
444
|
+
user: catUser,
|
|
445
|
+
assistant: catAssistant,
|
|
446
|
+
reasoning: catReasoning,
|
|
447
|
+
toolArgs: catToolArgs,
|
|
448
|
+
system: catSystem,
|
|
449
|
+
toolSchemas: catToolSchemas,
|
|
450
|
+
other,
|
|
451
|
+
},
|
|
452
|
+
updatedAt: new Date().toISOString(),
|
|
453
|
+
};
|
|
454
|
+
all[sessionID] = entry;
|
|
455
|
+
// Bound the file: keep the most recently updated MAX_TRACKED_SESSIONS.
|
|
456
|
+
const keys = Object.keys(all);
|
|
457
|
+
if (keys.length > MAX_TRACKED_SESSIONS) {
|
|
458
|
+
keys
|
|
459
|
+
.sort((a, b) =>
|
|
460
|
+
String(all[b]?.updatedAt || "").localeCompare(
|
|
461
|
+
String(all[a]?.updatedAt || ""),
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
.slice(MAX_TRACKED_SESSIONS)
|
|
465
|
+
.forEach((k) => delete all[k]);
|
|
466
|
+
}
|
|
467
|
+
// Random temp suffix + exclusive create (CWE-377), then atomic rename.
|
|
468
|
+
const tmp = `${STATE_FILE}.${randomBytes(6).toString("hex")}.tmp`;
|
|
469
|
+
writeFileSync(tmp, JSON.stringify(all), { encoding: "utf8", flag: "wx" });
|
|
470
|
+
try {
|
|
471
|
+
ensureRegularFile(STATE_FILE); // never rename over a planted symlink
|
|
472
|
+
renameWithRetry(tmp, STATE_FILE);
|
|
473
|
+
} finally {
|
|
474
|
+
// A rename that exhausted its retries leaves the temp file behind; drop
|
|
475
|
+
// it so repeated failures cannot litter %TEMP% (best-effort: on a
|
|
476
|
+
// SUCCESSFUL rename the temp is already gone, existsSync filters that).
|
|
477
|
+
try {
|
|
478
|
+
if (existsSync(tmp)) unlinkSync(tmp);
|
|
479
|
+
} catch {
|
|
480
|
+
/* best-effort cleanup */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
} finally {
|
|
484
|
+
// Release ONLY a lock we actually own (lock === null => fail-open, skip).
|
|
485
|
+
if (lock && lock.ok) releaseStateLock(lock.token);
|
|
486
|
+
}
|
|
487
|
+
} catch (err) {
|
|
488
|
+
logErr("writeStateFile failed", err);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// DEBUG event tap (DEBUG_EVENTS only; fully fault-tolerant — any failure here
|
|
494
|
+
// is swallowed and must never affect the main path).
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
// Append one line to the event log, truncating it when it grows past ~1MB.
|
|
498
|
+
function appendEventLog(line) {
|
|
499
|
+
try {
|
|
500
|
+
// Refuse to follow a symlink planted at the log path before writing to it.
|
|
501
|
+
ensureRegularFile(EVENT_LOG_FILE);
|
|
502
|
+
try {
|
|
503
|
+
const st = statSync(EVENT_LOG_FILE);
|
|
504
|
+
if (st.size > EVENT_LOG_MAX_BYTES) {
|
|
505
|
+
// truncate, keep it bounded (explicit "w": create/replace a regular file)
|
|
506
|
+
writeFileSync(EVENT_LOG_FILE, "", { encoding: "utf8", flag: "w" });
|
|
507
|
+
}
|
|
508
|
+
} catch {
|
|
509
|
+
// file does not exist yet — nothing to truncate
|
|
510
|
+
}
|
|
511
|
+
appendFileSync(EVENT_LOG_FILE, line + "\n", "utf8");
|
|
512
|
+
} catch (err) {
|
|
513
|
+
logErr("appendEventLog failed", err);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Tap every incoming event: timestamp + type. session.error gets the full
|
|
518
|
+
// JSON payload, session.compacted gets the sessionID. Never throws.
|
|
519
|
+
function debugEvent(event) {
|
|
520
|
+
if (!DEBUG_EVENTS || !event) return;
|
|
521
|
+
try {
|
|
522
|
+
let line = `[${new Date().toISOString()}] ${event.type}`;
|
|
523
|
+
if (event.type === "session.error") {
|
|
524
|
+
try {
|
|
525
|
+
line += ` ${JSON.stringify(event.properties ?? event.data ?? {})}`.slice(
|
|
526
|
+
0,
|
|
527
|
+
4000,
|
|
528
|
+
);
|
|
529
|
+
} catch {
|
|
530
|
+
line += " <unserializable payload>";
|
|
531
|
+
}
|
|
532
|
+
} else if (event.type === "session.compacted") {
|
|
533
|
+
const sid =
|
|
534
|
+
event.properties?.sessionID ?? event.data?.sessionID ?? "?";
|
|
535
|
+
line += ` sessionID=${sid}`;
|
|
536
|
+
}
|
|
537
|
+
appendEventLog(line);
|
|
538
|
+
} catch (err) {
|
|
539
|
+
logErr("debugEvent failed", err);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Read the whole state.json snapshot ({ sessionID: entry }) or {} on any error.
|
|
544
|
+
// The file is UNTRUSTED (shared across processes / user-writable): rebuild it
|
|
545
|
+
// with a plain object, dropping prototype-pollution keys ("__proto__",
|
|
546
|
+
// "constructor", "prototype") so no downstream lookup can hit the prototype.
|
|
547
|
+
function readStateSnapshot() {
|
|
548
|
+
try {
|
|
549
|
+
const parsed = JSON.parse(readFileSync(STATE_FILE, "utf8"));
|
|
550
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
551
|
+
return {};
|
|
552
|
+
}
|
|
553
|
+
const out = {};
|
|
554
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
555
|
+
if (isSafeSessionKey(k)) out[k] = v;
|
|
556
|
+
}
|
|
557
|
+
return out;
|
|
558
|
+
} catch {
|
|
559
|
+
return {};
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Append a note to the persisted final-summaries section (claimed once across
|
|
564
|
+
// instances). Never throws.
|
|
565
|
+
function pushFinalSummary(claimKey, summaryLines) {
|
|
566
|
+
try {
|
|
567
|
+
if (!claimOnce(claimKey, DEDUP_DIR)) return;
|
|
568
|
+
finalSummaries.push(summaryLines.join("\n"));
|
|
569
|
+
if (finalSummaries.length > MAX_FINAL_SUMMARIES) {
|
|
570
|
+
finalSummaries.splice(0, finalSummaries.length - MAX_FINAL_SUMMARIES);
|
|
571
|
+
}
|
|
572
|
+
writeLog([]); // persist final summaries
|
|
573
|
+
} catch (err) {
|
|
574
|
+
logErr("pushFinalSummary failed", err);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
export {
|
|
579
|
+
logErr,
|
|
580
|
+
renderFile,
|
|
581
|
+
writeLog,
|
|
582
|
+
appendLogLine,
|
|
583
|
+
sleepSync,
|
|
584
|
+
ensureRegularFile,
|
|
585
|
+
isStaleLock,
|
|
586
|
+
acquireStateLock,
|
|
587
|
+
releaseStateLock,
|
|
588
|
+
renameWithRetry,
|
|
589
|
+
writeStateFile,
|
|
590
|
+
appendEventLog,
|
|
591
|
+
debugEvent,
|
|
592
|
+
readStateSnapshot,
|
|
593
|
+
finalSummaries,
|
|
594
|
+
pushFinalSummary,
|
|
595
|
+
};
|