refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/output-retention/README.md
README.md: e1d2c828fd4c6526cc391e7f93d7fe20df26e268
README.zh.md: 36730ab83dc7f6a93376a8f0272160d29935e6e0

View File

@@ -0,0 +1,97 @@
# dsh-output-retention
English | [中文](README.zh.md)
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.
The library owns **only** the mechanical question *"what did we keep, and what did we omit?"*. Tool-specific code keeps its business semantics: file grouping, line numbering, exit codes, provider error states, per-line preview truncation, spill files, and the model-facing prose. This is the boundary the [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) draws.
It is a **library, not a service or plugin**: no `ctx`, registers nothing, emits no events. The only state is per-retainer (one accumulation), never cross-call. Tool packages import it directly.
## API
```ts
import {
ItemRetainer, TextRetainer,
describeOmitted, formatRetentionNotice,
} from '@deepseek-ai/dsh-output-retention'
import type {
Omitted, PushDecision, RetainedItems, RetainedText,
ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice,
} from '@deepseek-ai/dsh-output-retention'
```
| Export | Role |
|---|---|
| `ItemRetainer<T>` | Bounds ordered logical units (paths, grep matches, sources). `head` only. `push()` → `PushDecision`; `finish()` → `RetainedItems<T>`. |
| `TextRetainer` | Bounds a byte-oriented text stream. `head` / `tail` / `headTail`, UTF-8 boundaries preserved at `finish()`. `push()` → `PushDecision`; `finish()` → `RetainedText`. |
| `describeOmitted(omitted, unit)` | Standardized omission clause (`exact` prints a count; `unknown` does not). |
| `formatRetentionNotice(notice, recovery)` | Joins the standardized omission clause with the tool's own recovery guidance. |
| `Omitted` | `none` / `exact` / `unknown` — how much was omitted. |
| `PushDecision` | `{ kept, truncated }` — the per-push retention result. |
## Resource Modes
The two retainers are separate names, not one generic collector, because they differ in **resource model**.
- **`ItemRetainer` bounds ordered logical units.** A search tool can collect a full result set for spill-file recovery while retaining only the first `maxItems` for the model-facing preview. The omission count is exact because the caller keeps feeding every observed item.
- **`TextRetainer` bounds byte-oriented text.** `head`, `tail`, and `headTail` preserve UTF-8 boundaries at `finish()`; `headTail` is the shape `dsh-spill-policy` uses to build a bounded preview around a spill-file notice.
## `truncated` is a budget fact, never "incomplete"
`truncated` means *the retainer omitted otherwise-available content because of a budget*. It does **not** mean the upstream was incomplete. Permission failures, skipped binary files, provider partial failures, unreadable candidates, and invalid UTF-8 stay in tool-domain fields — never folded into `truncated`. Conflating the two is the bug this library's naming most invites; keep them separate.
## Bytes, not characters
Text caps and `omittedBytes` count **bytes**, for process/body safety (a child's pipe and an HTTP body are byte streams). A chunk that straddles a codepoint is handled: `finish()` trims a partial codepoint at each cut so the returned text never introduces a replacement char at the boundary, and the two sides are decoded separately so a codepoint is never reconstructed across the omitted middle. Character- or line-level preview budgets are a separate, tool-owned concern.
## Tool mappings
Current retention consumers use these mappings:
| Tool | Retainer & strategy | Notes |
|---|---|---|
| `glob` | `ItemRetainer<FsGlobEntry>`, `head` | Collect the full sorted path list for a spill file while retaining the first page inline. Path mapping, skipped candidates, and `incomplete` stay outside. |
| `grep` | `ItemRetainer<FlatGrepMatch>`, `head` | Collect matches for a spill file while retaining the first page inline. Per-match preview truncation, grouping, sorting, and `incomplete` stay outside. |
| `bash` | `TextRetainer`, `tail` or `headTail` | Executor still owns spill files, exit status, signal, timeout, and background jobs. |
| `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. |
| `web_search` | `ItemRetainer<WebSearchSource>`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. |
`read` remains outside this generic library. Its `read-render` helper owns a file-specific pagination contract — `offset`/`limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a byte cap over the selected window — which is a line-window renderer. A single `Omitted` count cannot represent both sides of that window.
## Usage shape
```ts ignore-check
// glob: keep the first page inline while still collecting the full list for spill.
const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults })
const allEntries: FsGlobEntry[] = []
for await (const entry of candidates) {
allEntries.push(entry)
retainer.push(entry)
}
const { items, truncated, omitted } = retainer.finish()
// bash: keep a head + tail, read to process exit.
const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
const { text, omittedBytes } = out.finish()
// A footer: the library standardizes the omission clause; the tool owns recovery words.
const footer = formatRetentionNotice(
{ scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
)
```
## Model Experience
Indirectly, through tool consumers that render retained content and omission metadata.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
## Known Limitations and Deferred Work
- **Item retention supports `head` only** — tail, head/tail, pagination, grouping, and provider-completeness semantics remain tool-owned.
- **Text retention is byte-oriented** — line and character windows such as `read` pagination require a separate renderer, and a cut may discard partial UTF-8 boundary bytes to keep returned text valid.

View File

@@ -0,0 +1,97 @@
# dsh-output-retention
[English](README.md) | 中文
一个轻依赖的**保留**库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。
该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md) 划定的边界。
它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。
## 对外接口
```ts
import {
ItemRetainer, TextRetainer,
describeOmitted, formatRetentionNotice,
} from '@deepseek-ai/dsh-output-retention'
import type {
Omitted, PushDecision, RetainedItems, RetainedText,
ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice,
} from '@deepseek-ai/dsh-output-retention'
```
| 导出项 | 职责 |
|---|---|
| `ItemRetainer<T>` | 限制有序逻辑单元(路径、grep 匹配项、来源)。只支持 `head`。`push()` → `PushDecision`;`finish()` → `RetainedItems<T>`。 |
| `TextRetainer` | 限制面向字节的文本流。`head` / `tail` / `headTail`,并在 `finish()` 时保留 UTF-8 边界。`push()` → `PushDecision`;`finish()` → `RetainedText`。 |
| `describeOmitted(omitted, unit)` | 标准化的省略子句(`exact` 输出数量;`unknown` 不输出)。 |
| `formatRetentionNotice(notice, recovery)` | 将标准化的省略子句与工具自有的恢复指引连接起来。 |
| `Omitted` | `none` / `exact` / `unknown`:省略了多少内容。 |
| `PushDecision` | `{ kept, truncated }`:每次 push 的保留结果。 |
## 资源模式
两个 retainer 使用独立名称,而不是同一个通用收集器,因为它们的**资源模型**不同。
- **`ItemRetainer` 限制有序逻辑单元**。搜索工具可收集完整结果集用于 spill 文件恢复,同时只为面向模型的预览保留前 `maxItems` 项。因为调用方会继续送入每个已观察到的项,所以省略数量是精确的。
- **`TextRetainer` 限制面向字节的文本**。`head`、`tail` 和 `headTail` 在 `finish()` 时保留 UTF-8 边界;`headTail` 是 `dsh-spill-policy` 用于围绕 spill 文件通知构建有界预览的形态。
## `truncated` 是预算事实,绝不表示「不完整」
`truncated` 表示*因为预算限制,retainer 省略了本可获得的内容*。它**不**表示上游不完整。权限失败、跳过二进制文件、提供方部分失败、不可读候选项和无效 UTF-8 保留在工具领域字段中,绝不合并到 `truncated`。将两者混为一谈是该库命名最容易诱发的缺陷;务必保持分离。
## 字节,而非字符
文本上限和 `omittedBytes` 按**字节**计数,以保证进程/正文安全(子进程管道和 HTTP 正文都是字节流)。跨越码点的分片会被正确处理:`finish()` 会修剪每个切割位置的不完整码点,使返回文本绝不在边界引入替换字符;首尾两侧会分开解码,因此绝不会跨越被省略的中间部分重建码点。按字符或行限制的预览预算属于独立的工具职责。
## 工具映射
当前的保留机制消费方采用以下映射:
| 工具 | Retainer 与策略 | 说明 |
|---|---|---|
| `glob` | `ItemRetainer<FsGlobEntry>`,`head` | 收集完整的已排序路径列表用于 spill 文件,同时在内联位置保留第一页。路径映射、已跳过候选项和 `incomplete` 保留在外部。 |
| `grep` | `ItemRetainer<FlatGrepMatch>`,`head` | 收集匹配项用于 spill 文件,同时在内联位置保留第一页。每个匹配项的预览截断、分组、排序和 `incomplete` 保留在外部。 |
| `bash` | `TextRetainer`,`tail` 或 `headTail` | 执行器仍负责 spill 文件、退出状态、信号、超时和后台任务。 |
| `web_fetch` | `TextRetainer`,`head` 或 `headTail` | 提供方/资源上限保留为提供方事实;retainer 只提供保留文本和省略元数据。 |
| `web_search` | `ItemRetainer<WebSearchSource>`,`head` | 当提供方返回的来源超过面向模型的结果应包含的数量时,标准化「来源已达上限」通知。 |
`read` 仍不属于这个通用库。其 `read-render` 辅助工具负责文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断,以及所选窗口的字节上限。该辅助工具是一个行窗口渲染器。单个 `Omitted` 数量无法表示该窗口两侧。
## 使用形态
```ts ignore-check
// glob: keep the first page inline while still collecting the full list for spill.
const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults })
const allEntries: FsGlobEntry[] = []
for await (const entry of candidates) {
allEntries.push(entry)
retainer.push(entry)
}
const { items, truncated, omitted } = retainer.finish()
// bash: keep a head + tail, read to process exit.
const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
const { text, omittedBytes } = out.finish()
// A footer: the library standardizes the omission clause; the tool owns recovery words.
const footer = formatRetentionNotice(
{ scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
)
```
## 模型体验
通过渲染保留内容和省略元数据的工具消费方间接影响模型。
#### KV Cache 影响
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
## 已知限制与暂缓事项
- **项保留只支持 `head`**:tail、head/tail、分页、分组和提供方完整性语义仍由工具负责。
- **文本保留面向字节**:`read` 分页等行窗口和字符窗口需要单独的渲染器;切割可能会丢弃部分 UTF-8 边界字节,以保持返回文本有效。

