dsh-cherry-bridge 0.1.10 → 0.2.0

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/src/tools.ts CHANGED
@@ -16,6 +16,7 @@ import { truncateErrorMessage } from './diagnostics.js'
16
16
  import type { ReadResult, SessionSubscriptions } from './subscriptions.js'
17
17
  import { PairingError } from './pairing.js'
18
18
  import type { PairingErrorKind } from './pairing.js'
19
+ import { failureText } from './text.js'
19
20
  import type { AgentCursor } from '@cherrystudio/remote-protocol/agent'
20
21
 
21
22
  /**
@@ -291,9 +292,11 @@ export interface CherryReadPart {
291
292
  toolName?: string
292
293
  toolCallId?: string
293
294
  /**
294
- * The DESCRIPTIVE face of a binary attachment (`kind: 'file'`): what the file
295
- * is, never what it holds. `content` stays empty for such a part by design —
296
- * see {@link CherryToolEngine.#attachmentFace}.
295
+ * The DESCRIPTIVE face of an attachment — a `kind: 'file'` part, or the
296
+ * legacy `kind: 'data'` part the desktop labels `name: 'file'` and whose
297
+ * metadata is JSON in `content.text`: what the file is, never what it holds.
298
+ * `content` stays empty for such a part unless its record carries a key this
299
+ * face does not project — see {@link CherryToolEngine.#attachmentFace}.
297
300
  */
298
301
  name?: string
299
302
  mediaType?: string
