Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
444 lines
17 KiB
TypeScript
444 lines
17 KiB
TypeScript
/**
|
||
* A dependency-light **retention** library: bounded model-facing output for
|
||
* tools that must cap how much context they return. A caller feeds items or
|
||
* text chunks into a bounded object, then gets the retained content plus exact
|
||
* omission metadata ({@link RetainedItems} / {@link RetainedText}).
|
||
*
|
||
* The library owns ONLY the mechanical question "what did we keep, what did we
|
||
* omit?". Tool-specific code still owns
|
||
* business semantics: file grouping, line numbering, exit codes, provider error
|
||
* states, per-line preview truncation, spill files, and the model-facing prose.
|
||
* In particular {@link RetainedText.truncated}/{@link RetainedItems.truncated}
|
||
* means "the retainer omitted otherwise-available content because of a budget" —
|
||
* NOT "the upstream was incomplete". Permission failures, skipped binaries,
|
||
* provider partial failures, and unreadable candidates stay in tool-domain
|
||
* fields, never folded into `truncated`.
|
||
*
|
||
* This is deliberately a library, not a cordis service or plugin: it takes no
|
||
* `ctx`, registers nothing, and emits no events. The two retainers are the only
|
||
* stateful pieces and their state is per-instance (one accumulation), never
|
||
* cross-call. Tool packages import it directly when they need bounded output.
|
||
*
|
||
* The two retainers differ in resource model, which is why they are two names
|
||
* rather than one generic collector:
|
||
* - {@link ItemRetainer} bounds ordered logical units (paths, grep matches,
|
||
* search sources). `head` retention only in v1.
|
||
* - {@link TextRetainer} bounds byte-oriented text streams (bash stdout/stderr,
|
||
* web bodies). `head` / `tail` / `headTail`, preserving UTF-8 boundaries at
|
||
* {@link TextRetainer.finish}.
|
||
*
|
||
* @module @deepseek-ai/dsh-output-retention
|
||
*/
|
||
|
||
/**
|
||
* How much content the retainer omitted.
|
||
*
|
||
* `exact` is the normal retainer shape: every unit/byte was observed, so the
|
||
* omitted count is precise. `unknown` is reserved for a caller that omits
|
||
* without a count; the retainers themselves never return it.
|
||
*/
|
||
export type Omitted =
|
||
| { kind: 'none' }
|
||
| { kind: 'exact'; count: number }
|
||
| { kind: 'unknown' }
|
||
|
||
/**
|
||
* The caller receives this after each `push()`.
|
||
*/
|
||
export interface PushDecision {
|
||
/** Was this whole unit / all of this chunk's bytes retained (nothing dropped)? */
|
||
kept: boolean
|
||
/** Cumulative: has the retainer omitted anything due to the budget yet? */
|
||
truncated: boolean
|
||
}
|
||
|
||
/**
|
||
* Final result for ordered logical units.
|
||
*
|
||
* `seen` means units OBSERVED by the retainer, not necessarily the total in the
|
||
* upstream source. `kept` is `items.length`, surfaced explicitly so a notice
|
||
* formatter need not re-count.
|
||
*/
|
||
export interface RetainedItems<T> {
|
||
items: T[]
|
||
truncated: boolean
|
||
seen: number
|
||
kept: number
|
||
omitted: Omitted
|
||
}
|
||
|
||
/**
|
||
* Final result for text streams.
|
||
*
|
||
* The returned `text` is safe to hand to a formatter: the retainer adds no
|
||
* tool-specific headers, exit markers, XML tags, or recovery instructions, and
|
||
* `omittedBytes` counts BYTES (not characters or lines) — text retention is
|
||
* byte-oriented for process/body safety. UTF-8 boundaries at each cut are
|
||
* preserved, so `text` never carries a replacement char introduced by the cut
|
||
* itself.
|
||
*/
|
||
export interface RetainedText {
|
||
text: string
|
||
truncated: boolean
|
||
omittedBytes: Omitted
|
||
}
|
||
|
||
/** Item retention strategy. Only `head` in v1; windows/grouped budgets wait for a second consumer. */
|
||
export type ItemRetentionStrategy = {
|
||
/** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
|
||
kind: 'head'
|
||
maxItems: number
|
||
}
|
||
|
||
/** Text retention strategy: keep a prefix, a suffix, or both, counted in bytes. */
|
||
export type TextRetentionStrategy =
|
||
| {
|
||
/** Keep the first `maxBytes` bytes. */
|
||
kind: 'head'
|
||
maxBytes: number
|
||
}
|
||
| {
|
||
/** Keep the final `maxBytes` bytes. Requires reading to the end. */
|
||
kind: 'tail'
|
||
maxBytes: number
|
||
}
|
||
| {
|
||
/** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
|
||
kind: 'headTail'
|
||
headBytes: number
|
||
tailBytes: number
|
||
}
|
||
|
||
/**
|
||
* A neutral, tool-agnostic description of one retention outcome — the input to
|
||
* {@link formatRetentionNotice}. It carries the mechanical facts (strategy,
|
||
* unit, limit, kept count, {@link Omitted}); the tool supplies the recovery
|
||
* words, because only the tool knows the recovery action ("narrow the pattern",
|
||
* "fetch a more specific URL", "read the spill file").
|
||
*/
|
||
export interface RetentionNotice {
|
||
/** Tool/scope label, e.g. `grep`, `web_fetch`, `bash stdout`. */
|
||
scope: string
|
||
strategy: 'head' | 'tail' | 'headTail'
|
||
unit: 'items' | 'bytes' | 'chars' | 'lines'
|
||
limit: number | { head: number; tail: number }
|
||
kept: number
|
||
omitted: Omitted
|
||
}
|
||
|
||
/** Assert a budget field is a non-negative integer (the retainer request contract). */
|
||
function assertBudget(value: number, name: string): void {
|
||
if (!Number.isInteger(value) || value < 0) {
|
||
throw new Error(`${name} must be a non-negative integer`)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bounds an ordered stream of logical units, keeping the first `maxItems`
|
||
* ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it
|
||
* was kept and whether the retained result is now truncated.
|
||
*
|
||
* Grouping, sorting, path mapping, per-unit preview truncation, and any
|
||
* `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing
|
||
* more. The caller pushes prepared logical units and, after {@link finish},
|
||
* groups/sorts the retained subset itself.
|
||
*/
|
||
export class ItemRetainer<T> {
|
||
private readonly maxItems: number
|
||
private readonly items: T[] = []
|
||
private seen = 0
|
||
private omittedCount = 0
|
||
|
||
/** @param strategy Head strategy: `maxItems` (non-negative integer). */
|
||
constructor(strategy: ItemRetentionStrategy) {
|
||
assertBudget(strategy.maxItems, 'maxItems')
|
||
this.maxItems = strategy.maxItems
|
||
}
|
||
|
||
/**
|
||
* Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped
|
||
* and counted as omitted. Callers keep pushing all observed units, so the final
|
||
* {@link Omitted} count is exact.
|
||
*
|
||
* @param item The prepared logical unit (path, flat match, source).
|
||
* @returns The per-push {@link PushDecision}.
|
||
*/
|
||
push(item: T): PushDecision {
|
||
this.seen++
|
||
if (this.items.length < this.maxItems) {
|
||
// Reached only below the cap, before any omission (items only grow, the
|
||
// cap is fixed), so nothing has been dropped yet: truncated is always false.
|
||
this.items.push(item)
|
||
return { kept: true, truncated: false }
|
||
}
|
||
this.omittedCount++
|
||
return {
|
||
kept: false,
|
||
truncated: true,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Finalize and report what was kept and omitted.
|
||
*
|
||
* @returns The {@link RetainedItems} snapshot (safe to group/sort downstream).
|
||
*/
|
||
finish(): RetainedItems<T> {
|
||
const truncated = this.omittedCount > 0
|
||
return {
|
||
items: this.items,
|
||
truncated,
|
||
seen: this.seen,
|
||
kept: this.items.length,
|
||
omitted: truncated
|
||
? { kind: 'exact', count: this.omittedCount }
|
||
: { kind: 'none' },
|
||
}
|
||
}
|
||
}
|
||
|
||
const encoder = new TextEncoder()
|
||
const decoder = new TextDecoder() // utf-8, non-fatal: internal malformed bytes → U+FFFD
|
||
|
||
/**
|
||
* Drop a trailing incomplete UTF-8 sequence so a prefix cut never emits a
|
||
* replacement char at the boundary. Walks back over continuation bytes
|
||
* (`10xxxxxx`) to the lead byte; if fewer bytes follow it than the lead byte's
|
||
* length declares, the sequence is incomplete and is trimmed. A complete tail,
|
||
* or a run too long/short to be a valid lead, is returned untouched (any
|
||
* genuinely malformed interior is left for the decoder to replace).
|
||
*/
|
||
function trimTrailingPartialUtf8(bytes: Uint8Array): Uint8Array {
|
||
let i = bytes.length - 1
|
||
// Continuation bytes are 0b10xxxxxx; scan back at most 3 (max sequence is 4).
|
||
// Indices are bounds-checked by the loop guard, so the reads are in range.
|
||
while (i >= 0 && ((bytes[i] as number) & 0xc0) === 0x80 && bytes.length - i <= 3) i--
|
||
if (i < 0) return bytes
|
||
const lead = bytes[i] as number
|
||
const expected = lead < 0x80 ? 1 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf8 ? 4 : 0
|
||
// expected 0 → not a lead byte (stray continuation / invalid): leave it.
|
||
if (expected === 0) return bytes
|
||
return bytes.length - i < expected ? bytes.subarray(0, i) : bytes
|
||
}
|
||
|
||
/**
|
||
* Drop leading continuation bytes (`10xxxxxx`) so a suffix cut starts on a
|
||
* lead/ASCII byte instead of mid-codepoint.
|
||
*/
|
||
function trimLeadingContinuationUtf8(bytes: Uint8Array): Uint8Array {
|
||
let i = 0
|
||
// i < length guards the read.
|
||
while (i < bytes.length && ((bytes[i] as number) & 0xc0) === 0x80) i++
|
||
return bytes.subarray(i)
|
||
}
|
||
|
||
/**
|
||
* Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both
|
||
* ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix
|
||
* accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both.
|
||
*
|
||
* Bytes, not characters: caps and `omittedBytes` are byte counts for process/
|
||
* body safety. Chunks that straddle a codepoint are handled — {@link finish}
|
||
* trims a partial codepoint at each cut so the returned text never introduces a
|
||
* replacement char at the boundary. The retainer holds at most
|
||
* `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped
|
||
* as they slide out), so a large stream does not accumulate unbounded.
|
||
*/
|
||
export class TextRetainer {
|
||
private readonly prefixCap: number
|
||
private readonly suffixCap: number
|
||
private readonly prefixChunks: Uint8Array[] = []
|
||
private prefixHeld = 0
|
||
private readonly suffixChunks: Uint8Array[] = []
|
||
private suffixHeld = 0
|
||
private total = 0
|
||
|
||
/** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */
|
||
constructor(strategy: TextRetentionStrategy) {
|
||
switch (strategy.kind) {
|
||
case 'head':
|
||
assertBudget(strategy.maxBytes, 'maxBytes')
|
||
this.prefixCap = strategy.maxBytes
|
||
this.suffixCap = 0
|
||
break
|
||
case 'tail':
|
||
assertBudget(strategy.maxBytes, 'maxBytes')
|
||
this.prefixCap = 0
|
||
this.suffixCap = strategy.maxBytes
|
||
break
|
||
case 'headTail':
|
||
assertBudget(strategy.headBytes, 'headBytes')
|
||
assertBudget(strategy.tailBytes, 'tailBytes')
|
||
this.prefixCap = strategy.headBytes
|
||
this.suffixCap = strategy.tailBytes
|
||
break
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix
|
||
* bytes fill up to the prefix cap then stop; suffix bytes roll so only the
|
||
* last `suffixCap` bytes are retained. `kept` is `true` only when no byte of
|
||
* this chunk was dropped.
|
||
*
|
||
* @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`).
|
||
* @returns The per-push {@link PushDecision}.
|
||
*/
|
||
push(chunk: Uint8Array | string): PushDecision {
|
||
const bytes = typeof chunk === 'string' ? encoder.encode(chunk) : chunk
|
||
const before = this.total
|
||
this.total += bytes.length
|
||
|
||
// Prefix: take only up to the cap; the rest of this chunk is "not prefixed".
|
||
const room = this.prefixCap - this.prefixHeld
|
||
const take = Math.max(0, Math.min(room, bytes.length))
|
||
if (take > 0) {
|
||
this.prefixChunks.push(bytes.subarray(0, take))
|
||
this.prefixHeld += take
|
||
}
|
||
|
||
// Suffix: append the whole chunk, then drop whole leading chunks that have
|
||
// fully slid out of the last `suffixCap` bytes (bounded memory).
|
||
if (this.suffixCap > 0) {
|
||
this.suffixChunks.push(bytes)
|
||
this.suffixHeld += bytes.length
|
||
let head = this.suffixChunks[0]
|
||
while (head !== undefined && this.suffixHeld - head.length >= this.suffixCap) {
|
||
this.suffixChunks.shift()
|
||
this.suffixHeld -= head.length
|
||
head = this.suffixChunks[0]
|
||
}
|
||
// The head chunk can still hold leading bytes beyond the last `suffixCap`
|
||
// — a single chunk LARGER than the window is retained whole by the loop
|
||
// above (dropping the only chunk would leave < cap). Trim those leading
|
||
// bytes so the accumulator (and finish()'s concat) stays bounded by
|
||
// `suffixCap` instead of allocating/copying the full chunk again;
|
||
// finish() only ever reads the last `suffixLen ≤ suffixCap` bytes, so this
|
||
// drops nothing it would return. (head.length > excess by the loop
|
||
// invariant `suffixHeld - head.length < suffixCap`, so the slice is non-empty.)
|
||
if (head !== undefined && this.suffixHeld > this.suffixCap) {
|
||
const excess = this.suffixHeld - this.suffixCap
|
||
this.suffixChunks[0] = head.subarray(excess)
|
||
this.suffixHeld -= excess
|
||
}
|
||
}
|
||
|
||
// Dropped = bytes that no side can keep. Compute cumulative omission the
|
||
// SAME way finish() does (via omittedAt), so push and finish never disagree;
|
||
// per-push we only need whether THIS chunk pushed the total past what the
|
||
// two caps hold.
|
||
const droppedThisChunk = this.omittedAt(this.total) > this.omittedAt(before)
|
||
return {
|
||
kept: !droppedThisChunk,
|
||
truncated: this.omittedAt(this.total) > 0,
|
||
}
|
||
}
|
||
|
||
/** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */
|
||
private omittedAt(total: number): number {
|
||
const prefixLen = Math.min(total, this.prefixCap)
|
||
const suffixLen = Math.min(total - prefixLen, this.suffixCap)
|
||
return total - prefixLen - suffixLen
|
||
}
|
||
|
||
/**
|
||
* Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8
|
||
* boundary at its cut) and report the exact omitted byte count.
|
||
*
|
||
* @returns The {@link RetainedText} snapshot (safe to hand to a formatter).
|
||
*/
|
||
finish(): RetainedText {
|
||
const prefixLen = Math.min(this.total, this.prefixCap)
|
||
const suffixLen = Math.min(this.total - prefixLen, this.suffixCap)
|
||
|
||
const prefix = concat(this.prefixChunks) // exactly prefixLen bytes (prefixHeld === prefixLen)
|
||
const suffix = concat(this.suffixChunks).subarray(this.suffixHeld - suffixLen)
|
||
|
||
// With nothing omitted by budget, prefix and suffix are ADJACENT slices of
|
||
// one stream (prefixLen + suffixLen === total), so the head|tail split is
|
||
// artificial: a codepoint may span it. Decode the contiguous whole as one
|
||
// buffer — trimming or decoding the halves separately here would corrupt a
|
||
// boundary-spanning codepoint though no content was dropped. Only a real
|
||
// omitted gap makes each side a true cut: trim each to a UTF-8 boundary and
|
||
// decode separately so a codepoint is never reconstructed across the gap.
|
||
const budgetOmitted = this.omittedAt(this.total)
|
||
const [keptPrefix, keptSuffix] = budgetOmitted > 0
|
||
? [trimTrailingPartialUtf8(prefix), trimLeadingContinuationUtf8(suffix)]
|
||
: [prefix, suffix]
|
||
const text = budgetOmitted > 0
|
||
? decoder.decode(keptPrefix) + decoder.decode(keptSuffix)
|
||
: decoder.decode(concat([prefix, suffix]))
|
||
|
||
// Report omission against the bytes ACTUALLY returned, not the pre-trim
|
||
// budget: a boundary trim drops partial-codepoint bytes too, so an exact
|
||
// count derived from the budget alone would overstate the retained text (and
|
||
// any "Omitted N bytes" notice built from it would be a lie).
|
||
const omitted = this.total - keptPrefix.length - keptSuffix.length
|
||
const truncated = omitted > 0
|
||
|
||
return {
|
||
text,
|
||
truncated,
|
||
omittedBytes: truncated
|
||
? { kind: 'exact', count: omitted }
|
||
: { kind: 'none' },
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Concatenate chunks into one contiguous buffer (their exact total length). */
|
||
function concat(chunks: readonly Uint8Array[]): Uint8Array {
|
||
let length = 0
|
||
for (const chunk of chunks) length += chunk.length
|
||
const out = new Uint8Array(length)
|
||
let offset = 0
|
||
for (const chunk of chunks) {
|
||
out.set(chunk, offset)
|
||
offset += chunk.length
|
||
}
|
||
return out
|
||
}
|
||
|
||
/**
|
||
* Standardized, false-precision-safe wording for one {@link Omitted} value —
|
||
* the "may standardize omission wording" half the library owns. `exact` prints
|
||
* the count (`Omitted 3 items`); `unknown` prints NO count because the caller
|
||
* did not provide one. `none` is the empty string.
|
||
*
|
||
* @param omitted The omission metadata from a retainer result.
|
||
* @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`).
|
||
* @returns A neutral clause (no trailing space), or `''` when nothing was omitted.
|
||
*/
|
||
export function describeOmitted(omitted: Omitted, unit: RetentionNotice['unit']): string {
|
||
switch (omitted.kind) {
|
||
case 'none':
|
||
return ''
|
||
case 'exact':
|
||
return `Omitted ${omitted.count} ${unit}.`
|
||
case 'unknown':
|
||
return `More ${unit} were omitted.`
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Turn a {@link RetentionNotice} into a one-line footer: the library-owned
|
||
* standardized omission clause ({@link describeOmitted}) followed by the tool's
|
||
* own recovery guidance. The library never owns recovery words — only the tool
|
||
* knows the action ("narrow the pattern", "fetch a more specific URL", "read the
|
||
* spill file") — so `recovery` supplies them and receives the full notice to
|
||
* phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two
|
||
* are joined with a single space.
|
||
*
|
||
* @param notice The neutral retention outcome.
|
||
* @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`).
|
||
* @returns The combined footer line.
|
||
*/
|
||
export function formatRetentionNotice(
|
||
notice: RetentionNotice,
|
||
recovery: (notice: RetentionNotice) => string,
|
||
): string {
|
||
return [describeOmitted(notice.omitted, notice.unit), recovery(notice)]
|
||
.filter(part => part.length > 0)
|
||
.join(' ')
|
||
}
|