@qilin-agent/output-retention 0.0.0-stage → 3.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/util/output-retention/README.md
5
+ /:
6
+ en: 058416b280f64a3a
7
+ zh: 6dd741394b67bd30
8
+ /qilin-output-retention:
9
+ en: 82dad0c2270dc0df
10
+ zh: de7c6f1334ed2692
11
+ /qilin-output-retention/summary:
12
+ en: 3f0331a40f919223
13
+ zh: 9c670d911ceb4ff6
14
+ /qilin-output-retention/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /qilin-output-retention/use-this-package:
18
+ en: f62f2da3b880d972
19
+ zh: be8b8bea3bff9cb6
20
+ /qilin-output-retention/use-this-package/bounding-a-list-of-items:
21
+ en: 4e33df2bffae04a9
22
+ zh: b79773e1936605bf
23
+ /qilin-output-retention/use-this-package/bounding-a-text-stream:
24
+ en: c0002b8dd81f4732
25
+ zh: 4f8c0e9f03e4ecf9
26
+ /qilin-output-retention/use-this-package/building-the-omission-footer:
27
+ en: 24a79057aea704e0
28
+ zh: ab6b91d0d1c3b8d7
29
+ /qilin-output-retention/use-this-package/what-truncated-means:
30
+ en: d496be708ac637dc
31
+ zh: 739017ce47f08148
32
+ /qilin-output-retention/use-this-package/how-the-current-tools-use-it:
33
+ en: ed4b3b343ad80143
34
+ zh: 65dd1f707c3ac747
35
+ /qilin-output-retention/understand-the-implementation:
36
+ en: fd2a6c959bfe864e
37
+ zh: 25b11d28aced568c
38
+ /qilin-output-retention/understand-the-implementation/source-map:
39
+ en: e2d87f9e71d8db62
40
+ zh: cf34dd787a069775
41
+ /qilin-output-retention/understand-the-implementation/two-retainers-two-resource-models:
42
+ en: 6a5dd228d9f9808b
43
+ zh: 9bba82c5771ee705
44
+ /qilin-output-retention/understand-the-implementation/how-the-budget-facts-stay-honest:
45
+ en: 101d99e1107d91c7
46
+ zh: ab64a35b62938db1
47
+ /qilin-output-retention/understand-the-implementation/the-read-render-exclusion:
48
+ en: f76b67fa9e940705
49
+ zh: e7731bfd31cc9f5c
50
+ /qilin-output-retention/further-exploration:
51
+ en: 7392047525d06599
52
+ zh: cc6e45cc72335359
53
+ /qilin-output-retention/model-experience:
54
+ en: 247cdeeae9390244
55
+ zh: 0720d77e0a1d1c15
56
+ /qilin-output-retention/model-experience/kv-cache-effect:
57
+ en: 4487c456cf996b48
58
+ zh: 2c25fe4a5a53f345
59
+ /qilin-output-retention/known-limitations-and-deferred-work:
60
+ en: dabd5ca291570e70
61
+ zh: d1d98ece31eaa664
62
+ /qilin-output-retention/known-limitations-and-deferred-work/dev-note:
63
+ en: e67f633dc6fe7773
64
+ zh: 3bb0c20c93f16f98
package/README.md CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Bounded model-facing output for tools that must cap how much context they return: item and text retainers plus a standardized omission footer."
3
+ kind: "package-library"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @qilin-agent/output-retention
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Use `qilin-output-retention` to cap the items or text a tool returns to a model while reporting what was omitted. `ItemRetainer` keeps an ordered head window and can report an exact omitted-item count; `TextRetainer` keeps head, tail, or head-and-tail byte windows without returning invalid UTF-8 cuts. `formatRetentionNotice` adds a consistent omission clause while each tool supplies its own recovery guidance. Grouping, line numbering, spill files, and provider errors remain tool responsibilities; consumers import this library directly rather than loading it through `cordis.yml`.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Use a retainer wherever a tool must cap how much of its result reaches the model, and report honestly what was dropped. Choose `ItemRetainer` for ordered logical units and `TextRetainer` for byte-oriented streams.
29
+
30
+ ### Bounding a list of items
31
+
32
+ ```ts
33
+ import { ItemRetainer } from '@qilin-agent/output-retention'
34
+
35
+ declare const globMaxResults: number
36
+ declare const candidates: AsyncIterable<{ path: string }>
37
+ const retainer = new ItemRetainer<{ path: string }>({ kind: 'head', maxItems: globMaxResults })
38
+ for await (const entry of candidates) {
39
+ retainer.push(entry) // keep draining past the cap for an exact count
40
+ }
41
+ const { items, truncated, omitted } = retainer.finish()
42
+ ```
43
+
44
+ `push()` reports per item whether it was kept, and `finish()` returns the retained items plus `omitted` — an exact count when the caller kept feeding every observed unit. A search tool can collect the full result set for a spill file while retaining only the first page for the model.
45
+
46
+ ### Bounding a text stream
47
+
48
+ ```text
49
+ import { TextRetainer } from '@qilin-agent/output-retention'
50
+
51
+ const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
52
+ child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
53
+ const { text, omittedBytes } = out.finish()
54
+ ```
55
+
56
+ `head`, `tail`, and `headTail` count bytes, not characters or lines: a child's pipe and an HTTP body are byte streams. `finish()` trims a partial codepoint at each cut, so the returned text never carries a replacement character introduced by the cut, and a codepoint is never reconstructed across the omitted middle.
57
+
58
+ ### Building the omission footer
59
+
60
+ ```ts
61
+ import { formatRetentionNotice } from '@qilin-agent/output-retention'
62
+
63
+ declare const grepMaxMatches: number
64
+ declare const items: { length: number }
65
+ import type { Omitted } from '@qilin-agent/output-retention'
66
+
67
+ declare const omitted: Omitted
68
+
69
+ const footer = formatRetentionNotice(
70
+ { scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
71
+ ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
72
+ )
73
+ ```
74
+
75
+ The library standardizes the omission clause (`Omitted 3 items.`) and joins it with the tool's own recovery guidance; only the tool knows the recovery action, so the tool supplies those words.
76
+
77
+ ### What `truncated` means
78
+
79
+ `truncated` is a budget fact: the retainer omitted otherwise-available content because of a cap. It never means the upstream was incomplete — permission failures, skipped binary files, provider partial failures, and unreadable candidates stay in tool-domain fields, never folded into `truncated`.
80
+
81
+ ### How the current tools use it
82
+
83
+ | Tool | Retainer | What the tool still owns |
84
+ |---|---|---|
85
+ | `glob` | `ItemRetainer`, `head` | Spill-file collection, path mapping, skipped candidates, `incomplete` |
86
+ | `grep` | `ItemRetainer`, `head` | Spill-file collection, per-match preview truncation, grouping, sorting |
87
+ | `bash` | `TextRetainer`, `tail` or `headTail` | Spill files, exit status, signal, timeout, background jobs |
88
+ | `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider and resource caps, error states |
89
+ | `web_search` | `ItemRetainer`, `head` | The "sources capped" notice wording and provider facts |
90
+
91
+ `read` stays outside this library: its line-window pagination (`offset`/`limit`, line numbers, `totalLines`) is a file-specific renderer that a single omission count cannot represent.
92
+
93
+ -----
94
+
95
+ <a id="understand-the-implementation"></a>
96
+ ## Understand the implementation
97
+
98
+ <details>
99
+ <summary>Implementation internals — click to expand</summary>
100
+
101
+ The library is built on one separation: it owns the mechanical question of what was kept and what was omitted; tool packages own every business meaning.
102
+
103
+ ### Source map
104
+
105
+ | File | Role |
106
+ |---|---|
107
+ | [`src/index.ts`](src/index.ts) | `ItemRetainer`, `TextRetainer`, `describeOmitted`, and `formatRetentionNotice` |
108
+
109
+ ### Two retainers, two resource models
110
+
111
+ `ItemRetainer` bounds ordered logical units and keeps only the first `maxItems`; the caller keeps pushing every observed unit so the omission count is exact. `TextRetainer` bounds bytes with one shared prefix/suffix accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both, and the accumulator holds at most `headBytes + tailBytes + one chunk` in memory, so a large stream does not accumulate unbounded.
112
+
113
+ ### How the budget facts stay honest
114
+
115
+ `push()` returns `kept` (this unit or chunk fully retained) and `truncated` (anything dropped yet). `finish()` reports omission against the bytes actually returned, so a UTF-8 boundary trim that drops partial-codepoint bytes is counted too — a notice built from the budget alone would overstate the retained text. `describeOmitted` prints a count only for `exact`; `unknown` prints no count because the caller provided none.
116
+
117
+ ### The read-render exclusion
118
+
119
+ `read`'s `offset`/`limit` pagination is a line-window renderer with its own byte cap over the selected window; a single `Omitted` value cannot represent both sides of that window, so it stays out of this library.
120
+
121
+ </details>
122
+
123
+ -----
124
+
125
+ <a id="further-exploration"></a>
126
+ ## Further Exploration
127
+
128
+ Read these pages when you need the consumers or the boundary decision behind the library.
129
+
130
+ - [Spill policy](../../spill/spill-policy/README.md) — composes `TextRetainer` for a bounded preview around a spill-file notice.
131
+ - [Spill subsystem](../../../docs/subsystems/spill.md) — the spill vocabulary this library's preview mechanics serve.
132
+ - [File search tool](../../fs/tool-fs-search/README.md) — an `ItemRetainer` consumer collecting full results for spill.
133
+
134
+ -----
135
+
136
+ <a id="model-experience"></a>
137
+ ## Model Experience
138
+
139
+ Indirectly, through the retention consumers that render retained content and omission metadata.
140
+
141
+ #### KV Cache effect
142
+
143
+ No direct invalidation; the retention consumers own any request-prefix changes.
144
+
145
+ ## Known Limitations and Deferred Work
146
+
147
+ <a id="known-limitations-and-deferred-work"></a>
148
+
149
+
150
+ These limits define what the retainers deliberately do not cover. They are current package constraints, not a task backlog.
151
+
152
+ - **Item retention supports `head` only** — tail, head/tail, pagination, grouping, and provider-completeness semantics remain tool-owned.
153
+ - **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 the returned text valid.
154
+
155
+ <a id="dev-note"></a>
156
+ ### Dev Note
157
+
158
+ <details>
159
+ <summary>Working context for maintainers — click to expand</summary>
160
+
161
+ None.
162
+
163
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,163 @@
1
+ ---
2
+ description: "为必须限制返回上下文量的工具提供有界的面向模型输出:项与文本 retainer,以及标准化的省略页脚。"
3
+ kind: "package-library"
4
+ ---
5
+
6
+ # @qilin-agent/output-retention
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 使用 `qilin-output-retention` 限制工具返回给模型的项或文本量,并报告省略了什么。`ItemRetainer` 保留有序的头部窗口,并可报告精确的省略项数;`TextRetainer` 保留 head、tail 或 head-and-tail 字节窗口,且不会返回因切割而无效的 UTF-8。`formatRetentionNotice` 添加一致的省略子句,各工具则提供自己的恢复指引。分组、行号、spill 文件与提供方错误仍归工具负责;消费方直接导入本库,而不通过 `cordis.yml` 加载。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 凡是工具必须限制其结果到达模型的数量、并如实报告丢弃内容的地方,都使用 retainer。有序逻辑单元选 `ItemRetainer`,面向字节的流选 `TextRetainer`。
29
+
30
+ ### 限制项列表
31
+
32
+ ```ts
33
+ import { ItemRetainer } from '@qilin-agent/output-retention'
34
+
35
+ declare const globMaxResults: number
36
+ declare const candidates: AsyncIterable<{ path: string }>
37
+ const retainer = new ItemRetainer<{ path: string }>({ kind: 'head', maxItems: globMaxResults })
38
+ for await (const entry of candidates) {
39
+ retainer.push(entry) // keep draining past the cap for an exact count
40
+ }
41
+ const { items, truncated, omitted } = retainer.finish()
42
+ ```
43
+
44
+ `push()` 逐项报告该项是否被保留,`finish()` 返回保留的项与 `omitted`——当调用方持续送入每个已观察单元时,这是一个精确计数。搜索工具可以收集完整结果集用于 spill 文件,同时只为模型保留第一页。
45
+
46
+ ### 限制文本流
47
+
48
+ ```text
49
+ import { TextRetainer } from '@qilin-agent/output-retention'
50
+
51
+ const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
52
+ child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
53
+ const { text, omittedBytes } = out.finish()
54
+ ```
55
+
56
+ `head`、`tail` 与 `headTail` 按字节而非字符或行计数:子进程管道与 HTTP 正文都是字节流。`finish()` 会在每个切割处修剪不完整的码点,因此返回的文本绝不会携带由切割引入的替换字符,码点也绝不会跨被省略的中间部分重建。
57
+
58
+ ### 构建省略页脚
59
+
60
+ ```ts
61
+ import { formatRetentionNotice } from '@qilin-agent/output-retention'
62
+
63
+ declare const grepMaxMatches: number
64
+ declare const items: { length: number }
65
+ import type { Omitted } from '@qilin-agent/output-retention'
66
+
67
+ declare const omitted: Omitted
68
+
69
+ const footer = formatRetentionNotice(
70
+ { scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
71
+ ({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
72
+ )
73
+ ```
74
+
75
+ 库负责标准化省略子句(`Omitted 3 items.`)并把它与工具自有的恢复指引拼接;只有工具知道恢复动作,因此这些措辞由工具提供。
76
+
77
+ ### `truncated` 意味着什么
78
+
79
+ `truncated` 是预算事实:retainer 因上限而省略了本可获得的内容。它绝不表示上游不完整——权限失败、跳过二进制文件、提供方部分失败与不可读候选项都留在工具领域字段中,绝不并入 `truncated`。
80
+
81
+ ### 当前工具如何使用它
82
+
83
+ | 工具 | Retainer | 工具仍负责什么 |
84
+ |---|---|---|
85
+ | `glob` | `ItemRetainer`,`head` | spill 文件收集、路径映射、已跳过候选项、`incomplete` |
86
+ | `grep` | `ItemRetainer`,`head` | spill 文件收集、逐匹配预览截断、分组、排序 |
87
+ | `bash` | `TextRetainer`,`tail` 或 `headTail` | spill 文件、退出状态、信号、超时、后台任务 |
88
+ | `web_fetch` | `TextRetainer`,`head` 或 `headTail` | 提供方与资源上限、错误状态 |
89
+ | `web_search` | `ItemRetainer`,`head` | 「来源已达上限」通知措辞与提供方事实 |
90
+
91
+ `read` 不属于本库:它的行窗口分页(`offset`/`limit`、行号、`totalLines`)是文件专属渲染器,单个省略计数无法表示该窗口的两侧。
92
+
93
+ -----
94
+
95
+ <a id="understand-the-implementation"></a>
96
+ ## 理解实现
97
+
98
+ <details>
99
+ <summary>实现细节——点击展开</summary>
100
+
101
+ 本库建立在一个分离之上:它负责「保留了什么、省略了什么」这个机制问题;业务含义全部归工具包所有。
102
+
103
+ ### 源码地图
104
+
105
+ | 文件 | 职责 |
106
+ |---|---|
107
+ | [`src/index.ts`](src/index.ts) | `ItemRetainer`、`TextRetainer`、`describeOmitted` 与 `formatRetentionNotice` |
108
+
109
+ ### 两个 retainer,两种资源模型
110
+
111
+ `ItemRetainer` 限制有序逻辑单元,只保留前 `maxItems` 个;调用方持续送入每个已观察单元,因此省略计数是精确的。`TextRetainer` 用同一个前缀/后缀累加器限制字节:`head` 只留前缀,`tail` 只留后缀,`headTail` 两者都留;累加器在内存中至多持有 `headBytes + tailBytes + 一个分片`,因此大流不会无界累积。
112
+
113
+ ### 预算事实如何保持诚实
114
+
115
+ `push()` 返回 `kept`(该单元或分片是否完整保留)与 `truncated`(是否已丢弃任何内容)。`finish()` 按实际返回的字节报告省略,因此丢弃部分码点字节的 UTF-8 边界修剪也会被计入——仅按预算推导的通知会高估保留文本。`describeOmitted` 只为 `exact` 打印计数;`unknown` 不打印计数,因为调用方没有提供。
116
+
117
+ ### read 渲染的排除
118
+
119
+ `read` 的 `offset`/`limit` 分页是行窗口渲染器,对所选窗口有自己的字节上限;单个 `Omitted` 值无法表示该窗口两侧,因此它不属于本库。
120
+
121
+ </details>
122
+
123
+ -----
124
+
125
+ <a id="further-exploration"></a>
126
+ ## 进一步探索
127
+
128
+ 当你需要消费方或库背后的边界决策时,阅读以下页面。
129
+
130
+ - [spill 策略](../../spill/spill-policy/README.zh.md)——组合 `TextRetainer`,围绕 spill 文件通知构建有界预览。
131
+ - [spill 子系统](../../../docs/subsystems/spill.zh.md)——本库预览机制所服务的 spill 词汇。
132
+ - [文件搜索工具](../../fs/tool-fs-search/README.zh.md)——为 spill 收集完整结果的 `ItemRetainer` 消费方。
133
+
134
+ -----
135
+
136
+ <a id="model-experience"></a>
137
+ ## 模型体验
138
+
139
+ 通过渲染保留内容与省略元数据的保留消费方间接影响模型。
140
+
141
+ #### KV Cache 影响
142
+
143
+ 不会直接导致失效;请求前缀的任何变更由保留消费方负责。
144
+
145
+ ## 已知限制与延期工作
146
+
147
+ <a id="known-limitations-and-deferred-work"></a>
148
+
149
+
150
+ 这些限制说明 retainer 刻意不覆盖什么。它们是当前包约束,不是任务积压。
151
+
152
+ - **项保留只支持 `head`**——tail、head/tail、分页、分组与提供方完整性语义仍归工具所有。
153
+ - **文本保留面向字节**——`read` 分页等行窗口与字符窗口需要单独的渲染器;切割可能丢弃部分 UTF-8 边界字节,以保持返回文本有效。
154
+
155
+ <a id="dev-note"></a>
156
+ ### 开发备注
157
+
158
+ <details>
159
+ <summary>维护者的工作上下文——点击展开</summary>
160
+
161
+ 无。
162
+
163
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,285 @@
1
+ //#region lib/types/index.js
2
+ /**
3
+ * A dependency-light **retention** library: bounded model-facing output for
4
+ * tools that must cap how much context they return. A caller feeds items or
5
+ * text chunks into a bounded object, then gets the retained content plus exact
6
+ * omission metadata ({@link RetainedItems} / {@link RetainedText}).
7
+ *
8
+ * The library owns ONLY the mechanical question "what did we keep, what did we
9
+ * omit?". Tool-specific code still owns
10
+ * business semantics: file grouping, line numbering, exit codes, provider error
11
+ * states, per-line preview truncation, spill files, and the model-facing prose.
12
+ * In particular {@link RetainedText.truncated}/{@link RetainedItems.truncated}
13
+ * means "the retainer omitted otherwise-available content because of a budget" —
14
+ * NOT "the upstream was incomplete". Permission failures, skipped binaries,
15
+ * provider partial failures, and unreadable candidates stay in tool-domain
16
+ * fields, never folded into `truncated`.
17
+ *
18
+ * This is deliberately a library, not a cordis service or plugin: it takes no
19
+ * `ctx`, registers nothing, and emits no events. The two retainers are the only
20
+ * stateful pieces and their state is per-instance (one accumulation), never
21
+ * cross-call. Tool packages import it directly when they need bounded output.
22
+ *
23
+ * The two retainers differ in resource model, which is why they are two names
24
+ * rather than one generic collector:
25
+ * - {@link ItemRetainer} bounds ordered logical units (paths, grep matches,
26
+ * search sources). `head` retention only in v1.
27
+ * - {@link TextRetainer} bounds byte-oriented text streams (bash stdout/stderr,
28
+ * web bodies). `head` / `tail` / `headTail`, preserving UTF-8 boundaries at
29
+ * {@link TextRetainer.finish}.
30
+ *
31
+ * @module @qilin-agent/output-retention
32
+ */
33
+ /** Assert a budget field is a non-negative integer (the retainer request contract). */
34
+ function assertBudget(value, name) {
35
+ if (!Number.isInteger(value) || value < 0) throw new Error(`${name} must be a non-negative integer`);
36
+ }
37
+ /**
38
+ * Bounds an ordered stream of logical units, keeping the first `maxItems`
39
+ * ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it
40
+ * was kept and whether the retained result is now truncated.
41
+ *
42
+ * Grouping, sorting, path mapping, per-unit preview truncation, and any
43
+ * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing
44
+ * more. The caller pushes prepared logical units and, after {@link finish},
45
+ * groups/sorts the retained subset itself.
46
+ */
47
+ var ItemRetainer = class {
48
+ maxItems;
49
+ items = [];
50
+ seen = 0;
51
+ omittedCount = 0;
52
+ /** @param strategy Head strategy: `maxItems` (non-negative integer). */
53
+ constructor(strategy) {
54
+ assertBudget(strategy.maxItems, "maxItems");
55
+ this.maxItems = strategy.maxItems;
56
+ }
57
+ /**
58
+ * Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped
59
+ * and counted as omitted. Callers keep pushing all observed units, so the final
60
+ * {@link Omitted} count is exact.
61
+ *
62
+ * @param item The prepared logical unit (path, flat match, source).
63
+ * @returns The per-push {@link PushDecision}.
64
+ */
65
+ push(item) {
66
+ this.seen++;
67
+ if (this.items.length < this.maxItems) {
68
+ this.items.push(item);
69
+ return {
70
+ kept: true,
71
+ truncated: false
72
+ };
73
+ }
74
+ this.omittedCount++;
75
+ return {
76
+ kept: false,
77
+ truncated: true
78
+ };
79
+ }
80
+ /**
81
+ * Finalize and report what was kept and omitted.
82
+ *
83
+ * @returns The {@link RetainedItems} snapshot (safe to group/sort downstream).
84
+ */
85
+ finish() {
86
+ const truncated = this.omittedCount > 0;
87
+ return {
88
+ items: this.items,
89
+ truncated,
90
+ seen: this.seen,
91
+ kept: this.items.length,
92
+ omitted: truncated ? {
93
+ kind: "exact",
94
+ count: this.omittedCount
95
+ } : { kind: "none" }
96
+ };
97
+ }
98
+ };
99
+ const encoder = new TextEncoder();
100
+ const decoder = new TextDecoder();
101
+ /**
102
+ * Drop a trailing incomplete UTF-8 sequence so a prefix cut never emits a
103
+ * replacement char at the boundary. Walks back over continuation bytes
104
+ * (`10xxxxxx`) to the lead byte; if fewer bytes follow it than the lead byte's
105
+ * length declares, the sequence is incomplete and is trimmed. A complete tail,
106
+ * or a run too long/short to be a valid lead, is returned untouched (any
107
+ * genuinely malformed interior is left for the decoder to replace).
108
+ */
109
+ function trimTrailingPartialUtf8(bytes) {
110
+ let i = bytes.length - 1;
111
+ while (i >= 0 && (bytes[i] & 192) === 128 && bytes.length - i <= 3) i--;
112
+ if (i < 0) return bytes;
113
+ const lead = bytes[i];
114
+ const expected = lead < 128 ? 1 : lead < 224 ? 2 : lead < 240 ? 3 : lead < 248 ? 4 : 0;
115
+ if (expected === 0) return bytes;
116
+ return bytes.length - i < expected ? bytes.subarray(0, i) : bytes;
117
+ }
118
+ /**
119
+ * Drop leading continuation bytes (`10xxxxxx`) so a suffix cut starts on a
120
+ * lead/ASCII byte instead of mid-codepoint.
121
+ */
122
+ function trimLeadingContinuationUtf8(bytes) {
123
+ let i = 0;
124
+ while (i < bytes.length && (bytes[i] & 192) === 128) i++;
125
+ return bytes.subarray(i);
126
+ }
127
+ /**
128
+ * Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both
129
+ * ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix
130
+ * accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both.
131
+ *
132
+ * Bytes, not characters: caps and `omittedBytes` are byte counts for process/
133
+ * body safety. Chunks that straddle a codepoint are handled — {@link finish}
134
+ * trims a partial codepoint at each cut so the returned text never introduces a
135
+ * replacement char at the boundary. The retainer holds at most
136
+ * `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped
137
+ * as they slide out), so a large stream does not accumulate unbounded.
138
+ */
139
+ var TextRetainer = class {
140
+ prefixCap;
141
+ suffixCap;
142
+ prefixChunks = [];
143
+ prefixHeld = 0;
144
+ suffixChunks = [];
145
+ suffixHeld = 0;
146
+ total = 0;
147
+ /** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */
148
+ constructor(strategy) {
149
+ switch (strategy.kind) {
150
+ case "head":
151
+ assertBudget(strategy.maxBytes, "maxBytes");
152
+ this.prefixCap = strategy.maxBytes;
153
+ this.suffixCap = 0;
154
+ break;
155
+ case "tail":
156
+ assertBudget(strategy.maxBytes, "maxBytes");
157
+ this.prefixCap = 0;
158
+ this.suffixCap = strategy.maxBytes;
159
+ break;
160
+ case "headTail":
161
+ assertBudget(strategy.headBytes, "headBytes");
162
+ assertBudget(strategy.tailBytes, "tailBytes");
163
+ this.prefixCap = strategy.headBytes;
164
+ this.suffixCap = strategy.tailBytes;
165
+ break;
166
+ }
167
+ }
168
+ /**
169
+ * Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix
170
+ * bytes fill up to the prefix cap then stop; suffix bytes roll so only the
171
+ * last `suffixCap` bytes are retained. `kept` is `true` only when no byte of
172
+ * this chunk was dropped.
173
+ *
174
+ * @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`).
175
+ * @returns The per-push {@link PushDecision}.
176
+ */
177
+ push(chunk) {
178
+ const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk;
179
+ const before = this.total;
180
+ this.total += bytes.length;
181
+ const room = this.prefixCap - this.prefixHeld;
182
+ const take = Math.max(0, Math.min(room, bytes.length));
183
+ if (take > 0) {
184
+ this.prefixChunks.push(bytes.subarray(0, take));
185
+ this.prefixHeld += take;
186
+ }
187
+ if (this.suffixCap > 0) {
188
+ this.suffixChunks.push(bytes);
189
+ this.suffixHeld += bytes.length;
190
+ let head = this.suffixChunks[0];
191
+ while (head !== void 0 && this.suffixHeld - head.length >= this.suffixCap) {
192
+ this.suffixChunks.shift();
193
+ this.suffixHeld -= head.length;
194
+ head = this.suffixChunks[0];
195
+ }
196
+ if (head !== void 0 && this.suffixHeld > this.suffixCap) {
197
+ const excess = this.suffixHeld - this.suffixCap;
198
+ this.suffixChunks[0] = head.subarray(excess);
199
+ this.suffixHeld -= excess;
200
+ }
201
+ }
202
+ return {
203
+ kept: !(this.omittedAt(this.total) > this.omittedAt(before)),
204
+ truncated: this.omittedAt(this.total) > 0
205
+ };
206
+ }
207
+ /** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */
208
+ omittedAt(total) {
209
+ const prefixLen = Math.min(total, this.prefixCap);
210
+ const suffixLen = Math.min(total - prefixLen, this.suffixCap);
211
+ return total - prefixLen - suffixLen;
212
+ }
213
+ /**
214
+ * Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8
215
+ * boundary at its cut) and report the exact omitted byte count.
216
+ *
217
+ * @returns The {@link RetainedText} snapshot (safe to hand to a formatter).
218
+ */
219
+ finish() {
220
+ const prefixLen = Math.min(this.total, this.prefixCap);
221
+ const suffixLen = Math.min(this.total - prefixLen, this.suffixCap);
222
+ const prefix = concat(this.prefixChunks);
223
+ const suffix = concat(this.suffixChunks).subarray(this.suffixHeld - suffixLen);
224
+ const budgetOmitted = this.omittedAt(this.total);
225
+ const [keptPrefix, keptSuffix] = budgetOmitted > 0 ? [trimTrailingPartialUtf8(prefix), trimLeadingContinuationUtf8(suffix)] : [prefix, suffix];
226
+ const text = budgetOmitted > 0 ? decoder.decode(keptPrefix) + decoder.decode(keptSuffix) : decoder.decode(concat([prefix, suffix]));
227
+ const omitted = this.total - keptPrefix.length - keptSuffix.length;
228
+ const truncated = omitted > 0;
229
+ return {
230
+ text,
231
+ truncated,
232
+ omittedBytes: truncated ? {
233
+ kind: "exact",
234
+ count: omitted
235
+ } : { kind: "none" }
236
+ };
237
+ }
238
+ };
239
+ /** Concatenate chunks into one contiguous buffer (their exact total length). */
240
+ function concat(chunks) {
241
+ let length = 0;
242
+ for (const chunk of chunks) length += chunk.length;
243
+ const out = new Uint8Array(length);
244
+ let offset = 0;
245
+ for (const chunk of chunks) {
246
+ out.set(chunk, offset);
247
+ offset += chunk.length;
248
+ }
249
+ return out;
250
+ }
251
+ /**
252
+ * Standardized, false-precision-safe wording for one {@link Omitted} value —
253
+ * the "may standardize omission wording" half the library owns. `exact` prints
254
+ * the count (`Omitted 3 items`); `unknown` prints NO count because the caller
255
+ * did not provide one. `none` is the empty string.
256
+ *
257
+ * @param omitted The omission metadata from a retainer result.
258
+ * @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`).
259
+ * @returns A neutral clause (no trailing space), or `''` when nothing was omitted.
260
+ */
261
+ function describeOmitted(omitted, unit) {
262
+ switch (omitted.kind) {
263
+ case "none": return "";
264
+ case "exact": return `Omitted ${omitted.count} ${unit}.`;
265
+ case "unknown": return `More ${unit} were omitted.`;
266
+ }
267
+ }
268
+ /**
269
+ * Turn a {@link RetentionNotice} into a one-line footer: the library-owned
270
+ * standardized omission clause ({@link describeOmitted}) followed by the tool's
271
+ * own recovery guidance. The library never owns recovery words — only the tool
272
+ * knows the action ("narrow the pattern", "fetch a more specific URL", "read the
273
+ * spill file") — so `recovery` supplies them and receives the full notice to
274
+ * phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two
275
+ * are joined with a single space.
276
+ *
277
+ * @param notice The neutral retention outcome.
278
+ * @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`).
279
+ * @returns The combined footer line.
280
+ */
281
+ function formatRetentionNotice(notice, recovery) {
282
+ return [describeOmitted(notice.omitted, notice.unit), recovery(notice)].filter((part) => part.length > 0).join(" ");
283
+ }
284
+ //#endregion
285
+ export { ItemRetainer, TextRetainer, describeOmitted, formatRetentionNotice };
@@ -0,0 +1,225 @@
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 @qilin-agent/output-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
+ } | {
42
+ kind: 'exact';
43
+ count: number;
44
+ } | {
45
+ kind: 'unknown';
46
+ };
47
+ /**
48
+ * The caller receives this after each `push()`.
49
+ */
50
+ export interface PushDecision {
51
+ /** Was this whole unit / all of this chunk's bytes retained (nothing dropped)? */
52
+ kept: boolean;
53
+ /** Cumulative: has the retainer omitted anything due to the budget yet? */
54
+ truncated: boolean;
55
+ }
56
+ /**
57
+ * Final result for ordered logical units.
58
+ *
59
+ * `seen` means units OBSERVED by the retainer, not necessarily the total in the
60
+ * upstream source. `kept` is `items.length`, surfaced explicitly so a notice
61
+ * formatter need not re-count.
62
+ */
63
+ export interface RetainedItems<T> {
64
+ items: T[];
65
+ truncated: boolean;
66
+ seen: number;
67
+ kept: number;
68
+ omitted: Omitted;
69
+ }
70
+ /**
71
+ * Final result for text streams.
72
+ *
73
+ * The returned `text` is safe to hand to a formatter: the retainer adds no
74
+ * tool-specific headers, exit markers, XML tags, or recovery instructions, and
75
+ * `omittedBytes` counts BYTES (not characters or lines) — text retention is
76
+ * byte-oriented for process/body safety. UTF-8 boundaries at each cut are
77
+ * preserved, so `text` never carries a replacement char introduced by the cut
78
+ * itself.
79
+ */
80
+ export interface RetainedText {
81
+ text: string;
82
+ truncated: boolean;
83
+ omittedBytes: Omitted;
84
+ }
85
+ /** Item retention strategy. Only `head` in v1; windows/grouped budgets wait for a second consumer. */
86
+ export type ItemRetentionStrategy = {
87
+ /** Keep the first `maxItems` units. Use for `glob`, `grep`, and web sources. */
88
+ kind: 'head';
89
+ maxItems: number;
90
+ };
91
+ /** Text retention strategy: keep a prefix, a suffix, or both, counted in bytes. */
92
+ export type TextRetentionStrategy = {
93
+ /** Keep the first `maxBytes` bytes. */
94
+ kind: 'head';
95
+ maxBytes: number;
96
+ } | {
97
+ /** Keep the final `maxBytes` bytes. Requires reading to the end. */
98
+ kind: 'tail';
99
+ maxBytes: number;
100
+ } | {
101
+ /** Keep a stable prefix and suffix, omitting the middle. Requires reading to the end. */
102
+ kind: 'headTail';
103
+ headBytes: number;
104
+ tailBytes: number;
105
+ };
106
+ /**
107
+ * A neutral, tool-agnostic description of one retention outcome — the input to
108
+ * {@link formatRetentionNotice}. It carries the mechanical facts (strategy,
109
+ * unit, limit, kept count, {@link Omitted}); the tool supplies the recovery
110
+ * words, because only the tool knows the recovery action ("narrow the pattern",
111
+ * "fetch a more specific URL", "read the spill file").
112
+ */
113
+ export interface RetentionNotice {
114
+ /** Tool/scope label, e.g. `grep`, `web_fetch`, `bash stdout`. */
115
+ scope: string;
116
+ strategy: 'head' | 'tail' | 'headTail';
117
+ unit: 'items' | 'bytes' | 'chars' | 'lines';
118
+ limit: number | {
119
+ head: number;
120
+ tail: number;
121
+ };
122
+ kept: number;
123
+ omitted: Omitted;
124
+ }
125
+ /**
126
+ * Bounds an ordered stream of logical units, keeping the first `maxItems`
127
+ * ({@link ItemRetentionStrategy} `head`). `push()` reports, per unit, whether it
128
+ * was kept and whether the retained result is now truncated.
129
+ *
130
+ * Grouping, sorting, path mapping, per-unit preview truncation, and any
131
+ * `incomplete` state stay OUTSIDE the retainer: it counts and keeps, nothing
132
+ * more. The caller pushes prepared logical units and, after {@link finish},
133
+ * groups/sorts the retained subset itself.
134
+ */
135
+ export declare class ItemRetainer<T> {
136
+ private readonly maxItems;
137
+ private readonly items;
138
+ private seen;
139
+ private omittedCount;
140
+ /** @param strategy Head strategy: `maxItems` (non-negative integer). */
141
+ constructor(strategy: ItemRetentionStrategy);
142
+ /**
143
+ * Offer one unit. Kept when the retainer is below `maxItems`; otherwise dropped
144
+ * and counted as omitted. Callers keep pushing all observed units, so the final
145
+ * {@link Omitted} count is exact.
146
+ *
147
+ * @param item The prepared logical unit (path, flat match, source).
148
+ * @returns The per-push {@link PushDecision}.
149
+ */
150
+ push(item: T): PushDecision;
151
+ /**
152
+ * Finalize and report what was kept and omitted.
153
+ *
154
+ * @returns The {@link RetainedItems} snapshot (safe to group/sort downstream).
155
+ */
156
+ finish(): RetainedItems<T>;
157
+ }
158
+ /**
159
+ * Bounds a byte-oriented text stream, keeping a prefix, a suffix, or both
160
+ * ({@link TextRetentionStrategy}). All three strategies share one prefix/suffix
161
+ * accumulator: `head` is prefix-only, `tail` is suffix-only, `headTail` is both.
162
+ *
163
+ * Bytes, not characters: caps and `omittedBytes` are byte counts for process/
164
+ * body safety. Chunks that straddle a codepoint are handled — {@link finish}
165
+ * trims a partial codepoint at each cut so the returned text never introduces a
166
+ * replacement char at the boundary. The retainer holds at most
167
+ * `prefixCap + tailBytes + one chunk` in memory (old suffix chunks are dropped
168
+ * as they slide out), so a large stream does not accumulate unbounded.
169
+ */
170
+ export declare class TextRetainer {
171
+ private readonly prefixCap;
172
+ private readonly suffixCap;
173
+ private readonly prefixChunks;
174
+ private prefixHeld;
175
+ private readonly suffixChunks;
176
+ private suffixHeld;
177
+ private total;
178
+ /** @param strategy One {@link TextRetentionStrategy} variant; byte budgets must be non-negative integers. */
179
+ constructor(strategy: TextRetentionStrategy);
180
+ /**
181
+ * Offer one chunk (a `Uint8Array`, or a `string` encoded as UTF-8). Prefix
182
+ * bytes fill up to the prefix cap then stop; suffix bytes roll so only the
183
+ * last `suffixCap` bytes are retained. `kept` is `true` only when no byte of
184
+ * this chunk was dropped.
185
+ *
186
+ * @param chunk The next bytes of the stream (`Uint8Array` or UTF-8 `string`).
187
+ * @returns The per-push {@link PushDecision}.
188
+ */
189
+ push(chunk: Uint8Array | string): PushDecision;
190
+ /** Bytes omitted once `total` bytes have been seen: `total − keptPrefix − keptSuffix`. */
191
+ private omittedAt;
192
+ /**
193
+ * Finalize: decode the retained prefix and suffix (each trimmed to a UTF-8
194
+ * boundary at its cut) and report the exact omitted byte count.
195
+ *
196
+ * @returns The {@link RetainedText} snapshot (safe to hand to a formatter).
197
+ */
198
+ finish(): RetainedText;
199
+ }
200
+ /**
201
+ * Standardized, false-precision-safe wording for one {@link Omitted} value —
202
+ * the "may standardize omission wording" half the library owns. `exact` prints
203
+ * the count (`Omitted 3 items`); `unknown` prints NO count because the caller
204
+ * did not provide one. `none` is the empty string.
205
+ *
206
+ * @param omitted The omission metadata from a retainer result.
207
+ * @param unit The noun for the omitted quantity (`items`, `bytes`, `chars`, `lines`).
208
+ * @returns A neutral clause (no trailing space), or `''` when nothing was omitted.
209
+ */
210
+ export declare function describeOmitted(omitted: Omitted, unit: RetentionNotice['unit']): string;
211
+ /**
212
+ * Turn a {@link RetentionNotice} into a one-line footer: the library-owned
213
+ * standardized omission clause ({@link describeOmitted}) followed by the tool's
214
+ * own recovery guidance. The library never owns recovery words — only the tool
215
+ * knows the action ("narrow the pattern", "fetch a more specific URL", "read the
216
+ * spill file") — so `recovery` supplies them and receives the full notice to
217
+ * phrase from (`kept`, `limit`, `omitted`, …). Either half may be empty; the two
218
+ * are joined with a single space.
219
+ *
220
+ * @param notice The neutral retention outcome.
221
+ * @param recovery Tool-supplied guidance builder; receives the notice, returns a sentence (or `''`).
222
+ * @returns The combined footer line.
223
+ */
224
+ export declare function formatRetentionNotice(notice: RetentionNotice, recovery: (notice: RetentionNotice) => string): string;
225
+ //# sourceMappingURL=index.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@qilin-agent/output-retention",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)",
4
+ "version": "3.1.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kkutysllb/QiLin.git",
11
+ "directory": "packages/util/output-retention"
12
+ },
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "types": "lib/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./src/*": "./src/*",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib/index.js",
29
+ "lib/types/**/*.d.ts"
30
+ ],
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@qilin-agent/kylin": "~4.0.3"
34
+ },
35
+ "devDependencies": {
36
+ "@qilin-agent/kylin": "~4.0.3"
37
+ }
6
38
  }