index.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. /**
  2. * A dependency-light **retention** library: bounded model-facing output for
  3. * tools that must cap how much context they return. A caller feeds items or
  4. * text chunks into a bounded object, then gets the retained content plus exact
  5. * omission metadata ({@link RetainedItems} / {@link RetainedText}).
  6. *
  7. * The library owns ONLY the mechanical question "what did we keep, what did we
  8. * omit?". Tool-specific code still owns
  9. * business semantics: file grouping, line numbering, exit codes, provider error
  10. * states, per-line preview truncation, spill files, and the model-facing prose.
  11. * In particular {@link RetainedText.truncated}/{@link RetainedItems.truncated}
  12. * means "the retainer omitted otherwise-available content because of a budget" —
  13. * NOT "the upstream was incomplete". Permission failures, skipped binaries,
  14. * provider partial failures, and unreadable candidates stay in tool-domain
  15. * fields, never folded into `truncated`.
  16. *
  17. * This is deliberately a library, not a cordis service or plugin: it takes no
  18. * `ctx`, registers nothing, and emits no events. The two retainers are the only
  19. * stateful pieces and their state is per-instance (one accumulation), never
  20. * cross-call. Tool packages import it directly when they need bounded output.
  21. *
  22. * The two retainers differ in resource model, which is why they are two names
  23. * rather than one generic collector:
  24. * - {@link ItemRetainer} bounds ordered logical units (paths, grep matches,
  25. * search sources). `head` retention only in v1.
  26. * - {@link TextRetainer} bounds byte-oriented text streams (bash stdout/stderr,
  27. * web bodies). `head` / `tail` / `headTail`, preserving UTF-8 boundaries at
  28. * {@link TextRetainer.finish}.
  29. *
  30. * @module @deepseek-ai/dsh-retention
  31. */
  32. /**
  33. * How much content the retainer omitted.
  34. *
  35. * `exact` is the normal retainer shape: every unit/byte was observed, so the
  36. * omitted count is precise. `unknown` is reserved for a caller that omits
  37. * without a count; the retainers themselves never return it.
  38. */
  39. export type Omitted =
  40. | { kind: 'none' }
  41. | { kind: 'exact'; count: number }
  42. | { kind: 'unknown' }
  43. /**
  44. * The caller receives this after each `push()`.
  45. */
  46. export interface PushDecision {
  47. /** Was this whole unit / all of this chunk's bytes retained (nothing dropped)? */
  48. kept: boolean
  49. /** Cumulative: has the retainer omitted anything due to the budget yet? */
  50. truncated: boolean
  51. }
  52. /**
  53. * Final result for ordered logical units.
  54. *
  55. * `seen` means units OBSERVED by the retainer, not necessarily the total in the
  56. * upstream source. `kept` is `items.length`, surfaced explicitly so a notice
  57. * formatter need not re-count.
  58. */
  59. export interface RetainedItems<T> {
  60. items: T[]
  61. truncated: boolean
  62. seen: number
  63. kept: number
  64. omitted: Omitted
  65. }
  66. /**
  67. * Final result for text streams.
  68. *
  69. * The returned `text` is safe to hand to a formatter: the retainer adds no
  70. * tool-specific headers, exit markers, XML tags, or recovery instructions, and
  71. * `omittedBytes` counts BYTES (not characters or lines) — text retention is
  72. * byte-oriented for process/body safety. UTF-8 boundaries at each cut are
  73. * preserved, so `text` never carries a replacement char introduced by the cut
  74. * itself.
  75. */
  76. export interface RetainedText {
  77. text: string
  78. truncated: boolean
  79. omittedBytes: Omitted
  80. }
  81. /** Item retention strategy. Only `head` in v1; windows/grouped budgets wait for a second consumer. */
  82. export type ItemRetentionStrategy = {
  83. /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
  84. kind: 'head'
  85. maxItems: number
  86. }
  87. /** Text retention strategy: keep a prefix, a suffix, or both, counted in bytes. */
  88. export type TextRetentionStrategy =
  89. | {
  90. /** Keep the first `maxBytes` bytes. */
  91. kind: 'head'
  92. maxBytes: number
  93. }
  94. | {
  95. /** Keep the final `maxBytes` bytes. Requires reading to the end. */
  96. kind: 'tail'
  97. maxBytes: number
  98. }
  99. | {
  100. /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
  101. kind: 'headTail'
  102. headBytes: number
  103. tailBytes: number
  104. }
  105. /**
  106. * A neutral, tool-agnostic description of one retention outcome — the input to
  107. * {@link formatRetentionNotice}. It carries the mechanical facts (strategy,
  108. * unit, limit, kept count, {@link Omitted}); the tool supplies the recovery
  109. * words, because only the tool knows the recovery action ("narrow the pattern",
  110. * "fetch a more specific URL", "read the spill file").
  111. */
  112. export interface RetentionNotice {
  113. /** Tool/scope label, e.g. `grep`, `web_fetch`, `bash stdout`. */
  114. scope: string
  115. strategy: 'head' | 'tail' | 'headTail'
  116. unit: 'items' | 'bytes' | 'chars' | 'lines'
  117. limit: number | { head: number; tail: number }
  118. kept: number
  119. omitted: Omitted
  120. }
  121. /** Assert a budget field is a non-negative integer (the retainer request contract). */
  122. function assertBudget(value: number, name: string): void {
  123. if (!Number.isInteger(value) || value < 0) {
  124. throw new Error(`${name} must be a non-negative integer`)
  125. }
  126. }
  127. /**
  128. * Bounds an ordered stream of logical units, keeping the first `maxItems`
  129. * ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it
  130. * was kept and whether the retained result is now truncated.
  131. *
  132. * Grouping, sorting, path mapping, per-unit preview truncation, and any
  133. * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing
  134. * more. The caller pushes already-shaped units and, after {@link finish},
  135. * groups/sorts the retained subset itself.
  136. */
  137. export class ItemRetainer<T> {
  138. private readonly maxItems: number
  139. private readonly items: T[] = []
  140. private seen = 0
  141. private omittedCount = 0
  142. /** @param strategy Head strategy: `maxItems` (non-negative integer). */
  143. constructor(strategy: ItemRetentionStrategy) {
  144. assertBudget(strategy.maxItems, 'maxItems')
  145. this.maxItems = strategy.maxItems
  146. }
  147. /**
  148. * Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped
  149. * and counted as omitted. Callers keep pushing all observed units, so the final
  150. * {@link Omitted} count is exact.
  151. *
  152. * @param item The already-shaped logical unit (path, flat match, source).
  153. * @returns The per-push {@link PushDecision}.
  154. */
  155. push(item: T): PushDecision {
  156. this.seen++
  157. if (this.items.length < this.maxItems) {
  158. // Reached only below the cap, before any omission (items only grow, the
  159. // cap is fixed), so nothing has been dropped yet: truncated is always false.
  160. this.items.push(item)
  161. return { kept: true, truncated: false }
  162. }
  163. this.omittedCount++
  164. return {
  165. kept: false,
  166. truncated: true,
  167. }
  168. }
  169. /**
  170. * Finalize and report what was kept and omitted.
  171. *
  172. * @returns The {@link RetainedItems} snapshot (safe to group/sort downstream).
  173. */
  174. finish(): RetainedItems<T> {
  175. const truncated = this.omittedCount > 0
  176. return {
  177. items: this.items,
  178. truncated,
  179. seen: this.seen,
  180. kept: this.items.length,
  181. omitted: truncated
  182. ? { kind: 'exact', count: this.omittedCount }
  183. : { kind: 'none' },
  184. }
  185. }
  186. }
  187. const encoder = new TextEncoder()
  188. const decoder = new TextDecoder() // utf-8, non-fatal: internal malformed bytes → U+FFFD
  189. /**
  190. * Drop a trailing incomplete UTF-8 sequence so a prefix cut never emits a
  191. * replacement char at the boundary. Walks back over continuation bytes
  192. * (`10xxxxxx`) to the lead byte; if fewer bytes follow it than the lead byte's
  193. * length declares, the sequence is incomplete and is trimmed. A complete tail,
  194. * or a run too long/short to be a valid lead, is returned untouched (any
  195. * genuinely malformed interior is left for the decoder to replace).
  196. */
  197. function trimTrailingPartialUtf8(bytes: Uint8Array): Uint8Array {
  198. let i = bytes.length - 1
  199. // Continuation bytes are 0b10xxxxxx; scan back at most 3 (max sequence is 4).
  200. // Indices are bounds-checked by the loop guard, so the reads are in range (a
  201. // cast, not `!`, per the repo's no-non-null-assertion rule).
  202. while (i >= 0 && ((bytes[i] as number) & 0xc0) === 0x80 && bytes.length - i <= 3) i--
  203. if (i < 0) return bytes
  204. const lead = bytes[i] as number
  205. const expected = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf8 ? 4 : 0
  206. // expected 0 → not a lead byte (stray continuation / invalid): leave it.
  207. if (expected === 0) return bytes
  208. return bytes.length - i < expected ? bytes.subarray(0, i) : bytes
  209. }
  210. /**
  211. * Drop leading continuation bytes (`10xxxxxx`) so a suffix cut starts on a
  212. * lead/ASCII byte instead of mid-codepoint.
  213. */
  214. function trimLeadingContinuationUtf8(bytes: Uint8Array): Uint8Array {
  215. let i = 0
  216. // i < length guards the read; cast rather than `!` (no-non-null-assertion).
  217. while (i < bytes.length && ((bytes[i] as number) & 0xc0) === 0x80) i++
  218. return bytes.subarray(i)
  219. }
  220. /**
  221. * Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both
  222. * ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix
  223. * accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both.
  224. *
  225. * Bytes, not characters: caps and `omittedBytes` are byte counts for process/
  226. * body safety. Chunks that straddle a codepoint are handled — {@link finish}
  227. * trims a partial codepoint at each cut so the returned text never introduces a
  228. * replacement char at the boundary. The retainer holds at most
  229. * `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped
  230. * as they slide out), so a large stream does not accumulate unbounded.
  231. */
  232. export class TextRetainer {
  233. private readonly prefixCap: number
  234. private readonly suffixCap: number
  235. private readonly prefixChunks: Uint8Array[] = []
  236. private prefixHeld = 0
  237. private readonly suffixChunks: Uint8Array[] = []
  238. private suffixHeld = 0
  239. private total = 0
  240. /** @param strategy One of the {@link TextRetentionStrategy} shapes; byte budgets must be non-negative integers. */
  241. constructor(strategy: TextRetentionStrategy) {
  242. switch (strategy.kind) {
  243. case 'head':
  244. assertBudget(strategy.maxBytes, 'maxBytes')
  245. this.prefixCap = strategy.maxBytes
  246. this.suffixCap = 0
  247. break
  248. case 'tail':
  249. assertBudget(strategy.maxBytes, 'maxBytes')
  250. this.prefixCap = 0
  251. this.suffixCap = strategy.maxBytes
  252. break
  253. case 'headTail':
  254. assertBudget(strategy.headBytes, 'headBytes')
  255. assertBudget(strategy.tailBytes, 'tailBytes')
  256. this.prefixCap = strategy.headBytes
  257. this.suffixCap = strategy.tailBytes
  258. break
  259. }
  260. }
  261. /**
  262. * Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix
  263. * bytes fill up to the prefix cap then stop; suffix bytes roll so only the
  264. * last `suffixCap` bytes are retained. `kept` is `true` only when no byte of
  265. * this chunk was dropped.
  266. *
  267. * @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`).
  268. * @returns The per-push {@link PushDecision}.
  269. */
  270. push(chunk: Uint8Array | string): PushDecision {
  271. const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk
  272. const before = this.total
  273. this.total += bytes.length
  274. // Prefix: take only up to the cap; the rest of this chunk is "not prefixed".
  275. const room = this.prefixCap - this.prefixHeld
  276. const take = Math.max(0, Math.min(room, bytes.length))
  277. if (take > 0) {
  278. this.prefixChunks.push(bytes.subarray(0, take))
  279. this.prefixHeld += take
  280. }
  281. // Suffix: append the whole chunk, then drop whole leading chunks that have
  282. // fully slid out of the last `suffixCap` bytes (bounded memory).
  283. if (this.suffixCap > 0) {
  284. this.suffixChunks.push(bytes)
  285. this.suffixHeld += bytes.length
  286. let head = this.suffixChunks[0]
  287. while (head !== undefined && this.suffixHeld - head.length >= this.suffixCap) {
  288. this.suffixChunks.shift()
  289. this.suffixHeld -= head.length
  290. head = this.suffixChunks[0]
  291. }
  292. // The head chunk can still hold leading bytes beyond the last `suffixCap`
  293. // — a single chunk LARGER than the window is retained whole by the loop
  294. // above (dropping the only chunk would leave < cap). Trim those leading
  295. // bytes so the accumulator (and finish()'s concat) stays bounded by
  296. // `suffixCap` instead of allocating/copying the full chunk again;
  297. // finish() only ever reads the last `suffixLen ≤ suffixCap` bytes, so this
  298. // drops nothing it would return. (head.length > excess by the loop
  299. // invariant `suffixHeld - head.length < suffixCap`, so the slice is non-empty.)
  300. if (head !== undefined && this.suffixHeld > this.suffixCap) {
  301. const excess = this.suffixHeld - this.suffixCap
  302. this.suffixChunks[0] = head.subarray(excess)
  303. this.suffixHeld -= excess
  304. }
  305. }
  306. // Dropped = bytes that no side can keep. Compute cumulative omission the
  307. // SAME way finish() does (via omittedAt), so push and finish never disagree;
  308. // per-push we only need whether THIS chunk pushed the total past what the
  309. // two caps hold.
  310. const droppedThisChunk = this.omittedAt(this.total) > this.omittedAt(before)
  311. return {
  312. kept: !droppedThisChunk,
  313. truncated: this.omittedAt(this.total) > 0,
  314. }
  315. }
  316. /** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */
  317. private omittedAt(total: number): number {
  318. const prefixLen = Math.min(total, this.prefixCap)
  319. const suffixLen = Math.min(total - prefixLen, this.suffixCap)
  320. return total - prefixLen - suffixLen
  321. }
  322. /**
  323. * Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8
  324. * boundary at its cut) and report the exact omitted byte count.
  325. *
  326. * @returns The {@link RetainedText} snapshot (safe to hand to a formatter).
  327. */
  328. finish(): RetainedText {
  329. const prefixLen = Math.min(this.total, this.prefixCap)
  330. const suffixLen = Math.min(this.total - prefixLen, this.suffixCap)
  331. const prefix = concat(this.prefixChunks) // exactly prefixLen bytes (prefixHeld === prefixLen)
  332. const suffix = concat(this.suffixChunks).subarray(this.suffixHeld - suffixLen)
  333. // With nothing omitted by budget, prefix and suffix are ADJACENT slices of
  334. // one stream (prefixLen + suffixLen === total), so the head|tail split is
  335. // artificial: a codepoint may span it. Decode the contiguous whole as one
  336. // buffer — trimming or decoding the halves separately here would corrupt a
  337. // boundary-spanning codepoint though no content was dropped. Only a real
  338. // omitted gap makes each side a true cut: trim each to a UTF-8 boundary and
  339. // decode separately so a codepoint is never reconstructed across the gap.
  340. const budgetOmitted = this.omittedAt(this.total)
  341. const [keptPrefix, keptSuffix] = budgetOmitted > 0
  342. ? [trimTrailingPartialUtf8(prefix), trimLeadingContinuationUtf8(suffix)]
  343. : [prefix, suffix]
  344. const text = budgetOmitted > 0
  345. ? decoder.decode(keptPrefix) + decoder.decode(keptSuffix)
  346. : decoder.decode(concat([prefix, suffix]))
  347. // Report omission against the bytes ACTUALLY returned, not the pre-trim
  348. // budget: a boundary trim drops partial-codepoint bytes too, so an exact
  349. // count derived from the budget alone would overstate the retained text (and
  350. // any "Omitted N bytes" notice built from it would be a lie).
  351. const omitted = this.total - keptPrefix.length - keptSuffix.length
  352. const truncated = omitted > 0
  353. return {
  354. text,
  355. truncated,
  356. omittedBytes: truncated
  357. ? { kind: 'exact', count: omitted }
  358. : { kind: 'none' },
  359. }
  360. }
  361. }
  362. /** Concatenate chunks into one contiguous buffer (their exact total length). */
  363. function concat(chunks: readonly Uint8Array[]): Uint8Array {
  364. let length = 0
  365. for (const chunk of chunks) length += chunk.length
  366. const out = new Uint8Array(length)
  367. let offset = 0
  368. for (const chunk of chunks) {
  369. out.set(chunk, offset)
  370. offset += chunk.length
  371. }
  372. return out
  373. }
  374. /**
  375. * Standardized, false-precision-safe wording for one {@link Omitted} value —
  376. * the "may standardize omission wording" half the library owns. `exact` prints
  377. * the count (`Omitted 3 items`); `unknown` prints NO count because the caller
  378. * did not provide one. `none` is the empty string.
  379. *
  380. * @param omitted The omission metadata from a retainer result.
  381. * @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`).
  382. * @returns A neutral clause (no trailing space), or `''` when nothing was omitted.
  383. */
  384. export function describeOmitted(omitted: Omitted, unit: RetentionNotice['unit']): string {
  385. switch (omitted.kind) {
  386. case 'none':
  387. return ''
  388. case 'exact':
  389. return `Omitted ${omitted.count} ${unit}.`
  390. case 'unknown':
  391. return `More ${unit} were omitted.`
  392. }
  393. }
  394. /**
  395. * Turn a {@link RetentionNotice} into a one-line footer: the library-owned
  396. * standardized omission clause ({@link describeOmitted}) followed by the tool's
  397. * own recovery guidance. The library never owns recovery words — only the tool
  398. * knows the action ("narrow the pattern", "fetch a more specific URL", "read the
  399. * spill file") — so `recovery` supplies them and receives the full notice to
  400. * phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two
  401. * are joined with a single space.
  402. *
  403. * @param notice The neutral retention outcome.
  404. * @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`).
  405. * @returns The combined footer line.
  406. */
  407. export function formatRetentionNotice(
  408. notice: RetentionNotice,
  409. recovery: (notice: RetentionNotice) => string,
  410. ): string {
  411. return [describeOmitted(notice.omitted, notice.unit), recovery(notice)]
  412. .filter(part => part.length > 0)
  413. .join(' ')
  414. }