Explorar o código

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.
Colby McHenry hai 1 mes
pai
achega
fc31b1e2bf

+ 469 - 0
__tests__/explore-session-state.test.ts

@@ -0,0 +1,469 @@
+/**
+ * Session-scoped explore call state (CG-17).
+ *
+ * The tracker is the foundation for cross-call dedup (CG-18) and budget decay
+ * (CG-19), so what it must get right is what those two will trust: the count of
+ * calls, the line ranges already served, and — above all — WHOSE they are. Two
+ * agents on one daemon share a ToolHandler and a worker pool; if their histories
+ * blend, a dedup built on this would withhold source from an agent that never
+ * saw it, and the agent Reads the file. That is the failure this suite guards.
+ *
+ * Three layers:
+ *   1. the state container itself — keying, monotonic call index, bounds;
+ *   2. the handler seam — a real explore against a real index records real
+ *      ranges, and the emission side-channel NEVER reaches the response;
+ *   3. the session seam — separate sessions on one engine, separate state.
+ */
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import * as fs from 'fs';
+import * as path from 'path';
+import * as os from 'os';
+import CodeGraph from '../src/index';
+import { ToolHandler } from '../src/mcp/tools';
+import { MCPSession } from '../src/mcp/session';
+import type { MCPEngine } from '../src/mcp/engine';
+import type { JsonRpcTransport, JsonRpcRequest, JsonRpcNotification } from '../src/mcp/transport';
+import {
+  EXPLORE_EMISSION_KEY,
+  EXPLORE_SESSION_LIMITS,
+  EXPLORE_SESSION_VIEW_ARG,
+  ExploreSessionState,
+  coalesceRanges,
+  exploreProjectKey,
+  rangesCover,
+  readExploreSessionView,
+  viewForProject,
+  type ExploreEmission,
+} from '../src/mcp/explore-session-state';
+
+const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'payroll-go');
+const QUERY = 'how does payroll cycle create and calculate payslips?';
+
+/** An emission shaped like a real one, for the container-level tests. */
+function emission(root: string, over: Partial<ExploreEmission> = {}): ExploreEmission {
+  return {
+    projectRoot: root,
+    query: 'q',
+    files: [{ path: 'a.ts', ranges: [{ start: 1, end: 10 }], bytes: 100 }],
+    sourceBytes: 100,
+    responseBytes: 400,
+    ...over,
+  };
+}
+
+describe('ExploreSessionState — the container', () => {
+  it('counts calls per project and hands back a 1-based session index', () => {
+    const state = new ExploreSessionState();
+    expect(state.record(emission('/repo/a'))?.index).toBe(1);
+    expect(state.record(emission('/repo/a'))?.index).toBe(2);
+    expect(state.callCount('/repo/a')).toBe(2);
+    expect(state.forProject('/repo/a')?.responseBytes).toBe(800);
+  });
+
+  it('keys state per project — a second project starts its own count', () => {
+    const state = new ExploreSessionState();
+    state.record(emission('/repo/a'));
+    state.record(emission('/repo/a'));
+    expect(state.record(emission('/repo/b'))?.index).toBe(1);
+    expect(state.callCount('/repo/a')).toBe(2);
+    expect(state.callCount('/repo/b')).toBe(1);
+    expect(state.forProject('/repo/b')?.calls).toHaveLength(1);
+  });
+
+  it('treats trailing slashes and `.` segments as the same project', () => {
+    const state = new ExploreSessionState();
+    state.record(emission('/repo/a'));
+    state.record(emission('/repo/a/'));
+    state.record(emission('/repo/a/./'));
+    expect(state.callCount('/repo/a')).toBe(3);
+    expect(state.snapshot()).toHaveLength(1);
+  });
+
+  it('never reports a project it was never told about', () => {
+    const state = new ExploreSessionState();
+    expect(state.forProject('/never/queried')).toBeNull();
+    expect(state.callCount('/never/queried')).toBe(0);
+  });
+
+  it('keeps counting past the retained-call bound — decay must not reset itself', () => {
+    const state = new ExploreSessionState();
+    const total = EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 5;
+    for (let i = 0; i < total; i++) state.record(emission('/repo/a'));
+    const project = state.forProject('/repo/a')!;
+    expect(project.callCount).toBe(total);
+    expect(project.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
+    // Detail is dropped from the OLDEST end; the newest call is always retained.
+    expect(project.calls[project.calls.length - 1]!.index).toBe(total);
+    expect(project.calls[0]!.index).toBe(total - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED + 1);
+  });
+
+  it('bounds the number of projects, evicting the least recently used', () => {
+    const state = new ExploreSessionState();
+    const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS + 2 }, (_, i) => `/repo/${i}`);
+    for (const root of roots) state.record(emission(root));
+    expect(state.snapshot()).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_PROJECTS);
+    expect(state.forProject(roots[0]!)).toBeNull();
+    expect(state.forProject(roots[roots.length - 1]!)).not.toBeNull();
+  });
+
+  it('keeps a re-queried project alive past newer ones', () => {
+    const state = new ExploreSessionState();
+    const roots = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_PROJECTS }, (_, i) => `/repo/${i}`);
+    for (const root of roots) state.record(emission(root));
+    state.record(emission(roots[0]!));        // touch the oldest
+    state.record(emission('/repo/newcomer')); // forces one eviction
+    expect(state.forProject(roots[0]!)?.callCount).toBe(2);
+    expect(state.forProject(roots[1]!)).toBeNull();
+  });
+
+  it('bounds files per call, keeping the ones that got the most source', () => {
+    const state = new ExploreSessionState();
+    const files = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL + 6 }, (_, i) => ({
+      path: `f${i}.ts`,
+      ranges: [{ start: 1, end: 5 }],
+      bytes: i + 1,
+    }));
+    state.record(emission('/repo/a', { files }));
+    const kept = state.forProject('/repo/a')!.calls[0]!.files;
+    expect(kept).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL);
+    expect(kept.map((f) => f.path)).toContain(`f${files.length - 1}.ts`);
+    expect(kept.map((f) => f.path)).not.toContain('f0.ts');
+  });
+
+  it('ignores an emission with no project root rather than filing it under ""', () => {
+    const state = new ExploreSessionState();
+    expect(state.record({ ...emission(''), projectRoot: '' })).toBeNull();
+    expect(state.snapshot()).toHaveLength(0);
+  });
+
+  it('hands out copies — a caller cannot mutate the record it read', () => {
+    const state = new ExploreSessionState();
+    state.record(emission('/repo/a'));
+    const snap = state.forProject('/repo/a')!;
+    snap.calls[0]!.files[0]!.ranges.push({ start: 999, end: 1000 });
+    expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.ranges).toHaveLength(1);
+  });
+
+  it('view() carries only the most recent calls per project', () => {
+    const state = new ExploreSessionState();
+    for (let i = 0; i < EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED; i++) state.record(emission('/repo/a'));
+    const view = state.view();
+    expect(view.projects[0]!.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
+    expect(view.projects[0]!.calls).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS);
+    expect(viewForProject(view, '/repo/a')?.callCount).toBe(EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
+    // A tracked session that hasn't touched this project yet reads as EMPTY,
+    // not untracked — only a missing view (nobody tracking) is null.
+    expect(viewForProject(view, '/repo/other')?.callCount).toBe(0);
+    expect(viewForProject(null, '/repo/a')).toBeNull();
+  });
+});
+
+describe('range bookkeeping', () => {
+  it('merges overlapping and adjacent spans into one', () => {
+    const { ranges, truncated } = coalesceRanges([
+      { start: 10, end: 20 },
+      { start: 15, end: 25 },  // overlaps
+      { start: 26, end: 30 },  // adjacent — one contiguous block of source
+      { start: 60, end: 61 },
+    ]);
+    expect(ranges).toEqual([{ start: 10, end: 30 }, { start: 60, end: 61 }]);
+    expect(truncated).toBe(false);
+  });
+
+  it('drops junk spans instead of recording a range that was never served', () => {
+    const { ranges } = coalesceRanges([
+      { start: 5, end: 1 },      // inverted
+      { start: 0, end: 3 },      // before line 1
+      { start: NaN, end: 4 },
+      { start: 7, end: 9 },
+    ]);
+    expect(ranges).toEqual([{ start: 7, end: 9 }]);
+  });
+
+  it('caps the range list by KEEPING the largest spans, and says it truncated', () => {
+    // Spaced far enough apart that none of them merge — this is about the cap.
+    const many = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 5 }, (_, i) => ({
+      start: i * 200 + 1,
+      end: i * 200 + 2 + i, // later spans are longer
+    }));
+    const { ranges, truncated } = coalesceRanges(many);
+    expect(truncated).toBe(true);
+    expect(ranges).toHaveLength(EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE);
+    // Still in line order, and the biggest span survived.
+    expect(ranges.map((r) => r.start)).toEqual([...ranges.map((r) => r.start)].sort((a, b) => a - b));
+    expect(ranges.some((r) => r.start === many[many.length - 1]!.start)).toBe(true);
+  });
+
+  it('flags truncation on the stored record so a consumer knows it under-knows', () => {
+    const state = new ExploreSessionState();
+    const ranges = Array.from({ length: EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE + 3 }, (_, i) => ({
+      start: i * 10 + 1, end: i * 10 + 4,
+    }));
+    state.record(emission('/repo/a', { files: [{ path: 'big.ts', ranges, bytes: 900 }] }));
+    expect(state.forProject('/repo/a')!.calls[0]!.files[0]!.rangesTruncated).toBe(true);
+  });
+
+  it('answers whether a line was already served', () => {
+    const ranges = [{ start: 10, end: 20 }, { start: 40, end: 41 }];
+    expect(rangesCover(ranges, 10)).toBe(true);
+    expect(rangesCover(ranges, 20)).toBe(true);
+    expect(rangesCover(ranges, 21)).toBe(false);
+    expect(rangesCover(ranges, 40)).toBe(true);
+  });
+
+  it('folds case only on the case-insensitive platforms', () => {
+    const insensitive = process.platform === 'darwin' || process.platform === 'win32';
+    expect(exploreProjectKey('/Repo/A') === exploreProjectKey('/repo/a')).toBe(insensitive);
+  });
+});
+
+describe('session view arriving on tool args', () => {
+  it('reads a well-formed view and ignores anything else', () => {
+    const state = new ExploreSessionState();
+    state.record(emission('/repo/a'));
+    expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: state.view() })?.projects).toHaveLength(1);
+    expect(readExploreSessionView({})).toBeNull();
+    expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: 'nope' })).toBeNull();
+    expect(readExploreSessionView({ [EXPLORE_SESSION_VIEW_ARG]: { projects: 'nope' } })).toBeNull();
+  });
+
+  it('drops malformed project entries rather than trusting them', () => {
+    const view = readExploreSessionView({
+      [EXPLORE_SESSION_VIEW_ARG]: { projects: [{ projectRoot: '/repo/a', calls: [] }, { nope: 1 }, null] },
+    });
+    expect(view?.projects).toHaveLength(1);
+  });
+});
+
+describe('explore records what it actually served', () => {
+  let testDir: string;
+  let cg: CodeGraph;
+  let handler: ToolHandler;
+
+  beforeAll(async () => {
+    testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg17-'));
+    fs.cpSync(FIXTURE_SRC, testDir, { recursive: true });
+    fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true });
+    cg = CodeGraph.initSync(testDir);
+    await cg.indexAll();
+    handler = new ToolHandler(cg);
+  }, 120_000);
+
+  afterAll(() => {
+    if (cg) cg.destroy();
+    if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true });
+  });
+
+  it('files one record per call, with the files and line ranges it emitted', async () => {
+    const session = new ExploreSessionState();
+    await handler.execute('codegraph_explore', { query: QUERY }, session);
+
+    const project = session.forProject(cg.getProjectRoot());
+    expect(project).not.toBeNull();
+    expect(project!.callCount).toBe(1);
+
+    const call = project!.calls[0]!;
+    expect(call.files.length).toBeGreaterThan(0);
+    expect(call.sourceBytes).toBeGreaterThan(0);
+    expect(call.responseBytes).toBeGreaterThan(call.sourceBytes);
+    for (const file of call.files) {
+      expect(file.ranges.length).toBeGreaterThan(0);
+      for (const r of file.ranges) {
+        expect(r.start).toBeGreaterThanOrEqual(1);
+        expect(r.end).toBeGreaterThanOrEqual(r.start);
+      }
+    }
+  }, 60_000);
+
+  it('records only files whose source is really in the response', async () => {
+    const session = new ExploreSessionState();
+    const result = await handler.execute('codegraph_explore', { query: QUERY }, session);
+    const text = result.content[0]!.text;
+    for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) {
+      expect(text).toContain(file.path);
+    }
+  }, 60_000);
+
+  it('the recorded ranges name lines that are really in the emitted source', async () => {
+    const session = new ExploreSessionState();
+    await handler.execute('codegraph_explore', { query: QUERY }, session);
+    for (const file of session.forProject(cg.getProjectRoot())!.calls[0]!.files) {
+      const lineCount = fs.readFileSync(path.join(testDir, file.path), 'utf-8').split('\n').length;
+      for (const r of file.ranges) expect(r.end).toBeLessThanOrEqual(lineCount);
+    }
+  }, 60_000);
+
+  it('leaves the agent-facing response untouched — no side-channel on the wire', async () => {
+    const session = new ExploreSessionState();
+    const tracked = await handler.execute('codegraph_explore', { query: QUERY }, session);
+    const untracked = await handler.execute('codegraph_explore', { query: QUERY });
+
+    expect(tracked.content[0]!.text).toBe(untracked.content[0]!.text);
+    for (const result of [tracked, untracked]) {
+      expect(EXPLORE_EMISSION_KEY in result).toBe(false);
+      expect(JSON.stringify(result)).not.toContain(EXPLORE_EMISSION_KEY);
+    }
+  }, 60_000);
+
+  it('ignores a session view a client spelled itself — the record is the server\'s', async () => {
+    const forged = {
+      projects: [{ projectRoot: cg.getProjectRoot(), callCount: 99, responseBytes: 1e6, calls: [] }],
+    };
+    const result = await handler.execute('codegraph_explore', {
+      query: QUERY,
+      [EXPLORE_SESSION_VIEW_ARG]: forged,
+    });
+    const clean = await handler.execute('codegraph_explore', { query: QUERY });
+    expect(result.content[0]!.text).toBe(clean.content[0]!.text);
+  }, 60_000);
+
+  it('counts an empty answer as a call, since it still spends the tier budget', async () => {
+    const session = new ExploreSessionState();
+    await handler.execute('codegraph_explore', { query: 'zzqqxx_no_such_symbol_anywhere' }, session);
+    const project = session.forProject(cg.getProjectRoot());
+    expect(project?.callCount).toBe(1);
+    expect(project?.calls[0]!.files).toHaveLength(0);
+  }, 60_000);
+
+  it('two sessions on ONE handler never see each other\'s calls', async () => {
+    const a = new ExploreSessionState();
+    const b = new ExploreSessionState();
+    await handler.execute('codegraph_explore', { query: QUERY }, a);
+    await handler.execute('codegraph_explore', { query: QUERY }, a);
+    await handler.execute('codegraph_explore', { query: QUERY }, b);
+
+    expect(a.callCount(cg.getProjectRoot())).toBe(2);
+    expect(b.callCount(cg.getProjectRoot())).toBe(1);
+  }, 90_000);
+
+  it('a caller that tracks nothing still gets a clean result', async () => {
+    const result = await handler.execute('codegraph_explore', { query: QUERY });
+    expect(result.isError).toBeFalsy();
+    expect(result.content[0]!.text.length).toBeGreaterThan(0);
+  }, 60_000);
+
+  it('reports the session state through the CG-4 diagnostic', async () => {
+    const sidecar = path.join(testDir, 'cg17-diagnostic.jsonl');
+    const session = new ExploreSessionState();
+    const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+    process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+    try {
+      await handler.execute('codegraph_explore', { query: QUERY }, session);
+      await handler.execute('codegraph_explore', { query: QUERY }, session);
+    } finally {
+      if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+      else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+    }
+
+    const reports = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').map((l) => JSON.parse(l));
+    expect(reports).toHaveLength(2);
+    // The first call is the session's first: nothing served before it.
+    expect(reports[0].session).toEqual({
+      callIndex: 1, priorCalls: 0, priorResponseChars: 0, priorFiles: [],
+    });
+    // The second sees the first call's files and their ranges.
+    expect(reports[1].session.callIndex).toBe(2);
+    expect(reports[1].session.priorCalls).toBe(1);
+    expect(reports[1].session.priorResponseChars).toBeGreaterThan(0);
+    expect(reports[1].session.priorFiles.length).toBeGreaterThan(0);
+    expect(reports[1].session.priorFiles[0].ranges[0]).toHaveLength(2);
+  }, 90_000);
+
+  it('omits the session block entirely when the caller tracks no state', async () => {
+    const sidecar = path.join(testDir, 'cg17-untracked.jsonl');
+    const previous = process.env.CODEGRAPH_EXPLORE_DEBUG;
+    process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar;
+    try {
+      await handler.execute('codegraph_explore', { query: QUERY });
+    } finally {
+      if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG;
+      else process.env.CODEGRAPH_EXPLORE_DEBUG = previous;
+    }
+    const report = JSON.parse(fs.readFileSync(sidecar, 'utf-8').trim());
+    expect(report.session).toBeUndefined();
+  }, 60_000);
+
+  it('keys on the RESOLVED root, not the path the agent typed', async () => {
+    // The same project reached two ways — bare, and via a `projectPath` pointing
+    // at a subdirectory. Both resolve to one index, so both must land in one
+    // bucket; keying on the typed path would split a session's history in two
+    // and hand a later call a half-empty record.
+    //
+    // (Two genuinely DIFFERENT projects can't be exercised here: opening a
+    // second index inside vitest fails on the lazy `require('../index')` — see
+    // the ToolHandler cache notes. The container-level tests above cover the
+    // multi-project keying itself.)
+    const session = new ExploreSessionState();
+    await handler.execute('codegraph_explore', { query: QUERY }, session);
+    await handler.execute(
+      'codegraph_explore',
+      { query: QUERY, projectPath: path.join(testDir, 'internal') },
+      session,
+    );
+
+    expect(session.snapshot()).toHaveLength(1);
+    expect(session.callCount(cg.getProjectRoot())).toBe(2);
+  }, 90_000);
+});
+
+describe('sessions sharing a daemon', () => {
+  /** Minimal transport: captures the message handler so a test can drive it. */
+  function fakeTransport(): JsonRpcTransport & { deliver: (m: JsonRpcRequest) => Promise<void>; results: unknown[] } {
+    let handle: ((m: JsonRpcRequest | JsonRpcNotification) => Promise<void>) | null = null;
+    const results: unknown[] = [];
+    return {
+      start(h) { handle = h as typeof handle; },
+      stop() { /* nothing to tear down */ },
+      send() { /* unused */ },
+      notify() { /* unused */ },
+      async request() { return {}; },
+      sendResult(_id, result) { results.push(result); },
+      sendError() { /* unused */ },
+      results,
+      async deliver(m: JsonRpcRequest) { await handle?.(m); },
+    };
+  }
+
+  it('give each session its own state, and one session\'s calls stay there', async () => {
+    const calls: Array<ExploreSessionState | undefined> = [];
+    // A ToolHandler stand-in: the point here is WHICH state object arrives, not
+    // what explore returns, so a real index would only slow the assertion down.
+    const handler = {
+      getTools: () => [],
+      execute: async (_tool: string, _args: Record<string, unknown>, state?: ExploreSessionState) => {
+        calls.push(state);
+        state?.record(emission('/repo/shared'));
+        return { content: [{ type: 'text' as const, text: 'ok' }] };
+      },
+    };
+    const engine = {
+      ensureInitialized: async () => { /* already open */ },
+      hasDefaultCodeGraph: () => true,
+      getProjectPath: () => '/repo/shared',
+      retryInitializeSync: () => { /* nothing to retry */ },
+      getToolHandler: () => handler,
+    } as unknown as MCPEngine;
+
+    const transportA = fakeTransport();
+    const transportB = fakeTransport();
+    const sessionA = new MCPSession(transportA, engine);
+    const sessionB = new MCPSession(transportB, engine);
+    sessionA.start();
+    sessionB.start();
+
+    expect(sessionA.getExploreSessionState()).not.toBe(sessionB.getExploreSessionState());
+
+    const call = (id: number): JsonRpcRequest => ({
+      jsonrpc: '2.0', id, method: 'tools/call',
+      params: { name: 'codegraph_explore', arguments: { query: 'q' } },
+    });
+    await transportA.deliver(call(1));
+    await transportA.deliver(call(2));
+    await transportB.deliver(call(3));
+
+    expect(calls[0]).toBe(sessionA.getExploreSessionState());
+    expect(calls[2]).toBe(sessionB.getExploreSessionState());
+    expect(sessionA.getExploreSessionState().callCount('/repo/shared')).toBe(2);
+    expect(sessionB.getExploreSessionState().callCount('/repo/shared')).toBe(1);
+  });
+});

