From d3c213042f5183488f03225e0ab3aa34a4cbae1d Mon Sep 17 00:00:00 2001 From: ReidenXerx Date: Fri, 17 Jul 2026 22:47:29 +0300 Subject: [PATCH] Fix false "context full" nudge: widen the usage read, drop the byte-size fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task-core pressure hook fired "context nearly full" when it wasn't. Two causes: 1. Window mismatch — the shared default is 200k, but a 1M-context (opus[1m]) session at ~400k tokens is only 40% full, yet 400k/200k = 200% → false fire. Addressed separately by the per-machine gitnexus-hooks.local.json window override (PR #8). 2. The estimator's byte-size fallback (this fix). When the last 128 KB of the transcript held no parseable `usage` record — which happens EVERY time a tool returns >128 KB (a big read/grep/dump pushes the assistant usage out of the tail) — it fell back to fileSize/3.5. The transcript is an unbounded append-only log (273 MB in one real crypto session → 78M "tokens"), so that reads as "always full" and fired on every tool call (the hook even re-nudges each call while no task-core exists yet). estimateContextTokens now WIDENS the tail read (128 KB → 2 MB → 8 MB) until it finds the real usage record, and returns 0 (unknown → treated as "not full") instead of ever guessing from byte size. Verified against the real 273 MB transcript (412k tokens, not 78M) + synthetic 3 MB / 10 MB trailing-line cases. 62/62 tests. Co-Authored-By: Claude Opus 4.8 --- bundle/.gnkit/lib/context-pressure.mjs | 57 ++++++++++++++++++-------- lib/kit.test.mjs | 21 +++++++++- 2 files changed, 59 insertions(+), 19 deletions(-) diff --git a/bundle/.gnkit/lib/context-pressure.mjs b/bundle/.gnkit/lib/context-pressure.mjs index ccd352c..813d794 100644 --- a/bundle/.gnkit/lib/context-pressure.mjs +++ b/bundle/.gnkit/lib/context-pressure.mjs @@ -14,10 +14,19 @@ */ import fs from "node:fs"; -const TAIL_BYTES = 131072; // 128 KB tail — enough to hold the last usage record +// Widen the tail read until a usage record appears. A single huge tool-result line (a big file +// read / grep / command dump can be MBs) sits at the very end at PostToolUse time and pushes the +// preceding assistant usage out of a small tail — so 128 KB alone often misses it. Cap the widen +// so the hook stays cheap; past the cap we report "unknown" rather than guessing. +const TAIL_STEPS = [131072, 2097152, 8388608]; // 128 KB → 2 MB → 8 MB /** * Estimate the current context size in tokens from a Claude Code transcript (JSONL). + * The signal is the LAST assistant message's usage (non-cached input + cache read + cache creation + * = everything sent to the model). We deliberately DO NOT fall back to a byte-count of the file: + * the transcript is an unbounded append-only log (it keeps already-compacted turns), so its size + * has no relation to current window occupancy — a byte estimate reads as "always full" and would + * fire the compaction nudge spuriously. Unknown → 0, which the caller treats as "not full". * @param {string} transcriptPath * @returns {number} estimated context tokens (0 if unknown/unreadable) */ @@ -30,23 +39,38 @@ export function estimateContextTokens(transcriptPath) { } if (!size) return 0; - let text = ""; - try { - const readBytes = Math.min(size, TAIL_BYTES); - const fd = fs.openSync(transcriptPath, "r"); + let prevRead = 0; + for (const step of TAIL_STEPS) { + const readBytes = Math.min(size, step); + if (readBytes <= prevRead) break; // whole file already scanned + let text; try { - const buf = Buffer.alloc(readBytes); - fs.readSync(fd, buf, 0, readBytes, size - readBytes); - text = buf.toString("utf8"); - } finally { - fs.closeSync(fd); + const fd = fs.openSync(transcriptPath, "r"); + try { + const buf = Buffer.alloc(readBytes); + fs.readSync(fd, buf, 0, readBytes, size - readBytes); + text = buf.toString("utf8"); + } finally { + fs.closeSync(fd); + } + } catch { + return 0; // unreadable → unknown, never nudge on a guess } - } catch { - return Math.round(size / 3.5); // couldn't read tail → byte estimate + const tokens = lastUsageTokens(text); + if (tokens != null) return tokens; + if (readBytes >= size) break; // scanned the entire file, no usage present + prevRead = readBytes; } + return 0; // no usage record found → unknown (not "full") +} - // Accurate: the LAST assistant message's usage is the prompt size in the window - // (non-cached input + cache read + cache creation = everything sent to the model). +/** + * Sum the LAST assistant-message usage in a JSONL chunk, scanning from the end. A leading partial + * line (the tail cut mid-record) simply fails to parse and is skipped. + * @param {string} text + * @returns {number | null} token total, or null if no usage record present + */ +function lastUsageTokens(text) { const lines = text.split("\n"); for (let i = lines.length - 1; i >= 0; i--) { const line = lines[i].trim(); @@ -55,7 +79,7 @@ export function estimateContextTokens(transcriptPath) { try { obj = JSON.parse(line); } catch { - continue; // partial line (tail cut mid-record) or non-JSON — skip + continue; // partial line or non-JSON — skip } const u = obj?.message?.usage || obj?.usage; if (u && typeof u.input_tokens === "number") { @@ -66,8 +90,7 @@ export function estimateContextTokens(transcriptPath) { ); } } - // No usage record in the tail → rough byte estimate (JSONL overhead ~3.5 bytes/token). - return Math.round(size / 3.5); + return null; } /** diff --git a/lib/kit.test.mjs b/lib/kit.test.mjs index 2ba71a2..589f0da 100644 --- a/lib/kit.test.mjs +++ b/lib/kit.test.mjs @@ -1927,11 +1927,28 @@ describe("gitnexus-agent-kit", () => { cp.contextPressure(transcript, { contextWindowTokens: 1000000, contextPressureThreshold: 0.9 }).over, false, ); - // No usage record → byte-size fallback; missing file → 0. + // No usage record anywhere → 0, NOT a byte-count of the file. The transcript is an unbounded + // append-only log (keeps already-compacted turns), so size ≠ window occupancy; a byte guess + // reads as "always full" and would fire the compaction nudge spuriously. fs.writeFileSync(transcript, "x".repeat(35000)); - assert.equal(cp.estimateContextTokens(transcript), 10000); + assert.equal(cp.estimateContextTokens(transcript), 0); assert.equal(cp.estimateContextTokens(path.join(tmp, "nope")), 0); + // A huge trailing tool-result line (a big read/grep/dump) sits at the tail at PostToolUse time + // and pushes the preceding usage past the base 128 KB read — the estimator must WIDEN to find + // it, not fall back to the file's byte size (~857k here). + const bigUsage = + '{"type":"assistant","message":{"usage":{"input_tokens":50000,"cache_read_input_tokens":300000,"cache_creation_input_tokens":10000}}}\n'; + fs.writeFileSync( + transcript, + bigUsage + JSON.stringify({ type: "user", message: { content: "z".repeat(3_000_000) } }) + "\n", + ); + assert.equal( + cp.estimateContextTokens(transcript), + 360000, + "widen past a 3MB trailing line to the real usage record", + ); + // Task-core survives clearSessionState (a task can span sessions); the nudge flag re-arms. fs.mkdirSync(path.join(tmp, ".gnkit"), { recursive: true }); fs.writeFileSync(sp.taskCorePath(tmp), "# TASK-CORE\nGOAL: x\n");