dsh-cherry-bridge 0.1.10 → 0.1.11
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 +3 -3
- package/README.md +3 -3
- package/dist/index.mjs +458 -27
- package/docs/e2e-checklist.md +7 -0
- package/package.json +1 -1
- package/src/tools.ts +623 -43
- package/static/settings-devices.jpg +0 -0
- package/static/settings-pairing-scope.jpg +0 -0
package/dist/index.mjs
CHANGED
|
@@ -4881,6 +4881,59 @@ const MESSAGE_CACHE_ENTRY_BYTES = 1048576;
|
|
|
4881
4881
|
*/
|
|
4882
4882
|
const MESSAGE_CACHE_BYTES_PER_DEVICE = 33554432;
|
|
4883
4883
|
/**
|
|
4884
|
+
* How many command bodies the engine remembers PER DEVICE, keyed by the
|
|
4885
|
+
* CALLER-SUPPLIED `commandId` (issues #115, #118).
|
|
4886
|
+
*
|
|
4887
|
+
* Why a memory exists at all: a command's recovery path is RECEIPT. A reply
|
|
4888
|
+
* lost on the wire is answered by re-sending the ORIGINAL body under the
|
|
4889
|
+
* original id, and the desktop's ledger replays the recorded outcome. That
|
|
4890
|
+
* promise was impossible for `cherry_send`, whose body carries a DERIVED field
|
|
4891
|
+
* (`expectedIdleRevision`) that moves with every successful send: the retry
|
|
4892
|
+
* quoted a fresh baseline, the body differed from the recorded one, and the peer
|
|
4893
|
+
* answered IDEMPOTENCY_CONFLICT — on the one path where the caller cannot tell
|
|
4894
|
+
* what already landed. So the engine remembers the EXACT body it emitted, per
|
|
4895
|
+
* (device, caller id, method), and a same-id retry replays it verbatim. The
|
|
4896
|
+
* same defect had a harder shape in `cherry_respond` / `cherry_cancel` (issue
|
|
4897
|
+
* #118): their DERIVED fields were re-derived, and the retry usually met a
|
|
4898
|
+
* pre-check (`unknown-interaction`, `no-active-execution`) before the body was
|
|
4899
|
+
* ever compared — the memory is consulted before those, which is why it holds
|
|
4900
|
+
* the whole issued body rather than a way to rebuild one.
|
|
4901
|
+
*
|
|
4902
|
+
* What an entry holds is one short body: a session id, the caller's text (at
|
|
4903
|
+
* most the protocol's 32,768 characters, pre-validated before it is ever
|
|
4904
|
+
* stored), one revision string — or, for respond/cancel, the interaction's
|
|
4905
|
+
* staleness triple / the execution to abort, plus the caller-owned arguments
|
|
4906
|
+
* the fingerprint compares. 128 is "more than any live retry window needs,
|
|
4907
|
+
* small enough to be trivial": a caller retries a handful of command ids around
|
|
4908
|
+
* a lost reply, and each is recoverable while it stays in this FIFO.
|
|
4909
|
+
* Eviction costs a REQUEST, never correctness — an evicted id has NO entry, so
|
|
4910
|
+
* a same-id retry re-derives a fresh baseline. For a SEND the re-derived body
|
|
4911
|
+
* reaches the peer, which refuses the differing body exactly as it would
|
|
4912
|
+
* without this memory; for a RESPOND or CANCEL the retry usually dies earlier,
|
|
4913
|
+
* at the LOCAL pre-checks (`unknown-interaction`, `no-active-execution`,
|
|
4914
|
+
* `execution-mismatch`) — the pre-fix failure mode, which names real state.
|
|
4915
|
+
*
|
|
4916
|
+
* PER DEVICE, like every engine cache: the id is the caller's, but the peer
|
|
4917
|
+
* that settled it is not, and a second desktop must never be sent a body minted
|
|
4918
|
+
* against the first.
|
|
4919
|
+
*
|
|
4920
|
+
* INJECTABLE for tests (`commandBodyEntriesPerDevice`), because the production
|
|
4921
|
+
* value needs 129 live command ids before it evicts.
|
|
4922
|
+
*/
|
|
4923
|
+
const COMMAND_BODY_ENTRIES_PER_DEVICE = 128;
|
|
4924
|
+
/**
|
|
4925
|
+
* The three command methods whose bodies the engine remembers (see
|
|
4926
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE}).
|
|
4927
|
+
*
|
|
4928
|
+
* Named once, because they are used twice each by design: as the remembered
|
|
4929
|
+
* entry's `method`, and as the method the entry is compared against on lookup.
|
|
4930
|
+
* Spelling them at both ends is how a lookup could start matching the wrong
|
|
4931
|
+
* tool without any type noticing.
|
|
4932
|
+
*/
|
|
4933
|
+
const SEND_METHOD = "agent.messages.send";
|
|
4934
|
+
const RESPOND_METHOD = "agent.interactions.respond";
|
|
4935
|
+
const CANCEL_METHOD = "agent.executions.cancel";
|
|
4936
|
+
/**
|
|
4884
4937
|
* EMPTY-PAGE RUN limit for every aggregate walk. The desktop's pinned-list walk
|
|
4885
4938
|
* legally answers ONE empty page with an advancing cursor (the pin→entity
|
|
4886
4939
|
* section transition, `{items: [], nextCursor: 'entity:'}`), so a single empty
|
|
@@ -5093,6 +5146,79 @@ function unavailableReason(error) {
|
|
|
5093
5146
|
*/
|
|
5094
5147
|
const IDEMPOTENCY_HINT = "the peer already recorded this commandId with a different body; regenerate the commandId for a retry whose parameters changed — never reuse an id with a different body (re-send the ORIGINAL body to replay the original receipt).";
|
|
5095
5148
|
/**
|
|
5149
|
+
* The peer's own wording for `IDEMPOTENCY_CONFLICT` (its `agentHandlers` throws
|
|
5150
|
+
* exactly this), used when the ENGINE is the one that knows the body differs —
|
|
5151
|
+
* a caller-supplied `commandId` it has already emitted with other arguments
|
|
5152
|
+
* (issue #115).
|
|
5153
|
+
*
|
|
5154
|
+
* The refusal is LOCAL because the outcome is already decided: the peer's ledger
|
|
5155
|
+
* holds the recorded body, so a forwarded body would come back as this same
|
|
5156
|
+
* conflict, one round trip later, at the cost of a wire call that can never
|
|
5157
|
+
* succeed. Reusing the sentence keeps ONE vocabulary for one condition; callers
|
|
5158
|
+
* (and the existing conflict tests) match on the prefix.
|
|
5159
|
+
*/
|
|
5160
|
+
const COMMAND_BODY_DIFFERS_MESSAGE = "Command body differs from the recorded command";
|
|
5161
|
+
/**
|
|
5162
|
+
* The JSON-RPC error code the transport reserves for protocol failures (the
|
|
5163
|
+
* server answers every peer-refused request with it). Used for the LOCAL
|
|
5164
|
+
* {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal so it is the same currency a
|
|
5165
|
+
* forwarded one would have been: a `JsonRpcRequestError` carrying
|
|
5166
|
+
* `{reason: 'IDEMPOTENCY_CONFLICT'}`, distinguishable only by the fact that no
|
|
5167
|
+
* request was ever sent.
|
|
5168
|
+
*
|
|
5169
|
+
* A LOCAL twin of the literal `src/rpc/jsonRpcClient.ts` documents in its own
|
|
5170
|
+
* `JsonRpcRequestError` docblock: that module exports no code constant, and
|
|
5171
|
+
* adding one only to name this number would widen the published surface for it.
|
|
5172
|
+
*/
|
|
5173
|
+
const JSON_RPC_PROTOCOL_ERROR_CODE = 1e3;
|
|
5174
|
+
/**
|
|
5175
|
+
* The LOCAL {@link COMMAND_BODY_DIFFERS_MESSAGE} refusal — the peer's own
|
|
5176
|
+
* reason and sentence, wrapped with the same hints a forwarded conflict earns,
|
|
5177
|
+
* and never sent anywhere.
|
|
5178
|
+
*
|
|
5179
|
+
* ONE constructor for every command that remembers its body (send, respond,
|
|
5180
|
+
* cancel): the caller must not have to learn a second vocabulary for one
|
|
5181
|
+
* condition, and a test matches the prefix `Command body differs` whichever
|
|
5182
|
+
* tool produced it.
|
|
5183
|
+
*/
|
|
5184
|
+
function commandBodyDiffers() {
|
|
5185
|
+
const conflict = new JsonRpcRequestError(JSON_RPC_PROTOCOL_ERROR_CODE, COMMAND_BODY_DIFFERS_MESSAGE, {
|
|
5186
|
+
reason: "IDEMPOTENCY_CONFLICT",
|
|
5187
|
+
message: COMMAND_BODY_DIFFERS_MESSAGE
|
|
5188
|
+
});
|
|
5189
|
+
withRateLimitHint(withIdempotencyHint(conflict));
|
|
5190
|
+
return conflict;
|
|
5191
|
+
}
|
|
5192
|
+
/**
|
|
5193
|
+
* DEEP structural equality, for the caller-owned half of a remembered command
|
|
5194
|
+
* body (issue #118).
|
|
5195
|
+
*
|
|
5196
|
+
* `cherry_respond`'s `response.answers` is a RECORD: two callers sending the
|
|
5197
|
+
* same answers in a different KEY ORDER are sending the SAME answer, and a
|
|
5198
|
+
* plain `!==` (or a JSON string comparison) would refuse the retry as a
|
|
5199
|
+
* different body — turning the receipt recovery into a local conflict on the
|
|
5200
|
+
* exact shapes it exists for. Arrays stay ORDERED: `questions` is a list, and
|
|
5201
|
+
* reordering it really is a different value.
|
|
5202
|
+
*
|
|
5203
|
+
* Deliberately small: the values compared here are tool arguments decoded from
|
|
5204
|
+
* JSON, so only JSON's two container kinds, the primitives, and `undefined`
|
|
5205
|
+
* (an omitted optional argument) need to be understood.
|
|
5206
|
+
*/
|
|
5207
|
+
function sameCommandValue(left, right) {
|
|
5208
|
+
if (left === right) return true;
|
|
5209
|
+
if (Array.isArray(left) || Array.isArray(right)) {
|
|
5210
|
+
if (!Array.isArray(left) || !Array.isArray(right)) return false;
|
|
5211
|
+
if (left.length !== right.length) return false;
|
|
5212
|
+
return left.every((value, index) => sameCommandValue(value, right[index]));
|
|
5213
|
+
}
|
|
5214
|
+
if (isRecord(left) && isRecord(right)) {
|
|
5215
|
+
const leftKeys = Object.keys(left);
|
|
5216
|
+
if (leftKeys.length !== Object.keys(right).length) return false;
|
|
5217
|
+
return leftKeys.every((key) => Object.hasOwn(right, key) && sameCommandValue(left[key], right[key]));
|
|
5218
|
+
}
|
|
5219
|
+
return false;
|
|
5220
|
+
}
|
|
5221
|
+
/**
|
|
5096
5222
|
* Passes a command failure through UNCHANGED, except that `IDEMPOTENCY_CONFLICT`
|
|
5097
5223
|
* gets {@link IDEMPOTENCY_HINT} appended to its message.
|
|
5098
5224
|
*
|
|
@@ -5275,14 +5401,37 @@ const CHERRY_TOOL_NAMES = [
|
|
|
5275
5401
|
* those tasks add mutating tools without touching the host seam.
|
|
5276
5402
|
*/
|
|
5277
5403
|
function registerCherryTools(ctx, deps, options = {}) {
|
|
5404
|
+
return registerCherryToolsWithEngine(ctx, deps, options).dispose;
|
|
5405
|
+
}
|
|
5406
|
+
/**
|
|
5407
|
+
* The same registration, with the ENGINE it built handed back.
|
|
5408
|
+
*
|
|
5409
|
+
* Exists for TESTS: the engine's memory bookkeeping (the remembered command
|
|
5410
|
+
* bodies, the bounds they obey) is private to it on purpose, and the only
|
|
5411
|
+
* honest way to pin "bounded" and "dropped with the device" is to observe it
|
|
5412
|
+
* through this one seam instead of inferring it from retry outcomes that are
|
|
5413
|
+
* identical for an evicted entry and for one that never existed.
|
|
5414
|
+
*
|
|
5415
|
+
* Both this function and `export class CherryToolEngine` are SHIPPED names
|
|
5416
|
+
* (`src` is published on purpose, see AGENTS.md → Build facts), so they are a
|
|
5417
|
+
* compatibility commitment — which is why this seam is kept minimal.
|
|
5418
|
+
*/
|
|
5419
|
+
function registerCherryToolsWithEngine(ctx, deps, options = {}) {
|
|
5278
5420
|
const tools = ctx.tools;
|
|
5279
5421
|
if (tools === void 0) {
|
|
5280
5422
|
console.warn("[cherry-remote] ctx.tools is unavailable; the cherry_* tools were not registered");
|
|
5281
|
-
return
|
|
5423
|
+
return {
|
|
5424
|
+
dispose: () => {},
|
|
5425
|
+
engine: void 0
|
|
5426
|
+
};
|
|
5282
5427
|
}
|
|
5283
|
-
const
|
|
5284
|
-
|
|
5285
|
-
|
|
5428
|
+
const engine = new CherryToolEngine(deps, options);
|
|
5429
|
+
const disposers = engine.definitions().map((definition) => tools.register(definition));
|
|
5430
|
+
return {
|
|
5431
|
+
engine,
|
|
5432
|
+
dispose: () => {
|
|
5433
|
+
for (const dispose of disposers) dispose();
|
|
5434
|
+
}
|
|
5286
5435
|
};
|
|
5287
5436
|
}
|
|
5288
5437
|
/** One tool declaration, in the exact shape the host registry consumes. */
|
|
@@ -5378,6 +5527,12 @@ var CherryToolEngine = class {
|
|
|
5378
5527
|
*/
|
|
5379
5528
|
#contentGate;
|
|
5380
5529
|
/**
|
|
5530
|
+
* The `maxBytes` one `agent.content.read` slice asks for
|
|
5531
|
+
* ({@link CONTENT_READ_MAX_BYTES} in production; injectable so a test can
|
|
5532
|
+
* reach a walk's second slice, see `CherryToolEngineOptions`).
|
|
5533
|
+
*/
|
|
5534
|
+
#contentReadSliceBytes;
|
|
5535
|
+
/**
|
|
5381
5536
|
* The parts pages already walked, keyed `<deviceId>\0<messageId>`.
|
|
5382
5537
|
*
|
|
5383
5538
|
* WHAT IS HELD is the RESULT of `agent.parts.list` — the raw part records plus
|
|
@@ -5444,6 +5599,49 @@ var CherryToolEngine = class {
|
|
|
5444
5599
|
* not pinned by bookkeeping.
|
|
5445
5600
|
*/
|
|
5446
5601
|
#watchedConnections = /* @__PURE__ */ new WeakSet();
|
|
5602
|
+
/**
|
|
5603
|
+
* The EXACT command body emitted for each (device, caller id, method), so a
|
|
5604
|
+
* lost reply can be recovered by receipt (issues #115, #118).
|
|
5605
|
+
*
|
|
5606
|
+
* Keyed `<deviceId>\0<commandId>`, exactly like every other engine cache. The
|
|
5607
|
+
* value is a {@link RememberedCommand}: the METHOD it was minted for, the
|
|
5608
|
+
* caller-owned FINGERPRINT a retry must match, and the BODY the retry
|
|
5609
|
+
* replays verbatim. The fingerprint is what must MATCH — a same-id call whose
|
|
5610
|
+
* arguments differ is not a retry of this command at all, and is refused
|
|
5611
|
+
* locally rather than sent. The body is what must be REPLAYED, derived
|
|
5612
|
+
* fields included: that is the whole point, because those fields are the ones
|
|
5613
|
+
* a retry cannot re-derive the same way.
|
|
5614
|
+
*
|
|
5615
|
+
* Three tools remember: `agent.messages.send` (the baseline it quoted),
|
|
5616
|
+
* `agent.interactions.respond` (the staleness triple) and
|
|
5617
|
+
* `agent.executions.cancel` (the execution it was told to abort). Two of
|
|
5618
|
+
* them run PRE-CHECKS before composing a body — respond's
|
|
5619
|
+
* `unknown-interaction`, cancel's `no-active-execution`/`execution-mismatch` —
|
|
5620
|
+
* and after a lost reply those pre-checks are usually the first thing the
|
|
5621
|
+
* retry meets, because the first attempt is what SETTLED the interaction and
|
|
5622
|
+
* ENDED the run. So a caller-supplied id is consulted BEFORE any derivation:
|
|
5623
|
+
* a remembered retry is an answer, not a fresh call.
|
|
5624
|
+
*
|
|
5625
|
+
* WHAT IS REMEMBERED: only CALLER-SUPPLIED ids. An auto-generated uuid is a
|
|
5626
|
+
* one-shot identity — nobody can retry it, so an entry for it is pure leak.
|
|
5627
|
+
* The entry is written the moment the command is ISSUED, before the reply is
|
|
5628
|
+
* awaited: a reply lost on the wire must not cost the only copy of the bytes
|
|
5629
|
+
* the peer's ledger will accept.
|
|
5630
|
+
*
|
|
5631
|
+
* Which body an id holds: the LATEST attempt issued for it — the first
|
|
5632
|
+
* attempt, or the self-heal's fresh-uuid retry when one ran. After a heal, a
|
|
5633
|
+
* same-id retry is therefore answered with the peer's conflict, deliberately:
|
|
5634
|
+
* replaying the ledger's rejected receipt would re-run the healed admission
|
|
5635
|
+
* and duplicate the run, and a conflict is the safer failure.
|
|
5636
|
+
*
|
|
5637
|
+
* Bounded FIFO, per device; dropped when the PAIRING is invalidated (unpair,
|
|
5638
|
+
* identity-mismatch) but KEPT across an ordinary reconnect — see
|
|
5639
|
+
* {@link COMMAND_BODY_ENTRIES_PER_DEVICE} and
|
|
5640
|
+
* {@link CherryToolEngine.#forgetDevice}.
|
|
5641
|
+
*/
|
|
5642
|
+
#commandBodies = /* @__PURE__ */ new Map();
|
|
5643
|
+
/** The per-device cap on {@link CherryToolEngine.#commandBodies}. */
|
|
5644
|
+
#commandBodyEntriesPerDevice;
|
|
5447
5645
|
constructor(deps, options = {}) {
|
|
5448
5646
|
this.#deps = deps;
|
|
5449
5647
|
this.#partsSafetyBound = Math.max(1, Math.floor(options.partsSafetyBound ?? DEFAULT_PARTS_SAFETY_BOUND));
|
|
@@ -5452,7 +5650,9 @@ var CherryToolEngine = class {
|
|
|
5452
5650
|
this.#cacheEntriesPerDevice = Math.max(1, Math.floor(options.cacheEntriesPerDevice ?? CACHE_ENTRIES_PER_DEVICE));
|
|
5453
5651
|
this.#messageCacheEntryBytes = Math.max(1, Math.floor(options.messageCacheEntryBytes ?? MESSAGE_CACHE_ENTRY_BYTES));
|
|
5454
5652
|
this.#messageCacheBytesPerDevice = Math.max(1, Math.floor(options.messageCacheBytesPerDevice ?? MESSAGE_CACHE_BYTES_PER_DEVICE));
|
|
5653
|
+
this.#commandBodyEntriesPerDevice = Math.max(1, Math.floor(options.commandBodyEntriesPerDevice ?? COMMAND_BODY_ENTRIES_PER_DEVICE));
|
|
5455
5654
|
this.#contentGate = new ContentReadGate(options.contentReadConcurrency ?? DEFAULT_CONTENT_READ_CONCURRENCY);
|
|
5655
|
+
this.#contentReadSliceBytes = Math.max(1, Math.floor(options.contentReadSliceBytes ?? CONTENT_READ_MAX_BYTES));
|
|
5456
5656
|
}
|
|
5457
5657
|
definitions() {
|
|
5458
5658
|
return [
|
|
@@ -6015,7 +6215,7 @@ var CherryToolEngine = class {
|
|
|
6015
6215
|
#readTool() {
|
|
6016
6216
|
return defineCherryTool({
|
|
6017
6217
|
name: "cherry_read",
|
|
6018
|
-
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory.
|
|
6218
|
+
description: "Read a session's message history snapshot. Two-level baseline: `agent.sessions.get` supplies the history revision, `agent.messages.list` the messages, and each message's own revision the parts. Large part text is externalized by the peer and fetched and sha256-verified through `agent.content.read`. An unchanged message (same message revision) reuses its cached part LIST, fenced by that revision, so a repeated read of a quiet session costs no `agent.parts.list` RPC; externalized content is still fetched and sha256-verified on every read, so unverified bytes are never served from memory. An attachment is reported as METADATA, never as its bytes: a `kind: \"file\"` part, or a legacy `data` part named `file` (the metadata as JSON in `content.text`), comes back as `name`/`mediaType` (plus `byteLength` for the `file` kind) with `content` emptied — or kept verbatim when that record carries a key this projection does not report. The bytes are deliberately not fetched into a text channel. Messages come NEWEST-first, so the first entry is the latest and `limit` drops the OLDEST messages — truncation never cuts inside a retained message's parts; a message whose own parts could not be fully read carries `incomplete`, and may carry `unavailableParts` when parts were unreadable. A failed message (`status: \"error\"`) may carry `failure: {reasonCode, retryable, message}` when the peer sent one — its classification, whether it judged a retry worthwhile, and a bounded summary; the peer's `source` and `context.responseBody` are never projected. The answer is ALSO bounded by an output budget: `truncated` with `truncationReason: \"aggregate-bytes\"` means the walk stopped because the result reached its size limit, not because `limit` was reached — raising `limit` does NOT reach the messages left out (it only costs more round trips and stops at the same budget). THE OLDER MESSAGES ARE REACHED BY THE RESUME TOKEN: a truncated answer carries `messagesResumeCursor` when the stop point can be named — absent when it cannot, e.g. an empty or malformed position, a token past the length ceiling, or no anchor id — and passing it back as `messagesResumeCursor` continues the walk from there (`limit` then bounds how many messages are returned FROM that point). The two token forms are `\"cursor:<peer position>\"` (the walk consumed its whole page window) and `\"after:<messageId>\"` (it stopped inside the window; the read restarts at the newest end and resumes strictly after that message). A token this tool did not emit is refused locally rather than forwarded, and an `after:` anchor that no longer exists in the history is an error, not an empty page. A `truncationReason` of \"limit\" is the caller's own ceiling and is reached by raising `limit`. Either way the newest message is always returned whole.",
|
|
6019
6219
|
parameters: {
|
|
6020
6220
|
type: "object",
|
|
6021
6221
|
properties: {
|
|
@@ -6276,38 +6476,115 @@ var CherryToolEngine = class {
|
|
|
6276
6476
|
releaseSlot?.();
|
|
6277
6477
|
}
|
|
6278
6478
|
} else text = "";
|
|
6479
|
+
const legacy = this.#legacyAttachment(kind, raw, text);
|
|
6279
6480
|
return {
|
|
6280
6481
|
partId,
|
|
6281
6482
|
kind,
|
|
6282
6483
|
revision,
|
|
6283
|
-
content: text,
|
|
6284
|
-
...this.#attachmentFace(kind, raw, content),
|
|
6484
|
+
content: legacy === void 0 ? text : legacy.remainder,
|
|
6485
|
+
...legacy === void 0 ? this.#attachmentFace(kind, raw, content) : this.#attachmentFace(kind, raw, content, legacy.record),
|
|
6285
6486
|
...typeof raw["state"] === "string" ? { state: raw["state"] } : {},
|
|
6286
6487
|
...typeof raw["toolName"] === "string" ? { toolName: raw["toolName"] } : {},
|
|
6287
6488
|
...typeof raw["toolCallId"] === "string" ? { toolCallId: raw["toolCallId"] } : {}
|
|
6288
6489
|
};
|
|
6289
6490
|
}
|
|
6290
6491
|
/**
|
|
6492
|
+
* The ONE place a legacy `data` part is recognised as an attachment: the
|
|
6493
|
+
* structural discriminator, the JSON parse, and the lossless CONTENT rule.
|
|
6494
|
+
*
|
|
6495
|
+
* The legal legacy face is `{kind: 'data', name: 'file', content: {text:
|
|
6496
|
+
* '{\"mediaType\":…,\"filename\":…}'}}` — the metadata SERIALIZED AS JSON in
|
|
6497
|
+
* `content.text`, observed on the wire (2.1.7 desktop, image upload,
|
|
6498
|
+
* 2026-10-22). An OBJECT inside `content` is not producible by a conforming
|
|
6499
|
+
* peer at all: the pinned protocol 0.5.0 types `content` as a strict
|
|
6500
|
+
* `{text}` | `{ref}` union, so the old documentation described a shape no
|
|
6501
|
+
* peer can emit — and the real one was never lifted, which is how an image
|
|
6502
|
+
* upload reached a model as a JSON blob.
|
|
6503
|
+
*
|
|
6504
|
+
* WHY THE NAME IS THE DISCRIMINATOR, and why it is not a heuristic: the
|
|
6505
|
+
* desktop labels every `data` part with its data KIND in `name` — an
|
|
6506
|
+
* attachment is `'file'`, a task event is `'data-compaction-anchor'`, an
|
|
6507
|
+
* error is `'data-error'` — and the protocol types that field as an
|
|
6508
|
+
* `opaqueId`. This lift therefore REQUIRES the label to be the literal
|
|
6509
|
+
* `'file'` rather than ASSUMING every peer sets one: a `data` part that
|
|
6510
|
+
* carries no name reads as no name, is not lifted, and passes through
|
|
6511
|
+
* untouched. Several of those payloads are JSON too, so "the text parses"
|
|
6512
|
+
* proves nothing; `'file'` is the one label that means "this is an
|
|
6513
|
+
* attachment".
|
|
6514
|
+
*
|
|
6515
|
+
* NOTHING IS EVER DEGRADED: a text that does not parse, parses to a
|
|
6516
|
+
* non-record, or carries neither `mediaType` nor `filename` as a string is
|
|
6517
|
+
* not an attachment, and the part passes through COMPLETELY unchanged
|
|
6518
|
+
* (`remainder` is the raw text, no field is lifted) — this runs on every
|
|
6519
|
+
* `data` part of every read, so a throw here would take down the whole
|
|
6520
|
+
* snapshot.
|
|
6521
|
+
*
|
|
6522
|
+
* THE CONTENT RULE: `remainder` is EMPTY when every key of the record is one
|
|
6523
|
+
* this face projects (`mediaType`, `filename`) — the metadata is fully
|
|
6524
|
+
* represented, and a model sees structure rather than a blob. A record
|
|
6525
|
+
* carrying ANY other key keeps its raw text, so a field we do not project is
|
|
6526
|
+
* never silently dropped (lossless by default). That promise is about KEYS: a
|
|
6527
|
+
* key we DO project whose value is not a string (say `mediaType: 123`) is
|
|
6528
|
+
* counted as represented — the key survives — and reports no field, because
|
|
6529
|
+
* this face lifts strings only. Emptied text therefore means "every key was
|
|
6530
|
+
* one of ours", not "every value produced a field".
|
|
6531
|
+
*/
|
|
6532
|
+
#legacyAttachment(kind, raw, text) {
|
|
6533
|
+
if (kind !== "data" || optionalString(raw["name"]) !== "file") return void 0;
|
|
6534
|
+
let parsed;
|
|
6535
|
+
try {
|
|
6536
|
+
parsed = JSON.parse(text);
|
|
6537
|
+
} catch {
|
|
6538
|
+
return;
|
|
6539
|
+
}
|
|
6540
|
+
if (!isRecord(parsed)) return void 0;
|
|
6541
|
+
if (optionalString(parsed["filename"]) === void 0 && optionalString(parsed["mediaType"]) === void 0) return;
|
|
6542
|
+
const everythingLifted = Object.keys(parsed).every((key) => key === "mediaType" || key === "filename");
|
|
6543
|
+
return {
|
|
6544
|
+
record: parsed,
|
|
6545
|
+
remainder: everythingLifted ? "" : text
|
|
6546
|
+
};
|
|
6547
|
+
}
|
|
6548
|
+
/**
|
|
6291
6549
|
* The descriptive metadata of an attachment part — and deliberately NOT a
|
|
6292
6550
|
* way to reach its bytes.
|
|
6293
6551
|
*
|
|
6294
|
-
* Two
|
|
6295
|
-
*
|
|
6552
|
+
* Two legal faces are covered, because both are inside the verified range and
|
|
6553
|
+
* they disagree about where the metadata lives:
|
|
6296
6554
|
*
|
|
6297
6555
|
* - **Cherry ≥ 2.1.5** — `{kind: 'file', name, ref}`, ref at the TOP level.
|
|
6298
|
-
* - **
|
|
6556
|
+
* - **legacy** — `{kind: 'data', name: 'file', content: {text: '<JSON>'}}`,
|
|
6557
|
+
* the metadata as JSON TEXT. {@link CherryToolEngine.#legacyAttachment}
|
|
6558
|
+
* parses it and passes the record in here.
|
|
6559
|
+
*
|
|
6560
|
+
* A THIRD arm — a `data` part carrying `{mediaType, filename}` as an OBJECT
|
|
6561
|
+
* inside `content` — is schema-ILLEGAL under the pinned protocol (see the
|
|
6562
|
+
* docblock above) and stays as a DEFENSIVE lift for a peer that violates it.
|
|
6563
|
+
* It is not the 2.1.4 projection: that story was wrong, and a misbehaving
|
|
6564
|
+
* peer's parts are not re-validated client-side, so the guarded arm costs
|
|
6565
|
+
* nothing and covers exactly the peer it was written for.
|
|
6299
6566
|
*
|
|
6300
6567
|
* Only string fields are lifted: a null `filename` (the peer's explicit "no
|
|
6301
6568
|
* name") stays absent rather than becoming the string "null". Anything a
|
|
6302
6569
|
* caller needs beyond this — the bytes — stays behind `agent.content.read`,
|
|
6303
6570
|
* which this plugin does not call for a file part.
|
|
6304
|
-
|
|
6305
|
-
|
|
6571
|
+
*
|
|
6572
|
+
* WHEN A PARSED `metadata` RECORD IS PRESENT IT IS AUTHORITATIVE. A legacy
|
|
6573
|
+
* attachment's record can arrive externalized as `{ref}`, and that ref is the
|
|
6574
|
+
* peer's serialization ENVELOPE, not the attachment's description: its
|
|
6575
|
+
* `mediaType` is the literal `'text/plain'` the envelope is labelled with and
|
|
6576
|
+
* its `byteLength` is the JSON metadata's own byte count. Letting either
|
|
6577
|
+
* outrank the record would report `text/plain` as an image's media type and a
|
|
6578
|
+
* size that is not the file's — so the record wins the media type, and this
|
|
6579
|
+
* face reports NO `byteLength` at all (a legacy `data` part carries none). The
|
|
6580
|
+
* `file` arm is untouched: there the ref IS the file's own description.
|
|
6581
|
+
*/
|
|
6582
|
+
#attachmentFace(kind, raw, content, metadata) {
|
|
6306
6583
|
if (kind !== "file" && kind !== "data") return {};
|
|
6307
6584
|
const ref = isRecord(raw["ref"]) ? raw["ref"] : isRecord(content["ref"]) ? content["ref"] : {};
|
|
6308
|
-
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(content["filename"]);
|
|
6309
|
-
const mediaType = optionalString(ref["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
6310
|
-
const byteLength = optionalString(ref["byteLength"]);
|
|
6585
|
+
const name = kind === "file" ? optionalString(raw["name"]) : optionalString(metadata?.["filename"]) ?? optionalString(content["filename"]);
|
|
6586
|
+
const mediaType = kind === "data" && metadata !== void 0 ? optionalString(metadata["mediaType"]) ?? optionalString(content["mediaType"]) : optionalString(ref["mediaType"]) ?? optionalString(metadata?.["mediaType"]) ?? optionalString(content["mediaType"]);
|
|
6587
|
+
const byteLength = kind === "file" ? optionalString(ref["byteLength"]) : void 0;
|
|
6311
6588
|
return {
|
|
6312
6589
|
...name === void 0 ? {} : { name },
|
|
6313
6590
|
...mediaType === void 0 ? {} : { mediaType },
|
|
@@ -6395,7 +6672,7 @@ var CherryToolEngine = class {
|
|
|
6395
6672
|
const declaredBytes = Number(ref["byteLength"]);
|
|
6396
6673
|
const refusal = this.#externalizedRefusal(ref);
|
|
6397
6674
|
if (refusal !== void 0) throw refusal;
|
|
6398
|
-
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes /
|
|
6675
|
+
const maxSlices = Number.isSafeInteger(declaredBytes) ? Math.max(1, Math.ceil(declaredBytes / this.#contentReadSliceBytes)) : DEFAULT_CONTENT_SLICE_BOUND;
|
|
6399
6676
|
let offset = "0";
|
|
6400
6677
|
let sha;
|
|
6401
6678
|
let complete = false;
|
|
@@ -6408,7 +6685,7 @@ var CherryToolEngine = class {
|
|
|
6408
6685
|
contentId,
|
|
6409
6686
|
revision,
|
|
6410
6687
|
offset,
|
|
6411
|
-
maxBytes:
|
|
6688
|
+
maxBytes: this.#contentReadSliceBytes
|
|
6412
6689
|
}, deadline === void 0 ? void 0 : { deadline: deadline.instant });
|
|
6413
6690
|
const data = typeof slice["dataBase64"] === "string" ? slice["dataBase64"] : "";
|
|
6414
6691
|
const cap = this.#externalizedByteCap;
|
|
@@ -6701,11 +6978,29 @@ var CherryToolEngine = class {
|
|
|
6701
6978
|
*/
|
|
6702
6979
|
async #admitSend(connection, subscriptions, deviceId, sessionId, request) {
|
|
6703
6980
|
const { args, text } = request;
|
|
6981
|
+
const callerId = optionalString(args["commandId"]);
|
|
6704
6982
|
const attempt = async (commandId, revisionOverride) => {
|
|
6705
|
-
const
|
|
6983
|
+
const remembered = callerId === void 0 || commandId !== callerId ? void 0 : this.#recallCommandBody(deviceId, callerId);
|
|
6984
|
+
if (remembered !== void 0 && remembered.method !== SEND_METHOD) throw commandBodyDiffers();
|
|
6985
|
+
const rememberedBody = remembered?.body;
|
|
6986
|
+
if (remembered !== void 0 && (rememberedBody?.["sessionId"] !== sessionId || rememberedBody["text"] !== text)) throw commandBodyDiffers();
|
|
6987
|
+
const expectedIdleRevision = optionalString(rememberedBody?.["expectedIdleRevision"]) ?? revisionOverride ?? await this.#idleRevision(connection, subscriptions, deviceId, sessionId);
|
|
6706
6988
|
const baselineSeq = this.#newestSeq(subscriptions, sessionId);
|
|
6989
|
+
if (callerId !== void 0) this.#rememberCommandBody(deviceId, callerId, {
|
|
6990
|
+
method: SEND_METHOD,
|
|
6991
|
+
fingerprint: {
|
|
6992
|
+
sessionId,
|
|
6993
|
+
text
|
|
6994
|
+
},
|
|
6995
|
+
body: {
|
|
6996
|
+
commandId,
|
|
6997
|
+
sessionId,
|
|
6998
|
+
text,
|
|
6999
|
+
expectedIdleRevision
|
|
7000
|
+
}
|
|
7001
|
+
});
|
|
6707
7002
|
return {
|
|
6708
|
-
receipt: await this.#command(connection,
|
|
7003
|
+
receipt: await this.#command(connection, SEND_METHOD, {
|
|
6709
7004
|
commandId,
|
|
6710
7005
|
sessionId,
|
|
6711
7006
|
text,
|
|
@@ -6956,6 +7251,19 @@ var CherryToolEngine = class {
|
|
|
6956
7251
|
message: "`response` must be {kind:\"answer\", answers:{<question text>: <answer>}}"
|
|
6957
7252
|
};
|
|
6958
7253
|
}
|
|
7254
|
+
const callerId = optionalString(args["commandId"]);
|
|
7255
|
+
const answer = response === void 0 ? { decision } : { response };
|
|
7256
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7257
|
+
if (remembered !== void 0 && remembered.method !== RESPOND_METHOD) throw commandBodyDiffers();
|
|
7258
|
+
if (remembered !== void 0) {
|
|
7259
|
+
const fingerprint = {
|
|
7260
|
+
sessionId,
|
|
7261
|
+
interactionId,
|
|
7262
|
+
...answer
|
|
7263
|
+
};
|
|
7264
|
+
if (!sameCommandValue(remembered.fingerprint, fingerprint)) throw commandBodyDiffers();
|
|
7265
|
+
return this.#command(connection, RESPOND_METHOD, remembered.body);
|
|
7266
|
+
}
|
|
6959
7267
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
6960
7268
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
6961
7269
|
const snapshot = await this.#interactionSnapshot(connection, subscriptions, record.deviceId, sessionId, interactionId);
|
|
@@ -6963,15 +7271,25 @@ var CherryToolEngine = class {
|
|
|
6963
7271
|
error: "unknown-interaction",
|
|
6964
7272
|
message: `no interaction ${interactionId} is known for session ${sessionId}`
|
|
6965
7273
|
};
|
|
6966
|
-
|
|
7274
|
+
const body = {
|
|
6967
7275
|
commandId: this.#commandId(args),
|
|
6968
7276
|
sessionId,
|
|
6969
7277
|
interactionId,
|
|
6970
7278
|
expectedRevision: snapshot.revision,
|
|
6971
7279
|
expectedExecutionId: snapshot.executionId,
|
|
6972
7280
|
inputDigest: snapshot.inputDigest,
|
|
6973
|
-
...
|
|
7281
|
+
...answer
|
|
7282
|
+
};
|
|
7283
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7284
|
+
method: RESPOND_METHOD,
|
|
7285
|
+
fingerprint: {
|
|
7286
|
+
sessionId,
|
|
7287
|
+
interactionId,
|
|
7288
|
+
...answer
|
|
7289
|
+
},
|
|
7290
|
+
body
|
|
6974
7291
|
});
|
|
7292
|
+
return this.#command(connection, RESPOND_METHOD, body);
|
|
6975
7293
|
}
|
|
6976
7294
|
});
|
|
6977
7295
|
}
|
|
@@ -7011,6 +7329,17 @@ var CherryToolEngine = class {
|
|
|
7011
7329
|
if (resolved.link === void 0) return resolved.verdict;
|
|
7012
7330
|
const { connection, record } = resolved.link;
|
|
7013
7331
|
const sessionId = requiredString(args, "sessionId");
|
|
7332
|
+
const callerId = optionalString(args["commandId"]);
|
|
7333
|
+
const supplied = optionalString(args["expectedExecutionId"]);
|
|
7334
|
+
const remembered = callerId === void 0 ? void 0 : this.#recallCommandBody(record.deviceId, callerId);
|
|
7335
|
+
if (remembered !== void 0 && remembered.method !== CANCEL_METHOD) throw commandBodyDiffers();
|
|
7336
|
+
if (remembered !== void 0) {
|
|
7337
|
+
if (!sameCommandValue(remembered.fingerprint, {
|
|
7338
|
+
sessionId,
|
|
7339
|
+
expectedExecutionId: supplied
|
|
7340
|
+
})) throw commandBodyDiffers();
|
|
7341
|
+
return this.#command(connection, CANCEL_METHOD, remembered.body);
|
|
7342
|
+
}
|
|
7014
7343
|
await this.#ensureSubscription(record.deviceId, sessionId);
|
|
7015
7344
|
const subscriptions = this.#deps.subs(record.deviceId);
|
|
7016
7345
|
const expectedExecutionId = await this.#activeExecutionId(connection, subscriptions, record.deviceId, sessionId);
|
|
@@ -7018,18 +7347,26 @@ var CherryToolEngine = class {
|
|
|
7018
7347
|
error: "no-active-execution",
|
|
7019
7348
|
message: `session ${sessionId} has no in-flight execution to cancel`
|
|
7020
7349
|
};
|
|
7021
|
-
const supplied = optionalString(args["expectedExecutionId"]);
|
|
7022
7350
|
if (supplied !== void 0 && supplied !== expectedExecutionId) return {
|
|
7023
7351
|
error: "execution-mismatch",
|
|
7024
7352
|
expectedExecutionId,
|
|
7025
7353
|
supplied,
|
|
7026
7354
|
message: `the cached active execution is ${expectedExecutionId}, not ${supplied}; re-read the session before cancelling`
|
|
7027
7355
|
};
|
|
7028
|
-
|
|
7356
|
+
const body = {
|
|
7029
7357
|
commandId: this.#commandId(args),
|
|
7030
7358
|
sessionId,
|
|
7031
7359
|
expectedExecutionId
|
|
7360
|
+
};
|
|
7361
|
+
if (callerId !== void 0) this.#rememberCommandBody(record.deviceId, callerId, {
|
|
7362
|
+
method: CANCEL_METHOD,
|
|
7363
|
+
fingerprint: {
|
|
7364
|
+
sessionId,
|
|
7365
|
+
expectedExecutionId: supplied
|
|
7366
|
+
},
|
|
7367
|
+
body
|
|
7032
7368
|
});
|
|
7369
|
+
return this.#command(connection, CANCEL_METHOD, body);
|
|
7033
7370
|
}
|
|
7034
7371
|
});
|
|
7035
7372
|
}
|
|
@@ -7120,7 +7457,7 @@ var CherryToolEngine = class {
|
|
|
7120
7457
|
const selected = this.#selectRecords(selector);
|
|
7121
7458
|
if (!Array.isArray(selected)) return selected;
|
|
7122
7459
|
const record = selected[0];
|
|
7123
|
-
this.#forgetDevice(record.deviceId);
|
|
7460
|
+
this.#forgetDevice(record.deviceId, { dropCommandBodies: true });
|
|
7124
7461
|
await this.#deps.evict(record.deviceId);
|
|
7125
7462
|
try {
|
|
7126
7463
|
this.#deps.store.remove(record.deviceId);
|
|
@@ -7388,7 +7725,8 @@ var CherryToolEngine = class {
|
|
|
7388
7725
|
* the stored credential no longer describes a valid pairing. A transient
|
|
7389
7726
|
* network drop ALSO lands here and clears the caches unnecessarily; that
|
|
7390
7727
|
* is accepted, because the cost is one round trip per session on reuse and
|
|
7391
|
-
* the alternative is keeping snapshots across an identity change.
|
|
7728
|
+
* the alternative is keeping snapshots across an identity change. (The
|
|
7729
|
+
* remembered command BODIES are the one exception: see `#forgetDevice`.)
|
|
7392
7730
|
* - `identity-mismatch` — the peer's pinned key changed: the pairing is over.
|
|
7393
7731
|
*
|
|
7394
7732
|
* {@link CherryToolEngine.#resolve} installs the watch on every tool call that
|
|
@@ -7411,7 +7749,7 @@ var CherryToolEngine = class {
|
|
|
7411
7749
|
this.#watchedConnections.add(connection);
|
|
7412
7750
|
watch(deviceId, (state) => {
|
|
7413
7751
|
if (state !== "offline" && state !== "identity-mismatch") return;
|
|
7414
|
-
this.#forgetDevice(deviceId);
|
|
7752
|
+
this.#forgetDevice(deviceId, { dropCommandBodies: state === "identity-mismatch" });
|
|
7415
7753
|
});
|
|
7416
7754
|
}
|
|
7417
7755
|
/**
|
|
@@ -7423,13 +7761,106 @@ var CherryToolEngine = class {
|
|
|
7423
7761
|
* leaves the archive (`cherry_unpair`), so a re-paired device cannot inherit
|
|
7424
7762
|
* the previous pairing's snapshots and a long-lived process does not
|
|
7425
7763
|
* accumulate entries for devices that no longer exist.
|
|
7764
|
+
*
|
|
7765
|
+
* The remembered command bodies obey a NARROWER seam than the read caches:
|
|
7766
|
+
* `dropCommandBodies` is passed by the callers that invalidate the PAIRING
|
|
7767
|
+
* (unpair, identity-mismatch), never by an ordinary `offline`. What a body
|
|
7768
|
+
* replays against — the peer's command ledger — survives a reconnect, and the
|
|
7769
|
+
* body is the only local copy of the exact bytes that ledger will accept, so
|
|
7770
|
+
* a link drop that emptied it would cost the recovery itself, where the read
|
|
7771
|
+
* caches merely pay one re-walk.
|
|
7426
7772
|
*/
|
|
7427
|
-
#forgetDevice(deviceId) {
|
|
7773
|
+
#forgetDevice(deviceId, options = {}) {
|
|
7428
7774
|
const prefix = `${deviceId}\u0000`;
|
|
7429
7775
|
for (const key of [...this.#revisionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#revisionCache, key);
|
|
7430
7776
|
for (const key of [...this.#executionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#executionCache, key);
|
|
7431
7777
|
for (const key of [...this.#interactionCache.keys()]) if (key.startsWith(prefix)) this.#forget(this.#interactionCache, key);
|
|
7432
7778
|
for (const key of [...this.#messageCache.keys()]) if (key.startsWith(prefix)) this.#dropCachedMessage(key);
|
|
7779
|
+
if (options.dropCommandBodies === true) {
|
|
7780
|
+
for (const key of [...this.#commandBodies.keys()]) if (key.startsWith(prefix)) this.#forget(this.#commandBodies, key);
|
|
7781
|
+
}
|
|
7782
|
+
}
|
|
7783
|
+
/**
|
|
7784
|
+
* The command remembered for one caller-supplied `commandId`, keyed
|
|
7785
|
+
* `<deviceId>\0<commandId>` — or `undefined` when this device has never
|
|
7786
|
+
* emitted one (a first attempt, or an entry the FIFO evicted).
|
|
7787
|
+
*
|
|
7788
|
+
* The METHOD is the caller's to judge, and it is a THREE-WAY decision, never
|
|
7789
|
+
* a miss:
|
|
7790
|
+
*
|
|
7791
|
+
* - no entry → a fresh call;
|
|
7792
|
+
* - an entry under the caller's own method → match the fingerprint, then
|
|
7793
|
+
* replay or refuse;
|
|
7794
|
+
* - an entry under a DIFFERENT method → a MISMATCH, refused locally. An id
|
|
7795
|
+
* minted for `agent.messages.send` is not a respond retry, and collapsing
|
|
7796
|
+
* that case into "no entry" would send a respond under a send's id and
|
|
7797
|
+
* let the PEER answer the conflict one round trip later.
|
|
7798
|
+
*/
|
|
7799
|
+
#recallCommandBody(deviceId, commandId) {
|
|
7800
|
+
return this.#commandBodies.get(this.#cacheKey(deviceId, commandId));
|
|
7801
|
+
}
|
|
7802
|
+
/**
|
|
7803
|
+
* TEST SEAM — the remembered bodies, as one `commandId` per device.
|
|
7804
|
+
*
|
|
7805
|
+
* The memory itself is PRIVATE, and stays that way for callers. What a test
|
|
7806
|
+
* needs to assert is its bookkeeping — that the bound evicts the oldest entry
|
|
7807
|
+
* and that {@link CherryToolEngine.#forgetDevice} drops a device's entries —
|
|
7808
|
+
* and neither can be observed through a real retry: a retry of an evicted id
|
|
7809
|
+
* is indistinguishable from a retry of an absent one, because both are refused
|
|
7810
|
+
* locally by the same rule. Keys are rendered WITHOUT the device component so
|
|
7811
|
+
* a test reads ids, not cache internals.
|
|
7812
|
+
*/
|
|
7813
|
+
rememberedCommandIds(deviceId) {
|
|
7814
|
+
const prefix = `${deviceId}${CACHE_KEY_SEPARATOR}`;
|
|
7815
|
+
return [...this.#commandBodies.keys()].filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length));
|
|
7816
|
+
}
|
|
7817
|
+
/**
|
|
7818
|
+
* TEST SEAM — fill the memory the way a long-lived process would: one entry
|
|
7819
|
+
* per id, WITHOUT spending a wire call on each. The bound and the FIFO order
|
|
7820
|
+
* are the engine's own ({@link CherryToolEngine.#rememberCommandBody}), which
|
|
7821
|
+
* is what makes this a test of the real policy rather than of a mock of it.
|
|
7822
|
+
*/
|
|
7823
|
+
rememberCommandBodyForTest(deviceId, commandId) {
|
|
7824
|
+
this.#rememberCommandBody(deviceId, commandId, {
|
|
7825
|
+
method: "agent.messages.send",
|
|
7826
|
+
fingerprint: {
|
|
7827
|
+
sessionId: "session-for-test",
|
|
7828
|
+
text: "text-for-test"
|
|
7829
|
+
},
|
|
7830
|
+
body: {
|
|
7831
|
+
commandId,
|
|
7832
|
+
sessionId: "session-for-test",
|
|
7833
|
+
text: "text-for-test",
|
|
7834
|
+
expectedIdleRevision: "0"
|
|
7835
|
+
}
|
|
7836
|
+
});
|
|
7837
|
+
}
|
|
7838
|
+
/**
|
|
7839
|
+
* Remembers the exact body one caller-supplied `commandId` was ISSUED with,
|
|
7840
|
+
* evicting this device's oldest entry when it is at the bound.
|
|
7841
|
+
*
|
|
7842
|
+
* FIFO, per device: `Map` insertion order IS the policy, the same deliberate
|
|
7843
|
+
* choice the session caches document, and a `set` on an EXISTING key keeps its
|
|
7844
|
+
* position — a refresh is a rewrite, not a use, so the policy stays
|
|
7845
|
+
* insert-order rather than becoming LRU. One line per eviction, naming the
|
|
7846
|
+
* device and the bound only.
|
|
7847
|
+
*/
|
|
7848
|
+
#rememberCommandBody(deviceId, commandId, remembered) {
|
|
7849
|
+
const key = this.#cacheKey(deviceId, commandId);
|
|
7850
|
+
if (!this.#commandBodies.has(key)) {
|
|
7851
|
+
let own = 0;
|
|
7852
|
+
let oldest;
|
|
7853
|
+
for (const candidate of this.#commandBodies.keys()) {
|
|
7854
|
+
if (!candidate.startsWith(`${deviceId}\u0000`)) continue;
|
|
7855
|
+
own += 1;
|
|
7856
|
+
if (oldest === void 0) oldest = candidate;
|
|
7857
|
+
}
|
|
7858
|
+
if (own >= this.#commandBodyEntriesPerDevice && oldest !== void 0) {
|
|
7859
|
+
this.#commandBodies.delete(oldest);
|
|
7860
|
+
console.warn(`[cherry-remote] remembered command bodies for device ${deviceId} reached their ${this.#commandBodyEntriesPerDevice}-commandId bound; evicted the oldest (a retry of that commandId now re-derives its body, and the peer decides whether it replays)`);
|
|
7861
|
+
}
|
|
7862
|
+
}
|
|
7863
|
+
this.#commandBodies.set(key, remembered);
|
|
7433
7864
|
}
|
|
7434
7865
|
/**
|
|
7435
7866
|
* The session's current idle revision, from the SUBSCRIPTION CACHE.
|