forked from deepseek-ai/deepseek-harness
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.ts
More file actions
443 lines (413 loc) · 17.3 KB
/
Copy pathindex.ts
File metadata and controls
443 lines (413 loc) · 17.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
/**
* 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(' ')
}