View File

@@ -0,0 +1,42 @@
{
"name": "@deepseek-ai/dsh-output-retention",
"description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/util/output-retention"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,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(' ')
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-output-retention`.
* @module @deepseek-ai/dsh-output-retention/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-output-retention'
/** Cordis companion plugin name. */
export const name = 'output-retention-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value
* algebra is enforced by unit tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,376 @@
import { describe, expect, it } from 'vitest'
import {
describeOmitted,
formatRetentionNotice,
ItemRetainer,
type Omitted,
type RetentionNotice,
TextRetainer,
} from '@deepseek-ai/dsh-output-retention'
/** Decode a RetainedText via a round-trip helper for readable UTF-8 assertions. */
const utf8 = (s: string): Uint8Array => new TextEncoder().encode(s)
describe('ItemRetainer — head retention', () => {
it('keeps the first maxItems while callers keep draining for an exact omitted count', () => {
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 2 })
expect(r.push('a')).toEqual({ kept: true, truncated: false })
expect(r.push('b')).toEqual({ kept: true, truncated: false })
expect(r.push('c')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.items).toEqual(['a', 'b'])
expect(result.kept).toBe(2)
expect(result.seen).toBe(3)
expect(result.truncated).toBe(true)
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
})
it('reports none when everything fits', () => {
const r = new ItemRetainer<number>({ kind: 'head', maxItems: 3 })
r.push(1)
r.push(2)
const result = r.finish()
expect(result.items).toEqual([1, 2])
expect(result.truncated).toBe(false)
expect(result.omitted).toEqual<Omitted>({ kind: 'none' })
})
it('keeps draining past the cap and reports an exact omitted count', () => {
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 1 })
expect(r.push('a')).toEqual({ kept: true, truncated: false })
expect(r.push('b')).toEqual({ kept: false, truncated: true })
expect(r.push('c')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.items).toEqual(['a'])
expect(result.seen).toBe(3)
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 2 })
})
})
describe('ItemRetainer — zero budget', () => {
it('keeps nothing and counts every pushed item as omitted', () => {
const r = new ItemRetainer<string>({ kind: 'head', maxItems: 0 })
expect(r.push('a')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.items).toEqual([])
expect(result.kept).toBe(0)
expect(result.omitted).toEqual<Omitted>({ kind: 'exact', count: 1 })
})
it('rejects a non-integer / negative maxItems', () => {
expect(() => new ItemRetainer({ kind: 'head', maxItems: -1 }))
.toThrow(/maxItems must be a non-negative integer/)
expect(() => new ItemRetainer({ kind: 'head', maxItems: 1.5 }))
.toThrow(/maxItems must be a non-negative integer/)
})
})
describe('TextRetainer — head (exact omission, reads to end)', () => {
it('keeps the prefix and counts omitted bytes exactly', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 5 })
expect(r.push('abc')).toEqual({ kept: true, truncated: false })
// 'de' fills the cap exactly (5 bytes) — still fully kept.
expect(r.push('de')).toEqual({ kept: true, truncated: false })
expect(r.push('fgh')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.text).toBe('abcde')
expect(result.truncated).toBe(true)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 3 })
})
it('flags a partially-dropped chunk as not fully kept', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
r.push('ab')
// 'cde' straddles the cap: 'c','d' fit, 'e' drops → kept:false.
expect(r.push('cde')).toEqual({ kept: false, truncated: true })
expect(r.finish().text).toBe('abcd')
})
it('keeps draining past the cap', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
r.push('abc')
expect(r.push('defg')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.text).toBe('abc')
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
})
})
describe('TextRetainer — tail (exact omission, reads to end)', () => {
it('keeps the final maxBytes and reports exact omission', () => {
const r = new TextRetainer({ kind: 'tail', maxBytes: 4 })
expect(r.push('hello')).toEqual({ kept: false, truncated: true })
r.push('world')
const result = r.finish()
expect(result.text).toBe('orld') // last 4 bytes of 'helloworld'
expect(result.truncated).toBe(true)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 6 })
})
it('keeps everything when the stream is under the cap', () => {
const r = new TextRetainer({ kind: 'tail', maxBytes: 100 })
r.push('short')
const result = r.finish()
expect(result.text).toBe('short')
expect(result.truncated).toBe(false)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
})
it('drops old chunks as they slide out of the tail window', () => {
const r = new TextRetainer({ kind: 'tail', maxBytes: 3 })
for (const c of ['11', '22', '33', '44']) r.push(c)
// Only the final 3 bytes survive; earlier whole chunks are dropped.
expect(r.finish().text).toBe('344')
})
})
describe('TextRetainer — headTail (prefix + suffix, omit the middle)', () => {
it('keeps a stable head and tail, omitting the middle exactly', () => {
const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
r.push('abcdefghij') // 10 bytes: head 'abc', tail 'hij', middle 'defg' omitted
const result = r.finish()
expect(result.text).toBe('abchij')
expect(result.truncated).toBe(true)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
})
it('does not double-count when head+tail cover the whole stream', () => {
const r = new TextRetainer({ kind: 'headTail', headBytes: 3, tailBytes: 3 })
r.push('abcdef') // exactly head(3) + tail(3), nothing omitted
const result = r.finish()
expect(result.text).toBe('abcdef')
expect(result.truncated).toBe(false)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
})
it('does not drop a codepoint that spans the head|tail split when nothing is omitted', () => {
// Regression: with head+tail covering the whole stream, the split is
// artificial — a multibyte codepoint may straddle it. 'éab' is C3 A9 61 62
// (4 bytes); headBytes 1 + tailBytes 3 covers all 4 with omitted === 0, but
// the split falls INSIDE 'é'. The bytes are contiguous, so the full 'éab'
// must survive — not be trimmed to 'ab'.
const r = new TextRetainer({ kind: 'headTail', headBytes: 1, tailBytes: 3 })
r.push('éab')
const result = r.finish()
expect(result.text).toBe('éab')
expect(result.truncated).toBe(false)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
})
it('still trims boundary partials once a real middle is omitted', () => {
// With a genuine gap the two sides ARE true cuts: '€' (3 bytes) split across
// the omitted middle must not resurface as a replacement char on either side.
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
r.push('a€€b') // 8 bytes; head 'a'+partial, tail partial+'b', middle omitted
const result = r.finish()
expect(result.truncated).toBe(true)
expect(result.text).not.toContain('<27>')
expect(result.text.startsWith('a')).toBe(true)
expect(result.text.endsWith('b')).toBe(true)
})
})
describe('TextRetainer — zero budgets', () => {
it('head maxBytes 0 keeps nothing and counts every byte exactly', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 0 })
expect(r.push('x')).toEqual({ kept: false, truncated: true })
const result = r.finish()
expect(result.text).toBe('')
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
})
it('an empty stream omits nothing', () => {
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
const result = r.finish()
expect(result.text).toBe('')
expect(result.truncated).toBe(false)
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'none' })
})
it('rejects non-integer / negative byte budgets', () => {
expect(() => new TextRetainer({ kind: 'head', maxBytes: -1 }))
.toThrow(/maxBytes must be a non-negative integer/)
expect(() => new TextRetainer({ kind: 'tail', maxBytes: 2.5 }))
.toThrow(/maxBytes must be a non-negative integer/)
expect(() => new TextRetainer({ kind: 'headTail', headBytes: -1, tailBytes: 2 }))
.toThrow(/headBytes must be a non-negative integer/)
expect(() => new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 1.1 }))
.toThrow(/tailBytes must be a non-negative integer/)
})
})
describe('TextRetainer — UTF-8 boundary handling', () => {
it('trims a partial codepoint at the head cut instead of emitting U+FFFD', () => {
// '€' is 3 bytes (E2 82 AC). A 2-byte head cap keeps 'a' (61) + the first
// byte of '€' (E2); that partial lead byte must be trimmed, not decoded to
// a replacement char.
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
r.push('a€b') // bytes: 61 E2 82 AC 62
const result = r.finish()
expect(result.text).toBe('a') // partial '€' dropped, no U+FFFD
expect(result.text).not.toContain('<27>')
// Omission counts bytes ACTUALLY absent from the returned text, including
// the partial 'E2' the boundary trim dropped: 5 total − 1 retained = 4
// (not the pre-trim budget of 3, which would overstate what was kept).
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
})
it('trims a leading partial codepoint at the tail cut', () => {
// Tail cap 2 over 'a€b' (5 bytes) keeps AC 62 — AC is a continuation byte
// (the middle of '€'); the leading continuation byte is dropped so the tail
// begins on a boundary.
const r = new TextRetainer({ kind: 'tail', maxBytes: 2 })
r.push('a€b')
const result = r.finish()
expect(result.text).toBe('b') // partial '€' at the front dropped
expect(result.text).not.toContain('<27>')
// Honest count: 5 total − 1 retained ('b') = 4, including the trimmed AC.
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 4 })
})
it('omitted count matches the bytes actually absent, across a headTail boundary trim', () => {
// Regression: the exact count must equal total − retained (post-trim), never
// the pre-trim budget. 'a€€b' is 8 bytes (61 E2828C… ×2 61? no: 61 E2 82 AC
// E2 82 AC 62). headBytes 2 keeps 'a'+partial-E2 → trims to 'a' (1 byte);
// tailBytes 2 keeps partial-AC+'b' → trims to 'b' (1 byte). Retained text is
// 2 bytes, so omitted must be 8 − 2 = 6 — not the budget's 8 − 2 − 2 = 4.
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
r.push('a€€b')
const result = r.finish()
const retainedBytes = new TextEncoder().encode(result.text).length
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 8 - retainedBytes })
})
it('preserves a whole multibyte codepoint that fits exactly', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
r.push('€x') // '€' is exactly 3 bytes
expect(r.finish().text).toBe('€')
})
it('does not reconstruct a codepoint across the omitted middle', () => {
// headBytes ends mid-'€' and tailBytes starts mid-another '€'; neither cut
// may glue a valid codepoint across the gap.
const r = new TextRetainer({ kind: 'headTail', headBytes: 2, tailBytes: 2 })
r.push('€€€') // 9 bytes
const result = r.finish()
expect(result.text).not.toContain('<27>')
expect(result.truncated).toBe(true)
})
it('accepts a raw Uint8Array chunk', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
r.push(utf8('xy'))
r.push(utf8('z'))
expect(r.finish().text).toBe('xy')
})
it('trims a partial 2-byte codepoint at the head cut', () => {
// 'é' is 2 bytes (C3 A9). A 2-byte head cap over 'aé' keeps 'a' (61) + the
// lead byte of 'é' (C3) — an incomplete 2-byte sequence to trim.
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
r.push('aé') // bytes: 61 C3 A9
const result = r.finish()
expect(result.text).toBe('a')
expect(result.text).not.toContain('<27>')
})
it('trims a partial 4-byte codepoint (emoji) at the head cut', () => {
// '😀' is 4 bytes (F0 9F 98 80). A 3-byte head cap keeps 'a' + the first two
// bytes of the emoji — an incomplete 4-byte sequence that must be trimmed.
const r = new TextRetainer({ kind: 'head', maxBytes: 3 })
r.push('a😀') // bytes: 61 F0 9F 98 80
const result = r.finish()
expect(result.text).toBe('a')
expect(result.text).not.toContain('<27>')
})
it('keeps a whole 4-byte codepoint that fits exactly', () => {
const r = new TextRetainer({ kind: 'head', maxBytes: 4 })
r.push('😀x')
expect(r.finish().text).toBe('😀')
})
it('leaves a head cut ending on a stray continuation run untouched', () => {
// A cut whose trailing bytes are ALL continuation bytes with no lead in
// reach is not a trimmable incomplete sequence — the trimmer bails (no lead
// byte found) and leaves them for the non-fatal decoder to replace.
const r = new TextRetainer({ kind: 'head', maxBytes: 2 })
// 0x80 0x80 are bare continuation bytes; 'z' follows so the head keeps just
// the two continuation bytes and the cut lands right after them.
r.push(new Uint8Array([0x80, 0x80, 0x7a]))
const result = r.finish()
// The trimmer did not throw and did not eat the bytes as a partial sequence;
// only the trailing 'z' is omitted by the 2-byte cap.
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
})
it('leaves a head cut ending on an invalid lead byte untouched', () => {
// 0xF8 is not a valid UTF-8 lead byte (only 0x00–0xF7 lead). The trimmer
// recognizes it as "not a lead" (expected length 0) and leaves the byte in
// place rather than trimming a phantom partial sequence.
const r = new TextRetainer({ kind: 'head', maxBytes: 1 })
r.push(new Uint8Array([0xf8, 0x61])) // 0xF8 kept, 'a' dropped by the 1-byte cap
const result = r.finish()
expect(result.omittedBytes).toEqual<Omitted>({ kind: 'exact', count: 1 })
})
})
describe('describeOmitted — false precision safety', () => {
it('prints an exact count for exact omission', () => {
expect(describeOmitted({ kind: 'exact', count: 3 }, 'items')).toBe('Omitted 3 items.')
expect(describeOmitted({ kind: 'exact', count: 12 }, 'bytes')).toBe('Omitted 12 bytes.')
})
it('prints NO count for unknown omission', () => {
expect(describeOmitted({ kind: 'unknown' }, 'lines')).toBe('More lines were omitted.')
})
it('returns empty string when nothing was omitted', () => {
expect(describeOmitted({ kind: 'none' }, 'chars')).toBe('')
})
})
describe('formatRetentionNotice', () => {
const notice = (omitted: Omitted): RetentionNotice => ({
scope: 'grep',
strategy: 'head',
unit: 'items',
limit: 100,
kept: 100,
omitted,
})
it('joins the standardized omission clause with the tool recovery guidance', () => {
const out = formatRetentionNotice(
notice({ kind: 'exact', count: 25 }),
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
)
expect(out).toBe('Omitted 25 items. Results capped at 100. Narrow the pattern, path, or include to see more.')
})
it('omits the empty half when nothing was omitted', () => {
const out = formatRetentionNotice(notice({ kind: 'none' }), () => 'Recovery text.')
expect(out).toBe('Recovery text.')
})
it('omits the empty half when the tool supplies no recovery text', () => {
const out = formatRetentionNotice(notice({ kind: 'exact', count: 2 }), () => '')
expect(out).toBe('Omitted 2 items.')
})
it('passes the full notice to the recovery builder (limit as a head/tail pair)', () => {
const headTail: RetentionNotice = {
scope: 'bash stdout',
strategy: 'headTail',
unit: 'bytes',
limit: { head: 2_000, tail: 2_000 },
kept: 4_000,
omitted: { kind: 'exact', count: 500 },
}
const out = formatRetentionNotice(headTail, n =>
typeof n.limit === 'object' ? `Kept ${n.limit.head}B head + ${n.limit.tail}B tail.` : '')
expect(out).toBe('Omitted 500 bytes. Kept 2000B head + 2000B tail.')
})
})

View File

@@ -0,0 +1,15 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../runtime-diagnostics/invariants"
}
]
}