{"version":3,"file":"read-dedup.d.ts","sourceRoot":"","sources":["../../../src/core/tools/read-dedup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,mFAAmF;AACnF,MAAM,WAAW,SAAS;IACzB,KAAK,EAAE,MAAM,CAAC;IACd,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,sEAAsE;AACtE,eAAO,MAAM,gBAAgB,EAAE,SAAuD,CAAC;AAEvF;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,GAAG,SAAS,CAStF;AAED,mDAAmD;AACnD,wBAAgB,aAAa,CAAC,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,GAAG,OAAO,CAEjE;AAOD;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,yBAAyB,CAAC;AAE3D,8DAA8D;AAC9D,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAExD;AAkBD,uDAAuD;AACvD,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAMnE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAGvE;AAED,2CAA2C;AAC3C,wBAAgB,eAAe,IAAI,IAAI,CAEtC;AAED,kEAAkE;AAClE,MAAM,WAAW,YAAY;IAC5B,0EAA0E;IAC1E,MAAM,EAAE,MAAM,CAAC;IACf,wEAAwE;IACxE,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,uEAAuE;IACvE,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,MAAM,WAAW,uBAAuB;IACvC,kDAAkD;IAClD,YAAY,EAAE,MAAM,CAAC;IACrB,4CAA4C;IAC5C,cAAc,EAAE,SAAS,CAAC;IAC1B,mFAAmF;IACnF,aAAa,EAAE,MAAM,CAAC;IACtB,mFAAmF;IACnF,WAAW,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,MAAM,CAAC;CACzC;AAoED;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,OAAO,EAAE,EAAE,IAAI,EAAE,uBAAuB,GAAG,YAAY,GAAG,IAAI,CA8GhH;AAED,gEAAgE;AAChE,MAAM,MAAM,mBAAmB,GAAG,IAAI,CAAC,YAAY,EAAE,SAAS,GAAG,OAAO,GAAG,KAAK,CAAC,CAAC;AAElF,8DAA8D;AAC9D,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,mBAAmB,GAAG,MAAM,CAQ3E","sourcesContent":["/**\n * Read de-duplication primitives.\n *\n * Two mechanisms share this module:\n *\n * - The post-hoc context GC (`context-gc.ts`), which stubs out a `read` result\n *   once a later overlapping read or an edit/write has superseded it.\n * - The at-call-time guard in the `read` tool, which short-circuits a read whose\n *   requested range is *already fully covered* by an earlier, still-live read in\n *   the current session, returning a pointer instead of re-fetching the file.\n *\n * Both reason about half-open line ranges `[start, end)` (end exclusive), so the\n * range math lives here and cannot drift between them. The guard additionally\n * needs to (a) recognise its own pointer results so they are never treated as\n * content-bearing reads, and (b) walk the session branch to find a covering\n * read — both of which are defined here to keep the tool file lean.\n */\n\n/** Half-open line interval `[start, end)` a read call covers; end is exclusive. */\nexport interface ReadRange {\n\tstart: number;\n\tend: number;\n}\n\n/** A read with no offset/limit covers the whole file (open-ended). */\nexport const WHOLE_FILE_RANGE: ReadRange = { start: 1, end: Number.POSITIVE_INFINITY };\n\n/**\n * Derive the line range a read call covers from its `offset`/`limit` args.\n * Missing offset means \"from line 1\"; missing limit means \"to end of file\"\n * (open-ended, so it overlaps any later read of the same file).\n */\nexport function readRangeFromArgs(args: Record<string, unknown> | undefined): ReadRange {\n\tconst offsetRaw = args?.offset;\n\tconst limitRaw = args?.limit;\n\tconst offset = typeof offsetRaw === \"number\" && Number.isFinite(offsetRaw) && offsetRaw > 0 ? offsetRaw : 1;\n\tconst end =\n\t\ttypeof limitRaw === \"number\" && Number.isFinite(limitRaw)\n\t\t\t? offset + Math.max(0, limitRaw)\n\t\t\t: Number.POSITIVE_INFINITY;\n\treturn { start: offset, end };\n}\n\n/** Whether two half-open line ranges intersect. */\nexport function rangesOverlap(a: ReadRange, b: ReadRange): boolean {\n\treturn a.start < b.end && b.start < a.end;\n}\n\n/** Whether `outer` fully contains `inner` (every line of inner lies within outer). */\nfunction rangeContains(outer: ReadRange, inner: ReadRange): boolean {\n\treturn outer.start <= inner.start && outer.end >= inner.end;\n}\n\n/**\n * Marker prefix for the at-call dedup pointer. Kept stable so the context GC can\n * recognise a pointer result and exclude it from supersession bookkeeping (a\n * pointer fetched no content, so it must not stub the read it points at).\n */\nexport const DEDUP_POINTER_PREFIX = \"[Already in context:\";\n\n/** Whether a tool-result text is an at-call dedup pointer. */\nexport function isDedupPointerText(text: string): boolean {\n\treturn text.trimStart().startsWith(DEDUP_POINTER_PREFIX);\n}\n\n/**\n * Content stamps for reads that have already been delivered.\n *\n * The dedup pointer tells the model the file \"has not changed since\" the read it\n * names. Nothing in the transcript can establish that: an editor, a formatter, a\n * branch switch or a second agent can rewrite the file between two reads without\n * leaving a trace in it. So each delivered read records a stamp of the file it\n * saw, and the pointer is only served when the file still stamps the same.\n *\n * Keyed by tool call id rather than by path, so two sessions sharing a process\n * cannot overwrite each other's observations. Bounded, and a missing entry\n * counts as \"cannot prove it is unchanged\" - the read then simply runs.\n */\nconst MAX_TRACKED_READS = 500;\nconst stampByCallId = new Map<string, string>();\n\n/** Remember the stamp of the file a read delivered. */\nexport function recordReadStamp(callId: string, stamp: string): void {\n\tif (stampByCallId.size >= MAX_TRACKED_READS) {\n\t\tconst oldest = stampByCallId.keys().next().value;\n\t\tif (oldest !== undefined) stampByCallId.delete(oldest);\n\t}\n\tstampByCallId.set(callId, stamp);\n}\n\n/**\n * Whether the file a read delivered still stamps the same. False when no stamp\n * was recorded, so an unprovable case re-reads rather than asserting freshness.\n */\nexport function readStampMatches(callId: string, stamp: string): boolean {\n\tconst recorded = stampByCallId.get(callId);\n\treturn recorded !== undefined && recorded === stamp;\n}\n\n/** Test seam: drop all recorded stamps. */\nexport function clearReadStamps(): void {\n\tstampByCallId.clear();\n}\n\n/** A covering earlier read, described for the pointer message. */\nexport interface CoveringRead {\n\t/** Tool call id of the read being pointed at, used to check its stamp. */\n\tcallId: string;\n\t/** The path as the earlier read spelled it (for a friendly pointer). */\n\tdisplay: string;\n\t/** Delivered range start (1-indexed line). */\n\tstart: number;\n\t/** Delivered range end (exclusive; Infinity for a whole-file read). */\n\tend: number;\n}\n\nexport interface FindCoveringReadOptions {\n\t/** Resolved absolute path of the current read. */\n\tresolvedPath: string;\n\t/** Range the current read is asking for. */\n\trequestedRange: ReadRange;\n\t/** Tool call id of the current read, excluded from candidate/supersession sets. */\n\tcurrentCallId: string;\n\t/** Resolve a raw read-arg path the same way the current read resolved its path. */\n\tresolvePath: (rawPath: string) => string;\n}\n\ninterface ContentBlock {\n\ttype?: string;\n\tid?: string;\n\tname?: string;\n\targuments?: Record<string, unknown>;\n\ttext?: string;\n}\n\ninterface MessageLike {\n\trole?: string;\n\tcontent?: ContentBlock[];\n\ttoolCallId?: string;\n\ttoolName?: string;\n\tisError?: boolean;\n}\n\nconst READ_TOOL = \"read\";\nconst MUTATE_TOOLS = new Set([\"edit\", \"write\"]);\n\n/** A session compaction entry — the boundary that trims the live context. */\nfunction isCompactionEntry(entry: unknown): boolean {\n\treturn !!entry && typeof entry === \"object\" && (entry as { type?: unknown }).type === \"compaction\";\n}\n\n/** Accept either raw `AgentMessage`s or session entries wrapping `.message`. */\nfunction toMessage(entry: unknown): MessageLike | null {\n\tif (!entry || typeof entry !== \"object\") return null;\n\tconst e = entry as { message?: unknown; role?: unknown };\n\tif (e.message && typeof e.message === \"object\") return e.message as MessageLike;\n\tif (typeof e.role === \"string\") return e as MessageLike;\n\treturn null;\n}\n\nfunction resultText(m: MessageLike): string {\n\treturn (m.content ?? [])\n\t\t.filter((c) => c.type === \"text\")\n\t\t.map((c) => c.text ?? \"\")\n\t\t.join(\"\");\n}\n\n/**\n * Recover the range a read result actually *delivered* from its text.\n *\n * A cap-truncated read announces `[Showing lines A-B of N ...]`, so it delivered\n * only `[A, B+1)` even though its args declared a wider range. A read whose first\n * line alone exceeded the byte cap delivered nothing. Any other (untruncated)\n * read delivered its full declared range. The user-`limit` early-stop notice\n * (`[N more lines in file ...]`) is *not* a cap truncation — the declared range\n * was delivered in full — so it falls through to `declared`.\n */\nfunction deliveredRange(text: string, declared: ReadRange): ReadRange {\n\t// The truncation notice is always the trailing `\\n\\n[Showing lines A-B of N ...]`\n\t// clause the read tool appends. Anchor to the end so a `[Showing lines ...]`\n\t// string that merely appears *inside* the file's content can't spoof it.\n\tconst showing = text.match(/\\n\\n\\[Showing lines (\\d+)-(\\d+) of \\d+[^\\]]*\\]\\s*$/);\n\tif (showing) {\n\t\tconst a = Number(showing[1]);\n\t\tconst b = Number(showing[2]);\n\t\tif (Number.isFinite(a) && Number.isFinite(b) && b >= a) return { start: a, end: b + 1 };\n\t}\n\t// First line alone exceeded the byte limit: the whole result *is* that notice,\n\t// so it must start the text. Nothing usable was delivered.\n\tif (/^\\[Line \\d+ is .+ exceeds .+ limit\\./.test(text)) return { start: 1, end: 1 };\n\treturn declared;\n}\n\n/**\n * Find the latest earlier read that (a) is for the same resolved path, (b)\n * actually delivered a range containing the requested range, and (c) is still\n * live in the outgoing context — i.e. the post-hoc GC will not have stubbed it,\n * because no later edit/write and no later overlapping content read supersede\n * it. Returns that read's delivered range for the pointer, or null when the\n * current read must actually run.\n *\n * Deliberately conservative: a truncated earlier read only covers what it\n * delivered, a whole-file read must have been delivered untruncated to count as\n * covering, and any pointer results (which fetched nothing) are ignored on both\n * the candidate and the supersession side.\n */\nexport function findCoveringRead(entries: readonly unknown[], opts: FindCoveringReadOptions): CoveringRead | null {\n\t// `declared` mirrors the range the GC uses for supersession (straight from the\n\t// call args); `delivered` is what the result text shows was actually returned,\n\t// used for coverage. The current call has no result yet, so it never appears.\n\tinterface PriorRead {\n\t\tindex: number;\n\t\tdeclared: ReadRange;\n\t\tdelivered: ReadRange;\n\t\tdisplay: string;\n\t\tcallId: string;\n\t}\n\n\t// Resolve each distinct raw path once — the resolver may hit the filesystem.\n\tconst resolveCache = new Map<string, string>();\n\tconst resolvePath = (raw: string): string => {\n\t\tconst hit = resolveCache.get(raw);\n\t\tif (hit !== undefined) return hit;\n\t\tconst resolved = opts.resolvePath(raw);\n\t\tresolveCache.set(raw, resolved);\n\t\treturn resolved;\n\t};\n\n\t// Single ordered pass. `readCall`/`mutateCallPath` map a call id to its path as\n\t// the call is seen (a toolCall always precedes its result); `reads` collects\n\t// the target path's reads and `lastMutateIndex` its last mutate. `order` gives\n\t// live-context position for the supersession/mutate comparisons.\n\tconst readCall = new Map<string, { resolved: string; display: string; declared: ReadRange }>();\n\tconst mutateCallPath = new Map<string, string>();\n\tconst reads: PriorRead[] = [];\n\tlet lastMutateIndex = -1;\n\t// A *failed* edit/write is not a mutate - the GC is right to ignore it, since\n\t// the file did not change. It is still the one moment the model most needs the\n\t// real bytes: the failure usually means its copy of the text does not match the\n\t// file. Serving a \"not re-fetched, unchanged\" pointer there leaves it retrying\n\t// the same wrong text with no way to see what is actually on disk.\n\tlet lastFailedMutateIndex = -1;\n\tlet order = 0;\n\n\tfor (const entry of entries) {\n\t\t// Compaction boundary: everything before it is replaced by a summary in the\n\t\t// live context, so drop the state accumulated so far. Conservative — the\n\t\t// kept tail before the boundary is dropped too — which can only miss a\n\t\t// dedup, never point at content that is no longer in context.\n\t\tif (isCompactionEntry(entry)) {\n\t\t\treadCall.clear();\n\t\t\tmutateCallPath.clear();\n\t\t\treads.length = 0;\n\t\t\tlastMutateIndex = -1;\n\t\t\torder = 0;\n\t\t\tcontinue;\n\t\t}\n\t\tconst m = toMessage(entry);\n\t\tif (!m) continue;\n\t\tconst i = order++;\n\t\tif (m.role === \"assistant\" && Array.isArray(m.content)) {\n\t\t\tfor (const b of m.content) {\n\t\t\t\tif (b.type !== \"toolCall\" || !b.id) continue;\n\t\t\t\tconst raw = b.arguments?.path;\n\t\t\t\tif (typeof raw !== \"string\" || raw.length === 0) continue;\n\t\t\t\tconst resolved = resolvePath(raw);\n\t\t\t\tif (b.name === READ_TOOL) {\n\t\t\t\t\treadCall.set(b.id, { resolved, display: raw, declared: readRangeFromArgs(b.arguments) });\n\t\t\t\t} else if (b.name && MUTATE_TOOLS.has(b.name)) {\n\t\t\t\t\tmutateCallPath.set(b.id, resolved);\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (m.role === \"toolResult\" && m.isError && m.toolCallId) {\n\t\t\tif (m.toolName && MUTATE_TOOLS.has(m.toolName) && mutateCallPath.get(m.toolCallId) === opts.resolvedPath) {\n\t\t\t\tlastFailedMutateIndex = i;\n\t\t\t}\n\t\t} else if (m.role === \"toolResult\" && !m.isError && m.toolCallId) {\n\t\t\tif (m.toolName === READ_TOOL) {\n\t\t\t\tif (m.toolCallId === opts.currentCallId) continue;\n\t\t\t\tconst info = readCall.get(m.toolCallId);\n\t\t\t\tif (!info || info.resolved !== opts.resolvedPath) continue;\n\t\t\t\tconst text = resultText(m);\n\t\t\t\t// A pointer fetched nothing: the GC excludes it from supersession, so we\n\t\t\t\t// must too (both as a candidate and as a superseder).\n\t\t\t\tif (isDedupPointerText(text)) continue;\n\t\t\t\treads.push({\n\t\t\t\t\tindex: i,\n\t\t\t\t\tdeclared: info.declared,\n\t\t\t\t\tdelivered: deliveredRange(text, info.declared),\n\t\t\t\t\tdisplay: info.display,\n\t\t\t\t\tcallId: m.toolCallId,\n\t\t\t\t});\n\t\t\t} else if (m.toolName && MUTATE_TOOLS.has(m.toolName)) {\n\t\t\t\tif (mutateCallPath.get(m.toolCallId) === opts.resolvedPath) lastMutateIndex = i;\n\t\t\t}\n\t\t}\n\t}\n\n\t// A read survives the GC iff no later mutate and no later read overlaps its\n\t// *declared* range — exactly the GC's own test — so predict it the same way.\n\tconst survivesGc = (r: PriorRead): boolean =>\n\t\tlastMutateIndex <= r.index && !reads.some((o) => o.index > r.index && rangesOverlap(o.declared, r.declared));\n\n\t// Nothing earlier can be trusted to satisfy this read once an edit against this\n\t// path has failed: let the read run and hand the model the current bytes.\n\tif (lastFailedMutateIndex > -1) return null;\n\n\tlet best: PriorRead | null = null;\n\tfor (const r of reads) {\n\t\t// Coverage uses the *delivered* range: a truncated read only holds what it returned.\n\t\tif (!rangeContains(r.delivered, opts.requestedRange)) continue;\n\t\tif (!survivesGc(r)) continue;\n\t\tif (!best || r.index > best.index) best = r;\n\t}\n\tif (!best) return null;\n\treturn { callId: best.callId, display: best.display, start: best.delivered.start, end: best.delivered.end };\n}\n\n/** The parts of a covering read the pointer message renders. */\nexport type CoveringReadDisplay = Pick<CoveringRead, \"display\" | \"start\" | \"end\">;\n\n/** Build the pointer text returned in place of a re-fetch. */\nexport function buildDedupPointerText(covering: CoveringReadDisplay): string {\n\tconst where =\n\t\tcovering.end === Number.POSITIVE_INFINITY\n\t\t\t? \"the entire file\"\n\t\t\t: covering.end - 1 > covering.start\n\t\t\t\t? `lines ${covering.start}-${covering.end - 1}`\n\t\t\t\t: `line ${covering.start}`;\n\treturn `${DEDUP_POINTER_PREFIX} ${covering.display} (${where}) was already read earlier in this session and has not changed since. Not re-fetched to save tokens — pass a different offset/limit, or edit the file, if you need other or newer content.]`;\n}\n"]}