davinci-resolve-mcp 2.208.0 → 2.208.1

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/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  Release history for the DaVinci Resolve MCP Server. The latest release is summarized in the root README; older entries live here to keep the README focused.
4
4
 
5
+ ## What's New in v2.208.1 — #188: variant item counts come from the timeline
6
+
7
+ ### Fixed
8
+
9
+ - **A silence ripple under-reported what it built, by exactly half.**
10
+ `execute_silence_ripple` returned `variant_video_items: 250` and
11
+ `variant_audio_items: 250` for a variant that really held 432 of each. The
12
+ bridge's `ResolveOperations._encode` truncated every proxied container to
13
+ `max_items` (500) with no signal anywhere, and `plan_silence_ripple`
14
+ interleaves video and audio — so a 432-range plan became 864 clipInfos in one
15
+ `AppendToTimeline`, Resolve placed and returned all 864, and the first 500
16
+ encoded are precisely 250 video plus 250 audio. The same response's
17
+ `readback.after.clip_count` said 864 and was right the whole time, because it
18
+ re-reads per track: two numbers from two sources in one payload, one of them
19
+ silently short. "Planned 432, got 250" reads exactly like 182 ranges failing
20
+ to land, which on a silence ripple is the operator's central fear, and
21
+ establishing that it was benign cost a full review cycle of hand-auditing
22
+ both tracks. Reported and fixed in #188 by @mart0vip.
23
+ - **Dropped elements are now reported, never silent.** `op_call` and
24
+ `op_get_attribute` carry a `truncated` block naming the count, limit and
25
+ containers; the client records it on `transport.truncations` and logs the
26
+ method. It warns rather than raises deliberately — the native call has
27
+ already run by the time the reply is encoded, so raising would turn a
28
+ completed 864-item assembly into an error and orphan the timeline. A short
29
+ list that looks complete was the failure mode; the bound itself is
30
+ legitimate.
31
+ - **The item ceiling no longer exceeds the handle table.** `max_items` was
32
+ clamped to 5000 against a 4096-entry `MAX_HANDLES`, so a long enough list
33
+ evicted its own earliest handles while it was still being minted and handed
34
+ the client ids that were already `stale_handle`. It now clamps to
35
+ `MAX_HANDLES`, with the default raised 500 → 2000.
36
+ - **Counts come from the timeline, not the append reply.**
37
+ `create_variant_from_ranges` reports `placed_item_counts` from the
38
+ post-assembly per-track re-read it was already taking for gap detection — no
39
+ extra Resolve calls — and `execute_silence_ripple` and `execute_tighten` now
40
+ share one accounting helper, tighten having carried the identical bug. A
41
+ planned-vs-placed disagreement is stated outright instead of left to a hand
42
+ audit.
43
+ - Beyond reporting: under the old ceiling a `cdl` applied to a large variant
44
+ only reached the first 250 video items.
45
+
5
46
  ## What's New in v2.208.0 — agent execution lifecycle & pre-flight risk inspection
6
47
 
7
48
  Adapted from the design contributed in PR #187.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  English | [简体中文](README.zh-CN.md)
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.208.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.208.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#server-modes)
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | 简体中文
4
4
 
