davinci-resolve-mcp 2.99.2 → 2.100.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/AGENTS.md +4 -0
- package/CHANGELOG.md +120 -0
- package/README.md +5 -4
- package/README.zh-CN.md +5 -5
- package/docs/SKILL.md +27 -1
- package/docs/contributing.md +1 -1
- package/docs/install.md +1 -1
- package/docs/reference/api-coverage.md +2 -2
- package/docs/reference/api-limitations.md +6 -6
- package/install.py +1 -1
- package/package.json +1 -1
- package/src/granular/common.py +1 -1
- package/src/server.py +118 -4
- package/src/utils/api_truth.py +27 -22
- package/src/utils/knowledge.py +594 -0
- package/src/utils/resolve_bridge_client.py +33 -0
package/AGENTS.md
CHANGED
|
@@ -66,6 +66,10 @@ semantics.
|
|
|
66
66
|
- Public overview, current stats, and docs map: `README.md`
|
|
67
67
|
- Historical release notes: `CHANGELOG.md`
|
|
68
68
|
- AI assistant operating reference: `docs/SKILL.md`
|
|
69
|
+
- Craft guidance for non-Claude-Code clients: the `knowledge` tool serves
|
|
70
|
+
`.claude/skills/`, `docs/guides/`, and `docs/kernels/` as resolved prose over
|
|
71
|
+
MCP. When you add a skill, guide, or kernel it is indexed automatically — a
|
|
72
|
+
drift guard fails the suite if anything in those directories is unreachable
|
|
69
73
|
- Release checklist and validation rules: `docs/process/release-process.md`
|
|
70
74
|
- Kernel workflow support maps: `docs/kernels/`
|
|
71
75
|
- API coverage and live-test status: `docs/reference/api-coverage.md`
|
package/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,128 @@
|
|
|
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.100.0
|
|
6
|
+
|
|
7
|
+
**The craft guidance is now readable by any MCP client.** This repository carries a
|
|
8
|
+
real body of editorial, colour, and audio guidance — how to tighten a take without
|
|
9
|
+
cutting the breath out of it, what frames to look at before applying a grade, which
|
|
10
|
+
API calls silently lie. It lived in `.claude/skills/`, `docs/guides/`, and
|
|
11
|
+
`docs/kernels/`, and it was reachable only by an agent with this checkout on disk.
|
|
12
|
+
|
|
13
|
+
Over MCP there is no checkout. A skill that says "open
|
|
14
|
+
`docs/guides/color-decision-guide.md`" is a dead end on Codex, Cursor, or a bare SDK
|
|
15
|
+
loop: the pointer resolves to nothing, and the agent operates the tools without ever
|
|
16
|
+
seeing the reasoning that makes the operation correct.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **`knowledge` tool** (36th compound tool) — the corpus served as prose, with no
|
|
21
|
+
Resolve connection involved:
|
|
22
|
+
- `topics(category?)` — the index: id, summary, size, sections, related topics.
|
|
23
|
+
35 topics across `workflow`, `guide`, `kernel`, `reference`, and `repo`.
|
|
24
|
+
- `get(topic, section?, inline?)` — resolved prose. Natural aliases (`"tighten"`,
|
|
25
|
+
`"dead air"`, `"grading"`) resolve to real topics, and referenced guides and
|
|
26
|
+
kernels arrive **inlined**, so what comes back is the manual rather than a path
|
|
27
|
+
to it. `section` returns one heading's subtree.
|
|
28
|
+
- `search(query, limit?)` — ranked topics with excerpts.
|
|
29
|
+
- `capabilities()` — topic counts by category and the corpus directories.
|
|
30
|
+
- **`knowledge://topics` MCP resource** — the same index, so hosts that consume
|
|
31
|
+
resources can see what guidance exists without spending a turn on it.
|
|
32
|
+
- `setup(action="schema")` now names the guidance, because an agent's orientation
|
|
33
|
+
call is where it will actually be noticed.
|
|
34
|
+
|
|
35
|
+
### Design notes
|
|
36
|
+
|
|
37
|
+
- **Inlining stops at one level.** Following references transitively would turn a
|
|
38
|
+
150-line answer into the whole `docs/` tree.
|
|
39
|
+
- **Oversized references are summarised, not truncated.** Over the inline budget an
|
|
40
|
+
agent gets the title, summary, section list, and the topic id to fetch — a
|
|
41
|
+
truncated prefix is the first N lines, which is rarely the part that answers the
|
|
42
|
+
question.
|
|
43
|
+
- **`reference` topics are terminal.** The 2250-line operating reference and the
|
|
44
|
+
generated API ledgers cross-link each other freely; inlining from them doubles a
|
|
45
|
+
document that was already complete.
|
|
46
|
+
- **An unknown section is an error that lists the real ones**, never a quiet return
|
|
47
|
+
of the whole document.
|
|
48
|
+
- **Search matches whole words.** Substring counting ranked `resolve-audio` top for
|
|
49
|
+
"dead air", because "air" is inside "F-air-light". Body hits are also normalised by
|
|
50
|
+
document length, so the longest document cannot win on mass alone.
|
|
51
|
+
|
|
52
|
+
### Guarded against drift
|
|
53
|
+
|
|
54
|
+
A test asserts every skill, guide, and kernel in the corpus reaches the index, and
|
|
55
|
+
that every alias points at a topic that exists. Knowledge added to this repository
|
|
56
|
+
later cannot go silently unserved — the failure mode a hand-kept list has every time.
|
|
57
|
+
|
|
58
|
+
### Validation
|
|
59
|
+
|
|
60
|
+
- Offline suite: 2849 passed, 1 skipped, 711 subtests, 0 failures.
|
|
61
|
+
- Three deliberate mutations (substring search, inlining disabled, unknown section
|
|
62
|
+
returning the whole document) were each caught by the new tests.
|
|
63
|
+
- No Resolve behavior changed; live test not required. A test asserts the tool never
|
|
64
|
+
reaches for a Resolve connection.
|
|
65
|
+
|
|
66
|
+
## What's New in v2.99.3
|
|
67
|
+
|
|
68
|
+
**Fusion authoring now works on the free edition.** v2.99.2 documented that
|
|
69
|
+
`fusion_comp add_tool` could not run there, because the in-app bridge reported
|
|
70
|
+
`GetAttrs`/`SetAttrs` as absent on a Fusion tool and `add_tool` calls `GetAttrs`
|
|
71
|
+
to build its return value — which took every server-authored Fusion graph with
|
|
72
|
+
it. Investigating the fallback found the premise was wrong: **those methods are
|
|
73
|
+
present and work.** Invoked directly on free 21.0.3.7, `GetAttrs` returned
|
|
74
|
+
`{TOOLS_Name: "Blur1", TOOLS_RegID: "Blur"}` and `SetAttrs` renamed the tool.
|
|
75
|
+
|
|
76
|
+
The fault was our capability check.
|
|
77
|
+
|
|
78
|
+
### The API truth underneath
|
|
79
|
+
|
|
80
|
+
`dir()` on a live Fusion Tool returns 38 names — with `Composition` listed
|
|
81
|
+
**twice** — and omits `GetAttrs`/`SetAttrs`. Resolve fabricates a callable for
|
|
82
|
+
*any* attribute name, so `dir()` is the only evidence of absence that exists,
|
|
83
|
+
which makes an omitted name unrecoverable by probing. The bridge's strict proxy
|
|
84
|
+
took that omission as authoritative and answered "has no attribute 'GetAttrs' in
|
|
85
|
+
this Resolve build" for a method that was right there.
|
|
86
|
+
|
|
87
|
+
Resolve's own API objects enumerate correctly — Timeline 60, TimelineItem 88,
|
|
88
|
+
Composition 92 — so this is specific to Fusion Tools.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- The bridge client now carries a **curated exception**: names that are
|
|
93
|
+
documented Fusion methods but absent from the enumeration resolve normally,
|
|
94
|
+
and only on an object whose own method list positively identifies it as a
|
|
95
|
+
Fusion object (`ConnectInput`/`FindMainInput`/`GetControlPageNames` on a Tool,
|
|
96
|
+
`AddTool`/`FindTool`/`GetToolList` on a Composition).
|
|
97
|
+
|
|
98
|
+
This is deliberately not a global relaxation. Dropping the scoping fails five
|
|
99
|
+
existing tests, including the ones that keep
|
|
100
|
+
`getattr(item, "CreateMagicMask", None)` honest — capability detection on
|
|
101
|
+
Resolve API objects answers exactly as before.
|
|
102
|
+
|
|
103
|
+
- `fusion_comp add_tool` needed **no change**. The fallback proposed in v2.99.2
|
|
104
|
+
would have papered over a client bug while leaving every other unenumerated
|
|
105
|
+
Fusion method broken.
|
|
106
|
+
|
|
107
|
+
### Live validation
|
|
108
|
+
|
|
109
|
+
Free DaVinci Resolve 21.0.3.7, whole graph through the server including a custom
|
|
110
|
+
tool name (which requires `SetAttrs`):
|
|
111
|
+
|
|
112
|
+
```
|
|
113
|
+
add_tool -> {'tool_name': 'FreeBlur', 'tool_type': 'Blur'}
|
|
114
|
+
connect -> {'success': True}
|
|
115
|
+
connect -> {'success': True}
|
|
116
|
+
set_input -> {'success': True}
|
|
117
|
+
render -> PSNR 23.32 dB vs baseline — RENDERED
|
|
118
|
+
```
|
|
119
|
+
|
|
5
120
|
## What's New in v2.99.2
|
|
6
121
|
|
|
122
|
+
> **Cause corrected in v2.99.3.** The "`fusion_comp add_tool` cannot run on the
|
|
123
|
+
> free edition" note below has the wrong cause: `GetAttrs`/`SetAttrs` are present
|
|
124
|
+
> on a Fusion tool and work when called — the bridge client reported them absent
|
|
125
|
+
> because `dir()` on a Fusion Tool omits them. `add_tool` needed no change.
|
|
126
|
+
|
|
7
127
|
**The Fusion comp-lock question is closed on Resolve 21.** The v2.98.5–v2.98.8
|
|
8
128
|
work isolated the bug on Studio 19.1.3.7 only, and the open caveat was whether
|
|
9
129
|
the "renders on 19.1.3.7, ignored on 21.0.4.5" split reported in
|
package/README.md
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
English | [简体中文](README.zh-CN.md)
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
|
-
[-blue.svg)](#server-modes)
|
|
9
9
|
[-18%20tools-blueviolet.svg)](#server-modes)
|
|
10
10
|
[](docs/reference/api-coverage.md#test-results)
|
|
11
11
|
[](https://www.blackmagicdesign.com/products/davinciresolve)
|
|
@@ -109,7 +109,7 @@ The command starts a loopback-only server and opens the control panel in your br
|
|
|
109
109
|
|
|
110
110
|
| Mode | Entry point | Tools | Best for |
|
|
111
111
|
|------|-------------|-------|----------|
|
|
112
|
-
| Compound | `src/server.py` |
|
|
112
|
+
| Compound | `src/server.py` | 36 | Default mode for most assistants. Related Resolve operations are grouped behind action parameters to keep context usage low. |
|
|
113
113
|
| Full / granular | `src/server.py --full` or `src/resolve_mcp_server.py` | 353 | Power users who want one MCP tool per Resolve API method. |
|
|
114
114
|
|
|
115
115
|
The compound server is recommended unless you specifically need the granular one-tool-per-method surface.
|
|
@@ -218,6 +218,7 @@ The open-source servers are complete and fully functional on their own.
|
|
|
218
218
|
| Audio and Fairlight | Track/item probes, source mapping, guarded audio property writes, voice isolation, auto-sync planning, transcription/subtitle probes |
|
|
219
219
|
| Render and deliver | Format/codec matrix probing, render settings validation, queued job lifecycle checks, guarded Quick Export |
|
|
220
220
|
| Extension authoring | Fuse, DCTL, ACES DCTL, and Resolve-page Lua/Python script lifecycle helpers with safe MCP-marked install/remove |
|
|
221
|
+
| Craft guidance | The bundled editorial, colour, audio, and workflow guidance served as prose over MCP — indexed, searchable, and readable by any client, not just ones with this repository on disk |
|
|
221
222
|
|
|
222
223
|
## Optional Extras
|
|
223
224
|
|
|
@@ -272,7 +273,7 @@ The default server is a local stdio process launched by your MCP client; it does
|
|
|
272
273
|
|
|
273
274
|
| Metric | Value |
|
|
274
275
|
|--------|-------|
|
|
275
|
-
| MCP Tools | **
|
|
276
|
+
| MCP Tools | **36** compound / **353** granular (live server) |
|
|
276
277
|
| Advanced (offline) tools | **18** — .drp/.drt/.drx + DB authoring, no Resolve running |
|
|
277
278
|
| Kernel Actions | **136** guarded workflow actions across 9 compound tools |
|
|
278
279
|
| API Methods Covered | **361/361** (100%) |
|
package/README.zh-CN.md
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
[English](README.md) | 简体中文
|
|
4
4
|
|
|
5
|
-
[](https://github.com/samuelgursky/davinci-resolve-mcp/releases)
|
|
6
6
|
[](https://www.npmjs.com/package/davinci-resolve-mcp)
|
|
7
7
|
[](docs/reference/api-coverage.md)
|
|
8
|
-
[-blue.svg)](#服务器模式)
|
|
9
9
|
[-18%20tools-blueviolet.svg)](#服务器模式)
|
|
10
10
|
[](docs/reference/api-coverage.md#test-results)
|
|
11
11
|
[](https://www.blackmagicdesign.com/products/davinciresolve)
|
|
12
12
|
[](https://www.python.org/downloads/)
|
|
13
13
|
[](https://opensource.org/licenses/MIT)
|
|
14
14
|
|
|
15
|
-
> 本翻译对应 v2.
|
|
15
|
+
> 本翻译对应 v2.100.0 版 README。如与英文原版有出入,以 [英文原版](README.md) 为准。
|
|
16
16
|
|
|
17
17
|
一个 Model Context Protocol (MCP) 服务器,让 AI 助手通过官方脚本 API 控制 DaVinci Resolve Studio(达芬奇)。它提供完整的 API 覆盖,外加带护栏的工作流助手,涵盖剪辑、媒体池整理、渲染设置、审阅标记、调色、Fusion、Fairlight、项目生命周期任务、扩展开发,以及不碰源媒体的媒体分析。
|
|
18
18
|
|
|
@@ -81,7 +81,7 @@ venv/bin/python -m src.control_panel
|
|
|
81
81
|
|
|
82
82
|
| 模式 | 入口 | 工具数 | 适合谁 |
|
|
83
83
|
|------|------|--------|--------|
|
|
84
|
-
| Compound(复合) | `src/server.py` |
|
|
84
|
+
| Compound(复合) | `src/server.py` | 36 | 大多数助手的默认模式。相关的 Resolve 操作按 action 参数分组,压低上下文占用。 |
|
|
85
85
|
| Full / granular(细粒度) | `src/server.py --full` 或 `src/resolve_mcp_server.py` | 353 | 想要"一个 Resolve API 方法 = 一个 MCP 工具"的重度用户。 |
|
|
86
86
|
|
|
87
87
|
除非你明确需要一方法一工具的细粒度界面,否则推荐复合模式。
|
|
@@ -196,7 +196,7 @@ DRX 调色写入**针对 Resolve Studio 做过实机校准**:调色参数默
|
|
|
196
196
|
|
|
197
197
|
| 指标 | 数值 |
|
|
198
198
|
|------|------|
|
|
199
|
-
| MCP 工具 | **
|
|
199
|
+
| MCP 工具 | **36** 复合 / **353** 细粒度(实时服务器) |
|
|
200
200
|
| Advanced(离线)工具 | **18**——.drp/.drt/.drx + 数据库创作,无需 Resolve 运行 |
|
|
201
201
|
| 内核 action | 9 个复合工具下 **136** 个带护栏的工作流 action |
|
|
202
202
|
| API 方法覆盖 | **361/361**(100%) |
|
package/docs/SKILL.md
CHANGED
|
@@ -162,7 +162,7 @@ before mutating Resolve state.
|
|
|
162
162
|
|
|
163
163
|
| Mode | Entry point | Tool count | Use when |
|
|
164
164
|
|---|---|---|---|
|
|
165
|
-
| Compound (default) | `src/server.py` |
|
|
165
|
+
| Compound (default) | `src/server.py` | 36 tools | Most workflows — keeps context lean |
|
|
166
166
|
| Granular (full) | `src/server.py --full` | 353 tools | Power users needing one tool per API method |
|
|
167
167
|
|
|
168
168
|
This skill document covers the **compound server** (the default). Each compound
|
|
@@ -494,6 +494,32 @@ you are on the correct page first.
|
|
|
494
494
|
|
|
495
495
|
## Tool Map
|
|
496
496
|
|
|
497
|
+
### Craft Guidance
|
|
498
|
+
|
|
499
|
+
**`knowledge`** — The editorial, colour, audio, and workflow guidance bundled with
|
|
500
|
+
this server, served as prose. No Resolve connection required.
|
|
501
|
+
|
|
502
|
+
Read a topic **before** a creative or destructive operation, not after. The tools will
|
|
503
|
+
happily execute an editorially wrong decision; this is where the reasoning lives —
|
|
504
|
+
measured numbers, known traps, and what each move costs to undo.
|
|
505
|
+
|
|
506
|
+
Key actions:
|
|
507
|
+
- `topics(category?)` — the index: topic id, one-line summary, size, sections, and
|
|
508
|
+
related topics. Categories: `workflow` (task playbooks: tighten a recording, build a
|
|
509
|
+
rough cut, match a grade), `guide`, `kernel` (per-surface tool maps), `reference`
|
|
510
|
+
(exhaustive ledgers including this document), `repo` (contributing here)
|
|
511
|
+
- `get(topic, section?, inline?)` — the resolved prose. Natural aliases work
|
|
512
|
+
(`"tighten"`, `"dead air"`, `"grading"`, `"conform"`). Referenced guides and kernels
|
|
513
|
+
arrive inlined, so a client with no checkout of this repository still gets the
|
|
514
|
+
manual, not a path to it. `section` returns one heading's subtree
|
|
515
|
+
- `search(query, limit?)` — ranked topics with excerpts
|
|
516
|
+
- `capabilities()` — topic count by category, and the corpus directories
|
|
517
|
+
|
|
518
|
+
The same index is published as the `knowledge://topics` MCP resource, so hosts that
|
|
519
|
+
consume resources can see what guidance exists without spending a turn.
|
|
520
|
+
|
|
521
|
+
---
|
|
522
|
+
|
|
497
523
|
### App Control
|
|
498
524
|
|
|
499
525
|
**`resolve_control`** — App-level operations.
|
package/docs/contributing.md
CHANGED
|
@@ -63,7 +63,7 @@ This MCP server controls DaVinci Resolve via its Scripting API. Some tools perfo
|
|
|
63
63
|
davinci-resolve-mcp/
|
|
64
64
|
├── install.py # Universal installer (macOS/Windows/Linux)
|
|
65
65
|
├── src/
|
|
66
|
-
│ ├── server.py # Compound MCP server —
|
|
66
|
+
│ ├── server.py # Compound MCP server — 36 tools (default)
|
|
67
67
|
│ ├── resolve_mcp_server.py # Thin full-server entrypoint — 353 tools
|
|
68
68
|
│ ├── granular/ # Modular full-server implementation
|
|
69
69
|
│ └── utils/ # Platform detection, Resolve connection helpers
|
package/docs/install.md
CHANGED
|
@@ -141,7 +141,7 @@ The MCP server comes in two modes:
|
|
|
141
141
|
|
|
142
142
|
| Mode | File | Tools | Best For |
|
|
143
143
|
|------|------|-------|----------|
|
|
144
|
-
| **Compound** (default) | `src/server.py` |
|
|
144
|
+
| **Compound** (default) | `src/server.py` | 36 | Most users — fast, clean, low context usage |
|
|
145
145
|
| **Full** | `src/resolve_mcp_server.py` | 353 | Power users who want one tool per API method |
|
|
146
146
|
|
|
147
147
|
The compound server's `timeline_item` tool includes dedicated actions for common workflows:
|
|
@@ -6,7 +6,7 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
|
|
|
6
6
|
|
|
7
7
|
| Metric | Value |
|
|
8
8
|
|--------|-------|
|
|
9
|
-
| MCP Tools | **
|
|
9
|
+
| MCP Tools | **36** compound (default) / **353** granular |
|
|
10
10
|
| Kernel Actions | **136** guarded MCP workflow actions across 9 compound tools |
|
|
11
11
|
| API Methods Covered | **361/361** (100%) |
|
|
12
12
|
| Methods Live Tested | **338/361** (93.6%) |
|
|
@@ -17,7 +17,7 @@ Complete Resolve scripting API coverage, live-test status, and method-by-method
|
|
|
17
17
|
|
|
18
18
|
## API Coverage
|
|
19
19
|
|
|
20
|
-
Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **
|
|
20
|
+
Every non-deprecated method in the DaVinci Resolve Scripting API is covered. The default compound server exposes **36 tools** that group related operations by action parameter, keeping LLM context windows lean. The full granular server provides **353 individual tools** for power users. Both modes cover all 13 API object classes. MCP-level kernel actions are tracked separately in [Kernel Action Coverage](../kernels/README.md).
|
|
21
21
|
|
|
22
22
|
The 34th compound tool is `timeline_versioning` (C6) — an MCP-level workflow
|
|
23
23
|
tool, not a wrapper around a Resolve API method. It surfaces the
|
|
@@ -355,13 +355,13 @@ values, or automation-hostile modal prompts.
|
|
|
355
355
|
- **Workaround / current handling:** Author OTIO for Resolve by mirroring what Resolve itself exports, and give every event its media timecode origin. editorial.convert_to_interchange (target 'otio') does this and reports any event whose origin had to be assumed in `mediaOriginAssumed` — a non-empty list means the file will only import if that media really starts at 00:00:00:00. To debug a refusal, export any timeline with EXPORT_OTIO and diff your document against it; do NOT chase missing media or reach for sanitize_media, which cannot even parse a .otio (it is JSON, not XML).
|
|
356
356
|
- **Tags:** timeline, import, interchange, otio, silent-failure, conform
|
|
357
357
|
|
|
358
|
-
### Fusion
|
|
358
|
+
### Fusion object dir() omits real methods (GetAttrs / SetAttrs)
|
|
359
359
|
|
|
360
|
-
- **Object:** `Fusion Tool
|
|
361
|
-
- **Signature:** `
|
|
362
|
-
- **Behavior:**
|
|
363
|
-
- **Workaround / current handling:**
|
|
364
|
-
- **Tags:** fusion, bridge, free-edition
|
|
360
|
+
- **Object:** `Fusion Tool / Composition`
|
|
361
|
+
- **Signature:** `dir(tool) -> incomplete list`
|
|
362
|
+
- **Behavior:** `dir()` on a live Fusion Tool returns 38 names — with 'Composition' listed TWICE — and omits GetAttrs and SetAttrs, which are documented Fusion Tool methods that work perfectly when called. Measured on free 21.0.3.7 over the in-app bridge: invoking GetAttrs directly returned {TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs renamed the tool. This matters because Resolve fabricates a callable for ANY attribute name, so `dir()` is the only evidence of absence that exists — which makes an omitted name unrecoverable by probing. Any capability detection built on dir()/hasattr will therefore report a real Fusion method as missing. Resolve's own API objects do not have this problem: Timeline (60), TimelineItem (88) and Composition (92) all enumerate correctly.
|
|
363
|
+
- **Workaround / current handling:** Do not treat dir()/hasattr as authoritative for Fusion Tool objects. Keep a curated set of documented Fusion methods that the enumeration omits, and identify a Fusion object positively (ConnectInput / FindMainInput / GetControlPageNames on a Tool, AddTool / FindTool / GetToolList on a Composition) rather than relaxing the check globally, which would silently re-open capability detection on Resolve API objects.
|
|
364
|
+
- **Tags:** fusion, introspection, bridge, free-edition
|
|
365
365
|
|
|
366
366
|
### Composition.Lock (suppresses render invalidation for value writes)
|
|
367
367
|
|
package/install.py
CHANGED
|
@@ -37,7 +37,7 @@ from src.utils.update_check import (
|
|
|
37
37
|
|
|
38
38
|
# ─── Version ──────────────────────────────────────────────────────────────────
|
|
39
39
|
|
|
40
|
-
VERSION = "2.
|
|
40
|
+
VERSION = "2.100.0"
|
|
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
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.100.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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"""
|
|
3
3
|
DaVinci Resolve MCP Server (Compound Tools)
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
36 compound tools covering 100% of the DaVinci Resolve Scripting API (336 methods)
|
|
6
6
|
plus Fusion Fuse, DCTL, and Resolve-page Script authoring tools.
|
|
7
7
|
Each tool groups related operations via an 'action' parameter.
|
|
8
8
|
|
|
@@ -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.100.0"
|
|
15
15
|
|
|
16
16
|
import base64
|
|
17
17
|
import os
|
|
@@ -317,7 +317,7 @@ def davinci_resolve_workflow() -> str:
|
|
|
317
317
|
return """Use this DaVinci Resolve MCP server as a guarded post-production control surface.
|
|
318
318
|
|
|
319
319
|
Core pattern:
|
|
320
|
-
- Prefer the
|
|
320
|
+
- Prefer the 36 compound tools and their action names over raw scripting.
|
|
321
321
|
- Start by probing state: resolve_control.get_version/get_page, project_manager.get_current, timeline.get_current, and media_pool.probe_media_pool.
|
|
322
322
|
- Before mutating timelines, media pools, render settings, grades, projects, databases, or extensions, prefer the matching probe, capabilities, boundary_report, safe_*, or dry_run action when one exists.
|
|
323
323
|
- Preserve source media integrity. Never transcode, proxy, rewrite, move, rename, or create derivatives of source media unless the user explicitly asks. Analysis output belongs in sidecars or analysis directories.
|
|
@@ -14730,6 +14730,17 @@ def setup(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any
|
|
|
14730
14730
|
if action in {"schema", "capabilities", "options"}:
|
|
14731
14731
|
return {
|
|
14732
14732
|
"actions": ["schema", "get_defaults", "set_defaults", "clear_defaults"],
|
|
14733
|
+
# Agents commonly call this first. The craft guidance is worth nothing if
|
|
14734
|
+
# nobody knows it is there, so the orientation call names it.
|
|
14735
|
+
"craft_guidance": {
|
|
14736
|
+
"tool": "knowledge",
|
|
14737
|
+
"start_with": "knowledge(action='topics')",
|
|
14738
|
+
"when": (
|
|
14739
|
+
"Before a creative or destructive operation — cutting, grading, "
|
|
14740
|
+
"conforming, tightening, delivering. The tools execute; this is "
|
|
14741
|
+
"where the reasoning and the measured numbers live."
|
|
14742
|
+
),
|
|
14743
|
+
},
|
|
14733
14744
|
"defaults": {
|
|
14734
14745
|
"media_analysis.timed_markers_default": {
|
|
14735
14746
|
"description": "Default answer for writing source-time analysis notes as Media Pool clip markers.",
|
|
@@ -29471,6 +29482,83 @@ def script_plugin(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[
|
|
|
29471
29482
|
"execute", "run_inline", *_EXTENSION_KERNEL_ACTIONS])
|
|
29472
29483
|
|
|
29473
29484
|
|
|
29485
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
29486
|
+
# TOOL: knowledge
|
|
29487
|
+
#
|
|
29488
|
+
# The craft guidance in this repository — how to tighten a take without cutting
|
|
29489
|
+
# the breath out of it, what to look at before applying a grade, which API calls
|
|
29490
|
+
# silently lie — has always been readable only by an agent with this checkout on
|
|
29491
|
+
# disk. Over MCP there is no checkout, so a client on any other host operated the
|
|
29492
|
+
# tools without ever seeing the reasoning that makes the operation correct.
|
|
29493
|
+
#
|
|
29494
|
+
# This serves that corpus as content: the index, one resolved topic, or a search.
|
|
29495
|
+
# No Resolve connection is involved at any point.
|
|
29496
|
+
# ═══════════════════════════════════════════════════════════════════════════════
|
|
29497
|
+
|
|
29498
|
+
_KNOWLEDGE_ACTIONS = ["topics", "get", "search", "capabilities"]
|
|
29499
|
+
|
|
29500
|
+
|
|
29501
|
+
@mcp.tool()
|
|
29502
|
+
@_guard_missing_params
|
|
29503
|
+
def knowledge(action: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
29504
|
+
"""Editorial, colour, audio, and workflow guidance — readable without this checkout.
|
|
29505
|
+
|
|
29506
|
+
Read a topic BEFORE a creative or destructive operation, not after. The guidance
|
|
29507
|
+
carries measured numbers and known traps; operating the tools without it is how a
|
|
29508
|
+
technically-correct call produces an editorially wrong result.
|
|
29509
|
+
|
|
29510
|
+
Actions:
|
|
29511
|
+
topics(category?) -> {topics} — the index: id, summary, size, sections, related.
|
|
29512
|
+
Categories: workflow (task playbooks), guide, kernel (per-surface tool maps),
|
|
29513
|
+
reference (exhaustive ledgers), repo (contributing to this project).
|
|
29514
|
+
get(topic, section?, inline?) -> {content} — resolved prose. Accepts natural
|
|
29515
|
+
aliases ("tighten", "dead air", "grading"). `section` returns one heading's
|
|
29516
|
+
subtree; `inline=false` skips the referenced documents.
|
|
29517
|
+
search(query, limit?) -> {hits} — ranked topics with excerpts.
|
|
29518
|
+
capabilities() -> {topic_count, categories, corpus}
|
|
29519
|
+
|
|
29520
|
+
No Resolve connection required.
|
|
29521
|
+
"""
|
|
29522
|
+
p = _params(params)
|
|
29523
|
+
from src.utils import knowledge as _knowledge_mod
|
|
29524
|
+
|
|
29525
|
+
try:
|
|
29526
|
+
if action == "topics":
|
|
29527
|
+
category = p.get("category")
|
|
29528
|
+
listing = _knowledge_mod.topics(category=str(category) if category else None)
|
|
29529
|
+
return _ok(topics=listing, count=len(listing),
|
|
29530
|
+
categories=list(_knowledge_mod.CATEGORIES))
|
|
29531
|
+
if action == "get":
|
|
29532
|
+
err, _clean = _validate_params(p, {
|
|
29533
|
+
"topic": {"type": str, "required": True, "non_empty": True},
|
|
29534
|
+
})
|
|
29535
|
+
if err:
|
|
29536
|
+
return _err(err)
|
|
29537
|
+
section = p.get("section")
|
|
29538
|
+
return _ok(**_knowledge_mod.get(
|
|
29539
|
+
str(p["topic"]),
|
|
29540
|
+
section=str(section) if section else None,
|
|
29541
|
+
inline=bool(p.get("inline", True)),
|
|
29542
|
+
))
|
|
29543
|
+
if action == "search":
|
|
29544
|
+
err, _clean = _validate_params(p, {
|
|
29545
|
+
"query": {"type": str, "required": True, "non_empty": True},
|
|
29546
|
+
})
|
|
29547
|
+
if err:
|
|
29548
|
+
return _err(err)
|
|
29549
|
+
hits = _knowledge_mod.search(str(p["query"]), limit=int(p.get("limit", 5)))
|
|
29550
|
+
return _ok(hits=hits, count=len(hits))
|
|
29551
|
+
if action in {"capabilities", "schema"}:
|
|
29552
|
+
return _ok(**_knowledge_mod.capabilities(), actions=_KNOWLEDGE_ACTIONS)
|
|
29553
|
+
except _knowledge_mod.KnowledgeError as exc:
|
|
29554
|
+
# The message already names the real topics or sections, so an agent that
|
|
29555
|
+
# guessed wrong can correct itself without a second round-trip.
|
|
29556
|
+
return _err(str(exc), code="UNKNOWN_TOPIC", category="invalid_params",
|
|
29557
|
+
retryable=False)
|
|
29558
|
+
|
|
29559
|
+
return _unknown(action, _KNOWLEDGE_ACTIONS)
|
|
29560
|
+
|
|
29561
|
+
|
|
29474
29562
|
# ═══════════════════════════════════════════════════════════════════════════════
|
|
29475
29563
|
# MCP Resources — agentic-flow improvement E1
|
|
29476
29564
|
#
|
|
@@ -29515,6 +29603,32 @@ def _resource_mcp_version() -> Dict[str, Any]:
|
|
|
29515
29603
|
}
|
|
29516
29604
|
|
|
29517
29605
|
|
|
29606
|
+
@mcp.resource("knowledge://topics")
|
|
29607
|
+
@_safe_resource
|
|
29608
|
+
def _resource_knowledge_topics() -> Dict[str, Any]:
|
|
29609
|
+
"""The knowledge index — id, summary, category, size. Pure read of bundled docs.
|
|
29610
|
+
|
|
29611
|
+
A host that consumes resources learns what guidance exists without spending a turn
|
|
29612
|
+
on it, which is the difference between the `knowledge` tool being available and it
|
|
29613
|
+
being used.
|
|
29614
|
+
"""
|
|
29615
|
+
from src.utils import knowledge as _knowledge_mod
|
|
29616
|
+
|
|
29617
|
+
return {
|
|
29618
|
+
"topics": [
|
|
29619
|
+
{
|
|
29620
|
+
"topic": item["topic"],
|
|
29621
|
+
"title": item["title"],
|
|
29622
|
+
"category": item["category"],
|
|
29623
|
+
"summary": item["summary"],
|
|
29624
|
+
"length_lines": item["resolved_length_lines"],
|
|
29625
|
+
}
|
|
29626
|
+
for item in _knowledge_mod.topics()
|
|
29627
|
+
],
|
|
29628
|
+
"fetch_with": "knowledge(action='get', params={'topic': '<id>'})",
|
|
29629
|
+
}
|
|
29630
|
+
|
|
29631
|
+
|
|
29518
29632
|
@mcp.resource("status://resolve_connection")
|
|
29519
29633
|
@_safe_resource
|
|
29520
29634
|
def _resource_resolve_connection() -> Dict[str, Any]:
|
|
@@ -29734,5 +29848,5 @@ if __name__ == "__main__":
|
|
|
29734
29848
|
logger.error(f"Unknown --transport {transport!r}; use stdio|sse|streamable-http")
|
|
29735
29849
|
sys.exit(2)
|
|
29736
29850
|
|
|
29737
|
-
logger.info("Starting DaVinci Resolve MCP Server (
|
|
29851
|
+
logger.info("Starting DaVinci Resolve MCP Server (36 compound tools)")
|
|
29738
29852
|
run_fastmcp_stdio(mcp)
|
package/src/utils/api_truth.py
CHANGED
|
@@ -404,29 +404,34 @@ API_TRUTH: List[Dict[str, Any]] = [
|
|
|
404
404
|
"submit": "bug",
|
|
405
405
|
},
|
|
406
406
|
{
|
|
407
|
-
"symbol": "Fusion
|
|
408
|
-
"object": "Fusion Tool
|
|
409
|
-
"signature": "
|
|
410
|
-
"reality": "
|
|
411
|
-
"
|
|
412
|
-
"
|
|
413
|
-
"
|
|
414
|
-
"
|
|
415
|
-
"
|
|
416
|
-
"
|
|
417
|
-
"
|
|
418
|
-
"
|
|
419
|
-
"
|
|
420
|
-
"
|
|
421
|
-
"
|
|
422
|
-
"
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
"
|
|
426
|
-
"
|
|
427
|
-
"
|
|
428
|
-
|
|
407
|
+
"symbol": "Fusion object dir() omits real methods (GetAttrs / SetAttrs)",
|
|
408
|
+
"object": "Fusion Tool / Composition",
|
|
409
|
+
"signature": "dir(tool) -> incomplete list",
|
|
410
|
+
"reality": "`dir()` on a live Fusion Tool returns 38 names — with "
|
|
411
|
+
"'Composition' listed TWICE — and omits GetAttrs and "
|
|
412
|
+
"SetAttrs, which are documented Fusion Tool methods that "
|
|
413
|
+
"work perfectly when called. Measured on free 21.0.3.7 over "
|
|
414
|
+
"the in-app bridge: invoking GetAttrs directly returned "
|
|
415
|
+
"{TOOLS_Name: 'Blur1', TOOLS_RegID: 'Blur'} and SetAttrs "
|
|
416
|
+
"renamed the tool. This matters because Resolve fabricates a "
|
|
417
|
+
"callable for ANY attribute name, so `dir()` is the only "
|
|
418
|
+
"evidence of absence that exists — which makes an omitted "
|
|
419
|
+
"name unrecoverable by probing. Any capability detection "
|
|
420
|
+
"built on dir()/hasattr will therefore report a real Fusion "
|
|
421
|
+
"method as missing. Resolve's own API objects do not have "
|
|
422
|
+
"this problem: Timeline (60), TimelineItem (88) and "
|
|
423
|
+
"Composition (92) all enumerate correctly.",
|
|
424
|
+
"recommended": "Do not treat dir()/hasattr as authoritative for Fusion "
|
|
425
|
+
"Tool objects. Keep a curated set of documented Fusion "
|
|
426
|
+
"methods that the enumeration omits, and identify a "
|
|
427
|
+
"Fusion object positively (ConnectInput / FindMainInput "
|
|
428
|
+
"/ GetControlPageNames on a Tool, AddTool / FindTool / "
|
|
429
|
+
"GetToolList on a Composition) rather than relaxing the "
|
|
430
|
+
"check globally, which would silently re-open capability "
|
|
431
|
+
"detection on Resolve API objects.",
|
|
432
|
+
"tags": ["fusion", "introspection", "bridge", "free-edition"],
|
|
429
433
|
"submit": "bug",
|
|
434
|
+
"mitigation": ["_FUSION_UNENUMERATED_METHODS", "_FUSION_OBJECT_MARKERS"],
|
|
430
435
|
},
|
|
431
436
|
{
|
|
432
437
|
"symbol": "Composition.Lock (suppresses render invalidation for value writes)",
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
"""Editorial and operating knowledge, resolved for clients that have no filesystem.
|
|
2
|
+
|
|
3
|
+
This repository carries a real body of craft guidance: how to tighten a recording
|
|
4
|
+
without cutting the breath out of it, what to look at before applying a grade, which
|
|
5
|
+
Resolve API calls silently lie. It lives in two places — `.claude/skills/*/SKILL.md`
|
|
6
|
+
and `docs/guides` + `docs/kernels` — and until now both were reachable only by an
|
|
7
|
+
agent that could open files in this checkout.
|
|
8
|
+
|
|
9
|
+
Over MCP the client has no checkout. A skill that says "open
|
|
10
|
+
`docs/guides/color-decision-guide.md`" is a dead end there: the pointer resolves to
|
|
11
|
+
nothing, and the agent operates the tools without ever seeing the reasoning that makes
|
|
12
|
+
the operation correct. So this module serves **content, not pointers** — it follows the
|
|
13
|
+
in-repo references one level and inlines what they point at.
|
|
14
|
+
|
|
15
|
+
## One level, deliberately
|
|
16
|
+
|
|
17
|
+
Following references transitively would turn a 150-line answer into the whole `docs/`
|
|
18
|
+
tree. One level is the depth at which a skill's own routing is satisfied: the skill
|
|
19
|
+
names the manual, and the manual arrives with it. Anything deeper is a different
|
|
20
|
+
question, and the agent can ask it as a separate topic.
|
|
21
|
+
|
|
22
|
+
## Nothing in the corpus is unreachable
|
|
23
|
+
|
|
24
|
+
The index is built from the directories themselves, not from a hand-kept list, and a
|
|
25
|
+
drift guard asserts every skill and every guide/kernel appears in it. A knowledge file
|
|
26
|
+
added later cannot go quietly unserved — which is the failure mode a hand-kept list has
|
|
27
|
+
every time.
|
|
28
|
+
|
|
29
|
+
## An oversized reference is summarised, not truncated
|
|
30
|
+
|
|
31
|
+
Inlining is capped. Under the cap a referenced document arrives whole; over it, what
|
|
32
|
+
arrives is its title, its summary and its section list, plus the topic id to fetch for
|
|
33
|
+
the full text. A truncated prefix would be worse than a pointer — it is the first N
|
|
34
|
+
lines, which is rarely the part that answers the question.
|
|
35
|
+
|
|
36
|
+
## Reference documents are terminal
|
|
37
|
+
|
|
38
|
+
`docs/SKILL.md`, the api ledgers and the release process are exhaustive by design and
|
|
39
|
+
cross-link each other freely. Inlining from them doubles a document that was already
|
|
40
|
+
complete, so the `reference` category resolves to itself. Skills, guides and kernels —
|
|
41
|
+
the documents whose job is to route — still inline.
|
|
42
|
+
|
|
43
|
+
## Sections are parsed from the primary document only
|
|
44
|
+
|
|
45
|
+
`get(topic, section=...)` slices the skill's or doc's own headings, never the inlined
|
|
46
|
+
material. Slicing across an inline boundary would return a section whose name belongs to
|
|
47
|
+
one document and whose body belongs to another. An unknown section is an error that
|
|
48
|
+
lists the real ones rather than quietly returning the whole file.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
from __future__ import annotations
|
|
52
|
+
|
|
53
|
+
import difflib
|
|
54
|
+
import math
|
|
55
|
+
import re
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
58
|
+
|
|
59
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
60
|
+
SKILLS_DIR = REPO_ROOT / ".claude" / "skills"
|
|
61
|
+
GUIDES_DIR = REPO_ROOT / "docs" / "guides"
|
|
62
|
+
KERNELS_DIR = REPO_ROOT / "docs" / "kernels"
|
|
63
|
+
|
|
64
|
+
# Skills about developing *this repository* rather than operating Resolve. Served, but
|
|
65
|
+
# categorised apart so an editor asking for craft guidance is not handed our release
|
|
66
|
+
# checklist.
|
|
67
|
+
_REPO_SKILLS = frozenset({"house-style", "release-check"})
|
|
68
|
+
|
|
69
|
+
# `docs/kernels/README.md` is a table of contents for its own directory: every entry it
|
|
70
|
+
# lists is already an indexed topic, so serving it adds a second, staler index. The
|
|
71
|
+
# drift guard skips it for the same reason.
|
|
72
|
+
_DOC_SKIPLIST = frozenset({"README.md"})
|
|
73
|
+
|
|
74
|
+
CATEGORIES = ("workflow", "guide", "kernel", "reference", "repo")
|
|
75
|
+
|
|
76
|
+
MAX_SUMMARY_CHARS = 320
|
|
77
|
+
EXCERPT_RADIUS = 160
|
|
78
|
+
|
|
79
|
+
# Dropped from search terms. Without this "cut to music" is decided by "to", and the
|
|
80
|
+
# longest document in the corpus wins every query that contains a preposition.
|
|
81
|
+
_STOPWORDS = frozenset(
|
|
82
|
+
"a an the to of in on for and or is are it its my me we with from at by as be "
|
|
83
|
+
"this that how what when do does can should".split()
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# Referenced documents longer than this are summarised rather than inlined whole. Set
|
|
87
|
+
# so the everyday kernels and guides (100-250 lines) arrive complete, while the two
|
|
88
|
+
# outliers — the 2250-line operating reference and the generated api-limitations ledger
|
|
89
|
+
# — do not swallow the topic that merely pointed at them.
|
|
90
|
+
MAX_INLINE_LINES = 260
|
|
91
|
+
|
|
92
|
+
# Documents outside `docs/guides` and `docs/kernels` that the corpus points at. Indexed
|
|
93
|
+
# explicitly, with stable ids, so an over-budget reference can name a topic to fetch
|
|
94
|
+
# instead of dead-ending. (`docs/SKILL.md` as a topic id would read as "a skill".)
|
|
95
|
+
_EXTRA_DOCS: Dict[str, str] = {
|
|
96
|
+
"mcp-operating-reference": "docs/SKILL.md",
|
|
97
|
+
"release-process": "docs/process/release-process.md",
|
|
98
|
+
"api-limitations": "docs/reference/api-limitations.md",
|
|
99
|
+
"api-coverage": "docs/reference/api-coverage.md",
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
# Natural phrasings an agent is likely to reach for, mapped to real topic ids. Keys are
|
|
103
|
+
# matched after lowercasing and collapsing separators, so "dead air" and "dead_air" both
|
|
104
|
+
# land.
|
|
105
|
+
_ALIASES: Dict[str, str] = {
|
|
106
|
+
"color": "resolve-color",
|
|
107
|
+
"colour": "resolve-color",
|
|
108
|
+
"grade": "resolve-color",
|
|
109
|
+
"grading": "resolve-color",
|
|
110
|
+
"look": "resolve-color",
|
|
111
|
+
"looks": "resolve-color",
|
|
112
|
+
"lut": "resolve-color",
|
|
113
|
+
"luts": "resolve-color",
|
|
114
|
+
"edit": "resolve-edit",
|
|
115
|
+
"editing": "resolve-edit",
|
|
116
|
+
"cut": "resolve-edit",
|
|
117
|
+
"cutting": "resolve-edit",
|
|
118
|
+
"trim": "resolve-edit",
|
|
119
|
+
"pacing": "resolve-edit",
|
|
120
|
+
"timeline": "resolve-edit",
|
|
121
|
+
"rough cut": "resolve-rough-cut",
|
|
122
|
+
"roughcut": "resolve-rough-cut",
|
|
123
|
+
"assembly": "resolve-rough-cut",
|
|
124
|
+
"selects": "resolve-rough-cut",
|
|
125
|
+
"tighten": "resolve-tighten-recording",
|
|
126
|
+
"tightening": "resolve-tighten-recording",
|
|
127
|
+
"dead air": "resolve-tighten-recording",
|
|
128
|
+
"silence": "resolve-tighten-recording",
|
|
129
|
+
"silences": "resolve-tighten-recording",
|
|
130
|
+
"pauses": "resolve-tighten-recording",
|
|
131
|
+
"audio": "resolve-audio",
|
|
132
|
+
"sound": "resolve-audio",
|
|
133
|
+
"mix": "resolve-audio",
|
|
134
|
+
"mixing": "resolve-audio",
|
|
135
|
+
"fairlight": "resolve-audio",
|
|
136
|
+
"loudness": "resolve-audio",
|
|
137
|
+
"conform": "resolve-conform",
|
|
138
|
+
"conforming": "resolve-conform",
|
|
139
|
+
"online": "resolve-conform",
|
|
140
|
+
"relink": "resolve-conform",
|
|
141
|
+
"aaf": "resolve-conform",
|
|
142
|
+
"delivery": "resolve-delivery",
|
|
143
|
+
"deliver": "resolve-delivery",
|
|
144
|
+
"render": "resolve-delivery",
|
|
145
|
+
"export": "resolve-delivery",
|
|
146
|
+
"fusion": "resolve-fusion",
|
|
147
|
+
"vfx": "resolve-fusion",
|
|
148
|
+
"titles": "resolve-fusion",
|
|
149
|
+
"analysis": "resolve-media-analysis",
|
|
150
|
+
"analyze": "resolve-media-analysis",
|
|
151
|
+
"analyse": "resolve-media-analysis",
|
|
152
|
+
"transcription": "resolve-media-analysis",
|
|
153
|
+
"transcribe": "resolve-media-analysis",
|
|
154
|
+
"media pool": "resolve-media-pool",
|
|
155
|
+
"bins": "resolve-media-pool",
|
|
156
|
+
"ingest": "resolve-media-pool",
|
|
157
|
+
"multicam": "resolve-media-pool",
|
|
158
|
+
"mcp": "resolve-mcp",
|
|
159
|
+
"tools": "resolve-mcp",
|
|
160
|
+
"session": "resolve-session",
|
|
161
|
+
"style": "house-style",
|
|
162
|
+
"release": "release-check",
|
|
163
|
+
"api": "api-coverage",
|
|
164
|
+
"limitations": "api-limitations",
|
|
165
|
+
"api limitations": "api-limitations",
|
|
166
|
+
"skill": "mcp-operating-reference",
|
|
167
|
+
"operating reference": "mcp-operating-reference",
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
class KnowledgeError(Exception):
|
|
172
|
+
"""A topic or section that does not exist. Carries the real options."""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ── corpus parsing ───────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _split_frontmatter(text: str) -> Tuple[Dict[str, str], str]:
|
|
179
|
+
"""Return (frontmatter fields, body). Absent frontmatter yields an empty dict."""
|
|
180
|
+
match = re.match(r"^---\n(.*?)\n---\n", text, re.S)
|
|
181
|
+
if not match:
|
|
182
|
+
return {}, text
|
|
183
|
+
fields: Dict[str, str] = {}
|
|
184
|
+
for line in match.group(1).splitlines():
|
|
185
|
+
key_value = re.match(r"^([a-zA-Z_][\w-]*):\s*(.*)$", line)
|
|
186
|
+
if key_value:
|
|
187
|
+
fields[key_value.group(1)] = key_value.group(2).strip()
|
|
188
|
+
return fields, text[match.end():]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _first_paragraph(body: str) -> str:
|
|
192
|
+
"""The first real paragraph after the H1 — a doc's own one-line summary."""
|
|
193
|
+
lines = body.splitlines()
|
|
194
|
+
start = 0
|
|
195
|
+
for index, line in enumerate(lines):
|
|
196
|
+
if line.startswith("# "):
|
|
197
|
+
start = index + 1
|
|
198
|
+
break
|
|
199
|
+
collected: List[str] = []
|
|
200
|
+
for line in lines[start:]:
|
|
201
|
+
stripped = line.strip()
|
|
202
|
+
if not stripped:
|
|
203
|
+
if collected:
|
|
204
|
+
break
|
|
205
|
+
continue
|
|
206
|
+
if stripped.startswith(("#", "|", "```", "- ", "* ", ">")):
|
|
207
|
+
if collected:
|
|
208
|
+
break
|
|
209
|
+
continue
|
|
210
|
+
collected.append(stripped)
|
|
211
|
+
summary = " ".join(collected)
|
|
212
|
+
if len(summary) > MAX_SUMMARY_CHARS:
|
|
213
|
+
summary = summary[: MAX_SUMMARY_CHARS - 1].rstrip() + "…"
|
|
214
|
+
return summary
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _title_of(body: str, fallback: str) -> str:
|
|
218
|
+
for line in body.splitlines():
|
|
219
|
+
if line.startswith("# "):
|
|
220
|
+
# `# Resolve Tighten Recording — Claude Code Skill` is scaffolding for one
|
|
221
|
+
# client. The subject is everything before the dash.
|
|
222
|
+
title = line[2:].strip()
|
|
223
|
+
return re.sub(r"\s*[—-]\s*Claude Code Skill\s*$", "", title).strip()
|
|
224
|
+
return fallback
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _strip_client_scaffolding(body: str) -> str:
|
|
228
|
+
"""Drop the one H1 that names a specific client. Prose is left alone."""
|
|
229
|
+
return re.sub(
|
|
230
|
+
r"^#\s+(.*?)\s*[—-]\s*Claude Code Skill\s*$",
|
|
231
|
+
lambda m: f"# {m.group(1)}",
|
|
232
|
+
body,
|
|
233
|
+
count=1,
|
|
234
|
+
flags=re.M,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
_DOC_REF_RE = re.compile(r"`(docs/[^`\s]+\.md)`|\]\((docs/[^)\s]+\.md)\)")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _doc_references(body: str) -> List[str]:
|
|
242
|
+
"""In-repo doc paths a body points at, in first-appearance order."""
|
|
243
|
+
seen: List[str] = []
|
|
244
|
+
for backticked, linked in _DOC_REF_RE.findall(body):
|
|
245
|
+
path = backticked or linked
|
|
246
|
+
if path not in seen:
|
|
247
|
+
seen.append(path)
|
|
248
|
+
return seen
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _demote_headings(body: str) -> str:
|
|
252
|
+
"""Shift every ATX heading one level down so inlined material nests correctly."""
|
|
253
|
+
return re.sub(r"^(#{1,5})(\s)", r"#\1\2", body, flags=re.M)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ── index ────────────────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _read(path: Path) -> str:
|
|
260
|
+
return path.read_text(encoding="utf-8")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _build_index() -> Dict[str, Dict[str, Any]]:
|
|
264
|
+
"""Topic id → record. Built from the directories, never from a hand-kept list."""
|
|
265
|
+
index: Dict[str, Dict[str, Any]] = {}
|
|
266
|
+
|
|
267
|
+
if SKILLS_DIR.is_dir():
|
|
268
|
+
for skill_md in sorted(SKILLS_DIR.glob("*/SKILL.md")):
|
|
269
|
+
topic = skill_md.parent.name
|
|
270
|
+
text = _read(skill_md)
|
|
271
|
+
fields, body = _split_frontmatter(text)
|
|
272
|
+
index[topic] = {
|
|
273
|
+
"topic": topic,
|
|
274
|
+
"title": _title_of(body, topic),
|
|
275
|
+
"summary": fields.get("description", "") or _first_paragraph(body),
|
|
276
|
+
"category": "repo" if topic in _REPO_SKILLS else "workflow",
|
|
277
|
+
"path": str(skill_md.relative_to(REPO_ROOT)),
|
|
278
|
+
"body": _strip_client_scaffolding(body).strip(),
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
for category, directory in (("guide", GUIDES_DIR), ("kernel", KERNELS_DIR)):
|
|
282
|
+
if not directory.is_dir():
|
|
283
|
+
continue
|
|
284
|
+
for doc in sorted(directory.glob("*.md")):
|
|
285
|
+
if doc.name in _DOC_SKIPLIST:
|
|
286
|
+
continue
|
|
287
|
+
topic = doc.stem
|
|
288
|
+
body = _split_frontmatter(_read(doc))[1]
|
|
289
|
+
index[topic] = {
|
|
290
|
+
"topic": topic,
|
|
291
|
+
"title": _title_of(body, topic),
|
|
292
|
+
"summary": _first_paragraph(body),
|
|
293
|
+
"category": category,
|
|
294
|
+
"path": str(doc.relative_to(REPO_ROOT)),
|
|
295
|
+
"body": body.strip(),
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for topic, reference in _EXTRA_DOCS.items():
|
|
299
|
+
path = REPO_ROOT / reference
|
|
300
|
+
if not path.is_file():
|
|
301
|
+
continue
|
|
302
|
+
body = _split_frontmatter(_read(path))[1]
|
|
303
|
+
index[topic] = {
|
|
304
|
+
"topic": topic,
|
|
305
|
+
"title": _title_of(body, topic),
|
|
306
|
+
"summary": _first_paragraph(body),
|
|
307
|
+
"category": "reference",
|
|
308
|
+
"path": reference,
|
|
309
|
+
"body": body.strip(),
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
for record in index.values():
|
|
313
|
+
record["related"] = _related_topics(record, index)
|
|
314
|
+
return index
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _related_topics(record: Dict[str, Any], index: Dict[str, Dict[str, Any]]) -> List[str]:
|
|
318
|
+
"""Topics this one points at: linked docs first, then topics named in the body."""
|
|
319
|
+
by_path = {other["path"]: other["topic"] for other in index.values()}
|
|
320
|
+
related: List[str] = []
|
|
321
|
+
for reference in _doc_references(record["body"]):
|
|
322
|
+
topic = by_path.get(reference)
|
|
323
|
+
if topic and topic != record["topic"] and topic not in related:
|
|
324
|
+
related.append(topic)
|
|
325
|
+
for topic in sorted(index):
|
|
326
|
+
if topic == record["topic"] or topic in related:
|
|
327
|
+
continue
|
|
328
|
+
if re.search(rf"\b{re.escape(topic)}\b", record["body"]):
|
|
329
|
+
related.append(topic)
|
|
330
|
+
return related
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
_INDEX_CACHE: Optional[Dict[str, Dict[str, Any]]] = None
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def index(*, refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
|
337
|
+
global _INDEX_CACHE
|
|
338
|
+
if _INDEX_CACHE is None or refresh:
|
|
339
|
+
_INDEX_CACHE = _build_index()
|
|
340
|
+
return _INDEX_CACHE
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _normalize(value: str) -> str:
|
|
344
|
+
return re.sub(r"[\s_]+", " ", str(value or "").strip().lower())
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def resolve_alias(topic: str) -> Optional[str]:
|
|
348
|
+
"""Map a natural phrasing to a topic id, or return the id when it is already one."""
|
|
349
|
+
records = index()
|
|
350
|
+
raw = str(topic or "").strip()
|
|
351
|
+
if raw in records:
|
|
352
|
+
return raw
|
|
353
|
+
normalized = _normalize(raw)
|
|
354
|
+
for candidate in records:
|
|
355
|
+
if _normalize(candidate) == normalized:
|
|
356
|
+
return candidate
|
|
357
|
+
hyphenated = normalized.replace(" ", "-")
|
|
358
|
+
if hyphenated in records:
|
|
359
|
+
return hyphenated
|
|
360
|
+
return _ALIASES.get(normalized)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
# ── public surface ───────────────────────────────────────────────────────────
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def topics(*, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
367
|
+
"""The index an agent reads before deciding what to fetch."""
|
|
368
|
+
if category is not None and category not in CATEGORIES:
|
|
369
|
+
raise KnowledgeError(
|
|
370
|
+
f"unknown category '{category}'. Valid categories: {', '.join(CATEGORIES)}"
|
|
371
|
+
)
|
|
372
|
+
listing = []
|
|
373
|
+
for record in index().values():
|
|
374
|
+
if category and record["category"] != category:
|
|
375
|
+
continue
|
|
376
|
+
listing.append(
|
|
377
|
+
{
|
|
378
|
+
"topic": record["topic"],
|
|
379
|
+
"title": record["title"],
|
|
380
|
+
"summary": record["summary"],
|
|
381
|
+
"category": record["category"],
|
|
382
|
+
"source": record["path"],
|
|
383
|
+
"length_lines": len(record["body"].splitlines()),
|
|
384
|
+
"resolved_length_lines": len(_resolve_body(record).splitlines()),
|
|
385
|
+
"sections": [name for name, _, _ in _sections(record["body"])],
|
|
386
|
+
"related": record["related"],
|
|
387
|
+
}
|
|
388
|
+
)
|
|
389
|
+
return sorted(listing, key=lambda item: (item["category"], item["topic"]))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _sections(body: str) -> List[Tuple[str, int, int]]:
|
|
393
|
+
"""(heading text, start line, end line) for every `##` section, in order."""
|
|
394
|
+
lines = body.splitlines()
|
|
395
|
+
starts = [
|
|
396
|
+
(index_, line[3:].strip())
|
|
397
|
+
for index_, line in enumerate(lines)
|
|
398
|
+
if line.startswith("## ")
|
|
399
|
+
]
|
|
400
|
+
out: List[Tuple[str, int, int]] = []
|
|
401
|
+
for position, (start, name) in enumerate(starts):
|
|
402
|
+
end = starts[position + 1][0] if position + 1 < len(starts) else len(lines)
|
|
403
|
+
out.append((name, start, end))
|
|
404
|
+
return out
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_body(record: Dict[str, Any]) -> str:
|
|
408
|
+
"""The topic's body with its in-repo references inlined one level deep."""
|
|
409
|
+
body = record["body"]
|
|
410
|
+
if record["category"] == "reference":
|
|
411
|
+
return body
|
|
412
|
+
references = _doc_references(body)
|
|
413
|
+
if not references:
|
|
414
|
+
return body
|
|
415
|
+
by_path = {other["path"]: other["topic"] for other in index().values()}
|
|
416
|
+
parts = [body]
|
|
417
|
+
for reference in references:
|
|
418
|
+
path = REPO_ROOT / reference
|
|
419
|
+
if not path.is_file():
|
|
420
|
+
# A reference to a file that no longer exists is a real defect in the
|
|
421
|
+
# corpus, but it must not take the whole answer down with it.
|
|
422
|
+
parts.append(
|
|
423
|
+
f"\n\n---\n\n## Referenced: `{reference}` (missing from the checkout)\n"
|
|
424
|
+
)
|
|
425
|
+
continue
|
|
426
|
+
referenced = _split_frontmatter(_read(path))[1].strip()
|
|
427
|
+
title = _title_of(referenced, reference)
|
|
428
|
+
header = f"\n\n---\n\n## Referenced: {title}\n\n_Source: `{reference}`_\n\n"
|
|
429
|
+
line_count = len(referenced.splitlines())
|
|
430
|
+
if line_count <= MAX_INLINE_LINES:
|
|
431
|
+
parts.append(header + _demote_headings(referenced))
|
|
432
|
+
continue
|
|
433
|
+
target = by_path.get(reference)
|
|
434
|
+
fetch = (
|
|
435
|
+
f"get(topic=\"{target}\") for the full text, or "
|
|
436
|
+
f"get(topic=\"{target}\", section=...) for one section"
|
|
437
|
+
if target
|
|
438
|
+
else "read it in the repository checkout"
|
|
439
|
+
)
|
|
440
|
+
sections = [name for name, _, _ in _sections(referenced)]
|
|
441
|
+
parts.append(
|
|
442
|
+
header
|
|
443
|
+
+ f"_{line_count} lines — summarised here rather than inlined. Call {fetch}._\n\n"
|
|
444
|
+
+ f"{_first_paragraph(referenced)}\n\n"
|
|
445
|
+
+ "Sections: "
|
|
446
|
+
+ (", ".join(sections) if sections else "(none)")
|
|
447
|
+
+ "\n"
|
|
448
|
+
)
|
|
449
|
+
return "".join(parts)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def get(topic: str, *, section: Optional[str] = None, inline: bool = True) -> Dict[str, Any]:
|
|
453
|
+
"""Resolve one topic to prose.
|
|
454
|
+
|
|
455
|
+
`section` slices the primary document's own `##` headings and implies no inlining;
|
|
456
|
+
an unknown section raises rather than quietly returning everything.
|
|
457
|
+
"""
|
|
458
|
+
resolved = resolve_alias(topic)
|
|
459
|
+
if resolved is None:
|
|
460
|
+
# Nearest matches, not all 35 ids: a wrong guess is usually a near miss, and a
|
|
461
|
+
# wall of every topic is harder to act on than three plausible ones.
|
|
462
|
+
near = difflib.get_close_matches(_normalize(topic), sorted(index()), n=3, cutoff=0.4)
|
|
463
|
+
hint = f"Did you mean: {', '.join(near)}? " if near else ""
|
|
464
|
+
raise KnowledgeError(
|
|
465
|
+
f"unknown topic '{topic}'. {hint}Call topics() for the full index."
|
|
466
|
+
)
|
|
467
|
+
record = index()[resolved]
|
|
468
|
+
|
|
469
|
+
if section is not None:
|
|
470
|
+
available = _sections(record["body"])
|
|
471
|
+
wanted = _normalize(section)
|
|
472
|
+
for name, start, end in available:
|
|
473
|
+
if _normalize(name) == wanted:
|
|
474
|
+
lines = record["body"].splitlines()[start:end]
|
|
475
|
+
return {
|
|
476
|
+
"topic": resolved,
|
|
477
|
+
"title": record["title"],
|
|
478
|
+
"section": name,
|
|
479
|
+
"category": record["category"],
|
|
480
|
+
"source": record["path"],
|
|
481
|
+
"content": "\n".join(lines).strip(),
|
|
482
|
+
"inlined": [],
|
|
483
|
+
"related": record["related"],
|
|
484
|
+
}
|
|
485
|
+
raise KnowledgeError(
|
|
486
|
+
f"unknown section '{section}' in topic '{resolved}'. "
|
|
487
|
+
f"Sections: {', '.join(name for name, _, _ in available) or '(none)'}"
|
|
488
|
+
)
|
|
489
|
+
|
|
490
|
+
inlining = inline and record["category"] != "reference"
|
|
491
|
+
content = _resolve_body(record) if inlining else record["body"]
|
|
492
|
+
return {
|
|
493
|
+
"topic": resolved,
|
|
494
|
+
"title": record["title"],
|
|
495
|
+
"category": record["category"],
|
|
496
|
+
"source": record["path"],
|
|
497
|
+
"content": content,
|
|
498
|
+
"inlined": _doc_references(record["body"]) if inlining else [],
|
|
499
|
+
"sections": [name for name, _, _ in _sections(record["body"])],
|
|
500
|
+
"related": record["related"],
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _term_count(haystack: str, term: str) -> int:
|
|
505
|
+
"""Whole-word occurrences.
|
|
506
|
+
|
|
507
|
+
Substring counting looks equivalent and is not: searching "dead air" scored
|
|
508
|
+
`resolve-audio` top because "air" is inside "F-air-light". Every term here is a word
|
|
509
|
+
the user typed, so a word boundary is the honest match.
|
|
510
|
+
"""
|
|
511
|
+
return len(re.findall(rf"(?<!\w){re.escape(term)}(?!\w)", haystack))
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def search(query: str, *, limit: int = 5) -> List[Dict[str, Any]]:
|
|
515
|
+
"""Rank topics against a query. Deterministic term scoring — no embeddings."""
|
|
516
|
+
text_query = str(query or "").strip()
|
|
517
|
+
if not text_query:
|
|
518
|
+
raise KnowledgeError("search requires a non-empty query")
|
|
519
|
+
words = [term for term in re.split(r"\W+", text_query.lower()) if term]
|
|
520
|
+
# A query made only of stopwords still deserves an answer, so fall back to the
|
|
521
|
+
# unfiltered words rather than returning nothing.
|
|
522
|
+
terms = [term for term in words if term not in _STOPWORDS] or words
|
|
523
|
+
phrase = text_query.lower()
|
|
524
|
+
|
|
525
|
+
hits: List[Dict[str, Any]] = []
|
|
526
|
+
for record in index().values():
|
|
527
|
+
body = _resolve_body(record)
|
|
528
|
+
haystack = body.lower()
|
|
529
|
+
heading = f"{record['topic']} {record['title']} {record['summary']}".lower()
|
|
530
|
+
# Body hits are normalised by document size. Raw counts make length the ranking
|
|
531
|
+
# signal: the 2250-line operating reference mentions everything at least once.
|
|
532
|
+
weight = math.sqrt(max(1, len(body.splitlines())))
|
|
533
|
+
body_score = sum(_term_count(haystack, term) for term in terms)
|
|
534
|
+
if phrase and phrase in haystack:
|
|
535
|
+
body_score += 5
|
|
536
|
+
score = 10 * body_score / weight
|
|
537
|
+
score += 3 * sum(_term_count(heading, term) for term in terms)
|
|
538
|
+
if phrase and phrase in heading:
|
|
539
|
+
score += 15
|
|
540
|
+
score = round(score, 3)
|
|
541
|
+
if score <= 0:
|
|
542
|
+
continue
|
|
543
|
+
hits.append(
|
|
544
|
+
{
|
|
545
|
+
"topic": record["topic"],
|
|
546
|
+
"title": record["title"],
|
|
547
|
+
"category": record["category"],
|
|
548
|
+
"score": score,
|
|
549
|
+
"excerpt": _excerpt(body, terms, phrase),
|
|
550
|
+
}
|
|
551
|
+
)
|
|
552
|
+
hits.sort(key=lambda hit: (-hit["score"], hit["topic"]))
|
|
553
|
+
return hits[: max(1, int(limit))]
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _excerpt(body: str, terms: List[str], phrase: str) -> str:
|
|
557
|
+
lowered = body.lower()
|
|
558
|
+
position = lowered.find(phrase) if phrase else -1
|
|
559
|
+
if position < 0:
|
|
560
|
+
for term in terms:
|
|
561
|
+
position = lowered.find(term)
|
|
562
|
+
if position >= 0:
|
|
563
|
+
break
|
|
564
|
+
if position < 0:
|
|
565
|
+
position = 0
|
|
566
|
+
start = max(0, position - EXCERPT_RADIUS)
|
|
567
|
+
end = min(len(body), position + EXCERPT_RADIUS)
|
|
568
|
+
snippet = " ".join(body[start:end].split())
|
|
569
|
+
prefix = "…" if start > 0 else ""
|
|
570
|
+
suffix = "…" if end < len(body) else ""
|
|
571
|
+
return f"{prefix}{snippet}{suffix}"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def capabilities() -> Dict[str, Any]:
|
|
575
|
+
"""What this server knows, summarised for a capability probe."""
|
|
576
|
+
records = index()
|
|
577
|
+
by_category: Dict[str, int] = {}
|
|
578
|
+
for record in records.values():
|
|
579
|
+
by_category[record["category"]] = by_category.get(record["category"], 0) + 1
|
|
580
|
+
return {
|
|
581
|
+
"topic_count": len(records),
|
|
582
|
+
"categories": by_category,
|
|
583
|
+
"aliases": len(_ALIASES),
|
|
584
|
+
"corpus": [
|
|
585
|
+
str(SKILLS_DIR.relative_to(REPO_ROOT)),
|
|
586
|
+
str(GUIDES_DIR.relative_to(REPO_ROOT)),
|
|
587
|
+
str(KERNELS_DIR.relative_to(REPO_ROOT)),
|
|
588
|
+
],
|
|
589
|
+
"note": (
|
|
590
|
+
"Craft and workflow guidance resolved to prose, so a client with no "
|
|
591
|
+
"checkout can read it. Fetch the index with topics(), then get(topic) "
|
|
592
|
+
"before a creative or destructive operation — not after."
|
|
593
|
+
),
|
|
594
|
+
}
|
|
@@ -198,6 +198,34 @@ def _decode_value(transport: BridgeTransport, value: Any) -> Any:
|
|
|
198
198
|
return value
|
|
199
199
|
|
|
200
200
|
|
|
201
|
+
# Fusion's own objects under-report themselves, and unlike Resolve's they cannot
|
|
202
|
+
# be probed. `dir()` on a Fusion Tool lists 38 names — with "Composition"
|
|
203
|
+
# appearing twice — and omits GetAttrs/SetAttrs, which are documented Fusion Tool
|
|
204
|
+
# methods that work perfectly when called: measured on free 21.0.3.7 over this
|
|
205
|
+
# bridge, GetAttrs returned {TOOLS_Name: "Blur1", TOOLS_RegID: "Blur"} and
|
|
206
|
+
# SetAttrs renamed the tool. Resolve fabricates a callable for ANY name, so
|
|
207
|
+
# `dir()` is the only evidence of absence that exists — which means a name it
|
|
208
|
+
# omits cannot be recovered by probing, only by knowing.
|
|
209
|
+
#
|
|
210
|
+
# This is a curated exception, not a relaxation of the strict proxy. It applies
|
|
211
|
+
# only to names that are documented Fusion methods, and only on an object whose
|
|
212
|
+
# own method list identifies it as a Fusion object. Resolve API capability
|
|
213
|
+
# detection — `getattr(item, "CreateMagicMask", None)` — answers exactly as
|
|
214
|
+
# before, which is what the strict proxy exists to protect.
|
|
215
|
+
#
|
|
216
|
+
# Symptom when this is missing: `fusion_comp add_tool` calls tool.GetAttrs() to
|
|
217
|
+
# build its return value, so the action died with "has no attribute 'GetAttrs'
|
|
218
|
+
# in this Resolve build" and took every server-authored Fusion graph with it.
|
|
219
|
+
_FUSION_UNENUMERATED_METHODS = frozenset({"GetAttrs", "SetAttrs"})
|
|
220
|
+
|
|
221
|
+
#: Names that positively identify a Fusion Tool or Composition, as opposed to a
|
|
222
|
+
#: Resolve API object. Drawn from what `dir()` DOES report on each.
|
|
223
|
+
_FUSION_OBJECT_MARKERS = frozenset({
|
|
224
|
+
"ConnectInput", "FindMainInput", "GetControlPageNames", # Tool
|
|
225
|
+
"AddTool", "FindTool", "GetToolList", # Composition
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
|
|
201
229
|
class _BoundMethod:
|
|
202
230
|
"""One callable method on a proxied object."""
|
|
203
231
|
|
|
@@ -309,6 +337,11 @@ class BridgeProxy:
|
|
|
309
337
|
{"target": self._handle, "name": name}) or {}
|
|
310
338
|
if probe.get("kind") == "value":
|
|
311
339
|
return _decode_value(self._transport, probe.get("value"))
|
|
340
|
+
if (name in _FUSION_UNENUMERATED_METHODS
|
|
341
|
+
and self._methods() & _FUSION_OBJECT_MARKERS):
|
|
342
|
+
# A Fusion object omitting one of its own documented
|
|
343
|
+
# methods — see _FUSION_UNENUMERATED_METHODS.
|
|
344
|
+
return _BoundMethod(self._transport, self._handle, name)
|
|
312
345
|
# Matches native semantics: hasattr() is False, getattr(..., None)
|
|
313
346
|
# is None, and a capability check refuses instead of guessing.
|
|
314
347
|
# A `callable` answer lands here too — Resolve fabricates one for
|