feat: inject file observation timeline on PreToolUse Read hook

When Claude reads a file, the PreToolUse hook queries for existing
observations about that file and injects the timeline into context
via additionalContext + permissionDecision: allow. This prevents
duplicate observations and saves tokens through active rediscovery.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-03-18 15:18:54 -07:00
parent b34aff1aa2
commit fb9d917f8a
9 changed files with 430 additions and 194 deletions
+40
View File
@@ -4,6 +4,7 @@
*/
import { Database } from 'bun:sqlite';
import path from 'path';
import { logger } from '../../../utils/logger.js';
import type { ObservationRecord } from '../../../types/database.js';
import type { GetObservationsByIdsOptions, ObservationSessionRow } from './types.js';
@@ -111,3 +112,42 @@ export function getObservationsForSession(
return stmt.all(memorySessionId) as ObservationSessionRow[];
}
/**
* Get observations associated with a given file path.
* Searches both files_read and files_modified using basename matching
* to handle differing absolute paths across sessions.
*/
export function getObservationsByFilePath(
db: Database,
filePath: string,
options?: { project?: string; limit?: number }
): ObservationRecord[] {
const basename = path.basename(filePath);
const likePattern = `%${basename}%`;
const limit = options?.limit ?? 30;
const additionalConditions: string[] = [];
const params: any[] = [likePattern, likePattern];
if (options?.project) {
additionalConditions.push('AND project = ?');
params.push(options.project);
}
const additionalWhere = additionalConditions.join(' ');
const stmt = db.prepare(`
SELECT *
FROM observations
WHERE (
EXISTS (SELECT 1 FROM json_each(files_read) WHERE value LIKE ?)
OR EXISTS (SELECT 1 FROM json_each(files_modified) WHERE value LIKE ?)
)
${additionalWhere}
ORDER BY created_at_epoch DESC
LIMIT ${limit}
`);
return stmt.all(...params) as ObservationRecord[];
}
@@ -18,6 +18,7 @@ import { SessionManager } from '../../SessionManager.js';
import { SSEBroadcaster } from '../../SSEBroadcaster.js';
import type { WorkerService } from '../../../worker-service.js';
import { BaseRouteHandler } from '../BaseRouteHandler.js';
import { getObservationsByFilePath } from '../../../sqlite/observations/get.js';
export class DataRoutes extends BaseRouteHandler {
constructor(
@@ -39,6 +40,7 @@ export class DataRoutes extends BaseRouteHandler {
// Fetch by ID endpoints
app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
app.get('/api/observations/by-file', this.handleGetObservationsByFile.bind(this));
app.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this));
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
app.post('/api/sdk-sessions/batch', this.handleGetSdkSessionsByIds.bind(this));
@@ -108,6 +110,26 @@ export class DataRoutes extends BaseRouteHandler {
res.json(observation);
});
/**
* Get observations associated with a file path
* GET /api/observations/by-file?path=<file_path>&project=<project>&limit=30
*/
private handleGetObservationsByFile = this.wrapHandler((req: Request, res: Response): void => {
const filePath = req.query.path as string | undefined;
if (!filePath) {
this.badRequest(res, 'path query parameter is required');
return;
}
const project = req.query.project as string | undefined;
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
const db = this.dbManager.getSessionStore().db;
const observations = getObservationsByFilePath(db, filePath, { project, limit });
res.json({ observations, count: observations.length });
});
/**
* Get observations by array of IDs
* POST /api/observations/batch