@qilin-agent/output-retention 0.0.0-stage → 3.1.4
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 +21 -0
- package/README.i18n.yaml +64 -0
- package/README.md +162 -2
- package/README.zh.md +163 -0
- package/lib/index.js +285 -0
- package/lib/types/index.d.ts +225 -0
- package/package.json +35 -3
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.
|
package/README.i18n.yaml
ADDED
|
@@ -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-agent-output-retention:
|
|
9
|
+
en: ec70ae8f9e521164
|
|
10
|
+
zh: b06ab5cd858745cb
|
|
11
|
+
/qilin-agent-output-retention/summary:
|
|
12
|
+
en: 3f0331a40f919223
|
|
13
|
+
zh: 9c670d911ceb4ff6
|
|
14
|
+
/qilin-agent-output-retention/table-of-contents:
|
|
15
|
+
en: d152484eb41ac6b4
|
|
16
|
+
zh: 09388d293f9be9cb
|
|
17
|
+
/qilin-agent-output-retention/use-this-package:
|
|
18
|
+
en: f62f2da3b880d972
|
|
19
|
+
zh: be8b8bea3bff9cb6
|
|
20
|
+
/qilin-agent-output-retention/use-this-package/bounding-a-list-of-items:
|
|
21
|
+
en: 4e33df2bffae04a9
|
|
22
|
+
zh: b79773e1936605bf
|
|
23
|
+
/qilin-agent-output-retention/use-this-package/bounding-a-text-stream:
|
|
24
|
+
en: c0002b8dd81f4732
|
|
25
|
+
zh: 4f8c0e9f03e4ecf9
|
|
26
|
+
/qilin-agent-output-retention/use-this-package/building-the-omission-footer:
|
|
27
|
+
en: 24a79057aea704e0
|
|
28
|
+
zh: ab6b91d0d1c3b8d7
|
|
29
|
+
/qilin-agent-output-retention/use-this-package/what-truncated-means:
|
|
30
|
+
en: d496be708ac637dc
|
|
31
|
+
zh: 739017ce47f08148
|
|
32
|
+
/qilin-agent-output-retention/use-this-package/how-the-current-tools-use-it:
|
|
33
|
+
en: ed4b3b343ad80143
|
|
34
|
+
zh: 65dd1f707c3ac747
|
|
35
|
+
/qilin-agent-output-retention/understand-the-implementation:
|
|
36
|
+
en: fd2a6c959bfe864e
|
|
37
|
+
zh: 25b11d28aced568c
|
|
38
|
+
/qilin-agent-output-retention/understand-the-implementation/source-map:
|
|
39
|
+
en: e2d87f9e71d8db62
|
|
40
|
+
zh: cf34dd787a069775
|
|
41
|
+
/qilin-agent-output-retention/understand-the-implementation/two-retainers-two-resource-models:
|
|
42
|
+
en: 6a5dd228d9f9808b
|
|
43
|
+
zh: 9bba82c5771ee705
|
|
44
|
+
/qilin-agent-output-retention/understand-the-implementation/how-the-budget-facts-stay-honest:
|
|
45
|
+
en: 101d99e1107d91c7
|
|
46
|
+
zh: ab64a35b62938db1
|
|
47
|
+
/qilin-agent-output-retention/understand-the-implementation/the-read-render-exclusion:
|
|
48
|
+
en: f76b67fa9e940705
|
|
49
|
+
zh: e7731bfd31cc9f5c
|
|
50
|
+
/qilin-agent-output-retention/further-exploration:
|
|
51
|
+
en: 7392047525d06599
|
|
52
|
+
zh: cc6e45cc72335359
|
|
53
|
+
/qilin-agent-output-retention/model-experience:
|
|
54
|
+
en: 247cdeeae9390244
|
|
55
|
+
zh: 0720d77e0a1d1c15
|
|
56
|
+
/qilin-agent-output-retention/model-experience/kv-cache-effect:
|
|
57
|
+
en: 4487c456cf996b48
|
|
58
|
+
zh: 2c25fe4a5a53f345
|
|
59
|
+
/qilin-agent-output-retention/known-limitations-and-deferred-work:
|
|
60
|
+
en: dabd5ca291570e70
|
|
61
|
+
zh: d1d98ece31eaa664
|
|
62
|
+
/qilin-agent-output-retention/known-limitations-and-deferred-work/dev-note:
|
|
63
|
+
en: e67f633dc6fe7773
|
|
64
|
+
zh: 3bb0c20c93f16f98
|
package/README.md
CHANGED
|
@@ -1,3 +1,163 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)",
|
|
4
|
+
"version": "3.1.4",
|
|
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
|
}
|