+ 69 - 0
src/mcp/explore-diagnostics.ts

@@ -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)})` +

+ 360 - 0
src/mcp/explore-session-state.ts

@@ -0,0 +1,360 @@
+/**
+ * Session-scoped `codegraph_explore` call state (CG-17).
+ *
+ * What it holds: for ONE MCP session, per project it queried, what explore has
+ * already returned — the files, the line ranges of source inside them, the bytes
+ * they cost, and where in the session each call fell. Nothing else in the server
+ * knows this today: every explore call is answered as if it were the first one,
+ * which is why a 4th call happily re-serves the same spine it already sent
+ * (#1500) and why the tier's call budget can only be *asked* for rather than
+ * enforced. This module is the record those two behaviours are built on
+ * (CG-18 cross-call dedup, CG-19 budget decay). It changes no response itself.
+ *
+ * Four constraints shape the design, all of them from how the daemon actually
+ * runs:
+ *
+ *   1. **Per session, never persisted.** One instance is owned by an
+ *      {@link ../mcp/session.MCPSession} and dies with the socket. A new agent
+ *      session starts clean — dedup across sessions would suppress source the
+ *      new agent has never seen.
+ *   2. **Per project inside the session.** A session can query several projects
+ *      by `projectPath`, so state is keyed by the RESOLVED project root
+ *      (`cg.getProjectRoot()`), not by whatever path the agent typed.
+ *   3. **Bounded.** A long-lived session must not grow without limit, so
+ *      everything is capped — see {@link EXPLORE_SESSION_LIMITS}. Eviction drops
+ *      DETAIL only: `callCount` and `responseBytes` keep counting past it, since
+ *      decay (CG-19) reads the count and must not be reset by its own bound.
+ *   4. **Daemon-safe.** The daemon shares ONE {@link ../mcp/tools.ToolHandler}
+ *      (and a pool of worker threads) across every connected session, so this
+ *      state can live neither on the handler nor in a worker. It lives on the
+ *      session; the handler is handed it per call, and the record of what a call
+ *      emitted travels back on the {@link ToolResult} so it can be recorded on
+ *      the main thread whether dispatch ran in-process or on a worker.
+ *
+ * Over- vs under-reporting: where a bound forces a choice, this module keeps
+ * FEWER ranges than were emitted, never more. A consumer that under-knows
+ * re-serves something the agent already has (wasteful); one that over-knows
+ * withholds source the agent never saw (a Read — the failure this whole area
+ * exists to prevent).
+ */
+
+import * as path from 'path';
+
+/**
+ * Property on a {@link ../mcp/tools.ToolResult} carrying what an explore call
+ * emitted. INTERNAL: `ToolHandler.execute` records it and deletes it before the
+ * result reaches the wire, so the agent-facing response is unchanged. It is a
+ * plain-object property (not a Symbol) on purpose — it has to survive the
+ * structured clone back from a query-pool worker.
+ */
+export const EXPLORE_EMISSION_KEY = '_cgExploreEmission';
+
+/**
+ * Argument key carrying this session's prior-call view INTO a tool call. Same
+ * reasoning as {@link EXPLORE_EMISSION_KEY}: it crosses the worker boundary, so
+ * it must be a serializable property on the args object.
+ */
+export const EXPLORE_SESSION_VIEW_ARG = '_cgExploreSession';
+
+/** An inclusive 1-based line span of a file that was emitted. */
+export interface ExploreLineRange {
+  start: number;
+  end: number;
+}
+
+/** What one call emitted for one file. */
+export interface ExploreFileEmission {
+  /** Project-relative path, exactly as the response's file header spells it. */
+  path: string;
+  /** Coalesced line spans whose source was in the response. */
+  ranges: ExploreLineRange[];
+  /** Source chars emitted for this file (excludes headers / fences). */
+  bytes: number;
+  /** Set when ranges were dropped to stay under the per-file bound. */
+  rangesTruncated?: boolean;
+}
+
+/** What one explore call emitted, as reported by the handler. */
+export interface ExploreEmission {
+  /** Resolved project root — the key state is filed under. */
+  projectRoot: string;
+  /** Normalized query text (post `normalizeQuerySpelling`). */
+  query: string;
+  files: ExploreFileEmission[];
+  /** Source chars across all files. */
+  sourceBytes: number;
+  /** Total chars of the response the agent received. */
+  responseBytes: number;
+}
+
+/** A recorded call: an emission plus where it fell in the session. */
+export interface ExploreCallRecord extends ExploreEmission {
+  /** 1-based call index within this session FOR THIS PROJECT. Survives eviction. */
+  index: number;
+}
+
+/** Everything the session knows about one project. */
+export interface ExploreProjectState {
+  projectRoot: string;
+  /** Explore calls made this session against this project, including evicted ones. */
+  callCount: number;
+  /** Response chars across every call, including evicted ones. */
+  responseBytes: number;
+  /** Retained call records, oldest first. Bounded — may omit early calls. */
+  calls: ExploreCallRecord[];
+}
+
+/**
+ * The bounded, serializable read-view handed to a tool call. Deliberately
+ * smaller than the full state: only the most recent calls carry their ranges,
+ * because that is what a dedup/decay decision reads and the whole thing is
+ * structured-cloned to a worker on every call.
+ */
+export interface ExploreSessionView {
+  projects: ExploreProjectState[];
+}
+
+/**
+ * Memory bounds. Every one of them caps DETAIL; none caps the counters that
+ * CG-19's decay reads.
+ *
+ * Sized against how sessions actually behave: an agent explores one project
+ * (occasionally a second in a monorepo) and the tier call budget is 1–5, so the
+ * retained window covers a whole realistic session and the caps only bite on
+ * pathological ones.
+ */
+export const EXPLORE_SESSION_LIMITS = {
+  /** Distinct projects kept per session; least-recently-used evicted first. */
+  MAX_PROJECTS: 4,
+  /** Call records kept per project (oldest dropped; `callCount` keeps counting). */
+  MAX_CALLS_RETAINED: 8,
+  /** Files kept per call — the ones that got the most source. */
+  MAX_FILES_PER_CALL: 24,
+  /** Line ranges kept per file after coalescing — the largest spans. */
+  MAX_RANGES_PER_FILE: 24,
+  /** Most-recent calls per project included in {@link ExploreSessionView}. */
+  MAX_VIEW_CALLS: 4,
+} as const;
+
+/**
+ * Key a project root is filed under. Resolved so `/repo` and `/repo/` agree;
+ * case-folded on the two platforms whose filesystems are case-insensitive, so a
+ * drive-letter or capitalization difference doesn't split one project in two.
+ */
+export function exploreProjectKey(projectRoot: string): string {
+  const resolved = path.resolve(projectRoot);
+  return process.platform === 'win32' || process.platform === 'darwin'
+    ? resolved.toLowerCase()
+    : resolved;
+}
+
+/**
+ * Merge overlapping / adjacent spans into the smallest equivalent set, then cap
+ * it. Adjacency (`next.start <= cur.end + 1`) counts as overlap: two ranges that
+ * touch describe one contiguous block of emitted source.
+ *
+ * When the cap bites, the LARGEST spans are kept and the result is re-sorted by
+ * line so the set still reads top-to-bottom — dropping small fragments loses the
+ * least information, and under-reporting is the safe direction (see the module
+ * header).
+ */
+export function coalesceRanges(
+  ranges: ReadonlyArray<ExploreLineRange>,
+  max: number = EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE,
+): { ranges: ExploreLineRange[]; truncated: boolean } {
+  const valid = ranges
+    .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end >= r.start && r.start >= 1)
+    .map((r) => ({ start: Math.floor(r.start), end: Math.floor(r.end) }))
+    .sort((a, b) => a.start - b.start || a.end - b.end);
+
+  const merged: ExploreLineRange[] = [];
+  for (const r of valid) {
+    const last = merged[merged.length - 1];
+    if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
+    else merged.push({ ...r });
+  }
+  if (merged.length <= max) return { ranges: merged, truncated: false };
+
+  const kept = [...merged]
+    .sort((a, b) => (b.end - b.start) - (a.end - a.start) || a.start - b.start)
+    .slice(0, max)
+    .sort((a, b) => a.start - b.start);
+  return { ranges: kept, truncated: true };
+}
+
+/** Whether a line falls inside any of the (sorted, coalesced) ranges. */
+export function rangesCover(ranges: ReadonlyArray<ExploreLineRange>, line: number): boolean {
+  return ranges.some((r) => line >= r.start && line <= r.end);
+}
+
+interface MutableProjectState {
+  projectRoot: string;
+  callCount: number;
+  responseBytes: number;
+  calls: ExploreCallRecord[];
+}
+
+/**
+ * One MCP session's explore history. Created per session, thrown away with it.
+ *
+ * Not thread-shared and not a singleton: two sessions on the same daemon own two
+ * instances and can never observe each other's calls. Every method is total —
+ * malformed input is normalized away rather than thrown, because this sits on
+ * the tool-call path and a bookkeeping bug must never fail an explore.
+ */
+export class ExploreSessionState {
+  /** Insertion-ordered; a touched project is re-inserted, so the head is the LRU. */
+  private readonly projects = new Map<string, MutableProjectState>();
+
+  /**
+   * File an emission. Returns the record as stored (with its session call
+   * index), or `null` if the emission was unusable.
+   */
+  record(emission: ExploreEmission): ExploreCallRecord | null {
+    if (!emission || typeof emission.projectRoot !== 'string' || !emission.projectRoot) return null;
+    const key = exploreProjectKey(emission.projectRoot);
+    const state = this.touch(key, emission.projectRoot);
+
+    state.callCount += 1;
+    state.responseBytes += Math.max(0, emission.responseBytes || 0);
+
+    const record: ExploreCallRecord = {
+      index: state.callCount,
+      projectRoot: emission.projectRoot,
+      query: typeof emission.query === 'string' ? emission.query : '',
+      files: this.boundFiles(emission.files),
+      sourceBytes: Math.max(0, emission.sourceBytes || 0),
+      responseBytes: Math.max(0, emission.responseBytes || 0),
+    };
+    state.calls.push(record);
+    if (state.calls.length > EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED) {
+      state.calls.splice(0, state.calls.length - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
+    }
+    return record;
+  }
+
+  /** Full state for one project, or `null` if it was never queried this session. */
+  forProject(projectRoot: string): ExploreProjectState | null {
+    const state = this.projects.get(exploreProjectKey(projectRoot));
+    return state ? cloneProject(state) : null;
+  }
+
+  /** Explore calls made this session against a project (including evicted ones). */
+  callCount(projectRoot: string): number {
+    return this.projects.get(exploreProjectKey(projectRoot))?.callCount ?? 0;
+  }
+
+  /** Every project this session has queried, least-recently-used first. */
+  snapshot(): ExploreProjectState[] {
+    return [...this.projects.values()].map(cloneProject);
+  }
+
+  /**
+   * The bounded view passed INTO a tool call. Trimmed to the most recent
+   * {@link EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS} calls per project: it crosses a
+   * worker boundary on every explore, so it carries what a dedup/decay decision
+   * needs and not the whole history.
+   */
+  view(): ExploreSessionView {
+    return {
+      projects: [...this.projects.values()].map((state) => ({
+        projectRoot: state.projectRoot,
+        callCount: state.callCount,
+        responseBytes: state.responseBytes,
+        calls: state.calls
+          .slice(-EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS)
+          .map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })),
+      })),
+    };
+  }
+
+  /** Drop everything. Used by tests; a real session just goes away instead. */
+  clear(): void {
+    this.projects.clear();
+  }
+
+  /**
+   * Fetch a project's state, creating it if new, and mark it most-recently-used.
+   * Evicts the LRU project past the bound — dropping a project entirely (rather
+   * than its detail) is right here: a session that has moved on to four other
+   * repos is not about to re-ask the first one.
+   */
+  private touch(key: string, projectRoot: string): MutableProjectState {
+    const existing = this.projects.get(key);
+    if (existing) {
+      this.projects.delete(key);
+      this.projects.set(key, existing);
+      return existing;
+    }
+    const created: MutableProjectState = { projectRoot, callCount: 0, responseBytes: 0, calls: [] };
+    this.projects.set(key, created);
+    while (this.projects.size > EXPLORE_SESSION_LIMITS.MAX_PROJECTS) {
+      const lru = this.projects.keys().next().value as string | undefined;
+      if (lru === undefined) break;
+      this.projects.delete(lru);
+    }
+    return created;
+  }
+
+  /**
+   * Normalize + bound one call's files: coalesce each file's ranges, then keep
+   * the files that got the most source. A call that renders more files than the
+   * bound has already spread its envelope thin, so the tail files carry the
+   * least — and losing them costs the least.
+   */
+  private boundFiles(files: ReadonlyArray<ExploreFileEmission> | undefined): ExploreFileEmission[] {
+    if (!Array.isArray(files) || files.length === 0) return [];
+    const normalized = files
+      .filter((f) => f && typeof f.path === 'string' && f.path.length > 0)
+      .map((f) => {
+        const { ranges, truncated } = coalesceRanges(f.ranges ?? []);
+        const out: ExploreFileEmission = { path: f.path, ranges, bytes: Math.max(0, f.bytes || 0) };
+        if (truncated) out.rangesTruncated = true;
+        return out;
+      });
+    if (normalized.length <= EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL) return normalized;
+    return [...normalized]
+      .sort((a, b) => b.bytes - a.bytes)
+      .slice(0, EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL);
+  }
+}
+
+function cloneProject(state: MutableProjectState): ExploreProjectState {
+  return {
+    projectRoot: state.projectRoot,
+    callCount: state.callCount,
+    responseBytes: state.responseBytes,
+    calls: state.calls.map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })),
+  };
+}
+
+/**
+ * Read the session view a caller injected into tool args, if any. Defensive:
+ * the key is internal, but the args object comes off the wire, so a client that
+ * spells it itself gets ignored rather than trusted into a crash.
+ */
+export function readExploreSessionView(args: Record<string, unknown>): ExploreSessionView | null {
+  const raw = args?.[EXPLORE_SESSION_VIEW_ARG];
+  if (!raw || typeof raw !== 'object') return null;
+  const projects = (raw as ExploreSessionView).projects;
+  if (!Array.isArray(projects)) return null;
+  return { projects: projects.filter((p) => p && typeof p.projectRoot === 'string') };
+}
+
+/**
+ * This session's prior state for one project, from an injected view.
+ *
+ * `null` means NOBODY IS TRACKING (no view was injected — the CLI, a bare
+ * handler). A view that simply hasn't seen this project yet returns an EMPTY
+ * state, not null: the distinction matters to consumers, since "first call of a
+ * tracked session" and "untracked" are different situations.
+ */
+export function viewForProject(
+  view: ExploreSessionView | null,
+  projectRoot: string,
+): ExploreProjectState | null {
+  if (!view) return null;
+  const key = exploreProjectKey(projectRoot);
+  return view.projects.find((p) => exploreProjectKey(p.projectRoot) === key)
+    ?? { projectRoot, callCount: 0, responseBytes: 0, calls: [] };
+}

+ 6 - 1
src/mcp/proxy.ts

@@ -30,6 +30,7 @@ import { CodeGraphPackageVersion } from './version';
 import { SERVER_INFO, PROTOCOL_VERSION, initializeInstructions } from './session';
 import { SERVER_INSTRUCTIONS } from './server-instructions';
 import { getStaticTools } from './tools';
+import { ExploreSessionState } from './explore-session-state';
 import { getTelemetry, ClientInfo } from '../telemetry';
 import type { MCPEngine } from './engine';
 
@@ -230,6 +231,10 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
   // new session starts), these would otherwise hang forever; we re-serve them
   // in-process so the host always gets a reply.
   const inflight = new Map<unknown, string>();
+  // Explore call history for the ONE host connection this proxy serves (CG-17).
+  // Only the daemon-unavailable fallback below uses it; when the daemon is up,
+  // the tracking happens on the daemon's own MCPSession.
+  const exploreSession = new ExploreSessionState();
   const trackInflight = (line: string): void => {
     try {
       const m = JSON.parse(line) as JsonRpc;
@@ -261,7 +266,7 @@ export async function runLocalHandshakeProxy(deps: LocalHandshakeDeps): Promise<
       try {
         await ensureEngine();
         const params = (msg.params || {}) as { name: string; arguments?: Record<string, unknown> };
-        const result = await engine!.getToolHandler().execute(params.name, params.arguments || {});
+        const result = await engine!.getToolHandler().execute(params.name, params.arguments || {}, exploreSession);
         writeClient({ jsonrpc: '2.0', id, result });
         getTelemetry().recordUsage('mcp_tool', params.name, !result.isError, telemetryClient);
       } catch (err) {

+ 20 - 1
src/mcp/session.ts

@@ -21,6 +21,7 @@ import { CodeGraphPackageVersion } from './version';
 import { findNearestCodeGraphRoot } from '../directory';
 import { getTelemetry, ClientInfo } from '../telemetry';
 import { getUpdateNotice } from '../upgrade/update-check';
+import { ExploreSessionState } from './explore-session-state';
 
 /**
  * MCP Server Info — kept on the session because some clients log it. The
@@ -110,6 +111,15 @@ export class MCPSession {
   private rootsAttempted = false;
   private resolvePromise: Promise<void> | null = null;
   private explicitProjectPath: string | null;
+  /**
+   * What `codegraph_explore` has already returned to THIS client, per project
+   * (CG-17). Owned by the session, not the engine: the daemon shares one engine
+   * (and one ToolHandler, and a pool of worker threads) across every connected
+   * client, so state kept over there would blend two agents' histories and let
+   * one session's calls suppress source the other has never seen. It dies with
+   * the session — a reconnecting client starts clean.
+   */
+  private readonly exploreSession = new ExploreSessionState();
 
   constructor(
     private transport: JsonRpcTransport,
@@ -140,6 +150,15 @@ export class MCPSession {
     return this.transport;
   }
 
+  /**
+   * This session's explore call history (CG-17). Exposed so tests can assert
+   * that two sessions on one daemon keep separate state; nothing in the server
+   * reaches for another session's copy.
+   */
+  getExploreSessionState(): ExploreSessionState {
+    return this.exploreSession;
+  }
+
   private async handleMessage(message: JsonRpcRequest | JsonRpcNotification): Promise<void> {
     const isRequest = 'id' in message;
     switch (message.method) {
@@ -286,7 +305,7 @@ export class MCPSession {
     await this.retryInitIfNeeded();
 
     if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} dispatch\n`);
