explore-dedup.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. /**
  2. * Cross-call source dedup for `codegraph_explore` (CG-18).
  3. *
  4. * The session record (CG-17) knows what earlier calls already sent. This module
  5. * is the algebra that turns that record into a decision for the call being
  6. * rendered: of the line ranges this call WOULD emit, which does the agent
  7. * already hold, and what is genuinely new.
  8. *
  9. * Three rules shape everything here, and all three come from the same place —
  10. * an insufficient-feeling response is what sends an agent to Read, and one or
  11. * two of those early in a session teach it to abandon codegraph entirely
  12. * (CLAUDE.md):
  13. *
  14. * 1. **A pointer, never a bare omission.** Removed source is replaced by a
  15. * back-reference naming the file, the symbols, and the line span, worded so
  16. * it is unmistakable that the source was already delivered IN THIS
  17. * CONVERSATION and is still current. Silence reads as "codegraph didn't
  18. * find it".
  19. * 2. **Only prove-it dedup.** A span is withheld only when the file's bytes
  20. * are byte-identical to what was served (a content fingerprint, not an
  21. * mtime and not the index's drift flag). An edit between calls means the
  22. * agent's copy is wrong, so the source is re-emitted in full.
  23. * 3. **Cut chunks, not slivers.** Only a covered run of at least
  24. * {@link EXPLORE_DEDUP.MIN_COVERED_LINES} lines is worth replacing. Below
  25. * that the pointer costs more than the source, and shattering a block into
  26. * one-line fragments produces exactly the ragged output that reads as a
  27. * failure. Everything not withheld is emitted — where the algebra is
  28. * unsure, it re-serves.
  29. *
  30. * Which way to be wrong, restated for this layer: re-serving something the agent
  31. * has is a few hundred wasted chars; withholding something it never saw is a
  32. * Read. Every threshold below leans to the first.
  33. */
  34. import { createHash } from 'crypto';
  35. import type { ExploreLineRange, ExploreProjectState } from './explore-session-state';
  36. export const EXPLORE_DEDUP = {
  37. /**
  38. * Shortest already-served run that may be replaced by a back-reference.
  39. *
  40. * Sized against what dedup is actually FOR — a later call re-serving a whole
  41. * method or file it already sent. A shorter covered run is either a signature
  42. * line in a skeleton render or the ±3 lines of context padding around a
  43. * cluster, and swapping either for a pointer trades bytes for noise: the
  44. * pointer sentence is itself ~140 chars, so under this length dedup would
  45. * make the response BIGGER while making it read as full of holes.
  46. */
  47. MIN_COVERED_LINES: 8,
  48. /**
  49. * Below this many chars of NEW source, a file's remainder is folded into its
  50. * back-reference instead of being fenced on its own.
  51. *
  52. * The shape this exists for, seen on the CG-17 fixture: a third call whose
  53. * only unheld line was the file's trailing blank one, rendered as a code fence
  54. * containing `228\t`. A fence holding two lines of nothing reads as a broken
  55. * response, and reading as broken is the expensive failure — it is the thing
  56. * that sends an agent to Read and keeps it there. So a remainder this small is
  57. * dropped rather than shown. It is the one place this module withholds
  58. * something the agent has not seen, and it is bounded to ~two lines that sit
  59. * directly against source the agent does hold; the file is still named, with
  60. * its symbols, so one follow-up explore fetches it whole.
  61. */
  62. MIN_DELTA_CHARS: 160,
  63. /** Line spans named in one pointer before it summarises the rest. */
  64. MAX_SPANS_IN_POINTER: 4,
  65. /** Symbols named in one pointer before it summarises the rest. */
  66. MAX_SYMBOLS_IN_POINTER: 5,
  67. } as const;
  68. const ON = new Set(['1', 'true', 'on', 'yes']);
  69. /**
  70. * Cross-call source suppression is opt-in. An MCP connection is not a reliable
  71. * conversation boundary: some hosts reuse it for subagents, and compaction can
  72. * discard source while keeping the connection alive (#1620). Without a host-
  73. * supplied context lifecycle, re-serving source is the only always-correct
  74. * default. Read per call (not memoized) so tests and launchers can toggle it.
  75. */
  76. export function exploreDedupEnabled(): boolean {
  77. const raw = process.env.CODEGRAPH_EXPLORE_DEDUP;
  78. if (raw === undefined) return false;
  79. return ON.has(raw.trim().toLowerCase());
  80. }
  81. /**
  82. * Identity of the bytes a call served for one file.
  83. *
  84. * This — not the index's drift flag — is what gates dedup. `isFileStaleOnDisk`
  85. * answers "did the file change since the last INDEX SYNC", which is a different
  86. * question with a different answer: two calls inside one drift window served the
  87. * same current bytes (dedup is correct), while a file edited and re-synced
  88. * between two calls is never "stale" and yet the agent's copy is now wrong
  89. * (dedup would be actively harmful). Length is prefixed so a hash prefix
  90. * collision cannot alias two files of different size.
  91. */
  92. export function fileFingerprint(content: string): string {
  93. return `${content.length}:${createHash('sha1').update(content).digest('hex').slice(0, 16)}`;
  94. }
  95. /** Sort + merge overlapping/adjacent spans into the smallest equivalent set. */
  96. export function mergeRanges(ranges: ReadonlyArray<ExploreLineRange>): ExploreLineRange[] {
  97. const valid = ranges
  98. .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end >= r.start && r.start >= 1)
  99. .map((r) => ({ start: Math.floor(r.start), end: Math.floor(r.end) }))
  100. .sort((a, b) => a.start - b.start || a.end - b.end);
  101. const out: ExploreLineRange[] = [];
  102. for (const r of valid) {
  103. const last = out[out.length - 1];
  104. if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
  105. else out.push({ ...r });
  106. }
  107. return out;
  108. }
  109. /** The parts of `range` that `served` covers. */
  110. export function intersectRange(
  111. range: ExploreLineRange,
  112. served: ReadonlyArray<ExploreLineRange>,
  113. ): ExploreLineRange[] {
  114. const out: ExploreLineRange[] = [];
  115. for (const s of served) {
  116. const start = Math.max(range.start, s.start);
  117. const end = Math.min(range.end, s.end);
  118. if (end >= start) out.push({ start, end });
  119. }
  120. return mergeRanges(out);
  121. }
  122. /** The parts of `range` that `cut` does NOT cover. */
  123. export function subtractRange(
  124. range: ExploreLineRange,
  125. cut: ReadonlyArray<ExploreLineRange>,
  126. ): ExploreLineRange[] {
  127. const out: ExploreLineRange[] = [];
  128. let cursor = range.start;
  129. for (const c of mergeRanges(cut)) {
  130. if (c.end < cursor) continue;
  131. if (c.start > range.end) break;
  132. if (c.start > cursor) out.push({ start: cursor, end: Math.min(c.start - 1, range.end) });
  133. cursor = Math.max(cursor, c.end + 1);
  134. if (cursor > range.end) break;
  135. }
  136. if (cursor <= range.end) out.push({ start: cursor, end: range.end });
  137. return out;
  138. }
  139. /** What one intended span becomes once the session's history is applied. */
  140. export interface RangeDedup {
  141. /** Spans to render now — everything not proven-already-held. */
  142. emit: ExploreLineRange[];
  143. /** Spans replaced by a back-reference. */
  144. covered: ExploreLineRange[];
  145. }
  146. /**
  147. * Split one intended span into what to emit and what to point back at.
  148. *
  149. * Covered runs shorter than {@link EXPLORE_DEDUP.MIN_COVERED_LINES} are left in
  150. * the emit set on purpose (rule 3 above) — so a span the agent holds "almost
  151. * all of" still comes back whole rather than as a stutter of fragments around
  152. * pointers.
  153. */
  154. export function dedupeRange(
  155. range: ExploreLineRange,
  156. served: ReadonlyArray<ExploreLineRange>,
  157. minCovered: number = EXPLORE_DEDUP.MIN_COVERED_LINES,
  158. ): RangeDedup {
  159. if (served.length === 0 || range.end < range.start) return { emit: [range], covered: [] };
  160. const covered = intersectRange(range, served).filter((r) => r.end - r.start + 1 >= minCovered);
  161. if (covered.length === 0) return { emit: [range], covered: [] };
  162. return { emit: subtractRange(range, covered), covered };
  163. }
  164. /**
  165. * Every line span this session has already served for one file, but ONLY from
  166. * calls that served the SAME BYTES.
  167. *
  168. * A record with no fingerprint is ignored rather than trusted: it cannot prove
  169. * the agent's copy matches the file on disk now, and an unprovable match is
  170. * exactly the case where re-serving is right.
  171. */
  172. export function servedRangesForFile(
  173. prior: ExploreProjectState | null,
  174. filePath: string,
  175. fingerprint: string,
  176. ): ExploreLineRange[] {
  177. if (!prior) return [];
  178. const spans: ExploreLineRange[] = [];
  179. for (const call of prior.calls) {
  180. for (const file of call.files) {
  181. if (file.path !== filePath) continue;
  182. if (!file.fingerprint || file.fingerprint !== fingerprint) continue;
  183. spans.push(...file.ranges);
  184. }
  185. }
  186. return mergeRanges(spans);
  187. }
  188. /** `L12`, `L12-40`, capped with a `+N more` tail. */
  189. export function formatSpans(spans: ReadonlyArray<ExploreLineRange>): string {
  190. const shown = spans.slice(0, EXPLORE_DEDUP.MAX_SPANS_IN_POINTER)
  191. .map((r) => (r.start === r.end ? `L${r.start}` : `L${r.start}-${r.end}`))
  192. .join(', ');
  193. const more = spans.length - EXPLORE_DEDUP.MAX_SPANS_IN_POINTER;
  194. return more > 0 ? `${shown}, +${more} more span${more === 1 ? '' : 's'}` : shown;
  195. }
  196. /**
  197. * The line that replaces withheld source.
  198. *
  199. * It has one job: make the agent reach into its own context instead of into
  200. * Read. So it carries the three things needed to find the source it already has
  201. * — path, symbols, line spans — plus the two facts that make using it safe:
  202. * that it came from THIS conversation, and that the file has not changed since
  203. * (which is checked, not asserted — see {@link fileFingerprint}). It never says
  204. * "omitted", and it never steers to Read.
  205. */
  206. export function formatBackReference(
  207. filePath: string,
  208. covered: ReadonlyArray<ExploreLineRange>,
  209. symbols: ReadonlyArray<string>,
  210. opts: { partial: boolean },
  211. ): string {
  212. const names = symbols.slice(0, EXPLORE_DEDUP.MAX_SYMBOLS_IN_POINTER);
  213. const moreNames = symbols.length - names.length;
  214. const symbolPart = names.length > 0
  215. ? ` (${names.join(', ')}${moreNames > 0 ? `, +${moreNames} more` : ''})`
  216. : '';
  217. const head = `> **Already sent earlier in this conversation:** \`${filePath}\` ${formatSpans(covered)}${symbolPart}`;
  218. const tail = opts.partial
  219. ? ' — unchanged on disk since, so that copy is still exact. Only the NEW lines are shown below; scroll back for the rest. Do NOT Read this file.'
  220. : ' — unchanged on disk since, so that copy is still exact and is not repeated here. Use it from your context; do NOT Read this file.';
  221. return head + tail;
  222. }
  223. /** Symbol names whose definitions fall inside the withheld spans. */
  224. export function symbolsInSpans(
  225. nodes: ReadonlyArray<{ name: string; kind: string; startLine: number; endLine: number }>,
  226. spans: ReadonlyArray<ExploreLineRange>,
  227. ): string[] {
  228. const out: string[] = [];
  229. const seen = new Set<string>();
  230. for (const n of nodes) {
  231. if (n.kind === 'import' || n.kind === 'export') continue;
  232. if (!spans.some((s) => n.startLine <= s.end && (n.endLine || n.startLine) >= s.start)) continue;
  233. if (seen.has(n.name)) continue;
  234. seen.add(n.name);
  235. out.push(n.name);
  236. }
  237. return out;
  238. }