diff --git a/src/core/history.ts b/src/core/history.ts index 09eacb6..8eb58ff 100644 --- a/src/core/history.ts +++ b/src/core/history.ts @@ -160,6 +160,28 @@ export function findClosedPrefixBoundary( return lastClosed; } +/** + * Claude Code appends "(file state is current in your context — no need to Read it + * back)" to Edit/Write tool_results. True when emitted; stale by the time the turn + * reaches this serializer: everything blocksToText feeds becomes collapsed/imaged + * HISTORY, the CLI's read-ledger resets on process restart, and the file may have + * changed in later turns anyway. Models trusting the hint from prior turns were the + * dominant cause of `File has not been read yet` gate errors (2026-07-03 audit, + * n=55 classified: 20 had a same-transcript Read invalidated by a restart while + * this hint said "current"; 34 edited from prior-session context with no Read at + * all). Rewriting at serialization time also cleans slabs inherited by future + * continuation sessions. Whitespace-tolerant match: 3 of ~2,125 logged instances + * wrap mid-hint. + */ +const FRESHNESS_HINT_RE = + /\(file state is current in your\s+context — no need to Read it back\)/g; +const STALE_FRESHNESS_NOTE = + '(state as of this PRIOR turn — the file may have changed since; Read it again before editing)'; + +export function staleFreshnessHints(text: string): string { + return text.replace(FRESHNESS_HINT_RE, STALE_FRESHNESS_NOTE); +} + /** * Linearise content blocks to a single string. Drops thinking blocks (only the * most-recent assistant turn needs bit-perfect thinking, and it's in the live tail). @@ -208,7 +230,7 @@ export function blocksToText(content: string | ContentBlock[]): string { innerText = ''; } const errMark = tr.is_error === true ? ' (error)' : ''; - parts.push(`[tool_result${errMark}]\n${innerText}`); + parts.push(`[tool_result${errMark}]\n${staleFreshnessHints(innerText)}`); break; } case 'image': diff --git a/src/core/tracker.ts b/src/core/tracker.ts index 745349e..803e552 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -65,6 +65,8 @@ export interface TrackEvent { passthrough_reasons?: { below_threshold?: number; not_profitable?: number }; /** Unrecognized tag names in the static slab — canary for Claude Code releases adding new dynamic tags. */ unknown_static_tags?: string[]; + /** Slab tags whose content changed within a session — proven per-turn dynamics busting the image cache. */ + churning_static_tags?: string[]; /** Per-bucket TEXT chars through each gate call site (static_slab, reminder, tool_result_*, history). * Undefined on uncompressed requests; enables per-bucket cpt regression. */ bucket_chars?: Partial 0) out.unknown_static_tags = info.unknownStaticTags; + if (info.churningStaticTags && info.churningStaticTags.length > 0) + out.churning_static_tags = info.churningStaticTags; if (info.systemSha8) out.system_sha8 = info.systemSha8; if (info.claudeMdSha8) out.claude_md_sha8 = info.claudeMdSha8; if (info.firstUserSha8) out.first_user_sha8 = info.firstUserSha8; diff --git a/src/core/transform.ts b/src/core/transform.ts index 246d473..a2aecc8 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -166,6 +166,10 @@ const CHARS_PER_TOKEN = 4; * Slab-specific because reminders/tool_results have unknown shape; those stay at 4. */ export const SLAB_CHARS_PER_TOKEN = 2.0; +// Tools whose stub description keeps a live-text read-before-edit precondition +// when full docs move into the imaged Tool Reference (read-gate audit, 2026-07-03). +const READ_FIRST_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']); + /** Empirical cpt for the history-collapse path (same Opus 4.7 telemetry as SLAB_CHARS_PER_TOKEN). * History is even denser (tool_use JSON dominates), so 2.0 is doubly conservative. */ export const HISTORY_CHARS_PER_TOKEN = 2.0; @@ -518,6 +522,9 @@ export interface TransformInfo { /** Tag-shaped blocks in the static slab not in DYNAMIC_BLOCK_TAGS. * Canary: a new per-turn Claude Code tag would appear here before cache rate collapses. */ unknownStaticTags?: string[]; + /** Static-slab tags whose content changed within a session — proven dynamic, + * busting the image cache each turn. The real alert signal. */ + churningStaticTags?: string[]; env?: EnvFields; /** sha8 of static slab + tool docs (what goes in the image). Repeats across turns → cache hits. */ systemSha8?: string; @@ -650,19 +657,45 @@ const DYNAMIC_BLOCK_TAGS = [ 'system-reminder', ] as const; -// Known-static tags in the slab (part of Claude Code's built-in prompt, not per-turn). -// Listed here so the canary in splitStaticDynamic doesn't false-fire on them. -// Add a tag only after confirming it doesn't rotate per turn. -const KNOWN_STATIC_TAGS = ['types'] as const; +// Known-static slab tags — suppresses first-sighting `unknownStaticTags` noise +// only. Correctness doesn't depend on this list: observeStaticTagChurn catches +// a wrong entry on its second sighting. +const KNOWN_STATIC_TAGS = [ + // Claude Code + 'types', + // opencode (codex system prompts have no tag-shaped blocks) + 'example', + 'available_skills', + // beast.txt + title.txt + 'examples', + 'rules', + 'task', + // copilot-gpt-5.txt + 'codeSearchInstructions', + 'codeSearchToolUseInstructions', + 'communicationGuidelines', + 'gptAgentInstructions', + 'outputFormatting', + 'structuredWorkflow', + 'toolUseInstructions', +] as const; function splitStaticDynamic(text: string): { staticText: string; dynamicText: string; blockCount: number; unknownTags: string[]; + /** tag → concatenated inner content of same-named slab blocks. */ + staticTagContents: Map; } { if (!text) - return { staticText: '', dynamicText: '', blockCount: 0, unknownTags: [] }; + return { + staticText: '', + dynamicText: '', + blockCount: 0, + unknownTags: [], + staticTagContents: new Map(), + }; const pattern = new RegExp( `<(${DYNAMIC_BLOCK_TAGS.join('|')})(\\s[^>]*)?>[\\s\\S]*?`, 'g', @@ -683,13 +716,16 @@ function splitStaticDynamic(text: string): { // surfacing the tag name lets us detect it within hours of a release. const known = new Set(DYNAMIC_BLOCK_TAGS); const knownStatic = new Set(KNOWN_STATIC_TAGS); - const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g; + const sniffer = /<([a-zA-Z][a-zA-Z0-9_-]*)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g; const unknown = new Set(); + const staticTagContents = new Map(); let s: RegExpExecArray | null; while ((s = sniffer.exec(staticBuf)) !== null) { const tag = s[1]!; - if (!known.has(tag) && !knownStatic.has(tag) && tag.length <= 64) - unknown.add(tag); + if (tag.length > 64) continue; + if (!known.has(tag) && !knownStatic.has(tag)) unknown.add(tag); + // Fold repeated tags (e.g. several s) into one fingerprint. + staticTagContents.set(tag, (staticTagContents.get(tag) ?? '') + s[2]!); } return { @@ -698,9 +734,49 @@ function splitStaticDynamic(text: string): { dynamicText: dynamicParts.join('\n\n'), blockCount: dynamicParts.length, unknownTags: [...unknown], + staticTagContents, }; } +/** FNV-1a 32-bit — cheap synchronous content fingerprint for churn detection. */ +function fnv1a(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +// Last content hash per (session, tag). Bounded LRU. +const TAG_OBSERVATIONS_MAX = 4096; +const tagObservations = new Map(); + +/** Returns slab tags whose content changed since the last sighting in the same + * session — proven per-turn dynamics, whatever the hardcoded lists say. */ +function observeStaticTagChurn( + sessionKey: string, + tagContents: ReadonlyMap, +): string[] { + const churned: string[] = []; + for (const [tag, inner] of tagContents) { + const key = `${sessionKey}\0${tag}`; + const hash = fnv1a(inner); + const prev = tagObservations.get(key); + if (prev !== undefined) { + if (prev !== hash) churned.push(tag); + tagObservations.delete(key); // refresh LRU position + } + tagObservations.set(key, hash); + } + while (tagObservations.size > TAG_OBSERVATIONS_MAX) { + const oldest = tagObservations.keys().next().value; + if (oldest === undefined) break; + tagObservations.delete(oldest); + } + return churned; +} + /** sha256[0..8] hex via Web Crypto (works in Node 18+ and Workers). 32-bit collision-safe. */ export async function sha8(text: string): Promise { const buf = new TextEncoder().encode(text); @@ -1421,6 +1497,7 @@ export async function transformRequest( dynamicText, blockCount: dynBlocks, unknownTags, + staticTagContents, } = splitStaticDynamic(sysBody); info.staticChars = staticText.length; info.dynamicChars = dynamicText.length + envMarkdown.length; @@ -1442,6 +1519,16 @@ export async function transformRequest( if (claudeMdSha) info.claudeMdSha8 = claudeMdSha; if (firstUserSha) info.firstUserSha8 = firstUserSha; + // Canary: slab tags whose content churns within a session bust the image + // cache every turn — report them regardless of the hardcoded lists. + if (staticTagContents.size > 0) { + const churning = observeStaticTagChurn( + firstUserSha ?? claudeMdSha ?? 'global', + staticTagContents, + ); + if (churning.length > 0) info.churningStaticTags = churning; + } + // 2. Move tool docs into the imaged "Tool Reference", stubbing originals. // Imaged (not text) because that IS the compression — descriptions and // schema annotations ride at image token rates, mirroring the GPT path. @@ -1470,6 +1557,17 @@ export async function transformRequest( schema = stripped; } } + // Read-before-Edit precondition rides as LIVE TEXT, not imaged: the CLI + // rejects Edit/Write on any existing file not Read in THIS session's + // process, and the rule lost salience once full tool docs moved into the + // imaged reference (read-gate audit, 2026-07-03). Three tools only, no + // banned wording — stays clear of the per-tool-stub repetition pattern + // that tripped reasoning_extraction (see wording note below). + const readFirstNote = READ_FIRST_TOOLS.has(t.name ?? '') + ? ' Requires a Read of the same file earlier in THIS session when the file' + + ' already exists — the call is rejected otherwise; file content recalled' + + ' from imaged or prior-session context does not satisfy this.' + : ''; return { ...t, // Wording note (do NOT reintroduce "system prompt"/"authoritative" — same ban @@ -1478,7 +1576,7 @@ export async function transformRequest( // reasoning_extraction refusal (stop_reason: "refusal" → Claude Code fell back // to claude-opus-4-8 immediately on cold start, 2026-07-02). The reference // block's own header says where the docs live; the stub only needs the heading. - description: `ⓘ Full docs: see "## Tool: ${t.name ?? '?'}" in the Tool Reference section.`, + description: `ⓘ Full docs: see "## Tool: ${t.name ?? '?'}" in the Tool Reference section.${readFirstNote}`, ...(schema !== undefined ? { input_schema: schema } : {}), }; }); diff --git a/tests/history.test.ts b/tests/history.test.ts index fffba11..b982bea 100644 --- a/tests/history.test.ts +++ b/tests/history.test.ts @@ -19,6 +19,7 @@ import { describe, expect, it } from 'vitest'; import { findClosedPrefixBoundary, blocksToText, + staleFreshnessHints, messagesToHistoryText, collapseHistory, HISTORY_DEFAULTS, @@ -206,6 +207,48 @@ describe('blocksToText', () => { }); }); +describe('staleFreshnessHints (read-gate audit, 2026-07-03)', () => { + const HINT = '(file state is current in your context — no need to Read it back)'; + const STALE = + '(state as of this PRIOR turn — the file may have changed since; Read it again before editing)'; + + it('rewrites the canonical Claude Code freshness hint', () => { + const input = `The file /tmp/x.ts has been updated successfully. ${HINT}`; + expect(staleFreshnessHints(input)).toBe( + `The file /tmp/x.ts has been updated successfully. ${STALE}`, + ); + }); + + it('rewrites the line-wrapped variant (3 of ~2,125 logged instances)', () => { + const wrapped = + '(file state is current in your\n context — no need to Read it back)'; + expect(staleFreshnessHints(`ok. ${wrapped}`)).toBe(`ok. ${STALE}`); + }); + + it('rewrites every occurrence, not just the first', () => { + const out = staleFreshnessHints(`${HINT} middle ${HINT}`); + expect(out).toBe(`${STALE} middle ${STALE}`); + expect(out).not.toContain('no need to Read it back'); + }); + + it('leaves unrelated text untouched', () => { + const s = 'Edit rejected: File has not been read yet. Read it first.'; + expect(staleFreshnessHints(s)).toBe(s); + }); + + it('applies inside blocksToText tool_result serialisation', () => { + const out = blocksToText([ + { + type: 'tool_result', + tool_use_id: 'tx1', + content: `The file /a/b.ts has been updated successfully. ${HINT}`, + }, + ]); + expect(out).toContain(STALE); + expect(out).not.toContain('no need to Read it back'); + }); +}); + describe('messagesToHistoryText', () => { it('wraps each turn in XML tags and joins with blank line', () => { const msgs: Message[] = [usr('hi'), asst('hello')];