feat: file-read decision gate — block reads when observation history exists
Add a PreToolUse gate that blocks file reads on first attempt when rich observation history exists, presenting the timeline as feedback. Claude then decides: use get_observations() (skip read, save tokens) or re-read (allowed on second attempt). - FileReadGate: in-memory session-scoped gate with 4h TTL - POST /api/file-context/gate endpoint in worker - stderrMessage plumbing in hook-command for exit code 2 - file-context handler uses gate to block/allow reads Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+805
-68128
File diff suppressed because one or more lines are too long
@@ -64,7 +64,7 @@ function formatFileTimeline(observations: ObservationRow[], filePath: string): s
|
|||||||
});
|
});
|
||||||
|
|
||||||
const lines: string[] = [
|
const lines: string[] = [
|
||||||
`Existing observations for this file \u2014 review via get_observations([IDs]) to avoid duplicates:`,
|
`Read blocked: This file has prior observations. Use get_observations([IDs]) to load what you need. Re-read the file only if you need raw content not captured in observations:`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const [day, dayObservations] of sortedDays) {
|
for (const [day, dayObservations] of sortedDays) {
|
||||||
@@ -128,15 +128,28 @@ export const fileContextHandler: EventHandler = {
|
|||||||
return { continue: true, suppressOutput: true };
|
return { continue: true, suppressOutput: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeline = formatFileTimeline(data.observations, filePath);
|
// Check the gate: has this file's timeline been shown in this session?
|
||||||
|
const gateResponse = await workerHttpRequest('/api/file-context/gate', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sessionId: input.sessionId, filePath: relativePath }),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
if (gateResponse.ok) {
|
||||||
hookSpecificOutput: {
|
const gateData = await gateResponse.json() as { firstAttempt: boolean };
|
||||||
hookEventName: 'PreToolUse',
|
|
||||||
permissionDecision: 'allow',
|
if (gateData.firstAttempt) {
|
||||||
additionalContext: timeline,
|
// BLOCK: Show timeline, Claude decides whether to re-read or use get_observations()
|
||||||
},
|
const timeline = formatFileTimeline(data.observations, filePath);
|
||||||
};
|
return {
|
||||||
|
exitCode: HOOK_EXIT_CODES.BLOCKING_ERROR,
|
||||||
|
stderrMessage: timeline,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ALLOW: Second attempt or gate check failed — let the read proceed silently
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn('HOOK', 'File context fetch error, skipping', {
|
logger.warn('HOOK', 'File context fetch error, skipping', {
|
||||||
error: error instanceof Error ? error.message : String(error),
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
|||||||
@@ -84,6 +84,14 @@ export async function hookCommand(platform: string, event: string, options: Hook
|
|||||||
|
|
||||||
console.log(JSON.stringify(output));
|
console.log(JSON.stringify(output));
|
||||||
const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS;
|
const exitCode = result.exitCode ?? HOOK_EXIT_CODES.SUCCESS;
|
||||||
|
|
||||||
|
// If handler wants to send a blocking message via stderr (exit code 2 contract),
|
||||||
|
// restore stderr and write the message before exiting
|
||||||
|
if (result.stderrMessage && exitCode === HOOK_EXIT_CODES.BLOCKING_ERROR) {
|
||||||
|
process.stderr.write = originalStderrWrite;
|
||||||
|
process.stderr.write(result.stderrMessage);
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.skipExit) {
|
if (!options.skipExit) {
|
||||||
process.exit(exitCode);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface HookResult {
|
|||||||
};
|
};
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
|
stderrMessage?: string; // Written to stderr before exit (for exit code 2 blocking feedback)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlatformAdapter {
|
export interface PlatformAdapter {
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* File Read Gate
|
||||||
|
*
|
||||||
|
* In-memory session-scoped gate tracking which files have had their timeline
|
||||||
|
* injected. Prevents duplicate timeline injections within the same session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface SessionEntry {
|
||||||
|
files: Set<string>;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
|
||||||
|
const sessionGates = new Map<string, SessionEntry>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily prune session entries older than the TTL.
|
||||||
|
*/
|
||||||
|
function pruneExpiredSessions(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [sessionId, entry] of sessionGates) {
|
||||||
|
if (now - entry.createdAt > TTL_MS) {
|
||||||
|
sessionGates.delete(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this is the first read of a file in a session, and mark it if so.
|
||||||
|
* Returns true if this is the first attempt (file was not previously seen).
|
||||||
|
* Returns false if the file was already seen in this session.
|
||||||
|
*/
|
||||||
|
export function checkAndMark(sessionId: string, filePath: string): boolean {
|
||||||
|
pruneExpiredSessions();
|
||||||
|
|
||||||
|
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
let entry = sessionGates.get(sessionId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = { files: new Set(), createdAt: Date.now() };
|
||||||
|
sessionGates.set(sessionId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.files.has(normalizedPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.files.add(normalizedPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all tracked files for a session (e.g., on session end).
|
||||||
|
*/
|
||||||
|
export function clearSession(sessionId: string): void {
|
||||||
|
sessionGates.delete(sessionId);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { SSEBroadcaster } from '../../SSEBroadcaster.js';
|
|||||||
import type { WorkerService } from '../../../worker-service.js';
|
import type { WorkerService } from '../../../worker-service.js';
|
||||||
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
import { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||||
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
|
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
|
||||||
|
import { checkAndMark } from '../../FileReadGate.js';
|
||||||
|
|
||||||
export class DataRoutes extends BaseRouteHandler {
|
export class DataRoutes extends BaseRouteHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -62,6 +63,9 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
|
|
||||||
// Import endpoint
|
// Import endpoint
|
||||||
app.post('/api/import', this.handleImport.bind(this));
|
app.post('/api/import', this.handleImport.bind(this));
|
||||||
|
|
||||||
|
// File-context gate
|
||||||
|
app.post('/api/file-context/gate', this.handleFileContextGate.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -496,4 +500,20 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
clearedCount
|
clearedCount
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a file has already had its timeline injected in this session
|
||||||
|
* POST /api/file-context/gate
|
||||||
|
* Body: { sessionId: string, filePath: string }
|
||||||
|
* Returns: { firstAttempt: boolean }
|
||||||
|
*/
|
||||||
|
private handleFileContextGate = this.wrapHandler((req: Request, res: Response): void => {
|
||||||
|
const { sessionId, filePath } = req.body;
|
||||||
|
if (!sessionId || !filePath) {
|
||||||
|
this.badRequest(res, 'sessionId and filePath are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const firstAttempt = checkAndMark(sessionId, filePath);
|
||||||
|
res.json({ firstAttempt });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user