@@ -604,6 +607,42 @@ export interface CherryCreateResult {
604
607
 
605
608
  /** The protocol's single-page hard cap, used only as the pre-hello ceiling. */
606
609
  const PAGE_ITEMS_FALLBACK = 100
610
+ /**
611
+ * The page this bridge asks for when the peer's hello advertised
612
+ * `agentMessageSummariesVersion: 1` (issue #105).
613
+ *
614
+ * WHY IT IS NOT {@link LIST_PAGE_CLAMP} (16): that clamp is metadata-BLIND. It
615
+ * exists because `AgentMessage` carries `partIds` — up to 4,096 entries of
616
+ * schema-capped ids each — so a count-only peer can answer a page the 65,536-byte
617
+ * record cannot hold and the transport kills the connection (`defaults.ts`).
618
+ * What `partsMode: 'paged'` returns is `AgentMessageSummary`: identity,
619
+ * revision, role, status and optional usage/model/failure, and NO `partIds`.
620
+ *
621
+ * WHY THE PAGE BOUND IS THE PEER'S, NOT OURS: the ONLY verified advertiser of
622
+ * the capability (desktop 2.1.8) budgets the ACCUMULATED page — `pageOf`
623
+ * prefix-truncates at `PAGE_ITEM_BYTES` = 62,279 bytes and `queryPage` re-asks
624
+ * at a halved limit, so its reply records stay under `recordBytes` = 65,536
625
+ * whatever our `limit` says, answering with a SHORT page + `nextCursor` (the
626
+ * shape our walk already tolerates) instead of a killed link. The single-item
627
+ * `RESOURCE_EXHAUSTED {ITEM_TOO_LARGE, retryable: false}` refusal is the
628
+ * exception arm, and a schema-valid summary row cannot reach it: measured
629
+ * against the 0.7.0 schema, the worst row (ids at 256 units, `model` fields at
630
+ * 2,048 chars each, 32 `costs` entries, `failure` refined to ≤4,096 bytes) is
631
+ * ≈37 KB — under the 62,279 budget, so the refusal exists for full
632
+ * `partIds`-bearing rows, not summaries.
633
+ *
634
+ * 100 is the protocol's own `pageItems` cap, i.e. the ceiling a walk can use at
635
+ * all: `#aggregate` still intersects this with the peer's NEGOTIATED
636
+ * `pageItems`, so a peer advertising less keeps its size. Residual exposure —
637
+ * a hypothetical peer advertising the capability WITHOUT a page budget —
638
+ * contains no verified desktop (≤2.1.7 never advertises and keeps the clamp),
639
+ * the same "pragmatic, not provable" class `defaults.ts` already scopes.
640
+ *
641
+ * The PARTS walk keeps {@link PARTS_LIST_PAGE_CLAMP} unconditionally: nothing
642
+ * in protocol 0.7.0 proves `agent.parts.list` is byte-aware, and a part is the
643
+ * fattest item there is.
644
+ */
645
+ const MESSAGE_SUMMARIES_PAGE_ITEMS = PAGE_ITEMS_FALLBACK
607
646
  /** The largest MESSAGE aggregate a caller may ask for; the per-page clamp still applies. */
608
647
  const MAX_AGGREGATE_LIMIT = 1000
609
648
  /**
@@ -749,6 +788,33 @@ interface AggregateWalk<T> {
749
788
  deadline?: AssemblyDeadline
750
789
  }
751
790
 
791
+ /**
792
+ * The `agent.messages.list` request for ONE message walk, and the page size to
793
+ * ask for — the ONE place the paged-summaries dialect is decided (issue #105).
794
+ *
795
+ * THE INVARIANT: `partsMode` is sent ONLY to a peer whose own hello advertised
796
+ * `agentMessageSummariesVersion: 1`. 0.5.0's params schema — what every desktop
797
+ * through 2.1.7 ships — is a STRICT object with no such key, so sending it
798
+ * there is a protocol violation, not a wasted field. Both message-walk faces
799
+ * (`cherry_read` and the terminal/timeout/idle aggregate) come through HERE for
800
+ * the same reason the resume token has one helper: two call sites spelling the
801
+ * dialect would eventually disagree about which peer may hear it.
802
+ *
803
+ * With the capability, the page size is {@link MESSAGE_SUMMARIES_PAGE_ITEMS} —
804
+ * summary rows are metadata, so the byte-safe clamp no longer bounds anything
805
+ * they can carry. Without it, the clamp stays exactly as it was: the
806
+ * count-paged peer still returns `partIds`-bearing rows.
807
+ */
808
+ function messageListRequest(connection: DeviceConnection): {
809
+ params: Record<string, unknown>
810
+ maxPerPage: number
811
+ } {
812
+ if (connection.limits.messageSummaries === 1) {
813
+ return { params: { partsMode: 'paged' }, maxPerPage: MESSAGE_SUMMARIES_PAGE_ITEMS }
814
+ }
815
+ return { params: {}, maxPerPage: LIST_PAGE_CLAMP }
816
+ }
817
+
752
818
  /** Token prefix for a peer page position; the one `#aggregate` can send back. */
753
819
  const RESUME_CURSOR_PREFIX = 'cursor:'
754
820
  /** Token prefix for "strictly older than this message id". */
@@ -862,6 +928,80 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1_048_576
862
928
  */
863
929
  const MESSAGE_CACHE_BYTES_PER_DEVICE = 33_554_432
864
930
 
931
+ /**
932
+ * One command the engine issued under a CALLER-SUPPLIED `commandId`, kept so a
933
+ * retry can replay it by receipt (issues #115, #118).
934
+ *
935
+ * The three fields answer the three questions a retry raises:
936
+ *
937
+ * - `method` — is this id being replayed by the tool that minted it? An id
938
+ * remembered for a send says nothing about a respond retry, and the reverse;
939
+ * - `fingerprint` — did the caller ask for the SAME command? The caller-owned
940
+ * arguments only, compared with {@link sameCommandValue}; the DERIVED body
941
+ * fields are deliberately excluded, because they are exactly what moved;
942
+ * - `body` — what the peer's ledger recorded, reproduced verbatim.
943
+ */
944
+ interface RememberedCommand {
945
+ method: string
946
+ fingerprint: unknown
947
+ body: Record<string, unknown>
948
+ }
949
+
950
+ /**
951
+ * How many command bodies the engine remembers PER DEVICE, keyed by the
952
+ * CALLER-SUPPLIED `commandId` (issues #115, #118).
953
+ *
954
+ * Why a memory exists at all: a command's recovery path is RECEIPT. A reply
955
+ * lost on the wire is answered by re-sending the ORIGINAL body under the
956
+ * original id, and the desktop's ledger replays the recorded outcome. That
957
+ * promise was impossible for `cherry_send`, whose body carries a DERIVED field
958
+ * (`expectedIdleRevision`) that moves with every successful send: the retry
959
+ * quoted a fresh baseline, the body differed from the recorded one, and the peer
960
+ * answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
961
+ * what already landed. So the engine remembers the EXACT body it emitted, per
962
+ * (device, caller id, method), and a same-id retry replays it verbatim. The
963
+ * same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
964
+ * #118): their DERIVED fields were re-derived, and the retry usually met a
965
+ * pre-check (`unknown-interaction`, `no-active-execution`) before the body was
966
+ * ever compared — the memory is consulted before those, which is why it holds
967
+ * the whole issued body rather than a way to rebuild one.
968
+ *
969
+ * What an entry holds is one short body: a session id, the caller's text (at
970
+ * most the protocol's 32,768 characters, pre-validated before it is ever
971
+ * stored), one revision string — or, for respond/cancel, the interaction's
972
+ * staleness triple / the execution to abort, plus the caller-owned arguments
973
+ * the fingerprint compares. 128 is "more than any live retry window needs,
974
+ * small enough to be trivial": a caller retries a handful of command ids around
975
+ * a lost reply, and each is recoverable while it stays in this FIFO.
976
+ * Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
977
+ * a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
978
+ * reaches the peer, which refuses the differing body exactly as it would
979
+ * without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
980
+ * at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
981
+ * `execution-mismatch`) — the pre-fix failure mode, which names real state.
982
+ *
983
+ * PER DEVICE, like every engine cache: the id is the caller's, but the peer
984
+ * that settled it is not, and a second desktop must never be sent a body minted
985
+ * against the first.
986
+ *
987
+ * INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
988
+ * value needs 129 live command ids before it evicts.
989
+ */
990
+ const COMMAND_BODY_ENTRIES_PER_DEVICE = 128
991
+
992
+ /**
993
+ * The three command methods whose bodies the engine remembers (see
994
+ * {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
995
+ *
996
+ * Named once, because they are used twice each by design: as the remembered
997
+ * entry's `method`, and as the method the entry is compared against on lookup.
998
+ * Spelling them at both ends is how a lookup could start matching the wrong
999
+ * tool without any type noticing.
1000
+ */
1001
+ const SEND_METHOD = 'agent.messages.send'
1002
+ const RESPOND_METHOD = 'agent.interactions.respond'
1003
+ const CANCEL_METHOD = 'agent.executions.cancel'
1004
+
865
1005
  /**
866
1006
  * EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
867
1007
  * legally answers ONE empty page with an advancing cursor (the pin→entity
@@ -1219,6 +1359,55 @@ function failureReason(error: unknown): string | undefined {
1219
1359
  return undefined
1220
1360
  }
1221
1361
 
1362
+ /**
1363
+ * The phrase this hint leaves behind, so applying it twice reads once.
1364
+ */
1365
+ const PEER_REFUSAL_FINAL_MARK = 'retrying the same request cannot help'
1366
+
1367
+ /**
1368
+ * The recovery advice a PERMANENT peer refusal grows — `RESOURCE_EXHAUSTED`
1369
+ * carrying `details.retryable === false`, which is the desktop's per-item byte
1370
+ * budget (`PAGE_ITEM_BYTES` = 62,279; `details.kind` is `ITEM_TOO_LARGE` with
1371
+ * `itemBytes` / `budgetBytes`), issue #105.
1372
+ *
1373
+ * WHY THE WARNING HAS TO BE ADDED: the official client marks every
1374
+ * `RESOURCE_EXHAUSTED` RETRYABLE and re-asks at the same page size on an
1375
+ * exponential backoff, so a byte-budget refusal never converges — and the
1376
+ * condition really is permanent: the same item over the same budget will be
1377
+ * over it again. A caller (or a model reading the error) that retries learns
1378
+ * nothing but spends the peer's rate window.
1379
+ *
1380
+ * The sentence is deliberately narrower than the reason: only the
1381
+ * `retryable === false` shape grows it. Every other `RESOURCE_EXHAUSTED` —
1382
+ * the upload window, the subscription cap, a legacy refusal with no `details` —
1383
+ * keeps its own text, because some of those ARE worth a later retry.
1384
+ *
1385
+ * Like {@link withIdempotencyHint} this EXTENDS `message` only: the reason, the
1386
+ * structured `data` and the {@link JsonRpcRequestError} identity stay the
1387
+ * peer's, so branching callers see exactly what it sent.
1388
+ */
1389
+ export function withPeerRefusalHint(error: unknown): unknown {
1390
+ if (!(error instanceof JsonRpcRequestError)) return error
1391
+ if (failureReason(error) !== 'RESOURCE_EXHAUSTED') return error
1392
+ const data = error.data
1393
+ if (!isRecord(data) || !isRecord(data['details']) || data['details']['retryable'] !== false) return error
1394
+ if (error.message.includes(PEER_REFUSAL_FINAL_MARK)) return error
1395
+ const kind = optionalString(data['details']['kind'])
1396
+ // The two permanent shapes need OPPOSITE advice. ITEM_TOO_LARGE means ONE
1397
+ // item alone exceeds the per-item budget: no page size fits it — that item
1398
+ // and everything behind it are unreachable (the upstream #21951 dead-end),
1399
+ // so suggesting a smaller `limit` would burn round trips learning nothing.
1400
+ const cause = kind === 'ITEM_TOO_LARGE' ? 'an ITEM_TOO_LARGE page item over its byte budget' : 'the same item'
1401
+ const tail =
1402
+ kind === 'ITEM_TOO_LARGE'
1403
+ ? 'the item ITSELF exceeds the peer\u2019s per-item budget, so no page size or request shape can fetch it (nor the items behind it — upstream #21951)'
1404
+ : 'ask for FEWER items per page (a smaller `limit`), which is the only change that can fit the budget'
1405
+ error.message =
1406
+ `${error.message} — the peer marked this refusal permanent (\`details.retryable: false\`: ${cause}); ` +
1407
+ `retrying the same request cannot help — ${tail}`
1408
+ return error
1409
+ }
1410
+
1222
1411
  /**
1223
1412
  * One `{ref}`'s content could not be TRUSTED: the bytes served do not match the
1224
1413
  * digest the ref advertises (or there was no digest to check against).
@@ -1266,6 +1455,89 @@ const IDEMPOTENCY_HINT =
1266
1455
  'a retry whose parameters changed — never reuse an id with a different body (re-send the ' +
1267
1456
  'ORIGINAL body to replay the original receipt).'
1268
1457
 
1458
+ /**
1459
+ * The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
1460
+ * exactly this), used when the ENGINE is the one that knows the body differs —
1461
+ * a caller-supplied `commandId` it has already emitted with other arguments
1462
+ * (issue #115).
1463
+ *
1464
+ * The refusal is LOCAL because the outcome is already decided: the peer's ledger
1465
+ * holds the recorded body, so a forwarded body would come back as this same
1466
+ * conflict, one round trip later, at the cost of a wire call that can never
1467
+ * succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
1468
+ * (and the existing conflict tests) match on the prefix.
1469
+ */
1470
+ const COMMAND_BODY_DIFFERS_MESSAGE = 'Command body differs from the recorded command'
1471
+
1472
+ /**
1473
+ * The JSON-RPC error code the transport reserves for protocol failures (the
1474
+ * server answers every peer-refused request with it). Used for the LOCAL
1475
+ * {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
1476
+ * forwarded one would have been: a `JsonRpcRequestError` carrying
1477
+ * `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
1478
+ * request was ever sent.
1479
+ *
1480
+ * A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
1481
+ * `JsonRpcRequestError` docblock: that module exports no code constant, and
1482
+ * adding one only to name this number would widen the published surface for it.
1483
+ */
1484
+ const JSON_RPC_PROTOCOL_ERROR_CODE = 1000
1485
+
1486
+ /**
1487
+ * The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
1488
+ * reason and sentence, wrapped with the same hints a forwarded conflict earns,
1489
+ * and never sent anywhere.
1490
+ *
1491
+ * ONE constructor for every command that remembers its body (send, respond,
1492
+ * cancel): the caller must not have to learn a second vocabulary for one
1493
+ * condition, and a test matches the prefix `Command body differs` whichever
1494
+ * tool produced it.
1495
+ */
1496
+ function commandBodyDiffers(): Error {
1497
+ const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
1498
+ reason: 'IDEMPOTENCY_CONFLICT',
1499
+ message: COMMAND_BODY_DIFFERS_MESSAGE,
1500
+ })
1501
+ // The two hint helpers pass an error through UNCHANGED (they only append to
1502
+ // a conflict's / a rate-limit's prose), so the identity is the object built
1503
+ // above; the return type keeps the `JsonRpcRequestError` the callers branch
1504
+ // on (the engine's command-failure currency).
1505
+ withRateLimitHint(withIdempotencyHint(conflict))
1506
+ return conflict
1507
+ }
1508
+
1509
+ /**
1510
+ * DEEP structural equality, for the caller-owned half of a remembered command
1511
+ * body (issue #118).
1512
+ *
1513
+ * `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
1514
+ * same answers in a different KEY ORDER are sending the SAME answer, and a
1515
+ * plain `!==` (or a JSON string comparison) would refuse the retry as a
1516
+ * different body — turning the receipt recovery into a local conflict on the
1517
+ * exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
1518
+ * reordering it really is a different value.
1519
+ *
1520
+ * Deliberately small: the values compared here are tool arguments decoded from
1521
+ * JSON, so only JSON's two container kinds, the primitives, and `undefined`
1522
+ * (an omitted optional argument) need to be understood.
1523
+ */
1524
+ function sameCommandValue(left: unknown, right: unknown): boolean {
1525
+ if (left === right) return true
1526
+ if (Array.isArray(left) || Array.isArray(right)) {
1527
+ if (!Array.isArray(left) || !Array.isArray(right)) return false
1528
+ if (left.length !== right.length) return false
1529
+ return left.every((value, index) => sameCommandValue(value, right[index]))
1530
+ }
1531
+ if (isRecord(left) && isRecord(right)) {
1532
+ const leftKeys = Object.keys(left)
1533
+ if (leftKeys.length !== Object.keys(right).length) return false
1534
+ return leftKeys.every(
1535
+ (key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]),
1536
+ )
1537
+ }
1538
+ return false
1539
+ }
1540
+
1269
1541
  /**
1270
1542
  * Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
1271
1543
  * gets {@link IDEMPOTENCY_HINT} appended to its message.
@@ -1478,6 +1750,15 @@ export interface CherryToolEngineOptions {
1478
1750
  * which no loopback suite should spend its run on.
1479
1751
  */
