Merge branch 'thedotmack/file-read-timeline-inject' into integration/validation-batch
This commit is contained in:
@@ -58,6 +58,18 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"PreToolUse": [
|
||||||
|
{
|
||||||
|
"matcher": "Read",
|
||||||
|
"hooks": [
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "_R=\"${CLAUDE_PLUGIN_ROOT}\"; [ -z \"$_R\" ] && _R=\"$HOME/.claude/plugins/marketplaces/thedotmack/plugin\"; node \"$_R/scripts/bun-runner.js\" \"$_R/scripts/worker-service.cjs\" hook claude-code file-context",
|
||||||
|
"timeout": 2000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
"Stop": [
|
"Stop": [
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
|
|||||||
+54
-1880
File diff suppressed because one or more lines are too long
+389
-433
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* File Context Handler - PreToolUse
|
||||||
|
*
|
||||||
|
* Injects relevant observation history when Claude reads/edits a file,
|
||||||
|
* so it can avoid duplicating past work.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
||||||
|
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
||||||
|
import { logger } from '../../utils/logger.js';
|
||||||
|
import { parseJsonArray } from '../../shared/timeline-formatting.js';
|
||||||
|
import { statSync } from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { isProjectExcluded } from '../../utils/project-filter.js';
|
||||||
|
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
||||||
|
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
||||||
|
import { getProjectContext } from '../../utils/project-name.js';
|
||||||
|
|
||||||
|
/** Skip the gate for files smaller than this — timeline overhead exceeds file read cost. */
|
||||||
|
const FILE_READ_GATE_MIN_BYTES = 1_500;
|
||||||
|
|
||||||
|
/** Fetch more candidates than the display limit so dedup still fills 15 slots. */
|
||||||
|
const FETCH_LOOKAHEAD_LIMIT = 40;
|
||||||
|
|
||||||
|
/** Maximum observations to show in the timeline. */
|
||||||
|
const DISPLAY_LIMIT = 15;
|
||||||
|
|
||||||
|
const TYPE_ICONS: Record<string, string> = {
|
||||||
|
decision: '\u2696\uFE0F',
|
||||||
|
bugfix: '\uD83D\uDD34',
|
||||||
|
feature: '\uD83D\uDFE3',
|
||||||
|
refactor: '\uD83D\uDD04',
|
||||||
|
discovery: '\uD83D\uDD35',
|
||||||
|
change: '\u2705',
|
||||||
|
};
|
||||||
|
|
||||||
|
function compactTime(timeStr: string): string {
|
||||||
|
return timeStr.toLowerCase().replace(' am', 'a').replace(' pm', 'p');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(epoch: number): string {
|
||||||
|
const date = new Date(epoch);
|
||||||
|
return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(epoch: number): string {
|
||||||
|
const date = new Date(epoch);
|
||||||
|
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ObservationRow {
|
||||||
|
id: number;
|
||||||
|
memory_session_id: string;
|
||||||
|
title: string | null;
|
||||||
|
type: string;
|
||||||
|
created_at_epoch: number;
|
||||||
|
files_read: string | null;
|
||||||
|
files_modified: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicate and rank observations for the timeline display.
|
||||||
|
*
|
||||||
|
* 1. Same-session dedup: keep only the most recent observation per session
|
||||||
|
* (input is already sorted newest-first by SQL).
|
||||||
|
* 2. Specificity scoring: rank by how specifically the observation is about
|
||||||
|
* the target file (modified > read-only, fewer total files > many).
|
||||||
|
* 3. Truncate to displayLimit.
|
||||||
|
*/
|
||||||
|
function deduplicateObservations(
|
||||||
|
observations: ObservationRow[],
|
||||||
|
targetPath: string,
|
||||||
|
displayLimit: number
|
||||||
|
): ObservationRow[] {
|
||||||
|
// Phase 1: Keep only the most recent observation per session
|
||||||
|
const seenSessions = new Set<string>();
|
||||||
|
const dedupedBySession: ObservationRow[] = [];
|
||||||
|
for (const obs of observations) {
|
||||||
|
const sessionKey = obs.memory_session_id ?? `no-session-${obs.id}`;
|
||||||
|
if (!seenSessions.has(sessionKey)) {
|
||||||
|
seenSessions.add(sessionKey);
|
||||||
|
dedupedBySession.push(obs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Score by specificity to the target file
|
||||||
|
const scored = dedupedBySession.map(obs => {
|
||||||
|
const filesRead = parseJsonArray(obs.files_read);
|
||||||
|
const filesModified = parseJsonArray(obs.files_modified);
|
||||||
|
const totalFiles = filesRead.length + filesModified.length;
|
||||||
|
const normalizedTarget = targetPath.replace(/\\/g, '/');
|
||||||
|
const inModified = filesModified.some(f => f.replace(/\\/g, '/') === normalizedTarget);
|
||||||
|
|
||||||
|
let specificityScore = 0;
|
||||||
|
if (inModified) specificityScore += 2;
|
||||||
|
if (totalFiles <= 3) specificityScore += 2;
|
||||||
|
else if (totalFiles <= 8) specificityScore += 1;
|
||||||
|
// totalFiles > 8: no bonus (survey-like observation)
|
||||||
|
|
||||||
|
return { obs, specificityScore };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stable sort: higher specificity first, preserve chronological order within same score
|
||||||
|
scored.sort((a, b) => b.specificityScore - a.specificityScore);
|
||||||
|
|
||||||
|
return scored.slice(0, displayLimit).map(s => s.obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileTimeline(observations: ObservationRow[], filePath: string): string {
|
||||||
|
// Escape filePath for safe interpolation into recovery hints (quotes, backslashes, newlines)
|
||||||
|
const safePath = filePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
||||||
|
// Group observations by day
|
||||||
|
const byDay = new Map<string, ObservationRow[]>();
|
||||||
|
for (const obs of observations) {
|
||||||
|
const day = formatDate(obs.created_at_epoch);
|
||||||
|
if (!byDay.has(day)) {
|
||||||
|
byDay.set(day, []);
|
||||||
|
}
|
||||||
|
byDay.get(day)!.push(obs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort days chronologically (use earliest observation in each group, not first — which is specificity-sorted)
|
||||||
|
const sortedDays = Array.from(byDay.entries()).sort((a, b) => {
|
||||||
|
const aEpoch = Math.min(...a[1].map(o => o.created_at_epoch));
|
||||||
|
const bEpoch = Math.min(...b[1].map(o => o.created_at_epoch));
|
||||||
|
return aEpoch - bEpoch;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`Read blocked: This file has prior observations. Choose the cheapest path:`,
|
||||||
|
`- **Already know enough?** The timeline below may be all you need (semantic priming).`,
|
||||||
|
`- **Need details?** get_observations([IDs]) — ~300 tokens each.`,
|
||||||
|
`- **Need current code?** smart_outline("${safePath}") for structure (~1-2k tokens), smart_unfold("${safePath}", "<symbol>") for a specific function (~400-2k tokens).`,
|
||||||
|
`- **Need to edit?** Use smart tools for line numbers, then sed via Bash (Edit requires Read, but you already have the context).`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [day, dayObservations] of sortedDays) {
|
||||||
|
// Sort within each day chronologically (deduplicateObservations reorders by specificity)
|
||||||
|
const chronological = [...dayObservations].sort((a, b) => a.created_at_epoch - b.created_at_epoch);
|
||||||
|
lines.push(`### ${day}`);
|
||||||
|
for (const obs of chronological) {
|
||||||
|
const title = obs.title || 'Untitled';
|
||||||
|
const icon = TYPE_ICONS[obs.type] || '\u2753';
|
||||||
|
const time = compactTime(formatTime(obs.created_at_epoch));
|
||||||
|
lines.push(`${obs.id} ${time} ${icon} ${title}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fileContextHandler: EventHandler = {
|
||||||
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
||||||
|
// Extract file_path from toolInput
|
||||||
|
const toolInput = input.toolInput as Record<string, unknown> | undefined;
|
||||||
|
const filePath = toolInput?.file_path as string | undefined;
|
||||||
|
|
||||||
|
if (!filePath) {
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip gate for files below the token-economics threshold — timeline (~370 tokens)
|
||||||
|
// costs more than reading small files directly.
|
||||||
|
try {
|
||||||
|
const statPath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.resolve(input.cwd || process.cwd(), filePath);
|
||||||
|
const stat = statSync(statPath);
|
||||||
|
if (stat.size < FILE_READ_GATE_MIN_BYTES) {
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.code === 'ENOENT') return { continue: true, suppressOutput: true };
|
||||||
|
// Other errors (symlink, permission denied) — fall through and let gate proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if project is excluded from tracking
|
||||||
|
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
||||||
|
if (input.cwd && isProjectExcluded(input.cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
||||||
|
logger.debug('HOOK', 'Project excluded from tracking, skipping file context', { cwd: input.cwd });
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure worker is running
|
||||||
|
const workerReady = await ensureWorkerRunning();
|
||||||
|
if (!workerReady) {
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query worker for observations related to this file
|
||||||
|
try {
|
||||||
|
const context = getProjectContext(input.cwd);
|
||||||
|
// Observations store relative paths — convert absolute to relative using cwd
|
||||||
|
const cwd = input.cwd || process.cwd();
|
||||||
|
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
|
||||||
|
const relativePath = path.relative(cwd, absolutePath).split(path.sep).join("/");
|
||||||
|
const queryParams = new URLSearchParams({ path: relativePath });
|
||||||
|
// Pass all project names (parent + worktree) for unified lookup
|
||||||
|
if (context.allProjects.length > 0) {
|
||||||
|
queryParams.set('projects', context.allProjects.join(','));
|
||||||
|
}
|
||||||
|
queryParams.set('limit', String(FETCH_LOOKAHEAD_LIMIT));
|
||||||
|
|
||||||
|
const response = await workerHttpRequest(`/api/observations/by-file?${queryParams.toString()}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
logger.warn('HOOK', 'File context query failed, skipping', { status: response.status, filePath });
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as { observations: ObservationRow[]; count: number };
|
||||||
|
|
||||||
|
if (!data.observations || data.observations.length === 0) {
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate: one per session, ranked by specificity to this file
|
||||||
|
const dedupedObservations = deduplicateObservations(data.observations, relativePath, DISPLAY_LIMIT);
|
||||||
|
if (dedupedObservations.length === 0) {
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deny the read with the timeline as the reason — Claude sees the timeline
|
||||||
|
// and decides: work from semantic priming, use get_observations(), or ask user to allow read
|
||||||
|
const timeline = formatFileTimeline(dedupedObservations, filePath);
|
||||||
|
return {
|
||||||
|
hookSpecificOutput: {
|
||||||
|
hookEventName: 'PreToolUse',
|
||||||
|
additionalContext: '',
|
||||||
|
permissionDecision: 'deny',
|
||||||
|
permissionDecisionReason: timeline,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('HOOK', 'File context fetch error, skipping', {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
return { continue: true, suppressOutput: true };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -13,6 +13,7 @@ import { observationHandler } from './observation.js';
|
|||||||
import { summarizeHandler } from './summarize.js';
|
import { summarizeHandler } from './summarize.js';
|
||||||
import { userMessageHandler } from './user-message.js';
|
import { userMessageHandler } from './user-message.js';
|
||||||
import { fileEditHandler } from './file-edit.js';
|
import { fileEditHandler } from './file-edit.js';
|
||||||
|
import { fileContextHandler } from './file-context.js';
|
||||||
import { sessionCompleteHandler } from './session-complete.js';
|
import { sessionCompleteHandler } from './session-complete.js';
|
||||||
|
|
||||||
export type EventType =
|
export type EventType =
|
||||||
@@ -22,7 +23,8 @@ export type EventType =
|
|||||||
| 'summarize' // Stop - generate summary (phase 1)
|
| 'summarize' // Stop - generate summary (phase 1)
|
||||||
| 'session-complete' // Stop - complete session (phase 2) - fixes #842
|
| 'session-complete' // Stop - complete session (phase 2) - fixes #842
|
||||||
| 'user-message' // SessionStart (parallel) - display to user
|
| 'user-message' // SessionStart (parallel) - display to user
|
||||||
| 'file-edit'; // Cursor afterFileEdit
|
| 'file-edit' // Cursor afterFileEdit
|
||||||
|
| 'file-context'; // PreToolUse - inject file observation history
|
||||||
|
|
||||||
const handlers: Record<EventType, EventHandler> = {
|
const handlers: Record<EventType, EventHandler> = {
|
||||||
'context': contextHandler,
|
'context': contextHandler,
|
||||||
@@ -31,7 +33,8 @@ const handlers: Record<EventType, EventHandler> = {
|
|||||||
'summarize': summarizeHandler,
|
'summarize': summarizeHandler,
|
||||||
'session-complete': sessionCompleteHandler,
|
'session-complete': sessionCompleteHandler,
|
||||||
'user-message': userMessageHandler,
|
'user-message': userMessageHandler,
|
||||||
'file-edit': fileEditHandler
|
'file-edit': fileEditHandler,
|
||||||
|
'file-context': fileContextHandler
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,4 +67,5 @@ export { observationHandler } from './observation.js';
|
|||||||
export { summarizeHandler } from './summarize.js';
|
export { summarizeHandler } from './summarize.js';
|
||||||
export { userMessageHandler } from './user-message.js';
|
export { userMessageHandler } from './user-message.js';
|
||||||
export { fileEditHandler } from './file-edit.js';
|
export { fileEditHandler } from './file-edit.js';
|
||||||
|
export { fileContextHandler } from './file-context.js';
|
||||||
export { sessionCompleteHandler } from './session-complete.js';
|
export { sessionCompleteHandler } from './session-complete.js';
|
||||||
|
|||||||
+6
-1
@@ -17,7 +17,12 @@ export interface NormalizedHookInput {
|
|||||||
export interface HookResult {
|
export interface HookResult {
|
||||||
continue?: boolean;
|
continue?: boolean;
|
||||||
suppressOutput?: boolean;
|
suppressOutput?: boolean;
|
||||||
hookSpecificOutput?: { hookEventName: string; additionalContext: string };
|
hookSpecificOutput?: {
|
||||||
|
hookEventName: string;
|
||||||
|
additionalContext: string;
|
||||||
|
permissionDecision?: 'allow' | 'deny';
|
||||||
|
permissionDecisionReason?: string;
|
||||||
|
};
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
exitCode?: number;
|
exitCode?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,3 +111,42 @@ export function getObservationsForSession(
|
|||||||
|
|
||||||
return stmt.all(memorySessionId) as ObservationSessionRow[];
|
return stmt.all(memorySessionId) as ObservationSessionRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get observations associated with a given file path, scoped to specific projects.
|
||||||
|
* Matches on the full file path (not just basename) to avoid cross-project collisions.
|
||||||
|
*/
|
||||||
|
export function getObservationsByFilePath(
|
||||||
|
db: Database,
|
||||||
|
filePath: string,
|
||||||
|
options?: { projects?: string[]; limit?: number }
|
||||||
|
): ObservationRecord[] {
|
||||||
|
const rawLimit = options?.limit;
|
||||||
|
const limit = Number.isInteger(rawLimit) && (rawLimit as number) > 0
|
||||||
|
? Math.min(rawLimit as number, 100)
|
||||||
|
: 15;
|
||||||
|
const params: (string | number)[] = [filePath, filePath];
|
||||||
|
|
||||||
|
let projectClause = '';
|
||||||
|
if (options?.projects?.length) {
|
||||||
|
const placeholders = options.projects.map(() => '?').join(',');
|
||||||
|
projectClause = `AND project IN (${placeholders})`;
|
||||||
|
params.push(...options.projects);
|
||||||
|
}
|
||||||
|
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM observations
|
||||||
|
WHERE (
|
||||||
|
EXISTS (SELECT 1 FROM json_each(files_read) WHERE value = ?)
|
||||||
|
OR EXISTS (SELECT 1 FROM json_each(files_modified) WHERE value = ?)
|
||||||
|
)
|
||||||
|
${projectClause}
|
||||||
|
ORDER BY created_at_epoch DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
return stmt.all(...params) as ObservationRecord[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { SessionManager } from '../../SessionManager.js';
|
|||||||
import { SSEBroadcaster } from '../../SSEBroadcaster.js';
|
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';
|
||||||
|
|
||||||
export class DataRoutes extends BaseRouteHandler {
|
export class DataRoutes extends BaseRouteHandler {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -39,6 +40,7 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
|
|
||||||
// Fetch by ID endpoints
|
// Fetch by ID endpoints
|
||||||
app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
|
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.post('/api/observations/batch', this.handleGetObservationsByIds.bind(this));
|
||||||
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
|
app.get('/api/session/:id', this.handleGetSessionById.bind(this));
|
||||||
app.post('/api/sdk-sessions/batch', this.handleGetSdkSessionsByIds.bind(this));
|
app.post('/api/sdk-sessions/batch', this.handleGetSdkSessionsByIds.bind(this));
|
||||||
@@ -108,6 +110,28 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
res.json(observation);
|
res.json(observation);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get observations associated with a file path, scoped to projects
|
||||||
|
* GET /api/observations/by-file?path=<file_path>&projects=<comma,separated>&limit=15
|
||||||
|
*/
|
||||||
|
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 projectsParam = req.query.projects as string | undefined;
|
||||||
|
const projects = projectsParam ? projectsParam.split(',').filter(Boolean) : undefined;
|
||||||
|
const parsedLimit = req.query.limit ? parseInt(req.query.limit as string, 10) : undefined;
|
||||||
|
const limit = Number.isFinite(parsedLimit) && parsedLimit! > 0 ? parsedLimit : undefined;
|
||||||
|
|
||||||
|
const db = this.dbManager.getSessionStore().db;
|
||||||
|
const observations = getObservationsByFilePath(db, filePath, { projects, limit });
|
||||||
|
|
||||||
|
res.json({ observations, count: observations.length });
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get observations by array of IDs
|
* Get observations by array of IDs
|
||||||
* POST /api/observations/batch
|
* POST /api/observations/batch
|
||||||
@@ -474,4 +498,5 @@ export class DataRoutes extends BaseRouteHandler {
|
|||||||
clearedCount
|
clearedCount
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user