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:
@@ -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 { BaseRouteHandler } from '../BaseRouteHandler.js';
|
||||
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
|
||||
import { checkAndMark } from '../../FileReadGate.js';
|
||||
|
||||
export class DataRoutes extends BaseRouteHandler {
|
||||
constructor(
|
||||
@@ -62,6 +63,9 @@ export class DataRoutes extends BaseRouteHandler {
|
||||
|
||||
// Import endpoint
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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