Compare commits
2 Commits
fix-title-
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
ba685a2d9d | |
|
|
15ce3c0834 |
|
|
@ -28,6 +28,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation
|
||||
```
|
||||
|
||||
Use `-Force` to back up and replace an existing installed copy:
|
||||
|
|
@ -40,6 +41,7 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill regr
|
|||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-bundle-audit -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill review-context-builder -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill routing-behavior-diff-audit -Force
|
||||
powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voice-generation -Force
|
||||
```
|
||||
|
||||
`-All` installs only registry rows with an installable status and `default` install policy. Optional Skills must be named explicitly.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ Existing CCPE content is not migrated here.
|
|||
| `review-bundle-audit` | Preflight review, handoff, release, CCRA, Web upload, or code-review bundle directories for required files, manifests, sidecars, reports, zip readability, warnings, and blockers. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-bundle-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-bundle-audit` | none |
|
||||
| `review-context-builder` | Build file-first review, audit, planning, release, handoff, PR, or Agent invocation context indexes and manifests from configured local source roots, patterns, and metadata. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\review-context-builder` | `installed` | `default` | `C:\Users\wangq\.agents\skills\review-context-builder` | none |
|
||||
| `routing-behavior-diff-audit` | Compare before/after selector, classifier, or routing outputs for targeted changes, collateral non-target changes, no-call changes, missing cases, and expected-route mismatch changes. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\routing-behavior-diff-audit` | `installed` | `default` | `C:\Users\wangq\.agents\skills\routing-behavior-diff-audit` | none |
|
||||
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `D:\AI\ClaudeCode\voice-ge` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||
| `voice-generation` | Generate spoken audio from Markdown scripts using the local `voice-gen` CLI and MiniMax `mmx`, including custom voice clone registration and voice registry management. | `C:\Users\wangq\Documents\Codex\skills-vault\skills\voice-generation` | `installed` | `conditional` | `C:\Users\wangq\.agents\skills\voice-generation` | none |
|
||||
|
||||
## Status Values
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: fix-title
|
||||
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: accept a file path array plus mode discussion or artifact, prefer subagent/thread isolation for large repairs, preserve originals, and produce fixed copies, heading plans, and reports."
|
||||
description: "Use when copied ChatGPT/LLM Markdown has semantically wrong heading hierarchy, especially discussion records with round headings and GPT reply blocks, or intake/artifact drafts with repeated accidental top-level headings. Agentic Markdown heading repair for one or more files: default to lightweight in-place repair that overwrites the source Markdown without sidecar files; use audit mode only when the caller asks for preserved originals, fixed copies, heading maps, JSON plans, or reports."
|
||||
---
|
||||
|
||||
# Fix Title
|
||||
|
|
@ -16,37 +16,113 @@ Required inputs:
|
|||
|
||||
Optional inputs:
|
||||
|
||||
- `output_dir`: output folder. If omitted, use repo-local `tmp/fix-title-output` when available; otherwise use `fix-title-output` beside the first source file.
|
||||
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `auto`.
|
||||
- `output`: `quick` or `audit`. Default to `quick`.
|
||||
- `execution`: `auto`, `subagent`, `thread`, or `inline`. Default to `inline` for quick single-file work and `auto` for audit or large batches.
|
||||
- `output_dir`: audit output folder. Ignore in quick mode unless the caller also asks for backup or reports.
|
||||
- `backup`: `never`, `always`, or `auto`. Default to `never`; rely on git unless the caller asks for backups or the file is outside version control.
|
||||
|
||||
Example user-facing request:
|
||||
|
||||
```text
|
||||
Use $fix-title with mode=artifact on:
|
||||
Use $fix-title with mode=artifact on C:\path\one.md
|
||||
```
|
||||
|
||||
This defaults to quick mode and overwrites `C:\path\one.md` directly.
|
||||
|
||||
Audit example:
|
||||
|
||||
```text
|
||||
Use $fix-title with mode=artifact and output=audit on:
|
||||
- C:\path\one.md
|
||||
- C:\path\two.md
|
||||
Output to C:\path\tmp\fix-title-output
|
||||
```
|
||||
|
||||
## Output Modes
|
||||
|
||||
### Quick
|
||||
|
||||
Use quick mode by default.
|
||||
|
||||
- Overwrite each source Markdown file in place.
|
||||
- Do not write `heading-map.json`, `heading-plan.json`, `.fixed.md`, heading reports, or batch reports.
|
||||
- Use in-memory edits and `scripts/semantic_heading_repair.py` helpers when practical.
|
||||
- Return a concise summary with changed heading counts and skipped files.
|
||||
- If a file is ambiguous, skip it and tell the caller why instead of creating audit artifacts by default.
|
||||
|
||||
Quick mode is for routine cleanup where the caller wants the document fixed, not a review package.
|
||||
|
||||
### Audit
|
||||
|
||||
Use audit mode only when the caller asks for auditability, preserved originals, fixed copies, maps, plans, reports, review evidence, batch reports, or no overwrite.
|
||||
|
||||
For each source file, write:
|
||||
|
||||
```text
|
||||
<stem>.heading-map.json
|
||||
<stem>.heading-plan.json
|
||||
<stem>.fixed.md
|
||||
<stem>.heading-report.md
|
||||
```
|
||||
|
||||
For batch runs, also write:
|
||||
|
||||
```text
|
||||
fix-title-batch-report.md
|
||||
```
|
||||
|
||||
The plan JSON is the audit boundary. Every changed heading must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"line": 25,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
|
||||
}
|
||||
```
|
||||
|
||||
The caller must verify returned files and reports in audit mode. Do not treat a subagent/thread message alone as proof of repair.
|
||||
|
||||
## Execution Isolation
|
||||
|
||||
Prefer isolation because semantic repair consumes context and does not depend on the caller's broader conversation.
|
||||
Quick single-file repairs should usually run inline. Prefer isolation only when semantic repair consumes significant context or the caller asks for it.
|
||||
|
||||
- `subagent`: Use when the host supports delegated agents. Pass only the skill path, input files, mode, output directory, and output contract.
|
||||
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session isolation or the batch is large enough to risk bloating the main context.
|
||||
- `inline`: Use only when no subagent/thread mechanism is available or the file is small.
|
||||
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` when available, otherwise `inline`.
|
||||
|
||||
The caller must verify returned files and reports. Do not treat a subagent/thread message alone as proof of repair.
|
||||
- `inline`: Use for quick mode and small files.
|
||||
- `subagent`: Use when the host supports delegated agents and the batch is large or audit mode is requested.
|
||||
- `thread`: Use when the host supports child/background threads and the user explicitly asks for Thread/sub-session execution.
|
||||
- `auto`: Choose `thread` when explicitly requested, otherwise choose `subagent` for audit or large batches when available, otherwise `inline`.
|
||||
|
||||
## Delegation Prompt
|
||||
|
||||
Send this shape to the isolated worker:
|
||||
For quick mode, send this shape to the isolated worker only when isolation is warranted:
|
||||
|
||||
```text
|
||||
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
|
||||
|
||||
Mode: <discussion|artifact>
|
||||
Output: quick
|
||||
Files:
|
||||
- <absolute-path-1>
|
||||
- <absolute-path-2>
|
||||
|
||||
Rules:
|
||||
- Read and write source files as UTF-8.
|
||||
- Overwrite each source Markdown file in place.
|
||||
- Do not write heading-map, heading-plan, fixed-copy, heading-report, or batch-report files.
|
||||
- Preserve non-heading content.
|
||||
- Apply only deliberate heading-level edits.
|
||||
- Flag ambiguous appended artifacts or uncertain heading relationships instead of silently making creative decisions.
|
||||
- Return changed heading counts and skipped files.
|
||||
```
|
||||
|
||||
For audit mode, send this shape:
|
||||
|
||||
```text
|
||||
Use $fix-title at <skill-path> to repair Markdown heading hierarchy.
|
||||
|
||||
Mode: <discussion|artifact>
|
||||
Output: audit
|
||||
Output directory: <output-dir>
|
||||
Files:
|
||||
- <absolute-path-1>
|
||||
|
|
@ -87,34 +163,6 @@ Use when copied GPT output is stored as a standalone planning/intake/artifact do
|
|||
|
||||
Example: after a root title, raw sibling headings `# 1.xxxx`, `# 2.xxx`, and `## 3.xxx` may all become `##` when they are peer sections.
|
||||
|
||||
## Output Contract
|
||||
|
||||
For each source file, write:
|
||||
|
||||
```text
|
||||
<stem>.heading-map.json
|
||||
<stem>.heading-plan.json
|
||||
<stem>.fixed.md
|
||||
<stem>.heading-report.md
|
||||
```
|
||||
|
||||
For batch runs, also write:
|
||||
|
||||
```text
|
||||
fix-title-batch-report.md
|
||||
```
|
||||
|
||||
The plan JSON is the audit boundary. Every changed heading must include:
|
||||
|
||||
```json
|
||||
{
|
||||
"line": 25,
|
||||
"from_level": 1,
|
||||
"to_level": 3,
|
||||
"reason": "This is a top-level section inside the GPT reply block under ## GPT."
|
||||
}
|
||||
```
|
||||
|
||||
## Internal Helper
|
||||
|
||||
Use `scripts/semantic_heading_repair.py` only as an internal importable helper. It is not a user-facing CLI.
|
||||
|
|
@ -124,13 +172,16 @@ Useful helper API:
|
|||
- `inspect_payload(path)`
|
||||
- `write_inspection(source, output, output_format="json")`
|
||||
- `prepare_batch_scaffold(files, mode, output_dir)`
|
||||
- `apply_edits(source, edits, output, report=None, mode="quick")`
|
||||
- `apply_edits_in_place(source, edits, mode="quick", report=None)`
|
||||
- `apply_plan(source, plan, output, report)`
|
||||
|
||||
The helper protects fenced code blocks, quoted headings, and indented code. It does not make semantic decisions.
|
||||
|
||||
## Repair Rules
|
||||
|
||||
- Never overwrite the source file unless the user explicitly asks for replacement.
|
||||
- Quick mode overwrites the source file by default.
|
||||
- Audit mode never overwrites the source file unless the caller explicitly asks for replacement after reviewing the fixed copy.
|
||||
- Do not mechanically add or subtract the same number of heading levels across a whole file.
|
||||
- Do not change non-heading content.
|
||||
- Do not change headings inside fenced code blocks, block quotes, or indented code.
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
display_name: "Fix Title"
|
||||
short_description: "Agentic repair for Markdown heading trees."
|
||||
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy without overwriting sources."
|
||||
short_description: "Lightweight repair for Markdown heading trees."
|
||||
default_prompt: "Use $fix-title with files=[...] and mode=artifact or mode=discussion to repair Markdown heading hierarchy in place. Ask for output=audit when fixed copies, heading maps, JSON plans, or reports are needed."
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ Recommended files:
|
|||
```text
|
||||
raw-discussion.md
|
||||
raw-artifact.md
|
||||
heading-plan.json
|
||||
audit-heading-plan.json
|
||||
```
|
||||
|
||||
The raw files should preserve original copied LLM/GPT Markdown before heading repair.
|
||||
The raw files should contain disposable copies of original LLM/GPT Markdown before heading repair.
|
||||
|
||||
Invoke the Skill through an agent prompt with `files=[...]`, `mode=discussion|artifact`, and an explicit `output_dir`. Do not overwrite the source during manual validation.
|
||||
Default manual validation should invoke the Skill with `files=[...]` and `mode=discussion|artifact`, then confirm the source copy was repaired in place without sidecar files.
|
||||
|
||||
Use `output=audit` and an explicit `output_dir` only when validating fixed-copy, heading-map, heading-plan, and report generation.
|
||||
|
|
|
|||
|
|
@ -274,14 +274,10 @@ def parse_plan(plan_path: pathlib.Path) -> tuple[str, list[Edit]]:
|
|||
return mode, edits
|
||||
|
||||
|
||||
def apply_plan(
|
||||
source: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None,
|
||||
) -> int:
|
||||
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
mode, edits = parse_plan(plan)
|
||||
def _apply_edits_to_lines(
|
||||
lines: list[str],
|
||||
edits: list[Edit],
|
||||
) -> list[dict[str, Any]]:
|
||||
headings_by_line = {heading.line: heading for heading in iter_headings(lines)}
|
||||
changed: list[dict[str, Any]] = []
|
||||
|
||||
|
|
@ -312,23 +308,63 @@ def apply_plan(
|
|||
}
|
||||
)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def apply_edits(
|
||||
source: pathlib.Path,
|
||||
edits: list[Edit],
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None = None,
|
||||
mode: str = "quick",
|
||||
plan_label: str = "in-memory edits",
|
||||
) -> int:
|
||||
lines = source.read_text(encoding="utf-8").splitlines(keepends=True)
|
||||
changed = _apply_edits_to_lines(lines, edits)
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text("".join(lines), encoding="utf-8")
|
||||
|
||||
if report is not None:
|
||||
report.parent.mkdir(parents=True, exist_ok=True)
|
||||
report.write_text(
|
||||
format_report(source, output, plan, mode, changed),
|
||||
format_report(source, output, plan_label, mode, changed),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return len(changed)
|
||||
|
||||
|
||||
def apply_edits_in_place(
|
||||
source: pathlib.Path,
|
||||
edits: list[Edit],
|
||||
mode: str = "quick",
|
||||
report: pathlib.Path | None = None,
|
||||
) -> int:
|
||||
return apply_edits(
|
||||
source=source,
|
||||
edits=edits,
|
||||
output=source,
|
||||
report=report,
|
||||
mode=mode,
|
||||
plan_label="in-place edits",
|
||||
)
|
||||
|
||||
|
||||
def apply_plan(
|
||||
source: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
report: pathlib.Path | None,
|
||||
) -> int:
|
||||
mode, edits = parse_plan(plan)
|
||||
return apply_edits(source, edits, output, report, mode, str(plan))
|
||||
|
||||
|
||||
def format_report(
|
||||
source: pathlib.Path,
|
||||
output: pathlib.Path,
|
||||
plan: pathlib.Path,
|
||||
plan: pathlib.Path | str,
|
||||
mode: str,
|
||||
changed: list[dict[str, Any]],
|
||||
) -> str:
|
||||
|
|
|
|||
|
|
@ -159,6 +159,35 @@ class SemanticHeadingRepairTest(unittest.TestCase):
|
|||
with self.assertRaisesRegex(ValueError, "expected level 1"):
|
||||
repair.apply_plan(source, plan, pathlib.Path(tmp) / "fixed.md", None)
|
||||
|
||||
def test_apply_edits_in_place_overwrites_source_without_sidecars(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
source = root / "source.md"
|
||||
source.write_text("# Title\n\n# Wrong peer\n", encoding="utf-8")
|
||||
|
||||
changed = repair.apply_edits_in_place(
|
||||
source,
|
||||
[
|
||||
repair.Edit(
|
||||
line=3,
|
||||
from_level=1,
|
||||
to_level=2,
|
||||
reason="Artifact sections after the title are children.",
|
||||
)
|
||||
],
|
||||
mode="artifact",
|
||||
)
|
||||
|
||||
self.assertEqual(changed, 1)
|
||||
self.assertEqual(
|
||||
source.read_text(encoding="utf-8"),
|
||||
"# Title\n\n## Wrong peer\n",
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(path.name for path in root.iterdir()),
|
||||
["source.md"],
|
||||
)
|
||||
|
||||
def test_prepare_batch_scaffold_accepts_file_array(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = pathlib.Path(tmp)
|
||||
|
|
|
|||
|
|
@ -38,8 +38,9 @@ C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pip install -e .\skills\vo
|
|||
Install and authenticate MiniMax `mmx`:
|
||||
|
||||
```powershell
|
||||
npm install -g mmx-cli
|
||||
mmx auth login
|
||||
npm.cmd install -g mmx-cli
|
||||
cmd /c mmx auth login
|
||||
cmd /c mmx auth status
|
||||
```
|
||||
|
||||
## Install Skill Wrapper
|
||||
|
|
@ -60,18 +61,27 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
|||
|
||||
Testing is intentionally separate from migration.
|
||||
|
||||
Run unit tests only after installation is ready:
|
||||
Run source tests from the repository root. Disabling pytest's cache provider avoids writes under a read-only installed Skill directory:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
||||
python -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
||||
conda run -n skills-vault python -B -m pytest .\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation
|
||||
```
|
||||
|
||||
Or without activating the environment:
|
||||
Validate the Skill structure and installed copy separately:
|
||||
|
||||
```powershell
|
||||
New-Item -ItemType Directory -Force -Path .\tmp | Out-Null
|
||||
C:\Users\wangq\.conda\envs\skills-vault\python.exe -m pytest .\skills\voice-generation\tests --basetemp .\tmp\pytest-voice-generation
|
||||
conda run -n skills-vault python -B .\scripts\quick_validate.py .\skills\voice-generation
|
||||
conda run -n skills-vault python -B .\scripts\quick_validate.py C:\Users\wangq\.agents\skills\voice-generation
|
||||
C:\Users\wangq\.conda\envs\skills-vault\python.exe -B -m pytest C:\Users\wangq\.agents\skills\voice-generation\tests -p no:cacheprovider --basetemp .\tmp\pytest-voice-generation-installed
|
||||
```
|
||||
|
||||
Smoke tests may call real MiniMax services and consume quota. Do not run them unless explicitly requested.
|
||||
|
||||
For a real long-form test, create an isolated project under the repository `tmp` directory, populate `voices.json` and `scripts\`, then run from that project root:
|
||||
|
||||
```powershell
|
||||
conda run -n skills-vault voice-gen gen --voice <voice-name> scripts --out output --format mp3
|
||||
```
|
||||
|
||||
The command sends the extracted narration text to MiniMax. Confirm that external transmission is intended before running it.
|
||||
|
|
|
|||
|
|
@ -69,3 +69,25 @@ conda environment: shared skills-vault
|
|||
editable package installed in shared env: yes
|
||||
old dedicated conda env removed: C:\Users\wangq\miniconda3\envs\voice-gen
|
||||
```
|
||||
|
||||
## Post-Migration Windows Hardening
|
||||
|
||||
Validated on 2026-07-10:
|
||||
|
||||
- Resolve the npm `mmx.CMD` shim and execute it through `cmd /c` on Windows.
|
||||
- Pass narration through a temporary UTF-8 file with `--text-file`; do not pass long text through `--text` or use mmx-cli's Windows-incompatible `--text-file -` path.
|
||||
- Remove the temporary text file after success or failure.
|
||||
- Escape non-ASCII path characters in CLI status lines as `\uXXXX` so `conda run` remains safe on GBK consoles; generated files retain their Unicode names.
|
||||
- Force repository tests to import the source tree instead of an older `.agents` installation.
|
||||
|
||||
Verification evidence:
|
||||
|
||||
```text
|
||||
source tests: 40 passed, 1 skipped
|
||||
installed-copy tests: 40 passed, 1 skipped
|
||||
real long-form voice: BroTsong-2026-06-10
|
||||
real input: 2602 extracted characters / 7030 UTF-8 bytes
|
||||
real output: tmp\voice-generation-e2e-20260710-01\output\01.从想法到作品口播文案.mp3
|
||||
audio: 505.152 seconds, 32000 Hz, 128000 bps, 14032 MPEG frames
|
||||
temporary text files after completion: 0
|
||||
```
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ powershell -ExecutionPolicy Bypass -File .\scripts\install-skill.ps1 -Skill voic
|
|||
External requirements:
|
||||
|
||||
- Conda
|
||||
- `mmx` CLI v1.0.16+ (`npm install -g mmx-cli`)
|
||||
- `mmx auth login` once
|
||||
- `mmx` CLI v1.0.16+ (`npm.cmd install -g mmx-cli` on Windows)
|
||||
- `cmd /c mmx auth login` once on Windows
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
|
@ -70,7 +70,9 @@ The CLI wraps MiniMax voice operations:
|
|||
- Uploaded files are registered through the MiniMax voice clone API.
|
||||
- Synthesis uses `mmx speech synthesize`.
|
||||
|
||||
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed.
|
||||
On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed through `cmd /c` where needed. Synthesis text is written to a short-lived UTF-8 file next to the requested output and passed with `--text-file`; the file is removed after each call. This avoids Windows command-line length and quoting limits. It also avoids mmx-cli's current `--text-file -` stdin implementation, which resolves `/dev/stdin` as a drive-local path on Windows.
|
||||
|
||||
CLI status lines escape non-ASCII path characters as ASCII `\uXXXX` sequences so `conda run` remains safe on GBK Windows consoles. The files themselves retain their original Unicode names.
|
||||
|
||||
## Project Layout
|
||||
|
||||
|
|
@ -78,7 +80,6 @@ On Windows, `mmx` is a `.cmd` shim from npm, so subprocess calls are routed thro
|
|||
skills/voice-generation/
|
||||
SKILL.md
|
||||
INSTALL.md
|
||||
environment.yml
|
||||
pyproject.toml
|
||||
src/voice_gen/
|
||||
tests/
|
||||
|
|
@ -96,13 +97,15 @@ output/
|
|||
## Development
|
||||
|
||||
```bash
|
||||
# Inside the skills-vault Conda env
|
||||
python -m pytest
|
||||
# From the repository root
|
||||
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests -p no:cacheprovider --basetemp tmp/pytest-voice-generation
|
||||
|
||||
# Smoke tests may call real MiniMax services and consume quota.
|
||||
python -m pytest --run-smoke
|
||||
conda run -n skills-vault python -B -m pytest skills/voice-generation/tests --run-smoke -p no:cacheprovider --basetemp tmp/pytest-voice-generation-smoke
|
||||
```
|
||||
|
||||
The test configuration forces imports from this repository's `src` tree so a separately installed `.agents/skills` copy cannot make source tests pass accidentally.
|
||||
|
||||
## As An Agentic Skill
|
||||
|
||||
The root `SKILL.md` exposes this tool to Agentic systems. The public installed Skill path is:
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ This Skill operates the local `voice-gen` Python CLI. The CLI wraps MiniMax `mmx
|
|||
|
||||
- Activate the configured Conda environment before running the CLI.
|
||||
- Ensure the `voice-gen` package has been installed in editable mode from the repository source.
|
||||
- Ensure the `mmx` CLI is installed and authenticated with `mmx auth login`.
|
||||
- Ensure the `mmx` CLI is installed and authenticated. On Windows, verify with `cmd /c mmx auth status` to avoid PowerShell shim policy issues.
|
||||
- Run CLI commands from the user's project root unless the user specifies another working directory.
|
||||
|
||||
## Workflow
|
||||
|
|
@ -61,6 +61,8 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
|||
|
||||
- Input scripts are Markdown files matching `*.md`.
|
||||
- Frontmatter, headings, code fences, list markers, bold, italic, and inline code markup are stripped before synthesis.
|
||||
- Synthesis text is passed to mmx through a temporary UTF-8 file, not a command-line `--text` value or `--text-file -`; the temporary file is removed after the call.
|
||||
- Status output escapes non-ASCII path characters as `\uXXXX` sequences for Windows console safety; generated files retain their original Unicode names.
|
||||
- Output files are named `<script-stem>.<format>`.
|
||||
- The local voice registry is `voices.json` in the current working directory.
|
||||
|
||||
|
|
@ -68,7 +70,7 @@ Use `voice-gen voices pull --force` only when overwriting existing local entries
|
|||
|
||||
- If `voice-gen voices list` is empty, ask the user to register or pull voices first.
|
||||
- If a voice is not found, list available voices and ask the user to choose one.
|
||||
- If `mmx` authentication fails, ask the user to run `mmx auth status` and `mmx auth login`.
|
||||
- If `mmx` authentication fails on Windows, ask the user to run `cmd /c mmx auth status` and `cmd /c mmx auth login`.
|
||||
- If quota fails, surface the quota error and suggest waiting for the next quota window or upgrading.
|
||||
- If the request is for music, sound effects, video, image, or non-MiniMax TTS, do not use this Skill.
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ _MMX_CONFIG_PATH = Path.home() / ".mmx" / "config.json"
|
|||
_oauth_token_cache: str | None = None
|
||||
|
||||
|
||||
def _ascii_safe(value: object) -> str:
|
||||
"""Return deterministic ASCII text for status output on Windows consoles."""
|
||||
return str(value).encode("ascii", errors="backslashreplace").decode("ascii")
|
||||
|
||||
|
||||
def _load_oauth_token() -> str:
|
||||
"""Load the OAuth access token from ~/.mmx/config.json (cached)."""
|
||||
global _oauth_token_cache
|
||||
|
|
@ -441,10 +446,13 @@ def cmd_gen(args: argparse.Namespace) -> int:
|
|||
out_path=out_path,
|
||||
format=args.format,
|
||||
)
|
||||
print(f"OK {md.name} -> {out_path}")
|
||||
print(f"OK {_ascii_safe(md.name)} -> {_ascii_safe(out_path)}")
|
||||
successes += 1
|
||||
except Exception as e: # noqa: BLE001 - log and continue
|
||||
print(f"ERROR {md.name}: {e}", file=sys.stderr)
|
||||
print(
|
||||
f"ERROR {_ascii_safe(md.name)}: {_ascii_safe(e)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
failures += 1
|
||||
print(f"done: {successes} ok, {failures} failed")
|
||||
return 0 if failures == 0 else 1
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
|
|
@ -11,7 +12,7 @@ from .errors import MmxError
|
|||
|
||||
|
||||
def _build_cmd(
|
||||
text: str,
|
||||
text_file: Path,
|
||||
voice_id: int | str,
|
||||
out_path: Path,
|
||||
format: str = "mp3",
|
||||
|
|
@ -25,7 +26,7 @@ def _build_cmd(
|
|||
"synthesize",
|
||||
"--non-interactive",
|
||||
"--quiet",
|
||||
"--text", text,
|
||||
"--text-file", str(text_file),
|
||||
"--voice", str(voice_id),
|
||||
"--format", format,
|
||||
"--out", str(out_path),
|
||||
|
|
@ -79,8 +80,22 @@ def synthesize(
|
|||
"""
|
||||
if run is None:
|
||||
run = _default_run
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
suffix=".txt",
|
||||
prefix=".voice-gen-",
|
||||
dir=out_path.parent,
|
||||
delete=False,
|
||||
) as text_handle:
|
||||
text_handle.write(text)
|
||||
text_path = Path(text_handle.name)
|
||||
|
||||
try:
|
||||
cmd = _build_cmd(
|
||||
text=text,
|
||||
text_file=text_path,
|
||||
voice_id=voice_id,
|
||||
out_path=out_path,
|
||||
format=format,
|
||||
|
|
@ -88,7 +103,6 @@ def synthesize(
|
|||
volume=volume,
|
||||
pitch=pitch,
|
||||
)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
result = run(cmd)
|
||||
if result.returncode != 0:
|
||||
raise MmxError(
|
||||
|
|
@ -103,4 +117,6 @@ def synthesize(
|
|||
stderr=f"mmx exited 0 but {out_path} was not created",
|
||||
command=" ".join(cmd),
|
||||
)
|
||||
finally:
|
||||
text_path.unlink(missing_ok=True)
|
||||
return out_path
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SOURCE_ROOT = Path(__file__).resolve().parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
|
||||
def pytest_addoption(parser):
|
||||
parser.addoption("--run-smoke", action="store_true", default=False,
|
||||
help="Run real-mmx smoke tests (consumes quota)")
|
||||
|
|
|
|||
|
|
@ -164,6 +164,41 @@ def test_gen_batch_end_to_end(tmp_path, monkeypatch):
|
|||
assert cmd[i + 1] == "anchor"
|
||||
|
||||
|
||||
def test_gen_status_output_is_ascii_safe(tmp_path, monkeypatch):
|
||||
import io
|
||||
import sys
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
assert main(["init"]) == 0
|
||||
(tmp_path / "scripts" / "中文口播.md").write_text("测试。", encoding="utf-8")
|
||||
(tmp_path / "voices.json").write_text(json.dumps({
|
||||
"voices": {
|
||||
"anchor": {
|
||||
"file_id": 42,
|
||||
"filename": "a.mp3",
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
}
|
||||
}
|
||||
}), encoding="utf-8")
|
||||
|
||||
from voice_gen import synthesizer
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
out.write_bytes(b"audio")
|
||||
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||
|
||||
monkeypatch.setattr(synthesizer, "_default_run", fake_run)
|
||||
buffer = io.BytesIO()
|
||||
ascii_stdout = io.TextIOWrapper(buffer, encoding="ascii", errors="strict")
|
||||
monkeypatch.setattr(sys, "stdout", ascii_stdout)
|
||||
|
||||
assert main(["gen", "--voice", "anchor", "scripts"]) == 0
|
||||
ascii_stdout.flush()
|
||||
output = buffer.getvalue().decode("ascii")
|
||||
assert "\\u4e2d\\u6587\\u53e3\\u64ad.md" in output
|
||||
|
||||
|
||||
def test_voices_add_rejects_duplicate_local_name(tmp_path, monkeypatch):
|
||||
"""Re-adding a file whose stem matches an existing local entry errors out."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
from pathlib import Path
|
||||
|
||||
import voice_gen
|
||||
|
||||
|
||||
def test_tests_import_repository_source():
|
||||
expected = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "src"
|
||||
/ "voice_gen"
|
||||
/ "__init__.py"
|
||||
)
|
||||
assert Path(voice_gen.__file__).resolve() == expected
|
||||
|
|
@ -8,13 +8,14 @@ from voice_gen.errors import MmxError
|
|||
|
||||
def test_build_cmd_minimum():
|
||||
cmd = _build_cmd(
|
||||
text="hi",
|
||||
text_file=Path("input.txt"),
|
||||
voice_id=123,
|
||||
out_path=Path("out.mp3"),
|
||||
format="mp3",
|
||||
)
|
||||
assert cmd[:5] == ["mmx", "speech", "synthesize", "--non-interactive", "--quiet"]
|
||||
assert "--text" in cmd and "hi" in cmd
|
||||
assert "--text-file" in cmd and "input.txt" in cmd
|
||||
assert "--text" not in cmd
|
||||
assert "--voice" in cmd and "123" in cmd
|
||||
assert "--out" in cmd and "out.mp3" in cmd
|
||||
assert "--format" in cmd and "mp3" in cmd
|
||||
|
|
@ -45,6 +46,32 @@ def test_synthesize_invokes_mmx_and_returns_path(tmp_path):
|
|||
assert captured["cmd"][captured["cmd"].index("--voice") + 1] == "999"
|
||||
|
||||
|
||||
def test_synthesize_passes_long_utf8_text_via_temporary_file(tmp_path):
|
||||
text = ("这是一段用于验证 Windows 长命令行安全性的中文口播稿。\n" * 500)[:9000]
|
||||
captured = {}
|
||||
|
||||
def fake_run(cmd, **kwargs):
|
||||
captured["cmd"] = cmd
|
||||
assert "--text" not in cmd
|
||||
text_path = Path(cmd[cmd.index("--text-file") + 1])
|
||||
captured["text_path"] = text_path
|
||||
captured["text"] = text_path.read_text(encoding="utf-8")
|
||||
out = Path(cmd[cmd.index("--out") + 1])
|
||||
out.write_bytes(b"fake-audio")
|
||||
return type("R", (), {"returncode": 0, "stderr": ""})()
|
||||
|
||||
out = synthesize(
|
||||
text=text,
|
||||
voice_id="test-voice",
|
||||
out_path=tmp_path / "long.mp3",
|
||||
run=fake_run,
|
||||
)
|
||||
|
||||
assert out.exists()
|
||||
assert captured["text"] == text
|
||||
assert not captured["text_path"].exists()
|
||||
|
||||
|
||||
def test_synthesize_raises_mmxerror_on_nonzero(tmp_path):
|
||||
def fake_run(cmd, **kwargs):
|
||||
return type("R", (), {"returncode": 3, "stderr": "auth failed"})()
|
||||
|
|
|
|||
Loading…
Reference in New Issue