@hasna-internal/kai-output-retention 0.1.1-rc.2
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 +6 -0
- package/README.md +97 -0
- package/README.zh.md +97 -0
- package/lib/index.js +285 -0
- package/lib/invariant.js +23 -0
- package/lib/types/index.d.ts +225 -0
- package/lib/types/invariant.d.ts +16 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DeepSeek
|
|
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,6 @@
|
|
|
1
|
+
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
+
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
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
|
+
README.md: e1d2c828fd4c6526cc391e7f93d7fe20df26e268
|
|
6
|
+
README.zh.md: 51055dd33e2cce3b82a1d15d6994f9d9f37a0579
|
package/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# dsh-output-retention
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## API
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
ItemRetainer, TextRetainer,
|
|
16
|
+
describeOmitted, formatRetentionNotice,
|
|
17
|
+
} from '@hasna-internal/kai-output-retention'
|
|
18
|
+
import type {
|
|
19
|
+
Omitted, PushDecision, RetainedItems, RetainedText,
|
|
20
|
+
ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice,
|
|
21
|
+
} from '@hasna-internal/kai-output-retention'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| Export | Role |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `ItemRetainer<T>` | Bounds ordered logical units (paths, grep matches, sources). `head` only. `push()` → `PushDecision`; `finish()` → `RetainedItems<T>`. |
|
|
27
|
+
| `TextRetainer` | Bounds a byte-oriented text stream. `head` / `tail` / `headTail`, UTF-8 boundaries preserved at `finish()`. `push()` → `PushDecision`; `finish()` → `RetainedText`. |
|
|
28
|
+
| `describeOmitted(omitted, unit)` | Standardized omission clause (`exact` prints a count; `unknown` does not). |
|
|
29
|
+
| `formatRetentionNotice(notice, recovery)` | Joins the standardized omission clause with the tool's own recovery guidance. |
|
|
30
|
+
| `Omitted` | `none` / `exact` / `unknown` — how much was omitted. |
|
|
31
|
+
| `PushDecision` | `{ kept, truncated }` — the per-push retention result. |
|
|
32
|
+
|
|
33
|
+
## Resource Modes
|
|
34
|
+
|
|
35
|
+
The two retainers are separate names, not one generic collector, because they differ in **resource model**.
|
|
36
|
+
|
|
37
|
+
- **`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.
|
|
38
|
+
- **`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.
|
|
39
|
+
|
|
40
|
+
## `truncated` is a budget fact, never "incomplete"
|
|
41
|
+
|
|
42
|
+
`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.
|
|
43
|
+
|
|
44
|
+
## Bytes, not characters
|
|
45
|
+
|
|
46
|
+
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.
|
|
47
|
+
|
|
48
|
+
## Tool mappings
|
|
49
|
+
|
|
50
|
+
Current retention consumers use these mappings:
|
|
51
|
+
|
|
52
|
+
| Tool | Retainer & strategy | Notes |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `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. |
|
|
55
|
+
| `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. |
|
|
56
|
+
| `bash` | `TextRetainer`, `tail` or `headTail` | Executor still owns spill files, exit status, signal, timeout, and background jobs. |
|
|
57
|
+
| `web_fetch` | `TextRetainer`, `head` or `headTail` | Provider/resource caps stay provider facts; the retainer supplies only retained text and omission metadata. |
|
|
58
|
+
| `web_search` | `ItemRetainer<WebSearchSource>`, `head` | Standardizes the "sources capped" notice when providers return more sources than the model-facing result should include. |
|
|
59
|
+
|
|
60
|
+
`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.
|
|
61
|
+
|
|
62
|
+
## Usage shape
|
|
63
|
+
|
|
64
|
+
```ts ignore-check
|
|
65
|
+
// glob: keep the first page inline while still collecting the full list for spill.
|
|
66
|
+
const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults })
|
|
67
|
+
const allEntries: FsGlobEntry[] = []
|
|
68
|
+
for await (const entry of candidates) {
|
|
69
|
+
allEntries.push(entry)
|
|
70
|
+
retainer.push(entry)
|
|
71
|
+
}
|
|
72
|
+
const { items, truncated, omitted } = retainer.finish()
|
|
73
|
+
|
|
74
|
+
// bash: keep a head + tail, read to process exit.
|
|
75
|
+
const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
|
|
76
|
+
child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
|
|
77
|
+
const { text, omittedBytes } = out.finish()
|
|
78
|
+
|
|
79
|
+
// A footer: the library standardizes the omission clause; the tool owns recovery words.
|
|
80
|
+
const footer = formatRetentionNotice(
|
|
81
|
+
{ scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
|
|
82
|
+
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Model Experience
|
|
87
|
+
|
|
88
|
+
Indirectly, through tool consumers that render retained content and omission metadata.
|
|
89
|
+
|
|
90
|
+
#### KV Cache effect
|
|
91
|
+
|
|
92
|
+
No direct invalidation; the named consumer owns any request-prefix changes.
|
|
93
|
+
|
|
94
|
+
## Known Limitations and Deferred Work
|
|
95
|
+
|
|
96
|
+
- **Item retention supports `head` only** — tail, head/tail, pagination, grouping, and provider-completeness semantics remain tool-owned.
|
|
97
|
+
- **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.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# dsh-output-retention
|
|
2
|
+
|
|
3
|
+
[English](README.md) | 中文
|
|
4
|
+
|
|
5
|
+
一个轻依赖的**保留**库:为必须限制返回上下文量的工具提供有界的面向模型输出。调用方将项或文本分片送入有界对象,然后取回保留的内容和精确的省略元数据。
|
|
6
|
+
|
|
7
|
+
该库**只**负责这个机制问题:*「我们保留了什么,又省略了什么?」*。工具专用代码保留其业务语义:文件分组、行号、退出码、提供方错误状态、每行预览截断、spill 文件以及面向模型的文案。这就是 [Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md) 划定的边界。
|
|
8
|
+
|
|
9
|
+
它是**库,而非服务或插件**:没有 `ctx`,不注册任何内容,不发出任何事件。状态只存在于每个 retainer(一次累积)中,绝不跨调用。工具包直接导入它。
|
|
10
|
+
|
|
11
|
+
## 对外接口
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import {
|
|
15
|
+
ItemRetainer, TextRetainer,
|
|
16
|
+
describeOmitted, formatRetentionNotice,
|
|
17
|
+
} from '@hasna-internal/kai-output-retention'
|
|
18
|
+
import type {
|
|
19
|
+
Omitted, PushDecision, RetainedItems, RetainedText,
|
|
20
|
+
ItemRetentionStrategy, TextRetentionStrategy, RetentionNotice,
|
|
21
|
+
} from '@hasna-internal/kai-output-retention'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
| 导出项 | 职责 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `ItemRetainer<T>` | 限制有序逻辑单元(路径、grep 匹配项、来源)。只支持 `head`。`push()` → `PushDecision`;`finish()` → `RetainedItems<T>`。 |
|
|
27
|
+
| `TextRetainer` | 限制面向字节的文本流。`head` / `tail` / `headTail`,并在 `finish()` 时保留 UTF-8 边界。`push()` → `PushDecision`;`finish()` → `RetainedText`。 |
|
|
28
|
+
| `describeOmitted(omitted, unit)` | 标准化的省略子句(`exact` 输出数量;`unknown` 不输出)。 |
|
|
29
|
+
| `formatRetentionNotice(notice, recovery)` | 将标准化的省略子句与工具自有的恢复指引连接起来。 |
|
|
30
|
+
| `Omitted` | `none` / `exact` / `unknown`:省略了多少内容。 |
|
|
31
|
+
| `PushDecision` | `{ kept, truncated }`:每次 push 的保留结果。 |
|
|
32
|
+
|
|
33
|
+
## 资源模式
|
|
34
|
+
|
|
35
|
+
两个 retainer 使用独立名称,而不是同一个通用收集器,因为它们的**资源模型**不同。
|
|
36
|
+
|
|
37
|
+
- **`ItemRetainer` 限制有序逻辑单元**。搜索工具可收集完整结果集用于 spill 文件恢复,同时只为面向模型的预览保留前 `maxItems` 项。因为调用方会继续送入每个已观察到的项,所以省略数量是精确的。
|
|
38
|
+
- **`TextRetainer` 限制面向字节的文本**。`head`、`tail` 和 `headTail` 在 `finish()` 时保留 UTF-8 边界;`headTail` 是 `dsh-spill-policy` 用于围绕 spill 文件通知构建有界预览的形态。
|
|
39
|
+
|
|
40
|
+
## `truncated` 是预算事实,绝不表示「不完整」
|
|
41
|
+
|
|
42
|
+
`truncated` 表示*因为预算限制,retainer 省略了本可获得的内容*。它**不**表示上游不完整。权限失败、跳过二进制文件、提供方部分失败、不可读候选项和无效 UTF-8 保留在工具领域字段中,绝不合并到 `truncated`。将两者混为一谈是该库命名最容易诱发的缺陷;务必保持分离。
|
|
43
|
+
|
|
44
|
+
## 字节,而非字符
|
|
45
|
+
|
|
46
|
+
文本上限和 `omittedBytes` 按**字节**计数,以保证进程/正文安全(子进程管道和 HTTP 正文都是字节流)。跨越码点的分片会被正确处理:`finish()` 会修剪每个切割位置的不完整码点,使返回文本绝不在边界引入替换字符;首尾两侧会分开解码,因此绝不会跨越被省略的中间部分重建码点。按字符或行限制的预览预算属于独立的工具职责。
|
|
47
|
+
|
|
48
|
+
## 工具映射
|
|
49
|
+
|
|
50
|
+
当前的保留机制消费方采用以下映射:
|
|
51
|
+
|
|
52
|
+
| 工具 | Retainer 与策略 | 说明 |
|
|
53
|
+
|---|---|---|
|
|
54
|
+
| `glob` | `ItemRetainer<FsGlobEntry>`,`head` | 收集完整的已排序路径列表用于 spill 文件,同时在内联位置保留第一页。路径映射、已跳过候选项和 `incomplete` 保留在外部。 |
|
|
55
|
+
| `grep` | `ItemRetainer<FlatGrepMatch>`,`head` | 收集匹配项用于 spill 文件,同时在内联位置保留第一页。每个匹配项的预览截断、分组、排序和 `incomplete` 保留在外部。 |
|
|
56
|
+
| `bash` | `TextRetainer`,`tail` 或 `headTail` | 执行器仍负责 spill 文件、退出状态、信号、超时和后台任务。 |
|
|
57
|
+
| `web_fetch` | `TextRetainer`,`head` 或 `headTail` | 提供方/资源上限保留为提供方事实;retainer 只提供保留文本和省略元数据。 |
|
|
58
|
+
| `web_search` | `ItemRetainer<WebSearchSource>`,`head` | 当提供方返回的来源超过面向模型的结果应包含的数量时,标准化「来源已达上限」通知。 |
|
|
59
|
+
|
|
60
|
+
`read` 仍不属于这个通用库。其 `read-render` 辅助工具负责文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、偏移越界错误、每行预览截断,以及所选窗口的字节上限。该辅助工具是一个行窗口渲染器。单个 `Omitted` 数量无法表示该窗口两侧。
|
|
61
|
+
|
|
62
|
+
## 使用形态
|
|
63
|
+
|
|
64
|
+
```ts ignore-check
|
|
65
|
+
// glob: keep the first page inline while still collecting the full list for spill.
|
|
66
|
+
const retainer = new ItemRetainer<FsGlobEntry>({ kind: 'head', maxItems: globMaxResults })
|
|
67
|
+
const allEntries: FsGlobEntry[] = []
|
|
68
|
+
for await (const entry of candidates) {
|
|
69
|
+
allEntries.push(entry)
|
|
70
|
+
retainer.push(entry)
|
|
71
|
+
}
|
|
72
|
+
const { items, truncated, omitted } = retainer.finish()
|
|
73
|
+
|
|
74
|
+
// bash: keep a head + tail, read to process exit.
|
|
75
|
+
const out = new TextRetainer({ kind: 'headTail', headBytes: headCap, tailBytes: tailCap })
|
|
76
|
+
child.stdout.on('data', (chunk: Buffer) => { out.push(chunk) })
|
|
77
|
+
const { text, omittedBytes } = out.finish()
|
|
78
|
+
|
|
79
|
+
// A footer: the library standardizes the omission clause; the tool owns recovery words.
|
|
80
|
+
const footer = formatRetentionNotice(
|
|
81
|
+
{ scope: 'grep', strategy: 'head', unit: 'items', limit: grepMaxMatches, kept: items.length, omitted },
|
|
82
|
+
({ kept }) => `Results capped at ${kept}. Narrow the pattern, path, or include to see more.`,
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 模型体验
|
|
87
|
+
|
|
88
|
+
通过渲染保留内容和省略元数据的工具消费方间接影响模型。
|
|
89
|
+
|
|
90
|
+
#### KV Cache 影响
|
|
91
|
+
|
|
92
|
+
不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
|
|
93
|
+
|
|
94
|
+
## 已知限制与暂缓事项
|
|
95
|
+
|
|
96
|
+
- **项保留只支持 `head`**:tail、head/tail、分页、分组和提供方完整性语义仍由工具负责。
|
|
97
|
+
- **文本保留面向字节**:`read` 分页等行窗口和字符窗口需要单独的渲染器;切割可能会丢弃部分 UTF-8 边界字节,以保持返回文本有效。
|
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 @hasna-internal/kai-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 };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/**
|
|
3
|
+
* Package-owned invariant companion for `@hasna-internal/kai-output-retention`.
|
|
4
|
+
* @module @hasna-internal/kai-output-retention/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = "@hasna-internal/kai-output-retention";
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = "output-retention-invariant";
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ["invariants"];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value
|
|
13
|
+
* algebra is enforced by unit tests.
|
|
14
|
+
*/
|
|
15
|
+
const install = () => {};
|
|
16
|
+
/**
|
|
17
|
+
* Register this package's invariant companion.
|
|
18
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
19
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
20
|
+
*/
|
|
21
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
22
|
+
//#endregion
|
|
23
|
+
export { apply, inject, name };
|
|
@@ -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 @hasna-internal/kai-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
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-owned invariant companion for `@hasna-internal/kai-output-retention`.
|
|
3
|
+
* @module @hasna-internal/kai-output-retention/invariant
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
export declare const name = "output-retention-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
export declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
+
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
+
*/
|
|
15
|
+
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
+
//# sourceMappingURL=invariant.d.ts.map
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hasna-internal/kai-output-retention",
|
|
3
|
+
"description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)",
|
|
4
|
+
"version": "0.1.1-rc.2",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
+
"directory": "packages/util/output-retention"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "lib/index.js",
|
|
15
|
+
"types": "lib/types/index.d.ts",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./lib/types/index.d.ts",
|
|
19
|
+
"default": "./lib/index.js"
|
|
20
|
+
},
|
|
21
|
+
"./invariant": {
|
|
22
|
+
"types": "./lib/types/invariant.d.ts",
|
|
23
|
+
"default": "./lib/invariant.js"
|
|
24
|
+
},
|
|
25
|
+
"./src/*": "./src/*",
|
|
26
|
+
"./package.json": "./package.json"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"lib/index.js",
|
|
30
|
+
"lib/invariant.js",
|
|
31
|
+
"lib/types/**/*.d.ts"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@hasna-internal/kai-invariants": "^0.1.1-rc.2",
|
|
36
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@hasna-internal/kai-invariants": "^0.1.1-rc.2",
|
|
40
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
41
|
+
}
|
|
42
|
+
}
|