1480
1752
  messageCacheBytesPerDevice?: number
1753
+ /**
1754
+ * Cap on the remembered `cherry_send` bodies for one device — issue #115.
1755
+ * Omitted uses {@link COMMAND_BODY_ENTRIES_PER_DEVICE}.
1756
+ *
1757
+ * Injectable so a test can drive the bound to one and observe the FIFO
1758
+ * arithmetic: at the production 128 a proof needs 129 live command ids, which
1759
+ * no loopback suite should spend its run on.
1760
+ */
1761
+ commandBodyEntriesPerDevice?: number
1481
1762
  /**
1482
1763
  * How many `{ref}` contents may be assembled at once across the WHOLE engine
1483
1764
  * — every tool call, every device. Omitted uses
@@ -1488,6 +1769,20 @@ export interface CherryToolEngineOptions {
1488
1769
  * property under test ("no more than the limit at once") is the same at 1.
1489
1770
  */
1490
1771
  contentReadConcurrency?: number
1772
+ /**
1773
+ * The `maxBytes` every `agent.content.read` slice asks for. Omitted uses
1774
+ * {@link CONTENT_READ_MAX_BYTES}, the protocol's `contentBytes`.
1775
+ *
1776
+ * TEST-ONLY, and it exists because the loopback fan-out caps a record at
1777
+ * 32 KiB: at the production 24 KiB no honest ref fits in TWO slices (one
1778
+ * full slice plus the `< 24 KiB` remainder), so a walk's SECOND slice — the
1779
+ * one that proves the ref's digest describes the whole of what is served,
1780
+ * rather than merely its first slice — is unreachable through the fake. A
1781
+ * smaller slice size makes any multi-slice behaviour testable. Never lowered
1782
+ * in production: the peer caps the slice anyway, and asking for less only
1783
+ * costs round trips.
1784
+ */
1785
+ contentReadSliceBytes?: number
1491
1786
  }
1492
1787
 
