diff --git a/docs/gateguard/design-notes.md b/docs/gateguard/design-notes.md index 3e0e14124..5809dc9aa 100644 --- a/docs/gateguard/design-notes.md +++ b/docs/gateguard/design-notes.md @@ -58,6 +58,8 @@ and trivial edits) is in [change-profile.md](change-profile.md). detection and sibling-collapse eligibility. Stateless; filesystem access is limited to worktree `.git` checks, realpath/lstat of the target's parent chain, and lstat/stat of the target for its link count. +- `scripts/lib/file-tail.js`: the bounded tail read shared with the + strategic-compact hook's `transcript-context.js`. - `scripts/lib/gateguard-turn-scan.js`: one bounded tail read of the Claude Code JSONL transcript, walked back to the start of the current turn. Yields the turn id, the turn's completed non-error search calls (newest first), their diff --git a/scripts/lib/file-tail.js b/scripts/lib/file-tail.js new file mode 100644 index 000000000..807a1a937 --- /dev/null +++ b/scripts/lib/file-tail.js @@ -0,0 +1,41 @@ +'use strict'; + +const fs = require('fs'); + +const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024; + +/** Last `tailBytes` of a file as UTF-8 text, with whether earlier bytes were skipped; null when unreadable. */ +function readFileTail(filePath, tailBytes) { + let fd; + try { + fd = fs.openSync(filePath, 'r'); + } catch { + return null; + } + + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - tailBytes); + const length = size - start; + if (length <= 0) { + return { text: '', truncated: false }; + } + + const buffer = Buffer.alloc(length); + const bytesRead = fs.readSync(fd, buffer, 0, length, start); + return { + text: buffer.toString('utf8', 0, bytesRead), + truncated: start > 0 + }; + } catch { + return null; + } finally { + try { + fs.closeSync(fd); + } catch { + /* ignore */ + } + } +} + +module.exports = { DEFAULT_TRANSCRIPT_TAIL_BYTES, readFileTail }; diff --git a/scripts/lib/gateguard-turn-scan.js b/scripts/lib/gateguard-turn-scan.js index 687623763..26cf20041 100644 --- a/scripts/lib/gateguard-turn-scan.js +++ b/scripts/lib/gateguard-turn-scan.js @@ -3,7 +3,7 @@ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); -const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./transcript-context'); +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail'); const TRANSCRIPT_SCAN_MAX_LINES = 2000; // see docs/gateguard/design-notes.md#read-is-not-evidence diff --git a/scripts/lib/transcript-context.js b/scripts/lib/transcript-context.js index b6665a986..87c6bb2d4 100644 --- a/scripts/lib/transcript-context.js +++ b/scripts/lib/transcript-context.js @@ -17,14 +17,13 @@ * keeping the PreToolUse hook fast even for very large sessions. */ -const fs = require('fs'); +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require('./file-tail'); const STANDARD_CONTEXT_WINDOW_TOKENS = 200000; const LARGE_CONTEXT_WINDOW_TOKENS = 1000000; const DEFAULT_CONTEXT_THRESHOLD_STANDARD = 160000; const DEFAULT_CONTEXT_THRESHOLD_LARGE = 250000; const DEFAULT_CONTEXT_INTERVAL_TOKENS = 60000; -const DEFAULT_TRANSCRIPT_TAIL_BYTES = 256 * 1024; const MAX_TOKEN_SETTING = 10000000; const LARGE_WINDOW_MODEL_MARKER = '[1m]'; @@ -59,38 +58,6 @@ function isKnownModelFamilyMatch(model, familyId) { * Read the trailing `tailBytes` of a file as UTF-8. * Returns null when the file is missing or unreadable. */ -function readFileTail(filePath, tailBytes) { - let fd; - try { - fd = fs.openSync(filePath, 'r'); - } catch { - return null; - } - - try { - const size = fs.fstatSync(fd).size; - const start = Math.max(0, size - tailBytes); - const length = size - start; - if (length <= 0) { - return { text: '', truncated: false }; - } - - const buffer = Buffer.alloc(length); - const bytesRead = fs.readSync(fd, buffer, 0, length, start); - return { - text: buffer.toString('utf8', 0, bytesRead), - truncated: start > 0 - }; - } catch { - return null; - } finally { - try { - fs.closeSync(fd); - } catch { - /* ignore */ - } - } -} /** * Extract the context token total from a transcript record's usage block. diff --git a/tests/hooks/gateguard-fact-force.test.js b/tests/hooks/gateguard-fact-force.test.js index b77ca8dcf..e0b794300 100644 --- a/tests/hooks/gateguard-fact-force.test.js +++ b/tests/hooks/gateguard-fact-force.test.js @@ -7394,7 +7394,7 @@ function runTests() { 'const hook = require(process.argv[1]);', 'hook.run(process.argv[2]);', "const names = Object.keys(require.cache).map(f => require('path').basename(f, '.js'));", - "const libs = names.filter(n => /^(gateguard-|transcript-context$)/.test(n) && !/^gateguard-(fact-force|heredoc)$/.test(n));", + "const libs = names.filter(n => /^(gateguard-|transcript-context$|file-tail$)/.test(n) && !/^gateguard-(fact-force|heredoc)$/.test(n));", 'process.stdout.write(JSON.stringify(libs.sort()));' ].join('\n'); try { @@ -7411,7 +7411,7 @@ function runTests() { ['a first shell command loads only the read-only check', lazyBash('npm test'), [], ['gateguard-readonly-shell', 'gateguard-state']], ['a shell command after the routine gate loads only the state helpers', lazyBash('npm run build'), [lazyBash('npm test')], ['gateguard-state']], ['an edit of a checked file loads only the target classification', lazyEdit, [lazyEdit], ['gateguard-state', 'gateguard-target-class']], - ['a first touch without a transcript loads no search matching', lazyEdit, [], ['gateguard-change-profile', 'gateguard-code-lexer', 'gateguard-file-context', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan', 'transcript-context']] + ['a first touch without a transcript loads no search matching', lazyEdit, [], ['file-tail', 'gateguard-change-profile', 'gateguard-code-lexer', 'gateguard-file-context', 'gateguard-state', 'gateguard-target-class', 'gateguard-turn-scan']] ]; for (const [label, payload, setup, expected] of lazyCases) { if (test(`lazy loading: ${label}`, () => assert.deepStrictEqual(loadedLibs(payload, setup), expected))) passed++; diff --git a/tests/lib/file-tail.test.js b/tests/lib/file-tail.test.js new file mode 100644 index 000000000..c5aa68a7c --- /dev/null +++ b/tests/lib/file-tail.test.js @@ -0,0 +1,65 @@ +'use strict'; +/** + * Tests for scripts/lib/file-tail.js. + * + * Run with: node tests/lib/file-tail.test.js + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { readFileTail, DEFAULT_TRANSCRIPT_TAIL_BYTES } = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'file-tail.js')); + +console.log('=== Testing file-tail.js ===\n'); + +let passed = 0; +let failed = 0; + +function test(desc, fn) { + try { + fn(); + console.log(` ✓ ${desc}`); + passed++; + } catch (e) { + console.log(` ✗ ${desc}: ${e.message}`); + failed++; + } +} + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'file-tail-')); +const file = path.join(dir, 'f.txt'); +fs.writeFileSync(file, 'abcdef'); + +test('the default tail is 256 KiB', () => { + assert.strictEqual(DEFAULT_TRANSCRIPT_TAIL_BYTES, 256 * 1024); +}); + +test('a file smaller than the tail is read whole', () => { + assert.deepStrictEqual(readFileTail(file, 1024), { text: 'abcdef', truncated: false }); +}); + +test('a larger file is cut to its last bytes and marked truncated', () => { + assert.deepStrictEqual(readFileTail(file, 2), { text: 'ef', truncated: true }); +}); + +test('an empty file gives empty text', () => { + const empty = path.join(dir, 'empty.txt'); + fs.writeFileSync(empty, ''); + assert.deepStrictEqual(readFileTail(empty, 10), { text: '', truncated: false }); +}); + +test('a missing file gives null', () => { + assert.strictEqual(readFileTail(path.join(dir, 'missing.txt'), 10), null); +}); + +test('transcript-context still exports the same reader', () => { + const context = require(path.join(__dirname, '..', '..', 'scripts', 'lib', 'transcript-context.js')); + assert.strictEqual(context.readFileTail, readFileTail); + assert.strictEqual(context.DEFAULT_TRANSCRIPT_TAIL_BYTES, DEFAULT_TRANSCRIPT_TAIL_BYTES); +}); + +fs.rmSync(dir, { recursive: true, force: true }); +console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); +process.exit(failed > 0 ? 1 : 0);