From 23294bd75d10a714dfaa78938301df8f8a178c4f Mon Sep 17 00:00:00 2001 From: silviu Date: Mon, 18 May 2026 21:56:31 +0300 Subject: [PATCH 1/3] =?UTF-8?q?release(v1.6.16):=20agent=20observability?= =?UTF-8?q?=20=E2=80=94=20read=5Fjournal=20+=20activity=5Flog=20+=20td=5Fs?= =?UTF-8?q?elf=5Fupdate=20+=20state=5Fcache=20bake=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added: `_read_journal` response hint on every dispatched MCP tool (`src/td_mcp/read_journal.py`, wired in `_forward()`). Each response carries {call_count, first_seen_at, last_seen_at, result_unchanged} keyed by (tool_name, args_fingerprint). Claude can see across MCP request boundaries which reads have moved and which haven't. - Added: 200-entry server-side activity_log ring buffer + new `td_get_activity_log` MCP tool (`src/td_mcp/activity_log.py`, `src/td_mcp/registry/tools_meta.py`). - Added: in-TD `activity_log` Table DAT mirror at /local/mcp_server/ activity_log — rolling 200-row window so users can wire agent activity into their visual patch. `td_component/event_emitter.py` gains `append_activity_row`; `mcp_webserver_callbacks.py` `_record_request_safe` appends on every HTTP request. - Added: `td_self_update` MCP tool + `td_mcp/self_updater.py`. Hits GitHub releases API, compares semver, optionally writes the latest .tox to all three install paths (repo, plugin cache, ~/.tdpilot/) with md5 sync reporting. Closes the seven-layer staleness saga. Pure stdlib so it also runs inside TD's Python via Textport. - Fixed: `td_component/state_cache.py` source was never baked into the textDAT — listed in `_TOX_SOURCE_FILES` since v1.6.7 for hash tracking, but `_populate_component` callers never passed `state_cache_code`. The DAT shipped empty, `.module` raised tdError, and hasattr propagated through (only catches AttributeError), killing the state_cache writer. Latent for 9 releases. Fixed in `td_component/build_export_mcp_tox.py:build_and_export`. - Fixed: `_record_request_safe` dual-writer separation — state_cache and activity_log writers now in independent try blocks. A state_cache compile error can no longer take down the activity_log mirror. Also switched `parent()` to `me.parent()` (instance method, always defined) to be resilient across TD module-load contexts. - Bumped: tool count 104 → 106 (EXPECTED_MIN_TOOL_COUNT). Updated README, npm/README, plugin_README, docs/{API_REFERENCE,USER_GUIDE, GETTING_STARTED,MANUAL}, skills/{tdpilot-core,tdpilot-production}, marketplace+plugin descriptions, npm description, mcp/manifest.json, td_component/renderer.py, CHANGELOG. - Bumped: version 1.6.15 → 1.6.16 across all 7 fields (pyproject, __init__, .claude-plugin/plugin.json, marketplace.json, npm/package, mcp/manifest, API_VERSION in mcp_webserver_callbacks.py). - Added: `scripts/audit_v1_6_16_live.py` — end-to-end live audit script that exercises all v1.6.16 surfaces against a running TD (29 checks, all PASS). Joins patch_session_smoke.py + full_td_mcp_e2e.py as a permanent regression tool in scripts/. Verification: 970 pytest pass, 29-check live audit pass, check_versions + check_tox_freshness + smoke_mcp_registry all green. .tox rebuilt via Textport (hash 796a3d488aa48052..., 18:34 UTC). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude-plugin/marketplace.json | 6 +- .claude-plugin/plugin.json | 4 +- CHANGELOG.md | 86 +++++ README.md | 18 +- docs/API_REFERENCE.md | 2 +- docs/GETTING_STARTED.md | 2 +- docs/MANUAL.md | 2 +- docs/USER_GUIDE.md | 2 +- mcp/manifest.json | 4 +- npm/README.md | 6 +- npm/package.json | 4 +- plugin_README.md | 4 +- pyproject.toml | 2 +- scripts/audit_v1_6_16_live.py | 401 ++++++++++++++++++++++ skills/tdpilot-core/SKILL.md | 8 +- skills/tdpilot-production/SKILL.md | 4 +- src/td_mcp/__init__.py | 2 +- src/td_mcp/activity_log.py | 136 ++++++++ src/td_mcp/read_journal.py | 177 ++++++++++ src/td_mcp/registry/tools_meta.py | 116 +++++++ src/td_mcp/release_gates.py | 4 +- src/td_mcp/self_updater.py | 329 ++++++++++++++++++ src/td_mcp/tool_registry.py | 37 +- td_component/.tox-source-hash.json | 4 +- td_component/build_export_mcp_tox.py | 26 ++ td_component/event_emitter.py | 53 +++ td_component/mcp_webserver_callbacks.py | 41 ++- td_component/renderer.py | 2 +- td_component/tdpilot.tox | Bin 52174 -> 34318 bytes tests/fixtures/tool_schemas.json | 33 ++ tests/test_activity_log.py | 171 +++++++++ tests/test_forward_journal_integration.py | 122 +++++++ tests/test_read_journal.py | 177 ++++++++++ tests/test_self_updater.py | 252 ++++++++++++++ uv.lock | 2 +- 35 files changed, 2194 insertions(+), 45 deletions(-) create mode 100644 scripts/audit_v1_6_16_live.py create mode 100644 src/td_mcp/activity_log.py create mode 100644 src/td_mcp/read_journal.py create mode 100644 src/td_mcp/registry/tools_meta.py create mode 100644 src/td_mcp/self_updater.py create mode 100644 tests/test_activity_log.py create mode 100644 tests/test_forward_journal_integration.py create mode 100644 tests/test_read_journal.py create mode 100644 tests/test_self_updater.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 772ad30..5799dfa 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "dreamrec-TDPilot", - "description": "AI copilot for TouchDesigner \u2014 104 MCP tools for live node graph control, parameter management, diagnostics, safety, streaming, technique memory, knowledge corpus, focus + locations, hint injection, component notes, and typed patch sessions.", + "description": "AI copilot for TouchDesigner \u2014 106 MCP tools for live node graph control, parameter management, diagnostics, safety, streaming, technique memory, knowledge corpus, focus + locations, hint injection, component notes, typed patch sessions, agent activity log, and one-tool self-update.", "owner": { "name": "dreamrec", "email": "dreamrec@users.noreply.github.com" @@ -9,8 +9,8 @@ "plugins": [ { "name": "tdpilot", - "description": "AI copilot for TouchDesigner with live MCP control \u2014 104 tools, focus + locations, hint injection, component notes, POPx inspection, knowledge corpus, project lifecycle, custom parameter authoring, snapshots, undo-block safety, and typed patch sessions. Works with Claude Desktop, Claude Code, and any MCP-compatible client.", - "version": "1.6.15", + "description": "AI copilot for TouchDesigner with live MCP control \u2014 106 tools, focus + locations, hint injection, component notes, POPx inspection, knowledge corpus, project lifecycle, custom parameter authoring, snapshots, undo-block safety, typed patch sessions, agent activity log, and one-tool self-update. Works with Claude Desktop, Claude Code, and any MCP-compatible client.", + "version": "1.6.16", "author": { "name": "silviu" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 6a82896..7341326 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tdpilot", - "version": "1.6.15", - "description": "TDPilot \u2014 AI copilot for TouchDesigner. 104 MCP tools for live node graph control, parameter management, diagnostics, safety, streaming, knowledge corpus, focus + locations, hint injection, component notes, technique memory, and typed patch sessions.", + "version": "1.6.16", + "description": "TDPilot \u2014 AI copilot for TouchDesigner. 106 MCP tools for live node graph control, parameter management, diagnostics, safety, streaming, knowledge corpus, focus + locations, hint injection, component notes, technique memory, typed patch sessions, agent activity log, and one-tool self-update.", "author": { "name": "silviu" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 31740da..3e94af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,92 @@ # Changelog +## 1.6.16 - 2026-05-18 — Agent observability (read journal + activity log) + one-tool self-update + +### Added + +- **`_read_journal` response hint on every dispatched tool** (`src/td_mcp/read_journal.py`, + wired in `src/td_mcp/tool_registry.py:_forward`). Each tool response now carries a + `{call_count, first_seen_at, last_seen_at, result_unchanged}` envelope keyed by + (tool_name, args_fingerprint). The model can see, across MCP request boundaries, + whether a repeat call returned the same result hash — eliminating the wasted + token cycles of re-fetching `td_get_nodes("/project1")` seven times when nothing + has moved. The journal is **advisory only**: every call still executes against + TD, so a mutating scene never serves stale data. Bounded to 500 distinct + fingerprints, oldest-by-`last_seen_at` evicted under pressure. Thread-safe + module-level dict (same pattern as `state_cache`). +- **`td_get_activity_log` MCP tool + 200-entry server-side ring buffer** + (`src/td_mcp/activity_log.py`, `src/td_mcp/registry/tools_meta.py`). + Every `_forward` dispatch appends one entry: `{ts, tool, args_summary, + result_summary, duration_ms, ok}`. `td_get_activity_log(limit, tool_filter)` + returns recent entries newest-first so Claude can ask "what have I done + this session?" without scrolling its own context. Optimized for time + ordering (vs `_read_journal`, which dedupes by fingerprint). +- **In-TD `activity_log` Table DAT mirror** (`td_component/build_export_mcp_tox.py`, + `td_component/event_emitter.py:append_activity_row`, + `td_component/mcp_webserver_callbacks.py:_record_request_safe`). A new + Table DAT at `/local/mcp_server/activity_log` is populated on every MCP + HTTP request with the same five-column schema as the server-side buffer. + Users can chain this DAT into their visuals (Select DAT → Text TOP / CHOP + pipeline) so the AI's actions become an addressable, time-windowed + data source inside the live patch. Rolling window of 200 rows. +- **`td_self_update` MCP tool + `td_mcp/self_updater.py`**. Hits the GitHub + releases API for `dreamrec/TDPilot`, compares the latest semver against + the running `__version__`, and (when `check_only=False`) downloads the + release asset and writes it to all three install paths the user-memory + staleness saga identified: the repo working-tree `td_component/`, the + Claude Code plugin cache `~/.claude/plugins/cache/dreamrec-TDPilot/.../`, + and the user-data dir `~/.tdpilot/td_component/`. Reports md5 sums per + path so the caller can verify three-way sync. The follow-up step + ("re-run setup_mcp_in_td.py inside TD Textport to refresh `/local/mcp_server`") + is included in the response payload so the seventh staleness layer + (live in-TD COMP) doesn't get forgotten. Pure stdlib (`urllib.request`, + `hashlib`) so it also runs inside TouchDesigner's Python interpreter via + `python -m td_mcp.self_updater`. + +### Changed + +- **Tool count 104 → 106** (`src/td_mcp/release_gates.py:EXPECTED_MIN_TOOL_COUNT`). + Two new tools above. All user-facing copies bumped: README badges, npm + description, plugin manifests, marketplace.json, skills, docs, CHANGELOG. +- **`_forward` dispatch wrapper** (`src/td_mcp/tool_registry.py:839`) now runs + the read-journal + activity-log hooks in `try/finally` blocks. Failures + inside either observability layer are swallowed — they must never break + a tool response. Timing precision via `time.perf_counter` so reported + `duration_ms` includes only the TD round-trip + serialization. + +### Fixed + +- **`state_cache.py` source was never baked into the textDAT** + (`td_component/build_export_mcp_tox.py:build_and_export`). The file + was added to `_TOX_SOURCE_FILES` in v1.6.7 for freshness-hash tracking + but the `_populate_component` callers never passed `state_cache_code`, + so the DAT shipped with empty `.text`. Accessing `.module` on an empty + Text DAT raises `td.tdError: Module compilation error`, which + propagated through `hasattr(cache, 'module')` (only catches + `AttributeError`) and aborted the surrounding state_cache writer. v1.6.16 + reads `td_component/state_cache.py` and threads it through both + `_populate_component` call sites. Latent since v1.6.7; surfaced when + the new dual-writer in `_record_request_safe` exposed the same code + path more directly. +- **`_record_request_safe` lost the activity_log mirror when state_cache + raised** (`td_component/mcp_webserver_callbacks.py`). The state_cache + and activity_log writers shared one outer try/except, so a state_cache + `tdError` (above) killed both. v1.6.16 splits them into independent + try blocks and switches the `parent()` global to the always-defined + `me.parent()` instance method, so the activity_log mirror is resilient + to any future state_cache breakage. + +### Migration + +- No breaking surface changes. Every existing tool keeps its current + signature; the new `_read_journal` field is purely additive on responses. +- Users who already have v1.6.15 installed can either reinstall the plugin + the normal way OR call the new `td_self_update(check_only=False)` from + Claude after upgrading to v1.6.16. The latter is the single-command flow + the rest of this release was built to enable. + + ## 1.6.15 - 2026-05-15 — TD 2025+ auth-handshake fix + Pydantic v2 ClassVar fix + auto-versioned .tox sidecars ### Fixed diff --git a/README.md b/README.md index da741ff..3833d9d 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,14 @@ ╚═╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝ ╚═════╝ ╚═╝ ``` -# TDPilot Runtime v1.6.15 +# TDPilot Runtime v1.6.16 [![CI](https://github.com/dreamrec/TDPilot/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/dreamrec/TDPilot/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/tdpilot?label=npm)](https://www.npmjs.com/package/tdpilot) [![downloads](https://img.shields.io/npm/dm/tdpilot?label=downloads)](https://www.npmjs.com/package/tdpilot) [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE) [![python](https://img.shields.io/badge/python-3.10%2B-blue)](./pyproject.toml) -[![MCP tools](https://img.shields.io/badge/MCP%20tools-104-blueviolet)](./docs/API_REFERENCE.md) +[![MCP tools](https://img.shields.io/badge/MCP%20tools-106-blueviolet)](./docs/API_REFERENCE.md) [![TouchDesigner](https://img.shields.io/badge/TouchDesigner-2025.30000%2B-ff6200)](https://derivative.ca) **TDPilot Runtime** is an MCP server for TouchDesigner. @@ -31,7 +31,7 @@ It lets an AI agent inspect, build, wire, optimize, and stabilize live TD networ /plugin install tdpilot@dreamrec-TDPilot ``` -That installs all **104 MCP tools**, 3 skills (`tdpilot-core`, `tdpilot-production`, `popx-touchdesigner`), 2 slash commands (`/td-check`, `/td-snapshot`), and the TD-side `.tox` component — one command, no Python setup required. +That installs all **106 MCP tools**, 3 skills (`tdpilot-core`, `tdpilot-production`, `popx-touchdesigner`), 2 slash commands (`/td-check`, `/td-snapshot`), and the TD-side `.tox` component — one command, no Python setup required. **Shell one-liner alternative:** @@ -70,11 +70,11 @@ Using Claude Desktop instead of Claude Code? See [`docs/INSTALL_CLAUDE_PLUGIN.md - A structured toolset for scene edits, diagnostics, event monitoring, and recovery. - A workflow-oriented MCP built for iterative patch development, not one-shot guessing. - A technique memory system that learns from your projects and builds a reusable library. -- 104-tool runtime surface with focus + locations, hint injection, component notes, knowledge corpus, vision diagnostics, TD 2025 native inspection, official recommendations, job resources, memory, optimizer, safety, POPx inspection, project lifecycle control, custom parameter authoring, and typed patch sessions. +- 106-tool runtime surface with focus + locations, hint injection, component notes, knowledge corpus, vision diagnostics, TD 2025 native inspection, official recommendations, job resources, memory, optimizer, safety, POPx inspection, project lifecycle control, custom parameter authoring, typed patch sessions, agent activity log, and one-tool self-update. ## Start Here: Core Workflow -You don't need all 104 tools. Start with these and expand as needed: +You don't need all 106 tools. Start with these and expand as needed: | Step | Tools | What You're Doing | |------|-------|-------------------| @@ -89,9 +89,11 @@ You don't need all 104 tools. Start with these and expand as needed: Everything else (vision, streaming, optimization, planning, TD2025 inspection) builds on top of this core. -## What's New In 1.6.15 – 1.6.14 +## What's New In 1.6.16 – 1.6.14 -Stability + ergonomics run since v1.6.0. Tool count unchanged at 104; hint corpus grew to 73 hints across 20 packs. Headlines: +Stability + ergonomics run since v1.6.0. v1.6.16 adds two new agent-observability tools (`td_get_activity_log`, `td_self_update`); tool count 104 → 106. Hint corpus stays at 73 hints across 20 packs. Headlines: + +- **v1.6.16** — Agent-observability + self-update. Every tool response now carries a `_read_journal` hint (`call_count`, `result_unchanged`, `first_seen_at`, `last_seen_at`) so Claude can see across MCP request boundaries which reads have moved and which haven't — no more wasted token cycles re-fetching the same view of `td_get_nodes`. A 200-entry server-side activity ring buffer mirrors to an in-TD Table DAT (`/local/mcp_server/activity_log`) so users can wire agent activity into their visuals. New `td_self_update` MCP tool hits the GitHub releases API and (optionally) writes the latest `tdpilot.tox` to all three install paths (repo, Claude Code plugin cache, `~/.tdpilot/`) with md5 sync reporting — closing the seven-layer staleness saga documented in user memory. - **v1.6.14** — MCP-server-side auth fix: `TDClient` now resolves the shared secret fresh on every request (env → `~/.tdpilot/.tdpilot.env` → constructor fallback, with 5s cache) and retries once on a 401 after invalidating the cache. Symmetric with the v1.6.13 TD-side fallback — closes the asymmetric half that was still 401ing real sessions when `bootstrap_auth` ran late and a stale module-level secret got baked into the cached `httpx.AsyncClient` headers. Also ships the **TD 2025.32820 release card** (Math Mix / Math Combine POPs, EXR compression, GLSL MAT `TDProjTextureLod`/`TDProjTextureSize`, Blackmagic SDK 16, CUDA 12.9.1, NDI 6.3.1) and a **CI freshness gate** (`scripts/check_release_notes_freshness.py`) that fails when seed cards trail `docs.derivative.ca/Release_Notes` by more than one build. - **v1.6.13** — Permanent TD-side auth-race fix: `_current_shared_secret()` in `td_component/mcp_webserver_callbacks.py` now has a file fallback that reads `~/.tdpilot/.tdpilot.env` when `os.environ` is empty. Stops the 401 cascade when TD is opened directly (Dock icon, double-click `.toe`) and inherits an empty env, then `npx tdpilot` later writes the secret to the file. @@ -205,7 +207,7 @@ Use this loop for every non-trivial task: 6. **Control token cost** — Prefer metadata checks over continuous image payloads. Ask the user before enabling high-token frame streaming. -## Tool Map (104 Tools) +## Tool Map (106 Tools) ### 1) Scene + Timeline + Project Lifecycle Use for global context, playback control, save/load, and undo operations. diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index ef7fad6..c0788ee 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -1,6 +1,6 @@ # TDPilot API Reference -> Auto-generated from TDPilot v1.6.15 | 104 tools | Source: `src/td_mcp/tool_registry.py` +> Auto-generated from TDPilot v1.6.16 | 106 tools | Source: `src/td_mcp/tool_registry.py` --- diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 17381ee..2160f8e 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -153,7 +153,7 @@ This is how you keep the patch understandable. ## The Small Set Of Tools That Matter Most -You do not need to learn all 104 tools to get real value. +You do not need to learn all 106 tools to get real value. If you are new, focus on these: diff --git a/docs/MANUAL.md b/docs/MANUAL.md index ff6ba83..5db07cc 100644 --- a/docs/MANUAL.md +++ b/docs/MANUAL.md @@ -1,4 +1,4 @@ -# TDPilot v1.6.15 Production Manual +# TDPilot v1.6.16 Production Manual This manual is for people who need real output in TouchDesigner, not theory. It explains what TDPilot does well, what it does not do, and how to run it with repeatable production discipline. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index fa8a925..1a657da 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -241,7 +241,7 @@ Prompt: ## Your "Good Enough" TDPilot Starter Stack -If you do not want to learn all 104 tools, focus on this stack: +If you do not want to learn all 106 tools, focus on this stack: - inspect: `td_get_info`, `td_get_nodes`, `td_get_node_detail`, `td_get_params` diff --git a/mcp/manifest.json b/mcp/manifest.json index 00982a5..a06de30 100644 --- a/mcp/manifest.json +++ b/mcp/manifest.json @@ -2,7 +2,7 @@ "schema_version": 1, "name": "TDPilot", "slug": "tdpilot", - "version": "1.6.15", + "version": "1.6.16", "repository": "https://github.com/dreamrec/TDPilot", "description": "MCP server for TouchDesigner with live graph control, diagnostics, safety, streaming, knowledge corpus, technique memory, and typed patch sessions.", "entrypoints": { @@ -32,7 +32,7 @@ "transport": "stdio" }, "surface": { - "tool_count": 104, + "tool_count": 106, "resource_template_count": 6, "static_resource_count": 1 }, diff --git a/npm/README.md b/npm/README.md index 7a3e991..ddefdf5 100644 --- a/npm/README.md +++ b/npm/README.md @@ -1,12 +1,12 @@ -# TDPilot v1.6.15 +# TDPilot v1.6.16 [![CI](https://github.com/dreamrec/TDPilot/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/dreamrec/TDPilot/actions/workflows/ci.yml) [![npm](https://img.shields.io/npm/v/tdpilot?label=npm)](https://www.npmjs.com/package/tdpilot) [![downloads](https://img.shields.io/npm/dm/tdpilot?label=downloads)](https://www.npmjs.com/package/tdpilot) [![license](https://img.shields.io/badge/license-MIT-blue)](https://github.com/dreamrec/TDPilot/blob/main/LICENSE) -[![MCP tools](https://img.shields.io/badge/MCP%20tools-104-blueviolet)](https://github.com/dreamrec/TDPilot/blob/main/docs/API_REFERENCE.md) +[![MCP tools](https://img.shields.io/badge/MCP%20tools-106-blueviolet)](https://github.com/dreamrec/TDPilot/blob/main/docs/API_REFERENCE.md) -AI copilot for TouchDesigner — 104 tools for full live control via MCP, with technique memory, knowledge corpus, POPx inspection, project lifecycle control, focus + locations, hint injection, component notes, and custom parameter authoring. +AI copilot for TouchDesigner — 106 tools for full live control via MCP, with technique memory, knowledge corpus, POPx inspection, project lifecycle control, focus + locations, hint injection, component notes, custom parameter authoring, agent activity log, and one-tool self-update. ## Quick start diff --git a/npm/package.json b/npm/package.json index 8402ee0..a9d026b 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,7 +1,7 @@ { "name": "tdpilot", - "version": "1.6.15", - "description": "AI copilot for TouchDesigner with live MCP control \u2014 104 tools, focus + locations, hint injection, component notes, POPx inspection, knowledge corpus, project lifecycle control, technique memory, typed patch sessions, Claude Code plugin marketplace install.", + "version": "1.6.16", + "description": "AI copilot for TouchDesigner with live MCP control \u2014 106 tools, focus + locations, hint injection, component notes, POPx inspection, knowledge corpus, project lifecycle control, technique memory, typed patch sessions, agent activity log, one-tool self-update, Claude Code plugin marketplace install.", "keywords": [ "touchdesigner", "mcp", diff --git a/plugin_README.md b/plugin_README.md index 55ff502..daa5512 100644 --- a/plugin_README.md +++ b/plugin_README.md @@ -1,6 +1,6 @@ # TDPilot — TouchDesigner AI Assistant Plugin -TDPilot v1.6.15 provides 104 MCP tools for live control of TouchDesigner projects from Claude (plus a 20-pack / 73-hint corpus — expanded in v1.6.1 and again in v1.6.12 with error_recovery hints — with v1.6.2 surface routing for response-context-aware hints), plus a one-button-install panel inside the `.tox` itself (drag-drop into TD, click "Bootstrap All", done). v1.6.10 closes the auto-update gap that v1.6.6 left open: the v1.6.6 `_save_toe_with_externaltox` mechanism only protected the canonical `~/.tdpilot/tdpilot_default.toe` autoload — user-created `.toe` files in arbitrary locations had a frozen embedded COMP body that wouldn't auto-update. v1.6.10 adds a "Pin this project to disk .tox" pulse + a Body status row in the panel that detects frozen-body state. Click once on any open project → externaltox attached → save with `saveExternalToxs=False` → reopen .toe → fresh content. From that point on, `npx tdpilot@latest` + TD relaunch = automatic update for that project, just like the canonical autoload. +TDPilot v1.6.16 provides 106 MCP tools for live control of TouchDesigner projects from Claude. v1.6.16 adds agent-observability primitives: every tool response now carries a `_read_journal` hint so Claude can see across MCP request boundaries which reads have moved and which haven't, plus a 200-entry server-side `activity_log` ring buffer mirrored into an in-TD Table DAT (`/local/mcp_server/activity_log`) for wiring agent activity into a live visual patch. v1.6.16 also ships `td_self_update` — a one-tool GitHub-driven updater that writes the latest `tdpilot.tox` to all three install paths (repo working-tree, Claude Code plugin cache, `~/.tdpilot/`) with md5 sync reporting, closing the multi-layer staleness problem that dogged the v1.6.x line. ## Components @@ -8,7 +8,7 @@ TDPilot v1.6.15 provides 104 MCP tools for live control of TouchDesigner project - **touchdesigner** — Connects to TDPilot MCP server via `npx tdpilot` (stdio transport) ### Skills -- **tdpilot-core** — Core patching discipline: 104-tool reference, node layout, color coding, expressions, error verification, visual checks, technique memory, knowledge corpus, v1.1 features (custom parameters, project lifecycle, POP inspection) +- **tdpilot-core** — Core patching discipline: 106-tool reference, node layout, color coding, expressions, error verification, visual checks, technique memory, knowledge corpus, v1.1 features (custom parameters, project lifecycle, POP inspection), agent activity log, and self-update - **tdpilot-production** — Production-safe workflow: staged edits, undo blocks, snapshots, completion gates, failure protocol - **popx-touchdesigner** — POPX workflow skill for 59 GPU-accelerated operators. References must be built locally from your own licensed POPx copy (see `references/BUILD.md`) diff --git a/pyproject.toml b/pyproject.toml index 8490a9e..59503c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tdpilot" -version = "1.6.15" +version = "1.6.16" description = "MCP server for live control of TouchDesigner — create, inspect, connect, and manipulate nodes via AI agents" readme = "README.md" license = "MIT" diff --git a/scripts/audit_v1_6_16_live.py b/scripts/audit_v1_6_16_live.py new file mode 100644 index 0000000..35b6739 --- /dev/null +++ b/scripts/audit_v1_6_16_live.py @@ -0,0 +1,401 @@ +"""End-to-end live audit of v1.6.16 against a running TouchDesigner. + +Run from the worktree root: + + cd + PYTHONPATH=src python scripts/audit_v1_6_16_live.py + +What it does (in order): + +1. Loads the WORKTREE's `td_mcp` (not the installed plugin), so the + `_read_journal` injection, `activity_log` recording, and the two new + tools are exercised with the v1.6.16 code. +2. Builds a real `TDClient` + `ServiceContainer` + `Context` so + ``tool_registry._forward`` runs against the live TD on 127.0.0.1:9981. +3. Resolves the shared secret from `~/.tdpilot/.tdpilot.env` so requests + pass auth on TD 2025+. +4. Exercises: + - First `td_get_info` → `_read_journal.call_count == 1`, + `result_unchanged is None`. + - Second `td_get_info` → `call_count == 2`. `result_unchanged` is + determined by whether `seconds`/`frame` advanced (almost always + True if TD is playing, so we accept either value but require the + field to be present). + - `td_get_activity_log` MCP tool → returns the two entries we just + created, newest-first, with all expected columns. + - `td_self_update(check_only=True)` MCP tool → returns the GitHub + latest-release semver comparison with a structured envelope. + - Error path: `td_get_nodes("/nonexistent")` records ok=True (TD + returns 200 with success:false body) — confirms the recorder + follows HTTP-status contract (not body-level semantic failures). +5. Checks `activity_log` ring-buffer bounds + that the journal hint + shape stays stable across all dispatched tools. + +Each step prints PASS / FAIL with the evidence inline. Final summary at +the end. Exit code 0 if everything passed; non-zero otherwise. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import time +import traceback +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +# ── bootstrap ──────────────────────────────────────────────────────── + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "src" +if str(SRC) not in sys.path: + sys.path.insert(0, str(SRC)) + +# Skip the env-bootstrap module's heavy startup probe; we resolve the +# shared secret manually below. +os.environ.setdefault("TDPILOT_SKIP_AUTH_BOOTSTRAP", "1") + +_secret_file = Path.home() / ".tdpilot" / ".tdpilot.env" +if _secret_file.is_file(): + for line in _secret_file.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line.startswith("TD_MCP_SHARED_SECRET="): + value = line.split("=", 1)[1].strip().strip('"').strip("'") + if value: + os.environ["TD_MCP_SHARED_SECRET"] = value + break + +from td_mcp import __version__ as worktree_version # noqa: E402 +from td_mcp import activity_log, read_journal # noqa: E402 +from td_mcp import tool_registry as tr # noqa: E402 +from td_mcp.registry.tools_meta import ( # noqa: E402 + td_get_activity_log, + td_self_update, +) +from td_mcp.services import ServiceContainer # noqa: E402 +from td_mcp.td_client import TDClient # noqa: E402 +from td_mcp.telemetry import TelemetryCollector # noqa: E402 + +# ── helpers ────────────────────────────────────────────────────────── + +PASSES: list[str] = [] +FAILS: list[str] = [] + + +def check(label: str, condition: bool, evidence: str = "") -> None: + if condition: + PASSES.append(label) + print(f"PASS {label}" + (f"\n {evidence}" if evidence else "")) + else: + FAILS.append(label) + print(f"FAIL {label}" + (f"\n {evidence}" if evidence else "")) + + +def _build_ctx() -> SimpleNamespace: + client = TDClient( + host="127.0.0.1", + port=9981, + shared_secret=os.environ.get("TD_MCP_SHARED_SECRET"), + ) + telemetry = TelemetryCollector() + services = ServiceContainer( + td_client=client, + technique_store=None, + preference_store=None, + telemetry=telemetry, + ) + state = {"services": services} + return SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=state, + lifespan_state=state, + ) + ) + + +def _parse_envelope(raw: Any) -> dict[str, Any]: + if isinstance(raw, dict): + return raw + if isinstance(raw, str): + return json.loads(raw) + raise TypeError(f"unexpected envelope type {type(raw).__name__}") + + +# ── checks ─────────────────────────────────────────────────────────── + + +async def audit_first_call_envelope(ctx: SimpleNamespace) -> None: + read_journal.reset() + activity_log.reset() + raw = await tr._forward(ctx, "td_get_info", "info") + parsed = _parse_envelope(raw) + has_hint = "_read_journal" in parsed + check( + "1.1 _read_journal envelope present on first call", + has_hint, + f"keys={sorted(parsed.keys())[:8]}", + ) + if has_hint: + h = parsed["_read_journal"] + check( + "1.2 first call: call_count == 1", + h["call_count"] == 1, + f"call_count={h['call_count']}", + ) + check( + "1.3 first call: result_unchanged is None", + h["result_unchanged"] is None, + f"result_unchanged={h['result_unchanged']!r}", + ) + check( + "1.4 first_seen_at and last_seen_at present", + "first_seen_at" in h and "last_seen_at" in h, + f"first_seen_at={h.get('first_seen_at')}, last_seen_at={h.get('last_seen_at')}", + ) + api_v = parsed.get("api_version") + check( + "1.5 TD api_version is 1.6.16 (TD-side wired)", + api_v == "1.6.16", + f"api_version={api_v!r}", + ) + + +async def audit_repeat_call(ctx: SimpleNamespace) -> None: + raw = await tr._forward(ctx, "td_get_info", "info") + parsed = _parse_envelope(raw) + h = parsed["_read_journal"] + check( + "2.1 second call: call_count == 2", + h["call_count"] == 2, + f"call_count={h['call_count']}", + ) + # result_unchanged is True only if TD info bytes are identical. The + # `frame` and `seconds` fields advance every cook tick, so on a playing + # project this is almost always False. We accept either, but the field + # MUST be a real bool (not None). + check( + "2.2 second call: result_unchanged is bool (True or False, not None)", + isinstance(h["result_unchanged"], bool), + f"result_unchanged={h['result_unchanged']!r}", + ) + + +async def audit_static_endpoint_unchanged(ctx: SimpleNamespace) -> None: + """Pick an endpoint whose response IS stable across cooks: list_families. + Two repeat calls should produce result_unchanged=True. + """ + raw1 = await tr._forward(ctx, "td_list_families", "families") + raw2 = await tr._forward(ctx, "td_list_families", "families") + p2 = _parse_envelope(raw2) + h = p2["_read_journal"] + check( + "3.1 static endpoint repeat: result_unchanged == True", + h["result_unchanged"] is True, + f"call_count={h['call_count']}, result_unchanged={h['result_unchanged']}", + ) + + +async def audit_activity_log_server_side() -> None: + snap = activity_log.snapshot(limit=10) + check( + "4.1 activity_log captured >= 4 entries from audit so far", + len(snap) >= 4, + f"entries={len(snap)}, tools={[e['tool'] for e in snap]}", + ) + # Field-shape contract + if snap: + e = snap[0] + required = {"ts", "tool", "args_summary", "result_summary", "duration_ms", "ok"} + check( + "4.2 activity_log entry shape complete", + required.issubset(set(e.keys())), + f"keys={sorted(e.keys())}", + ) + check( + "4.3 activity_log duration_ms is float and > 0", + isinstance(e["duration_ms"], (int, float)) and e["duration_ms"] >= 0, + f"duration_ms={e['duration_ms']}", + ) + + +async def audit_td_get_activity_log_tool(ctx: SimpleNamespace) -> None: + raw = await td_get_activity_log(ctx, limit=5) + parsed = _parse_envelope(raw) + check( + "5.1 td_get_activity_log returns count + max_buffer + entries", + all(k in parsed for k in ("count", "max_buffer", "entries", "schema_version")), + f"keys={sorted(parsed.keys())}", + ) + check( + "5.2 td_get_activity_log respects limit", + len(parsed["entries"]) <= 5, + f"returned={len(parsed['entries'])}", + ) + # Did it ALSO log its own call? + snap_after = activity_log.snapshot(limit=1) + check( + "5.3 td_get_activity_log itself is NOT recorded (server-local, no _forward path)", + snap_after[0]["tool"] != "td_get_activity_log", + f"newest_tool={snap_after[0]['tool']}", + ) + # tool_filter argument + filtered = await td_get_activity_log(ctx, limit=20, tool_filter="td_list_families") + fparsed = _parse_envelope(filtered) + check( + "5.4 td_get_activity_log tool_filter works", + all(e["tool"] == "td_list_families" for e in fparsed["entries"]), + f"filtered_tools={[e['tool'] for e in fparsed['entries']]}", + ) + + +async def audit_td_self_update_tool(ctx: SimpleNamespace) -> None: + # check_only=True is safe — no disk writes, just GitHub API hit. + raw = await td_self_update(ctx, check_only=True) + parsed = _parse_envelope(raw) + has_required = all(k in parsed for k in ("installed", "latest", "newer_available", "release_url")) + check( + "6.1 td_self_update check_only returns full envelope", + has_required, + f"keys={sorted(parsed.keys())}", + ) + if has_required: + check( + "6.2 installed matches worktree __version__", + parsed["installed"] == worktree_version, + f"installed={parsed['installed']!r}, worktree={worktree_version!r}", + ) + check( + "6.3 follow_up reminder present (post-update setup hint)", + "follow_up" in parsed and "setup_mcp_in_td" in parsed.get("follow_up", ""), + f"follow_up={parsed.get('follow_up','')[:80]!r}", + ) + + +async def audit_error_path(ctx: SimpleNamespace) -> None: + """A request that TD rejects: nonexistent path. TD returns 200 + success:false.""" + activity_log.reset() + raw = await tr._forward(ctx, "td_get_nodes", "nodes/list", {"path": "/no/such/path/anywhere"}) + parsed = _parse_envelope(raw) + # The response itself may or may not be wrapped — depends on the error format. + snap = activity_log.snapshot(limit=1) + has_entry = len(snap) == 1 + check( + "7.1 error-path call still records to activity_log", + has_entry, + f"entries={len(snap)}", + ) + if has_entry: + check( + "7.2 error-path call has _read_journal too (advisory layer is universal)", + "_read_journal" in parsed or parsed.get("success") is False, + f"keys={sorted(parsed.keys())[:8]}, success={parsed.get('success')}", + ) + + +async def audit_concurrent_calls(ctx: SimpleNamespace) -> None: + """Fire 10 parallel calls; verify no race conditions in journal/log.""" + read_journal.reset() + activity_log.reset() + results = await asyncio.gather( + *[tr._forward(ctx, "td_list_families", "families") for _ in range(10)] + ) + # All 10 should have read_journal hints with call_count summing to ≥ 10 + parsed_all = [_parse_envelope(r) for r in results] + counts = sorted(p["_read_journal"]["call_count"] for p in parsed_all) + check( + "8.1 concurrent: 10 parallel calls all have envelopes", + all("_read_journal" in p for p in parsed_all), + f"all hinted", + ) + check( + "8.2 concurrent: call_count reaches 10 monotonically (no lost counts)", + max(counts) == 10 and min(counts) == 1, + f"counts={counts}", + ) + # activity_log should have exactly 10 entries + snap = activity_log.snapshot(limit=20) + check( + "8.3 concurrent: activity_log recorded all 10 calls", + len(snap) == 10, + f"recorded={len(snap)}", + ) + + +async def audit_tool_schemas() -> None: + """Pydantic schemas for the new tools — make sure FastMCP knows about them.""" + tools = tr.mcp._tool_manager._tools + for name in ("td_get_activity_log", "td_self_update"): + t = tools.get(name) + check( + f"9.{'1' if name == 'td_get_activity_log' else '2'} {name} is registered as a Tool object", + t is not None, + f"type={type(t).__name__ if t else 'None'}", + ) + + +async def audit_module_imports() -> None: + """Cold-import test: the side-effect chain in registry/__init__.py must + not break the package.""" + check( + "10.1 td_mcp.read_journal module loads cleanly", + hasattr(read_journal, "record_call"), + "", + ) + check( + "10.2 td_mcp.activity_log module loads cleanly", + hasattr(activity_log, "record") and hasattr(activity_log, "snapshot"), + "", + ) + from td_mcp import self_updater + check( + "10.3 td_mcp.self_updater module loads cleanly", + callable(self_updater.run) and callable(self_updater.is_newer), + "", + ) + + +# ── main ───────────────────────────────────────────────────────────── + + +async def main() -> int: + print(f"=== Live audit of TDPilot v{worktree_version} ===\n") + ctx = _build_ctx() + try: + # Health-check the TD connection up front. + h = await ctx.request_context.lifespan_state["services"].td_client.health_check() + check("0.0 TD reachable + auth OK", h.get("status") == "ok", f"health={h}") + if not FAILS: + await audit_first_call_envelope(ctx) + await audit_repeat_call(ctx) + await audit_static_endpoint_unchanged(ctx) + await audit_activity_log_server_side() + await audit_td_get_activity_log_tool(ctx) + await audit_td_self_update_tool(ctx) + await audit_error_path(ctx) + await audit_concurrent_calls(ctx) + await audit_tool_schemas() + await audit_module_imports() + finally: + try: + await ctx.request_context.lifespan_state["services"].td_client.close() + except Exception: + pass + + print(f"\n=== Summary: {len(PASSES)} pass, {len(FAILS)} fail ===") + if FAILS: + print("FAILED:") + for f in FAILS: + print(f" - {f}") + return 1 + return 0 + + +if __name__ == "__main__": + try: + sys.exit(asyncio.run(main())) + except Exception: + traceback.print_exc() + sys.exit(2) diff --git a/skills/tdpilot-core/SKILL.md b/skills/tdpilot-core/SKILL.md index 1372400..5d5c527 100644 --- a/skills/tdpilot-core/SKILL.md +++ b/skills/tdpilot-core/SKILL.md @@ -1,7 +1,7 @@ --- name: tdpilot-core description: > - Core patching discipline for TDPilot v1.6.15 — the AI assistant inside TouchDesigner. + Core patching discipline for TDPilot v1.6.16 — the AI assistant inside TouchDesigner. Use this skill whenever working with TouchDesigner through the td_ MCP tools. It governs how you build, debug, modify, and maintain TD projects: clean node layouts with color coding, error checking after every operation, visual @@ -12,15 +12,15 @@ description: > project lifecycle, technique memory, everything. --- -# TDPilot Core v1.6.15 — Patching Discipline (104 tools) +# TDPilot Core v1.6.16 — Patching Discipline (106 tools) -You are an AI assistant working live inside a TouchDesigner project. You have full control through 104 MCP tools — but control without discipline creates mess. This skill defines how you work. +You are an AI assistant working live inside a TouchDesigner project. You have full control through 106 MCP tools — but control without discipline creates mess. This skill defines how you work. The goal: every action you take should leave the project cleaner, more readable, and more stable than you found it. You're not generating throwaway demos — you're working inside someone's real project. --- -## Complete Tool Surface — v1.6.14 (104 tools, 6 resource templates + 1 static resource) +## Complete Tool Surface — v1.6.16 (106 tools, 6 resource templates + 1 static resource) ### Scene & Info (2) - `td_get_info` — project name, TD version, OS, FPS, timeline state diff --git a/skills/tdpilot-production/SKILL.md b/skills/tdpilot-production/SKILL.md index 75092a4..c566841 100644 --- a/skills/tdpilot-production/SKILL.md +++ b/skills/tdpilot-production/SKILL.md @@ -1,14 +1,14 @@ --- name: tdpilot-production description: > - Production-grade TouchDesigner MCP workflow for TDPilot v1.6.15 (104 tools): + Production-grade TouchDesigner MCP workflow for TDPilot v1.6.16 (106 tools): staged edits with undo blocks, rollback safety via snapshots, token-efficient diagnostics, strict completion gates, and v1.1 features including td_project_lifecycle (save/undo/redo), td_custom_parameters (declarative param authoring), and td_pop_inspect (POP-native data inspection). --- -# TDPilot Production v1.6.15 +# TDPilot Production v1.6.16 ## Use This Skill When - The user asks for reliable, production-safe network edits. diff --git a/src/td_mcp/__init__.py b/src/td_mcp/__init__.py index 192bb92..7445f96 100644 --- a/src/td_mcp/__init__.py +++ b/src/td_mcp/__init__.py @@ -1,6 +1,6 @@ """TouchDesigner MCP Server — AI-powered control of TouchDesigner via MCP.""" -__version__ = "1.6.15" +__version__ = "1.6.16" # v1.4.7: renamed from ``tdpilot_v1_3.tox`` (legacy v1.3-era filename) to a # version-less, stable filename. The TD component's API_VERSION lives inside diff --git a/src/td_mcp/activity_log.py b/src/td_mcp/activity_log.py new file mode 100644 index 0000000..2ef9dc7 --- /dev/null +++ b/src/td_mcp/activity_log.py @@ -0,0 +1,136 @@ +"""Server-side ring buffer of recent tool-call activity. + +Every successful or failed dispatch through :func:`td_mcp.tool_registry._forward` +appends one entry here. The buffer is bounded — older entries are evicted — +and is exposed to MCP clients via the new ``td_get_activity_log`` tool so +Claude can introspect what it's already done across long sessions. + +When TD is connected, the same row is also mirrored to a Table DAT named +``activity_log`` inside the ``mcp_server`` COMP. Users can wire that DAT +into their visuals (e.g. ``op('/local/mcp_server/activity_log')`` chained +into a Select DAT → Text TOP) so the AI's actions become a first-class +input to the live patch — agent activity as a visual data stream. + +Two separate stores, two separate jobs: + +* :mod:`td_mcp.read_journal` — deduped by (tool, args), tracks whether + repeat reads changed. Optimized for "is this still fresh?" +* :mod:`td_mcp.activity_log` — flat time-ordered. Optimized for "what + happened recently?" + +Thread-safe via :class:`threading.Lock`. FastMCP transports may dispatch +from worker threads. +""" + +from __future__ import annotations + +import json +import threading +from collections import deque +from datetime import datetime, timezone +from typing import Any + +MAX_ENTRIES = 200 +SUMMARY_MAX_LEN = 120 + +# Header for the in-TD Table DAT consumer. Column order MUST match +# :func:`format_tsv_row`. Kept as a constant so the build script and the +# DAT consumer can both reference one source of truth. +TSV_HEADER = "ts\ttool\targs_summary\tduration_ms\tok\n" + +_lock = threading.Lock() +_entries: deque[dict[str, Any]] = deque(maxlen=MAX_ENTRIES) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _summarize(value: Any) -> str: + """Compact a value into a single short line for the TD Table DAT. + + Pydantic models, datetimes, custom classes survive via ``default=str``. + Newlines and tabs are squashed so the value fits in one TSV cell. + """ + if value is None: + return "" + if isinstance(value, str): + text = value + else: + try: + text = json.dumps(value, default=str, separators=(",", ":")) + except Exception: + text = repr(value) + text = text.replace("\t", " ").replace("\n", " ").replace("\r", " ") + if len(text) > SUMMARY_MAX_LEN: + text = text[: SUMMARY_MAX_LEN - 1] + "…" + return text + + +def record( + *, + tool_name: str, + args: Any, + result: Any, + duration_ms: float, + ok: bool, +) -> dict[str, Any]: + """Append one entry to the ring buffer. Returns the appended entry.""" + entry = { + "ts": _utc_now_iso(), + "tool": tool_name, + "args_summary": _summarize(args), + "result_summary": _summarize(result), + "duration_ms": float(duration_ms), + "ok": bool(ok), + } + with _lock: + _entries.append(entry) + return entry + + +def snapshot( + limit: int | None = None, + tool_filter: str | None = None, +) -> list[dict[str, Any]]: + """Return entries newest-first. + + * ``limit`` caps the number of returned entries. + * ``tool_filter`` (exact match) restricts to one tool name. + """ + with _lock: + items = [dict(e) for e in _entries] + items.reverse() # newest first + if tool_filter: + items = [e for e in items if e["tool"] == tool_filter] + if limit is not None and limit >= 0: + items = items[:limit] + return items + + +def reset() -> None: + """Clear the log. Test-only — not called from production paths.""" + with _lock: + _entries.clear() + + +def format_tsv_row(entry: dict[str, Any]) -> str: + """Format one entry as a single \\n-terminated TSV row for the TD Table DAT. + + Column order: ``ts, tool, args_summary, duration_ms, ok`` (ok serialized + as ``1`` / ``0`` for cheap DAT-side filtering). Embedded \\n/\\t/\\r in + string fields are stripped to keep row boundaries clean. + """ + + def clean(v: Any) -> str: + s = str(v) if not isinstance(v, str) else v + return s.replace("\t", " ").replace("\n", " ").replace("\r", " ") + + fields = [ + clean(entry.get("ts", "")), + clean(entry.get("tool", "")), + clean(entry.get("args_summary", "")), + f"{float(entry.get('duration_ms', 0.0)):.2f}", + "1" if entry.get("ok") else "0", + ] + return "\t".join(fields) + "\n" diff --git a/src/td_mcp/read_journal.py b/src/td_mcp/read_journal.py new file mode 100644 index 0000000..eaf575b --- /dev/null +++ b/src/td_mcp/read_journal.py @@ -0,0 +1,177 @@ +"""Session-scoped tool-call journal — passive observability for read tools. + +Every tool call routed through :func:`td_mcp.tool_registry._forward` ends up +here. The journal records a fingerprint of the arguments and a hash of the +result. On the next call with the same arguments, the journal returns a hint +saying whether the result is unchanged since last time. + +The journal is **not** a cache. Every call still executes against TD. The +hint is purely advisory — Claude can decide whether to re-fetch, but the +underlying call happens regardless. This avoids the worst failure mode of a +transparent cache (silently serving stale data after the scene mutates). + +Why a journal beats a cache here: + +* Live TD scenes mutate constantly — node creations, parameter writes, + cooking changes. A cache that doesn't invalidate on every possible + mutation lies; a cache that invalidates on every mutation is no cache. +* Read tools are cheap. The expensive thing across an MCP session is the + token cost of re-asking. The journal makes that visible to the model. +* The hint reasonably composes across long sessions: "you've called + `td_get_nodes('/project1')` 7 times today and the result hasn't moved" + is information Claude can act on. + +Storage shape:: + + _entries: dict[(tool_name, args_fingerprint)] -> { + "tool": str, + "args_fingerprint": str, + "first_seen_at": iso8601-UTC, + "last_seen_at": iso8601-UTC, + "call_count": int, + "last_result_hash": str, + } + +Module-level state is thread-safe via :class:`threading.Lock`. MCP tool +handlers may be invoked from worker threads under the FastMCP transport. +""" + +from __future__ import annotations + +import hashlib +import json +import threading +from datetime import datetime, timezone +from typing import Any + +MAX_ENTRIES = 500 + +_lock = threading.Lock() +_entries: dict[tuple[str, str], dict[str, Any]] = {} + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _canonical_json(value: Any) -> str: + """Stable JSON serialization for fingerprinting. + + Sorted keys + default=str so objects that aren't JSON-native (Pydantic + models, datetimes, custom classes) still produce a fingerprint without + raising. + """ + return json.dumps(value, sort_keys=True, default=str, separators=(",", ":")) + + +def _fingerprint(value: Any) -> str: + canonical = _canonical_json(value) + return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16] + + +def _evict_oldest_if_needed() -> None: + """Trim entries down to MAX_ENTRIES by removing oldest last_seen_at.""" + if len(_entries) <= MAX_ENTRIES: + return + # Sort by last_seen_at ascending; drop oldest until at limit. + ordered = sorted(_entries.items(), key=lambda kv: kv[1]["last_seen_at"]) + excess = len(_entries) - MAX_ENTRIES + for key, _ in ordered[:excess]: + _entries.pop(key, None) + + +def record_call(tool_name: str, args: Any, result: Any) -> dict[str, Any]: + """Record one tool call and return the hint dict to attach to its response. + + The hint shape is:: + + { + "call_count": int, + "first_seen_at": iso8601, + "last_seen_at": iso8601, + "result_unchanged": True | False | None, + } + + ``result_unchanged`` is ``None`` on the first call (no prior result to + compare). ``True`` when the new result's hash equals the previous one, + ``False`` otherwise. + """ + args_fp = _fingerprint(args if args is not None else {}) + result_hash = _fingerprint(result) + now = _utc_now_iso() + key = (tool_name, args_fp) + + with _lock: + existing = _entries.get(key) + if existing is None: + entry = { + "tool": tool_name, + "args_fingerprint": args_fp, + "first_seen_at": now, + "last_seen_at": now, + "call_count": 1, + "last_result_hash": result_hash, + } + _entries[key] = entry + _evict_oldest_if_needed() + unchanged: bool | None = None + else: + unchanged = existing["last_result_hash"] == result_hash + existing["call_count"] += 1 + existing["last_seen_at"] = now + existing["last_result_hash"] = result_hash + entry = existing + + return { + "call_count": entry["call_count"], + "first_seen_at": entry["first_seen_at"], + "last_seen_at": entry["last_seen_at"], + "result_unchanged": unchanged, + } + + +def snapshot() -> list[dict[str, Any]]: + """Return a list copy of all journal entries (recent first). + + Entries are public-shape — no internal hashes leak except the args + fingerprint, which is intentional so callers can correlate. + """ + with _lock: + items = [dict(v) for v in _entries.values()] + items.sort(key=lambda e: e["last_seen_at"], reverse=True) + return items + + +def reset() -> None: + """Clear the journal. Test-only — not called from production paths.""" + with _lock: + _entries.clear() + + +def attach_hint(response: Any, hint: dict[str, Any]) -> Any: + """Splice ``_read_journal: hint`` into a tool response. + + Polymorphic, matching :func:`_attach_hints` in tool_registry: + + * If ``response`` is a JSON string that parses to a dict, return a new + JSON string with the hint merged. + * If ``response`` is a dict, return a new dict with the hint merged. + * Otherwise (non-JSON string, list-shaped JSON, etc.), return the + response unchanged. A failure to attach must never break a tool call. + """ + if isinstance(response, dict): + merged = dict(response) + merged["_read_journal"] = hint + return merged + + if isinstance(response, str): + try: + parsed = json.loads(response) + except Exception: + return response + if not isinstance(parsed, dict): + return response + parsed["_read_journal"] = hint + return json.dumps(parsed, indent=2, default=str) + + return response diff --git a/src/td_mcp/registry/tools_meta.py b/src/td_mcp/registry/tools_meta.py new file mode 100644 index 0000000..5a2a0f1 --- /dev/null +++ b/src/td_mcp/registry/tools_meta.py @@ -0,0 +1,116 @@ +"""Meta tools — agent observability + server self-management. + +Two server-local tools that don't talk to TouchDesigner over MCP: + +* ``td_get_activity_log`` — recent tool-call history (server-side ring + buffer). Useful for Claude to inspect what it's done in this session + across MCP request boundaries. +* ``td_self_update`` — check for and install a newer TDPilot release + from GitHub. Closes the long-running staleness problem documented in + ``CLAUDE.md`` (seven artifact layers go stale silently). + +Neither tool requires a live TD connection or an exec-mode privilege — +they introspect or mutate server-local state only. That's why they live +in a dedicated module instead of ``tools_info.py`` or ``tools_system.py``. + +Part of the v1.6.16 surface (tool count 104 → 106). +""" + +from __future__ import annotations + +from typing import Annotated, Any + +from mcp.server.fastmcp import Context +from pydantic import Field + +# Intentional cycle — see registry/__init__.py. +from td_mcp import tool_registry as _tr # noqa: E402 +from td_mcp.errors import format_tool_error +from td_mcp.tool_registry import mcp # noqa: E402 + + +@mcp.tool(name="td_get_activity_log") +async def td_get_activity_log( + ctx: Context, + limit: Annotated[ + int, + Field( + default=20, + ge=1, + le=200, + description="How many recent entries to return (1–200, newest first).", + ), + ] = 20, + tool_filter: Annotated[ + str | None, + Field( + default=None, + description="If set, only return entries for this exact tool name.", + ), + ] = None, +) -> str: + """Recent tool-call activity from this MCP server's ring buffer. + + Returns a JSON array of entries newest-first, each with ``ts``, ``tool``, + ``args_summary``, ``result_summary``, ``duration_ms``, ``ok``. The buffer + holds the most recent 200 calls; older entries are evicted. + + Pairs with the in-TD ``activity_log`` Table DAT mirror so the same data + is also wireable into a live visual patch. + """ + finish = _tr._start_tool(ctx, "td_get_activity_log") + try: + from td_mcp import activity_log + + entries = activity_log.snapshot(limit=limit, tool_filter=tool_filter) + payload = { + "schema_version": 1, + "count": len(entries), + "max_buffer": activity_log.MAX_ENTRIES, + "entries": entries, + } + return _tr._as_json_output(payload) + except Exception as exc: + _tr._record_tool_error(ctx, "td_get_activity_log") + return format_tool_error(exc) + finally: + finish() + + +@mcp.tool(name="td_self_update") +async def td_self_update( + ctx: Context, + check_only: Annotated[ + bool, + Field( + default=True, + description=( + "If True (default), only check whether a newer release exists. " + "If False, download + install the latest .plugin/.tox to all " + "three install paths (~/.tdpilot, plugin cache, repo)." + ), + ), + ] = True, +) -> str: + """Check for and optionally install a newer TDPilot release from GitHub. + + Default behavior (``check_only=True``) hits the GitHub releases API and + returns ``{installed, latest, newer_available, release_url, asset_urls}``. + Set ``check_only=False`` to actually download and install — this writes + to ``~/.tdpilot/td_component/tdpilot.tox``, the Claude Code plugin cache, + and the repo working-tree (when running from a clone). On success returns + md5 fingerprints for each install path so the caller can verify sync. + + Network-only — does not touch TouchDesigner. Safe to run when TD is closed. + """ + finish = _tr._start_tool(ctx, "td_self_update") + try: + from td_mcp import self_updater + + result = self_updater.run(check_only=check_only) + return _tr._as_json_output(result) + except Exception as exc: + _tr._record_tool_error(ctx, "td_self_update") + return format_tool_error(exc) + finally: + finish() diff --git a/src/td_mcp/release_gates.py b/src/td_mcp/release_gates.py index 267da6a..8b1deca 100644 --- a/src/td_mcp/release_gates.py +++ b/src/td_mcp/release_gates.py @@ -15,4 +15,6 @@ # 2026-05-02: bumped 101 → 102 with td_get_hints (v1.6.0 Phase 2). # 2026-05-02: bumped 102 → 103 with td_component_notes (v1.6.0 Phase 3). # 2026-05-12: bumped 103 → 104 with td_tool_batch (deepseek-v4 backport). -EXPECTED_MIN_TOOL_COUNT: int = 104 +# 2026-05-18: bumped 104 → 106 with td_get_activity_log + td_self_update +# (v1.6.16 agent-observability + auto-update surface). +EXPECTED_MIN_TOOL_COUNT: int = 106 diff --git a/src/td_mcp/self_updater.py b/src/td_mcp/self_updater.py new file mode 100644 index 0000000..bf1501c --- /dev/null +++ b/src/td_mcp/self_updater.py @@ -0,0 +1,329 @@ +"""TDPilot self-updater — close the staleness saga. + +Background — the problem this exists to solve +--------------------------------------------- + +The user-memory note ``feedback_check_plugin_cache_freshness.md`` documents +**seven** locations where TDPilot's bits can fall out of sync: + +1. The repo working-tree ``td_component/tdpilot.tox``. +2. The Claude Code plugin cache: ``~/.claude/plugins/cache/...``. +3. The user data dir ``~/.tdpilot/td_component/tdpilot.tox``. +4. The live TD COMP installed at ``/local/mcp_server`` — built from + sources at setup time, not from a .tox reference. +5. The ``.claude-plugin/marketplace.json`` ``plugins[0].version`` field, + which controls whether Claude Code's UI offers an "Update" button. +6. The npm install (``npx tdpilot``), pinned to ``git describe`` at + release-tag push time. +7. The TD startup ``.toe`` (``~/.tdpilot/tdpilot_default.toe``) + embedded-script snapshot. + +When any of these lag, users see "the fix from last session isn't working" +even when the fix is in the repo. The historical pattern was: ship a +release, push to GitHub, wait for the user to re-install, find out months +later that they're still running an old plugin cache. + +What this module does +--------------------- + +Asks GitHub "is there a newer release?", and (optionally) downloads the +release asset to all configured install paths in one shot. No system +package manager, no shell scripts to remember — one tool call from inside +Claude Code or one ``import`` from the TD Textport. + +Architecture +------------ + +Pure stdlib (``urllib.request``, ``json``, ``hashlib``) — the same script +runs inside TouchDesigner's Python interpreter, which lags upstream Python +and ships no third-party HTTP libraries. Network access is injected as a +callable (``fetch_releases``, ``download_asset``) so tests cover every +branch without touching the network. The default callables make real +HTTPS calls. + +Out of scope +------------ + +* Updating ``.claude-plugin/marketplace.json`` — that's the GitHub-side + release packager's job; the user-side updater only refreshes binary + artifacts. +* Refreshing the in-TD ``/local/mcp_server`` COMP — that still requires + re-running ``setup_mcp_in_td.py`` from TD Textport. We surface that as + a follow-up step in the result payload. +""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any, Callable + +GITHUB_RELEASES_URL = "https://api.github.com/repos/dreamrec/TDPilot/releases/latest" +DEFAULT_ASSET_NAME = "tdpilot.tox" +HTTP_TIMEOUT = 30 + + +# ────────────────────────────────────────────────────────── +# Semver comparison +# ────────────────────────────────────────────────────────── + + +_VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)") + + +def _parse_version(value: str) -> tuple[int, int, int] | None: + if not isinstance(value, str): + return None + match = _VERSION_RE.match(value.strip()) + if not match: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def is_newer(*, latest: str, installed: str) -> bool: + """Return True when ``latest`` is a strictly higher semver than ``installed``. + + Tolerates ``v`` prefixes (``v1.6.16`` parses the same as ``1.6.16``). + Returns False on any parse failure — the safe default is "no update." + """ + left = _parse_version(latest) + right = _parse_version(installed) + if left is None or right is None: + return False + return left > right + + +# ────────────────────────────────────────────────────────── +# Default network callables +# ────────────────────────────────────────────────────────── + + +def _default_fetch_releases() -> dict[str, Any]: + """Hit the GitHub releases API. Raises on network/HTTP errors.""" + req = urllib.request.Request( + GITHUB_RELEASES_URL, + headers={"User-Agent": "tdpilot-self-updater"}, + ) + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + body = resp.read() + return json.loads(body) + + +def _default_download_asset(url: str) -> bytes: + req = urllib.request.Request(url, headers={"User-Agent": "tdpilot-self-updater"}) + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp: + return resp.read() + + +# ────────────────────────────────────────────────────────── +# Install path resolution +# ────────────────────────────────────────────────────────── + + +def _resolve_default_install_paths(asset_name: str = DEFAULT_ASSET_NAME) -> list[Path]: + """Return the standard set of install paths for this machine. + + Three locations per the CLAUDE.md sync table: + + 1. Repo working tree ``td_component/``. + 2. Claude Code plugin cache (best-effort glob). + 3. User data dir ``~/.tdpilot/td_component/``. + + Each path is returned even if its parent directory doesn't exist — + :func:`_install_to_paths` creates parents on demand. + """ + home = Path.home() + paths: list[Path] = [] + + # 1. Repo working tree, only if the script is running from inside a + # clone. The src/td_mcp file is three levels deep from repo root. + try: + repo_root = Path(__file__).resolve().parents[2] + if (repo_root / "pyproject.toml").is_file() and (repo_root / "td_component").is_dir(): + paths.append(repo_root / "td_component" / asset_name) + except Exception: + pass + + # 2. Claude Code plugin cache. Pattern: + # ~/.claude/plugins/cache/dreamrec-TDPilot/tdpilot//td_component/ + # The version subdir is the canonical version; we let the caller's + # plugin manager pick a specific subdir on next launch — we just + # drop the file into ALL versioned subdirs that already exist, so + # whichever one Claude Code resolves to is up-to-date. + cache_root = home / ".claude" / "plugins" / "cache" / "dreamrec-TDPilot" / "tdpilot" + if cache_root.is_dir(): + for version_dir in cache_root.iterdir(): + if not version_dir.is_dir(): + continue + paths.append(version_dir / "td_component" / asset_name) + + # 3. User data dir. + paths.append(home / ".tdpilot" / "td_component" / asset_name) + + return paths + + +# ────────────────────────────────────────────────────────── +# Install +# ────────────────────────────────────────────────────────── + + +def _install_to_paths( + payload: bytes, + paths: list[Path], +) -> tuple[list[str], dict[str, str]]: + """Write ``payload`` to every path; return (installed_paths, md5_map). + + Parent directories are created as needed. Failures on individual + paths are skipped — the caller decides whether partial success + counts as success. md5 is reported for every WRITTEN path so the + caller can verify three-way sync. + """ + installed: list[str] = [] + md5_map: dict[str, str] = {} + md5 = hashlib.md5(payload).hexdigest() + for path in paths: + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(payload) + installed.append(str(path)) + md5_map[str(path)] = md5 + except Exception: + continue + return installed, md5_map + + +# ────────────────────────────────────────────────────────── +# Public entrypoint +# ────────────────────────────────────────────────────────── + + +def run( + *, + check_only: bool = True, + installed_version: str | None = None, + fetch_releases: Callable[[], dict[str, Any]] | None = None, + download_asset: Callable[[str], bytes] | None = None, + install_paths: list[Path] | None = None, + asset_name: str = DEFAULT_ASSET_NAME, +) -> dict[str, Any]: + """Check (and optionally install) the latest TDPilot release. + + Parameters + ---------- + check_only: + When True (default), report only — never download or write. + installed_version: + Override for the installed version. Defaults to ``td_mcp.__version__``. + fetch_releases, download_asset: + Network callables, injected for tests. Production defaults call + GitHub via stdlib urllib. + install_paths: + Where to write the asset when ``check_only=False``. Defaults to + the three-location sync table. + asset_name: + Which release asset to pull. Defaults to ``tdpilot.tox``. + """ + if installed_version is None: + from td_mcp import __version__ as installed_version + + fetch = fetch_releases or _default_fetch_releases + download = download_asset or _default_download_asset + + try: + release = fetch() + except Exception as exc: + return { + "error": f"failed to fetch releases: {exc}", + "installed": installed_version, + } + + if not isinstance(release, dict) or "tag_name" not in release: + return { + "error": "malformed release payload (missing tag_name)", + "installed": installed_version, + } + + latest_tag = release.get("tag_name", "") + latest_version = latest_tag.lstrip("v") + release_url = release.get("html_url", "") + newer = is_newer(latest=latest_tag, installed=installed_version) + + result: dict[str, Any] = { + "installed": installed_version, + "latest": latest_version, + "newer_available": newer, + "release_url": release_url, + "follow_up": ( + "After this update, re-run setup_mcp_in_td.py inside TouchDesigner " + "Textport to refresh the live /local/mcp_server COMP from the new .tox." + ), + } + + if check_only or not newer: + return result + + # Find the requested asset. + asset_url: str | None = None + for asset in release.get("assets") or []: + if not isinstance(asset, dict): + continue + if asset.get("name") == asset_name: + asset_url = asset.get("browser_download_url") + break + + if not asset_url: + return { + **result, + "error": f"release {latest_tag!r} does not contain asset {asset_name!r}", + } + + try: + payload = download(asset_url) + except Exception as exc: + return {**result, "error": f"failed to download asset: {exc}"} + + if not isinstance(payload, (bytes, bytearray)) or len(payload) == 0: + return {**result, "error": "downloaded asset is empty"} + + paths = install_paths if install_paths is not None else _resolve_default_install_paths(asset_name) + if not paths: + return {**result, "error": "no install paths resolved"} + + installed_to, md5_map = _install_to_paths(bytes(payload), [Path(p) for p in paths]) + if not installed_to: + return {**result, "error": "no install paths were writable"} + + result["installed_to"] = installed_to + result["md5"] = md5_map + result["bytes_written"] = len(payload) + return result + + +# ────────────────────────────────────────────────────────── +# CLI entry — `python -m td_mcp.self_updater` +# ────────────────────────────────────────────────────────── + + +def _main() -> int: + import argparse + + parser = argparse.ArgumentParser(description="TDPilot self-updater.") + parser.add_argument( + "--install", + action="store_true", + help="Download and install the latest release. Default is check-only.", + ) + args = parser.parse_args() + result = run(check_only=not args.install) + print(json.dumps(result, indent=2)) + return 0 if "error" not in result else 1 + + +if __name__ == "__main__": + raise SystemExit(_main()) diff --git a/src/td_mcp/tool_registry.py b/src/td_mcp/tool_registry.py index fc000cf..11c0be1 100644 --- a/src/td_mcp/tool_registry.py +++ b/src/td_mcp/tool_registry.py @@ -846,16 +846,47 @@ async def _forward( audit_details: dict[str, Any] | None = None, ) -> str: finish = _start_tool(ctx, tool_name) + started = time.perf_counter() + success = False + data: Any = None try: data = await _get_client(ctx).request(endpoint, body) if audit_event: _audit_log(ctx, audit_event, audit_details or (body or {})) - return _as_json_output(data) + output = _as_json_output(data) + success = True + # Annotate with read-journal hint so repeat calls surface + # "result_unchanged" without changing observable behavior. Defensive: + # any failure inside journaling is swallowed — a malformed entry + # must never break the tool response. + try: + from td_mcp import read_journal as _read_journal + + hint = _read_journal.record_call(tool_name, body, data) + output = _read_journal.attach_hint(output, hint) + except Exception: + pass + return output except Exception as exc: _record_tool_error(ctx, tool_name) return format_tool_error(exc) finally: finish() + # Append to the activity ring buffer (server-side + in-TD Table DAT + # mirror via event_emitter). Defensive: a failure here must never + # break the tool response, hence the broad except. + try: + from td_mcp import activity_log as _activity_log + + _activity_log.record( + tool_name=tool_name, + args=body, + result=data, + duration_ms=(time.perf_counter() - started) * 1000.0, + ok=success, + ) + except Exception: + pass def _current_exec_mode() -> str: @@ -2061,6 +2092,10 @@ def _rescue_exec_mode_error( td_memory_replay, td_memory_save, ) +from td_mcp.registry.tools_meta import ( # noqa: E402 + td_get_activity_log, + td_self_update, +) from td_mcp.registry.tools_notes import ( # noqa: E402 td_component_notes, ) diff --git a/td_component/.tox-source-hash.json b/td_component/.tox-source-hash.json index 2fd8c49..5f8c94f 100644 --- a/td_component/.tox-source-hash.json +++ b/td_component/.tox-source-hash.json @@ -1,6 +1,6 @@ { - "tox_source_hash": "2c6eecdfc222147f7b9a5afe4c24bac34f9f100d6b0bec17e1128139d8c8c0cc", - "built_at": "2026-05-15T18:08:02.888744+00:00", + "tox_source_hash": "796a3d488aa48052ced74fe75b47629ff69f698394d48954df9c859c7347cb76", + "built_at": "2026-05-18T18:34:45.671303+00:00", "source_files": [ "td_component/mcp_webserver_callbacks.py", "td_component/event_emitter.py", diff --git a/td_component/build_export_mcp_tox.py b/td_component/build_export_mcp_tox.py index f3476a2..0e5a66d 100644 --- a/td_component/build_export_mcp_tox.py +++ b/td_component/build_export_mcp_tox.py @@ -435,6 +435,14 @@ def _populate_component(comp, callbacks_code, event_emitter_code, ws_callbacks_c # docstring above for context. Created here so every fresh loadTox # produces a panel-renderable COMP. state_cache = _create_with_fallback(comp, ("textDAT",), "state_cache") + # v1.6.16: activity_log Table DAT — a rolling mirror of the MCP + # server's tool-call ring buffer. Each row is one tool dispatch: + # ``ts | tool | args_summary | duration_ms | ok``. event_emitter + # appends here via ``append_activity_row`` when the ws_callbacks + # bridge delivers an ``activity`` message. Users can chain this DAT + # into their visuals so the agent's actions become an addressable + # data stream inside the live patch. + activity_log = _create_with_fallback(comp, ("tableDAT",), "activity_log") _set_first_par(webserver, ("port",), WEB_PORT) _set_first_par(webserver, ("active", "enable"), 1) @@ -445,6 +453,14 @@ def _populate_component(comp, callbacks_code, event_emitter_code, ws_callbacks_c event_emitter.text = event_emitter_code info.text = info_text state_cache.text = state_cache_code + # Seed the activity_log with its header row so wiring is non-empty + # before the first MCP request lands. Subsequent rows are appended + # by event_emitter.append_activity_row. + try: + activity_log.clear() + activity_log.appendRow(["ts", "tool", "args_summary", "duration_ms", "ok"]) + except Exception: + pass _configure_websocket_dat(ws_client) @@ -456,6 +472,7 @@ def _populate_component(comp, callbacks_code, event_emitter_code, ws_callbacks_c event_emitter.nodeX, event_emitter.nodeY = 520, -180 info.nodeX, info.nodeY = 520, 0 state_cache.nodeX, state_cache.nodeY = 780, -90 + activity_log.nodeX, activity_log.nodeY = 780, -270 except Exception: pass @@ -488,6 +505,13 @@ def build_and_export(): callbacks_code = _read_repo_file(repo_root, "td_component/mcp_webserver_callbacks.py") event_emitter_code = _read_repo_file(repo_root, "td_component/event_emitter.py") ws_callbacks_code = _read_repo_file(repo_root, "td_component/ws_callbacks.py") + # v1.6.16: state_cache.py was listed in _TOX_SOURCE_FILES since v1.6.7 + # for freshness-hash tracking but its content was never baked into the + # state_cache textDAT — the build silently created an empty DAT, whose + # `.module` then raised tdError on access, taking the state_cache + # writer down with it. This read closes the loop so the DAT ships + # with a compilable module body. + state_cache_code = _read_repo_file(repo_root, "td_component/state_cache.py") export_path = _resolve_export_path(repo_root) info_text = _build_info_text(repo_root, export_path) @@ -512,6 +536,7 @@ def build_and_export(): event_emitter_code, ws_callbacks_code, info_text, + state_cache_code, ) export_comp.save(export_path) _save_versioned_export(repo_root, export_comp, export_path) @@ -531,6 +556,7 @@ def build_and_export(): event_emitter_code, ws_callbacks_code, info_text, + state_cache_code, ) print("[TDPilot] Installed {}".format(installed_comp.path)) diff --git a/td_component/event_emitter.py b/td_component/event_emitter.py index 1cee692..0d87741 100644 --- a/td_component/event_emitter.py +++ b/td_component/event_emitter.py @@ -112,3 +112,56 @@ def stats(): result["buffer_depth"] = len(_BUFFER) result["dedupe_keys"] = len(_LAST_EMIT) return result + + +# ───────────────────────────────────────────────────────────── +# Activity log mirror (v1.6.16) +# ───────────────────────────────────────────────────────────── +# The MCP server keeps a ring buffer of recent tool calls. When TD is +# connected via the ws_callbacks bridge, ws_callbacks dispatches an +# ``activity`` message into this module's ``append_activity_row`` so the +# same data lands in a Table DAT named ``activity_log`` inside the +# ``mcp_server`` COMP. Users can wire that DAT into their visual patch — +# agent activity becomes an addressable data source. + +ACTIVITY_DAT_CANDIDATES = ( + "/local/mcp_server/activity_log", + "/project1/mcp_server/activity_log", + "activity_log", +) +ACTIVITY_MAX_ROWS = 200 +ACTIVITY_HEADER = ("ts", "tool", "args_summary", "duration_ms", "ok") + + +def _resolve_activity_dat(): + for path in ACTIVITY_DAT_CANDIDATES: + dat = op(path) + if dat is not None: + return dat + return None + + +def append_activity_row(row): + """Append one row to the in-TD activity_log Table DAT. + + ``row`` is a 5-tuple matching ``ACTIVITY_HEADER``. The DAT is treated + as a rolling window — when row count exceeds ``ACTIVITY_MAX_ROWS``, + the oldest non-header row is dropped. + + Returns True when the row was written, False when the DAT can't be + resolved (TD-side wiring missing) — both cases must not raise. + """ + dat = _resolve_activity_dat() + if dat is None: + return False + try: + # If empty, seed with the header row. + if dat.numRows == 0: + dat.appendRow(list(ACTIVITY_HEADER)) + dat.appendRow([str(cell) for cell in row]) + # Trim oldest rows (keep header at index 0). + while dat.numRows > ACTIVITY_MAX_ROWS + 1: + dat.deleteRow(1) + return True + except Exception: + return False diff --git a/td_component/mcp_webserver_callbacks.py b/td_component/mcp_webserver_callbacks.py index 3e214ef..35565d1 100644 --- a/td_component/mcp_webserver_callbacks.py +++ b/td_component/mcp_webserver_callbacks.py @@ -26,7 +26,7 @@ # Configuration # ───────────────────────────────────────────────────────────── -API_VERSION = "1.6.15" +API_VERSION = "1.6.16" SCREENSHOT_TEMP_PATH = os.path.join(os.environ.get('TEMP', os.environ.get('TMP', '/tmp')), 'td_mcp_screenshot.jpg') # Auth + policy env is read at CALL TIME, not import time — otherwise TD's @@ -198,6 +198,10 @@ def _record_request_safe(uri, response, t0): callbacks textDAT; ``op('state_cache')`` finds the sibling created by ``_populate_component`` (build_export_mcp_tox.py:437). """ + # v1.6.16: prep block — wrapped tight so any failure here aborts both + # writers cleanly. Both writers below are INDEPENDENT try blocks so a + # state_cache compile error (DAT empty / Python syntax error in its + # text) can't take down the activity_log mirror. try: if uri.startswith('/api/'): tool_name = uri[len('/api/'):].split('?', 1)[0] or '/' @@ -206,10 +210,37 @@ def _record_request_safe(uri, response, t0): latency_ms = (time.time() - t0) * 1000.0 status = int(response.get('statusCode', 200) or 200) ok = status < 400 - cache = parent().op('state_cache') - if cache is None or not hasattr(cache, 'module'): - return - cache.module.record_request(tool_name, latency_ms, ok) + except Exception: + return + + # state_cache writer — feeds the in-TD status panel. + # NOTE: avoid hasattr(cache, 'module'). TD's tdError on a broken + # state_cache.module access propagates through hasattr() (which + # only catches AttributeError), so a separate try/except is the + # only safe shape. + try: + cache = me.parent().op('state_cache') + if cache is not None: + cache.module.record_request(tool_name, latency_ms, ok) + except Exception: + pass + + # activity_log mirror — feeds the in-TD Table DAT for visual wiring + # and the new td_get_activity_log MCP tool. Uses me.parent() (instance + # method, always defined) instead of the bare parent() global to be + # safe across TD module-load contexts. + try: + emitter = me.parent().op('event_emitter') + if emitter is not None: + ts_iso = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) + row = ( + ts_iso, + tool_name, + '', # args summary not available TD-side; surfaced via td_get_activity_log instead + '%.2f' % latency_ms, + '1' if ok else '0', + ) + emitter.module.append_activity_row(row) except Exception: pass diff --git a/td_component/renderer.py b/td_component/renderer.py index 66a10a9..0f478d5 100644 --- a/td_component/renderer.py +++ b/td_component/renderer.py @@ -220,7 +220,7 @@ def bootstrap(): cache_dat.module.update( version=version, build=build_str, - tools=104, # keep in sync with EXPECTED_MIN_TOOL_COUNT in release_gates.py + tools=106, # keep in sync with EXPECTED_MIN_TOOL_COUNT in release_gates.py popx=_detect_popx(), ws="OK", ) diff --git a/td_component/tdpilot.tox b/td_component/tdpilot.tox index b3f159ffee128a1c5349b941333fcf2078202823..f07de613186e3b98775f0ac4d6a200b41ec421c8 100644 GIT binary patch literal 34318 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 S?m+s*ObVLCsVv@7&XH%R2)9N6 literal 52174 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

bfRRWOY-KdjmkvqJ>8F# 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

DReCbQo>!OeE) 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 SimpleNamespace: + lifespan_state = {"services": object()} + return SimpleNamespace( + request_context=SimpleNamespace( + lifespan_context=lifespan_state, + lifespan_state=lifespan_state, + ) + ) + + +class _ScriptableClient: + """Per-call response scripting so we can simulate scene mutation.""" + + def __init__(self): + self.next_response: object = {} + + async def request(self, endpoint: str, body: dict | None = None): + return self.next_response + + +@pytest.fixture(autouse=True) +def _reset_journal(): + read_journal.reset() + yield + read_journal.reset() + + +@pytest.mark.asyncio +async def test_forward_injects_read_journal_hint(monkeypatch): + """First call: hint shows call_count=1, result_unchanged=None.""" + client = _ScriptableClient() + client.next_response = {"nodes": [{"name": "geo1"}]} + monkeypatch.setattr(registry, "_get_client", lambda _ctx: client) + + output = await registry._forward( + _make_ctx(), + "td_get_nodes", + "nodes/list", + {"path": "/project1"}, + ) + + parsed = json.loads(output) + assert parsed["nodes"] == [{"name": "geo1"}] + assert "_read_journal" in parsed + assert parsed["_read_journal"]["call_count"] == 1 + assert parsed["_read_journal"]["result_unchanged"] is None + + +@pytest.mark.asyncio +async def test_forward_repeat_call_marks_unchanged(monkeypatch): + """Repeat call with same args + same TD response: result_unchanged=True.""" + client = _ScriptableClient() + client.next_response = {"nodes": [{"name": "geo1"}]} + monkeypatch.setattr(registry, "_get_client", lambda _ctx: client) + + ctx = _make_ctx() + await registry._forward(ctx, "td_get_nodes", "nodes/list", {"path": "/project1"}) + second = await registry._forward(ctx, "td_get_nodes", "nodes/list", {"path": "/project1"}) + + parsed = json.loads(second) + assert parsed["_read_journal"]["call_count"] == 2 + assert parsed["_read_journal"]["result_unchanged"] is True + + +@pytest.mark.asyncio +async def test_forward_repeat_call_with_changed_data_marks_changed(monkeypatch): + """Repeat call with same args but different TD response: result_unchanged=False.""" + client = _ScriptableClient() + monkeypatch.setattr(registry, "_get_client", lambda _ctx: client) + + ctx = _make_ctx() + client.next_response = {"nodes": [{"name": "geo1"}]} + await registry._forward(ctx, "td_get_nodes", "nodes/list", {"path": "/project1"}) + + client.next_response = {"nodes": [{"name": "geo1"}, {"name": "geo2"}]} + second = await registry._forward(ctx, "td_get_nodes", "nodes/list", {"path": "/project1"}) + + parsed = json.loads(second) + assert parsed["_read_journal"]["call_count"] == 2 + assert parsed["_read_journal"]["result_unchanged"] is False + + +@pytest.mark.asyncio +async def test_forward_error_path_does_not_record(monkeypatch): + """When TD raises, journal must not record a phantom successful call.""" + + class _BoomClient: + async def request(self, endpoint, body=None): + raise RuntimeError("td gone away") + + monkeypatch.setattr(registry, "_get_client", lambda _ctx: _BoomClient()) + + await registry._forward( + _make_ctx(), + "td_get_nodes", + "nodes/list", + {"path": "/project1"}, + ) + + assert read_journal.snapshot() == [] diff --git a/tests/test_read_journal.py b/tests/test_read_journal.py new file mode 100644 index 0000000..967e275 --- /dev/null +++ b/tests/test_read_journal.py @@ -0,0 +1,177 @@ +"""Tests for the read_journal module — passive observability layer that +annotates tool responses with "you've called this before, result unchanged" +hints so Claude can decide whether to re-fetch. + +The journal is NOT a cache. It records call fingerprints and result hashes, +but every call still executes against TD. It exists to let the model build +a session-level mental model of "what have I asked about, and has it moved?" +which is otherwise invisible across MCP request boundaries. +""" + +from __future__ import annotations + +import time + +import pytest + +from td_mcp import read_journal + + +@pytest.fixture(autouse=True) +def _reset_journal(): + """Each test gets a fresh journal.""" + read_journal.reset() + yield + read_journal.reset() + + +def test_first_call_records_count_one(): + hint = read_journal.record_call("td_get_nodes", {"path": "/project1"}, {"nodes": [{"name": "geo1"}]}) + assert hint["call_count"] == 1 + assert hint["result_unchanged"] is None # no prior to compare + assert isinstance(hint["first_seen_at"], str) + assert hint["first_seen_at"].endswith("Z") # ISO-8601 UTC + + +def test_repeat_same_args_same_result_marks_unchanged(): + args = {"path": "/project1"} + result = {"nodes": [{"name": "geo1"}]} + read_journal.record_call("td_get_nodes", args, result) + hint = read_journal.record_call("td_get_nodes", args, result) + assert hint["call_count"] == 2 + assert hint["result_unchanged"] is True + + +def test_repeat_same_args_different_result_marks_changed(): + args = {"path": "/project1"} + read_journal.record_call("td_get_nodes", args, {"nodes": [{"name": "geo1"}]}) + hint = read_journal.record_call("td_get_nodes", args, {"nodes": [{"name": "geo1"}, {"name": "geo2"}]}) + assert hint["call_count"] == 2 + assert hint["result_unchanged"] is False + + +def test_args_dict_order_insensitive(): + """Fingerprint must canonicalize key order — {a:1,b:2} == {b:2,a:1}.""" + read_journal.record_call("t", {"a": 1, "b": 2}, "r") + hint = read_journal.record_call("t", {"b": 2, "a": 1}, "r") + assert hint["call_count"] == 2 + assert hint["result_unchanged"] is True + + +def test_different_args_separate_entries(): + read_journal.record_call("td_get_nodes", {"path": "/a"}, "r1") + hint = read_journal.record_call("td_get_nodes", {"path": "/b"}, "r2") + assert hint["call_count"] == 1 + assert hint["result_unchanged"] is None + + +def test_different_tools_separate_entries(): + read_journal.record_call("td_get_nodes", {"path": "/a"}, "r") + hint = read_journal.record_call("td_get_params", {"path": "/a"}, "r") + assert hint["call_count"] == 1 + + +def test_none_args_treated_as_empty_dict(): + """A tool with no args (None) should still be journalable.""" + read_journal.record_call("td_get_info", None, {"build": "2025.32460"}) + hint = read_journal.record_call("td_get_info", None, {"build": "2025.32460"}) + assert hint["call_count"] == 2 + assert hint["result_unchanged"] is True + + +def test_non_json_serializable_result_does_not_crash(): + """Result objects that contain non-JSON types (e.g. bytes, custom classes) + must not raise — we coerce via default=str.""" + + class Custom: + def __repr__(self) -> str: + return "Custom()" + + # Should not raise. + hint = read_journal.record_call("t", {}, {"obj": Custom()}) + assert hint["call_count"] == 1 + + +def test_journal_bounded_by_max_entries(): + """When over MAX_ENTRIES, oldest by last_seen_at is evicted.""" + # Fill past the limit + for i in range(read_journal.MAX_ENTRIES + 50): + read_journal.record_call("t", {"i": i}, "r") + entries = read_journal.snapshot() + assert len(entries) == read_journal.MAX_ENTRIES + + +def test_snapshot_returns_independent_copy(): + """Mutating the snapshot must not affect live journal state.""" + read_journal.record_call("t", {"a": 1}, "r") + snap = read_journal.snapshot() + snap.clear() + snap2 = read_journal.snapshot() + assert len(snap2) == 1 + + +def test_reset_empties_journal(): + read_journal.record_call("t", {"a": 1}, "r") + read_journal.reset() + assert read_journal.snapshot() == [] + + +def test_first_seen_at_preserved_across_calls(): + read_journal.record_call("t", {"a": 1}, "r") + time.sleep(0.001) + hint = read_journal.record_call("t", {"a": 1}, "r") + assert hint["first_seen_at"] <= hint["last_seen_at"] + + +def test_hint_shape_contract(): + """Hint dict has exactly the documented keys.""" + hint = read_journal.record_call("t", {}, "r") + assert set(hint.keys()) == {"call_count", "first_seen_at", "last_seen_at", "result_unchanged"} + + +def test_snapshot_entries_have_tool_and_count(): + """Entries returned by snapshot expose tool name + count for introspection.""" + read_journal.record_call("td_get_nodes", {"path": "/a"}, "r") + read_journal.record_call("td_get_nodes", {"path": "/a"}, "r") + snap = read_journal.snapshot() + assert len(snap) == 1 + entry = snap[0] + assert entry["tool"] == "td_get_nodes" + assert entry["call_count"] == 2 + assert "first_seen_at" in entry + assert "last_seen_at" in entry + + +def test_attach_hint_to_json_string(): + """attach_hint() splices _read_journal into a JSON-string response.""" + import json + response = json.dumps({"nodes": []}) + hint = read_journal.record_call("td_get_nodes", {}, {"nodes": []}) + new_response = read_journal.attach_hint(response, hint) + parsed = json.loads(new_response) + assert parsed["_read_journal"]["call_count"] == 1 + + +def test_attach_hint_to_dict(): + """attach_hint() splices into a dict response.""" + response = {"nodes": []} + hint = read_journal.record_call("td_get_nodes", {}, {"nodes": []}) + new_response = read_journal.attach_hint(response, hint) + assert new_response["_read_journal"]["call_count"] == 1 + + +def test_attach_hint_to_non_dict_string_is_passthrough(): + """If JSON parses to a list (or other non-dict), attach_hint must not crash.""" + import json + response = json.dumps([1, 2, 3]) + hint = read_journal.record_call("t", {}, [1, 2, 3]) + out = read_journal.attach_hint(response, hint) + # Should return original string unchanged. + assert json.loads(out) == [1, 2, 3] + + +def test_attach_hint_to_non_json_string_is_passthrough(): + """Non-JSON string responses (e.g. markdown) are returned unchanged.""" + response = "## Nodes\n- geo1" + hint = read_journal.record_call("t", {}, response) + assert read_journal.attach_hint(response, hint) == response diff --git a/tests/test_self_updater.py b/tests/test_self_updater.py new file mode 100644 index 0000000..34f9e9a --- /dev/null +++ b/tests/test_self_updater.py @@ -0,0 +1,252 @@ +"""Tests for the self_updater module — GitHub-driven version check + install. + +The module's job is to close the multi-layer staleness problem documented +in CLAUDE.md: ``tdpilot.tox`` lives in three places that go out of sync +silently, and the Claude Code plugin cache + the user-data ``~/.tdpilot/`` +both lag the latest release until manually reinstalled. + +Network calls are injected via a ``fetch_releases`` callable so tests +exercise every branch without touching the network. The production code +defaults to calling the GitHub API via stdlib urllib. +""" + +from __future__ import annotations + +import pytest + +from td_mcp import self_updater + + +# ────────────────────────────────────────────────────────── +# Pure version-comparison logic +# ────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + "older,newer", + [ + ("1.0.0", "1.0.1"), + ("1.0.9", "1.1.0"), + ("1.6.15", "1.6.16"), + ("1.6.99", "1.7.0"), + ("1.9.0", "2.0.0"), + # Tag prefix tolerance — v1.0.0 == 1.0.0 from the comparison standpoint. + ("v1.0.0", "v1.0.1"), + ], +) +def test_is_newer_recognizes_higher_version(older, newer): + assert self_updater.is_newer(latest=newer, installed=older) is True + + +@pytest.mark.parametrize("a", ["1.0.0", "1.6.15", "2.3.4"]) +def test_is_newer_equal_returns_false(a): + assert self_updater.is_newer(latest=a, installed=a) is False + + +def test_is_newer_lower_returns_false(): + assert self_updater.is_newer(latest="1.6.14", installed="1.6.15") is False + + +def test_is_newer_malformed_returns_false_does_not_raise(): + """When either version is unparseable, the safe default is "no update".""" + assert self_updater.is_newer(latest="not-a-version", installed="1.0.0") is False + assert self_updater.is_newer(latest="1.0.0", installed="not-a-version") is False + + +def test_is_newer_strips_v_prefix(): + assert self_updater.is_newer(latest="v1.6.16", installed="1.6.15") is True + assert self_updater.is_newer(latest="1.6.16", installed="v1.6.15") is True + + +# ────────────────────────────────────────────────────────── +# run(check_only=True) — happy paths +# ────────────────────────────────────────────────────────── + + +def _fake_release(tag: str, assets: list[dict] | None = None) -> dict: + return { + "tag_name": tag, + "html_url": f"https://github.com/dreamrec/TDPilot/releases/tag/{tag}", + "assets": assets or [], + } + + +def test_check_only_reports_up_to_date_when_latest_equals_installed(): + fake = lambda: _fake_release("v1.6.15") # noqa: E731 + result = self_updater.run( + check_only=True, + installed_version="1.6.15", + fetch_releases=fake, + ) + assert result["newer_available"] is False + assert result["installed"] == "1.6.15" + assert result["latest"] == "1.6.15" + assert "release_url" in result + + +def test_check_only_reports_newer_available(): + fake = lambda: _fake_release("v1.6.16") # noqa: E731 + result = self_updater.run( + check_only=True, + installed_version="1.6.15", + fetch_releases=fake, + ) + assert result["newer_available"] is True + assert result["installed"] == "1.6.15" + assert result["latest"] == "1.6.16" + assert result["release_url"].endswith("/v1.6.16") + + +def test_check_only_does_not_touch_disk(tmp_path): + """check_only=True must never write to disk.""" + fake = lambda: _fake_release("v1.6.16") # noqa: E731 + result = self_updater.run( + check_only=True, + installed_version="1.6.15", + fetch_releases=fake, + install_paths=[tmp_path / "site_a", tmp_path / "site_b"], + ) + assert result["newer_available"] is True + # No files created. + assert not any(tmp_path.iterdir()) + + +# ────────────────────────────────────────────────────────── +# run(check_only=True) — error paths +# ────────────────────────────────────────────────────────── + + +def test_network_failure_returns_structured_error(): + def boom(): + raise RuntimeError("no connection") + + result = self_updater.run( + check_only=True, + installed_version="1.6.15", + fetch_releases=boom, + ) + assert "error" in result + assert "no connection" in result["error"] + # On error, we still report what we know about the installed side. + assert result["installed"] == "1.6.15" + + +def test_empty_release_list_handled(): + """If the GitHub API returns a malformed/empty payload, surface it cleanly.""" + fake = lambda: {} # noqa: E731 + result = self_updater.run( + check_only=True, + installed_version="1.6.15", + fetch_releases=fake, + ) + assert "error" in result + + +# ────────────────────────────────────────────────────────── +# run(check_only=False) — install path with mocked downloader +# ────────────────────────────────────────────────────────── + + +def test_install_writes_asset_to_all_install_paths(tmp_path): + """When check_only=False and a newer release exists, the tox is written + to every configured install path. md5 sums are reported.""" + asset_payload = b"FAKE_TOX_BYTES_v1.6.16" + + def fake_fetch(): + return _fake_release( + "v1.6.16", + assets=[ + { + "name": "tdpilot.tox", + "browser_download_url": "https://example.invalid/tdpilot.tox", + } + ], + ) + + def fake_download(url): + assert url == "https://example.invalid/tdpilot.tox" + return asset_payload + + site_a = tmp_path / "site_a" / "td_component" / "tdpilot.tox" + site_b = tmp_path / "site_b" / "td_component" / "tdpilot.tox" + + result = self_updater.run( + check_only=False, + installed_version="1.6.15", + fetch_releases=fake_fetch, + download_asset=fake_download, + install_paths=[site_a, site_b], + asset_name="tdpilot.tox", + ) + + assert result["newer_available"] is True + assert result["installed_to"] == [str(site_a), str(site_b)] + assert site_a.read_bytes() == asset_payload + assert site_b.read_bytes() == asset_payload + # md5 fields report identical hashes for sync verification. + md5_a = result["md5"][str(site_a)] + md5_b = result["md5"][str(site_b)] + assert md5_a == md5_b + assert len(md5_a) == 32 + + +def test_install_skips_when_up_to_date(tmp_path): + """install path is a no-op when installed == latest.""" + fake = lambda: _fake_release( # noqa: E731 + "v1.6.15", + assets=[{"name": "tdpilot.tox", "browser_download_url": "x"}], + ) + target = tmp_path / "td_component" / "tdpilot.tox" + + result = self_updater.run( + check_only=False, + installed_version="1.6.15", + fetch_releases=fake, + download_asset=lambda _u: pytest.fail("download must not run"), + install_paths=[target], + asset_name="tdpilot.tox", + ) + assert result["newer_available"] is False + assert "installed_to" not in result or result.get("installed_to") == [] + assert not target.exists() + + +def test_install_missing_asset_reports_error(tmp_path): + """If the named asset isn't in the release, surface a clear error + instead of crashing on a None URL.""" + + fake = lambda: _fake_release("v1.6.16", assets=[]) # noqa: E731 + + result = self_updater.run( + check_only=False, + installed_version="1.6.15", + fetch_releases=fake, + download_asset=lambda _u: pytest.fail("must not run"), + install_paths=[tmp_path / "td_component" / "tdpilot.tox"], + asset_name="tdpilot.tox", + ) + assert "error" in result + assert "asset" in result["error"].lower() + + +def test_install_creates_parent_directories(tmp_path): + """When the install path's parent doesn't exist, it's created.""" + asset_payload = b"x" + fake = lambda: _fake_release( # noqa: E731 + "v1.6.16", + assets=[{"name": "tdpilot.tox", "browser_download_url": "u"}], + ) + + nested = tmp_path / "deep" / "nested" / "td_component" / "tdpilot.tox" + assert not nested.parent.exists() + + result = self_updater.run( + check_only=False, + installed_version="1.6.15", + fetch_releases=fake, + download_asset=lambda _u: asset_payload, + install_paths=[nested], + asset_name="tdpilot.tox", + ) + assert nested.exists() + assert result["newer_available"] is True diff --git a/uv.lock b/uv.lock index 57399b4..ab873ac 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,7 +1170,7 @@ wheels = [ [[package]] name = "tdpilot" -version = "1.6.14" +version = "1.6.15" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 876565b75e01cc593c34061209de05c5784d582a Mon Sep 17 00:00:00 2001 From: silviu Date: Mon, 18 May 2026 22:07:12 +0300 Subject: [PATCH 2/3] chore(lint): apply ruff --fix to v1.6.16 release Three trivial auto-fixes flagged by CI lint on the v1.6.16 release commit: - F541 in scripts/audit_v1_6_16_live.py: drop extraneous `f` prefix on f-string with no placeholder. - UP035 in src/td_mcp/self_updater.py: import `Callable` from `collections.abc` instead of `typing` (PEP 585 deprecation). - I001 in tests/test_self_updater.py: import-block ordering. All other CI jobs on v1.6.16 already pass (test 3.11, test 3.12, npm publish, install parse-checks macos+windows, Build & upload release assets v1.6.16 with tdpilot.plugin + tdpilot.mcpb attached). Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/audit_v1_6_16_live.py | 2 +- src/td_mcp/self_updater.py | 3 ++- tests/test_self_updater.py | 1 - uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/audit_v1_6_16_live.py b/scripts/audit_v1_6_16_live.py index 35b6739..b828c03 100644 --- a/scripts/audit_v1_6_16_live.py +++ b/scripts/audit_v1_6_16_live.py @@ -308,7 +308,7 @@ async def audit_concurrent_calls(ctx: SimpleNamespace) -> None: check( "8.1 concurrent: 10 parallel calls all have envelopes", all("_read_journal" in p for p in parsed_all), - f"all hinted", + "all hinted", ) check( "8.2 concurrent: call_count reaches 10 monotonically (no lost counts)", diff --git a/src/td_mcp/self_updater.py b/src/td_mcp/self_updater.py index bf1501c..59f1791 100644 --- a/src/td_mcp/self_updater.py +++ b/src/td_mcp/self_updater.py @@ -60,8 +60,9 @@ import re import urllib.error import urllib.request +from collections.abc import Callable from pathlib import Path -from typing import Any, Callable +from typing import Any GITHUB_RELEASES_URL = "https://api.github.com/repos/dreamrec/TDPilot/releases/latest" DEFAULT_ASSET_NAME = "tdpilot.tox" diff --git a/tests/test_self_updater.py b/tests/test_self_updater.py index 34f9e9a..0ef5fe2 100644 --- a/tests/test_self_updater.py +++ b/tests/test_self_updater.py @@ -16,7 +16,6 @@ from td_mcp import self_updater - # ────────────────────────────────────────────────────────── # Pure version-comparison logic # ────────────────────────────────────────────────────────── diff --git a/uv.lock b/uv.lock index ab873ac..19244c1 100644 --- a/uv.lock +++ b/uv.lock @@ -1170,7 +1170,7 @@ wheels = [ [[package]] name = "tdpilot" -version = "1.6.15" +version = "1.6.16" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 1f1bb26c1d3315539b8ce70b7bd41664cfc765a3 Mon Sep 17 00:00:00 2001 From: silviu Date: Mon, 18 May 2026 22:09:17 +0300 Subject: [PATCH 3/3] chore(format): apply ruff format to v1.6.16 release files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI lint step runs BOTH `ruff check` and `ruff format --check`. The previous fix only ran `ruff check --fix`, missing two files the formatter wanted to reflow: - scripts/audit_v1_6_16_live.py - tests/test_read_journal.py No behavior change — pure whitespace/style. 970 tests still pass; `ruff check` + `ruff format --check` both clean locally. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/audit_v1_6_16_live.py | 7 +++---- tests/test_read_journal.py | 2 ++ 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/audit_v1_6_16_live.py b/scripts/audit_v1_6_16_live.py index b828c03..bbcfb2f 100644 --- a/scripts/audit_v1_6_16_live.py +++ b/scripts/audit_v1_6_16_live.py @@ -270,7 +270,7 @@ async def audit_td_self_update_tool(ctx: SimpleNamespace) -> None: check( "6.3 follow_up reminder present (post-update setup hint)", "follow_up" in parsed and "setup_mcp_in_td" in parsed.get("follow_up", ""), - f"follow_up={parsed.get('follow_up','')[:80]!r}", + f"follow_up={parsed.get('follow_up', '')[:80]!r}", ) @@ -299,9 +299,7 @@ async def audit_concurrent_calls(ctx: SimpleNamespace) -> None: """Fire 10 parallel calls; verify no race conditions in journal/log.""" read_journal.reset() activity_log.reset() - results = await asyncio.gather( - *[tr._forward(ctx, "td_list_families", "families") for _ in range(10)] - ) + results = await asyncio.gather(*[tr._forward(ctx, "td_list_families", "families") for _ in range(10)]) # All 10 should have read_journal hints with call_count summing to ≥ 10 parsed_all = [_parse_envelope(r) for r in results] counts = sorted(p["_read_journal"]["call_count"] for p in parsed_all) @@ -350,6 +348,7 @@ async def audit_module_imports() -> None: "", ) from td_mcp import self_updater + check( "10.3 td_mcp.self_updater module loads cleanly", callable(self_updater.run) and callable(self_updater.is_newer), diff --git a/tests/test_read_journal.py b/tests/test_read_journal.py index 967e275..959f849 100644 --- a/tests/test_read_journal.py +++ b/tests/test_read_journal.py @@ -145,6 +145,7 @@ def test_snapshot_entries_have_tool_and_count(): def test_attach_hint_to_json_string(): """attach_hint() splices _read_journal into a JSON-string response.""" import json + response = json.dumps({"nodes": []}) hint = read_journal.record_call("td_get_nodes", {}, {"nodes": []}) new_response = read_journal.attach_hint(response, hint) @@ -163,6 +164,7 @@ def test_attach_hint_to_dict(): def test_attach_hint_to_non_dict_string_is_passthrough(): """If JSON parses to a list (or other non-dict), attach_hint must not crash.""" import json + response = json.dumps([1, 2, 3]) hint = read_journal.record_call("t", {}, [1, 2, 3]) out = read_journal.attach_hint(response, hint)