dsh-cherry-bridge 0.1.7 → 0.1.9
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 +31 -17
- package/README.md +20 -12
- package/dist/index.mjs +201 -43
- package/docs/e2e-checklist.md +1 -1
- package/lib/client.js +2 -2
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +1 -1
- package/src/connection.ts +61 -0
- package/src/defaults.ts +23 -0
- package/src/tools.ts +264 -78
- package/ui/index.html +1 -1
- package/ui/src/i18n.ts +2 -2
package/src/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { DeviceConnection, DeviceState } from './connection.js'
|
|
|
4
4
|
import type { DeviceRecord, DeviceResolution, DeviceStore } from './deviceStore.js'
|
|
5
5
|
import { QUARANTINE_RECOVERY, QuarantinedArchiveError } from './deviceStore.js'
|
|
6
6
|
import {
|
|
7
|
+
DEVICE_SETTLE_WAIT_MS,
|
|
7
8
|
INTERACTIONS_LIST_PAGE_CLAMP,
|
|
8
9
|
LIST_PAGE_CLAMP,
|
|
9
10
|
MAX_RESUME_TOKEN_CHARS,
|
|
@@ -391,9 +392,15 @@ export interface CherryReadSnapshot {
|
|
|
391
392
|
*
|
|
392
393
|
* Present exactly when `truncated` is true and a stop point can be named
|
|
393
394
|
* honestly; because the walk runs newest-first, it marks OLDER messages as
|
|
394
|
-
* still unread — the only direction a truncation leaves behind.
|
|
395
|
-
*
|
|
396
|
-
*
|
|
395
|
+
* still unread — the only direction a truncation leaves behind.
|
|
396
|
+
*
|
|
397
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `truncated` is what tells them
|
|
398
|
+
* apart: a COMPLETE walk (`truncated: false` — nothing older left), and a
|
|
399
|
+
* TRUNCATED walk whose stop point could not be named (`truncated: true` plus
|
|
400
|
+
* `truncationReason` — older messages still unread). The absence therefore
|
|
401
|
+
* never means "nothing older left" on its own. WHICH stops can be named is
|
|
402
|
+
* decided in ONE place ({@link messageResumeToken}); the refusal cases are not
|
|
403
|
+
* restated here.
|
|
397
404
|
*
|
|
398
405
|
* WHAT REACHES THE UNREAD MESSAGES DEPENDS ON THE REASON. A `'limit'` stop was
|
|
399
406
|
* the caller's own ceiling: raise `limit`, or resume with this token to walk
|
|
@@ -403,6 +410,12 @@ export interface CherryReadSnapshot {
|
|
|
403
410
|
* messages are reached. From the resume point `limit` bounds how many messages
|
|
404
411
|
* ONE answer carries, so a long history is walked in successive answers, each
|
|
405
412
|
* carrying the next stop point.
|
|
413
|
+
*
|
|
414
|
+
* WITH NO TOKEN NAMED, the reason is all the caller has: a `'limit'` stop is
|
|
415
|
+
* still reachable by widening `limit`; a `'safety-bound'` stop is the internal
|
|
416
|
+
* circuit breaker, which no wider `limit` moves; and an `'aggregate-bytes'`
|
|
417
|
+
* stop has no remedy in that answer at all — the budget, not the ceiling, is
|
|
418
|
+
* what stopped it. The loss is stated rather than repaired.
|
|
406
419
|
*/
|
|
407
420
|
messagesResumeCursor?: string
|
|
408
421
|
}
|
|
@@ -447,10 +460,19 @@ export interface CherryMessageWalkVerdict {
|
|
|
447
460
|
* `cursor:<peer position>` when the whole collected window was returned;
|
|
448
461
|
* `after:<last returned message id>` when the byte stop happened INSIDE that
|
|
449
462
|
* window, because the peer's position sits past messages this walk trimmed
|
|
450
|
-
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
463
|
+
* and `AgentMessage` carries no `createdAt` to synthesise a closer one.
|
|
464
|
+
*
|
|
465
|
+
* IT IS ABSENT IN TWO OPPOSITE CASES, and `messagesTruncated` is what tells
|
|
466
|
+
* them apart: a COMPLETE walk (`messagesTruncated` absent or false — nothing
|
|
467
|
+
* older left), and a TRUNCATED walk whose stop point could not be named
|
|
468
|
+
* (`messagesTruncated: true` plus `messagesTruncationReason` — older history
|
|
469
|
+
* still unread). The absence never means "nothing older left" on its own.
|
|
470
|
+
* WHICH stops can be named is decided in ONE place
|
|
471
|
+
* ({@link messageResumeToken}) and is the same rule as
|
|
472
|
+
* {@link CherryReadSnapshot.messagesResumeCursor}. THE REMEDY IS NOT THE SAME
|
|
473
|
+
* ON THIS FACE: a `'limit'` stop here is the walk's own fixed message
|
|
474
|
+
* allowance — these tools take no `limit` argument — so the way further is
|
|
475
|
+
* `cherry_read`, which also carries no deadline.
|
|
454
476
|
*/
|
|
455
477
|
messagesResumeCursor?: string
|
|
456
478
|
}
|
|
@@ -1009,6 +1031,14 @@ export interface CherryToolsDeps {
|
|
|
1009
1031
|
* this one subscribes the engine to ONE device's transitions.
|
|
1010
1032
|
*/
|
|
1011
1033
|
watchDeviceState?: (deviceId: string, cb: (state: DeviceState) => void) => void
|
|
1034
|
+
/**
|
|
1035
|
+
* How long a device tool waits for an establishment attempt that is ALREADY
|
|
1036
|
+
* IN FLIGHT (`state === 'connecting'`) before judging the device (issue #99).
|
|
1037
|
+
*
|
|
1038
|
+
* OPTIONAL: production omits it and takes {@link DEVICE_SETTLE_WAIT_MS}; test
|
|
1039
|
+
* rigs drive it down so the bounded case needs no real five seconds.
|
|
1040
|
+
*/
|
|
1041
|
+
deviceSettleWaitMs?: number
|
|
1012
1042
|
}
|
|
1013
1043
|
|
|
1014
1044
|
// ---------------------------------------------------------------------------
|
|
@@ -1596,10 +1626,14 @@ class CherryToolEngine {
|
|
|
1596
1626
|
* ⑤): the candidate list is the answer. A MISS is a verdict too — see
|
|
1597
1627
|
* {@link CherryDeviceNotFound} — and it carries the ids the caller could have
|
|
1598
1628
|
* meant so the answer stays actionable.
|
|
1629
|
+
*
|
|
1630
|
+
* ASYNC because of the `connecting` wait below: the pool dials on first touch
|
|
1631
|
+
* and on every reconnect, so a tool called in that window used to be refused
|
|
1632
|
+
* for a connection that comes up a moment later (issue #99).
|
|
1599
1633
|
*/
|
|
1600
|
-
#resolve(
|
|
1634
|
+
async #resolve(
|
|
1601
1635
|
device: string,
|
|
1602
|
-
): ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable {
|
|
1636
|
+
): Promise<ResolvedDevice | CherryAmbiguousDevice | CherryDeviceNotFound | CherryDeviceUnavailable> {
|
|
1603
1637
|
const resolution: DeviceResolution = this.#deps.store.resolve(device)
|
|
1604
1638
|
if ('ambiguous' in resolution) {
|
|
1605
1639
|
return { error: 'ambiguous-device', device, candidates: resolution.ambiguous }
|
|
@@ -1613,10 +1647,21 @@ class CherryToolEngine {
|
|
|
1613
1647
|
// transition this exists for can land while the device is already offline,
|
|
1614
1648
|
// and the watch must be in place from the first call either way.
|
|
1615
1649
|
this.#watchConnection(resolution.deviceId, connection)
|
|
1616
|
-
|
|
1650
|
+
// `connecting` is an attempt IN FLIGHT, not a verdict (issue #99): the pool
|
|
1651
|
+
// dials on first touch and on every reconnect, so refusing here used to
|
|
1652
|
+
// report a failure about a connection that comes up a moment later. Wait for
|
|
1653
|
+
// that attempt — bounded, and never starting one — then judge the state it
|
|
1654
|
+
// settled on. Every other non-connected state is answered at once: no wait
|
|
1655
|
+
// can change `offline` (parked in backoff), `disabled` or
|
|
1656
|
+
// `identity-mismatch`, and waiting would only delay the answer.
|
|
1657
|
+
const state =
|
|
1658
|
+
connection.state === 'connecting'
|
|
1659
|
+
? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
|
|
1660
|
+
: connection.state
|
|
1661
|
+
if (state !== 'connected') {
|
|
1617
1662
|
// The established Task 5 shape: the tool never dials implicitly, and the
|
|
1618
1663
|
// caller gets the same `{device, state}` verdict the RPC layer throws.
|
|
1619
|
-
return { error: 'device-unavailable', device: resolution.deviceId, state
|
|
1664
|
+
return { error: 'device-unavailable', device: resolution.deviceId, state }
|
|
1620
1665
|
}
|
|
1621
1666
|
return { record: resolution, connection }
|
|
1622
1667
|
}
|
|
@@ -1654,6 +1699,10 @@ class CherryToolEngine {
|
|
|
1654
1699
|
/**
|
|
1655
1700
|
* Resolve-or-verdict: every non-`cherry_devices` tool needs a live link.
|
|
1656
1701
|
*
|
|
1702
|
+
* `connecting` is waited out inside {@link #resolve} first, so the
|
|
1703
|
+
* `DeviceUnavailableError` below is only ever raised for a state the wait
|
|
1704
|
+
* could not improve on (bounded by `deviceSettleWaitMs`).
|
|
1705
|
+
*
|
|
1657
1706
|
* The failure faces are deliberately DIFFERENT (controller ruling ⑤): an
|
|
1658
1707
|
* ambiguous selector and a MISS are ANSWERS — the candidate list, or the
|
|
1659
1708
|
* known ids, returned in band, because the caller can act on them — while a
|
|
@@ -1666,8 +1715,8 @@ class CherryToolEngine {
|
|
|
1666
1715
|
* `cherry_devices` does not come through this method — it goes through
|
|
1667
1716
|
* {@link #selectRecords}, which returns the verdict in band.
|
|
1668
1717
|
*/
|
|
1669
|
-
#requireConnection(device: string): ResolvedDevice | CherryAmbiguousDevice {
|
|
1670
|
-
const resolved = this.#resolve(device)
|
|
1718
|
+
async #requireConnection(device: string): Promise<ResolvedDevice | CherryAmbiguousDevice> {
|
|
1719
|
+
const resolved = await this.#resolve(device)
|
|
1671
1720
|
if (!this.#isVerdict(resolved)) return resolved
|
|
1672
1721
|
if (resolved.error === 'ambiguous-device') return resolved
|
|
1673
1722
|
if (resolved.error === 'not-found') {
|
|
@@ -1689,14 +1738,13 @@ class CherryToolEngine {
|
|
|
1689
1738
|
* replace it with a weaker, wrong one — a miss under a READ-ONLY archive
|
|
1690
1739
|
* would describe a store that still holds a valid token as "no such device".
|
|
1691
1740
|
*/
|
|
1692
|
-
#linkOr(
|
|
1693
|
-
device: string,
|
|
1694
|
-
):
|
|
1741
|
+
async #linkOr(device: string): Promise<
|
|
1695
1742
|
| { link: ResolvedDevice; verdict?: undefined }
|
|
1696
|
-
| { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
|
|
1743
|
+
| { link?: undefined; verdict: CherryAmbiguousDevice | CherryArchiveQuarantined }
|
|
1744
|
+
> {
|
|
1697
1745
|
const quarantined = this.#quarantineVerdict()
|
|
1698
1746
|
if (quarantined !== undefined) return { verdict: quarantined }
|
|
1699
|
-
const resolved = this.#requireConnection(device)
|
|
1747
|
+
const resolved = await this.#requireConnection(device)
|
|
1700
1748
|
if ('error' in resolved) return { verdict: resolved }
|
|
1701
1749
|
return { link: resolved }
|
|
1702
1750
|
}
|
|
@@ -2100,19 +2148,31 @@ class CherryToolEngine {
|
|
|
2100
2148
|
#listPage(page: CherryPage<Record<string, unknown>>, binding: ListResumeBinding): Record<string, unknown> {
|
|
2101
2149
|
// A position this face would REFUSE is not a position it may name. The
|
|
2102
2150
|
// round-trip symmetry (a token a tool emits is a token that tool accepts) is
|
|
2103
|
-
// the whole point of the token, so a peer answering `nextCursor: ''
|
|
2104
|
-
//
|
|
2105
|
-
//
|
|
2106
|
-
//
|
|
2107
|
-
//
|
|
2108
|
-
|
|
2151
|
+
// the whole point of the token, so a peer answering `nextCursor: ''`, one
|
|
2152
|
+
// carrying whitespace/control characters, or one shaped like our retired v1
|
|
2153
|
+
// token gets NO token. The absence has TWO opposite cases — a COMPLETE walk,
|
|
2154
|
+
// and a TRUNCATED one whose stop could not be named — and only `truncated`
|
|
2155
|
+
// tells them apart. The remedy follows the REASON: a `'limit'` stop answers
|
|
2156
|
+
// to a wider `limit`; a `'safety-bound'` stop does not, because it is an
|
|
2157
|
+
// internal circuit breaker a caller cannot move. The rule is NOT restated
|
|
2158
|
+
// here or in the message face:
|
|
2159
|
+
// `nameablePeerPosition` is the one place it lives, so the emitters and
|
|
2160
|
+
// their parsers cannot drift (issue #85).
|
|
2161
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
2162
|
+
// The same refusal one dimension further: an encoded token over
|
|
2163
|
+
// {@link MAX_RESUME_TOKEN_CHARS} is one this face would refuse on replay, so
|
|
2164
|
+
// it is not named ({@link withinResumeTokenCeiling}, issue #76). Judged only
|
|
2165
|
+
// on the arm that NAMES it — a complete walk has nothing to name, so it must
|
|
2166
|
+
// not pay for the encoding.
|
|
2167
|
+
const token =
|
|
2168
|
+
!page.truncated || position === undefined
|
|
2169
|
+
? undefined
|
|
2170
|
+
: withinResumeTokenCeiling(encodeListResumeCursor(binding, position))
|
|
2109
2171
|
return {
|
|
2110
2172
|
items: page.items,
|
|
2111
2173
|
truncated: page.truncated,
|
|
2112
2174
|
...(page.truncationReason === undefined ? {} : { truncationReason: page.truncationReason }),
|
|
2113
|
-
...(
|
|
2114
|
-
? { resumeCursor: encodeListResumeCursor(binding, position) }
|
|
2115
|
-
: {}),
|
|
2175
|
+
...(token === undefined ? {} : { resumeCursor: token }),
|
|
2116
2176
|
}
|
|
2117
2177
|
}
|
|
2118
2178
|
|
|
@@ -2139,11 +2199,12 @@ class CherryToolEngine {
|
|
|
2139
2199
|
const quarantined = this.#quarantineVerdict()
|
|
2140
2200
|
if (quarantined !== undefined) return quarantined
|
|
2141
2201
|
if (selector === undefined) {
|
|
2142
|
-
|
|
2202
|
+
const records = this.#deps.store.list()
|
|
2203
|
+
return { devices: await Promise.all(records.map((record) => this.#deviceView(record))) }
|
|
2143
2204
|
}
|
|
2144
2205
|
const selected = this.#selectRecords(selector)
|
|
2145
2206
|
if (!Array.isArray(selected)) return selected
|
|
2146
|
-
return { devices: selected.map((record) => this.#deviceView(record)) }
|
|
2207
|
+
return { devices: await Promise.all(selected.map((record) => this.#deviceView(record))) }
|
|
2147
2208
|
},
|
|
2148
2209
|
})
|
|
2149
2210
|
}
|
|
@@ -2175,7 +2236,9 @@ class CherryToolEngine {
|
|
|
2175
2236
|
}
|
|
2176
2237
|
|
|
2177
2238
|
/**
|
|
2178
|
-
* One device's view WITHOUT dialing
|
|
2239
|
+
* One device's view WITHOUT dialing — but after waiting out an attempt that is
|
|
2240
|
+
* already in flight, exactly as {@link #resolve} does, so a cold-start row
|
|
2241
|
+
* does not present a transient `connecting` as the device's answer.
|
|
2179
2242
|
*
|
|
2180
2243
|
* A disabled record is authoritative: the pooled connection is constructed
|
|
2181
2244
|
* with its own `record.enabled` snapshot and reports `disabled` only once
|
|
@@ -2183,11 +2246,18 @@ class CherryToolEngine {
|
|
|
2183
2246
|
* for a device whose connection was never started. Everything else falls back
|
|
2184
2247
|
* to the connection's own CLOSED state set.
|
|
2185
2248
|
*/
|
|
2186
|
-
#deviceView(record: DeviceRecord): CherryDeviceView {
|
|
2249
|
+
async #deviceView(record: DeviceRecord): Promise<CherryDeviceView> {
|
|
2187
2250
|
let state: DeviceState = record.enabled ? 'offline' : 'disabled'
|
|
2188
2251
|
if (record.enabled) {
|
|
2189
2252
|
try {
|
|
2190
|
-
|
|
2253
|
+
const connection = this.#deps.connect(record.deviceId)
|
|
2254
|
+
// Same transient as {@link #resolve} (issue #99): a row rendered while
|
|
2255
|
+
// the first dial is in flight must not present `connecting` as the
|
|
2256
|
+
// device's answer. Bounded, and only for an attempt already running.
|
|
2257
|
+
state =
|
|
2258
|
+
connection.state === 'connecting'
|
|
2259
|
+
? await connection.waitForSettled(this.#deps.deviceSettleWaitMs ?? DEVICE_SETTLE_WAIT_MS)
|
|
2260
|
+
: connection.state
|
|
2191
2261
|
} catch (error) {
|
|
2192
2262
|
// A device that cannot even be resolved to a connection still deserves a
|
|
2193
2263
|
// truthful row; `offline` is the fallback verdict.
|
|
@@ -2215,9 +2285,10 @@ class CherryToolEngine {
|
|
|
2215
2285
|
'List the Cherry Studio agents available on a device. Paginated: `limit` bounds ' +
|
|
2216
2286
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2217
2287
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2218
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2219
|
-
'
|
|
2220
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2288
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2289
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2290
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2291
|
+
'answer).',
|
|
2221
2292
|
parameters: {
|
|
2222
2293
|
type: 'object',
|
|
2223
2294
|
properties: {
|
|
@@ -2234,7 +2305,7 @@ class CherryToolEngine {
|
|
|
2234
2305
|
additionalProperties: false,
|
|
2235
2306
|
},
|
|
2236
2307
|
execute: async (args) => {
|
|
2237
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2308
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2238
2309
|
if (resolved.link === undefined) return resolved.verdict
|
|
2239
2310
|
const { connection, record } = resolved.link
|
|
2240
2311
|
const binding: ListResumeBinding = { method: 'agent.agents.list', deviceId: record.deviceId, params: {} }
|
|
@@ -2258,8 +2329,9 @@ class CherryToolEngine {
|
|
|
2258
2329
|
'List an agent\'s REGISTERED workspaces, plus whether a system workspace exists. ' +
|
|
2259
2330
|
'The system workspace has no enumerable id: it is not in `items`, and its identity ' +
|
|
2260
2331
|
'is only observable by creating a session in it and reading it back. A truncated ' +
|
|
2261
|
-
'answer (`truncated` + `truncationReason`) stopped before the peer ran out
|
|
2262
|
-
'`resumeCursor
|
|
2332
|
+
'answer (`truncated` + `truncationReason`) stopped before the peer ran out; when the stop ' +
|
|
2333
|
+
'point can be named it carries `resumeCursor` — replay it as `cursor` with the same ' +
|
|
2334
|
+
'filters to continue.',
|
|
2263
2335
|
parameters: {
|
|
2264
2336
|
type: 'object',
|
|
2265
2337
|
properties: {
|
|
@@ -2277,7 +2349,7 @@ class CherryToolEngine {
|
|
|
2277
2349
|
additionalProperties: false,
|
|
2278
2350
|
},
|
|
2279
2351
|
execute: async (args) => {
|
|
2280
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2352
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2281
2353
|
if (resolved.link === undefined) return resolved.verdict
|
|
2282
2354
|
const { connection, record } = resolved.link
|
|
2283
2355
|
const agentId = requiredString(args, 'agentId')
|
|
@@ -2310,9 +2382,10 @@ class CherryToolEngine {
|
|
|
2310
2382
|
'`workspaceId` filters pass straight through to the peer. Paginated: `limit` bounds ' +
|
|
2311
2383
|
'the TOTAL across pages (default 100, max 1000) and is aggregated internally. ' +
|
|
2312
2384
|
'A truncated answer (`truncated` + `truncationReason`) stopped before the peer ran ' +
|
|
2313
|
-
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one).
|
|
2314
|
-
'
|
|
2315
|
-
'filters to continue from that stop point (`limit` then bounds the next
|
|
2385
|
+
'out (`limit` is your ceiling, `safety-bound` the 1000-item internal one). When the ' +
|
|
2386
|
+
'stop point can be named it carries `resumeCursor`: replay it as `cursor` with the same ' +
|
|
2387
|
+
'tool, device and filters to continue from that stop point (`limit` then bounds the next ' +
|
|
2388
|
+
'answer).',
|
|
2316
2389
|
parameters: {
|
|
2317
2390
|
type: 'object',
|
|
2318
2391
|
properties: {
|
|
@@ -2331,7 +2404,7 @@ class CherryToolEngine {
|
|
|
2331
2404
|
additionalProperties: false,
|
|
2332
2405
|
},
|
|
2333
2406
|
execute: async (args) => {
|
|
2334
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2407
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2335
2408
|
if (resolved.link === undefined) return resolved.verdict
|
|
2336
2409
|
const { connection, record } = resolved.link
|
|
2337
2410
|
const agentId = optionalString(args['agentId'])
|
|
@@ -2382,8 +2455,9 @@ class CherryToolEngine {
|
|
|
2382
2455
|
'"aggregate-bytes"` means the walk stopped because the result reached its size limit, ' +
|
|
2383
2456
|
'not because `limit` was reached — raising `limit` does NOT reach the messages left out ' +
|
|
2384
2457
|
'(it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE ' +
|
|
2385
|
-
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor`
|
|
2386
|
-
'stop point can be named
|
|
2458
|
+
'REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the ' +
|
|
2459
|
+
'stop point can be named — absent when it cannot, e.g. an empty or malformed position, a ' +
|
|
2460
|
+
'token past the length ceiling, or no anchor id — and ' +
|
|
2387
2461
|
'passing it back as `messagesResumeCursor` continues the walk from there (`limit` then ' +
|
|
2388
2462
|
'bounds how many messages are returned FROM that point). The two token forms are ' +
|
|
2389
2463
|
'`"cursor:<peer position>"` (the walk consumed its whole page window) and ' +
|
|
@@ -2405,7 +2479,8 @@ class CherryToolEngine {
|
|
|
2405
2479
|
description:
|
|
2406
2480
|
'Maximum number of messages to return in total across pages (default 100). Distinct from ' +
|
|
2407
2481
|
'the output budget: the answer may stop earlier with `truncationReason: "aggregate-bytes"`, ' +
|
|
2408
|
-
'which a larger `limit` does not extend — the resume token is
|
|
2482
|
+
'which a larger `limit` does not extend — the resume token, when one is returned, is how ' +
|
|
2483
|
+
'older messages are reached.',
|
|
2409
2484
|
},
|
|
2410
2485
|
messagesResumeCursor: {
|
|
2411
2486
|
type: 'string',
|
|
@@ -2428,7 +2503,7 @@ class CherryToolEngine {
|
|
|
2428
2503
|
// a malformed cursor, which would present the newest page again as
|
|
2429
2504
|
// "continued history" — the refusal has to be ours.
|
|
2430
2505
|
const resume = parseMessageResume(args['messagesResumeCursor'])
|
|
2431
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
2506
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
2432
2507
|
if (resolved.link === undefined) return resolved.verdict
|
|
2433
2508
|
const { connection, record } = resolved.link
|
|
2434
2509
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -2586,11 +2661,30 @@ class CherryToolEngine {
|
|
|
2586
2661
|
...(truncationReason === undefined ? {} : { truncationReason }),
|
|
2587
2662
|
// Only a TRUNCATED walk has unread history, and the token then marks the
|
|
2588
2663
|
// stop point (OLDER messages remain unread) —
|
|
2589
|
-
// {@link CherryReadSnapshot.messagesResumeCursor}.
|
|
2590
|
-
//
|
|
2591
|
-
//
|
|
2592
|
-
//
|
|
2593
|
-
//
|
|
2664
|
+
// {@link CherryReadSnapshot.messagesResumeCursor}. Whether that stop can
|
|
2665
|
+
// be NAMED is not the walk's verdict: it depends on the peer's payload,
|
|
2666
|
+
// and {@link messageResumeToken} is the ONE place that judges it — content
|
|
2667
|
+
// through `nameableRemainder`, length through `withinResumeTokenCeiling`,
|
|
2668
|
+
// and, for a POSITION only, the retired-v1 check
|
|
2669
|
+
// ({@link nameablePeerPosition}; the anchor arm deliberately skips it,
|
|
2670
|
+
// exactly as `parseMessageResume`'s `after:` branch does). The refusal
|
|
2671
|
+
// cases are therefore not enumerated again here.
|
|
2672
|
+
//
|
|
2673
|
+
// The key is OMITTED rather than emitted `undefined`, and its absence only
|
|
2674
|
+
// means something once read together with `truncated`: the two cases have
|
|
2675
|
+
// opposite meanings —
|
|
2676
|
+
//
|
|
2677
|
+
// - a COMPLETE walk (nothing older left): `truncated: false`, no token —
|
|
2678
|
+
// the absence really does say "nothing older left";
|
|
2679
|
+
// - a TRUNCATED walk whose stop point cannot be named: `truncated: true`
|
|
2680
|
+
// plus a `truncationReason`, no token — OLDER MESSAGES REMAIN UNREAD,
|
|
2681
|
+
// and the absence never means they ran out. The remedy follows the
|
|
2682
|
+
// REASON: only a `limit` stop answers to a wider `limit`; a
|
|
2683
|
+
// `safety-bound` stop is the internal circuit breaker, and an
|
|
2684
|
+
// `aggregate-bytes` stop is this answer's own output budget, which a
|
|
2685
|
+
// wider `limit` does not move (more round trips, same budget). A
|
|
2686
|
+
// `deadline` stop is a WAIT face's reason — `cherry_read` carries no
|
|
2687
|
+
// deadline — so it is never this literal's.
|
|
2594
2688
|
...(truncated && resumeToken !== undefined ? { messagesResumeCursor: resumeToken } : {}),
|
|
2595
2689
|
}
|
|
2596
2690
|
}
|
|
@@ -3144,7 +3238,7 @@ class CherryToolEngine {
|
|
|
3144
3238
|
additionalProperties: false,
|
|
3145
3239
|
},
|
|
3146
3240
|
execute: async (args) => {
|
|
3147
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3241
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3148
3242
|
if (resolved.link === undefined) return resolved.verdict
|
|
3149
3243
|
const { record } = resolved.link
|
|
3150
3244
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3262,7 +3356,7 @@ class CherryToolEngine {
|
|
|
3262
3356
|
additionalProperties: false,
|
|
3263
3357
|
},
|
|
3264
3358
|
execute: async (args) => {
|
|
3265
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3359
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3266
3360
|
if (resolved.link === undefined) return resolved.verdict
|
|
3267
3361
|
const { connection, record } = resolved.link
|
|
3268
3362
|
|
|
@@ -3383,7 +3477,7 @@ class CherryToolEngine {
|
|
|
3383
3477
|
additionalProperties: false,
|
|
3384
3478
|
},
|
|
3385
3479
|
execute: async (args) => {
|
|
3386
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3480
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3387
3481
|
if (resolved.link === undefined) return resolved.verdict
|
|
3388
3482
|
const { connection, record } = resolved.link
|
|
3389
3483
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3614,7 +3708,7 @@ class CherryToolEngine {
|
|
|
3614
3708
|
additionalProperties: false,
|
|
3615
3709
|
},
|
|
3616
3710
|
execute: async (args) => {
|
|
3617
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3711
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3618
3712
|
if (resolved.link === undefined) return resolved.verdict
|
|
3619
3713
|
const { connection, record } = resolved.link
|
|
3620
3714
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3753,8 +3847,8 @@ class CherryToolEngine {
|
|
|
3753
3847
|
'pending filter is applied here. Each entry carries its `kind` (question|decision) and a ' +
|
|
3754
3848
|
'summary; answer questions with cherry_respond\'s `response` form and decisions with ' +
|
|
3755
3849
|
'`decision`. A truncated answer (`truncated` + `truncationReason`) stopped before the ' +
|
|
3756
|
-
'peer ran out
|
|
3757
|
-
'continue.',
|
|
3850
|
+
'peer ran out; when the stop point can be named it carries `resumeCursor` — replay it as ' +
|
|
3851
|
+
'`cursor` from the same `sessionId` to continue.',
|
|
3758
3852
|
parameters: {
|
|
3759
3853
|
type: 'object',
|
|
3760
3854
|
properties: {
|
|
@@ -3778,7 +3872,7 @@ class CherryToolEngine {
|
|
|
3778
3872
|
additionalProperties: false,
|
|
3779
3873
|
},
|
|
3780
3874
|
execute: async (args) => {
|
|
3781
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3875
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3782
3876
|
if (resolved.link === undefined) return resolved.verdict
|
|
3783
3877
|
const { connection, record } = resolved.link
|
|
3784
3878
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3888,7 +3982,7 @@ class CherryToolEngine {
|
|
|
3888
3982
|
additionalProperties: false,
|
|
3889
3983
|
},
|
|
3890
3984
|
execute: async (args) => {
|
|
3891
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
3985
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3892
3986
|
if (resolved.link === undefined) return resolved.verdict
|
|
3893
3987
|
const { connection, record } = resolved.link
|
|
3894
3988
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -3994,7 +4088,7 @@ class CherryToolEngine {
|
|
|
3994
4088
|
additionalProperties: false,
|
|
3995
4089
|
},
|
|
3996
4090
|
execute: async (args) => {
|
|
3997
|
-
const resolved = this.#linkOr(requiredString(args, 'device'))
|
|
4091
|
+
const resolved = await this.#linkOr(requiredString(args, 'device'))
|
|
3998
4092
|
if (resolved.link === undefined) return resolved.verdict
|
|
3999
4093
|
const { connection, record } = resolved.link
|
|
4000
4094
|
const sessionId = requiredString(args, 'sessionId')
|
|
@@ -5547,9 +5641,11 @@ class CherryToolEngine {
|
|
|
5547
5641
|
|
|
5548
5642
|
/**
|
|
5549
5643
|
* The timeout result: NOT a failure. Partial content read so far +, whenever a
|
|
5550
|
-
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5551
|
-
* the unread history begins
|
|
5552
|
-
* or
|
|
5644
|
+
* stop point can be named honestly, a `messagesResumeCursor` token marking
|
|
5645
|
+
* where the unread history begins. The token is missing in TWO OPPOSITE cases
|
|
5646
|
+
* — a COMPLETE walk, or a TRUNCATED one whose stop point could not be named —
|
|
5647
|
+
* and `messagesTruncated` is what tells them apart; see
|
|
5648
|
+
* {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5553
5649
|
*
|
|
5554
5650
|
* The spec's own sentence (§3.6, 2026-10-09 design: 「超时不是失败:返回已收到
|
|
5555
5651
|
* 的部分内容 + 当前游标,agent 可续查」) is a statement of that contract in
|
|
@@ -5824,9 +5920,10 @@ class CherryToolEngine {
|
|
|
5824
5920
|
* `messagesResumeCursor`: the read scans from the newest end to the anchor
|
|
5825
5921
|
* and continues strictly after it.
|
|
5826
5922
|
*
|
|
5827
|
-
* The token is
|
|
5828
|
-
*
|
|
5829
|
-
*
|
|
5923
|
+
* The token is missing in TWO OPPOSITE cases — a COMPLETE walk, and a
|
|
5924
|
+
* TRUNCATED one whose stop point could not be named — and `messagesTruncated`
|
|
5925
|
+
* is what tells them apart; absence never means "nothing older left" on its
|
|
5926
|
+
* own. See {@link CherryMessageWalkVerdict.messagesResumeCursor}.
|
|
5830
5927
|
*/
|
|
5831
5928
|
async #readTerminalMessages(
|
|
5832
5929
|
connection: DeviceConnection,
|
|
@@ -6096,28 +6193,111 @@ function parseQuestionInput(input: unknown): string[] | undefined {
|
|
|
6096
6193
|
* invented: a wrong anchor is worse than no anchor, and `truncated` still says
|
|
6097
6194
|
* the walk was cut. Otherwise the whole collected window was consumed, and the
|
|
6098
6195
|
* peer's page position is exactly where the unread history begins
|
|
6099
|
-
* (`cursor:<pos>`)
|
|
6100
|
-
*
|
|
6196
|
+
* (`cursor:<pos>`).
|
|
6197
|
+
*
|
|
6198
|
+
* BOTH arms name a remainder only when the PARSER would accept it
|
|
6199
|
+
* ({@link mangledRemainder}): empty, not equal to its own `.trim()` (which is
|
|
6200
|
+
* leading/trailing whitespace), or carrying a control character anywhere is
|
|
6201
|
+
* refused HERE exactly as `parseMessageResume` refuses it on replay. The
|
|
6202
|
+
* emitter must never mint what the parser refuses — a token this tool will not
|
|
6203
|
+
* take back is worse than none, because the refusal then surfaces at the RESUME
|
|
6204
|
+
* call instead of at the answer that produced it. This is the CONTENT
|
|
6205
|
+
* dimension; {@link withinResumeTokenCeiling} is the sibling LENGTH one. The
|
|
6206
|
+
* `after:` anchor is covered by the same rule, not just the `cursor:` position:
|
|
6207
|
+
* a message id is an `opaqueId` too, and padding is schema-valid, so a padded
|
|
6208
|
+
* id really can reach the anchor.
|
|
6209
|
+
*
|
|
6210
|
+
* EXPORTED FOR ITS OWN TESTS. The LENGTH guard on the `after:` arm cannot be
|
|
6211
|
+
* driven through a tool: an anchor is a message ID, and projecting that message
|
|
6212
|
+
* means asking the peer for `agent.parts.list` with it, whose `messageId` is an
|
|
6213
|
+
* `opaqueId` (256 characters). A peer item carrying an id above the token
|
|
6214
|
+
* ceiling therefore fails its parts walk and is never RETURNED, so no walk
|
|
6215
|
+
* reaches the point of naming it. The length guard on that arm is still
|
|
6216
|
+
* required — the emitter must never mint what the parser refuses, whatever the
|
|
6217
|
+
* peer's cap becomes — and this export is how a unit test pins it
|
|
6218
|
+
* (`tests/unit/resumeTokenEmit.test.ts`). The CONTENT guard on both arms IS
|
|
6219
|
+
* reachable, because `opaqueId` allows padding: `tests/integration/
|
|
6220
|
+
* toolsReadResume.test.ts` ⑭d/⑭e drive it through the tools.
|
|
6101
6221
|
*/
|
|
6102
|
-
function messageResumeToken(
|
|
6222
|
+
export function messageResumeToken(
|
|
6103
6223
|
page: CherryPage<unknown>,
|
|
6104
6224
|
returned: number,
|
|
6105
6225
|
lastReturnedId: string | undefined,
|
|
6106
6226
|
): string | undefined {
|
|
6107
|
-
const anchor = optionalString(lastReturnedId)
|
|
6227
|
+
const anchor = nameableRemainder(optionalString(lastReturnedId))
|
|
6108
6228
|
// TWO forms of "the walk stopped inside what it was given": it returned fewer
|
|
6109
6229
|
// items than it collected, or the PEER overshot the requested page and items
|
|
6110
6230
|
// were left in it ({@link CherryPage.pageOverflow}). Either way the page's own
|
|
6111
6231
|
// position sits past messages this walk never returned, so only the anchor is
|
|
6112
6232
|
// exact.
|
|
6113
6233
|
if (returned < page.items.length || page.pageOverflow === true) {
|
|
6114
|
-
|
|
6234
|
+
// The ANCHOR takes the content predicate only — NOT the v1 detector, and
|
|
6235
|
+
// that is not an oversight: `parseMessageResume`'s `after:` branch runs
|
|
6236
|
+
// `mangledRemainder` and nothing else, so an id-shaped `after:` remainder is
|
|
6237
|
+
// a token it ACCEPTS back. Applying the detector here would be the mirror
|
|
6238
|
+
// image of this issue — refusing to name a token the parser takes.
|
|
6239
|
+
return anchor === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_ANCHOR_PREFIX}${anchor}`)
|
|
6115
6240
|
}
|
|
6116
|
-
//
|
|
6117
|
-
//
|
|
6118
|
-
|
|
6119
|
-
|
|
6120
|
-
|
|
6241
|
+
// A POSITION takes both: the content predicate, and the retired-format
|
|
6242
|
+
// detector its own parser applies before anything else (issue #85).
|
|
6243
|
+
const position = nameablePeerPosition(optionalString(page.nextCursor))
|
|
6244
|
+
return position === undefined ? undefined : withinResumeTokenCeiling(`${RESUME_CURSOR_PREFIX}${position}`)
|
|
6245
|
+
}
|
|
6246
|
+
|
|
6247
|
+
/**
|
|
6248
|
+
* The CONTENT dimension of "a remainder this bridge may name", or `undefined`
|
|
6249
|
+
* when it must not name one.
|
|
6250
|
+
*
|
|
6251
|
+
* The parsers run `mangledRemainder` on every remainder they read, so one that
|
|
6252
|
+
* fails it is a remainder this tool would refuse on replay: naming it hands the
|
|
6253
|
+
* caller a stop point that can never be used, and the refusal then surfaces at
|
|
6254
|
+
* the RESUME call instead of at the answer that produced it (issues #70, #80).
|
|
6255
|
+
* What it rejects, exactly: empty, not equal to its own `.trim()` (leading/
|
|
6256
|
+
* trailing whitespace), or a control character ANYWHERE — an interior plain space
|
|
6257
|
+
* is legal and round-trips.
|
|
6258
|
+
*
|
|
6259
|
+
* This is the parser's own predicate, not a copy of it, so the two sides cannot
|
|
6260
|
+
* drift into two dialects. The sibling dimension is the LENGTH ceiling, judged
|
|
6261
|
+
* on the FINAL token ({@link withinResumeTokenCeiling}).
|
|
6262
|
+
*/
|
|
6263
|
+
function nameableRemainder(value: string | undefined): string | undefined {
|
|
6264
|
+
return value === undefined || mangledRemainder(value) ? undefined : value
|
|
6265
|
+
}
|
|
6266
|
+
|
|
6267
|
+
/**
|
|
6268
|
+
* A remainder this bridge may name AS A PAGE POSITION: {@link nameableRemainder}
|
|
6269
|
+
* plus the one refusal that applies to positions and NOT to anchors.
|
|
6270
|
+
*
|
|
6271
|
+
* `looksLikeV1ListToken` exists because a v1 list token (0.1.6: base64url of
|
|
6272
|
+
* `{v,m,d,h,p}`) lives under `cursor:` and is indistinguishable from a peer
|
|
6273
|
+
* position, so both parsers NAME and refuse it rather than forwarding it to a
|
|
6274
|
+
* desktop that would silently serve its FIRST page. An emitter that wrapped such
|
|
6275
|
+
* a position would mint `cursor:<v1 shape>` and then have its own parser answer
|
|
6276
|
+
* "issued by an older plugin version" about a token this tool had just printed —
|
|
6277
|
+
* the same broken round trip as #76/#80, one detector further out (issue #85).
|
|
6278
|
+
*
|
|
6279
|
+
* The `after:` arm deliberately does NOT use this: `parseMessageResume`'s
|
|
6280
|
+
* `after:` branch never consults the detector, so an `after:` remainder of that
|
|
6281
|
+
* shape is a token it accepts.
|
|
6282
|
+
*/
|
|
6283
|
+
function nameablePeerPosition(value: string | undefined): string | undefined {
|
|
6284
|
+
const remainder = nameableRemainder(value)
|
|
6285
|
+
return remainder === undefined || looksLikeV1ListToken(remainder) ? undefined : remainder
|
|
6286
|
+
}
|
|
6287
|
+
|
|
6288
|
+
/**
|
|
6289
|
+
* The token ceiling, enforced on the way OUT as well as in. A token this tool
|
|
6290
|
+
* refuses to replay is worse than no token at all: `truncated` already says the
|
|
6291
|
+
* walk was cut, and the remedy then follows the reason — a `'limit'` stop
|
|
6292
|
+
* answers to a wider `limit` only where the caller owns that ceiling — so a
|
|
6293
|
+
* stop point whose token would push past
|
|
6294
|
+
* {@link MAX_RESUME_TOKEN_CHARS} is simply not named. Measured on the FINAL
|
|
6295
|
+
* string, never on a reconstruction of its length, so a format change cannot
|
|
6296
|
+
* leave the guard behind. Exactly at the ceiling is accepted — it is a length
|
|
6297
|
+
* the parser takes back.
|
|
6298
|
+
*/
|
|
6299
|
+
function withinResumeTokenCeiling(token: string): string | undefined {
|
|
6300
|
+
return token.length <= MAX_RESUME_TOKEN_CHARS ? token : undefined
|
|
6121
6301
|
}
|
|
6122
6302
|
|
|
6123
6303
|
/**
|
|
@@ -6224,8 +6404,14 @@ function messageIdOf(raw: Record<string, unknown>): string {
|
|
|
6224
6404
|
* `undefined`/`null` means "from the newest message", which is the ordinary
|
|
6225
6405
|
* read. An empty remainder (`'cursor:'`, `'after:'`) is refused like any other
|
|
6226
6406
|
* foreign token: an empty anchor could never match a message.
|
|
6407
|
+
*
|
|
6408
|
+
* EXPORTED FOR ITS OWN TESTS — specifically for the round-trip property, which
|
|
6409
|
+
* is the invariant this whole file is built around: every token the emitters
|
|
6410
|
+
* NAME must be one this function ACCEPTS. Pinning that with a probe table is
|
|
6411
|
+
* what turns "we mirrored every refusal" from a promise into a check
|
|
6412
|
+
* (`tests/unit/resumeTokenEmit.test.ts`, issue #85).
|
|
6227
6413
|
*/
|
|
6228
|
-
function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6414
|
+
export function parseMessageResume(value: unknown): MessageResume | undefined {
|
|
6229
6415
|
if (value === undefined || value === null) return undefined
|
|
6230
6416
|
const refuse = (): never => {
|
|
6231
6417
|
throw new Error(
|
package/ui/index.html
CHANGED
package/ui/src/i18n.ts
CHANGED
|
@@ -57,7 +57,7 @@ export const LOCALE_IDS: readonly LocaleId[] = ['zh', 'en']
|
|
|
57
57
|
*/
|
|
58
58
|
const zh: Dict = {
|
|
59
59
|
// -- app shell ------------------------------------------------------------
|
|
60
|
-
'app.title': 'Cherry
|
|
60
|
+
'app.title': 'Cherry 互联',
|
|
61
61
|
'app.subtitle':
|
|
62
62
|
'通过远程传输把本机 DeepSeek Harness 连接到 Cherry Studio 桌面端;设备档案保存在插件数据目录,页面不接触任何凭据。',
|
|
63
63
|
// The one string that used to reach a Chinese page in English (App.tsx's
|
|
@@ -210,7 +210,7 @@ const zh: Dict = {
|
|
|
210
210
|
*/
|
|
211
211
|
const en: Dict = {
|
|
212
212
|
// -- app shell ------------------------------------------------------------
|
|
213
|
-
'app.title': 'Cherry
|
|
213
|
+
'app.title': 'Cherry Bridge',
|
|
214
214
|
'app.subtitle':
|
|
215
215
|
'Connect this machine’s DeepSeek Harness to a Cherry Studio desktop over the remote transport. Device records live in the plugin data directory; this page never touches a credential.',
|
|
216
216
|
'app.eventSourceUnavailable': 'EventSource is unavailable in this environment',
|