-    const result = await this.engine.getToolHandler().execute(toolName, toolArgs);
+    const result = await this.engine.getToolHandler().execute(toolName, toolArgs, this.exploreSession);
     if (process.env.CODEGRAPH_MCP_DEBUG) process.stderr.write(`[mcp-debug] toolsCall ${toolName} id=${String(request.id)} done\n`);
     this.transport.sendResult(request.id, result);
     // After the reply is on the wire — telemetry must never delay a tool

+ 197 - 17
src/mcp/tools.ts

@@ -42,6 +42,16 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C
 import { scanDynamicDispatch } from './dynamic-boundaries';
 import { getUpdateNotice } from '../upgrade/update-check';
 import { ExploreDiagnostics } from './explore-diagnostics';
+import {
+  EXPLORE_EMISSION_KEY,
+  EXPLORE_SESSION_VIEW_ARG,
+  ExploreSessionState,
+  readExploreSessionView,
+  viewForProject,
+  type ExploreEmission,
+  type ExploreFileEmission,
+  type ExploreLineRange,
+} from './explore-session-state';
 
 /**
  * An expected, recoverable "codegraph can't serve this" condition — most
@@ -891,6 +901,15 @@ export interface ToolResult {
     text: string;
   }>;
   isError?: boolean;
+  /**
+   * INTERNAL side-channel (CG-17): what a `codegraph_explore` call actually put
+   * on the wire — files, line ranges, bytes. It rides the result because the
+   * call may have run on a query-pool worker, while the session state it feeds
+   * lives on the main thread. {@link ToolHandler.execute} records it and DELETES
+   * it, so nothing here ever reaches the client. Keyed by
+   * {@link EXPLORE_EMISSION_KEY}; the two must stay in sync.
+   */
+  _cgExploreEmission?: ExploreEmission;
 }
 
 /**
@@ -1805,9 +1824,19 @@ export class ToolHandler {
   }
 
   /**
-   * Execute a tool by name
+   * Execute a tool by name.
+   *
+   * `sessionState` is the CALLER's per-session explore history (CG-17). The
+   * daemon shares one ToolHandler across every connected session, so this state
+   * cannot live on the handler — each session owns one and hands it in, which is
+   * what keeps two sessions on one daemon from ever seeing each other's calls.
+   * Omit it (the CLI does) and explore behaves exactly as before, untracked.
    */