1493
1788
  /**
@@ -1536,22 +1831,58 @@ export function registerCherryTools(
1536
1831
  deps: CherryToolsDeps,
1537
1832
  options: CherryToolEngineOptions = {},
1538
1833
  ): () => void {
1834
+ return registerCherryToolsWithEngine(ctx, deps, options).dispose
1835
+ }
1836
+
1837
+ /**
1838
+ * The same registration, with the ENGINE it built handed back.
1839
+ *
1840
+ * Exists for TESTS: the engine's memory bookkeeping (the remembered command
1841
+ * bodies, the bounds they obey) is private to it on purpose, and the only
1842
+ * honest way to pin "bounded" and "dropped with the device" is to observe it
1843
+ * through this one seam instead of inferring it from retry outcomes that are
1844
+ * identical for an evicted entry and for one that never existed.
1845
+ *
1846
+ * Both this function and `export class CherryToolEngine` are SHIPPED names
1847
+ * (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
1848
+ * compatibility commitment — which is why this seam is kept minimal.
1849
+ */
1850
+ export function registerCherryToolsWithEngine(
1851
+ ctx: CherryToolsContext,
1852
+ deps: CherryToolsDeps,
1853
+ options: CherryToolEngineOptions = {},
1854
+ ): { dispose: () => void; engine: CherryToolEngine | undefined } {
1539
1855
  const tools = ctx.tools
1540
1856
  if (tools === undefined) {
1541
1857
  // Headless deployments have no tool registry. Degrade to a warning rather
1542
1858
  // than throwing: the assembly's HTTP/UI half must still come up.
1543
1859
  console.warn('[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered')
1544
- return () => {}
1860
+ return { dispose: () => {}, engine: undefined }
1545
1861
  }
1546
1862
 
1547
1863
  const engine = new CherryToolEngine(deps, options)
1548
1864
  const disposers = engine.definitions().map((definition) => tools.register(definition))
1549
- return () => {
1550
- for (const dispose of disposers) dispose()
1865
+ return {
1866
+ engine,
1867
+ dispose: () => {
1868
+ for (const dispose of disposers) dispose()
1869
+ },
1551
1870
  }
1552
1871
  }
1553
1872
 
1554
- /** One tool declaration, in the exact shape the host registry consumes. */
1873
+ /**
1874
+ * One tool declaration, in the exact shape the host registry consumes.
1875
+ *
1876
+ * This helper adds no result WRAPPER and this ticket adds no result-side
1877
+ * truncation (issue #161): a tool result is the MODEL's signal, and the peer is
1878
+ * the user's own Noise-authenticated desktop, so shortening the peer's
1879
+ * explanation costs the caller the text it acts on. The two result-text clamps
1880
+ * that DO exist are per-field product choices, not a log rule — `aggregateError`
1881
+ * (`message.slice(0, 200)` in `#assemblyFailed`) and `truncateErrorMessage`'s
1882
+ * 200-character diagnostics digest used by `projectMessageFailure`. The LOG
1883
+ * face is where boundedness is required (`failureText` / `clipText` in `src/text.ts`);
1884
+ * this ticket's sweep does not change tool output.
1885
+ */
1555
1886
  function defineCherryTool(definition: {
1556
1887
  name: string
1557
1888
  description: string
@@ -1574,7 +1905,7 @@ function defineCherryTool(definition: {
1574
1905
  * share. Kept as one class (rather than per-face closures) so the per-session
1575
1906
  * and per-device state the later faces add has an obvious home.
1576
1907
  */
1577
- class CherryToolEngine {
1908
+ export class CherryToolEngine {
1578
1909
  readonly #deps: CherryToolsDeps
1579
1910
  /**
1580
1911
  * Circuit-breaker for the parts walk. A LOCAL policy value, injectable so a
@@ -1649,6 +1980,12 @@ class CherryToolEngine {
1649
1980
  * being read, shared by every tool call and every device this engine serves.
1650
1981
  */
1651
1982
  readonly #contentGate: ContentReadGate
1983
+ /**
1984
+ * The `maxBytes` one `agent.content.read` slice asks for
1985
+ * ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
1986
+ * reach a walk's second slice, see `CherryToolEngineOptions`).
1987
+ */
1988
+ readonly #contentReadSliceBytes: number
1652
1989
  /**
1653
1990
  * The parts pages already walked, keyed `<deviceId>\0<messageId>`.
1654
1991
  *
@@ -1719,7 +2056,49 @@ class CherryToolEngine {
1719
2056
  * not pinned by bookkeeping.
1720
2057
  */
1721
2058
  readonly #watchedConnections = new WeakSet<DeviceConnection>()
1722
-
2059
+ /**
2060
+ * The EXACT command body emitted for each (device, caller id, method), so a
2061
+ * lost reply can be recovered by receipt (issues #115, #118).
2062
+ *
2063
+ * Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
2064
+ * value is a {@link RememberedCommand}: the METHOD it was minted for, the
2065
+ * caller-owned FINGERPRINT a retry must match, and the BODY the retry
2066
+ * replays verbatim. The fingerprint is what must MATCH — a same-id call whose
2067
+ * arguments differ is not a retry of this command at all, and is refused
2068
+ * locally rather than sent. The body is what must be REPLAYED, derived
2069
+ * fields included: that is the whole point, because those fields are the ones
2070
+ * a retry cannot re-derive the same way.
2071
+ *
2072
+ * Three tools remember: `agent.messages.send` (the baseline it quoted),
2073
+ * `agent.interactions.respond` (the staleness triple) and
2074
+ * `agent.executions.cancel` (the execution it was told to abort). Two of
2075
+ * them run PRE-CHECKS before composing a body — respond's
2076
+ * `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
2077
+ * and after a lost reply those pre-checks are usually the first thing the
2078
+ * retry meets, because the first attempt is what SETTLED the interaction and
2079
+ * ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
2080
+ * a remembered retry is an answer, not a fresh call.
2081
+ *
2082
+ * WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
2083
+ * one-shot identity — nobody can retry it, so an entry for it is pure leak.
2084
+ * The entry is written the moment the command is ISSUED, before the reply is
2085
+ * awaited: a reply lost on the wire must not cost the only copy of the bytes
2086
+ * the peer's ledger will accept.
2087
+ *
2088
+ * Which body an id holds: the LATEST attempt issued for it — the first
2089
+ * attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
2090
+ * same-id retry is therefore answered with the peer's conflict, deliberately:
2091
+ * replaying the ledger's rejected receipt would re-run the healed admission
2092
+ * and duplicate the run, and a conflict is the safer failure.
2093
+ *
2094
+ * Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
2095
+ * identity-mismatch) but KEPT across an ordinary reconnect — see
2096
+ * {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
2097
+ * {@link CherryToolEngine.#forgetDevice}.
2098
+ */
2099
+ readonly #commandBodies = new Map<string, RememberedCommand>()
2100
+ /** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
2101
+ readonly #commandBodyEntriesPerDevice: number
1723
2102
  constructor(deps: CherryToolsDeps, options: CherryToolEngineOptions = {}) {
1724
2103
  this.#deps = deps
1725
2104
  this.#partsSafetyBound = Math.max(
@@ -1743,7 +2122,15 @@ class CherryToolEngine {
1743
2122
  1,
1744
2123
  Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE),
1745
2124
  )
2125
+ this.#commandBodyEntriesPerDevice = Math.max(
2126
+ 1,
2127
+ Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE),
2128
+ )
1746
2129
  this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY)
2130
+ this.#contentReadSliceBytes = Math.max(
2131
+ 1,
2132
+ Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES),
2133
+ )
1747
2134
  }
1748
2135
 
1749
2136
  definitions(): CherryToolDefinition[] {
@@ -2137,7 +2524,12 @@ class CherryToolEngine {
2137
2524
  if (deadline !== undefined && deadlineExhaustion(error)) {
2138
2525
  return this.#truncated(items, 'deadline', cursor)
2139
2526
  }
2140
- throw error
2527
+ // A PERMANENT peer refusal (issue #105) grows its advice HERE, at the
2528
+ // one seam every paged walk's refusals pass through, and ONLY the
2529
+ // `details.retryable: false` shape: a walk has no retry of its own to
2530
+ // reason about, and the caller is the party that must change the
2531
+ // request. The error object, its reason and its `data` are unchanged.
2532
+ throw withPeerRefusalHint(error)
2141
2533
  }
2142
2534
  const extracted = extract(result)
2143
2535
  // THE PAGE'S OWN SHAPE (issue #46), judged on EVERY page — terminal or
@@ -2596,8 +2988,11 @@ class CherryToolEngine {
2596
2988
  'sha256-verified through `agent.content.read`. An unchanged message (same message ' +
2597
2989
  'revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a ' +
2598
2990
  'quiet session costs no `agent.parts.list` RPC; externalized content is still fetched ' +
2599
- 'and sha256-verified on every read, so unverified bytes are never served from memory. A binary attachment is reported as ' +
2600
- '`kind: "file"` with `name`/`mediaType`/`byteLength` and an EMPTY `content` — the bytes ' +
2991
+ 'and sha256-verified on every read, so unverified bytes are never served from memory. An ' +
2992
+ 'attachment is reported as METADATA, never as its bytes: a `kind: "file"` part, or a ' +
2993
+ 'legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as ' +
2994
+ '`name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or ' +
2995
+ 'kept verbatim when that record carries a key this projection does not report. The bytes ' +
2601
2996
  'are deliberately not fetched into a text channel. Messages come NEWEST-first, so the ' +
2602
2997
  'first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts ' +
2603
2998
  'inside a retained message\'s parts; a message whose own parts could not be fully read ' +
@@ -2753,15 +3148,18 @@ class CherryToolEngine {
2753
3148
  throw new Error(`agent.sessions.get returned no historyRevision for session ${sessionId}`)
2754
3149
  }
2755
3150
 
3151
+ // The paged-summaries dialect (issue #105) from the ONE helper both message
3152
+ // faces share: `partsMode` only for a peer whose hello advertised it.
3153
+ const messageList = messageListRequest(connection)
2756
3154
  const page = await this.#aggregate(
2757
3155
  connection,
2758
3156
  'agent.messages.list',
2759
- { sessionId, historyRevision },
3157
+ { sessionId, historyRevision, ...messageList.params },
2760
3158
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
2761
3159
  {
2762
3160
  limit,
2763
3161
  safetyBound: MAX_AGGREGATE_LIMIT,
2764
- maxPerPage: LIST_PAGE_CLAMP,
3162
+ maxPerPage: messageList.maxPerPage,
2765
3163
  resume:
2766
3164
  resume === undefined
2767
3165
  ? undefined
@@ -3053,52 +3451,152 @@ class CherryToolEngine {
3053
3451
  text = ''
3054
3452
  }
3055
3453
 
3454
+ // A LEGACY ATTACHMENT is a `data` part whose metadata is JSON TEXT, not an
3455
+ // object (see {@link CherryToolEngine.#attachmentFace}). The face is lifted
3456
+ // here, from the ALREADY-RESOLVED text — the same text a `{ref}` payload
3457
+ // resolves to — so an externalized metadata record lifts identically.
3458
+ const legacy = this.#legacyAttachment(kind, raw, text)
3056
3459
  return {
3057
3460
  partId,
3058
3461
  kind,
3059
3462
  revision,
3060
- content: text,
3061
- // Pre-2.1.5 peers (2.1.4 is inside the verified range) projected a file
3062
- // as a `data` part whose metadata sits INSIDE `content`; lifting it here
3063
- // is what keeps an attachment from degrading into an anonymous empty
3064
- // part across the peer upgrade.
3065
- ...this.#attachmentFace(kind, raw, content),
3463
+ // THE CONTENT RULE: the JSON text is emptied only when EVERY key of the
3464
+ // record is one the face projects; a record carrying keys we do not
3465
+ // project keeps its raw text, so nothing is silently dropped.
3466
+ content: legacy === undefined ? text : legacy.remainder,
3467
+ ...(legacy === undefined
3468
+ ? this.#attachmentFace(kind, raw, content)
3469
+ : this.#attachmentFace(kind, raw, content, legacy.record)),
3066
3470
  ...(typeof raw['state'] === 'string' ? { state: raw['state'] } : {}),
3067
3471
  ...(typeof raw['toolName'] === 'string' ? { toolName: raw['toolName'] } : {}),
3068
3472
  ...(typeof raw['toolCallId'] === 'string' ? { toolCallId: raw['toolCallId'] } : {}),
3069
3473
  }
3070
3474
  }
3071
3475
 
3476
+ /**
3477
+ * The ONE place a legacy `data` part is recognised as an attachment: the
3478
+ * structural discriminator, the JSON parse, and the lossless CONTENT rule.
3479
+ *
3480
+ * The legal legacy face is `{kind: 'data', name: 'file', content: {text:
3481
+ * '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
3482
+ * `content.text`, observed on the wire (2.1.7 desktop, image upload,
3483
+ * 2026-10-22). An OBJECT inside `content` is not producible by a conforming
3484
+ * peer at all: the pinned protocol 0.7.0 types `content` as a strict
3485
+ * `{text}` | `{ref}` union, so the old documentation described a shape no
3486
+ * peer can emit — and the real one was never lifted, which is how an image
3487
+ * upload reached a model as a JSON blob.
3488
+ *
3489
+ * WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
3490
+ * desktop labels every `data` part with its data KIND in `name` — an
3491
+ * attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
3492
+ * error is `'data-error'` — and the protocol types that field as an
3493
+ * `opaqueId`. This lift therefore REQUIRES the label to be the literal
3494
+ * `'file'` rather than ASSUMING every peer sets one: a `data` part that
3495
+ * carries no name reads as no name, is not lifted, and passes through
3496
+ * untouched. Several of those payloads are JSON too, so "the text parses"
3497
+ * proves nothing; `'file'` is the one label that means "this is an
3498
+ * attachment".
3499
+ *
3500
+ * NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
3501
+ * non-record, or carries neither `mediaType` nor `filename` as a string is
3502
+ * not an attachment, and the part passes through COMPLETELY unchanged
3503
+ * (`remainder` is the raw text, no field is lifted) — this runs on every
3504
+ * `data` part of every read, so a throw here would take down the whole
3505
+ * snapshot.
3506
+ *
3507
+ * THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
3508
+ * this face projects (`mediaType`, `filename`) — the metadata is fully
3509
+ * represented, and a model sees structure rather than a blob. A record
3510
+ * carrying ANY other key keeps its raw text, so a field we do not project is
3511
+ * never silently dropped (lossless by default). That promise is about KEYS: a
3512
+ * key we DO project whose value is not a string (say `mediaType: 123`) is
3513
+ * counted as represented — the key survives — and reports no field, because
3514
+ * this face lifts strings only. Emptied text therefore means "every key was
3515
+ * one of ours", not "every value produced a field".
3516
+ */
3517
+ #legacyAttachment(
3518
+ kind: string,
3519
+ raw: Record<string, unknown>,
3520
+ text: string,
3521
+ ): { record: Record<string, unknown>; remainder: string } | undefined {
3522
+ if (kind !== 'data' || optionalString(raw['name']) !== 'file') return undefined
3523
+ let parsed: unknown
3524
+ try {
3525
+ parsed = JSON.parse(text) as unknown
3526
+ } catch {
3527
+ return undefined
3528
+ }
3529
+ if (!isRecord(parsed)) return undefined
3530
+ // At least ONE projected key must be a string: a record naming neither
3531
+ // `mediaType` nor `filename` says nothing about an attachment, whatever
3532
+ // its name label claims.
3533
+ if (optionalString(parsed['filename']) === undefined && optionalString(parsed['mediaType']) === undefined) {
3534
+ return undefined
3535
+ }
3536
+ const everythingLifted = Object.keys(parsed).every((key) => key === 'mediaType' || key === 'filename')
3537
+ return { record: parsed, remainder: everythingLifted ? '' : text }
3538
+ }
3539
+
3072
3540
  /**
3073
3541
  * The descriptive metadata of an attachment part — and deliberately NOT a
3074
3542
  * way to reach its bytes.
3075
3543
  *
3076
- * Two peer generations are covered, because both are inside the verified
3077
- * range and they disagree about where the metadata lives:
3544
+ * Two legal faces are covered, because both are inside the verified range and
3545
+ * they disagree about where the metadata lives:
3078
3546
  *
3079
3547
  * - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
3080
- * - **Cherry 2.1.4** — `{kind: 'data', name, content: {mediaType, filename}}`.
3548
+ * - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
3549
+ * the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
3550
+ * parses it and passes the record in here.
3551
+ *
3552
+ * A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
3553
+ * inside `content` — is schema-ILLEGAL under the pinned protocol (see the
3554
+ * docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
3555
+ * It is not the 2.1.4 projection: that story was wrong, and a misbehaving
3556
+ * peer's parts are not re-validated client-side, so the guarded arm costs
3557
+ * nothing and covers exactly the peer it was written for.
3081
3558
  *
3082
3559
  * Only string fields are lifted: a null `filename` (the peer's explicit "no
3083
3560
  * name") stays absent rather than becoming the string "null". Anything a
3084
3561
  * caller needs beyond this — the bytes — stays behind `agent.content.read`,
3085
3562
  * which this plugin does not call for a file part.
3563
+ *
3564
+ * WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
3565
+ * attachment's record can arrive externalized as `{ref}`, and that ref is the
3566
+ * peer's serialization ENVELOPE, not the attachment's description: its
3567
+ * `mediaType` is the literal `'text/plain'` the envelope is labelled with and
3568
+ * its `byteLength` is the JSON metadata's own byte count. Letting either
3569
+ * outrank the record would report `text/plain` as an image's media type and a
3570
+ * size that is not the file's — so the record wins the media type, and this
3571
+ * face reports NO `byteLength` at all (a legacy `data` part carries none). The
3572
+ * `file` arm is untouched: there the ref IS the file's own description.
3086
3573
  */
3087
3574
  #attachmentFace(
3088
3575
  kind: string,
3089
3576
  raw: Record<string, unknown>,
3090
3577
  content: Record<string, unknown>,
3578
+ metadata?: Record<string, unknown>,
3091
3579
  ): { name?: string; mediaType?: string; byteLength?: string } {
3092
3580
  if (kind !== 'file' && kind !== 'data') return {}
3093
3581
  const ref = isRecord(raw['ref']) ? raw['ref'] : isRecord(content['ref']) ? content['ref'] : {}
3094
3582
  // WHERE the name lives is part of the shape, not a matter of preference: on
3095
3583
  // a `file` part `name` IS the filename, while a `data` part's `name` is the
3096
- // data KIND label — the 2.1.4 projection passes the literal `'file'` there —
3097
- // so the filename can only come from `content.filename`. Reading `name` for
3098
- // the data kind would report "file" as the attachment's name.
3099
- const name = kind === 'file' ? optionalString(raw['name']) : optionalString(content['filename'])
3100
- const mediaType = optionalString(ref['mediaType']) ?? optionalString(content['mediaType'])
3101
- const byteLength = optionalString(ref['byteLength'])
3584
+ // data KIND label (the attachment's is the literal `'file'`), so a legacy
3585
+ // filename can only come from the metadata record's `filename`. Reading
3586
+ // `name` for the data kind would report "file" as the attachment's name.
3587
+ const name =
3588
+ kind === 'file'
3589
+ ? optionalString(raw['name'])
3590
+ : (optionalString(metadata?.['filename']) ?? optionalString(content['filename']))
3591
+ const mediaType =
3592
+ kind === 'data' && metadata !== undefined
3593
+ ? (optionalString(metadata['mediaType']) ?? optionalString(content['mediaType']))
3594
+ : (optionalString(ref['mediaType']) ??
3595
+ optionalString(metadata?.['mediaType']) ??
3596
+ optionalString(content['mediaType']))
3597
+ // A legacy `data` part carries no `byteLength`: the only ref it can have is
3598
+ // the metadata envelope's, whose length describes the RECORD.
3599
+ const byteLength = kind === 'file' ? optionalString(ref['byteLength']) : undefined
3102
3600
  return {
3103
3601
  ...(name === undefined ? {} : { name }),
3104
3602
  ...(mediaType === undefined ? {} : { mediaType }),
@@ -3209,7 +3707,7 @@ class CherryToolEngine {
3209
3707
  const refusal = this.#externalizedRefusal(ref)
3210
3708
  if (refusal !== undefined) throw refusal
3211
3709
  const maxSlices = Number.isSafeInteger(declaredBytes)
3212
- ? Math.max(1, Math.ceil(declaredBytes / CONTENT_READ_MAX_BYTES))
3710
+ ? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes))
3213
3711
  : DEFAULT_CONTENT_SLICE_BOUND
3214
3712
 
3215
3713
  let offset = '0'
@@ -3238,7 +3736,9 @@ class CherryToolEngine {
3238
3736
  contentId,
3239
3737
  revision,
3240
3738
  offset,
3241
- maxBytes: CONTENT_READ_MAX_BYTES,
3739
+ // The protocol's slice size in production; injectable for tests that
3740
+ // must reach a walk's SECOND slice (see the option's docblock).
3741
+ maxBytes: this.#contentReadSliceBytes,
3242
3742
  },
3243
3743
  deadline === undefined ? undefined : { deadline: deadline.instant },
3244
3744
  )) as Record<string, unknown>
@@ -3458,7 +3958,16 @@ class CherryToolEngine {
3458
3958
  try {
3459
3959
  await subscriptions.ack(sessionId, result.nextCursor)
3460
3960
  } catch (error) {
3461
- console.warn(`[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed`, error)
3961
+ // ONE bounded STRING (issue #161): the ack refusal is the PEER's
3962
+ // JSON-RPC `error.message` verbatim (a CONFLICT from an out-of-window
3963
+ // cursor, a reset race), so the old object print let the console
3964
+ // render peer text with no local bound. Ceiling:
3965
+ // `[cherry-remote] acknowledging session ` (38) + sessionId +
3966
+ // ` at ` (4) + seq + ` failed: ` (9) + LOG_TEXT_MAX (240).
3967
+ console.warn(
3968
+ `[cherry-remote] acknowledging session ${sessionId} at ${result.nextCursor.seq} failed: ` +
3969
+ failureText(error),
3970
+ )
3462
3971
  this.#reEnsureInBackground(record.deviceId, sessionId)
3463
3972
  }
3464
3973
  return { events: result.events, nextCursor: result.nextCursor }
@@ -3774,12 +4283,37 @@ class CherryToolEngine {
3774
4283
  request: { args: Record<string, unknown>; text: string },
3775
4284
  ): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> {
3776
4285
  const { args, text } = request
4286
+ const callerId = optionalString(args['commandId'])
3777
4287
  const attempt = async (
3778
4288
  commandId: string,
3779
4289
  revisionOverride?: string,
3780
4290
  ): Promise<{ receipt: Record<string, unknown>; baselineSeq: number }> => {
4291
+ // The memory belongs to the id it was MINTED for, so the lookup is gated
4292
+ // on `commandId === callerId`: the self-heal's fresh-uuid attempt derives
4293
+ // its own baseline instead of inheriting the caller's.
4294
+ const remembered =
4295
+ callerId === undefined || commandId !== callerId
4296
+ ? undefined
4297
+ : this.#recallCommandBody(deviceId, callerId)
4298
+ // A remembered id that belongs to ANOTHER method is a mismatch, not a
4299
+ // miss: refusing locally keeps the decision off the wire.
4300
+ if (remembered !== undefined && remembered.method !== SEND_METHOD) throw commandBodyDiffers()
4301
+ // A same-id call whose caller-owned arguments differ from the remembered
4302
+ // body is not a retry of that command: the peer holds the original body,
4303
+ // so the conflict is already decided and is answered LOCALLY, in the
4304
+ // peer's own words, with no wire call.
4305
+ const rememberedBody = remembered?.body
4306
+ const mismatched =
4307
+ remembered !== undefined &&
4308
+ (rememberedBody?.['sessionId'] !== sessionId || rememberedBody['text'] !== text)
4309
+ if (mismatched) throw commandBodyDiffers()
4310
+ // THE REMEMBERED BODY IS REPLAYED, derived field included. Rebuilding it
4311
+ // from a derived `expectedIdleRevision` instead is what #115's memory
4312
+ // exists to stop, so the value is read back rather than recomputed.
3781
4313
  const expectedIdleRevision =
3782
- revisionOverride ?? (await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
4314
+ optionalString(rememberedBody?.['expectedIdleRevision']) ??
4315
+ revisionOverride ??
4316
+ (await this.#idleRevision(connection, subscriptions, deviceId, sessionId))
3783
4317
  // THE BASELINE IS TAKEN BEFORE THE COMMAND IS ISSUED, and that ordering
3784
4318
  // is what makes a fast run observable at all. The waiter's baseline is
3785
4319
  // the cut that decides which events belong to THIS run; taking it after
@@ -3791,7 +4325,16 @@ class CherryToolEngine {
3791
4325
  // Nothing before this point can emit this run's events: the command has
3792
4326
  // not been sent yet.
3793
4327
  const baselineSeq = this.#newestSeq(subscriptions, sessionId)
3794
- const receipt = await this.#command(connection, 'agent.messages.send', {
4328
+ // The body is remembered the moment it is ISSUED — a reply lost on the wire
4329
+ // must not cost the only copy of the bytes the peer's ledger will accept.
4330
+ if (callerId !== undefined) {
4331
+ this.#rememberCommandBody(deviceId, callerId, {
4332
+ method: SEND_METHOD,
4333
+ fingerprint: { sessionId, text },
4334
+ body: { commandId, sessionId, text, expectedIdleRevision },
4335
+ })
4336
+ }
4337
+ const receipt = await this.#command(connection, SEND_METHOD, {
3795
4338
  commandId,
3796
4339
  sessionId,
3797
4340
  text,
@@ -4187,6 +4730,26 @@ class CherryToolEngine {
4187
4730
  }
4188
4731
  }
4189
4732
 
4733
+ const callerId = optionalString(args['commandId'])
4734
+ const answer: Record<string, unknown> = response === undefined ? { decision } : { response }
4735
+ // A RETRY IS ANSWERED BEFORE ANYTHING IS DERIVED. The staleness triple
4736
+ // below is derived from what the plugin has observed, and after a lost
4737
+ // reply that observation has MOVED (the first answer settled the card)
4738
+ // or vanished (the peer no longer serves it). Re-deriving would either
4739
+ // quote a body the ledger never recorded or — the usual case — stop at
4740
+ // the `unknown-interaction` verdict below, before the body is ever
4741
+ // compared. So a caller-supplied id is consulted FIRST: same id, same
4742
+ // caller-owned arguments and same method replays the remembered body
4743
+ // verbatim, and the snapshot is not asked at all.
4744
+ const remembered =
4745
+ callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
4746
+ if (remembered !== undefined && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers()
4747
+ if (remembered !== undefined) {
4748
+ const fingerprint = { sessionId, interactionId, ...answer }
4749
+ if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers()
4750
+ return this.#command(connection, RESPOND_METHOD, remembered.body)
4751
+ }
4752
+
4190
4753
  await this.#ensureSubscription(record.deviceId, sessionId)
4191
4754
  const subscriptions = this.#deps.subs(record.deviceId)
4192
4755
 
@@ -4209,15 +4772,27 @@ class CherryToolEngine {
4209
4772
  } satisfies Record<string, unknown>
4210
4773
  }
4211
4774
 
4212
- return this.#command(connection, 'agent.interactions.respond', {
4213
- commandId: this.#commandId(args),
4775
+ const commandId = this.#commandId(args)
4776
+ const body = {
4777
+ commandId,
4214
4778
  sessionId,
4215
4779
  interactionId,
4216
4780
  expectedRevision: snapshot.revision,
4217
4781
  expectedExecutionId: snapshot.executionId,
4218
4782
  inputDigest: snapshot.inputDigest,
4219
- ...(response === undefined ? { decision } : { response }),
4220
- })
4783
+ ...answer,
4784
+ }
4785
+ // ISSUE-TIME WRITE: the body is fully composed here, so it is remembered
4786
+ // before the reply is awaited — a reply lost on the wire must not cost
4787
+ // the only copy of the bytes the peer's ledger will accept.
4788
+ if (callerId !== undefined) {
4789
+ this.#rememberCommandBody(record.deviceId, callerId, {
4790
+ method: RESPOND_METHOD,
4791
+ fingerprint: { sessionId, interactionId, ...answer },
4792
+ body,
4793
+ })
4794
+ }
4795
+ return this.#command(connection, RESPOND_METHOD, body)
4221
4796
  },
4222
4797
  })
