davinci-resolve-mcp 2.203.0 → 2.205.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/CHANGELOG.md +100 -0
- package/README.md +28 -1
- package/README.zh-CN.md +12 -2
- package/bin/davinci-resolve-advanced-mcp.mjs +49 -1
- package/bin/davinci-resolve-mcp.mjs +174 -1
- package/docs/SKILL.md +53 -0
- package/docs/install.md +1 -0
- package/install.py +70 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +178 -6
- package/src/utils/operation_result.py +377 -0
- package/src/utils/readback.py +29 -0
package/package.json
CHANGED
package/src/granular/common.py
CHANGED
|
@@ -87,7 +87,7 @@ if not logging.getLogger().handlers:
|
|
|
87
87
|
handlers=[logging.StreamHandler()],
|
|
88
88
|
)
|
|
89
89
|
|
|
90
|
-
VERSION = "2.
|
|
90
|
+
VERSION = "2.205.0"
|
|
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.
|
|
14
|
+
VERSION = "2.205.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -60,6 +60,12 @@ from src.utils.page_lock import (
|
|
|
60
60
|
)
|
|
61
61
|
from src.utils.proc import safe_run
|
|
62
62
|
from src.utils.readback import verify_by_readback, verification_stats as _verification_stats
|
|
63
|
+
from src.utils import operation_result as _operation_result
|
|
64
|
+
from src.utils.operation_result import (
|
|
65
|
+
build_operation_envelope as _build_operation_envelope,
|
|
66
|
+
get_envelope_mode as _get_envelope_mode,
|
|
67
|
+
set_envelope_mode as _set_envelope_mode,
|
|
68
|
+
)
|
|
63
69
|
from src.utils.render_ids import (
|
|
64
70
|
render_codec_id_from_codecs as _render_codec_id_from_codecs,
|
|
65
71
|
render_format_id_from_formats as _render_format_id_from_formats,
|
|
@@ -1472,6 +1478,15 @@ def _guarded_action_name(args, kwargs) -> str:
|
|
|
1472
1478
|
return str(args[0]) if args else "?"
|
|
1473
1479
|
|
|
1474
1480
|
|
|
1481
|
+
def _guarded_params(args, kwargs) -> Optional[Dict[str, Any]]:
|
|
1482
|
+
"""This call's `params` dict, wherever in the signature it was passed."""
|
|
1483
|
+
if isinstance(kwargs.get("params"), dict):
|
|
1484
|
+
return kwargs["params"]
|
|
1485
|
+
if len(args) > 1 and isinstance(args[1], dict):
|
|
1486
|
+
return args[1]
|
|
1487
|
+
return None
|
|
1488
|
+
|
|
1489
|
+
|
|
1475
1490
|
def _guard_missing_params(fn):
|
|
1476
1491
|
"""Tool decorator: report a missing parameter instead of leaking a KeyError.
|
|
1477
1492
|
|
|
@@ -1489,21 +1504,35 @@ def _guard_missing_params(fn):
|
|
|
1489
1504
|
- **The signature is not (action, params).** `media_analysis` also takes
|
|
1490
1505
|
`ctx: Optional[Context]`, so the wrapper forwards `*args, **kwargs` rather
|
|
1491
1506
|
than naming parameters it does not know about.
|
|
1507
|
+
|
|
1508
|
+
It is also where the operation envelope is attached, for the same reason it
|
|
1509
|
+
is where the missing-parameter guard lives: this is the one seam every tool
|
|
1510
|
+
return passes through. In the default `dual` mode the payload is untouched
|
|
1511
|
+
and the envelope rides under `_operation` — see `src/utils/operation_result`
|
|
1512
|
+
for why it is namespaced rather than flattened.
|
|
1492
1513
|
"""
|
|
1514
|
+
tool_name = getattr(fn, "__name__", "tool")
|
|
1515
|
+
|
|
1493
1516
|
if inspect.iscoroutinefunction(fn):
|
|
1494
1517
|
@functools.wraps(fn)
|
|
1495
1518
|
async def wrapper(*args, **kwargs):
|
|
1519
|
+
action = _guarded_action_name(args, kwargs)
|
|
1496
1520
|
try:
|
|
1497
|
-
|
|
1521
|
+
result = await fn(*args, **kwargs)
|
|
1498
1522
|
except _MissingParam as exc:
|
|
1499
|
-
|
|
1523
|
+
result = _missing_param_error(exc, action)
|
|
1524
|
+
return _build_operation_envelope(
|
|
1525
|
+
tool_name, action, _guarded_params(args, kwargs), result)
|
|
1500
1526
|
else:
|
|
1501
1527
|
@functools.wraps(fn)
|
|
1502
1528
|
def wrapper(*args, **kwargs):
|
|
1529
|
+
action = _guarded_action_name(args, kwargs)
|
|
1503
1530
|
try:
|
|
1504
|
-
|
|
1531
|
+
result = fn(*args, **kwargs)
|
|
1505
1532
|
except _MissingParam as exc:
|
|
1506
|
-
|
|
1533
|
+
result = _missing_param_error(exc, action)
|
|
1534
|
+
return _build_operation_envelope(
|
|
1535
|
+
tool_name, action, _guarded_params(args, kwargs), result)
|
|
1507
1536
|
|
|
1508
1537
|
wrapper.__wrapped_by_missing_param_guard__ = True
|
|
1509
1538
|
return wrapper
|
|
@@ -5238,6 +5267,15 @@ def _timeline_ripple_insert_impl(proj, tl, p: Dict[str, Any], *, resolve=None) -
|
|
|
5238
5267
|
"readback": {"after_counts": after_counts, "missing": missing},
|
|
5239
5268
|
"gap_frames_by_track": gap_by_track,
|
|
5240
5269
|
"warnings": warnings,
|
|
5270
|
+
# Semantic delta for the operation envelope. Declared rather than
|
|
5271
|
+
# inferred: this action deletes and re-appends the tail to move it, so
|
|
5272
|
+
# a reader counting raw API calls would see a deletion that the edit
|
|
5273
|
+
# did not make.
|
|
5274
|
+
"_changes": {
|
|
5275
|
+
"items_added": len(built_inserts),
|
|
5276
|
+
"items_moved": len(tail_rows),
|
|
5277
|
+
"items_deleted": 0,
|
|
5278
|
+
},
|
|
5241
5279
|
}
|
|
5242
5280
|
if failures:
|
|
5243
5281
|
result["failures"] = failures
|
|
@@ -10215,6 +10253,63 @@ def _read_json_strict(path: str) -> Dict[str, Any]:
|
|
|
10215
10253
|
return payload if isinstance(payload, dict) else {}
|
|
10216
10254
|
|
|
10217
10255
|
|
|
10256
|
+
_SERVER_PREFS_ENV = "RESOLVE_MCP_SERVER_PREFS"
|
|
10257
|
+
|
|
10258
|
+
|
|
10259
|
+
def _server_preferences_path() -> str:
|
|
10260
|
+
"""Where server-general defaults live.
|
|
10261
|
+
|
|
10262
|
+
Separate from the media-analysis preferences file: these are settings about
|
|
10263
|
+
how the server answers, not about how it analyses media, and mixing them
|
|
10264
|
+
would make either file's name a lie.
|
|
10265
|
+
"""
|
|
10266
|
+
override = os.environ.get(_SERVER_PREFS_ENV)
|
|
10267
|
+
if override:
|
|
10268
|
+
return os.path.realpath(os.path.abspath(os.path.expanduser(override)))
|
|
10269
|
+
return os.path.join(project_dir, "logs", "server-preferences.json")
|
|
10270
|
+
|
|
10271
|
+
|
|
10272
|
+
def _read_server_preferences() -> Dict[str, Any]:
|
|
10273
|
+
try:
|
|
10274
|
+
return _read_json_strict(_server_preferences_path())
|
|
10275
|
+
except ConfigParseError:
|
|
10276
|
+
return {}
|
|
10277
|
+
|
|
10278
|
+
|
|
10279
|
+
def _write_server_preferences(preferences: Dict[str, Any]) -> None:
|
|
10280
|
+
# Same atomic replace as the other preference stores: a crash mid-write must
|
|
10281
|
+
# not truncate a file whose reader falls back to {}, since the next save
|
|
10282
|
+
# would then persist that empty state over the user's settings.
|
|
10283
|
+
path = _server_preferences_path()
|
|
10284
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
10285
|
+
tmp_path = f"{path}.tmp-{os.getpid()}-{threading.get_ident()}-{time.time_ns()}"
|
|
10286
|
+
try:
|
|
10287
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
10288
|
+
json.dump(preferences, handle, indent=2, sort_keys=True)
|
|
10289
|
+
handle.write("\n")
|
|
10290
|
+
os.replace(tmp_path, path)
|
|
10291
|
+
finally:
|
|
10292
|
+
try:
|
|
10293
|
+
os.remove(tmp_path)
|
|
10294
|
+
except OSError:
|
|
10295
|
+
pass
|
|
10296
|
+
|
|
10297
|
+
|
|
10298
|
+
def _apply_persisted_envelope_mode() -> str:
|
|
10299
|
+
"""Restore the saved result_envelope default at startup.
|
|
10300
|
+
|
|
10301
|
+
An explicit RESOLVE_MCP_RESULT_ENVELOPE wins: a process-level override is a
|
|
10302
|
+
deliberate act for this run, and a saved preference should not silently
|
|
10303
|
+
outrank it.
|
|
10304
|
+
"""
|
|
10305
|
+
if os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE"):
|
|
10306
|
+
return _get_envelope_mode()
|
|
10307
|
+
saved = _read_server_preferences().get("result_envelope")
|
|
10308
|
+
if isinstance(saved, str):
|
|
10309
|
+
_set_envelope_mode(saved)
|
|
10310
|
+
return _get_envelope_mode()
|
|
10311
|
+
|
|
10312
|
+
|
|
10218
10313
|
def _read_media_analysis_preferences() -> Dict[str, Any]:
|
|
10219
10314
|
path = _media_analysis_preferences_path()
|
|
10220
10315
|
try:
|
|
@@ -15091,13 +15186,59 @@ def _setup_updates_defaults() -> Dict[str, Any]:
|
|
|
15091
15186
|
}
|
|
15092
15187
|
|
|
15093
15188
|
|
|
15189
|
+
def _setup_general_defaults() -> Dict[str, Any]:
|
|
15190
|
+
return {
|
|
15191
|
+
"result_envelope": _get_envelope_mode(),
|
|
15192
|
+
"options": {"result_envelope": list(_operation_result.MODES)},
|
|
15193
|
+
"preferences_path": _server_preferences_path(),
|
|
15194
|
+
}
|
|
15195
|
+
|
|
15196
|
+
|
|
15094
15197
|
def _setup_defaults_snapshot() -> Dict[str, Any]:
|
|
15095
15198
|
return {
|
|
15199
|
+
"general": _setup_general_defaults(),
|
|
15096
15200
|
"media_analysis": _setup_media_analysis_defaults(),
|
|
15097
15201
|
"updates": _setup_updates_defaults(),
|
|
15098
15202
|
}
|
|
15099
15203
|
|
|
15100
15204
|
|
|
15205
|
+
def _setup_set_general_defaults(general: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
|
|
15206
|
+
"""Persist server-general defaults. Currently just the result envelope."""
|
|
15207
|
+
if not general:
|
|
15208
|
+
return {"changed": False, "recognized": False}
|
|
15209
|
+
|
|
15210
|
+
mode = _first_param(
|
|
15211
|
+
general, "result_envelope", "resultEnvelope", "envelope_mode", "envelopeMode",
|
|
15212
|
+
default=None)
|
|
15213
|
+
if mode is None:
|
|
15214
|
+
return {"changed": False, "recognized": False}
|
|
15215
|
+
|
|
15216
|
+
cleaned = str(mode).strip().lower()
|
|
15217
|
+
if cleaned not in _operation_result.MODES:
|
|
15218
|
+
return _err(
|
|
15219
|
+
f"result_envelope must be one of {', '.join(_operation_result.MODES)}",
|
|
15220
|
+
code="INVALID_ENUM_VALUE", category="validation",
|
|
15221
|
+
state={"result_envelope": mode})
|
|
15222
|
+
|
|
15223
|
+
if dry_run:
|
|
15224
|
+
return {"changed": True, "recognized": True, "result_envelope": cleaned,
|
|
15225
|
+
"current": _get_envelope_mode()}
|
|
15226
|
+
|
|
15227
|
+
# Persist before applying: a saved setting that does not survive a restart
|
|
15228
|
+
# is a setting the caller was told they changed and did not.
|
|
15229
|
+
try:
|
|
15230
|
+
preferences = _read_json_strict(_server_preferences_path())
|
|
15231
|
+
except ConfigParseError as exc:
|
|
15232
|
+
return _err(
|
|
15233
|
+
f"server preferences file is unreadable: {exc}",
|
|
15234
|
+
code="CONFIG_UNREADABLE", category="state",
|
|
15235
|
+
remediation=f"Repair or delete {_server_preferences_path()}, then retry.")
|
|
15236
|
+
preferences["result_envelope"] = cleaned
|
|
15237
|
+
_write_server_preferences(preferences)
|
|
15238
|
+
_set_envelope_mode(cleaned)
|
|
15239
|
+
return {"changed": True, "recognized": True, "result_envelope": _get_envelope_mode()}
|
|
15240
|
+
|
|
15241
|
+
|
|
15101
15242
|
def _setup_set_media_analysis_defaults(media_defaults: Dict[str, Any], dry_run: bool) -> Dict[str, Any]:
|
|
15102
15243
|
if not media_defaults:
|
|
15103
15244
|
return {"changed": False, "recognized": False}
|
|
@@ -15691,6 +15832,17 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15691
15832
|
},
|
|
15692
15833
|
"updates.check_interval_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
|
|
15693
15834
|
"updates.snooze_hours": {"values": "number >= 0.1", "storage": str(update_state_path(project_dir))},
|
|
15835
|
+
"general.result_envelope": {
|
|
15836
|
+
"description": (
|
|
15837
|
+
"Where the operation envelope goes. 'dual' (default) leaves the "
|
|
15838
|
+
"payload untouched and adds the envelope under '_operation'; "
|
|
15839
|
+
"'pure' returns only the envelope with the payload under 'result'; "
|
|
15840
|
+
"'legacy' adds nothing. Override per call with params={'envelope': ...}."
|
|
15841
|
+
),
|
|
15842
|
+
"values": list(_operation_result.MODES),
|
|
15843
|
+
"current": _get_envelope_mode(),
|
|
15844
|
+
"storage": _server_preferences_path(),
|
|
15845
|
+
},
|
|
15694
15846
|
},
|
|
15695
15847
|
}
|
|
15696
15848
|
|
|
@@ -15702,12 +15854,23 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15702
15854
|
if action in {"set_defaults", "set", "configure"}:
|
|
15703
15855
|
defaults = p.get("defaults") if isinstance(p.get("defaults"), dict) else {}
|
|
15704
15856
|
merged = {**defaults, **{k: v for k, v in p.items() if k != "defaults"}}
|
|
15857
|
+
# media_analysis owns every unclaimed key, so anything another setter
|
|
15858
|
+
# owns has to be named here or it lands in the wrong store.
|
|
15859
|
+
_general_keys = {
|
|
15860
|
+
"general", "result_envelope", "resultEnvelope",
|
|
15861
|
+
"envelope_mode", "envelopeMode",
|
|
15862
|
+
}
|
|
15863
|
+
general_defaults = {
|
|
15864
|
+
**_setup_nested(merged, "general"),
|
|
15865
|
+
**{k: v for k, v in merged.items() if k in _general_keys and k != "general"},
|
|
15866
|
+
}
|
|
15705
15867
|
media_defaults = {
|
|
15706
15868
|
**_setup_nested(merged, "media_analysis", "mediaAnalysis"),
|
|
15707
15869
|
**{
|
|
15708
15870
|
key: value
|
|
15709
15871
|
for key, value in merged.items()
|
|
15710
15872
|
if key not in {"updates", "mcp_updates", "mcpUpdates", "dry_run", "dryRun"}
|
|
15873
|
+
and key not in _general_keys
|
|
15711
15874
|
},
|
|
15712
15875
|
**({
|
|
15713
15876
|
"timed_markers_default": _first_param(
|
|
@@ -15758,19 +15921,25 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
15758
15921
|
} if any(key in merged for key in ("snooze_hours", "snoozeHours", "update_snooze_hours", "updateSnoozeHours")) else {}),
|
|
15759
15922
|
}
|
|
15760
15923
|
|
|
15924
|
+
general_result = _setup_set_general_defaults(general_defaults, dry_run)
|
|
15925
|
+
if general_result.get("error"):
|
|
15926
|
+
return general_result
|
|
15761
15927
|
media_result = _setup_set_media_analysis_defaults(media_defaults, dry_run)
|
|
15762
15928
|
if media_result.get("error"):
|
|
15763
15929
|
return media_result
|
|
15764
15930
|
update_result = _setup_set_updates_defaults(update_defaults, dry_run)
|
|
15765
15931
|
if update_result.get("error"):
|
|
15766
15932
|
return update_result
|
|
15767
|
-
recognized =
|
|
15933
|
+
recognized = (bool(general_result.get("recognized"))
|
|
15934
|
+
or bool(media_result.get("recognized"))
|
|
15935
|
+
or bool(update_result.get("recognized")))
|
|
15768
15936
|
if not recognized:
|
|
15769
15937
|
return _err("set_defaults did not receive a recognized default to set")
|
|
15770
15938
|
|
|
15771
15939
|
return _ok(
|
|
15772
15940
|
dry_run=dry_run,
|
|
15773
15941
|
changes={
|
|
15942
|
+
"general": general_result,
|
|
15774
15943
|
"media_analysis": media_result,
|
|
15775
15944
|
"updates": update_result,
|
|
15776
15945
|
},
|
|
@@ -31310,6 +31479,9 @@ def _install_threaded_tool_dispatch(fastmcp) -> int:
|
|
|
31310
31479
|
|
|
31311
31480
|
|
|
31312
31481
|
if __name__ == "__main__":
|
|
31482
|
+
# Before any tool can answer: a saved result_envelope default has to be in
|
|
31483
|
+
# force on the first call, not the second.
|
|
31484
|
+
logger.info(f"Result envelope mode: {_apply_persisted_envelope_mode()}")
|
|
31313
31485
|
start_background_update_check(VERSION, project_dir, logger, env=_setup_update_env())
|
|
31314
31486
|
_install_threaded_tool_dispatch(mcp)
|
|
31315
31487
|
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""A standard operation envelope over every compound tool's return value.
|
|
2
|
+
|
|
3
|
+
Agents orchestrating multi-turn edits have to answer the same three questions
|
|
4
|
+
after every call — did it actually happen, was it verified, and what changed —
|
|
5
|
+
and today each tool answers them in its own vocabulary: ``readback.missing``,
|
|
6
|
+
``succeeded``/``failed``, ``partial``, ``status: "confirmation_required"``.
|
|
7
|
+
This module normalizes those into one shape so the answer is in the same place
|
|
8
|
+
every time.
|
|
9
|
+
|
|
10
|
+
Adapted from the design contributed in PR #181.
|
|
11
|
+
|
|
12
|
+
**Where the envelope lives.** Its keys are namespaced under a single reserved
|
|
13
|
+
key rather than merged into the top level, because half of them are already
|
|
14
|
+
domain keys here and merging silently destroys them:
|
|
15
|
+
|
|
16
|
+
status 22 sites — a background job's "done", a transcription's
|
|
17
|
+
"Transcribed", a confirm gate's "confirmation_required"
|
|
18
|
+
operation 20 sites
|
|
19
|
+
warnings 15 sites
|
|
20
|
+
result 8 sites
|
|
21
|
+
changes 2 sites
|
|
22
|
+
|
|
23
|
+
Flattening the envelope over those rewrites `job_status`'s "done" to "success"
|
|
24
|
+
(an agent polling a job then never sees it finish) and a confirm gate's
|
|
25
|
+
"confirmation_required" to "blocked" — renaming the very signal the envelope
|
|
26
|
+
exists to make unambiguous. So in the default ``dual`` mode the raw payload is
|
|
27
|
+
passed through **untouched** and the envelope is added under ``_operation``,
|
|
28
|
+
following the existing ``_versioning`` private-key convention. Nothing is
|
|
29
|
+
shadowed, nothing is dropped, and there is still exactly one place to look.
|
|
30
|
+
|
|
31
|
+
Modes:
|
|
32
|
+
``dual`` (default) raw payload verbatim + ``_operation``.
|
|
33
|
+
``pure`` only the envelope, domain payload nested under ``result``. Opt in
|
|
34
|
+
per call with ``params={"envelope": "pure"}``, per session with
|
|
35
|
+
``setup(action="set_defaults", params={"result_envelope": "pure"})``,
|
|
36
|
+
or per process with ``RESOLVE_MCP_RESULT_ENVELOPE=pure``.
|
|
37
|
+
``legacy`` no envelope at all.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import uuid
|
|
45
|
+
from typing import Any, Dict, List, Optional
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger("resolve-mcp.operation-result")
|
|
48
|
+
|
|
49
|
+
#: The reserved key the envelope hangs off in ``dual`` mode. Verified unused as
|
|
50
|
+
#: a domain key across ``src/`` — ``test_envelope_key_stays_reserved`` keeps it
|
|
51
|
+
#: that way, since the day a tool returns its own ``_operation`` is the day this
|
|
52
|
+
#: mode starts destroying payloads the way the flat one did.
|
|
53
|
+
ENVELOPE_KEY = "_operation"
|
|
54
|
+
|
|
55
|
+
MODES = ("dual", "pure", "legacy")
|
|
56
|
+
DEFAULT_MODE = "dual"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean_mode(mode: Any) -> Optional[str]:
|
|
60
|
+
if not isinstance(mode, str):
|
|
61
|
+
return None
|
|
62
|
+
candidate = mode.strip().lower()
|
|
63
|
+
return candidate if candidate in MODES else None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
_CURRENT_ENVELOPE_MODE = (
|
|
67
|
+
_clean_mode(os.environ.get("RESOLVE_MCP_RESULT_ENVELOPE")) or DEFAULT_MODE
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_envelope_mode() -> str:
|
|
72
|
+
"""The envelope mode applied when a call does not name one."""
|
|
73
|
+
return _CURRENT_ENVELOPE_MODE
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def set_envelope_mode(mode: str) -> str:
|
|
77
|
+
"""Set the default envelope mode. Unknown values leave it unchanged."""
|
|
78
|
+
global _CURRENT_ENVELOPE_MODE
|
|
79
|
+
cleaned = _clean_mode(mode)
|
|
80
|
+
if cleaned:
|
|
81
|
+
_CURRENT_ENVELOPE_MODE = cleaned
|
|
82
|
+
return _CURRENT_ENVELOPE_MODE
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def new_execution_id() -> str:
|
|
86
|
+
"""A compact id for correlating one tool call across logs and transcripts."""
|
|
87
|
+
return f"exec_{uuid.uuid4().hex[:12]}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ─── Status ──────────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def normalize_status(raw: Any) -> str:
|
|
93
|
+
"""Reduce a result to 'success' | 'partial' | 'blocked' | 'failed'.
|
|
94
|
+
|
|
95
|
+
Deliberately narrow. Every rule below keys on a convention this repo
|
|
96
|
+
actually uses, checked against the call sites — a heuristic that guesses
|
|
97
|
+
from a plausible-looking key name produces exactly the confident-and-wrong
|
|
98
|
+
status the envelope is supposed to eliminate. The clearest example is
|
|
99
|
+
``blocked``: it reads like a gate flag, but here it is a domain key holding
|
|
100
|
+
the *list of targets that could not be resolved* (`timeline` range delete,
|
|
101
|
+
`timeline_item_color.bulk_match_to_hero`), and a successful dry-run carries
|
|
102
|
+
a non-empty one. Keying on it would report a gate that never happened.
|
|
103
|
+
"""
|
|
104
|
+
if not isinstance(raw, dict):
|
|
105
|
+
return "success" if bool(raw) else "failed"
|
|
106
|
+
|
|
107
|
+
# The confirm gate. `_issue_confirm_token` is the single producer of these
|
|
108
|
+
# responses and always stamps this exact status, so the check is exact
|
|
109
|
+
# rather than inferred.
|
|
110
|
+
if raw.get("status") == "confirmation_required" or raw.get("confirmation_required") is True:
|
|
111
|
+
return "blocked"
|
|
112
|
+
|
|
113
|
+
if raw.get("error"):
|
|
114
|
+
return "failed"
|
|
115
|
+
if raw.get("success") is False:
|
|
116
|
+
return "failed"
|
|
117
|
+
|
|
118
|
+
# Bulk operations set this explicitly when some units failed.
|
|
119
|
+
if raw.get("partial") is True:
|
|
120
|
+
return "partial"
|
|
121
|
+
succeeded, failed = raw.get("succeeded"), raw.get("failed")
|
|
122
|
+
if isinstance(succeeded, int) and isinstance(failed, int) and succeeded > 0 and failed > 0:
|
|
123
|
+
return "partial"
|
|
124
|
+
|
|
125
|
+
return "success"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ─── Warnings ────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
def extract_warnings(raw: Any) -> List[str]:
|
|
131
|
+
"""Every advisory the payload carries, as a flat list of strings."""
|
|
132
|
+
if not isinstance(raw, dict):
|
|
133
|
+
return []
|
|
134
|
+
|
|
135
|
+
out: List[str] = []
|
|
136
|
+
|
|
137
|
+
def add(value: Any) -> None:
|
|
138
|
+
if isinstance(value, str) and value.strip() and value.strip() not in out:
|
|
139
|
+
out.append(value.strip())
|
|
140
|
+
|
|
141
|
+
plural = raw.get("warnings")
|
|
142
|
+
if isinstance(plural, list):
|
|
143
|
+
for item in plural:
|
|
144
|
+
add(item if isinstance(item, str) else str(item) if item else None)
|
|
145
|
+
else:
|
|
146
|
+
add(plural)
|
|
147
|
+
add(raw.get("warning"))
|
|
148
|
+
|
|
149
|
+
ignored = raw.get("ignored_options")
|
|
150
|
+
if ignored:
|
|
151
|
+
add(f"Ignored unsupported options: {ignored}")
|
|
152
|
+
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─── Verification ────────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def extract_verification(raw: Any) -> Dict[str, Any]:
|
|
159
|
+
"""Normalize whatever verification evidence the payload carries.
|
|
160
|
+
|
|
161
|
+
'unverified' means *no evidence was reported*, which is not the same as
|
|
162
|
+
'checked and fine' — a caller that needs certainty should treat it as an
|
|
163
|
+
open question rather than a pass.
|
|
164
|
+
"""
|
|
165
|
+
unverified = {"status": "unverified", "checks": [], "contradiction": False}
|
|
166
|
+
if not isinstance(raw, dict):
|
|
167
|
+
return unverified
|
|
168
|
+
|
|
169
|
+
# An impl that already speaks this shape wins outright.
|
|
170
|
+
existing = raw.get("verification")
|
|
171
|
+
if isinstance(existing, dict) and existing:
|
|
172
|
+
merged = dict(existing)
|
|
173
|
+
merged.setdefault(
|
|
174
|
+
"status", "passed" if existing.get("verified") else "unverified")
|
|
175
|
+
merged.setdefault("checks", [])
|
|
176
|
+
merged.setdefault("contradiction", False)
|
|
177
|
+
return merged
|
|
178
|
+
|
|
179
|
+
checks: List[Dict[str, Any]] = []
|
|
180
|
+
status = "unverified"
|
|
181
|
+
contradiction = False
|
|
182
|
+
|
|
183
|
+
readback = raw.get("readback")
|
|
184
|
+
if isinstance(readback, dict):
|
|
185
|
+
missing = readback.get("missing")
|
|
186
|
+
if isinstance(missing, list):
|
|
187
|
+
checks.append({
|
|
188
|
+
"check": "readback_verification",
|
|
189
|
+
"passed": not missing,
|
|
190
|
+
"missing_items": len(missing),
|
|
191
|
+
})
|
|
192
|
+
status = "passed" if not missing else "failed"
|
|
193
|
+
|
|
194
|
+
# verify_by_readback's own shape: a mutation that reported success while the
|
|
195
|
+
# post-state disagrees is a contradiction, this repo's single most valuable
|
|
196
|
+
# reliability signal — it must not be flattened into a plain failure.
|
|
197
|
+
if "verified" in raw:
|
|
198
|
+
verified = bool(raw["verified"])
|
|
199
|
+
contradiction = bool(raw.get("contradiction"))
|
|
200
|
+
checks.append({
|
|
201
|
+
"check": "readback_post_state",
|
|
202
|
+
"passed": verified,
|
|
203
|
+
"contradiction": contradiction,
|
|
204
|
+
"observed": raw.get("observed"),
|
|
205
|
+
})
|
|
206
|
+
status = "contradiction" if contradiction else ("passed" if verified else "failed")
|
|
207
|
+
|
|
208
|
+
if "property_restore_failures" in raw:
|
|
209
|
+
failures = _as_int(raw.get("property_restore_failures"))
|
|
210
|
+
checks.append({
|
|
211
|
+
"check": "property_restore",
|
|
212
|
+
"passed": failures == 0,
|
|
213
|
+
"restored_items": _as_int(raw.get("properties_restored_items")),
|
|
214
|
+
"failures": failures,
|
|
215
|
+
})
|
|
216
|
+
if failures and status in ("passed", "unverified"):
|
|
217
|
+
status = "partial"
|
|
218
|
+
|
|
219
|
+
succeeded, failed = raw.get("succeeded"), raw.get("failed")
|
|
220
|
+
if isinstance(succeeded, int) and isinstance(failed, int):
|
|
221
|
+
checks.append({
|
|
222
|
+
"check": "bulk_operations",
|
|
223
|
+
"passed": failed == 0,
|
|
224
|
+
"succeeded": succeeded,
|
|
225
|
+
"failed": failed,
|
|
226
|
+
})
|
|
227
|
+
if status == "unverified":
|
|
228
|
+
if failed == 0 and succeeded > 0:
|
|
229
|
+
status = "passed"
|
|
230
|
+
elif succeeded > 0:
|
|
231
|
+
status = "partial"
|
|
232
|
+
elif failed > 0:
|
|
233
|
+
status = "failed"
|
|
234
|
+
|
|
235
|
+
if not checks:
|
|
236
|
+
return unverified
|
|
237
|
+
return {"status": status, "checks": checks, "contradiction": contradiction}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ─── Changes ─────────────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
def _as_int(value: Any) -> int:
|
|
243
|
+
try:
|
|
244
|
+
return int(value)
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
#: Domain key → semantic delta. Every entry is a key that exists in `src/` and
|
|
250
|
+
#: means what the delta says it means; nothing is mapped on the strength of a
|
|
251
|
+
#: suggestive name. `properties_restored_items`, for instance, is deliberately
|
|
252
|
+
#: absent: a ripple insert restores clip properties it had to re-apply after
|
|
253
|
+
#: moving items, which is bookkeeping, not an edit the user made.
|
|
254
|
+
_COUNT_ALIASES = {
|
|
255
|
+
"items_added": ("inserted_clips",),
|
|
256
|
+
"items_moved": ("tail_items_shifted",),
|
|
257
|
+
"items_deleted": (),
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def extract_changes(raw: Any) -> Optional[Dict[str, Any]]:
|
|
262
|
+
"""The operation's semantic delta, or None when it did not report one.
|
|
263
|
+
|
|
264
|
+
None, not ``{}``. An empty dict reads as "this operation changed nothing",
|
|
265
|
+
which is a false statement about a ripple insert that simply never declared
|
|
266
|
+
its deltas — the silent-lie failure this codebase treats as a bug class. A
|
|
267
|
+
caller that needs a number and gets None knows to go and count.
|
|
268
|
+
"""
|
|
269
|
+
if not isinstance(raw, dict):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
changes: Dict[str, Any] = {}
|
|
273
|
+
|
|
274
|
+
# An impl that declares its own deltas is authoritative.
|
|
275
|
+
declared = raw.get("_changes")
|
|
276
|
+
if isinstance(declared, dict):
|
|
277
|
+
changes.update(declared)
|
|
278
|
+
|
|
279
|
+
for canonical, aliases in _COUNT_ALIASES.items():
|
|
280
|
+
if canonical in raw:
|
|
281
|
+
changes[canonical] = _as_int(raw[canonical])
|
|
282
|
+
continue
|
|
283
|
+
for alias in aliases:
|
|
284
|
+
if alias in raw:
|
|
285
|
+
changes[canonical] = _as_int(raw[alias])
|
|
286
|
+
break
|
|
287
|
+
|
|
288
|
+
if "shift_frames" in raw:
|
|
289
|
+
changes["shift_frames"] = raw["shift_frames"]
|
|
290
|
+
|
|
291
|
+
versioning = raw.get("_versioning")
|
|
292
|
+
if isinstance(versioning, dict):
|
|
293
|
+
metric = versioning.get("metric")
|
|
294
|
+
before, after = versioning.get("before_value"), versioning.get("after_value")
|
|
295
|
+
if metric and before is not None and after is not None:
|
|
296
|
+
changes["metric"] = metric
|
|
297
|
+
changes["before_value"] = before
|
|
298
|
+
changes["after_value"] = after
|
|
299
|
+
try:
|
|
300
|
+
changes["delta"] = round(float(after) - float(before), 4)
|
|
301
|
+
except (TypeError, ValueError):
|
|
302
|
+
pass
|
|
303
|
+
if versioning.get("archived"):
|
|
304
|
+
changes["timeline_archived"] = True
|
|
305
|
+
if versioning.get("archived_version"):
|
|
306
|
+
changes["archived_version"] = versioning["archived_version"]
|
|
307
|
+
|
|
308
|
+
return changes or None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ─── Assembly ────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
def is_passthrough(obj: Any) -> bool:
|
|
314
|
+
"""MCP content objects (Image, TextContent, EmbeddedResource) go through as-is."""
|
|
315
|
+
return getattr(obj.__class__, "__name__", "") in {
|
|
316
|
+
"Image", "TextContent", "EmbeddedResource"
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _requested_mode(params: Optional[Dict[str, Any]], fallback: Optional[str]) -> str:
|
|
321
|
+
if isinstance(params, dict):
|
|
322
|
+
for key in ("envelope", "_envelope"):
|
|
323
|
+
if key in params:
|
|
324
|
+
named = _clean_mode(params[key])
|
|
325
|
+
if named:
|
|
326
|
+
return named
|
|
327
|
+
if isinstance(params[key], bool):
|
|
328
|
+
return "pure" if params[key] else "legacy"
|
|
329
|
+
return _clean_mode(fallback) or get_envelope_mode()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def build_operation_envelope(
|
|
333
|
+
tool_name: str,
|
|
334
|
+
action: str,
|
|
335
|
+
params: Optional[Dict[str, Any]],
|
|
336
|
+
raw_result: Any,
|
|
337
|
+
*,
|
|
338
|
+
execution_id: Optional[str] = None,
|
|
339
|
+
mode: Optional[str] = None,
|
|
340
|
+
) -> Any:
|
|
341
|
+
"""Attach the operation envelope to one action's return value."""
|
|
342
|
+
if is_passthrough(raw_result):
|
|
343
|
+
return raw_result
|
|
344
|
+
|
|
345
|
+
selected = _requested_mode(params, mode)
|
|
346
|
+
if selected == "legacy":
|
|
347
|
+
return raw_result
|
|
348
|
+
|
|
349
|
+
envelope: Dict[str, Any] = {
|
|
350
|
+
"status": normalize_status(raw_result),
|
|
351
|
+
"operation": f"{tool_name}.{action}",
|
|
352
|
+
"execution_id": execution_id or new_execution_id(),
|
|
353
|
+
"verification": extract_verification(raw_result),
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
changes = extract_changes(raw_result)
|
|
357
|
+
if changes is not None:
|
|
358
|
+
envelope["changes"] = changes
|
|
359
|
+
|
|
360
|
+
warnings = extract_warnings(raw_result)
|
|
361
|
+
if warnings:
|
|
362
|
+
envelope["warnings"] = warnings
|
|
363
|
+
|
|
364
|
+
versioning = raw_result.get("_versioning") if isinstance(raw_result, dict) else None
|
|
365
|
+
if isinstance(versioning, dict) and versioning.get("analysis_run_id"):
|
|
366
|
+
envelope["run_id"] = versioning["analysis_run_id"]
|
|
367
|
+
|
|
368
|
+
if selected == "pure":
|
|
369
|
+
return {**envelope, "result": raw_result}
|
|
370
|
+
|
|
371
|
+
# dual: the payload is passed through byte-for-byte and the envelope rides
|
|
372
|
+
# alongside it. A non-dict return has nowhere to carry the key, so it is
|
|
373
|
+
# handed back unchanged rather than being boxed into a shape callers of
|
|
374
|
+
# that action have never seen.
|
|
375
|
+
if not isinstance(raw_result, dict):
|
|
376
|
+
return raw_result
|
|
377
|
+
return {**raw_result, ENVELOPE_KEY: envelope}
|