From b257bbc01a68aa25f840cbfce857ff2053b52d6e Mon Sep 17 00:00:00 2001 From: Kadin Bullock Date: Sat, 4 Jul 2026 17:31:27 -0600 Subject: [PATCH 1/3] feat(mcp): add edit_summary + mutate_folder tools; summary editing + folder CRUD (v0.6.0) Two capabilities users reported missing from their agents: 1. Editing AI summaries (previously read-only). New edit_summary MCP tool (operation=correct|replace) + `correct-summary` / `set-summary` CLI. Backed by POST /ai/update_note_info; the reported use case is fixing a misspelled name in a summary. correct=literal find/replace (the summary counterpart of correct_transcript); replace=full-content overwrite. Requires an existing generated summary. 2. Folder create / edit / delete (previously only list + move-recording). New mutate_folder MCP tool (action=create|edit|delete) + `folder create|edit|delete` CLI. Backed by POST/PATCH/DELETE /filetag/. edit sends only supplied fields (name/color/icon), preserving the rest. delete is irreversible for the folder (recordings inside are kept but unfiled) so it is gated behind confirm=true (MCP) / --yes (CLI), mirroring delete_recording. MCP surface grows 10 -> 12 tools. Endpoints reverse-engineered from har-captures/plaud-summary-find-and-replace.har and plaud-folder-create-edit-delete.har. Validated: full unit suite (851+ pass) + ruff + mypy green; live round-trips against a real account (folder create/edit/delete, summary correct+revert) via both CLI and MCP handler; frozen PyInstaller mcp.exe serves all 12 tools and frozen cli.exe runs the new commands end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 27 ++- CLAUDE.md | 2 +- README.md | 5 +- pyproject.toml | 2 +- src/plaud_tools/cli.py | 86 +++++++ src/plaud_tools/client.py | 171 ++++++++++++- src/plaud_tools/mcp.py | 126 ++++++++++ src/plaud_tools/server.py | 82 +++++++ tests/data/tool_descriptions.golden.json | 76 ++++++ tests/test_client.py | 295 +++++++++++++++++++++++ tests/test_interfaces.py | 207 ++++++++++++++++ tests/test_mcp_golden.py | 5 +- tests/test_server.py | 20 ++ 13 files changed, 1087 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10074c9..b8a1cad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.6.0] - 2026-07-04 + +### Added + +- **Summary editing (`edit_summary` MCP tool / `correct-summary` + `set-summary` + CLI).** Previously AI summaries were read-only. You can now fix text in a + generated summary — the reported case being correcting a misspelled name. + `operation="correct"` does a literal, case-sensitive find-and-replace (the + summary counterpart of `correct_transcript`); `operation="replace"` overwrites + the whole summary with new markdown. Backed by Plaud's + `POST /ai/update_note_info`. The recording must already have a generated + summary (this edits the existing note; it does not create one). +- **Folder management (`mutate_folder` MCP tool / `folder create|edit|delete` + CLI).** Previously folders could only be listed and moved between; there was + no way to create, rename/recolor, or delete a folder from the tools. + `mutate_folder` adds all three via Plaud's `POST/PATCH/DELETE /filetag/`. + `edit` sends only the fields you supply (name/color/icon), preserving the + rest. `delete` is irreversible for the folder itself — recordings inside are + kept but become unfiled — so it is gated behind a required `confirm=true` + (MCP) / `--yes` (CLI), mirroring `delete_recording`. New folders default to a + standard icon/color when omitted. + +The MCP surface grows from 10 to 12 tools. + ## [0.5.0] - 2026-06-21 ### Fixed @@ -1332,7 +1356,8 @@ For full detail see the v0.1.20–v0.1.22 sections below. Headline items: `scripts/plaud_entry.py` wrapper mirrors the existing `plaud_mcp_entry.py` / `plaud_tray_entry.py` pattern. -[Unreleased]: https://github.com/massive-value/plaud-tools/compare/v0.5.0...HEAD +[Unreleased]: https://github.com/massive-value/plaud-tools/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/massive-value/plaud-tools/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/massive-value/plaud-tools/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/massive-value/plaud-tools/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/massive-value/plaud-tools/compare/v0.3.4...v0.4.0 diff --git a/CLAUDE.md b/CLAUDE.md index 667960c..67b8792 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ The Python rewrite is complete. The active code lives under `src/plaud_tools/`: - `client.py` — Plaud domain/client layer (auth, session, all API flows) - `cli.py` — Python CLI (`plaud` / `pld` entry points) -- `mcp.py` — MCP handler functions (10 tools: browse, get, mutate, delete, rename_speaker, correct_transcript, upload, process, list_folders, merge_recordings) +- `mcp.py` — MCP handler functions (12 tools: browse, get, mutate, delete, rename_speaker, correct_transcript, edit_summary, upload, process, list_folders, mutate_folder, merge_recordings) - `server.py` — Python MCP server process (`plaud-mcp` entry point, stdio transport) The TypeScript prior art has been removed. The `har-captures/` directory contains live Plaud API traffic captures (gitignored — local reference only). diff --git a/README.md b/README.md index f3dbf41..bf5fe3a 100644 --- a/README.md +++ b/README.md @@ -73,16 +73,17 @@ Open the tray menu, click **Uninstall…**, review the checklist, and click **Un ## What PlaudTools can do -PlaudTools gives your AI assistant seven tools for working with your Plaud account. You don't call these directly — you ask in plain English, and the assistant picks the right one. +PlaudTools gives your AI assistant a set of tools for working with your Plaud account. You don't call these directly — you ask in plain English, and the assistant picks the right one. | What it does | What you'd ask | |---|---| | Find recordings | *"Show me my recordings from last week."* | | Read a recording | *"What did I say in the Henderson meeting?"* | | Rename, move, or trash | *"Rename yesterday's 9am recording to 'Tax planning call'."* | +| Fix a transcript or summary | *"Fix the spelling of the client's name in yesterday's summary."* | | Upload audio | *"Upload this audio file and transcribe it."* (with attachment) | | Transcribe and summarize | *"Transcribe and summarize yesterday's recording."* | -| List folders | *"What folders do I have in Plaud?"* | +| List and manage folders | *"Create a 'Client Calls' folder"* / *"What folders do I have in Plaud?"* | | Merge recordings | *"Merge these three call segments into one recording."* | ## Other ways to install diff --git a/pyproject.toml b/pyproject.toml index da5e9b5..027f3e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "plaud-tools" -version = "0.5.0" +version = "0.6.0" description = "Python rewrite for Plaud CLI and MCP workflows." readme = "README.md" requires-python = ">=3.11" diff --git a/src/plaud_tools/cli.py b/src/plaud_tools/cli.py index 62883b1..5324d76 100644 --- a/src/plaud_tools/cli.py +++ b/src/plaud_tools/cli.py @@ -57,6 +57,24 @@ def build_parser() -> argparse.ArgumentParser: folders_cmd = sub.add_parser("folders") # noqa: F841 # side-effect: registers subparser + folder_cmd = sub.add_parser("folder", help="Create, edit, or delete folders.") + folder_sub = folder_cmd.add_subparsers(dest="folder_command", required=True) + + folder_create = folder_sub.add_parser("create") + folder_create.add_argument("name") + folder_create.add_argument("--color", help="Hex color, e.g. '#4c8eff'") + folder_create.add_argument("--icon", help="Icon glyph codepoint, e.g. 'e627'") + + folder_edit = folder_sub.add_parser("edit") + folder_edit.add_argument("folder_id") + folder_edit.add_argument("--name") + folder_edit.add_argument("--color") + folder_edit.add_argument("--icon") + + folder_delete = folder_sub.add_parser("delete") + folder_delete.add_argument("folder_id") + folder_delete.add_argument("--yes", action="store_true") + move_to_folder_cmd = sub.add_parser("move-to-folder") move_to_folder_cmd.add_argument("recording_id") move_to_folder_cmd.add_argument("folder_id") @@ -75,6 +93,19 @@ def build_parser() -> argparse.ArgumentParser: correct_transcript_cmd.add_argument("find") correct_transcript_cmd.add_argument("replace") + correct_summary_cmd = sub.add_parser("correct-summary") + correct_summary_cmd.add_argument("recording_id") + correct_summary_cmd.add_argument("find") + correct_summary_cmd.add_argument("replace") + + set_summary_cmd = sub.add_parser("set-summary") + set_summary_cmd.add_argument("recording_id") + set_summary_group = set_summary_cmd.add_mutually_exclusive_group(required=True) + set_summary_group.add_argument("--content", help="New summary markdown") + set_summary_group.add_argument( + "--content-file", help="Path to a file containing the new summary markdown" + ) + transcribe_cmd = sub.add_parser("transcribe") transcribe_cmd.add_argument("recording_id") transcribe_cmd.add_argument("--template") @@ -409,6 +440,29 @@ def _handle_folders(args: argparse.Namespace, client: PlaudClient) -> str: # no ) +def _handle_folder(args: argparse.Namespace, client: PlaudClient) -> str: + def _shape(tag: Any) -> dict[str, Any]: + return {"id": tag.id, "name": tag.name, "color": tag.color, "icon": tag.icon} + + if args.folder_command == "create": + tag = client.create_folder(args.name, color=args.color, icon=args.icon) + return json.dumps({"ok": True, "action": "create", "folder": _shape(tag)}, indent=2) + if args.folder_command == "edit": + if args.name is None and args.color is None and args.icon is None: + raise ValueError("folder edit requires at least one of --name, --color, --icon") + tag = client.update_folder(args.folder_id, name=args.name, color=args.color, icon=args.icon) + return json.dumps({"ok": True, "action": "edit", "folder": _shape(tag)}, indent=2) + if args.folder_command == "delete": + if not args.yes: + raise ValueError( + f"Deleting folder {args.folder_id!r} cannot be undone (recordings inside are kept " + f"but become unfiled). Re-run with --yes to confirm." + ) + client.delete_folder(args.folder_id) + return json.dumps({"ok": True, "action": "delete", "folder_id": args.folder_id}, indent=2) + raise AssertionError(f"unhandled folder command: {args.folder_command}") + + def _handle_move(args: argparse.Namespace, client: PlaudClient) -> str: folder_id = None if args.folder_id == "-" else args.folder_id client.set_recording_folder(args.recording_id, folder_id) @@ -485,6 +539,35 @@ def _handle_correct_transcript(args: argparse.Namespace, client: PlaudClient) -> ) +def _handle_correct_summary(args: argparse.Namespace, client: PlaudClient) -> str: + result = client.correct_summary(args.recording_id, args.find, args.replace) + return json.dumps( + { + "ok": True, + "recording_id": args.recording_id, + "find": args.find, + "replace": args.replace, + "replacements": result["replacements"], + }, + indent=2, + ) + + +def _handle_set_summary(args: argparse.Namespace, client: PlaudClient) -> str: + if args.content_file: + path = Path(args.content_file) + if not path.exists(): + raise ValueError(f"file not found: {args.content_file}") + content = path.read_text(encoding="utf-8") + else: + content = args.content + client.set_summary(args.recording_id, content) + return json.dumps( + {"ok": True, "recording_id": args.recording_id, "mutation": "set-summary"}, + indent=2, + ) + + def _handle_upload(args: argparse.Namespace, client: PlaudClient) -> str: import tempfile @@ -631,6 +714,7 @@ def _handle_ping(args: argparse.Namespace, client: PlaudClient) -> str: # noqa: "summary": _handle_summary, "rename": _handle_rename, "folders": _handle_folders, + "folder": _handle_folder, "move-to-folder": _handle_move, "move": _handle_move, "trash": _handle_trash, @@ -640,6 +724,8 @@ def _handle_ping(args: argparse.Namespace, client: PlaudClient) -> str: # noqa: "trash-restore": _handle_trash_restore, "rename-speaker": _handle_rename_speaker, "correct-transcript": _handle_correct_transcript, + "correct-summary": _handle_correct_summary, + "set-summary": _handle_set_summary, "upload": _handle_upload, "merge": _handle_merge, "transcribe": _handle_transcribe, diff --git a/src/plaud_tools/client.py b/src/plaud_tools/client.py index ae48c27..1000622 100644 --- a/src/plaud_tools/client.py +++ b/src/plaud_tools/client.py @@ -55,6 +55,14 @@ def _jitter(lo: float, hi: float) -> float: _MAX_ATTEMPTS = 3 _BACKOFF_BASE = 1.0 # seconds — see formula above +# Defaults for folder (filetag) creation. The Plaud web client always sends an +# icon and color when creating a folder; the API appears to require both. These +# mirror the values Plaud assigns to most existing folders (icon "e627" is the +# common default folder glyph) so a folder created via the API is visually +# indistinguishable from one created in the web app when the caller omits them. +_DEFAULT_FOLDER_ICON = "e627" +_DEFAULT_FOLDER_COLOR = "#4c8eff" + @dataclass(slots=True) class PlaudRecordingQuery: @@ -439,19 +447,89 @@ def rename_recording(self, recording_id: str, filename: str) -> None: body={"filename": filename}, ) + def _normalize_file_tag(self, item: dict[str, Any]) -> FileTag: + return FileTag( + id=str(item.get("id") or item.get("filetag_id") or ""), + name=str(item.get("name") or ""), + color=str(item.get("color") or ""), + icon=str(item.get("icon") or ""), + raw=item, + ) + def list_file_tags(self) -> list[FileTag]: data = self._request_json("GET", "/filetag/", strict=True) items = data.get("data_filetag_list") or data.get("data") or data.get("filetags") or [] - return [ - FileTag( - id=str(item.get("id") or item.get("filetag_id") or ""), - name=str(item.get("name") or ""), - color=str(item.get("color") or ""), - icon=str(item.get("icon") or ""), - raw=item, - ) - for item in items - ] + return [self._normalize_file_tag(item) for item in items] + + def create_folder( + self, + name: str, + *, + color: str | None = None, + icon: str | None = None, + ) -> FileTag: + """Create a new folder (filetag) and return it. + + Mirrors the web client's ``POST /filetag/`` call. ``color`` and ``icon`` + default to Plaud's common folder defaults when omitted (see + ``_DEFAULT_FOLDER_*``). A duplicate name is rejected by Plaud with + ``status:-2 msg:"filetag name existed"``, which surfaces here as a + ``PlaudApiError`` via the strict-status check in ``_request_json``. + """ + if not name.strip(): + raise ValueError("folder name cannot be empty") + data = self._request_json( + "POST", + "/filetag/", + strict=True, + body={ + "name": name, + "icon": icon or _DEFAULT_FOLDER_ICON, + "color": color or _DEFAULT_FOLDER_COLOR, + }, + ) + return self._normalize_file_tag(data.get("data_filetag") or {}) + + def update_folder( + self, + folder_id: str, + *, + name: str | None = None, + color: str | None = None, + icon: str | None = None, + ) -> FileTag: + """Edit an existing folder's name, color, and/or icon (``PATCH /filetag/{id}``). + + Only the supplied fields are sent; at least one of name/color/icon is + required. Returns the updated folder as echoed back by Plaud. + """ + if not folder_id: + raise ValueError("folder_id cannot be empty") + body: dict[str, Any] = {} + if name is not None: + if not name.strip(): + raise ValueError("folder name cannot be empty") + body["name"] = name + if color is not None: + body["color"] = color + if icon is not None: + body["icon"] = icon + if not body: + raise ValueError("update_folder requires at least one of name, color, icon") + data = self._request_json("PATCH", f"/filetag/{folder_id}", strict=True, body=body) + return self._normalize_file_tag(data.get("data_filetag") or {}) + + def delete_folder(self, folder_id: str) -> None: + """Delete a folder (``DELETE /filetag/{id}``). + + Deletes only the folder itself — recordings inside it are not deleted; + they lose the folder association and become unfiled. There is no undo + for the folder, so callers should gate this behind an explicit + confirmation (the CLI ``--yes`` flag / the MCP ``confirm`` parameter). + """ + if not folder_id: + raise ValueError("folder_id cannot be empty") + self._request_json("DELETE", f"/filetag/{folder_id}", strict=True) def list_trash(self) -> list[Recording]: return self.list_recordings(PlaudRecordingQuery(is_trash=1)) @@ -622,6 +700,79 @@ def correct_transcript(self, recording_id: str, find: str, replace: str) -> dict self.edit_transcript(recording_id, next_segments) return {"replacements": replacements, "segments_changed": segments_changed} + def _get_summary_note(self, recording_id: str) -> tuple[str, str]: + """Return ``(note_id, current_content)`` for a recording's AI summary. + + The ``note_id`` is the ``data_id`` of the completed ``auto_sum_note`` + entry in the detail's ``content_list`` — exactly what + ``/ai/update_note_info`` expects. The current content is read inline + when present and otherwise fetched from the summary's ``data_link``. + + Raises ``ValueError`` if the recording has no completed summary. + """ + data = self._request_json("GET", f"/file/detail/{recording_id}", strict=True) + raw = data.get("data", data) + note_id: str | None = None + for item in raw.get("content_list") or []: + if item.get("data_type") == "auto_sum_note" and item.get("task_status") == 1: + note_id = item.get("data_id") + break + if not note_id: + raise ValueError(f"recording {recording_id} has no summary yet") + content = self._extract_inline_summary(raw, note_id) + if content is None: + content = self._fetch_summary_from_data_link(raw) + return note_id, content or "" + + def _update_summary_note(self, recording_id: str, note_id: str, content: str) -> None: + """POST the full summary body back to Plaud (``/ai/update_note_info``). + + The web app replaces the entire ``note_content`` on every edit — there + is no partial-patch endpoint — so callers pass the complete new text. + """ + self._request_json( + "POST", + "/ai/update_note_info", + strict=True, + body={ + "file_id": recording_id, + "note_id": note_id, + "note_type": "auto_sum_note", + "note_content": content, + }, + ) + + def set_summary(self, recording_id: str, content: str) -> None: + """Overwrite a recording's AI summary with ``content`` (full replace). + + The recording must already have a generated summary — this edits the + existing note, it does not create one (use ``transcribe_and_summarize`` + to generate a summary first). + """ + if not content.strip(): + raise ValueError("summary content cannot be empty") + note_id, _ = self._get_summary_note(recording_id) + self._update_summary_note(recording_id, note_id, content) + + def correct_summary(self, recording_id: str, find: str, replace: str) -> dict[str, int]: + """Find-and-replace literal text in a recording's AI summary. + + The summary equivalent of ``correct_transcript`` — the intended use is + fixing a misspelled name or term in an otherwise-good summary. Matching + is literal (not regex) and case-sensitive. Returns the number of + occurrences replaced. Raises ``ValueError`` if the text is not found. + """ + if not find: + raise ValueError("find text cannot be empty") + note_id, content = self._get_summary_note(recording_id) + if not content: + raise ValueError(f"recording {recording_id} has no summary text to edit") + count = content.count(find) + if count == 0: + raise ValueError(f'no occurrences of "{find}" found in summary') + self._update_summary_note(recording_id, note_id, content.replace(find, replace)) + return {"replacements": count} + def _request_json( self, method: str, diff --git a/src/plaud_tools/mcp.py b/src/plaud_tools/mcp.py index fe2df03..25b83c7 100644 --- a/src/plaud_tools/mcp.py +++ b/src/plaud_tools/mcp.py @@ -495,6 +495,130 @@ def inner(client: PlaudClient) -> dict[str, Any]: return _call(get_client, inner) + def edit_summary( + recording_id: str, + operation: str, + find: str | None = None, + replace: str | None = None, + content: str | None = None, + ) -> dict[str, Any]: + def inner(client: PlaudClient) -> dict[str, Any]: + if operation == "correct": + if find is None or replace is None: + return _error_result( + "find and replace are required for operation=correct", + error_code="validation", + retryable=False, + ) + result = client.correct_summary(recording_id, find, replace) + return _json_result( + { + "ok": True, + "recording_id": recording_id, + "operation": "correct", + "find": find, + "replace": replace, + "replacements": result["replacements"], + } + ) + + if operation == "replace": + if content is None: + return _error_result( + "content is required for operation=replace", + error_code="validation", + retryable=False, + ) + client.set_summary(recording_id, content) + return _json_result( + {"ok": True, "recording_id": recording_id, "operation": "replace"} + ) + + return _error_result( + f"unknown operation: {operation!r} (expected 'correct' or 'replace')", + error_code="validation", + retryable=False, + ) + + return _call(get_client, inner) + + def mutate_folder( + action: str, + folder_id: str | None = None, + name: str | None = None, + color: str | None = None, + icon: str | None = None, + confirm: bool = False, + ) -> dict[str, Any]: + def inner(client: PlaudClient) -> dict[str, Any]: + if action == "create": + if not name: + return _error_result( + "name is required for action=create", + error_code="validation", + retryable=False, + ) + tag = client.create_folder(name, color=color, icon=icon) + return _json_result( + { + "ok": True, + "action": "create", + "folder": {"id": tag.id, "name": tag.name, "color": tag.color, "icon": tag.icon}, + } + ) + + if action == "edit": + if not folder_id: + return _error_result( + "folder_id is required for action=edit", + error_code="validation", + retryable=False, + ) + if name is None and color is None and icon is None: + return _error_result( + "action=edit requires at least one of name, color, icon", + error_code="validation", + retryable=False, + ) + tag = client.update_folder(folder_id, name=name, color=color, icon=icon) + return _json_result( + { + "ok": True, + "action": "edit", + "folder": {"id": tag.id, "name": tag.name, "color": tag.color, "icon": tag.icon}, + } + ) + + if action == "delete": + if not folder_id: + return _error_result( + "folder_id is required for action=delete", + error_code="validation", + retryable=False, + ) + # Deleting a folder is irreversible (the folder is gone; the + # recordings inside survive but become unfiled). Gate it behind + # an explicit confirm, mirroring delete_recording — the stdio + # surface can show no interactive prompt. + if not confirm: + return _error_result( + "Deleting a folder cannot be undone (recordings inside are kept but " + "become unfiled). Re-invoke with confirm=true only after the human has " + "confirmed they want to delete this folder.", + error_code="validation", + retryable=False, + ) + client.delete_folder(folder_id) + return _json_result({"ok": True, "action": "delete", "folder_id": folder_id}) + + return _error_result( + f"unknown action: {action!r} (expected 'create', 'edit', or 'delete')", + error_code="validation", + retryable=False, + ) + + return _call(get_client, inner) + return { "browse_recordings": browse_recordings, "get_recording": get_recording, @@ -506,4 +630,6 @@ def inner(client: PlaudClient) -> dict[str, Any]: "process_recording": process_recording, "list_folders": list_folders, "merge_recordings": merge_recordings, + "edit_summary": edit_summary, + "mutate_folder": mutate_folder, } diff --git a/src/plaud_tools/server.py b/src/plaud_tools/server.py index 41747b5..8b71f59 100644 --- a/src/plaud_tools/server.py +++ b/src/plaud_tools/server.py @@ -383,6 +383,88 @@ def _setup_mcp_logging() -> None: openWorldHint=True, ), ), + types.Tool( + name="edit_summary", + description="Edit a recording's AI summary. operation='correct' does a literal find-and-replace (e.g. fix a misspelled name); operation='replace' overwrites the whole summary with new markdown. The recording must already have a generated summary.", # noqa: E501 + inputSchema={ + "type": "object", + "properties": { + "recording_id": {"type": "string"}, + "operation": { + "type": "string", + "enum": ["correct", "replace"], + "description": "'correct' = find/replace text; 'replace' = overwrite entire summary", + }, + "find": { + "type": "string", + "description": "Exact text to find (case-sensitive, literal). Required for operation=correct.", # noqa: E501 + }, + "replace": { + "type": "string", + "description": "Replacement text (may be empty to delete). Required for operation=correct.", # noqa: E501 + }, + "content": { + "type": "string", + "description": "Full replacement summary markdown. Required for operation=replace.", + }, + }, + "required": ["recording_id", "operation"], + }, + # Reversible content edit — a 'correct' can be undone by re-running with + # swapped find/replace; a 'replace' overwrites, but the prior text can be + # re-supplied. idempotentHint omitted: a second 'correct' with the same + # find returns "no occurrences" (an error), so it is not a no-op. + annotations=types.ToolAnnotations( + destructiveHint=False, + openWorldHint=True, + ), + ), + types.Tool( + name="mutate_folder", + description="Manage Plaud folders: create a new folder, edit an existing one's name/color/icon, or delete one. To move a recording into a folder, use mutate_recording(mutation='move') instead.", # noqa: E501 + inputSchema={ + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "edit", "delete"], + }, + "folder_id": { + "type": "string", + "description": "Folder ID (from `list_folders`); required for edit and delete", + }, + "name": { + "type": "string", + "description": "Folder name; required for create, optional for edit", + }, + "color": { + "type": "string", + "description": "Hex color (e.g. '#4c8eff'); optional", + }, + "icon": { + "type": "string", + "description": "Icon glyph codepoint (e.g. 'e627'); optional", + }, + "confirm": { + "type": "boolean", + "description": ( + "Required true for action=delete. Set only after the human confirms; " + "deleting a folder is irreversible (recordings inside are kept but unfiled)." + ), + }, + }, + "required": ["action"], + }, + # create/edit are reversible; delete is irreversible for the folder + # itself (recordings survive), so it is gated by a required confirm=true + # in the handler. destructiveHint=True flags the delete path to clients; + # idempotentHint=False because deleting a missing folder errors, not no-ops. + annotations=types.ToolAnnotations( + destructiveHint=True, + idempotentHint=False, + openWorldHint=True, + ), + ), ] diff --git a/tests/data/tool_descriptions.golden.json b/tests/data/tool_descriptions.golden.json index f00b4e0..87ccb26 100644 --- a/tests/data/tool_descriptions.golden.json +++ b/tests/data/tool_descriptions.golden.json @@ -275,5 +275,81 @@ "type": "object" }, "name": "merge_recordings" + }, + { + "description": "Edit a recording's AI summary. operation='correct' does a literal find-and-replace (e.g. fix a misspelled name); operation='replace' overwrites the whole summary with new markdown. The recording must already have a generated summary.", + "inputSchema": { + "properties": { + "content": { + "description": "Full replacement summary markdown. Required for operation=replace.", + "type": "string" + }, + "find": { + "description": "Exact text to find (case-sensitive, literal). Required for operation=correct.", + "type": "string" + }, + "operation": { + "description": "'correct' = find/replace text; 'replace' = overwrite entire summary", + "enum": [ + "correct", + "replace" + ], + "type": "string" + }, + "recording_id": { + "type": "string" + }, + "replace": { + "description": "Replacement text (may be empty to delete). Required for operation=correct.", + "type": "string" + } + }, + "required": [ + "recording_id", + "operation" + ], + "type": "object" + }, + "name": "edit_summary" + }, + { + "description": "Manage Plaud folders: create a new folder, edit an existing one's name/color/icon, or delete one. To move a recording into a folder, use mutate_recording(mutation='move') instead.", + "inputSchema": { + "properties": { + "action": { + "enum": [ + "create", + "edit", + "delete" + ], + "type": "string" + }, + "color": { + "description": "Hex color (e.g. '#4c8eff'); optional", + "type": "string" + }, + "confirm": { + "description": "Required true for action=delete. Set only after the human confirms; deleting a folder is irreversible (recordings inside are kept but unfiled).", + "type": "boolean" + }, + "folder_id": { + "description": "Folder ID (from `list_folders`); required for edit and delete", + "type": "string" + }, + "icon": { + "description": "Icon glyph codepoint (e.g. 'e627'); optional", + "type": "string" + }, + "name": { + "description": "Folder name; required for create, optional for edit", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + "name": "mutate_folder" } ] diff --git a/tests/test_client.py b/tests/test_client.py index bf00350..2026aed 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -791,6 +791,301 @@ def test_correct_transcript_rejects_missing_transcript(tmp_path): client.correct_transcript("rec1", "a", "b") +# --------------------------------------------------------------------------- +# Summary editing (set_summary / correct_summary) — POST /ai/update_note_info +# --------------------------------------------------------------------------- + + +def _detail_with_inline_summary(summary_text, *, note_id="auto_sum:hash:rec1"): + """GET /file/detail response carrying an inline auto_sum_note summary.""" + return HttpResponse( + 200, + json.dumps( + { + "status": 0, + "data": { + "file_id": "rec1", + "file_name": "Meeting", + "content_list": [ + { + "data_type": "auto_sum_note", + "task_status": 1, + "data_id": note_id, + "data_link": "https://s3.fake/summary.json", + } + ], + "pre_download_content_list": [ + {"data_id": note_id, "data_content": json.dumps({"ai_content": summary_text})} + ], + }, + } + ).encode(), + {}, + ) + + +def test_correct_summary_replaces_text_and_posts_note_update(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + _detail_with_inline_summary("Customer: Suzan met with Suzan again."), + HttpResponse(200, json.dumps({"status": 0, "data": {}}).encode(), {}), + ] + ) + client = PlaudClient(manager, transport=transport) + result = client.correct_summary("rec1", "Suzan", "Susan") + assert result == {"replacements": 2} + # No S3 fetch was needed (inline summary); detail + note-update = 2 calls. + assert len(transport.calls) == 2 + post = transport.calls[1] + assert post["method"] == "POST" + assert post["url"] == "https://api-euc1.plaud.ai/ai/update_note_info" + body = json.loads(post["body"].decode("utf-8")) + assert body == { + "file_id": "rec1", + "note_id": "auto_sum:hash:rec1", + "note_type": "auto_sum_note", + "note_content": "Customer: Susan met with Susan again.", + } + + +def test_correct_summary_rejects_no_match(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport([_detail_with_inline_summary("nothing to change here")]) + client = PlaudClient(manager, transport=transport) + with pytest.raises(ValueError, match='no occurrences of "zzz" found in summary'): + client.correct_summary("rec1", "zzz", "x") + + +def test_correct_summary_rejects_empty_find(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="find text cannot be empty"): + client.correct_summary("rec1", "", "x") + + +def test_correct_summary_rejects_missing_summary(tmp_path): + manager, _ = make_manager(tmp_path) + # content_list has a transcript but no completed auto_sum_note. + transport = StubTransport( + [ + HttpResponse( + 200, + json.dumps( + { + "status": 0, + "data": { + "file_id": "rec1", + "file_name": "Meeting", + "content_list": [{"data_type": "transaction", "task_status": 1}], + }, + } + ).encode(), + {}, + ) + ] + ) + client = PlaudClient(manager, transport=transport) + with pytest.raises(ValueError, match="has no summary yet"): + client.correct_summary("rec1", "a", "b") + + +def test_correct_summary_fetches_content_from_data_link(tmp_path): + """When the summary is not inlined, correct_summary fetches it from data_link.""" + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + HttpResponse( + 200, + json.dumps( + { + "status": 0, + "data": { + "file_id": "rec1", + "file_name": "Meeting", + "content_list": [ + { + "data_type": "auto_sum_note", + "task_status": 1, + "data_id": "auto_sum:hash:rec1", + "data_link": "https://s3.fake/summary.json", + } + ], + }, + } + ).encode(), + {}, + ), + HttpResponse(200, json.dumps({"ai_content": "Meeting with Bxb"}).encode(), {}), + HttpResponse(200, json.dumps({"status": 0, "data": {}}).encode(), {}), + ] + ) + client = PlaudClient(manager, transport=transport) + result = client.correct_summary("rec1", "Bxb", "Bob") + assert result == {"replacements": 1} + assert transport.calls[1]["url"] == "https://s3.fake/summary.json" + body = json.loads(transport.calls[2]["body"].decode("utf-8")) + assert body["note_content"] == "Meeting with Bob" + + +def test_set_summary_overwrites_full_content(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + _detail_with_inline_summary("old summary"), + HttpResponse(200, json.dumps({"status": 0, "data": {}}).encode(), {}), + ] + ) + client = PlaudClient(manager, transport=transport) + client.set_summary("rec1", "# Brand New Summary\n\n- point") + post = transport.calls[1] + body = json.loads(post["body"].decode("utf-8")) + assert body["note_content"] == "# Brand New Summary\n\n- point" + assert body["note_id"] == "auto_sum:hash:rec1" + + +def test_set_summary_rejects_empty_content(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="summary content cannot be empty"): + client.set_summary("rec1", " ") + + +# --------------------------------------------------------------------------- +# Folder CRUD (create_folder / update_folder / delete_folder) — /filetag/ +# --------------------------------------------------------------------------- + + +def test_create_folder_posts_name_icon_color_and_returns_tag(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + HttpResponse( + 200, + json.dumps( + { + "status": 0, + "data_filetag": { + "id": "new1", + "name": "Clients", + "icon": "e708", + "color": "#f9a251", + }, + } + ).encode(), + {}, + ) + ] + ) + client = PlaudClient(manager, transport=transport) + tag = client.create_folder("Clients", color="#f9a251", icon="e708") + assert tag.id == "new1" + assert tag.name == "Clients" + call = transport.calls[0] + assert call["method"] == "POST" + assert call["url"] == "https://api-euc1.plaud.ai/filetag/" + assert json.loads(call["body"].decode("utf-8")) == { + "name": "Clients", + "icon": "e708", + "color": "#f9a251", + } + + +def test_create_folder_defaults_icon_and_color(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [HttpResponse(200, json.dumps({"status": 0, "data_filetag": {"id": "x", "name": "N"}}).encode(), {})] + ) + client = PlaudClient(manager, transport=transport) + client.create_folder("N") + body = json.loads(transport.calls[0]["body"].decode("utf-8")) + assert body["icon"] == client_mod._DEFAULT_FOLDER_ICON + assert body["color"] == client_mod._DEFAULT_FOLDER_COLOR + + +def test_create_folder_rejects_blank_name(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="folder name cannot be empty"): + client.create_folder(" ") + + +def test_create_folder_duplicate_name_raises_api_error(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + HttpResponse( + 200, + json.dumps({"status": -2, "msg": "filetag name existed", "data_filetag": None}).encode(), + {}, + ) + ] + ) + client = PlaudClient(manager, transport=transport) + with pytest.raises(PlaudApiError, match="filetag name existed"): + client.create_folder("Existing") + + +def test_update_folder_patches_only_supplied_fields(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [ + HttpResponse( + 200, + json.dumps( + { + "status": 0, + "data_filetag": {"id": "f1", "name": "Renamed", "icon": "e604", "color": "#fb5c5c"}, + } + ).encode(), + {}, + ) + ] + ) + client = PlaudClient(manager, transport=transport) + tag = client.update_folder("f1", name="Renamed", color="#fb5c5c") + assert tag.name == "Renamed" + call = transport.calls[0] + assert call["method"] == "PATCH" + assert call["url"] == "https://api-euc1.plaud.ai/filetag/f1" + # icon was not supplied, so it must not be in the body. + assert json.loads(call["body"].decode("utf-8")) == {"name": "Renamed", "color": "#fb5c5c"} + + +def test_update_folder_requires_at_least_one_field(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="at least one of name, color, icon"): + client.update_folder("f1") + + +def test_update_folder_rejects_blank_id(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="folder_id cannot be empty"): + client.update_folder("", name="X") + + +def test_delete_folder_uses_delete_method(tmp_path): + manager, _ = make_manager(tmp_path) + transport = StubTransport( + [HttpResponse(200, json.dumps({"status": 0, "msg": "delete success"}).encode(), {})] + ) + client = PlaudClient(manager, transport=transport) + client.delete_folder("f1") + call = transport.calls[0] + assert call["method"] == "DELETE" + assert call["url"] == "https://api-euc1.plaud.ai/filetag/f1" + assert call["body"] is None + + +def test_delete_folder_rejects_blank_id(tmp_path): + manager, _ = make_manager(tmp_path) + client = PlaudClient(manager, transport=StubTransport([])) + with pytest.raises(ValueError, match="folder_id cannot be empty"): + client.delete_folder("") + + def test_transcribe_and_summarize_uses_expected_payload(tmp_path): manager, _ = make_manager(tmp_path) transport = StubTransport( diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index a4c7d83..4baf164 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -1178,3 +1178,210 @@ def request(self, method, url, headers, body=None, *, timeout=None): } result = client._fetch_summary_from_data_link(raw) assert result == "# My Summary\n\nContent here." + + +# --------------------------------------------------------------------------- +# edit_summary — MCP tool + CLI (correct-summary / set-summary) +# --------------------------------------------------------------------------- + + +class SummaryStub(StubClient): + def __init__(self): + self.correct_call = None + self.set_call = None + + def correct_summary(self, recording_id, find, replace): + self.correct_call = (recording_id, find, replace) + return {"replacements": 3} + + def set_summary(self, recording_id, content): + self.set_call = (recording_id, content) + + +def test_mcp_edit_summary_correct_calls_correct_summary(): + client = SummaryStub() + handlers = build_handlers(lambda: client) + result = handlers["edit_summary"](recording_id="rec1", operation="correct", find="Suzan", replace="Susan") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["replacements"] == 3 + assert client.correct_call == ("rec1", "Suzan", "Susan") + + +def test_mcp_edit_summary_correct_requires_find_and_replace(): + handlers = build_handlers(lambda: SummaryStub()) + result = handlers["edit_summary"](recording_id="rec1", operation="correct", find="x") + assert result["isError"] is True + payload = json.loads(result["content"][0]["text"]) + assert "find and replace" in payload["error"] + + +def test_mcp_edit_summary_replace_calls_set_summary(): + client = SummaryStub() + handlers = build_handlers(lambda: client) + result = handlers["edit_summary"](recording_id="rec1", operation="replace", content="# New") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert client.set_call == ("rec1", "# New") + + +def test_mcp_edit_summary_replace_requires_content(): + handlers = build_handlers(lambda: SummaryStub()) + result = handlers["edit_summary"](recording_id="rec1", operation="replace") + assert result["isError"] is True + assert "content is required" in json.loads(result["content"][0]["text"])["error"] + + +def test_mcp_edit_summary_rejects_unknown_operation(): + handlers = build_handlers(lambda: SummaryStub()) + result = handlers["edit_summary"](recording_id="rec1", operation="frobnicate") + assert result["isError"] is True + assert "unknown operation" in json.loads(result["content"][0]["text"])["error"] + + +def test_cli_correct_summary_calls_client(): + client = SummaryStub() + output = run_cli(["correct-summary", "rec1", "Suzan", "Susan"], client) + payload = json.loads(output) + assert payload["replacements"] == 3 + assert client.correct_call == ("rec1", "Suzan", "Susan") + + +def test_cli_set_summary_from_content_flag(): + client = SummaryStub() + output = run_cli(["set-summary", "rec1", "--content", "# Fresh summary"], client) + payload = json.loads(output) + assert payload["ok"] is True + assert client.set_call == ("rec1", "# Fresh summary") + + +def test_cli_set_summary_from_content_file(tmp_path): + md = tmp_path / "summary.md" + md.write_text("# From file\n\nbody", encoding="utf-8") + client = SummaryStub() + output = run_cli(["set-summary", "rec1", "--content-file", str(md)], client) + payload = json.loads(output) + assert payload["ok"] is True + assert client.set_call == ("rec1", "# From file\n\nbody") + + +# --------------------------------------------------------------------------- +# mutate_folder — MCP tool + CLI (folder create/edit/delete) +# --------------------------------------------------------------------------- + + +class FolderStub(StubClient): + def __init__(self): + self.create_call = None + self.update_call = None + self.delete_call = None + + def create_folder(self, name, *, color=None, icon=None): + self.create_call = (name, color, icon) + return FileTag(id="new1", name=name, color=color or "", icon=icon or "") + + def update_folder(self, folder_id, *, name=None, color=None, icon=None): + self.update_call = (folder_id, name, color, icon) + return FileTag(id=folder_id, name=name or "old", color=color or "", icon=icon or "") + + def delete_folder(self, folder_id): + self.delete_call = folder_id + + +def test_mcp_mutate_folder_create(): + client = FolderStub() + handlers = build_handlers(lambda: client) + result = handlers["mutate_folder"](action="create", name="Clients", color="#111", icon="e627") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert payload["folder"]["id"] == "new1" + assert client.create_call == ("Clients", "#111", "e627") + + +def test_mcp_mutate_folder_create_requires_name(): + handlers = build_handlers(lambda: FolderStub()) + result = handlers["mutate_folder"](action="create") + assert result["isError"] is True + assert "name is required" in json.loads(result["content"][0]["text"])["error"] + + +def test_mcp_mutate_folder_edit(): + client = FolderStub() + handlers = build_handlers(lambda: client) + result = handlers["mutate_folder"](action="edit", folder_id="f1", name="Renamed") + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert client.update_call == ("f1", "Renamed", None, None) + + +def test_mcp_mutate_folder_edit_requires_a_field(): + handlers = build_handlers(lambda: FolderStub()) + result = handlers["mutate_folder"](action="edit", folder_id="f1") + assert result["isError"] is True + assert "at least one of name, color, icon" in json.loads(result["content"][0]["text"])["error"] + + +def test_mcp_mutate_folder_delete_requires_confirm(): + client = FolderStub() + handlers = build_handlers(lambda: client) + result = handlers["mutate_folder"](action="delete", folder_id="f1") + assert result["isError"] is True + assert "confirm=true" in json.loads(result["content"][0]["text"])["error"] + assert client.delete_call is None # not deleted without confirm + + +def test_mcp_mutate_folder_delete_with_confirm(): + client = FolderStub() + handlers = build_handlers(lambda: client) + result = handlers["mutate_folder"](action="delete", folder_id="f1", confirm=True) + payload = json.loads(result["content"][0]["text"]) + assert payload["ok"] is True + assert client.delete_call == "f1" + + +def test_mcp_mutate_folder_rejects_unknown_action(): + handlers = build_handlers(lambda: FolderStub()) + result = handlers["mutate_folder"](action="obliterate") + assert result["isError"] is True + assert "unknown action" in json.loads(result["content"][0]["text"])["error"] + + +def test_cli_folder_create(): + client = FolderStub() + output = run_cli(["folder", "create", "Clients", "--color", "#111", "--icon", "e627"], client) + payload = json.loads(output) + assert payload["ok"] is True + assert client.create_call == ("Clients", "#111", "e627") + + +def test_cli_folder_edit(): + client = FolderStub() + output = run_cli(["folder", "edit", "f1", "--name", "Renamed"], client) + payload = json.loads(output) + assert payload["action"] == "edit" + assert client.update_call == ("f1", "Renamed", None, None) + + +def test_cli_folder_edit_requires_a_field(): + import pytest + + client = FolderStub() + with pytest.raises(ValueError, match="at least one of --name, --color, --icon"): + run_cli(["folder", "edit", "f1"], client) + + +def test_cli_folder_delete_requires_yes(): + import pytest + + client = FolderStub() + with pytest.raises(ValueError, match="cannot be undone"): + run_cli(["folder", "delete", "f1"], client) + assert client.delete_call is None + + +def test_cli_folder_delete_with_yes(): + client = FolderStub() + output = run_cli(["folder", "delete", "f1", "--yes"], client) + payload = json.loads(output) + assert payload["ok"] is True + assert client.delete_call == "f1" diff --git a/tests/test_mcp_golden.py b/tests/test_mcp_golden.py index e41fac9..711d2c6 100644 --- a/tests/test_mcp_golden.py +++ b/tests/test_mcp_golden.py @@ -36,8 +36,9 @@ _GOLDEN_PATH = Path(__file__).parent / "data" / "tool_descriptions.golden.json" -# 1.1 * 387 word baseline; update intentionally if descriptions change -_TOKEN_BUDGET_WORDS = 425 +# 1.1 * 442 word baseline (12 tools, incl. edit_summary + mutate_folder added in +# v0.6.0); update intentionally if descriptions change +_TOKEN_BUDGET_WORDS = 486 def _serialize_tools() -> str: diff --git a/tests/test_server.py b/tests/test_server.py index 9513f6f..f583ba5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -23,6 +23,8 @@ "process_recording", "list_folders", "merge_recordings", + "edit_summary", + "mutate_folder", } @@ -30,6 +32,24 @@ def test_server_exposes_expected_tools(): assert {t.name for t in _TOOLS} == _EXPECTED_TOOL_NAMES +def test_server_edit_summary_requires_recording_id_and_operation(): + tool = next(t for t in _TOOLS if t.name == "edit_summary") + assert set(tool.inputSchema["required"]) == {"recording_id", "operation"} + assert tool.inputSchema["properties"]["operation"]["enum"] == ["correct", "replace"] + + +def test_server_mutate_folder_requires_only_action(): + tool = next(t for t in _TOOLS if t.name == "mutate_folder") + assert tool.inputSchema["required"] == ["action"] + assert set(tool.inputSchema["properties"]["action"]["enum"]) == {"create", "edit", "delete"} + + +def test_server_mutate_folder_is_flagged_destructive(): + # The delete path is irreversible for the folder; the annotation warns clients. + tool = next(t for t in _TOOLS if t.name == "mutate_folder") + assert tool.annotations.destructiveHint is True + + def test_server_list_folders_has_no_required_fields(): tool = next(t for t in _TOOLS if t.name == "list_folders") assert "required" not in tool.inputSchema From 2645e7bd72d87b0a132de5b7b9c99223010f8819 Mon Sep 17 00:00:00 2001 From: Kadin Bullock Date: Sat, 4 Jul 2026 17:32:50 -0600 Subject: [PATCH 2/3] style: ruff format mcp.py (edit_summary handler) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/plaud_tools/mcp.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/plaud_tools/mcp.py b/src/plaud_tools/mcp.py index 25b83c7..19c068f 100644 --- a/src/plaud_tools/mcp.py +++ b/src/plaud_tools/mcp.py @@ -530,9 +530,7 @@ def inner(client: PlaudClient) -> dict[str, Any]: retryable=False, ) client.set_summary(recording_id, content) - return _json_result( - {"ok": True, "recording_id": recording_id, "operation": "replace"} - ) + return _json_result({"ok": True, "recording_id": recording_id, "operation": "replace"}) return _error_result( f"unknown operation: {operation!r} (expected 'correct' or 'replace')", From 8f35f49b288a68ef36228bf68d443d4f0bf91126 Mon Sep 17 00:00:00 2001 From: Kadin Bullock Date: Sat, 4 Jul 2026 17:37:35 -0600 Subject: [PATCH 3/3] ci: re-pin ffmpeg 8.1.1 -> 8.1.2 (gyan.dev rotated the old build away) bundle-smoke (and the release build, which shares the step) were failing at "Download ffmpeg (pinned)": gyan.dev only hosts the latest version-pinned essentials build, so the ffmpeg-8.1.1 URL now 404s. Bump both ci.yml and release.yml to 8.1.2 with its gyan.dev-published SHA-256. Unrelated to the v0.6.0 feature work but required for a green bundle-smoke + release. This will recur on the next gyan.dev bump; a durable fix (immutable GitHub-hosted build source) is noted for follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 11 +++++++---- .github/workflows/release.yml | 11 +++++++---- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cb8f0c..bcb6ace 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,10 +92,13 @@ jobs: - name: Download ffmpeg (pinned) run: | - # Pinned to ffmpeg 8.1.1 (essentials build) — same pin as release.yml. - # SHA-256 sourced from https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.1-essentials_build.zip.sha256 - $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.1-essentials_build.zip" - $expectedHash = "6F58CE889F59C311410F7D2B18895B33C03456463486F3B1EBC93D97A0F54541" + # Pinned to ffmpeg 8.1.2 (essentials build) — same pin as release.yml. + # NOTE: gyan.dev only hosts the latest version-pinned build; older ones 404, + # so this pin must be bumped whenever gyan.dev publishes a new release + # (was 8.1.1, rotated away 2026-07). Keep release.yml's pin in lockstep. + # SHA-256 sourced from https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip.sha256 + $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip" + $expectedHash = "DB580001CAA24AC104C8CB856CD113A87B0A443F7BDF47D8C12B1D740584A2EC" Invoke-WebRequest $ffmpegUrl -OutFile ffmpeg.zip $actualHash = (Get-FileHash ffmpeg.zip -Algorithm SHA256).Hash if ($actualHash -ne $expectedHash) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 28a730e..ac5447c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -48,10 +48,13 @@ jobs: - name: Download ffmpeg run: | - # Pinned to ffmpeg 8.1.1 (essentials build). - # SHA-256 sourced from https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.1-essentials_build.zip.sha256 - $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.1-essentials_build.zip" - $expectedHash = "6F58CE889F59C311410F7D2B18895B33C03456463486F3B1EBC93D97A0F54541" + # Pinned to ffmpeg 8.1.2 (essentials build). + # NOTE: gyan.dev only hosts the latest version-pinned build; older ones 404, + # so this pin must be bumped whenever gyan.dev publishes a new release + # (was 8.1.1, rotated away 2026-07). Keep ci.yml's pin in lockstep. + # SHA-256 sourced from https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip.sha256 + $ffmpegUrl = "https://www.gyan.dev/ffmpeg/builds/packages/ffmpeg-8.1.2-essentials_build.zip" + $expectedHash = "DB580001CAA24AC104C8CB856CD113A87B0A443F7BDF47D8C12B1D740584A2EC" Invoke-WebRequest $ffmpegUrl -OutFile ffmpeg.zip $actualHash = (Get-FileHash ffmpeg.zip -Algorithm SHA256).Hash if ($actualHash -ne $expectedHash) {