dsh-cherry-bridge 0.1.5 → 0.1.6
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 +12 -12
- package/README.md +12 -12
- package/dist/index.mjs +158 -32
- package/package.json +1 -1
- package/src/tools.ts +177 -33
package/README.en.md
CHANGED
|
@@ -37,14 +37,14 @@ flowchart TB
|
|
|
37
37
|
> **DSH version requirement**: `>=0.2.0-rc.2 <0.3.0-0` (declared via `engines.dsh` in `package.json`; when unmet the bundle is skipped and its settings-page row disabled — `dsh plugin allow-version` grants an explicit exemption; 0.3.0 needs a fresh compatibility pass before the range widens).
|
|
38
38
|
|
|
39
39
|
```bash
|
|
40
|
-
# From
|
|
41
|
-
npx @deepseek-ai/dsh plugin --profile web add
|
|
40
|
+
# From npm (recommended, pinned)
|
|
41
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.6
|
|
42
42
|
|
|
43
|
-
#
|
|
44
|
-
npx @deepseek-ai/dsh plugin --profile web add
|
|
43
|
+
# Install the latest npm release
|
|
44
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
45
45
|
|
|
46
|
-
#
|
|
47
|
-
dsh plugin --profile
|
|
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.6
|
|
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). |
|
|
107
|
-
| `cherry_workspaces` | List an agent's **registered** workspaces, and whether a system workspace exists. |
|
|
108
|
-
| `cherry_sessions` | List an agent's sessions, optionally filtered by workspace. |
|
|
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), plus 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` and 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`) plus 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
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. |
|
|
110
|
-
| `cherry_events` | Read the session's buffered events newer than `cursor`, acking them after delivery. |
|
|
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). |
|
|
114
|
+
| `cherry_interactions` | List the session's **pending** interaction cards (questions / approvals). A truncated answer always carries `truncated` + `truncationReason` and a `resumeCursor` — replay it as `cursor` 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. |
|
|
@@ -224,7 +224,7 @@ npm run typecheck # server + UI projects
|
|
|
224
224
|
npm run build # tsdown → dist/index.mjs + dist/index.d.mts
|
|
225
225
|
npm run build:client # vite → lib/client.js
|
|
226
226
|
npm run dev:ui # settings-page dev server (host must run at 127.0.0.1:23023)
|
|
227
|
-
npm run prepublishOnly # pre-publish gate: typecheck + test + build + build:client
|
|
227
|
+
npm run prepublishOnly # pre-publish gate: typecheck + test + build + build:client + npm pack --dry-run
|
|
228
228
|
```
|
|
229
229
|
|
|
230
230
|
`dist/` and `lib/` are **committed** artifacts: after changing `src/` or `ui/`
|
package/README.md
CHANGED
|
@@ -35,14 +35,14 @@ flowchart TB
|
|
|
35
35
|
> **DSH 版本需求**:`>=0.2.0-rc.2 <0.3.0-0`(由 `package.json` 的 `engines.dsh` 声明;不满足时 bundle 被跳过、设置页里该插件的行被禁用,确需在区间外使用可用 `dsh plugin allow-version` 精确豁免。0.3.0 需重新评估兼容性后再放开)。
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
# 从
|
|
39
|
-
npx @deepseek-ai/dsh plugin --profile web add
|
|
38
|
+
# 从 npm 安装(推荐,钉住版本)
|
|
39
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge@0.1.6
|
|
40
40
|
|
|
41
|
-
#
|
|
42
|
-
npx @deepseek-ai/dsh plugin --profile web add
|
|
41
|
+
# 安装 npm 最新发布版
|
|
42
|
+
npx @deepseek-ai/dsh plugin --profile web add dsh-cherry-bridge
|
|
43
43
|
|
|
44
|
-
#
|
|
45
|
-
dsh plugin --profile
|
|
44
|
+
# 从 GitHub 源码安装(与 npm 发布同一提交)
|
|
45
|
+
npx @deepseek-ai/dsh plugin --profile web add github:SAXEM1997/dsh-cherry-bridge#v0.1.6
|
|
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
|
|
98
|
-
| `cherry_workspaces` | 列出某 agent
|
|
99
|
-
| `cherry_sessions` | 列出某 agent
|
|
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
100
|
| `cherry_read` | 读会话消息历史快照,**从新到旧**返回(`limit` 丢掉的是**最旧**的消息)。附件 part 只回 `name`/`mediaType`/`byteLength`,`content` 为空——字节不取。被截断时 `truncated` + `truncationReason` 一定说明:`limit` 表示你设的条数到了,提高 `limit` 即可;`aggregate-bytes` 表示单次回答达到了**输出大小上限**——与 `limit` 无关,提高 `limit` 只会多付往返、仍停在同一预算。**被挡下的更旧消息用 `messagesResumeCursor` 续读**:把本次返回的 `messagesResumeCursor` 原样传给下一次调用的 `messagesResumeCursor` 参数即可从停点继续(`limit` 此时约束的是**从停点起**本次最多返回多少条),长历史分几次回答读完。两种令牌:`cursor:<peer position>`(整窗读完后的 peer 分页位置)与 `after:<messageId>`(停在窗内,续读从最新端扫到该消息、返回它更旧的那些)。不是本工具发出的令牌会被**本地拒绝**(不转发给桌面端——桌面端会静默回退到最新页);`after:` 锚点在历史里找不到则**报错**,不会返回空结果。 |
|
|
101
|
-
| `cherry_events` | 读取该会话缓冲区内比 `cursor` 更新的事件,并在投递后 ack
|
|
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` | 列出该会话**待答**的交互卡(提问 /
|
|
105
|
+
| `cherry_interactions` | 列出该会话**待答**的交互卡(提问 / 审批)。被截断时必带 `truncated`+`truncationReason`,并给出 `resumeCursor`——用 `sessionId` 相同的调用回传为 `cursor` 即可续读(`limit` 计的是过滤**前**的对端条目)。 |
|
|
106
106
|
| `cherry_respond` | 应答交互卡:提问必须全量作答,审批走 `decision`。 |
|
|
107
107
|
| `cherry_cancel` | 取消会话在途执行(`expectedExecutionId` 必填)。 |
|
|
108
108
|
| `cherry_pair` | 用「扫码后页面显示的配对信息」建立与桌面端的配对。 |
|
|
@@ -176,7 +176,7 @@ npm run typecheck # 服务端 + UI 两侧
|
|
|
176
176
|
npm run build # tsdown → dist/index.mjs + dist/index.d.mts
|
|
177
177
|
npm run build:client # vite → lib/client.js
|
|
178
178
|
npm run dev:ui # 设置页 dev server(需宿主在 127.0.0.1:23023 运行)
|
|
179
|
-
npm run prepublishOnly # 发布前闸门:typecheck + test + build + build:client
|
|
179
|
+
npm run prepublishOnly # 发布前闸门:typecheck + test + build + build:client + npm pack --dry-run
|
|
180
180
|
```
|
|
181
181
|
|
|
182
182
|
`dist/` 与 `lib/` 是**随仓库提交**的产物:改完 `src/` 或 `ui/` 必须重跑
|
package/dist/index.mjs
CHANGED
|
@@ -5445,6 +5445,30 @@ var CherryToolEngine = class {
|
|
|
5445
5445
|
...pageOverflow === void 0 ? {} : { pageOverflow }
|
|
5446
5446
|
};
|
|
5447
5447
|
}
|
|
5448
|
+
/** Decode one list `cursor` argument, or undefined when none was given. */
|
|
5449
|
+
#listResume(raw, binding) {
|
|
5450
|
+
if (raw === void 0 || raw === null) return void 0;
|
|
5451
|
+
return {
|
|
5452
|
+
kind: "cursor",
|
|
5453
|
+
cursor: parseListResumeCursor(raw, binding)
|
|
5454
|
+
};
|
|
5455
|
+
}
|
|
5456
|
+
/**
|
|
5457
|
+
* The public shape of a list walk's result: items, the stop verdict, and — only
|
|
5458
|
+
* when the walk really stopped at a peer position — the RESUMABLE token.
|
|
5459
|
+
*
|
|
5460
|
+
* The peer's own position never leaves this method: it is opaque, unusable as
|
|
5461
|
+
* an input on its own, and exposing it invited callers to replay it (issue
|
|
5462
|
+
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
5463
|
+
*/
|
|
5464
|
+
#listPage(page, binding) {
|
|
5465
|
+
return {
|
|
5466
|
+
items: page.items,
|
|
5467
|
+
truncated: page.truncated,
|
|
5468
|
+
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason },
|
|
5469
|
+
...page.truncated && page.nextCursor !== void 0 ? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) } : {}
|
|
5470
|
+
};
|
|
5471
|
+
}
|
|
5448
5472
|
#devicesTool() {
|
|
5449
5473
|
return defineCherryTool({
|
|
5450
5474
|
name: "cherry_devices",
|
|
@@ -5521,7 +5545,7 @@ var CherryToolEngine = class {
|
|
|
5521
5545
|
#agentsTool() {
|
|
5522
5546
|
return defineCherryTool({
|
|
5523
5547
|
name: "cherry_agents",
|
|
5524
|
-
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.",
|
|
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). A truncated answer also 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).",
|
|
5525
5549
|
parameters: {
|
|
5526
5550
|
type: "object",
|
|
5527
5551
|
properties: {
|
|
@@ -5534,7 +5558,8 @@ var CherryToolEngine = class {
|
|
|
5534
5558
|
minimum: 1,
|
|
5535
5559
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5536
5560
|
description: "Maximum number of agents to return in total across pages (default 100)."
|
|
5537
|
-
}
|
|
5561
|
+
},
|
|
5562
|
+
cursor: LIST_CURSOR_PARAMETER
|
|
5538
5563
|
},
|
|
5539
5564
|
required: ["device"],
|
|
5540
5565
|
additionalProperties: false
|
|
@@ -5542,18 +5567,28 @@ var CherryToolEngine = class {
|
|
|
5542
5567
|
execute: async (args) => {
|
|
5543
5568
|
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5544
5569
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5545
|
-
const { connection } = resolved.link;
|
|
5546
|
-
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
5570
|
+
const { connection, record } = resolved.link;
|
|
5571
|
+
const binding = {
|
|
5572
|
+
method: "agent.agents.list",
|
|
5573
|
+
deviceId: record.deviceId,
|
|
5574
|
+
params: {}
|
|
5575
|
+
};
|
|
5576
|
+
const resume = this.#listResume(args["cursor"], binding);
|
|
5577
|
+
const page = await this.#aggregate(connection, "agent.agents.list", {}, (result) => ({
|
|
5578
|
+
...arrayOf(result["items"]),
|
|
5579
|
+
nextCursor: result["nextCursor"]
|
|
5580
|
+
}), {
|
|
5581
|
+
limit: aggregateLimit(args["limit"]),
|
|
5582
|
+
...resume === void 0 ? {} : { resume }
|
|
5583
|
+
});
|
|
5584
|
+
return this.#listPage(page, binding);
|
|
5550
5585
|
}
|
|
5551
5586
|
});
|
|
5552
5587
|
}
|
|
5553
5588
|
#workspacesTool() {
|
|
5554
5589
|
return defineCherryTool({
|
|
5555
5590
|
name: "cherry_workspaces",
|
|
5556
|
-
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.",
|
|
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 and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
|
|
5557
5592
|
parameters: {
|
|
5558
5593
|
type: "object",
|
|
5559
5594
|
properties: {
|
|
@@ -5570,7 +5605,8 @@ var CherryToolEngine = class {
|
|
|
5570
5605
|
minimum: 1,
|
|
5571
5606
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5572
5607
|
description: "Maximum number of workspaces to return in total across pages (default 100)."
|
|
5573
|
-
}
|
|
5608
|
+
},
|
|
5609
|
+
cursor: LIST_CURSOR_PARAMETER
|
|
5574
5610
|
},
|
|
5575
5611
|
required: ["device", "agentId"],
|
|
5576
5612
|
additionalProperties: false
|
|
@@ -5578,21 +5614,28 @@ var CherryToolEngine = class {
|
|
|
5578
5614
|
execute: async (args) => {
|
|
5579
5615
|
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5580
5616
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5581
|
-
const { connection } = resolved.link;
|
|
5582
|
-
const
|
|
5617
|
+
const { connection, record } = resolved.link;
|
|
5618
|
+
const params = { agentId: requiredString(args, "agentId") };
|
|
5619
|
+
const binding = {
|
|
5620
|
+
method: "agent.workspaces.list",
|
|
5621
|
+
deviceId: record.deviceId,
|
|
5622
|
+
params
|
|
5623
|
+
};
|
|
5624
|
+
const resume = this.#listResume(args["cursor"], binding);
|
|
5583
5625
|
let systemWorkspace = false;
|
|
5584
|
-
const page = await this.#aggregate(connection, "agent.workspaces.list",
|
|
5626
|
+
const page = await this.#aggregate(connection, "agent.workspaces.list", params, (result) => {
|
|
5585
5627
|
if (typeof result["systemWorkspace"] === "boolean") systemWorkspace = result["systemWorkspace"];
|
|
5586
5628
|
return {
|
|
5587
5629
|
...arrayOf(result["items"]),
|
|
5588
5630
|
nextCursor: result["nextCursor"]
|
|
5589
5631
|
};
|
|
5590
|
-
}, {
|
|
5632
|
+
}, {
|
|
5633
|
+
limit: aggregateLimit(args["limit"]),
|
|
5634
|
+
...resume === void 0 ? {} : { resume }
|
|
5635
|
+
});
|
|
5591
5636
|
return {
|
|
5592
|
-
|
|
5593
|
-
systemWorkspace
|
|
5594
|
-
truncated: page.truncated,
|
|
5595
|
-
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
|
|
5637
|
+
...this.#listPage(page, binding),
|
|
5638
|
+
systemWorkspace
|
|
5596
5639
|
};
|
|
5597
5640
|
}
|
|
5598
5641
|
});
|
|
@@ -5600,7 +5643,7 @@ var CherryToolEngine = class {
|
|
|
5600
5643
|
#sessionsTool() {
|
|
5601
5644
|
return defineCherryTool({
|
|
5602
5645
|
name: "cherry_sessions",
|
|
5603
|
-
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.",
|
|
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). A truncated answer also 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).",
|
|
5604
5647
|
parameters: {
|
|
5605
5648
|
type: "object",
|
|
5606
5649
|
properties: {
|
|
@@ -5621,7 +5664,8 @@ var CherryToolEngine = class {
|
|
|
5621
5664
|
minimum: 1,
|
|
5622
5665
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
5623
5666
|
description: "Maximum number of sessions to return in total across pages (default 100)."
|
|
5624
|
-
}
|
|
5667
|
+
},
|
|
5668
|
+
cursor: LIST_CURSOR_PARAMETER
|
|
5625
5669
|
},
|
|
5626
5670
|
required: ["device"],
|
|
5627
5671
|
additionalProperties: false
|
|
@@ -5629,16 +5673,27 @@ var CherryToolEngine = class {
|
|
|
5629
5673
|
execute: async (args) => {
|
|
5630
5674
|
const resolved = this.#linkOr(requiredString(args, "device"));
|
|
5631
5675
|
if (resolved.link === void 0) return resolved.verdict;
|
|
5632
|
-
const { connection } = resolved.link;
|
|
5676
|
+
const { connection, record } = resolved.link;
|
|
5633
5677
|
const agentId = optionalString(args["agentId"]);
|
|
5634
5678
|
const workspaceId = optionalString(args["workspaceId"]);
|
|
5635
|
-
|
|
5679
|
+
const params = {
|
|
5636
5680
|
...agentId === void 0 ? {} : { agentId },
|
|
5637
5681
|
...workspaceId === void 0 ? {} : { workspaceId }
|
|
5638
|
-
}
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5682
|
+
};
|
|
5683
|
+
const binding = {
|
|
5684
|
+
method: "agent.sessions.list",
|
|
5685
|
+
deviceId: record.deviceId,
|
|
5686
|
+
params
|
|
5687
|
+
};
|
|
5688
|
+
const resume = this.#listResume(args["cursor"], binding);
|
|
5689
|
+
const page = await this.#aggregate(connection, "agent.sessions.list", params, (result) => ({
|
|
5690
|
+
...arrayOf(result["items"]),
|
|
5691
|
+
nextCursor: result["nextCursor"]
|
|
5692
|
+
}), {
|
|
5693
|
+
limit: aggregateLimit(args["limit"]),
|
|
5694
|
+
...resume === void 0 ? {} : { resume }
|
|
5695
|
+
});
|
|
5696
|
+
return this.#listPage(page, binding);
|
|
5642
5697
|
}
|
|
5643
5698
|
});
|
|
5644
5699
|
}
|
|
@@ -6061,7 +6116,7 @@ var CherryToolEngine = class {
|
|
|
6061
6116
|
#eventsTool() {
|
|
6062
6117
|
return defineCherryTool({
|
|
6063
6118
|
name: "cherry_events",
|
|
6064
|
-
description: "Read the session's buffered events newer than `cursor`, delivering them and then acknowledging the advanced cursor to the peer. While the window has dropped no events, every cursor this plugin handed out is servable, including the baseline `cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, the boundary it reports is not: that boundary and everything below it answer a structured `cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild the baseline with `cherry_read` and resume from that instead.",
|
|
6119
|
+
description: "Read the session's buffered events newer than `cursor`, delivering them and then acknowledging the advanced cursor to the peer. While the window has dropped no events, every cursor this plugin handed out is servable, including the baseline `cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, the boundary it reports is not: that boundary and everything below it answer a structured `cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild the baseline with `cherry_read` and resume from that instead. The answer is `{events, nextCursor}`; pass that `nextCursor` back as `cursor` on the next call to continue from it.",
|
|
6065
6120
|
parameters: {
|
|
6066
6121
|
type: "object",
|
|
6067
6122
|
properties: {
|
|
@@ -6419,7 +6474,7 @@ var CherryToolEngine = class {
|
|
|
6419
6474
|
#interactionsTool() {
|
|
6420
6475
|
return defineCherryTool({
|
|
6421
6476
|
name: "cherry_interactions",
|
|
6422
|
-
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`.",
|
|
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 and carries `resumeCursor`: replay it as `cursor` with the same filters to continue.",
|
|
6423
6478
|
parameters: {
|
|
6424
6479
|
type: "object",
|
|
6425
6480
|
properties: {
|
|
@@ -6440,7 +6495,8 @@ var CherryToolEngine = class {
|
|
|
6440
6495
|
minimum: 1,
|
|
6441
6496
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
6442
6497
|
description: "Maximum number of interactions to return in total across pages (default 100)."
|
|
6443
|
-
}
|
|
6498
|
+
},
|
|
6499
|
+
cursor: LIST_CURSOR_PARAMETER
|
|
6444
6500
|
},
|
|
6445
6501
|
required: ["device", "sessionId"],
|
|
6446
6502
|
additionalProperties: false
|
|
@@ -6450,12 +6506,20 @@ var CherryToolEngine = class {
|
|
|
6450
6506
|
if (resolved.link === void 0) return resolved.verdict;
|
|
6451
6507
|
const { connection, record } = resolved.link;
|
|
6452
6508
|
const sessionId = requiredString(args, "sessionId");
|
|
6509
|
+
const params = { sessionId };
|
|
6510
|
+
const binding = {
|
|
6511
|
+
method: "agent.interactions.list",
|
|
6512
|
+
deviceId: record.deviceId,
|
|
6513
|
+
params
|
|
6514
|
+
};
|
|
6515
|
+
const resume = this.#listResume(args["cursor"], binding);
|
|
6453
6516
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
6454
|
-
const page = await this.#aggregate(connection, "agent.interactions.list",
|
|
6517
|
+
const page = await this.#aggregate(connection, "agent.interactions.list", params, (result) => ({
|
|
6455
6518
|
...arrayOf(result["items"]),
|
|
6456
6519
|
nextCursor: result["nextCursor"]
|
|
6457
6520
|
}), {
|
|
6458
6521
|
limit: aggregateLimit(args["limit"]),
|
|
6522
|
+
...resume === void 0 ? {} : { resume },
|
|
6459
6523
|
safetyBound: MAX_AGGREGATE_LIMIT,
|
|
6460
6524
|
maxPerPage: INTERACTIONS_LIST_PAGE_CLAMP
|
|
6461
6525
|
});
|
|
@@ -6463,9 +6527,8 @@ var CherryToolEngine = class {
|
|
|
6463
6527
|
const items = page.items.filter((item) => includeAnswered || item["status"] === "pending").map((item) => this.#interactionView(item));
|
|
6464
6528
|
for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item);
|
|
6465
6529
|
return {
|
|
6466
|
-
|
|
6467
|
-
|
|
6468
|
-
...page.truncationReason === void 0 ? {} : { truncationReason: page.truncationReason }
|
|
6530
|
+
...this.#listPage(page, binding),
|
|
6531
|
+
items
|
|
6469
6532
|
};
|
|
6470
6533
|
}
|
|
6471
6534
|
});
|
|
@@ -8187,6 +8250,69 @@ function parseMessageResume(value) {
|
|
|
8187
8250
|
return refuse();
|
|
8188
8251
|
}
|
|
8189
8252
|
/**
|
|
8253
|
+
* The LIST face's stop point (issue #65): a self-describing token that the SAME
|
|
8254
|
+
* tool accepts back, bound to the query that produced it.
|
|
8255
|
+
*
|
|
8256
|
+
* The message face wraps the peer position in `cursor:` / `after:` and is done,
|
|
8257
|
+
* because a message walk is scoped by its session. A list walk is scoped by
|
|
8258
|
+
* (method, device, filters), and the peer position is opaque: a position from
|
|
8259
|
+
* another query is a perfectly valid number that reads the WRONG slice with no
|
|
8260
|
+
* error anywhere. So this token carries a fingerprint of the binding and a
|
|
8261
|
+
* mismatched replay is refused HERE — before any RPC.
|
|
8262
|
+
*/
|
|
8263
|
+
const LIST_RESUME_PREFIX = "cursor:";
|
|
8264
|
+
/** The `cursor` parameter every list tool shares (issue #65). */
|
|
8265
|
+
const LIST_CURSOR_PARAMETER = {
|
|
8266
|
+
type: "string",
|
|
8267
|
+
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."
|
|
8268
|
+
};
|
|
8269
|
+
/** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
|
|
8270
|
+
function listParamsFingerprint(params) {
|
|
8271
|
+
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, 16);
|
|
8273
|
+
}
|
|
8274
|
+
function encodeListResumeCursor(binding, peerPosition) {
|
|
8275
|
+
const payload = {
|
|
8276
|
+
v: 1,
|
|
8277
|
+
m: binding.method,
|
|
8278
|
+
d: binding.deviceId,
|
|
8279
|
+
h: listParamsFingerprint(binding.params),
|
|
8280
|
+
p: peerPosition
|
|
8281
|
+
};
|
|
8282
|
+
return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
8283
|
+
}
|
|
8284
|
+
/**
|
|
8285
|
+
* Decode a list token, refusing anything that is not one this tool emitted for
|
|
8286
|
+
* THIS binding. The refusal names the dimension that differs (tool / device /
|
|
8287
|
+
* filters) and never echoes the peer position, which is peer-supplied data.
|
|
8288
|
+
*
|
|
8289
|
+
* An `after:` token is refused by the prefix check: list walks have no anchor
|
|
8290
|
+
* form (they never trim a page, so a peer position always exists for a stop the
|
|
8291
|
+
* peer reported).
|
|
8292
|
+
*/
|
|
8293
|
+
function parseListResumeCursor(value, binding) {
|
|
8294
|
+
const refuse = (why) => {
|
|
8295
|
+
throw new Error(`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, device and filters.`);
|
|
8296
|
+
};
|
|
8297
|
+
if (typeof value !== "string" || !value.startsWith(LIST_RESUME_PREFIX)) refuse("must be a `cursor:` token this tool emitted");
|
|
8298
|
+
const raw = value.slice(7);
|
|
8299
|
+
let decoded;
|
|
8300
|
+
try {
|
|
8301
|
+
decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
8302
|
+
} catch {
|
|
8303
|
+
refuse("is not a token this tool emitted");
|
|
8304
|
+
}
|
|
8305
|
+
if (typeof decoded !== "object" || decoded === null) refuse("is not a token this tool emitted");
|
|
8306
|
+
const payload = decoded;
|
|
8307
|
+
if (payload["v"] !== 1 || typeof payload["m"] !== "string" || typeof payload["d"] !== "string" || typeof payload["h"] !== "string" || typeof payload["p"] !== "string") refuse("is not a token this tool emitted");
|
|
8308
|
+
if (payload["m"] !== binding.method) refuse("was issued for a different tool");
|
|
8309
|
+
if (payload["d"] !== binding.deviceId) refuse("was issued for a different device");
|
|
8310
|
+
if (payload["h"] !== listParamsFingerprint(binding.params)) refuse("was issued for a different filter set");
|
|
8311
|
+
const peerPosition = payload["p"];
|
|
8312
|
+
if (mangledRemainder(peerPosition)) refuse("is not a token this tool emitted");
|
|
8313
|
+
return peerPosition;
|
|
8314
|
+
}
|
|
8315
|
+
/**
|
|
8190
8316
|
* The assistant text a terminal aggregate reports: the awaited RUN's assistant
|
|
8191
8317
|
* message text parts, joined.
|
|
8192
8318
|
*
|
package/package.json
CHANGED
package/src/tools.ts
CHANGED
|
@@ -2082,6 +2082,31 @@ class CherryToolEngine {
|
|
|
2082
2082
|
}
|
|
2083
2083
|
}
|
|
2084
2084
|
|
|
2085
|
+
/** Decode one list `cursor` argument, or undefined when none was given. */
|
|
2086
|
+
#listResume(raw: unknown, binding: ListResumeBinding): { kind: 'cursor'; cursor: string } | undefined {
|
|
2087
|
+
if (raw === undefined || raw === null) return undefined
|
|
2088
|
+
return { kind: 'cursor', cursor: parseListResumeCursor(raw, binding) }
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
/**
|
|
2092
|
+
* The public shape of a list walk's result: items, the stop verdict, and — only
|
|
2093
|
+
* when the walk really stopped at a peer position — the RESUMABLE token.
|
|
2094
|
+
*
|
|
2095
|
+
* The peer's own position never leaves this method: it is opaque, unusable as
|
|
2096
|
+
* an input on its own, and exposing it invited callers to replay it (issue
|
|
2097
|
+
* #65). What a caller can act on is a token, so that is what is emitted.
|
|
2098
|
+
*/
|
|
2099
|
+
#listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
|
|
2100
|
+
return {
|
|
2101
|
+
items: page.items,
|
|
2102
|
+
truncated: page.truncated,
|
|
2103
|
+
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
2104
|
+
...(page.truncated && page.nextCursor !== undefined
|
|
2105
|
+
? { resumeCursor: encodeListResumeCursor(binding, page.nextCursor) }
|
|
2106
|
+
: {}),
|
|
2107
|
+
}
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2085
2110
|
// -------------------------------------------------------------------------
|
|
2086
2111
|
// ① cherry_devices
|
|
2087
2112
|
// -------------------------------------------------------------------------
|
|
@@ -2179,7 +2204,11 @@ class CherryToolEngine {
|
|
|
2179
2204
|
name: 'cherry_agents',
|
|
2180
2205
|
description:
|
|
2181
2206
|
'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
|
|
2182
|
-
'the TOTAL across pages (default 100, max 1000) and is aggregated internally.'
|
|
2207
|
+
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2208
|
+
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2209
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
|
|
2210
|
+
'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
|
|
2211
|
+
'filters to continue from that stop point (`limit` then bounds the next answer).',
|
|
2183
2212
|
parameters: {
|
|
2184
2213
|
type: 'object',
|
|
2185
2214
|
properties: {
|
|
@@ -2190,6 +2219,7 @@ class CherryToolEngine {
|
|
|
2190
2219
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
2191
2220
|
description: 'Maximum number of agents to return in total across pages (default 100).',
|
|
2192
2221
|
},
|
|
2222
|
+
cursor: LIST_CURSOR_PARAMETER,
|
|
2193
2223
|
},
|
|
2194
2224
|
required: ['device'],
|
|
2195
2225
|
additionalProperties: false,
|
|
@@ -2197,14 +2227,17 @@ class CherryToolEngine {
|
|
|
2197
2227
|
execute: async (args) => {
|
|
2198
2228
|
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2199
2229
|
if (resolved.link === undefined) return resolved.verdict
|
|
2200
|
-
const { connection } = resolved.link
|
|
2201
|
-
|
|
2230
|
+
const { connection, record } = resolved.link
|
|
2231
|
+
const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
|
|
2232
|
+
const resume = this.#listResume(args['cursor'], binding)
|
|
2233
|
+
const page = await this.#aggregate(
|
|
2202
2234
|
connection,
|
|
2203
2235
|
'agent.agents.list',
|
|
2204
2236
|
{},
|
|
2205
|
-
(
|
|
2206
|
-
{ limit: aggregateLimit(args['limit']) },
|
|
2237
|
+
(result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
|
|
2238
|
+
{ limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
|
|
2207
2239
|
)
|
|
2240
|
+
return this.#listPage(page, binding)
|
|
2208
2241
|
},
|
|
2209
2242
|
})
|
|
2210
2243
|
}
|
|
@@ -2215,7 +2248,9 @@ class CherryToolEngine {
|
|
|
2215
2248
|
description:
|
|
2216
2249
|
'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
|
|
2217
2250
|
'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
|
|
2218
|
-
'is only observable by creating a session in it and reading it back.'
|
|
2251
|
+
'is only observable by creating a session in it and reading it back. A truncated ' +
|
|
2252
|
+
'answer (`truncated` + `truncationReason`) stopped before the peer ran out and carries ' +
|
|
2253
|
+
'`resumeCursor`: replay it as `cursor` with the same filters to continue.',
|
|
2219
2254
|
parameters: {
|
|
2220
2255
|
type: 'object',
|
|
2221
2256
|
properties: {
|
|
@@ -2227,6 +2262,7 @@ class CherryToolEngine {
|
|
|
2227
2262
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
2228
2263
|
description: 'Maximum number of workspaces to return in total across pages (default 100).',
|
|
2229
2264
|
},
|
|
2265
|
+
cursor: LIST_CURSOR_PARAMETER,
|
|
2230
2266
|
},
|
|
2231
2267
|
required: ['device', 'agentId'],
|
|
2232
2268
|
additionalProperties: false,
|
|
@@ -2234,27 +2270,25 @@ class CherryToolEngine {
|
|
|
2234
2270
|
execute: async (args) => {
|
|
2235
2271
|
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2236
2272
|
if (resolved.link === undefined) return resolved.verdict
|
|
2237
|
-
const { connection } = resolved.link
|
|
2273
|
+
const { connection, record } = resolved.link
|
|
2238
2274
|
const agentId = requiredString(args, 'agentId')
|
|
2275
|
+
const params = { agentId }
|
|
2276
|
+
const binding: ListResumeBinding = { method: 'agent.workspaces.list', deviceId: record.deviceId, params }
|
|
2277
|
+
const resume = this.#listResume(args['cursor'], binding)
|
|
2239
2278
|
let systemWorkspace = false
|
|
2240
2279
|
const page = await this.#aggregate(
|
|
2241
2280
|
connection,
|
|
2242
2281
|
'agent.workspaces.list',
|
|
2243
|
-
|
|
2282
|
+
params,
|
|
2244
2283
|
(result) => {
|
|
2245
2284
|
// The flag rides on every page; the last page read wins, and the
|
|
2246
2285
|
// real peer always sends it.
|
|
2247
2286
|
if (typeof result['systemWorkspace'] === 'boolean') systemWorkspace = result['systemWorkspace']
|
|
2248
2287
|
return { ...arrayOf(result['items']), nextCursor: result['nextCursor'] }
|
|
2249
2288
|
},
|
|
2250
|
-
{ limit: aggregateLimit(args['limit']) },
|
|
2289
|
+
{ limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
|
|
2251
2290
|
)
|
|
2252
|
-
return {
|
|
2253
|
-
items: page.items,
|
|
2254
|
-
systemWorkspace,
|
|
2255
|
-
truncated: page.truncated,
|
|
2256
|
-
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
2257
|
-
}
|
|
2291
|
+
return { ...this.#listPage(page, binding), systemWorkspace }
|
|
2258
2292
|
},
|
|
2259
2293
|
})
|
|
2260
2294
|
}
|
|
@@ -2265,7 +2299,11 @@ class CherryToolEngine {
|
|
|
2265
2299
|
description:
|
|
2266
2300
|
'List an agent\'s sessions, optionally filtered by workspace. The `agentId` and ' +
|
|
2267
2301
|
'`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
|
|
2268
|
-
'the TOTAL across pages (default 100, max 1000) and is aggregated internally.'
|
|
2302
|
+
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2303
|
+
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2304
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). A truncated ' +
|
|
2305
|
+
'answer also carries `resumeCursor`: replay it as `cursor` with the same tool, device and ' +
|
|
2306
|
+
'filters to continue from that stop point (`limit` then bounds the next answer).',
|
|
2269
2307
|
parameters: {
|
|
2270
2308
|
type: 'object',
|
|
2271
2309
|
properties: {
|
|
@@ -2278,6 +2316,7 @@ class CherryToolEngine {
|
|
|
2278
2316
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
2279
2317
|
description: 'Maximum number of sessions to return in total across pages (default 100).',
|
|
2280
2318
|
},
|
|
2319
|
+
cursor: LIST_CURSOR_PARAMETER,
|
|
2281
2320
|
},
|
|
2282
2321
|
required: ['device'],
|
|
2283
2322
|
additionalProperties: false,
|
|
@@ -2285,21 +2324,27 @@ class CherryToolEngine {
|
|
|
2285
2324
|
execute: async (args) => {
|
|
2286
2325
|
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2287
2326
|
if (resolved.link === undefined) return resolved.verdict
|
|
2288
|
-
const { connection } = resolved.link
|
|
2327
|
+
const { connection, record } = resolved.link
|
|
2289
2328
|
const agentId = optionalString(args['agentId'])
|
|
2290
2329
|
const workspaceId = optionalString(args['workspaceId'])
|
|
2291
2330
|
// Filter passthrough: the params carry the caller's filters verbatim,
|
|
2292
|
-
// and `#aggregate` adds only `limit`/`cursor`.
|
|
2293
|
-
|
|
2331
|
+
// and `#aggregate` adds only `limit`/`cursor`. The SAME object is the
|
|
2332
|
+
// token's binding, so a replay with other filters cannot read another
|
|
2333
|
+
// query's page position.
|
|
2334
|
+
const params = {
|
|
2335
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
2336
|
+
...(workspaceId === undefined ? {} : { workspaceId }),
|
|
2337
|
+
}
|
|
2338
|
+
const binding: ListResumeBinding = { method: 'agent.sessions.list', deviceId: record.deviceId, params }
|
|
2339
|
+
const resume = this.#listResume(args['cursor'], binding)
|
|
2340
|
+
const page = await this.#aggregate(
|
|
2294
2341
|
connection,
|
|
2295
2342
|
'agent.sessions.list',
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
},
|
|
2300
|
-
(page) => ({ ...arrayOf(page['items']), nextCursor: page['nextCursor'] }),
|
|
2301
|
-
{ limit: aggregateLimit(args['limit']) },
|
|
2343
|
+
params,
|
|
2344
|
+
(result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
|
|
2345
|
+
{ limit: aggregateLimit(args['limit']), ...(resume === undefined ? {} : { resume }) },
|
|
2302
2346
|
)
|
|
2347
|
+
return this.#listPage(page, binding)
|
|
2303
2348
|
},
|
|
2304
2349
|
})
|
|
2305
2350
|
}
|
|
@@ -3064,7 +3109,9 @@ class CherryToolEngine {
|
|
|
3064
3109
|
'`cherry_send`/`cherry_wait` report on an empty window. Once the window DISCARDS events, ' +
|
|
3065
3110
|
'the boundary it reports is not: that boundary and everything below it answer a structured ' +
|
|
3066
3111
|
'`cursor-out-of-window` verdict, and only positions strictly past it are served — rebuild ' +
|
|
3067
|
-
'the baseline with `cherry_read` and resume from that instead.'
|
|
3112
|
+
'the baseline with `cherry_read` and resume from that instead. The answer is ' +
|
|
3113
|
+
'`{events, nextCursor}`; pass that `nextCursor` back as `cursor` on the next call to ' +
|
|
3114
|
+
'continue from it.',
|
|
3068
3115
|
parameters: {
|
|
3069
3116
|
type: 'object',
|
|
3070
3117
|
properties: {
|
|
@@ -3694,7 +3741,9 @@ class CherryToolEngine {
|
|
|
3694
3741
|
'answer. The peer serves the full four-state set (pending/approved/denied/expired) — the ' +
|
|
3695
3742
|
'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
|
|
3696
3743
|
'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
|
|
3697
|
-
'`decision`.'
|
|
3744
|
+
'`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
|
|
3745
|
+
'peer ran out and carries `resumeCursor`: replay it as `cursor` with the same filters to ' +
|
|
3746
|
+
'continue.',
|
|
3698
3747
|
parameters: {
|
|
3699
3748
|
type: 'object',
|
|
3700
3749
|
properties: {
|
|
@@ -3712,6 +3761,7 @@ class CherryToolEngine {
|
|
|
3712
3761
|
maximum: MAX_AGGREGATE_LIMIT,
|
|
3713
3762
|
description: 'Maximum number of interactions to return in total across pages (default 100).',
|
|
3714
3763
|
},
|
|
3764
|
+
cursor: LIST_CURSOR_PARAMETER,
|
|
3715
3765
|
},
|
|
3716
3766
|
required: ['device', 'sessionId'],
|
|
3717
3767
|
additionalProperties: false,
|
|
@@ -3721,6 +3771,9 @@ class CherryToolEngine {
|
|
|
3721
3771
|
if (resolved.link === undefined) return resolved.verdict
|
|
3722
3772
|
const { connection, record } = resolved.link
|
|
3723
3773
|
const sessionId = requiredString(args, 'sessionId')
|
|
3774
|
+
const params = { sessionId }
|
|
3775
|
+
const binding: ListResumeBinding = { method: 'agent.interactions.list', deviceId: record.deviceId, params }
|
|
3776
|
+
const resume = this.#listResume(args['cursor'], binding)
|
|
3724
3777
|
|
|
3725
3778
|
await this.#ensureSubscription(record.deviceId, sessionId)
|
|
3726
3779
|
|
|
@@ -3730,10 +3783,11 @@ class CherryToolEngine {
|
|
|
3730
3783
|
const page = await this.#aggregate(
|
|
3731
3784
|
connection,
|
|
3732
3785
|
'agent.interactions.list',
|
|
3733
|
-
|
|
3786
|
+
params,
|
|
3734
3787
|
(result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
|
|
3735
3788
|
{
|
|
3736
3789
|
limit: aggregateLimit(args['limit']),
|
|
3790
|
+
...(resume === undefined ? {} : { resume }),
|
|
3737
3791
|
safetyBound: MAX_AGGREGATE_LIMIT,
|
|
3738
3792
|
// An interaction record carries an inline input AND an inline
|
|
3739
3793
|
// summary, the fattest item of the plain lists.
|
|
@@ -3752,11 +3806,7 @@ class CherryToolEngine {
|
|
|
3752
3806
|
// docblock): priming from it is what makes every listed card answerable.
|
|
3753
3807
|
for (const item of page.items) this.#rememberInteraction(record.deviceId, sessionId, item)
|
|
3754
3808
|
|
|
3755
|
-
return {
|
|
3756
|
-
items,
|
|
3757
|
-
truncated: page.truncated,
|
|
3758
|
-
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
3759
|
-
}
|
|
3809
|
+
return { ...this.#listPage(page, binding), items }
|
|
3760
3810
|
},
|
|
3761
3811
|
})
|
|
3762
3812
|
}
|
|
@@ -6187,6 +6237,100 @@ function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
|
6187
6237
|
return refuse()
|
|
6188
6238
|
}
|
|
6189
6239
|
|
|
6240
|
+
/**
|
|
6241
|
+
* The LIST face's stop point (issue #65): a self-describing token that the SAME
|
|
6242
|
+
* tool accepts back, bound to the query that produced it.
|
|
6243
|
+
*
|
|
6244
|
+
* The message face wraps the peer position in `cursor:` / `after:` and is done,
|
|
6245
|
+
* because a message walk is scoped by its session. A list walk is scoped by
|
|
6246
|
+
* (method, device, filters), and the peer position is opaque: a position from
|
|
6247
|
+
* another query is a perfectly valid number that reads the WRONG slice with no
|
|
6248
|
+
* error anywhere. So this token carries a fingerprint of the binding and a
|
|
6249
|
+
* mismatched replay is refused HERE — before any RPC.
|
|
6250
|
+
*/
|
|
6251
|
+
const LIST_RESUME_PREFIX = 'cursor:'
|
|
6252
|
+
|
|
6253
|
+
/** The `cursor` parameter every list tool shares (issue #65). */
|
|
6254
|
+
const LIST_CURSOR_PARAMETER = {
|
|
6255
|
+
type: 'string',
|
|
6256
|
+
description:
|
|
6257
|
+
'A `resumeCursor` a previous TRUNCATED answer of this tool returned: replay it with the SAME ' +
|
|
6258
|
+
'device and filters to continue from that stop point (`limit` then bounds this answer). A token ' +
|
|
6259
|
+
'from another tool, device or filter set is refused locally.',
|
|
6260
|
+
} as const
|
|
6261
|
+
|
|
6262
|
+
/** What a list token is valid for. `params` are the CALLER's filters, not the wire page params. */
|
|
6263
|
+
interface ListResumeBinding {
|
|
6264
|
+
method: string
|
|
6265
|
+
deviceId: string
|
|
6266
|
+
params: Record<string, unknown>
|
|
6267
|
+
}
|
|
6268
|
+
|
|
6269
|
+
/** Canonical, key-sorted rendering of the caller's filters (undefined keys dropped). */
|
|
6270
|
+
function listParamsFingerprint(params: Record<string, unknown>): string {
|
|
6271
|
+
const entries = Object.keys(params)
|
|
6272
|
+
.sort()
|
|
6273
|
+
.filter((key) => params[key] !== undefined)
|
|
6274
|
+
.map((key) => [key, params[key]] as const)
|
|
6275
|
+
return createHash('sha256').update(JSON.stringify(entries)).digest('hex').slice(0, 16)
|
|
6276
|
+
}
|
|
6277
|
+
|
|
6278
|
+
function encodeListResumeCursor(binding: ListResumeBinding, peerPosition: string): string {
|
|
6279
|
+
const payload = {
|
|
6280
|
+
v: 1,
|
|
6281
|
+
m: binding.method,
|
|
6282
|
+
d: binding.deviceId,
|
|
6283
|
+
h: listParamsFingerprint(binding.params),
|
|
6284
|
+
p: peerPosition,
|
|
6285
|
+
}
|
|
6286
|
+
return LIST_RESUME_PREFIX + Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
|
|
6287
|
+
}
|
|
6288
|
+
|
|
6289
|
+
/**
|
|
6290
|
+
* Decode a list token, refusing anything that is not one this tool emitted for
|
|
6291
|
+
* THIS binding. The refusal names the dimension that differs (tool / device /
|
|
6292
|
+
* filters) and never echoes the peer position, which is peer-supplied data.
|
|
6293
|
+
*
|
|
6294
|
+
* An `after:` token is refused by the prefix check: list walks have no anchor
|
|
6295
|
+
* form (they never trim a page, so a peer position always exists for a stop the
|
|
6296
|
+
* peer reported).
|
|
6297
|
+
*/
|
|
6298
|
+
function parseListResumeCursor(value: unknown, binding: ListResumeBinding): string {
|
|
6299
|
+
const refuse = (why: string): never => {
|
|
6300
|
+
throw new Error(
|
|
6301
|
+
`cursor ${why}; omit \`cursor\` to read from the start, or replay the token with the same tool, ` +
|
|
6302
|
+
'device and filters.',
|
|
6303
|
+
)
|
|
6304
|
+
}
|
|
6305
|
+
if (typeof value !== 'string' || !value.startsWith(LIST_RESUME_PREFIX)) {
|
|
6306
|
+
refuse('must be a `cursor:` token this tool emitted')
|
|
6307
|
+
}
|
|
6308
|
+
const raw = (value as string).slice(LIST_RESUME_PREFIX.length)
|
|
6309
|
+
let decoded: unknown
|
|
6310
|
+
try {
|
|
6311
|
+
decoded = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
|
|
6312
|
+
} catch {
|
|
6313
|
+
refuse('is not a token this tool emitted')
|
|
6314
|
+
}
|
|
6315
|
+
if (typeof decoded !== 'object' || decoded === null) refuse('is not a token this tool emitted')
|
|
6316
|
+
const payload = decoded as Record<string, unknown>
|
|
6317
|
+
if (
|
|
6318
|
+
payload['v'] !== 1 ||
|
|
6319
|
+
typeof payload['m'] !== 'string' ||
|
|
6320
|
+
typeof payload['d'] !== 'string' ||
|
|
6321
|
+
typeof payload['h'] !== 'string' ||
|
|
6322
|
+
typeof payload['p'] !== 'string'
|
|
6323
|
+
) {
|
|
6324
|
+
refuse('is not a token this tool emitted')
|
|
6325
|
+
}
|
|
6326
|
+
if (payload['m'] !== binding.method) refuse('was issued for a different tool')
|
|
6327
|
+
if (payload['d'] !== binding.deviceId) refuse('was issued for a different device')
|
|
6328
|
+
if (payload['h'] !== listParamsFingerprint(binding.params)) refuse('was issued for a different filter set')
|
|
6329
|
+
const peerPosition = payload['p'] as string
|
|
6330
|
+
if (mangledRemainder(peerPosition)) refuse('is not a token this tool emitted')
|
|
6331
|
+
return peerPosition
|
|
6332
|
+
}
|
|
6333
|
+
|
|
6190
6334
|
/**
|
|
6191
6335
|
* The assistant text a terminal aggregate reports: the awaited RUN's assistant
|
|
6192
6336
|
* message text parts, joined.
|