dsh-cherry-bridge 0.1.6 → 0.1.8
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/README.en.md +30 -16
- package/README.md +19 -11
- package/dist/index.mjs +253 -45
- package/package.json +1 -1
- package/src/defaults.ts +21 -0
- package/src/tools.ts +423 -78
package/README.en.md
CHANGED
|
@@ -38,13 +38,13 @@ flowchart TB
|
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
40
|
# From npm (recommended, pinned)
|
|
41
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.
|
|
41
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
|
|
42
42
|
|
|
43
43
|
# Install the latest npm release
|
|
44
44
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
45
45
|
|
|
46
46
|
# From GitHub source (same commit as the npm release)
|
|
47
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.
|
|
47
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
|
|
48
48
|
```
|
|
49
49
|
|
|
50
50
|
The package ships `cordis.patch.yml` (`package.json`'s `dsh.bundle.patch` points
|
|
@@ -103,15 +103,15 @@ cherry_interactions → see pending question/approval cards; cherry_respond answ
|
|
|
103
103
|
| Tool | What it does |
|
|
104
104
|
| --- | --- |
|
|
105
105
|
| `cherry_devices` | List every paired device and its connection state (`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`). |
|
|
106
|
-
| `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling)
|
|
107
|
-
| `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason
|
|
108
|
-
| `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`)
|
|
109
|
-
| `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
|
|
106
|
+
| `cherry_agents` | List the agents available on a device (paginated). A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`, the latter the 1000-item internal ceiling); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and filters to continue from that stop point (`limit` then bounds the next answer). A token from another tool, device or filter set is refused **locally**, never forwarded. |
|
|
107
|
+
| `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` with the same filters to continue. |
|
|
108
|
+
| `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. A truncated answer always carries `truncated` + `truncationReason` (`limit` or `safety-bound`); when the stop point can be named it also carries a `resumeCursor`: replay it as `cursor` with the same device and **identical filters** to continue (`limit` then bounds the next answer); a token from another tool, device or filter set is refused **locally**. |
|
|
109
|
+
| `cherry_read` | Read a session's message history snapshot, **newest-first** (`limit` drops the **oldest** messages). Attachment parts come back as `name`/`mediaType`/`byteLength` with an empty `content` — the bytes are never fetched. A truncated walk always says so (`truncated` + `truncationReason`): `limit` means your own ceiling was reached and raising `limit` helps; `aggregate-bytes` means the answer hit its **output size limit** — independent of `limit` (a larger `limit` only costs more round trips and stops at the same budget). **The older messages are reached by the resume token**: pass the `messagesResumeCursor` a truncated answer returned as this tool's own `messagesResumeCursor` argument to continue from that stop point (`limit` then bounds how many messages come back **from there**), walking a long history in successive answers. When the stop point cannot be named the token is **absent** (`truncated` stays `true`, and the absence does **not** mean the history ended); a wider `limit` is not a general remedy — it moves a `limit` stop only, while an `aggregate-bytes` stop leaves the older messages unreachable in that answer (see [Errors and limits](#errors-and-limits)). Two token forms: `cursor:<peer position>` (the peer page position after a whole window was consumed) and `after:<messageId>` (the walk stopped inside its window; the resume scans from the newest end to that message and returns the older ones). A token this tool did not emit is **refused locally** (never forwarded — the desktop silently falls back to the newest page for one), and an `after:` anchor that is no longer in the history is an **error**, not an empty result. |
|
|
110
110
|
| `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. The answer is `{events, nextCursor}`; pass that `nextCursor` back as `cursor` to continue from it. |
|
|
111
111
|
| `cherry_create` | Create a session in an agent workspace (workspace selection is **required**; identity is read back). |
|
|
112
112
|
| `cherry_send` | Send a message, by default waiting for a terminal state and returning the assistant text; `wait: false` returns the command receipt immediately. `messagesTruncated`/`messagesTruncationReason` report a **message**-level cut (older messages dropped); `deadline` means the **answer's own assembly budget** ran out (see below), with the content read so far still returned. |
|
|
113
113
|
| `cherry_wait` | Wait only for the session's in-flight execution to settle (sends nothing). |
|
|
114
|
-
| `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason
|
|
114
|
+
| `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason`; when the stop point can be named it also carries a `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue (`limit` counts the peer's pre-filter rows). |
|
|
115
115
|
| `cherry_respond` | Answer an interaction card: questions must be answered in full; approvals take `decision`. |
|
|
116
116
|
| `cherry_cancel` | Cancel the session's in-flight execution (`expectedExecutionId` required). |
|
|
117
117
|
| `cherry_pair` | Pair with a desktop using the pairing information shown by the scanned page. |
|
|
@@ -124,12 +124,12 @@ The `device` argument accepts an **exact `deviceId`**, or a **prefix of a
|
|
|
124
124
|
`cherry_send(wait: true)` / `cherry_wait` results carry one of **8 `status`
|
|
125
125
|
values**: the four protocol terminal states
|
|
126
126
|
(`completed`/`failed`/`cancelled`/`interrupted`), plus `awaiting-interaction`
|
|
127
|
-
(a pending card appeared while the run is still alive), `timeout` (the window
|
|
128
|
-
out, **not a failure** — partial content plus a stop-point
|
|
129
|
-
`idle` (`cherry_wait` met a session with nothing
|
|
130
|
-
snapshot immediately), and `subscription-lost` (the
|
|
131
|
-
could not be rebuilt, so the outcome is
|
|
132
|
-
with it).
|
|
127
|
+
(a pending card appeared while the run is still alive), `timeout` (the window
|
|
128
|
+
ran out, **not a failure** — partial content comes back, plus a stop-point
|
|
129
|
+
cursor when one can be named), `idle` (`cherry_wait` met a session with nothing
|
|
130
|
+
in flight and returned the snapshot immediately), and `subscription-lost` (the
|
|
131
|
+
subscription was lost and could not be rebuilt, so the outcome is
|
|
132
|
+
**unknowable**; repair guidance ships with it).
|
|
133
133
|
|
|
134
134
|
## Errors and limits
|
|
135
135
|
|
|
@@ -152,9 +152,23 @@ with it).
|
|
|
152
152
|
- A truncated history walk always says so (`truncationReason`; `limit`,
|
|
153
153
|
`aggregate-bytes` and `deadline` mean different things, see above) and,
|
|
154
154
|
whenever a stop point can be named honestly, carries a `messagesResumeCursor`
|
|
155
|
-
resume token
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
resume token. **When it cannot be named the token is absent**: the peer's
|
|
156
|
+
position is empty, carries leading/trailing whitespace or a control character,
|
|
157
|
+
looks like a retired v1 token, the token would exceed the 4,096-character
|
|
158
|
+
ceiling, or there is no message id to anchor on. The absence does **not** mean
|
|
159
|
+
the history ended (`truncated` stays `true`); the remedy follows the reason —
|
|
160
|
+
a `limit` stop answers to a wider `limit` only where the caller owns that
|
|
161
|
+
ceiling (`cherry_read` and the four list tools; a **wait answer's** `limit` is
|
|
162
|
+
its own fixed allowance, so read further with `cherry_read`), an
|
|
163
|
+
`aggregate-bytes` stop does not (nothing reaches past it in that answer), a
|
|
164
|
+
`safety-bound` stop is an internal circuit breaker no caller can move, and a
|
|
165
|
+
wait answer cut by its deadline is continued with `cherry_read`, which carries
|
|
166
|
+
no deadline. **Both faces obey one rule**:
|
|
167
|
+
the message face's `messagesResumeCursor` and the four list tools'
|
|
168
|
+
`resumeCursor` are emitted only when this tool would accept them back; an
|
|
169
|
+
interior plain space is legal and is not a reason to withhold one.
|
|
170
|
+
A message whose own parts could not be fully read carries `incomplete: true`
|
|
171
|
+
(inside a wait answer, a message cut by the deadline carries
|
|
158
172
|
`partsTruncated: "deadline"`).
|
|
159
173
|
- **A wait answer has an assembly budget of its own.** After the window ends,
|
|
160
174
|
`cherry_send(wait: true)` / `cherry_wait` still read some history (the message
|
package/README.md
CHANGED
|
@@ -36,13 +36,13 @@ flowchart TB
|
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
38
|
# 从 npm 安装(推荐,钉住版本)
|
|
39
|
-
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.
|
|
39
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.8
|
|
40
40
|
|
|
41
41
|
# 安装 npm 最新发布版
|
|
42
42
|
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
43
43
|
|
|
44
44
|
# 从 GitHub 源码安装(与 npm 发布同一提交)
|
|
45
|
-
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.
|
|
45
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.8
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
本包自带 `cordis.patch.yml`(`package.json` 的 `dsh.bundle.patch` 指向它),
|
|
@@ -94,15 +94,15 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
94
94
|
| 工具 | 作用 |
|
|
95
95
|
| --- | --- |
|
|
96
96
|
| `cherry_devices` | 列出已配对设备及连接状态(`connected`/`connecting`/`offline`/`disabled`/`identity-mismatch`)。 |
|
|
97
|
-
| `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000
|
|
98
|
-
| `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason
|
|
99
|
-
| `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound
|
|
100
|
-
| `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit`
|
|
97
|
+
| `cherry_agents` | 列出某设备上可用的 agent(分页)。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`,后者是 1000 条内部上限);停点可命名时还给出 `resumeCursor`——用同一 device 与过滤条件把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**,不会转发给对端。 |
|
|
98
|
+
| `cherry_workspaces` | 列出某 agent 的**已注册**工作区,以及是否存在系统工作区。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用相同过滤条件回传为 `cursor` 即可续读。 |
|
|
99
|
+
| `cherry_sessions` | 列出某 agent 的会话,可按工作区过滤。被截断时必带 `truncated`+`truncationReason`(`limit` 或 `safety-bound`),停点可命名时还给出 `resumeCursor`——用同一 device 与**相同过滤条件**把它回传为 `cursor` 即可从停点继续(此时 `limit` 约束的是下一次回答);跨工具/设备/过滤条件的令牌会被**本地拒绝**。 |
|
|
100
|
+
| `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit` 此时约束的是**从停点起**本次最多返回多少条),长历史分几次回答读完。停点不可命名时**没有**这个令牌(`truncated` 仍为 `true`,**缺失本身不代表历史已到底**;补救按 `truncationReason` 分:`limit` 可以提高 `limit`,`aggregate-bytes` 不行——被挡住的旧消息在这一回答里拿不到,详见[错误与限制](#错误与限制))。两种令牌:`cursor:<peer position>`(整窗读完后的 peer 分页位置)与 `after:<messageId>`(停在窗内,续读从最新端扫到该消息、返回它更旧的那些)。不是本工具发出的令牌会被**本地拒绝**(不转发给桌面端——桌面端会静默回退到最新页);`after:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
|
|
101
101
|
| `cherry_events` | 读取该会话缓冲区内比 `cursor` 更新的事件,并在投递后 ack。返回 `{events, nextCursor}`;把 `nextCursor` 作为下次的 `cursor` 传回即可续读。 |
|
|
102
102
|
| `cherry_create` | 在 agent 工作区创建会话(工作区选择**必填**;创建后回读身份)。 |
|
|
103
103
|
| `cherry_send` | 向会话发消息,默认等待运行到终态并返回助手文本;`wait: false` 立即返回命令回执。内容被截断时 `messagesTruncated`/`messagesTruncationReason` 说的是**消息**被截断(丢更旧的);`deadline` 表示**回答自身的装配时限**到了(见下),已读到的内容照常返回。 |
|
|
104
104
|
| `cherry_wait` | 只等待该会话在途执行收敛(不发送新消息)。 |
|
|
105
|
-
| `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason
|
|
105
|
+
| `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`;停点可命名时还给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
|
|
106
106
|
| `cherry_respond` | 应答交互卡:提问必须全量作答,审批走 `decision`。 |
|
|
107
107
|
| `cherry_cancel` | 取消会话在途执行(`expectedExecutionId` 必填)。 |
|
|
108
108
|
| `cherry_pair` | 用「扫码后页面显示的配对信息」建立与桌面端的配对。 |
|
|
@@ -113,7 +113,7 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
113
113
|
|
|
114
114
|
`cherry_send(wait: true)` / `cherry_wait` 的 `status` 有 **8 个取值**:四个协议终态
|
|
115
115
|
(`completed`/`failed`/`cancelled`/`interrupted`),加上 `awaiting-interaction`(出现待答
|
|
116
|
-
交互卡,运行仍活着)、`timeout
|
|
116
|
+
交互卡,运行仍活着)、`timeout`(窗口用尽,**不是失败**,随附部分内容,停点可命名时再带游标)、
|
|
117
117
|
`idle`(`cherry_wait` 遇到没有在途执行的会话,立即返回快照)、`subscription-lost`
|
|
118
118
|
(事件订阅丢失且重建失败,运行结局**不可知**,随附修复指引)。
|
|
119
119
|
|
|
@@ -128,9 +128,17 @@ cherry_interactions → 看待答的提问/审批卡;cherry_respond 作答
|
|
|
128
128
|
不在这个乘积里(每次并发读最多再带一整条消息,因为输出预算只在消息之间检查),parts 缓存另计。
|
|
129
129
|
等待槽位也计入等待类回答的时限:排到时限就放弃并把这一 part 标 `unavailable`。
|
|
130
130
|
- 历史走查被截断时一定说明(`truncationReason`,`limit` / `aggregate-bytes` / `deadline` 三种含义不同,见上),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
`
|
|
131
|
+
并在停点可命名时给出 `messagesResumeCursor` 续读令牌。**停点不可命名时就不给**:对端位置为空、
|
|
132
|
+
带首尾空白或任意控制字符、形如已停用的 v1 令牌、令牌会超过 4096 字符上限、或没有可当锚点的
|
|
133
|
+
消息 id——此时 `truncated` 仍为 `true`,**缺失本身不代表历史已到底**。补救按 `truncationReason`
|
|
134
|
+
分:`limit` 停点只有在调用方自己握着上限时才靠更宽的 `limit`(`cherry_read` 与四个列表工具);
|
|
135
|
+
**wait 类回答的 `limit` 是它自己的固定条数额度**,更旧的历史要用 `cherry_read` 去读。
|
|
136
|
+
`aggregate-bytes` 停点不行——预算是它停下的原因,更宽的 `limit` 只会多付往返,被挡住的旧消息
|
|
137
|
+
在这一回答里拿不到;`safety-bound` 停点是内部熔断,调用方动不了它;`deadline`(只出现在 wait
|
|
138
|
+
类回答)同样改用 `cherry_read` 续读,它没有时限。
|
|
139
|
+
**两个面是同一条规则**:消息面的 `messagesResumeCursor` 与四个列表工具的
|
|
140
|
+
`resumeCursor` 都只发出本工具会收回的令牌;位置内部的普通空格合法,不会因此被拒。
|
|
141
|
+
某条消息自身 parts 没读全时带 `incomplete: true`(wait 类回答里被时限截断的那条消息会带 `partsTruncated: "deadline"`)。
|
|
134
142
|
- **等待类回答有自己的装配时限**:`cherry_send(wait: true)` / `cherry_wait` 在窗口结束后仍要读一段历史
|
|
135
143
|
(消息页 + 每条消息的 parts + 每个外置 `{ref}` 内容),这一步现在是**有时限的**——截至「你的窗口 +
|
|
136
144
|
同样长度的时间」,下限 1 秒、上限 15 秒(即窗口 1 秒 → 最多再等 1 秒;窗口 20 秒 → 最多再等 15 秒)。
|
package/dist/index.mjs
CHANGED
|
@@ -2261,6 +2261,26 @@ const RECORD_BUDGET_BYTES = 63488;
|
|
|
2261
2261
|
const PARTS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 16896);
|
|
2262
2262
|
/** Interactions per interactions.list page: inline input + inline summary. */
|
|
2263
2263
|
const INTERACTIONS_LIST_PAGE_CLAMP = Math.floor(RECORD_BUDGET_BYTES / 25088);
|
|
2264
|
+
/**
|
|
2265
|
+
* Hard ceiling on ONE resume token (issues #70 and #74), in characters.
|
|
2266
|
+
*
|
|
2267
|
+
* BOTH faces take a token back from the caller — `cursor` on the four list
|
|
2268
|
+
* tools, `messagesResumeCursor` on `cherry_read` — and both tail it with a
|
|
2269
|
+
* peer-supplied position whose length we do not control: the desktop's session
|
|
2270
|
+
* cursor is `entity:<orderKey>:<uuid>` (~57 characters), a message cursor
|
|
2271
|
+
* `<ms>:<uuid>`, the numeric `pageOf` faces a handful of digits, and a future
|
|
2272
|
+
* desktop may use something longer — but nothing legitimate comes near this.
|
|
2273
|
+
* The bound exists so a hand-built or model-mangled string is refused by a
|
|
2274
|
+
* BOUND instead of being split, hashed and carried to the peer, and so both
|
|
2275
|
+
* parameters can declare `maxLength` honestly. ONE number for both faces: it
|
|
2276
|
+
* bounds caller INPUT, not what a token means.
|
|
2277
|
+
*
|
|
2278
|
+
* The emit side enforces it too (#76): a stop point whose token would exceed
|
|
2279
|
+
* this is not named at all, because a token this tool refuses to replay is
|
|
2280
|
+
* worse than no token — `truncated` still says the walk was cut. One number,
|
|
2281
|
+
* judged in both directions.
|
|
2282
|
+
*/
|
|
2283
|
+
const MAX_RESUME_TOKEN_CHARS = 4096;
|
|
2264
2284
|
//#endregion
|
|
2265
2285
|
//#region src/urlHost.ts
|
|
2266
2286
|
/**
|
|
@@ -5462,11 +5482,13 @@ var CherryToolEngine = class {
|
|
|
5462
5482
|
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
5463
5483
|
*/
|
|
5464
5484
|
#listPage(page, binding) {
|
|
5485
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
5486
|
+
const token = !page.truncated || position === void 0 ? void 0 : withinResumeTokenCeiling(encodeListResumeCursor(binding, position));
|
|
5465
5487
|
return {
|
|
5466
5488
|
items: page.items,
|
|
5467
5489
|
truncated: page.truncated,
|
|
5468
5490
|
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
|
|
5469
|
-
...
|
|
5491
|
+
...token === void 0 ? {} : { resumeCursor: token }
|
|
5470
5492
|
};
|
|
5471
5493
|
}
|
|
5472
5494
|
#devicesTool() {
|
|
@@ -5545,7 +5567,7 @@ var CherryToolEngine = class {
|
|
|
5545
5567
|
#agentsTool() {
|
|
5546
5568
|
return defineCherryTool({
|
|
5547
5569
|
name: "cherry_agents",
|
|
5548
|
-
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5570
|
+
description: "List the Cherry Studio agents available on a device. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5549
5571
|
parameters: {
|
|
5550
5572
|
type: "object",
|
|
5551
5573
|
properties: {
|
|
@@ -5588,7 +5610,7 @@ var CherryToolEngine = class {
|
|
|
5588
5610
|
#workspacesTool() {
|
|
5589
5611
|
return defineCherryTool({
|
|
5590
5612
|
name: "cherry_workspaces",
|
|
5591
|
-
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
5613
|
+
description: "List an agent's REGISTERED workspaces, plus whether a system workspace exists. The system workspace has no enumerable id: it is not in `items`, and its identity is only observable by creating a session in it and reading it back. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` with the same filters to continue.",
|
|
5592
5614
|
parameters: {
|
|
5593
5615
|
type: "object",
|
|
5594
5616
|
properties: {
|
|
@@ -5643,7 +5665,7 @@ var CherryToolEngine = class {
|
|
|
5643
5665
|
#sessionsTool() {
|
|
5644
5666
|
return defineCherryTool({
|
|
5645
5667
|
name: "cherry_sessions",
|
|
5646
|
-
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
5668
|
+
description: "List an agent's sessions, optionally filtered by workspace. The `agentId` and `workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds the TOTAL across pages (default 100, max 1000) and is aggregated internally. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same tool, device and filters to continue from that stop point (`limit` then bounds the next answer).",
|
|
5647
5669
|
parameters: {
|
|
5648
5670
|
type: "object",
|
|
5649
5671
|
properties: {
|
|
@@ -5700,7 +5722,7 @@ var CherryToolEngine = class {
|
|
|
5700
5722
|
#readTool() {
|
|
5701
5723
|
return defineCherryTool({
|
|
5702
5724
|
name: "cherry_read",
|
|
5703
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
5725
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as `kind: \"file\"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
5704
5726
|
parameters: {
|
|
5705
5727
|
type: "object",
|
|
5706
5728
|
properties: {
|
|
@@ -5716,11 +5738,12 @@ var CherryToolEngine = class {
|
|
|
5716
5738
|
type: "integer",
|
|
5717
5739
|
minimum: 1,
|
|
5718
5740
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5719
|
-
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token is how older messages are reached."
|
|
5741
|
+
description: "Maximum number of messages to return in total across pages (default 100). Distinct from the output budget: the answer may stop earlier with `truncationReason: \"aggregate-bytes\"`, which a larger `limit` does not extend — the resume token, when one is returned, is how older messages are reached."
|
|
5720
5742
|
},
|
|
5721
5743
|
messagesResumeCursor: {
|
|
5722
5744
|
type: "string",
|
|
5723
|
-
|
|
5745
|
+
maxLength: MAX_RESUME_TOKEN_CHARS,
|
|
5746
|
+
description: `A \`messagesResumeCursor\` a previous truncated read (this tool, or cherry_send/cherry_wait) returned: \`"cursor:<peer position>"\` or \`"after:<messageId>"\`. Continues the walk at that stop point instead of at the newest message; \`limit\` bounds what comes back from there. Omit it to read from the newest message. A token this tool did not emit is refused locally, and ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling.`
|
|
5724
5747
|
}
|
|
5725
5748
|
},
|
|
5726
5749
|
required: ["device", "sessionId"],
|
|
@@ -6474,7 +6497,7 @@ var CherryToolEngine = class {
|
|
|
6474
6497
|
#interactionsTool() {
|
|
6475
6498
|
return defineCherryTool({
|
|
6476
6499
|
name: "cherry_interactions",
|
|
6477
|
-
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
6500
|
+
description: "List the session's PENDING interactions (questions and approval decisions) that need an answer. The peer serves the full four-state set (pending/approved/denied/expired) — the pending filter is applied here. Each entry carries its `kind` (question|decision) and a summary; answer questions with cherry_respond's `response` form and decisions with `decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as `cursor` from the same `sessionId` to continue.",
|
|
6478
6501
|
parameters: {
|
|
6479
6502
|
type: "object",
|
|
6480
6503
|
properties: {
|
|
@@ -7657,9 +7680,11 @@ var CherryToolEngine = class {
|
|
|
7657
7680
|
}
|
|
7658
7681
|
/**
|
|
7659
7682
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
7660
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7661
|
-
* the unread history begins
|
|
7662
|
-
* or
|
|
7683
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
7684
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
7685
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
7686
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
7687
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7663
7688
|
*
|
|
7664
7689
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
7665
7690
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -7886,9 +7911,10 @@ var CherryToolEngine = class {
|
|
|
7886
7911
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
7887
7912
|
* and continues strictly after it.
|
|
7888
7913
|
*
|
|
7889
|
-
* The token is
|
|
7890
|
-
*
|
|
7891
|
-
*
|
|
7914
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
7915
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
7916
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
7917
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
7892
7918
|
*/
|
|
7893
7919
|
async #readTerminalMessages(connection, sessionId, session, deadline) {
|
|
7894
7920
|
const historyRevision = optionalString(session["historyRevision"]);
|
|
@@ -8118,14 +8144,90 @@ function parseQuestionInput(input) {
|
|
|
8118
8144
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
8119
8145
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
8120
8146
|
* peer's page position is exactly where the unread history begins
|
|
8121
|
-
* (`cursor:<pos>`)
|
|
8122
|
-
*
|
|
8147
|
+
* (`cursor:<pos>`).
|
|
8148
|
+
*
|
|
8149
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
8150
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
8151
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
8152
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
8153
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
8154
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
8155
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
8156
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
8157
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
8158
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
8159
|
+
* id really can reach the anchor.
|
|
8160
|
+
*
|
|
8161
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
8162
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
8163
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
8164
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
8165
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
8166
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
8167
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
8168
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
8169
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
8170
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
8171
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
8123
8172
|
*/
|
|
8124
8173
|
function messageResumeToken(page, returned, lastReturnedId) {
|
|
8125
|
-
const anchor = optionalString(lastReturnedId);
|
|
8126
|
-
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : `${RESUME_ANCHOR_PREFIX}${anchor}
|
|
8127
|
-
const position = optionalString(page.nextCursor);
|
|
8128
|
-
return position === void 0 ? void 0 : `${RESUME_CURSOR_PREFIX}${position}
|
|
8174
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId));
|
|
8175
|
+
if (returned < page.items.length || page.pageOverflow === true) return anchor === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`);
|
|
8176
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor));
|
|
8177
|
+
return position === void 0 ? void 0 : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`);
|
|
8178
|
+
}
|
|
8179
|
+
/**
|
|
8180
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
8181
|
+
* when it must not name one.
|
|
8182
|
+
*
|
|
8183
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
8184
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
8185
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
8186
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
8187
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
8188
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
8189
|
+
* is legal and round-trips.
|
|
8190
|
+
*
|
|
8191
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
8192
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
8193
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
8194
|
+
*/
|
|
8195
|
+
function nameableRemainder(value) {
|
|
8196
|
+
return value === void 0 || mangledRemainder(value) ? void 0 : value;
|
|
8197
|
+
}
|
|
8198
|
+
/**
|
|
8199
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
8200
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
8201
|
+
*
|
|
8202
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
8203
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
8204
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
8205
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
8206
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
8207
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
8208
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
8209
|
+
*
|
|
8210
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
8211
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
8212
|
+
* shape is a token it accepts.
|
|
8213
|
+
*/
|
|
8214
|
+
function nameablePeerPosition(value) {
|
|
8215
|
+
const remainder = nameableRemainder(value);
|
|
8216
|
+
return remainder === void 0 || looksLikeV1ListToken(remainder) ? void 0 : remainder;
|
|
8217
|
+
}
|
|
8218
|
+
/**
|
|
8219
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
8220
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
8221
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
8222
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
8223
|
+
* stop point whose token would push past
|
|
8224
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
8225
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
8226
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
8227
|
+
* the parser takes back.
|
|
8228
|
+
*/
|
|
8229
|
+
function withinResumeTokenCeiling(token) {
|
|
8230
|
+
return token.length <= 4096 ? token : void 0;
|
|
8129
8231
|
}
|
|
8130
8232
|
/**
|
|
8131
8233
|
* The message-walk verdict, projected off an aggregate page.
|
|
@@ -8223,6 +8325,12 @@ function messageIdOf(raw) {
|
|
|
8223
8325
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
8224
8326
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
8225
8327
|
* foreign token: an empty anchor could never match a message.
|
|
8328
|
+
*
|
|
8329
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
8330
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
8331
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
8332
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
8333
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
8226
8334
|
*/
|
|
8227
8335
|
function parseMessageResume(value) {
|
|
8228
8336
|
if (value === void 0 || value === null) return void 0;
|
|
@@ -8231,9 +8339,11 @@ function parseMessageResume(value) {
|
|
|
8231
8339
|
};
|
|
8232
8340
|
if (typeof value !== "string") refuse();
|
|
8233
8341
|
const token = value;
|
|
8342
|
+
if (token.length > 4096) throw new Error(`cherry_read: messagesResumeCursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit the argument to read from the newest message.`);
|
|
8234
8343
|
if (token.startsWith(RESUME_CURSOR_PREFIX)) {
|
|
8235
8344
|
const cursor = token.slice(7);
|
|
8236
8345
|
if (mangledRemainder(cursor)) refuse();
|
|
8346
|
+
if (looksLikeV1ListToken(cursor)) throw new Error("cherry_read: messagesResumeCursor is a v1 LIST `resumeCursor` (issued by an older plugin version, before list tokens were compact): it resumes a list walk, not a message history, and it can no longer be verified. Omit the argument to read from the newest message, or pass the token this tool returned.");
|
|
8237
8347
|
return {
|
|
8238
8348
|
kind: "cursor",
|
|
8239
8349
|
cursor
|
|
@@ -8247,6 +8357,7 @@ function parseMessageResume(value) {
|
|
|
8247
8357
|
messageId
|
|
8248
8358
|
};
|
|
8249
8359
|
}
|
|
8360
|
+
if (token.startsWith(LIST_RESUME_PREFIX)) throw new Error("cherry_read: messagesResumeCursor is a LIST `resumeCursor` (`list:…`) — it resumes a list walk (cherry_agents / cherry_workspaces / cherry_sessions / cherry_interactions), not a message history. Omit the argument to read from the newest message, or pass the token this tool returned.");
|
|
8250
8361
|
return refuse();
|
|
8251
8362
|
}
|
|
8252
8363
|
/**
|
|
@@ -8259,27 +8370,112 @@ function parseMessageResume(value) {
|
|
|
8259
8370
|
* another query is a perfectly valid number that reads the WRONG slice with no
|
|
8260
8371
|
* error anywhere. So this token carries a fingerprint of the binding and a
|
|
8261
8372
|
* mismatched replay is refused HERE — before any RPC.
|
|
8373
|
+
*
|
|
8374
|
+
* Wire format (issue #70):
|
|
8375
|
+
* `list:v2:<tool>:<device12>:<filters8>:<checksum8>:<peer position>` — the
|
|
8376
|
+
* position verbatim and LAST. It replaced a base64 JSON blob (`{v,m,d,h,p}`)
|
|
8377
|
+
* that was ~2.3x longer and had no integrity check: measured over 20,000 random
|
|
8378
|
+
* single-character corruptions, 28.7% of the old tokens were ACCEPTED with a
|
|
8379
|
+
* different position, because the position is the one field a structural check
|
|
8380
|
+
* cannot validate. The compact form plus the checksum makes that class empty.
|
|
8381
|
+
*
|
|
8382
|
+
* The prefix is `list:` and NOT `cursor:` (issue #72): the message face already
|
|
8383
|
+
* owns `cursor:` / `after:`, and while the two faces shared a prefix a list
|
|
8384
|
+
* token was a legal `messagesResumeCursor` payload — it was forwarded to the
|
|
8385
|
+
* desktop, which answers an unparseable position by serving its FIRST page, so
|
|
8386
|
+
* "continue from here" silently re-read the newest page. Two faces, two
|
|
8387
|
+
* prefixes: the refusal is a PREFIX check on either side, never an inspection
|
|
8388
|
+
* of the peer's position (a position that merely starts with `list:` still
|
|
8389
|
+
* travels verbatim inside its own `cursor:` token).
|
|
8390
|
+
*/
|
|
8391
|
+
const LIST_RESUME_PREFIX = "list:";
|
|
8392
|
+
/**
|
|
8393
|
+
* The token's format marker (issue #70). It is a FIELD, not part of the prefix,
|
|
8394
|
+
* so a token from the PREVIOUS format is recognised and refused by name
|
|
8395
|
+
* ({@link looksLikeV1ListToken}) instead of getting the generic shape error: a
|
|
8396
|
+
* resume token outlives a plugin upgrade (the caller is holding it), and "start
|
|
8397
|
+
* the walk over" is actionable where "not a token this tool emitted" invites
|
|
8398
|
+
* one more attempt with the same string.
|
|
8399
|
+
*
|
|
8400
|
+
* A format NEWER than this one has no detector and falls through to the
|
|
8401
|
+
* generic refusal, which is the honest verdict: this release cannot know that a
|
|
8402
|
+
* future format is a resume token at all.
|
|
8403
|
+
*/
|
|
8404
|
+
const LIST_TOKEN_VERSION = "v2";
|
|
8405
|
+
/**
|
|
8406
|
+
* One character per list tool, replacing the full method name (19 characters of
|
|
8407
|
+
* a 225-character token). Every method a list tool can issue MUST appear here:
|
|
8408
|
+
* {@link encodeListResumeCursor} throws on an unknown one rather than emit a
|
|
8409
|
+
* token the matching parser could never accept.
|
|
8410
|
+
*/
|
|
8411
|
+
const LIST_TOOL_CODES = {
|
|
8412
|
+
"agent.agents.list": "a",
|
|
8413
|
+
"agent.workspaces.list": "w",
|
|
8414
|
+
"agent.sessions.list": "s",
|
|
8415
|
+
"agent.interactions.list": "i"
|
|
8416
|
+
};
|
|
8417
|
+
/** The inverse of {@link LIST_TOOL_CODES}, for {@link parseListResumeCursor}. */
|
|
8418
|
+
const LIST_CODE_METHODS = Object.fromEntries(Object.entries(LIST_TOOL_CODES).map(([method, code]) => [code, method]));
|
|
8419
|
+
/**
|
|
8420
|
+
* The fields after the version marker, BEFORE the peer position: tool code,
|
|
8421
|
+
* device digest, filter fingerprint and checksum. The position is the rest of
|
|
8422
|
+
* the token — it is peer-supplied and may contain the delimiter itself (the
|
|
8423
|
+
* desktop's session cursor is `entity:<orderKey>:<uuid>`), so the split stops
|
|
8424
|
+
* at a fixed count and never consumes it.
|
|
8262
8425
|
*/
|
|
8263
|
-
const
|
|
8264
|
-
/** The `cursor` parameter every list tool shares (
|
|
8426
|
+
const LIST_TOKEN_FIELDS = 4;
|
|
8427
|
+
/** The `cursor` parameter every list tool shares (issues #65 and #70). */
|
|
8265
8428
|
const LIST_CURSOR_PARAMETER = {
|
|
8266
8429
|
type: "string",
|
|
8267
|
-
|
|
8430
|
+
maxLength: MAX_RESUME_TOKEN_CHARS,
|
|
8431
|
+
description: `A \`resumeCursor\` a previous TRUNCATED answer of this tool returned: replay it with the SAME device and filters to continue from that stop point (\`limit\` then bounds this answer). A token from another tool, device or filter set is refused locally, and so is a token this account cannot have received intact (the token carries a checksum; ${MAX_RESUME_TOKEN_CHARS} characters is the ceiling).`
|
|
8268
8432
|
};
|
|
8269
8433
|
/** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
|
|
8270
8434
|
function listParamsFingerprint(params) {
|
|
8271
8435
|
const entries = Object.keys(params).sort().filter((key) => params[key] !== void 0).map((key) => [key, params[key]]);
|
|
8272
|
-
return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0,
|
|
8436
|
+
return createHash("sha256").update(JSON.stringify(entries)).digest("hex").slice(0, 8);
|
|
8437
|
+
}
|
|
8438
|
+
/**
|
|
8439
|
+
* The device half of the binding, as a digest rather than the id itself: a
|
|
8440
|
+
* `deviceId` is a 36-character UUID, and the token only ever has to answer "was
|
|
8441
|
+
* this issued for the device I just resolved?", never "which device was it?".
|
|
8442
|
+
* 12 hex characters (48 bits) make an accidental collision between two paired
|
|
8443
|
+
* desktops a non-event, where the old field was a third of the token.
|
|
8444
|
+
*/
|
|
8445
|
+
function listDeviceFingerprint(deviceId) {
|
|
8446
|
+
return createHash("sha256").update(deviceId).digest("hex").slice(0, 12);
|
|
8447
|
+
}
|
|
8448
|
+
/**
|
|
8449
|
+
* The token's integrity check (issue #70). It covers the peer position, which
|
|
8450
|
+
* is the ONE field no structural check can validate: a substitution there used
|
|
8451
|
+
* to decode as a perfectly well-formed token and was forwarded, and the desktop
|
|
8452
|
+
* silently falls back to its FIRST page for a position it cannot parse — so the
|
|
8453
|
+
* caller's "continue from here" came back as the newest page presented as older
|
|
8454
|
+
* history. 8 hex characters (32 bits) turn every single-character corruption
|
|
8455
|
+
* into a local refusal; the token is not a security boundary, so a MAC would
|
|
8456
|
+
* only make it longer.
|
|
8457
|
+
*/
|
|
8458
|
+
function listTokenChecksum(body, peerPosition) {
|
|
8459
|
+
return createHash("sha256").update(`${body}\u0000${peerPosition}`).digest("hex").slice(0, 8);
|
|
8273
8460
|
}
|
|
8274
8461
|
function encodeListResumeCursor(binding, peerPosition) {
|
|
8275
|
-
const
|
|
8276
|
-
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
|
|
8280
|
-
|
|
8281
|
-
|
|
8282
|
-
|
|
8462
|
+
const code = LIST_TOOL_CODES[binding.method];
|
|
8463
|
+
if (code === void 0) throw new Error(`${binding.method}: no list resume token code`);
|
|
8464
|
+
const body = `${LIST_TOKEN_VERSION}:${code}:${listDeviceFingerprint(binding.deviceId)}:${listParamsFingerprint(binding.params)}`;
|
|
8465
|
+
return `${LIST_RESUME_PREFIX}${body}:${listTokenChecksum(body, peerPosition)}:${peerPosition}`;
|
|
8466
|
+
}
|
|
8467
|
+
/**
|
|
8468
|
+
* Whether a `cursor:` remainder is a token from the base64-JSON era (issue #65,
|
|
8469
|
+
* `v: 1`). Only used to explain a refusal: the payload is never replayed.
|
|
8470
|
+
*/
|
|
8471
|
+
function looksLikeV1ListToken(raw) {
|
|
8472
|
+
if (raw === "" || raw.includes(":")) return false;
|
|
8473
|
+
try {
|
|
8474
|
+
const decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
8475
|
+
return typeof decoded === "object" && decoded !== null && decoded["v"] === 1;
|
|
8476
|
+
} catch {
|
|
8477
|
+
return false;
|
|
8478
|
+
}
|
|
8283
8479
|
}
|
|
8284
8480
|
/**
|
|
8285
8481
|
* Decode a list token, refusing anything that is not one this tool emitted for
|
|
@@ -8294,21 +8490,33 @@ function parseListResumeCursor(value, binding) {
|
|
|
8294
8490
|
const refuse = (why) => {
|
|
8295
8491
|
throw new Error(`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, device and filters.`);
|
|
8296
8492
|
};
|
|
8297
|
-
if (typeof value !== "string"
|
|
8298
|
-
const
|
|
8299
|
-
|
|
8300
|
-
|
|
8301
|
-
|
|
8302
|
-
|
|
8493
|
+
if (typeof value !== "string") refuse("must be a `list:` token this tool emitted");
|
|
8494
|
+
const token = value;
|
|
8495
|
+
if (token.length > 4096) throw new Error(`cursor is ${token.length} characters long, over the ${MAX_RESUME_TOKEN_CHARS}-character ceiling for a resume token; omit \`cursor\` to read from the start.`);
|
|
8496
|
+
if (token.startsWith(RESUME_CURSOR_PREFIX)) {
|
|
8497
|
+
if (looksLikeV1ListToken(token.slice(7))) throw new Error("cursor was issued by an older plugin version (a v1 list token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
|
|
8498
|
+
refuse("is a MESSAGE-face token (`cursor:<peer position>`, i.e. a `messagesResumeCursor`), which a list walk never issues — a list token starts with `list:`");
|
|
8499
|
+
}
|
|
8500
|
+
if (!token.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `list:` token this tool emitted");
|
|
8501
|
+
const raw = token.slice(5);
|
|
8502
|
+
if (!raw.startsWith(`${LIST_TOKEN_VERSION}:`)) {
|
|
8503
|
+
if (looksLikeV1ListToken(raw)) throw new Error("cursor was issued by an older plugin version (a v1 token, from before list tokens were compact and checksummed), so it can no longer be verified; start this walk over with `cursor` omitted.");
|
|
8303
8504
|
refuse("is not a token this tool emitted");
|
|
8304
8505
|
}
|
|
8305
|
-
|
|
8306
|
-
const
|
|
8307
|
-
|
|
8308
|
-
|
|
8309
|
-
|
|
8310
|
-
|
|
8311
|
-
|
|
8506
|
+
let rest = raw.slice(3);
|
|
8507
|
+
const fields = [];
|
|
8508
|
+
for (let index = 0; index < LIST_TOKEN_FIELDS; index += 1) {
|
|
8509
|
+
const separator = rest.indexOf(":");
|
|
8510
|
+
if (separator < 0) refuse("is not a token this tool emitted");
|
|
8511
|
+
fields.push(rest.slice(0, separator));
|
|
8512
|
+
rest = rest.slice(separator + 1);
|
|
8513
|
+
}
|
|
8514
|
+
const [code, device, filters, checksum] = fields;
|
|
8515
|
+
const peerPosition = rest;
|
|
8516
|
+
if (checksum !== listTokenChecksum(`${LIST_TOKEN_VERSION}:${code}:${device}:${filters}`, peerPosition)) refuse("is not a token this tool emitted (its checksum does not match, so it was altered in transit)");
|
|
8517
|
+
if (LIST_CODE_METHODS[code] !== binding.method) refuse("was issued for a different tool");
|
|
8518
|
+
if (device !== listDeviceFingerprint(binding.deviceId)) refuse("was issued for a different device");
|
|
8519
|
+
if (filters !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
|
|
8312
8520
|
if (mangledRemainder(peerPosition)) refuse("is not a token this tool emitted");
|
|
8313
8521
|
return peerPosition;
|
|
8314
8522
|
}
|