explore-session-state.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368
  1. /**
  2. * Session-scoped `codegraph_explore` call state (CG-17).
  3. *
  4. * What it holds: for ONE MCP session, per project it queried, what explore has
  5. * already returned — the files, the line ranges of source inside them, the bytes
  6. * they cost, and where in the session each call fell. Nothing else in the server
  7. * knows this today: every explore call is answered as if it were the first one,
  8. * which is why a 4th call happily re-serves the same spine it already sent
  9. * (#1500) and why the tier's call budget can only be *asked* for rather than
  10. * enforced. This module is the record those two behaviours are built on
  11. * (CG-18 cross-call dedup, CG-19 budget decay). It changes no response itself.
  12. *
  13. * Four constraints shape the design, all of them from how the daemon actually
  14. * runs:
  15. *
  16. * 1. **Per session, never persisted.** One instance is owned by an
  17. * {@link ../mcp/session.MCPSession} and dies with the socket. A new agent
  18. * session starts clean — dedup across sessions would suppress source the
  19. * new agent has never seen.
  20. * 2. **Per project inside the session.** A session can query several projects
  21. * by `projectPath`, so state is keyed by the RESOLVED project root
  22. * (`cg.getProjectRoot()`), not by whatever path the agent typed.
  23. * 3. **Bounded.** A long-lived session must not grow without limit, so
  24. * everything is capped — see {@link EXPLORE_SESSION_LIMITS}. Eviction drops
  25. * DETAIL only: `callCount` and `responseBytes` keep counting past it, since
  26. * decay (CG-19) reads the count and must not be reset by its own bound.
  27. * 4. **Daemon-safe.** The daemon shares ONE {@link ../mcp/tools.ToolHandler}
  28. * (and a pool of worker threads) across every connected session, so this
  29. * state can live neither on the handler nor in a worker. It lives on the
  30. * session; the handler is handed it per call, and the record of what a call
  31. * emitted travels back on the {@link ToolResult} so it can be recorded on
  32. * the main thread whether dispatch ran in-process or on a worker.
  33. *
  34. * Over- vs under-reporting: where a bound forces a choice, this module keeps
  35. * FEWER ranges than were emitted, never more. A consumer that under-knows
  36. * re-serves something the agent already has (wasteful); one that over-knows
  37. * withholds source the agent never saw (a Read — the failure this whole area
  38. * exists to prevent).
  39. */
  40. import * as path from 'path';
  41. /**
  42. * Property on a {@link ../mcp/tools.ToolResult} carrying what an explore call
  43. * emitted. INTERNAL: `ToolHandler.execute` records it and deletes it before the
  44. * result reaches the wire, so the agent-facing response is unchanged. It is a
  45. * plain-object property (not a Symbol) on purpose — it has to survive the
  46. * structured clone back from a query-pool worker.
  47. */
  48. export const EXPLORE_EMISSION_KEY = '_cgExploreEmission';
  49. /**
  50. * Argument key carrying this session's prior-call view INTO a tool call. Same
  51. * reasoning as {@link EXPLORE_EMISSION_KEY}: it crosses the worker boundary, so
  52. * it must be a serializable property on the args object.
  53. */
  54. export const EXPLORE_SESSION_VIEW_ARG = '_cgExploreSession';
  55. /** An inclusive 1-based line span of a file that was emitted. */
  56. export interface ExploreLineRange {
  57. start: number;
  58. end: number;
  59. }
  60. /** What one call emitted for one file. */
  61. export interface ExploreFileEmission {
  62. /** Project-relative path, exactly as the response's file header spells it. */
  63. path: string;
  64. /** Coalesced line spans whose source was in the response. */
  65. ranges: ExploreLineRange[];
  66. /** Source chars emitted for this file (excludes headers / fences). */
  67. bytes: number;
  68. /**
  69. * Identity of the bytes those ranges were sliced from (CG-18). Cross-call
  70. * dedup withholds a span only when the file still hashes to this, so an edit
  71. * between two calls re-serves instead of pointing at source the agent holds a
  72. * now-wrong copy of. Absent = unprovable, which dedup treats as "re-serve".
  73. */
  74. fingerprint?: string;
  75. /** Set when ranges were dropped to stay under the per-file bound. */
  76. rangesTruncated?: boolean;
  77. }
  78. /** What one explore call emitted, as reported by the handler. */
  79. export interface ExploreEmission {
  80. /** Resolved project root — the key state is filed under. */
  81. projectRoot: string;
  82. /** Normalized query text (post `normalizeQuerySpelling`). */
  83. query: string;
  84. files: ExploreFileEmission[];
  85. /** Source chars across all files. */
  86. sourceBytes: number;
  87. /** Total chars of the response the agent received. */
  88. responseBytes: number;
  89. }
  90. /** A recorded call: an emission plus where it fell in the session. */
  91. export interface ExploreCallRecord extends ExploreEmission {
  92. /** 1-based call index within this session FOR THIS PROJECT. Survives eviction. */
  93. index: number;
  94. }
  95. /** Everything the session knows about one project. */
  96. export interface ExploreProjectState {
  97. projectRoot: string;
  98. /** Explore calls made this session against this project, including evicted ones. */
  99. callCount: number;
  100. /** Response chars across every call, including evicted ones. */
  101. responseBytes: number;
  102. /** Retained call records, oldest first. Bounded — may omit early calls. */
  103. calls: ExploreCallRecord[];
  104. }
  105. /**
  106. * The bounded, serializable read-view handed to a tool call. Deliberately
  107. * smaller than the full state: only the most recent calls carry their ranges,
  108. * because that is what a dedup/decay decision reads and the whole thing is
  109. * structured-cloned to a worker on every call.
  110. */
  111. export interface ExploreSessionView {
  112. projects: ExploreProjectState[];
  113. }
  114. /**
  115. * Memory bounds. Every one of them caps DETAIL; none caps the counters that
  116. * CG-19's decay reads.
  117. *
  118. * Sized against how sessions actually behave: an agent explores one project
  119. * (occasionally a second in a monorepo) and the tier call budget is 1–5, so the
  120. * retained window covers a whole realistic session and the caps only bite on
  121. * pathological ones.
  122. */
  123. export const EXPLORE_SESSION_LIMITS = {
  124. /** Distinct projects kept per session; least-recently-used evicted first. */
  125. MAX_PROJECTS: 4,
  126. /** Call records kept per project (oldest dropped; `callCount` keeps counting). */
  127. MAX_CALLS_RETAINED: 8,
  128. /** Files kept per call — the ones that got the most source. */
  129. MAX_FILES_PER_CALL: 24,
  130. /** Line ranges kept per file after coalescing — the largest spans. */
  131. MAX_RANGES_PER_FILE: 24,
  132. /** Most-recent calls per project included in {@link ExploreSessionView}. */
  133. MAX_VIEW_CALLS: 4,
  134. } as const;
  135. /**
  136. * Key a project root is filed under. Resolved so `/repo` and `/repo/` agree;
  137. * case-folded on the two platforms whose filesystems are case-insensitive, so a
  138. * drive-letter or capitalization difference doesn't split one project in two.
  139. */
  140. export function exploreProjectKey(projectRoot: string): string {
  141. const resolved = path.resolve(projectRoot);
  142. return process.platform === 'win32' || process.platform === 'darwin'
  143. ? resolved.toLowerCase()
  144. : resolved;
  145. }
  146. /**
  147. * Merge overlapping / adjacent spans into the smallest equivalent set, then cap
  148. * it. Adjacency (`next.start <= cur.end + 1`) counts as overlap: two ranges that
  149. * touch describe one contiguous block of emitted source.
  150. *
  151. * When the cap bites, the LARGEST spans are kept and the result is re-sorted by
  152. * line so the set still reads top-to-bottom — dropping small fragments loses the
  153. * least information, and under-reporting is the safe direction (see the module
  154. * header).
  155. */
  156. export function coalesceRanges(
  157. ranges: ReadonlyArray<ExploreLineRange>,
  158. max: number = EXPLORE_SESSION_LIMITS.MAX_RANGES_PER_FILE,
  159. ): { ranges: ExploreLineRange[]; truncated: boolean } {
  160. const valid = ranges
  161. .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end >= r.start && r.start >= 1)
  162. .map((r) => ({ start: Math.floor(r.start), end: Math.floor(r.end) }))
  163. .sort((a, b) => a.start - b.start || a.end - b.end);
  164. const merged: ExploreLineRange[] = [];
  165. for (const r of valid) {
  166. const last = merged[merged.length - 1];
  167. if (last && r.start <= last.end + 1) last.end = Math.max(last.end, r.end);
  168. else merged.push({ ...r });
  169. }
  170. if (merged.length <= max) return { ranges: merged, truncated: false };
  171. const kept = [...merged]
  172. .sort((a, b) => (b.end - b.start) - (a.end - a.start) || a.start - b.start)
  173. .slice(0, max)
  174. .sort((a, b) => a.start - b.start);
  175. return { ranges: kept, truncated: true };
  176. }
  177. /** Whether a line falls inside any of the (sorted, coalesced) ranges. */
  178. export function rangesCover(ranges: ReadonlyArray<ExploreLineRange>, line: number): boolean {
  179. return ranges.some((r) => line >= r.start && line <= r.end);
  180. }
  181. interface MutableProjectState {
  182. projectRoot: string;
  183. callCount: number;
  184. responseBytes: number;
  185. calls: ExploreCallRecord[];
  186. }
  187. /**
  188. * One MCP session's explore history. Created per session, thrown away with it.
  189. *
  190. * Not thread-shared and not a singleton: two sessions on the same daemon own two
  191. * instances and can never observe each other's calls. Every method is total —
  192. * malformed input is normalized away rather than thrown, because this sits on
  193. * the tool-call path and a bookkeeping bug must never fail an explore.
  194. */
  195. export class ExploreSessionState {
  196. /** Insertion-ordered; a touched project is re-inserted, so the head is the LRU. */
  197. private readonly projects = new Map<string, MutableProjectState>();
  198. /**
  199. * File an emission. Returns the record as stored (with its session call
  200. * index), or `null` if the emission was unusable.
  201. */
  202. record(emission: ExploreEmission): ExploreCallRecord | null {
  203. if (!emission || typeof emission.projectRoot !== 'string' || !emission.projectRoot) return null;
  204. const key = exploreProjectKey(emission.projectRoot);
  205. const state = this.touch(key, emission.projectRoot);
  206. state.callCount += 1;
  207. state.responseBytes += Math.max(0, emission.responseBytes || 0);
  208. const record: ExploreCallRecord = {
  209. index: state.callCount,
  210. projectRoot: emission.projectRoot,
  211. query: typeof emission.query === 'string' ? emission.query : '',
  212. files: this.boundFiles(emission.files),
  213. sourceBytes: Math.max(0, emission.sourceBytes || 0),
  214. responseBytes: Math.max(0, emission.responseBytes || 0),
  215. };
  216. state.calls.push(record);
  217. if (state.calls.length > EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED) {
  218. state.calls.splice(0, state.calls.length - EXPLORE_SESSION_LIMITS.MAX_CALLS_RETAINED);
  219. }
  220. return record;
  221. }
  222. /** Full state for one project, or `null` if it was never queried this session. */
  223. forProject(projectRoot: string): ExploreProjectState | null {
  224. const state = this.projects.get(exploreProjectKey(projectRoot));
  225. return state ? cloneProject(state) : null;
  226. }
  227. /** Explore calls made this session against a project (including evicted ones). */
  228. callCount(projectRoot: string): number {
  229. return this.projects.get(exploreProjectKey(projectRoot))?.callCount ?? 0;
  230. }
  231. /** Every project this session has queried, least-recently-used first. */
  232. snapshot(): ExploreProjectState[] {
  233. return [...this.projects.values()].map(cloneProject);
  234. }
  235. /**
  236. * The bounded view passed INTO a tool call. Trimmed to the most recent
  237. * {@link EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS} calls per project: it crosses a
  238. * worker boundary on every explore, so it carries what a dedup/decay decision
  239. * needs and not the whole history.
  240. */
  241. view(): ExploreSessionView {
  242. return {
  243. projects: [...this.projects.values()].map((state) => ({
  244. projectRoot: state.projectRoot,
  245. callCount: state.callCount,
  246. responseBytes: state.responseBytes,
  247. calls: state.calls
  248. .slice(-EXPLORE_SESSION_LIMITS.MAX_VIEW_CALLS)
  249. .map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })),
  250. })),
  251. };
  252. }
  253. /** Drop everything. Used by tests; a real session just goes away instead. */
  254. clear(): void {
  255. this.projects.clear();
  256. }
  257. /**
  258. * Fetch a project's state, creating it if new, and mark it most-recently-used.
  259. * Evicts the LRU project past the bound — dropping a project entirely (rather
  260. * than its detail) is right here: a session that has moved on to four other
  261. * repos is not about to re-ask the first one.
  262. */
  263. private touch(key: string, projectRoot: string): MutableProjectState {
  264. const existing = this.projects.get(key);
  265. if (existing) {
  266. this.projects.delete(key);
  267. this.projects.set(key, existing);
  268. return existing;
  269. }
  270. const created: MutableProjectState = { projectRoot, callCount: 0, responseBytes: 0, calls: [] };
  271. this.projects.set(key, created);
  272. while (this.projects.size > EXPLORE_SESSION_LIMITS.MAX_PROJECTS) {
  273. const lru = this.projects.keys().next().value as string | undefined;
  274. if (lru === undefined) break;
  275. this.projects.delete(lru);
  276. }
  277. return created;
  278. }
  279. /**
  280. * Normalize + bound one call's files: coalesce each file's ranges, then keep
  281. * the files that got the most source. A call that renders more files than the
  282. * bound has already spread its envelope thin, so the tail files carry the
  283. * least — and losing them costs the least.
  284. */
  285. private boundFiles(files: ReadonlyArray<ExploreFileEmission> | undefined): ExploreFileEmission[] {
  286. if (!Array.isArray(files) || files.length === 0) return [];
  287. const normalized = files
  288. .filter((f) => f && typeof f.path === 'string' && f.path.length > 0)
  289. .map((f) => {
  290. const { ranges, truncated } = coalesceRanges(f.ranges ?? []);
  291. const out: ExploreFileEmission = { path: f.path, ranges, bytes: Math.max(0, f.bytes || 0) };
  292. if (typeof f.fingerprint === 'string' && f.fingerprint) out.fingerprint = f.fingerprint;
  293. if (truncated) out.rangesTruncated = true;
  294. return out;
  295. });
  296. if (normalized.length <= EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL) return normalized;
  297. return [...normalized]
  298. .sort((a, b) => b.bytes - a.bytes)
  299. .slice(0, EXPLORE_SESSION_LIMITS.MAX_FILES_PER_CALL);
  300. }
  301. }
  302. function cloneProject(state: MutableProjectState): ExploreProjectState {
  303. return {
  304. projectRoot: state.projectRoot,
  305. callCount: state.callCount,
  306. responseBytes: state.responseBytes,
  307. calls: state.calls.map((c) => ({ ...c, files: c.files.map((f) => ({ ...f, ranges: [...f.ranges] })) })),
  308. };
  309. }
  310. /**
  311. * Read the session view a caller injected into tool args, if any. Defensive:
  312. * the key is internal, but the args object comes off the wire, so a client that
  313. * spells it itself gets ignored rather than trusted into a crash.
  314. */
  315. export function readExploreSessionView(args: Record<string, unknown>): ExploreSessionView | null {
  316. const raw = args?.[EXPLORE_SESSION_VIEW_ARG];
  317. if (!raw || typeof raw !== 'object') return null;
  318. const projects = (raw as ExploreSessionView).projects;
  319. if (!Array.isArray(projects)) return null;
  320. return { projects: projects.filter((p) => p && typeof p.projectRoot === 'string') };
  321. }
  322. /**
  323. * This session's prior state for one project, from an injected view.
  324. *
  325. * `null` means NOBODY IS TRACKING (no view was injected — the CLI, a bare
  326. * handler). A view that simply hasn't seen this project yet returns an EMPTY
  327. * state, not null: the distinction matters to consumers, since "first call of a
  328. * tracked session" and "untracked" are different situations.
  329. */
  330. export function viewForProject(
  331. view: ExploreSessionView | null,
  332. projectRoot: string,
  333. ): ExploreProjectState | null {
  334. if (!view) return null;
  335. const key = exploreProjectKey(projectRoot);
  336. return view.projects.find((p) => exploreProjectKey(p.projectRoot) === key)
  337. ?? { projectRoot, callCount: 0, responseBytes: 0, calls: [] };
  338. }