5
- [![Version](https://img.shields.io/badge/version-2.208.0-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
5
+ [![Version](https://img.shields.io/badge/version-2.208.1-blue.svg)](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
6
6
  [![npm](https://img.shields.io/npm/v/davinci-resolve-mcp.svg?label=npm&color=CB3837)](https://www.npmjs.com/package/davinci-resolve-mcp)
7
7
  [![API Coverage](https://img.shields.io/badge/API%20Coverage-100%25-brightgreen.svg)](docs/reference/api-coverage.md)
8
8
  [![Tools](https://img.shields.io/badge/MCP%20Tools-36%20(353%20full)-blue.svg)](#服务器模式)
@@ -12,7 +12,7 @@
12
12
  [![Python](https://img.shields.io/badge/python-3.10+-green.svg)](https://www.python.org/downloads/)
13
13
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](https://opensource.org/licenses/MIT)
14
14
 
15
- > 本翻译对应 v2.208.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
15
+ > 本翻译对应 v2.208.1 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
16
16
 
17
17
  一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
18
18
 
package/install.py CHANGED
@@ -37,7 +37,7 @@ from src.utils.update_check import (
37
37
 
38
38
  # ─── Version ──────────────────────────────────────────────────────────────────
39
39
 
40
- VERSION = "2.208.0"
40
+ VERSION = "2.208.1"
41
41
  # Only hard floor: mcp[cli] requires Python 3.10+. There is no upper bound —
42
42
  # Resolve's scripting bridge loads into newer interpreters on recent builds
43
43
  # (Python 3.14 verified against Resolve Studio 20.3.2). Older Resolve builds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "davinci-resolve-mcp",
3
- "version": "2.208.0",
3
+ "version": "2.208.1",
4
4
  "description": "NPM bootstrapper for the DaVinci Resolve MCP Server.",
5
5
  "license": "MIT",
6
6
  "author": "Samuel Gursky <samgursky@gmail.com>",
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
87
87
  handlers=[logging.StreamHandler()],
88
88
  )
89
89
 
90
- VERSION = "2.208.0"
90
+ VERSION = "2.208.1"
91
91
  logger = logging.getLogger("davinci-resolve-mcp")
92
92
  logger.info(f"Starting DaVinci Resolve MCP Server v{VERSION}")
93
93
  logger.info(f"Detected platform: {get_platform()}")
package/src/server.py CHANGED
@@ -11,7 +11,7 @@ Usage:
11
11
  python src/server.py --full # Start the 353-tool granular server instead
12
12
  """
13
13
 
14
- VERSION = "2.208.0"
14
+ VERSION = "2.208.1"
15
15
 
16
16
  import base64
17
17
  import os
@@ -6606,6 +6606,26 @@ def _variant_item_placement(item) -> Dict[str, Any]:
6606
6606
  }
6607
6607
 
6608
6608
 
6609
+ def _snapshot_track_item_counts(snapshot: Dict[str, Any]) -> Dict[str, int]:
6610
+ """Per-track-type item counts read from a conform snapshot of a live timeline.
6611
+
6612
+ This is the ONLY honest answer to "what did the assembly actually place".
6613
+ The obvious alternative — counting what `MediaPool.AppendToTimeline`
6614
+ returned — is a witness derived from the same call it would be checking, and
6615
+ it lies in two measured ways: the in-app bridge caps any proxied list at
6616
+ `max_items` (an 864-clipInfo append came back as 500 items, so a variant
6617
+ holding 432 video + 432 audio was reported as 250 + 250), and Resolve drops
6618
+ colliding records from the reply without an error (see the api_truth entry
6619
+ "MediaPool.AppendToTimeline (overlapping records — earlier item wins)").
6620
+ Re-reading the timeline per track cannot be fooled by either.
6621
+ """
6622
+ counts: Dict[str, int] = {}
6623
+ for track_type, block in (snapshot.get("tracks") or {}).items():
6624
+ rows = (block or {}).get("tracks") or []
6625
+ counts[str(track_type)] = sum(int(row.get("item_count") or 0) for row in rows)
6626
+ return counts
6627
+
6628
+
6609
6629
  def _variant_audio_summary(built):
6610
6630
  """Video/audio range counts for an assembled variant, warning when it carries
6611
6631
  no audio. create_variant_from_ranges places exactly the ranges given, so a
@@ -6618,6 +6638,61 @@ def _variant_audio_summary(built):
6618
6638
  return summary
6619
6639
 
6620
6640
 
6641
+ def _variant_audio_accounting(variant: Dict[str, Any], *, planned_video: int,
6642
+ planned_audio: int) -> Dict[str, Any]:
6643
+ """The planned-vs-placed block on a tighten / silence-ripple readback.
6644
+
6645
+ Shared by execute_tighten and execute_silence_ripple so the two cannot
6646
+ drift: they answer the same operator question, "did every range I planned
6647
+ actually land in the variant".
6648
+
6649
+ Placed counts come from the assembler's post-assembly re-read of the
6650
+ timeline, never from what `AppendToTimeline` returned — see
6651
+ `_snapshot_track_item_counts` for why the append's reply is not evidence.
6652
+ A count that is short for a *reporting* reason and a count that is short
6653
+ because material was dropped must never look the same here: on a silence
6654
+ ripple the operator's whole fear is dropped material, so a disagreement is
6655
+ stated outright rather than left to be discovered by hand-auditing tracks.
6656
+ """
6657
+ placed = variant.get("placed_item_counts")
6658
+ video = (placed or {}).get("video")
6659
+ audio = (placed or {}).get("audio")
6660
+ accounting: Dict[str, Any] = {
6661
+ "planned_audio_ranges": planned_audio,
6662
+ "planned_video_ranges": planned_video,
6663
+ "variant_audio_items": audio,
6664
+ "variant_video_items": video,
6665
+ "counts_source": "post-assembly per-track read of the variant timeline",
6666
+ }
6667
+ if video is None or audio is None:
6668
+ accounting["note"] = (
6669
+ "Placed item counts are UNAVAILABLE — the variant could not be re-read "
6670
+ "after assembly. Verify with timeline_item get_items_in_track before "
6671
+ "using this variant."
6672
+ )
6673
+ return accounting
6674
+ disagreements = []
6675
+ if video != planned_video:
6676
+ disagreements.append(f"video {video}/{planned_video}")
6677
+ if audio != planned_audio:
6678
+ disagreements.append(f"audio {audio}/{planned_audio}")
6679
+ if disagreements:
6680
+ accounting["note"] = (
6681
+ "PLACED COUNT DISAGREES WITH THE PLAN (placed/planned: "
6682
+ + ", ".join(disagreements)
6683
+ + ") — ranges did not land. Resolve drops colliding records from an "
6684
+ "append without erroring; check readback.gaps_overlaps and the "
6685
+ "tracks themselves before using this variant."
6686
+ )
6687
+ elif planned_audio:
6688
+ accounting["note"] = "Variant carries audio mirrored from the video cuts."
6689
+ else:
6690
+ accounting["note"] = (
6691
+ "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
6692
+ )
6693
+ return accounting
6694
+
6695
+
6621
6696
  def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) -> Dict[str, Any]:
6622
6697
  ranges = p.get("ranges") or p.get("clip_infos")
6623
6698
  if not isinstance(ranges, list) or not ranges:
@@ -6768,16 +6843,21 @@ def _timeline_create_variant_from_ranges(proj, source_tl, p: Dict[str, Any]) ->
6768
6843
  if p.get("cdl"):
6769
6844
  target_ids = [row.get("timeline_item_id") for row in items_out if row.get("timeline_item_id") and row.get("range", {}).get("media_type") == 1]
6770
6845
  look_result = _timeline_apply_look_to_items(new_tl, {"target_ids": target_ids, "cdl": p.get("cdl")})
6846
+ # One snapshot, two consumers: gap detection and the placed-item counts.
6847
+ # `items` above is only as complete as the append's REPLY, so it is not
6848
+ # evidence of what landed — `placed_item_counts` re-reads the timeline.
6849
+ snapshot = _timeline_conform_snapshot(new_tl, {})
6771
6850
  return {
6772
6851
  "success": True,
6773
6852
  "name": new_tl.GetName(),
6774
6853
  "id": new_tl.GetUniqueId(),
6775
6854
  "items": items_out,
6855
+ "placed_item_counts": _snapshot_track_item_counts(snapshot),
6776
6856
  "placement_mismatches": placement_mismatches,
6777
6857
  "audio": _variant_audio_summary(built),
6778
6858
  "markers": marker_results,
6779
6859
  "look": look_result,
6780
- "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(_timeline_conform_snapshot(new_tl, {}), {}),
6860
+ "gaps_overlaps": _detect_gaps_overlaps_from_snapshot(snapshot, {}),
6781
6861
  }
6782
6862
 
6783
6863
 
@@ -24172,24 +24252,11 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24172
24252
  structural_diff if include_details
24173
24253
  else _compact_structural_diff(structural_diff)
24174
24254
  ),
24175
- "audio_accounting": {
24176
- "planned_audio_ranges": audio_keep_ranges,
24177
- "planned_video_ranges": video_keep_ranges,
24178
- # variant_* count placed items; variant["audio"] counts requested ranges.
24179
- "variant_audio_items": sum(
24180
- 1 for it in (variant.get("items") or [])
24181
- if (it.get("range") or {}).get("media_type") == 2
24182
- ),
24183
- "variant_video_items": sum(
24184
- 1 for it in (variant.get("items") or [])
24185
- if (it.get("range") or {}).get("media_type") == 1
24186
- ),
24187
- "note": (
24188
- "Variant carries audio mirrored from the video cuts."
24189
- if audio_keep_ranges
24190
- else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
24191
- ),
24192
- },
24255
+ # variant_* count PLACED items, re-read from the variant;
24256
+ # variant["audio"] counts requested ranges.
24257
+ "audio_accounting": _variant_audio_accounting(
24258
+ variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
24259
+ ),
24193
24260
  },
24194
24261
  "plan_id": plan.get("plan_id"),
24195
24262
  }
@@ -24308,23 +24375,9 @@ def edit_engine(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[st
24308
24375
  structural_diff if include_details
24309
24376
  else _compact_structural_diff(structural_diff)
24310
24377
  ),
24311
- "audio_accounting": {
24312
- "planned_audio_ranges": audio_keep_ranges,
24313
- "planned_video_ranges": video_keep_ranges,
24314
- "variant_audio_items": sum(
24315
- 1 for it in (variant.get("items") or [])
24316
- if (it.get("range") or {}).get("media_type") == 2
24317
- ),
24318
- "variant_video_items": sum(
24319
- 1 for it in (variant.get("items") or [])
24320
- if (it.get("range") or {}).get("media_type") == 1
24321
- ),
24322
- "note": (
24323
- "Variant carries audio mirrored from the video cuts."
24324
- if audio_keep_ranges
24325
- else "Variant is VIDEO-ONLY (silent) — re-plan with include_audio=True for sound."
24326
- ),
24327
- },
24378
+ "audio_accounting": _variant_audio_accounting(
24379
+ variant, planned_video=video_keep_ranges, planned_audio=audio_keep_ranges,
24380
+ ),
24328
24381
  },
24329
24382
  "plan_id": plan.get("plan_id"),
24330
24383
  }
@@ -33,6 +33,18 @@ proxy deliberately does not.
33
33
  timeline item-by-item is the shape that bites.
34
34
  - **Bridge absence.** If the in-Resolve script is not running, construction fails
35
35
  with a clear message rather than pretending; there is nothing to fall back to.
36
+ - **Incomplete replies.** A returned container longer than the surface's
37
+ `max_items` comes back short. That used to be invisible, and a short list is
38
+ indistinguishable from a genuinely short result — an 864-clipInfo
39
+ `AppendToTimeline` returned 500 items and a caller counted them as the whole
40
+ answer. The surface now reports every drop and `_BoundMethod` surfaces it
41
+ (`transport.truncations`, plus a warning naming the method).
42
+
43
+ It warns rather than raising, deliberately: the native call has already *run*
44
+ by the time the reply is encoded, so raising would turn completed Resolve work
45
+ — a placed 864-item assembly — into an error and orphan the result. The honest
46
+ handling is for the caller to stop treating a returned list as a count, which
47
+ is why the tools that report item counts re-read them from the timeline.
36
48
  """
37
49
 
38
50
  from __future__ import annotations
@@ -117,6 +129,31 @@ class BridgeTransport:
117
129
  # keeps request/response pairing simple and matches the _bridge_lock
118
130
  # discipline the rest of the server already follows.
119
131
  self._lock = threading.RLock()
132
+ #: Replies the surface reported as incomplete, newest last: one row per
133
+ #: (method, dropped, total). Kept so a caller that suspects a short
134
+ #: enumeration can prove it instead of inferring it from a count that
135
+ #: looks plausible. Bounded — this is a diagnostic, not a log.
136
+ self.truncations: List[Dict[str, Any]] = []
137
+
138
+ def note_truncation(self, method: str, truncated: Any) -> None:
139
+ """Record and announce a reply the surface could not carry in full."""
140
+ if not isinstance(truncated, dict):
141
+ return
142
+ row = {"method": method, **truncated}
143
+ with self._lock:
144
+ self.truncations.append(row)
145
+ del self.truncations[:-32]
146
+ # Shapes here come off the wire from a bridge that may be older than
147
+ # this client, so nothing is indexed or assumed present.
148
+ containers = truncated.get("containers")
149
+ first = containers[0] if isinstance(containers, list) and containers else {}
150
+ logger.warning(
151
+ "bridge reply for %s was TRUNCATED: %s of %s elements dropped (limit %s). "
152
+ "The returned list is not a count — re-read the object instead.",
153
+ method, truncated.get("dropped"),
154
+ first.get("total") if isinstance(first, dict) else None,
155
+ truncated.get("limit"),
156
+ )
120
157
 
121
158
  def request(self, operation: str, arguments: Dict[str, Any]) -> Any:
122
159
  payload = {
@@ -251,6 +288,7 @@ class _BoundMethod:
251
288
  {"target": self._handle, "method": self._name,
252
289
  "args": [_encode_argument(a) for a in args]},
253
290
  )
291
+ self._transport.note_truncation(self._name, (result or {}).get("truncated"))
254
292
  return _decode_value(self._transport, (result or {}).get("value"))
255
293
 
256
294
  def __repr__(self) -> str: # pragma: no cover - debugging aid
@@ -345,6 +383,7 @@ class BridgeProxy:
345
383
  probe = self._transport.request("get_attribute",
346
384
  {"target": self._handle, "name": name}) or {}
347
385
  if probe.get("kind") == "value":
386
+ self._transport.note_truncation(name, probe.get("truncated"))
348
387
  return _decode_value(self._transport, probe.get("value"))
349
388
  if (name in _FUSION_UNENUMERATED_METHODS
350
389
  and self._methods() & _FUSION_OBJECT_MARKERS):
@@ -174,13 +174,22 @@ class ResolveOperations:
174
174
  #: loses a handle gets a clear `stale_handle` error and can re-fetch.
175
175
  MAX_HANDLES = 4096
176
176
 
177
+ #: Elements carried out of one encoded return value. A timeline-scale
178
+ #: enumeration has to fit: the old 500 silently halved an 864-item
179
+ #: `AppendToTimeline` return, and the caller counted the 500 it got as the
180
+ #: whole truth (a 432+432 variant read back as 250+250, issue: silence-ripple
181
+ #: audio_accounting). Whatever the ceiling is, exceeding it is now REPORTED
182
+ #: — see `_encode` — because a short list that looks complete is the failure
183
+ #: mode, not the bound itself.
184
+ DEFAULT_MAX_ITEMS = 2000
185
+
177
186
  def __init__(
178
187
  self,
179
188
  resolve: Any,
180
189
  *,
181
190
  media_roots: List[str],
182
191
  output_roots: List[str],
183
- max_items: int = 500,
192
+ max_items: int = DEFAULT_MAX_ITEMS,
184
193
  lifecycle: Optional[Callable[[str], Dict[str, Any]]] = None,
185
194
  ) -> None:
186
195
  if resolve is None:
@@ -191,7 +200,16 @@ class ResolveOperations:
191
200
  # pretending to stop something they have no handle on.
192
201
  self._lifecycle = lifecycle
193
202
  self.policy = PathPolicy(media_roots, output_roots)
194
- self.max_items = max(1, min(int(max_items), 5000))
203
+ # Capped at MAX_HANDLES, not at some larger round number: every live
204
+ # object in an encoded list mints a handle, so a list longer than the
205
+ # table evicts its own earliest entries before the client can use them
206
+ # and hands back handles that are already `stale_handle`. A ceiling
207
+ # above the table would trade a short list for a poisoned one.
208
+ self.max_items = max(1, min(int(max_items), self.MAX_HANDLES))
209
+ #: Set by `_encode` when a return value did not fit, read by the ops
210
+ #: that encode. Not a counter across calls — it answers "was THIS reply
211
+ #: complete", which is the only question a caller can act on.
212
+ self._encode_truncation: List[Dict[str, Any]] = []
195
213
  self._routes: Dict[str, Callable[[Dict[str, Any]], Any]] = {
196
214
  name: getattr(self, f"op_{name}") for name in self.OPERATIONS
197
215
  }
@@ -507,21 +525,68 @@ class ResolveOperations:
507
525
  Every object produced by one call carries the same shape, including the
508
526
  elements of a returned list — a track's timeline items are homogeneous,
509
527
  which is exactly the case where sharing a cached method set pays.
528
+
529
+ **Dropping elements is recorded, never silent.** A container longer than
530
+ `max_items` used to come back shortened with nothing anywhere saying so,
531
+ and a short list is indistinguishable from a genuinely short result: an
532
+ 864-clipInfo `AppendToTimeline` returned 500 items, the caller counted
533
+ them, and a variant holding 432 video + 432 audio was reported to the
534
+ operator as 250 + 250 — which reads exactly like 182 ranges failing to
535
+ land. The bound itself is legitimate (see `max_items`); hiding it is
536
+ not, so every drop is reported alongside the value.
510
537
  """
511
538
  if value is None or isinstance(value, (bool, int, float, str)):
512
539
  return value
513
540
  if depth > 6:
514
541
  return str(value)
515
542
  if isinstance(value, (list, tuple)):
516
- return [self._encode(v, depth + 1, shape) for v in list(value)[: self.max_items]]
543
+ items = list(value)
544
+ self._note_truncation(len(items), "list", depth, shape)
545
+ return [self._encode(v, depth + 1, shape) for v in items[: self.max_items]]
517
546
  if isinstance(value, dict):
518
- return {str(k): self._encode(v, depth + 1, shape) for k, v in list(value.items())[: self.max_items]}
547
+ pairs = list(value.items())
548
+ self._note_truncation(len(pairs), "dict", depth, shape)
549
+ return {str(k): self._encode(v, depth + 1, shape) for k, v in pairs[: self.max_items]}
519
550
  return {
520
551
  "__handle__": self._mint(value, shape),
521
552
  "__type__": type(value).__name__,
522
553
  "__shape__": shape,
523
554
  }
524
555
 
556
+ def _note_truncation(self, total: int, kind: str, depth: int, shape: str) -> None:
557
+ if total <= self.max_items:
558
+ return
559
+ self._encode_truncation.append({
560
+ "shape": shape, "kind": kind, "depth": depth,
561
+ "returned": self.max_items, "total": total,
562
+ "dropped": total - self.max_items,
563
+ })
564
+
565
+ def _encoded(self, value: Any, shape: str) -> Dict[str, Any]:
566
+ """Encode one return value into a reply, carrying any truncation with it.
567
+
568
+ The `truncated` block is the whole point: a caller that reads `value` as
569
+ a complete answer is wrong exactly when this key is present, and it
570
+ cannot know that from the value alone.
571
+ """
572
+ self._encode_truncation = []
573
+ encoded = self._encode(value, shape=shape)
574
+ reply: Dict[str, Any] = {"value": encoded}
575
+ if self._encode_truncation:
576
+ dropped = sum(row["dropped"] for row in self._encode_truncation)
577
+ reply["truncated"] = {
578
+ "dropped": dropped,
579
+ "limit": self.max_items,
580
+ "containers": self._encode_truncation[:8],
581
+ "hint": (
582
+ "This reply is INCOMPLETE — the value is not evidence of how many "
583
+ "items exist. Re-read in smaller pieces, or count from the object "
584
+ "itself rather than from this list."
585
+ ),
586
+ }
587
+ self._encode_truncation = []
588
+ return reply
589
+
525
590
  def _decode(self, value: Any) -> Any:
526
591
  """Argument -> live object, rehydrating handles the bridge itself issued."""
527
592
  if isinstance(value, dict):
@@ -571,7 +636,7 @@ class ResolveOperations:
571
636
  "resolve_raised",
572
637
  f"Resolve raised while running {method}: {str(exc)[:200]}",
573
638
  )
574
- return {"value": self._encode(result, shape=f"{self._shape_of(target_key)}.{method}")}
639
+ return self._encoded(result, shape=f"{self._shape_of(target_key)}.{method}")
575
640
 
576
641
  def op_list_methods(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
577
642
  """The public attribute names on a target — what `hasattr` should answer.
@@ -690,7 +755,8 @@ class ResolveOperations:
690
755
  return {"kind": "none",
691
756
  "note": "present-but-None; on Resolve objects this is indistinguishable "
692
757
  "from absent, because getattr never raises"}
693
- return {"kind": "value", "value": self._encode(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")}
758
+ encoded = self._encoded(value, shape=f"{self._shape_of(arguments.get('target', 'resolve'))}.{name}")
759
+ return {"kind": "value", **encoded}
694
760
 
695
761
  def op_release_handles(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
696
762
  """Drop handles a client no longer needs, or all of them."""