-  async execute(toolName: string, args: Record<string, unknown>): Promise<ToolResult> {
+  async execute(
+    toolName: string,
+    args: Record<string, unknown>,
+    sessionState?: ExploreSessionState,
+  ): Promise<ToolResult> {
     try {
       // Block the first tool call on the engine's post-open reconcile so we
       // never serve rows for files deleted/edited while no MCP server was
@@ -1869,9 +1898,20 @@ export class ToolHandler {
       // cross-cutting notices — worktree-index mismatch (#155) and per-file
       // staleness (#403) — which need the watched MAIN instance and so are
       // always applied here, never in the worker.
-      const result = (this.queryPool && this.queryPool.healthy && this.queryPool.ready)
-        ? await this.queryPool.run(toolName, args)
-        : await this.executeReadTool(toolName, args);
+      //
+      // Explore also carries the session's own call history down (CG-17) and its
+      // emission record back up. Both travel as plain properties — on the args
+      // object down, on the ToolResult up — because either leg may cross a
+      // structured-clone boundary into a worker, where a closure or a handler
+      // field could not follow.
+      const dispatchArgs = this.withSessionView(toolName, args, sessionState);
+      const raw = (this.queryPool && this.queryPool.healthy && this.queryPool.ready)
+        ? await this.queryPool.run(toolName, dispatchArgs)
+        : await this.executeReadTool(toolName, dispatchArgs);
+      // Record + STRIP before anything else touches the result: the emission is
+      // internal bookkeeping and must never reach the client, whether or not a
+      // caller passed session state.
+      const result = this.takeExploreEmission(raw, sessionState);
       const withWorktree = this.withWorktreeNotice(result, args.projectPath as string | undefined);
       return this.withStalenessNotice(withWorktree, args.projectPath as string | undefined);
     } catch (err) {
@@ -1893,6 +1933,57 @@ export class ToolHandler {
     }
   }
 
+  /**
+   * Attach the caller's session view to an explore call's args (CG-17), on a
+   * COPY so the caller's object is never mutated. Nothing else sees it: a
+   * non-explore tool, or a caller with no session state, gets the args
+   * unchanged and pays nothing.
+   *
+   * A client that spells the internal key itself is stripped rather than
+   * trusted — the view decides what source a later call may withhold, so it has
+   * to come from the server's own record, never from the wire.
+   */
+  private withSessionView(
+    toolName: string,
+    args: Record<string, unknown>,
+    sessionState: ExploreSessionState | undefined,
+  ): Record<string, unknown> {
+    if (!(EXPLORE_SESSION_VIEW_ARG in args) && (!sessionState || toolName !== 'codegraph_explore')) {
+      return args;
+    }
+    const copy = { ...args };
+    delete copy[EXPLORE_SESSION_VIEW_ARG];
+    if (sessionState && toolName === 'codegraph_explore') {
+      copy[EXPLORE_SESSION_VIEW_ARG] = sessionState.view();
+    }
+    return copy;
+  }
+
+  /**
+   * Record an explore call's emission into the caller's session state and strip
+   * it from the result (CG-17).
+   *
+   * Unconditional strip: the property is internal, so it comes off even when
+   * there is no session state to record it into (the CLI path) — that is what
+   * keeps the agent-facing response byte-identical. Recording is wrapped
+   * because a bookkeeping bug must never fail a tool call that already
+   * succeeded.
+   */
+  private takeExploreEmission(
+    result: ToolResult,
+    sessionState: ExploreSessionState | undefined,
+  ): ToolResult {
+    const emission = result?.[EXPLORE_EMISSION_KEY];
+    if (emission === undefined) return result;
+    delete result[EXPLORE_EMISSION_KEY];
+    if (sessionState) {
+      try {
+        sessionState.record(emission);
+      } catch { /* bookkeeping only — never fail a served call */ }
+    }
+    return result;
+  }
+
   /**
    * Run a single read tool to completion and return its raw {@link ToolResult},
    * classifying expected failures the same way {@link execute}'s catch does so
@@ -3029,6 +3120,29 @@ export class ToolHandler {
     // byte-identical. It only OBSERVES: it must never feed back into rendering.
     const diag = ExploreDiagnostics.start(query, projectRoot, budget, maxFiles, indexedFileCount);
 
+    // What this session has already been served for THIS project (CG-17).
+    // Read-only at this stage — it is reported in the diagnostic and nothing
+    // else, so the response is unchanged. Cross-call dedup (CG-18) and budget
+    // decay (CG-19) are the consumers this exists for.
+    const priorCalls = viewForProject(readExploreSessionView(args), projectRoot);
+    diag?.noteSession(priorCalls);
+
+    // What this call ends up emitting, per file — the record handed back to the
+    // session state on the main thread. Filled by every render path below, then
+    // filtered to the files that SURVIVE the final hard-ceiling cut, so the
+    // record is what the agent actually received rather than what the loop
+    // hoped to send.
+    const emittedByFile = new Map<string, { ranges: ExploreLineRange[]; bytes: number }>();
+    const noteEmitted = (fp: string, ranges: ExploreLineRange[], bytes: number): void => {
+      const existing = emittedByFile.get(fp);
+      if (existing) {
+        existing.ranges.push(...ranges);
+        existing.bytes += bytes;
+      } else {
+        emittedByFile.set(fp, { ranges: [...ranges], bytes });
+      }
+    };
+
     // Step 1: Find relevant context with generous parameters.
     // Use a large maxNodes budget — explore has its own 35k char output limit
     // that prevents context bloat, so more nodes just means better coverage
@@ -3042,7 +3156,12 @@ export class ToolHandler {
 
     if (subgraph.nodes.size === 0) {
       diag?.finishEmpty('no relevant code found — empty subgraph');
-      return this.textResult(`No relevant code found for "${query}"`);
+      const empty = `No relevant code found for "${query}"`;
+      // Still an explore call, so it is still recorded: an empty answer spends a
+      // call against the tier budget even though it emits no source.
+      return this.exploreResult(empty, {
+        projectRoot, query, files: [], sourceBytes: 0, responseBytes: empty.length,
+      });
     }
 
     // Graph-aware glue: findRelevantContext builds the subgraph from name/text
@@ -4014,6 +4133,7 @@ export class ToolHandler {
         // signature line (capped, with a "+N more" tail so the structure map of a
         // god-file doesn't itself bloat the budget).
         const skel: string[] = [];
+        const skelRanges: ExploreLineRange[] = [];
         let coveredUntil = 0; // skip symbols already inside an emitted body
         let sigCount = 0, sigDropped = 0;
         const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
@@ -4023,6 +4143,7 @@ export class ToolHandler {
             const end = n.endLine;
             const body = fileLines.slice(n.startLine - 1, end).join('\n');
             skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
+            skelRanges.push({ start: n.startLine, end });
             coveredUntil = end;
           } else {
             // Elide the body, emit the signature. node.startLine can point at a
@@ -4034,7 +4155,11 @@ export class ToolHandler {
             if (lineNo <= coveredUntil) continue;
             if (sigCount >= SIG_MAX) { sigDropped++; continue; }
             const sig = (fileLines[lineNo - 1] || '').trim();
-            if (sig) { skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig); sigCount++; }
+            if (sig) {
+              skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
+              skelRanges.push({ start: lineNo, end: lineNo });
+              sigCount++;
+            }
           }
         }
         if (sigDropped > 0) skel.push(`… +${sigDropped} more (signatures elided)`);
@@ -4055,6 +4180,7 @@ export class ToolHandler {
           sourceSpent += skel.join('\n').length;
           // Always "clipped": the per-symbol view elides bodies by construction.
           diag?.recordRender(filePath, bodyIds.size > 0 ? 'focused' : 'skeleton', skel.join('\n').length, true);
+          noteEmitted(filePath, skelRanges, skel.join('\n').length);
           renderedFilePaths.push(filePath);
           filesIncluded++;
           continue;
@@ -4159,6 +4285,8 @@ export class ToolHandler {
         totalChars += wholeSection.length + 200;
         sourceSpent += wholeSection.length;
         diag?.recordRender(filePath, 'whole', wholeSection.length, false);
+        // The whole file, minus any trailing blank lines the render trimmed.
+        noteEmitted(filePath, [{ start: 1, end: body.split('\n').length }], wholeSection.length);
         renderedFilePaths.push(filePath);
         filesIncluded++;
         if (fileStale) staleRendered.push(filePath);
@@ -4325,28 +4453,42 @@ export class ToolHandler {
       // the spine's call still appears in context.
       const OVERSIZE_SPINE_LINES = 200;
       const SPINE_WINDOW = 28; // lines each side of the next-hop call site
-      const buildSection = (c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number }): string => {
+      // Returns the rendered text AND the line spans it covers. The spans are
+      // what the session record is built from (CG-17): reporting them from the
+      // same function that slices the source is what keeps the record honest —
+      // a second function mirroring these window/padding rules would drift, and
+      // a record that claims lines it never sent withholds them from a later
+      // call, which costs a Read.
+      const buildSection = (
+        c: { start: number; end: number; hasSpine?: boolean; spineCallLine?: number },
+      ): { text: string; ranges: ExploreLineRange[] } => {
         if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
           const call = c.spineCallLine;
           const winStart = Math.max(c.start, call - SPINE_WINDOW);
           const winEnd = Math.min(c.end, call + SPINE_WINDOW);
           const parts: string[] = [];
+          const spans: ExploreLineRange[] = [];
           // Signature head, only when it sits clearly above the window (else the
           // window already covers the method opening).
           const headEnd = Math.min(c.start + 4, winStart - 2);
           if (headEnd >= c.start) {
             const head = fileLines.slice(c.start - 1, headEnd).join('\n');
             parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
+            spans.push({ start: c.start, end: headEnd });
           }
           const win = fileLines.slice(winStart - 1, winEnd).join('\n');
           parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
-          return parts.join(GAP_MARKER);
+          spans.push({ start: winStart, end: winEnd });
+          return { text: parts.join(GAP_MARKER), ranges: spans };
         }
         const startIdx = Math.max(0, c.start - 1 - contextPadding);
         const endIdx = Math.min(fileLines.length, c.end + contextPadding);
         const slice = fileLines.slice(startIdx, endIdx).join('\n');
         // startIdx is 0-based, so the slice's first line is line startIdx + 1.
-        return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
+        return {
+          text: withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice,
+          ranges: [{ start: startIdx + 1, end: endIdx }],
+        };
       };
 
       /**
@@ -4365,7 +4507,10 @@ export class ToolHandler {
        * body is never cut, and the members are chosen by the same importance the
        * cluster ranking uses. Returns null when nothing needed shrinking.
        */
-      const shrinkCluster = (c: ExploreCluster, cap: number): string | null => {
+      const shrinkCluster = (
+        c: ExploreCluster,
+        cap: number,
+      ): { text: string; ranges: ExploreLineRange[] } | null => {
         if (c.members.length < 2) return null;
         const byImportance = [...c.members].sort((a, b) =>
           b.importance - a.importance || (a.end - a.start) - (b.end - b.start) || a.start - b.start);
@@ -4390,7 +4535,11 @@ export class ToolHandler {
           if (last && r.start <= last.end + gapThreshold) last.end = Math.max(last.end, r.end);
           else merged.push({ start: r.start, end: r.end });
         }
-        return merged.map((m) => buildSection(m)).join(GAP_MARKER);
+        const sections = merged.map((m) => buildSection(m));
+        return {
+          text: sections.map((s) => s.text).join(GAP_MARKER),
+          ranges: sections.flatMap((s) => s.ranges),
+        };
       };
 
       // Rank clusters for inclusion under the per-file cap. Entry-point
@@ -4439,10 +4588,10 @@ export class ToolHandler {
       const chosenIndices = new Set<number>();
       // Shrunk renders for oversize clusters, by cluster index (CG-12). Computed
       // during selection and reused at emission so the two never disagree.
-      const shrunkSections = new Map<number, string>();
+      const shrunkSections = new Map<number, { text: string; ranges: ExploreLineRange[] }>();
       let projectedChars = 0;
       for (const rc of rankedClusters) {
-        const sectionLen = buildSection(rc.c).length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
+        const sectionLen = buildSection(rc.c).text.length + (chosenIndices.size > 0 ? GAP_MARKER.length : 0);
         // The top-ranked cluster is always taken — an empty file section sends the
         // agent to Read, negating the savings. But "always taken" is not "taken at
         // any size": when it overruns the reservation it is SHRUNK to the
@@ -4453,7 +4602,7 @@ export class ToolHandler {
           const shrunk = sectionLen > cap ? shrinkCluster(rc.c, cap) : null;
           if (shrunk !== null) shrunkSections.set(rc.idx, shrunk);
           chosenIndices.add(rc.idx);
-          projectedChars += shrunk !== null ? shrunk.length : sectionLen;
+          projectedChars += shrunk !== null ? shrunk.text.length : sectionLen;
           continue;
         }
         // A spine cluster (the rendered call path) is the flow answer — include it
@@ -4469,12 +4618,14 @@ export class ToolHandler {
       // Emit chosen clusters in source order so the file reads top-to-bottom.
       let fileSection = '';
       const allSymbols: string[] = [];
+      const sectionRanges: ExploreLineRange[] = [];
       for (let i = 0; i < clusters.length; i++) {
         if (!chosenIndices.has(i)) continue;
         const cluster = clusters[i]!;
         const section = shrunkSections.get(i) ?? buildSection(cluster);
         if (fileSection.length > 0) fileSection += GAP_MARKER;
-        fileSection += section;
+        fileSection += section.text;
+        sectionRanges.push(...section.ranges);
         allSymbols.push(...cluster.symbols);
       }
 
@@ -4532,6 +4683,7 @@ export class ToolHandler {
       totalChars += fileSection.length + 200;
       sourceSpent += fileSection.length;
       diag?.recordRender(filePath, 'clusters', fileSection.length, chosenIndices.size < clusters.length);
+      noteEmitted(filePath, sectionRanges, fileSection.length);
       renderedFilePaths.push(filePath);
       filesIncluded++;
     }
@@ -4678,7 +4830,35 @@ export class ToolHandler {
     // shares account for the hard-ceiling truncation above (CG-4).
     diag?.finish(finalText, output.length, hardCeiling, filesIncluded);
 
-    return this.textResult(finalText);
+    // Session record (CG-17): only the files that SURVIVED the hard ceiling —
+    // a section the truncation dropped was never delivered, and recording it
+    // would let a later call withhold source the agent has never seen.
+    const emittedFiles: ExploreFileEmission[] = [];
+    let sourceBytes = 0;
+    for (const fp of survivors) {
+      const emitted = emittedByFile.get(fp);
+      if (!emitted || emitted.bytes <= 0) continue;
+      emittedFiles.push({ path: fp, ranges: emitted.ranges, bytes: emitted.bytes });
+      sourceBytes += emitted.bytes;
+    }
+    return this.exploreResult(finalText, {
+      projectRoot,
+      query,
+      files: emittedFiles,
+      sourceBytes,
+      responseBytes: finalText.length,
+    });
+  }
+
+  /**
+   * An explore response plus the record of what it emitted (CG-17). The record
+   * rides the result only as far as {@link execute}, which files it into the
+   * calling session's state and deletes it — see {@link EXPLORE_EMISSION_KEY}.
+   */
+  private exploreResult(text: string, emission: ExploreEmission): ToolResult {
+    const result = this.textResult(text);
+    result[EXPLORE_EMISSION_KEY] = emission;
+    return result;
   }
 
   /**