4223
4798
  }
@@ -4259,6 +4834,27 @@ class CherryToolEngine {
4259
4834
  if (resolved.link === undefined) return resolved.verdict
4260
4835
  const { connection, record } = resolved.link
4261
4836
  const sessionId = requiredString(args, 'sessionId')
4837
+ const callerId = optionalString(args['commandId'])
4838
+ // The caller's own value is a PRE-CHECK, not a body field — but it is
4839
+ // part of what the caller ASKED FOR, so it belongs in the fingerprint: a
4840
+ // retry asserting a different execution is not a retry of this cancel.
4841
+ const supplied = optionalString(args['expectedExecutionId'])
4842
+
4843
+ // A RETRY IS ANSWERED BEFORE THE PRE-CHECKS — the same ordering (and
4844
+ // the same reason) as `agent.interactions.respond`'s consult above;
4845
+ // what differs here is only which verdicts would otherwise eat the
4846
+ // retry: `no-active-execution` and `execution-mismatch` describe the
4847
+ // state RIGHT NOW, and a lost reply left the run the first attempt
4848
+ // cancelled already finished.
4849
+ const remembered =
4850
+ callerId === undefined ? undefined : this.#recallCommandBody(record.deviceId, callerId)
4851
+ if (remembered !== undefined && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers()
4852
+ if (remembered !== undefined) {
4853
+ if (!sameCommandValue(remembered.fingerprint, { sessionId, expectedExecutionId: supplied })) {
4854
+ throw commandBodyDiffers()
4855
+ }
4856
+ return this.#command(connection, CANCEL_METHOD, remembered.body)
4857
+ }
4262
4858
 
4263
4859
  await this.#ensureSubscription(record.deviceId, sessionId)
4264
4860
  const subscriptions = this.#deps.subs(record.deviceId)
@@ -4274,7 +4870,6 @@ class CherryToolEngine {
4274
4870
  message: `session ${sessionId} has no in-flight execution to cancel`,
4275
4871
  } satisfies Record<string, unknown>
4276
4872
  }
4277
- const supplied = optionalString(args['expectedExecutionId'])
4278
4873
  if (supplied !== undefined && supplied !== expectedExecutionId) {
4279
4874
  return {
4280
4875
  error: 'execution-mismatch',
@@ -4286,11 +4881,18 @@ class CherryToolEngine {
4286
4881
  } satisfies Record<string, unknown>
4287
4882
  }
4288
4883
 
4289
- return this.#command(connection, 'agent.executions.cancel', {
4290
- commandId: this.#commandId(args),
4291
- sessionId,
4292
- expectedExecutionId,
4293
- })
4884
+ const commandId = this.#commandId(args)
4885
+ const body = { commandId, sessionId, expectedExecutionId }
4886
+ // ISSUE-TIME WRITE, as respond's: the derived execution id is exactly
4887
+ // the field a retry cannot re-derive once the run has ended.
4888
+ if (callerId !== undefined) {
4889
+ this.#rememberCommandBody(record.deviceId, callerId, {
4890
+ method: CANCEL_METHOD,
4891
+ fingerprint: { sessionId, expectedExecutionId: supplied },
4892
+ body,
4893
+ })
4894
+ }
4895
+ return this.#command(connection, CANCEL_METHOD, body)
4294
4896
  },
