feat(mcp): remember what explore already served this session (CG-17)
Explore answers every call as if it were the first: no record of the files and line ranges it already sent, so a 4th call re-serves the 1st call's spine and the tier call budget can only be asked for, never enforced. Track it per MCP session, per resolved project root — files, coalesced line ranges, bytes, and the call's index in the session. Nothing reads it yet: the response is byte-identical, which the suite pins against an untracked call of the same query. The daemon shares ONE ToolHandler and a pool of worker threads across every connected client, so the state can live neither on the handler nor in a worker. It lives on MCPSession and is handed to execute() per call; the session's view rides DOWN on the args and the call's emission rides BACK on the ToolResult, both as plain properties so they survive the structured clone to and from a worker. execute() records the emission on the main thread and deletes it unconditionally — including for callers that track nothing, like the CLI — so it can never reach the wire. A view a client spells itself is discarded rather than trusted. Ranges are reported by the render loop itself (buildSection now returns the spans it slices alongside the text), and only files that survive the final hard-ceiling truncation are recorded. Where a bound forces a choice the record keeps FEWER ranges than were emitted: under-reporting re-serves something the agent has, over-reporting withholds source it never saw and costs a Read. Every bound caps detail only — callCount keeps counting past eviction, so CG-19's decay can't reset itself every 8 calls.
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
*/
|
||||
|
||||
import { appendFileSync } from 'fs';
|
||||
import type { ExploreProjectState } from './explore-session-state';
|
||||
|
||||
/** How a file's source was rendered into the response. */
|
||||
export type ExploreRenderMode =
|
||||
@@ -135,6 +136,22 @@ export interface ExploreDiagnosticFile extends ExploreCandidateMeta {
|
||||
allocatedShare: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* This session's explore history for this project, as of BEFORE the call being
|
||||
* reported (CG-17). Present only when the caller tracks session state — the CLI
|
||||
* and bare-handler callers don't, so it is absent there rather than zeroed.
|
||||
*/
|
||||
export interface ExploreDiagnosticSession {
|
||||
/** 1-based index of THIS call within the session, for this project. */
|
||||
callIndex: number;
|
||||
/** Calls already served this session for this project. */
|
||||
priorCalls: number;
|
||||
/** Response chars already served this session for this project. */
|
||||
priorResponseChars: number;
|
||||
/** Files already served source this session, most-recent call first. */
|
||||
priorFiles: Array<{ path: string; ranges: Array<[number, number]>; bytes: number }>;
|
||||
}
|
||||
|
||||
/** The full report — one per explore call, JSON-serialized to the sink. */
|
||||
export interface ExploreDiagnosticReport {
|
||||
tool: 'codegraph_explore';
|
||||
@@ -142,6 +159,8 @@ export interface ExploreDiagnosticReport {
|
||||
projectRoot: string;
|
||||
indexedFileCount: number;
|
||||
note?: string;
|
||||
/** Session-scoped call state (CG-17); absent when the caller tracks none. */
|
||||
session?: ExploreDiagnosticSession;
|
||||
budget: {
|
||||
maxOutputChars: number;
|
||||
maxCharsPerFile: number;
|
||||
@@ -221,6 +240,7 @@ export class ExploreDiagnostics {
|
||||
private graphGateThreshold = 0;
|
||||
private graphGateApplied = false;
|
||||
private note = '';
|
||||
private session: ExploreDiagnosticSession | undefined;
|
||||
private allocPool = 0;
|
||||
private allocCliffAt = 0;
|
||||
private allocCliffed: string[] = [];
|
||||
@@ -265,6 +285,40 @@ export class ExploreDiagnostics {
|
||||
this.stages.pastRelevanceGate = kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record what this session had already been served for this project (CG-17),
|
||||
* so the report says which call in the session it is and what the earlier ones
|
||||
* cost. Read-only for now: nothing in the render loop consults it, which is
|
||||
* what keeps the response byte-identical at this stage.
|
||||
*
|
||||
* Files are listed most-recent call first and de-duplicated by path — the same
|
||||
* file re-served across calls is the pattern this instrument exists to make
|
||||
* visible, and its ranges are unioned so a glance shows what of it the agent
|
||||
* already holds.
|
||||
*/
|
||||
noteSession(prior: ExploreProjectState | null): void {
|
||||
if (!prior) return;
|
||||
const byPath = new Map<string, { path: string; ranges: Array<[number, number]>; bytes: number }>();
|
||||
for (const call of [...prior.calls].reverse()) {
|
||||
for (const file of call.files) {
|
||||
const existing = byPath.get(file.path);
|
||||
const spans = file.ranges.map((r) => [r.start, r.end] as [number, number]);
|
||||
if (existing) {
|
||||
existing.ranges.push(...spans);
|
||||
existing.bytes += file.bytes;
|
||||
} else {
|
||||
byPath.set(file.path, { path: file.path, ranges: spans, bytes: file.bytes });
|
||||
}
|
||||
}
|
||||
}
|
||||
this.session = {
|
||||
callIndex: prior.callCount + 1,
|
||||
priorCalls: prior.callCount,
|
||||
priorResponseChars: prior.responseBytes,
|
||||
priorFiles: [...byPath.values()],
|
||||
};
|
||||
}
|
||||
|
||||
/** Candidate count after the `group.score >= floor` filter. */
|
||||
setScoreFloor(floor: number, kept: number): void {
|
||||
this.scoreFloor = floor;
|
||||
@@ -384,6 +438,7 @@ export class ExploreDiagnostics {
|
||||
projectRoot: this.projectRoot,
|
||||
indexedFileCount: this.indexedFileCount,
|
||||
note: this.note || undefined,
|
||||
session: this.session,
|
||||
budget: {
|
||||
maxOutputChars: this.budget.maxOutputChars,
|
||||
maxCharsPerFile: this.budget.maxCharsPerFile,
|
||||
@@ -524,6 +579,20 @@ export function renderTable(report: ExploreDiagnosticReport): string {
|
||||
out.push(`codegraph explore diagnostic — "${report.query}"`);
|
||||
out.push(` project ${report.projectRoot} · ${num(report.indexedFileCount)} files indexed`);
|
||||
if (report.note) out.push(` note: ${report.note}`);
|
||||
if (report.session) {
|
||||
const s = report.session;
|
||||
out.push(
|
||||
` session call #${s.callIndex} for this project` +
|
||||
` · ${num(s.priorCalls)} prior call${s.priorCalls === 1 ? '' : 's'}` +
|
||||
` · ${num(s.priorResponseChars)} chars already served`,
|
||||
);
|
||||
for (const f of s.priorFiles.slice(0, 12)) {
|
||||
const spans = f.ranges.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(',');
|
||||
const more = f.ranges.length > 6 ? `,+${f.ranges.length - 6}` : '';
|
||||
out.push(` already served ${f.path} · ${num(f.bytes)} chars · L${spans}${more}`);
|
||||
}
|
||||
if (s.priorFiles.length > 12) out.push(` … +${s.priorFiles.length - 12} more already-served file(s)`);
|
||||
}
|
||||
out.push(
|
||||
` envelope ${num(env.chars)} chars delivered · ${num(env.allocatedChars)} allocated` +
|
||||
` of ${num(budget.maxOutputChars)} budget (hard ceiling ${num(budget.hardCeiling)})` +
|
||||
|
||||
Reference in New Issue
Block a user