4295
4897
  })
4296
4898
  }
@@ -4451,7 +5053,7 @@ class CherryToolEngine {
4451
5053
  // pairing's idle revisions and execution ids, and would grow the maps
4452
5054
  // for the lifetime of the process. The store record is untouched by
4453
5055
  // this — the caches are pure derived state and can always be rebuilt.
4454
- this.#forgetDevice(record.deviceId)
5056
+ this.#forgetDevice(record.deviceId, { dropCommandBodies: true })
4455
5057
  await this.#deps.evict(record.deviceId)
4456
5058
  // The remove is GUARDED, not merely ordered. `#selectRecords` already
4457
5059
  // rejects a quarantined store before a record can be named, so reaching
@@ -4812,7 +5414,8 @@ class CherryToolEngine {
4812
5414
  * the stored credential no longer describes a valid pairing. A transient
4813
5415
  * network drop ALSO lands here and clears the caches unnecessarily; that
4814
5416
  * is accepted, because the cost is one round trip per session on reuse and
4815
- * the alternative is keeping snapshots across an identity change.
5417
+ * the alternative is keeping snapshots across an identity change. (The
5418
+ * remembered command BODIES are the one exception: see `#forgetDevice`.)
4816
5419
  * - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
4817
5420
  *
4818
5421
  * {@link CherryToolEngine.#resolve} installs the watch on every tool call that
@@ -4840,7 +5443,8 @@ class CherryToolEngine {
4840
5443
  this.#watchedConnections.add(connection)
4841
5444
  watch(deviceId, (state) => {
4842
5445
  if (state !== 'offline' && state !== 'identity-mismatch') return
4843
- this.#forgetDevice(deviceId)
5446
+ // A plain `offline` keeps the remembered command bodies: see `#forgetDevice`.
5447
+ this.#forgetDevice(deviceId, { dropCommandBodies: state === 'identity-mismatch' })
4844
5448
  })
4845
5449
  }
4846
5450
 
@@ -4853,8 +5457,16 @@ class CherryToolEngine {
4853
5457
  * leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
4854
5458
  * the previous pairing's snapshots and a long-lived process does not
4855
5459
  * accumulate entries for devices that no longer exist.
5460
+ *
5461
+ * The remembered command bodies obey a NARROWER seam than the read caches:
5462
+ * `dropCommandBodies` is passed by the callers that invalidate the PAIRING
5463
+ * (unpair, identity-mismatch), never by an ordinary `offline`. What a body
5464
+ * replays against — the peer's command ledger — survives a reconnect, and the
5465
+ * body is the only local copy of the exact bytes that ledger will accept, so
5466
+ * a link drop that emptied it would cost the recovery itself, where the read
5467
+ * caches merely pay one re-walk.
4856
5468
  */
4857
- #forgetDevice(deviceId: string): void {
5469
+ #forgetDevice(deviceId: string, options: { dropCommandBodies?: boolean } = {}): void {
4858
5470
  const prefix = `${deviceId}\u0000`
4859
5471
  for (const key of [...this.#revisionCache.keys()]) {
4860
5472
  if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key)
@@ -4875,6 +5487,116 @@ class CherryToolEngine {
4875
5487
  for (const key of [...this.#messageCache.keys()]) {
4876
5488
  if (key.startsWith(prefix)) this.#dropCachedMessage(key)
4877
5489
  }
5490
+ // The remembered command bodies (issues #115/#118) differ from every cache above:
5491
+ // what they replay against is the peer's command LEDGER, and that survives
5492
+ // an ordinary reconnect — so they are dropped only when the PAIRING is
5493
+ // invalidated (`dropCommandBodies`: unpair, identity-mismatch), because a
5494
+ // body minted against one pairing must not be replayed to whatever desktop
5495
+ // answers under this device id next.
5496
+ //
5497
+ // The residual, stated rather than closed: GRANT_REVOKED also surfaces as
5498
+ // `offline`, so a voided pairing's entries survive until the re-pair path
5499
+ // (unpair / identity-mismatch) or the 128-entry FIFO retires them; a same-id
5500
+ // retry across a silent re-pair replays a body the new peer admits as a NEW
5501
+ // command — and the caller asked for that send.
5502
+ if (options.dropCommandBodies === true) {
5503
+ for (const key of [...this.#commandBodies.keys()]) {
5504
+ if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key)
5505
+ }
5506
+ }
5507
+ }
5508
+
5509
+ /**
5510
+ * The command remembered for one caller-supplied `commandId`, keyed
5511
+ * `<deviceId>\0<commandId>` — or `undefined` when this device has never
5512
+ * emitted one (a first attempt, or an entry the FIFO evicted).
5513
+ *
5514
+ * The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
5515
+ * a miss:
5516
+ *
5517
+ * - no entry → a fresh call;
5518
+ * - an entry under the caller's own method → match the fingerprint, then
5519
+ * replay or refuse;
5520
+ * - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
5521
+ * minted for `agent.messages.send` is not a respond retry, and collapsing
5522
+ * that case into "no entry" would send a respond under a send's id and
5523
+ * let the PEER answer the conflict one round trip later.
5524
+ */
5525
+ #recallCommandBody(deviceId: string, commandId: string): RememberedCommand | undefined {
5526
+ return this.#commandBodies.get(this.#cacheKey(deviceId, commandId))
5527
+ }
5528
+
5529
+ /**
5530
+ * TEST SEAM — the remembered bodies, as one `commandId` per device.
5531
+ *
5532
+ * The memory itself is PRIVATE, and stays that way for callers. What a test
5533
+ * needs to assert is its bookkeeping — that the bound evicts the oldest entry
5534
+ * and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
5535
+ * and neither can be observed through a real retry: a retry of an evicted id
5536
+ * is indistinguishable from a retry of an absent one, because both are refused
5537
+ * locally by the same rule. Keys are rendered WITHOUT the device component so
5538
+ * a test reads ids, not cache internals.
5539
+ */
5540
+ rememberedCommandIds(deviceId: string): string[] {
5541
+ const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`
5542
+ return [...this.#commandBodies.keys()]
5543
+ .filter((key) => key.startsWith(prefix))
5544
+ .map((key) => key.slice(prefix.length))
5545
+ }
5546
+
5547
+ /**
5548
+ * TEST SEAM — fill the memory the way a long-lived process would: one entry
5549
+ * per id, WITHOUT spending a wire call on each. The bound and the FIFO order
5550
+ * are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
5551
+ * is what makes this a test of the real policy rather than of a mock of it.
5552
+ */
5553
+ rememberCommandBodyForTest(deviceId: string, commandId: string): void {
5554
+ this.#rememberCommandBody(deviceId, commandId, {
5555
+ method: 'agent.messages.send',
5556
+ fingerprint: { sessionId: 'session-for-test', text: 'text-for-test' },
5557
+ body: {
5558
+ commandId,
5559
+ sessionId: 'session-for-test',
5560
+ text: 'text-for-test',
5561
+ expectedIdleRevision: '0',
5562
+ },
5563
+ })
5564
+ }
5565
+
5566
+ /**
5567
+ * Remembers the exact body one caller-supplied `commandId` was ISSUED with,
5568
+ * evicting this device's oldest entry when it is at the bound.
5569
+ *
5570
+ * FIFO, per device: `Map` insertion order IS the policy, the same deliberate
5571
+ * choice the session caches document, and a `set` on an EXISTING key keeps its
5572
+ * position — a refresh is a rewrite, not a use, so the policy stays
5573
+ * insert-order rather than becoming LRU. One line per eviction, naming the
5574
+ * device and the bound only.
5575
+ */
5576
+ #rememberCommandBody(
5577
+ deviceId: string,
5578
+ commandId: string,
5579
+ remembered: RememberedCommand,
5580
+ ): void {
5581
+ const key = this.#cacheKey(deviceId, commandId)
5582
+ if (!this.#commandBodies.has(key)) {
5583
+ let own = 0
5584
+ let oldest: string | undefined
5585
+ for (const candidate of this.#commandBodies.keys()) {
5586
+ if (!candidate.startsWith(`${deviceId}\u0000`)) continue
5587
+ own += 1
5588
+ if (oldest === undefined) oldest = candidate
5589
+ }
5590
+ if (own >= this.#commandBodyEntriesPerDevice && oldest !== undefined) {
5591
+ this.#commandBodies.delete(oldest)
5592
+ console.warn(
5593
+ `[cherry-remote] remembered command bodies for device ${deviceId} reached their ` +
5594
+ `${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of ` +
5595
+ 'that commandId now re-derives its body, and the peer decides whether it replays)',
5596
+ )
5597
+ }
5598
+ }
5599
+ this.#commandBodies.set(key, remembered)
4878
5600
  }
4879
5601
 
4880
5602
  /**
@@ -5997,15 +6719,25 @@ class CherryToolEngine {
5997
6719
  * identity object: the verdict is already known and true, and only the
5998
6720
  * content behind it failed.
5999
6721
  *
6000
- * `aggregateError` is BOUNDED like every other peer-echoed string in a result
6001
- * (the full text goes to the log): the reason is actionable at 200 characters
6002
- * and the log is where the operator reads the rest.
6722
+ * `aggregateError` is a TOOL RESULT, not a log line — see
6723
+ * {@link defineCherryTool} for the product decision (issue #161): a result is
6724
+ * the MODEL's signal, so it is not bounded by the logging rule, and the
6725
+ * 200-character clip here is this field's own choice.
6726
+ *
6727
+ * The LOG line IS the logging face, and it renders the failure as ONE bounded
6728
+ * string through {@link failureText} (issue #161). An assembly failure is
6729
+ * usually a peer RPC refusal — its `error.message` is the peer's own text,
6730
+ * capped at 512 characters by the failure schema — and the old line
6731
+ * interpolated it whole: one 512-char refusal made a 654-character line for
6732
+ * the `timeout` / `s-1` fixture (83 fixed prefix + 512 + 59 suffix). The
6733
+ * RESULT keeps the raw message's first 200 characters; a bound on the log
6734
+ * does not change what the model receives.
6003
6735
  */
6004
6736
  #assemblyFailed(identity: Record<string, unknown>, error: unknown): Record<string, unknown> {
6005
6737
  const message = error instanceof Error ? error.message : String(error)
6006
6738
  console.warn(
6007
6739
  `[cherry-remote] the ${String(identity['status'])} answer for session ${String(identity['sessionId'])} ` +
6008
- `could not assemble its content (${message}); returning the verdict with the assembly failure reported`,
6740
+ `could not assemble its content (${failureText(error)}); returning the verdict with the assembly failure reported`,
6009
6741
  )
6010
6742
  return {
6011
6743
  ...identity,
@@ -6101,12 +6833,14 @@ class CherryToolEngine {
6101
6833
  const historyRevision = optionalString(session['historyRevision'])
6102
6834
  if (historyRevision === undefined) return { messages: [] }
6103
6835
 
6836
+ // The SAME dialect `cherry_read` sends, from the same helper (issue #105).
6837
+ const messageList = messageListRequest(connection)
6104
6838
  const page = await this.#aggregate(
6105
6839
  connection,
6106
6840
  'agent.messages.list',
6107
- { sessionId, historyRevision },
6841
+ { sessionId, historyRevision, ...messageList.params },
6108
6842
  (result) => ({ ...arrayOf(result['items']), nextCursor: result['nextCursor'] }),
6109
- { limit: TERMINAL_AGGREGATE_MESSAGES, deadline },
6843
+ { limit: TERMINAL_AGGREGATE_MESSAGES, deadline, maxPerPage: messageList.maxPerPage },
6110
6844
  )
6111
6845
 
6112
6846
  const messages: CherryReadMessage[] = []
@@ -6181,11 +6915,24 @@ class CherryToolEngine {
6181
6915
  try {
6182
6916
  layer = this.#deps.subs(deviceId)
6183
6917
  } catch (error) {
6184
- console.warn(`[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}`, error)
6918
+ // ONE bounded STRING (issue #161). This accessor's throw is LOCAL (no
6919
+ // record for the device, or the identity never loaded), but the arm is
6920
+ // converted with its sibling so the background re-ensure has ONE failure
6921
+ // shape and no branch can hand the console an unbounded object.
6922
+ console.warn(
6923
+ `[cherry-remote] could not re-ensure session ${sessionId} of ${deviceId}: ${failureText(error)}`,
6924
+ )
6185
6925
  return
6186
6926
  }
6187
6927
  void layer.ensure(sessionId).catch((error: unknown) => {
6188
- console.warn(`[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed`, error)
6928
+ // ONE bounded STRING (issue #161): `ensure` reaches the peer, so the
6929
+ // rejection message is the peer's JSON-RPC `error.message` verbatim (up
6930
+ // to the 512-char failure schema cap) or a transport message. The old
6931
+ // form passed the error OBJECT, so the console printed its stack plus
6932
+ // every own property unbounded.
6933
+ console.warn(
6934
+ `[cherry-remote] re-ensuring session ${sessionId} of ${deviceId} failed: ${failureText(error)}`,
6935
+ )
6189
6936
  })
6190
6937
  }
6191
6938