'))
-```
-
-The examples below uses our most capable image model `gpt-image-2`
-
-## 4. Use Cases — Generate (text → image)
-
-## 4.1 Infographics
-Use infographics to explain structured information for a specific audience: students, executives, customers, or the general public. Examples include explainers, posters, labeled diagrams, timelines, and “visual wiki” assets. For dense layouts or heavy in-image text, it's recommedned to set output generation quality to "high".
-
-```python
-prompt = """
-Create a detailed Infographic of the functioning and flow of an automatic coffee machine like a Jura.
-From bean basket, to grinding, to scale, water tank, boiler, etc.
-I'd like to understand technically and visually the flow.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "infographic_coffee_machine_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.2 Translation in Images
-
-Used for localizing existing designs (ads, UI screenshots, packaging, infographics) into another language without rebuilding the layout from scratch. The key is to preserve everything except the text—keep typography style, placement, spacing, and hierarchy consistent—while translating verbatim and accurately, with no extra words, no reflow unless necessary, and no unintended edits to logos, icons, or imagery.
-
-```python
-prompt = """
-Translate the text in the infographic to Spanish. Do not change any other aspect of the image.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/output_images/infographic_coffee_machine_gpt-image-2.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-
-save_image(result, "infographic_coffee_machine_sp_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.3 Photorealistic Images that Feel “natural”
-
-To get believable photorealism, prompt the model as if a real photo is being captured in the moment. Use photography language (lens, lighting, framing) and explicitly ask for real texture (pores, wrinkles, fabric wear, imperfections). Avoid words that imply studio polish or staging. When detail matters, set quality="high".
-
-```python
-prompt = """
-Create a photorealistic candid photograph of an elderly sailor standing on a small fishing boat.
-He has weathered skin with visible wrinkles, pores, and sun texture, and a few faded traditional sailor tattoos on his arms.
-He is calmly adjusting a net while his dog sits nearby on the deck. Shot like a 35mm film photograph, medium close-up at eye level, using a 50mm lens.
-Soft coastal daylight, shallow depth of field, subtle film grain, natural color balance.
-The image should feel honest and unposed, with real skin texture, worn materials, and everyday detail. No glamorization, no heavy retouching.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "photorealism-gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.4 World knowledge
-
-GPT image generation models can pair strong reasoning with world knowledge. For example, when asked to generate a scene set in Bethel, New York in August 1969, they can infer Woodstock and produce an accurate, context-appropriate image without being explicitly told about the event.
-
-```python
-prompt = """
-Create a realistic outdoor crowd scene in Bethel, New York on August 16, 1969.
-Photorealistic, period-accurate clothing, staging, and environment.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "world_knowledge-gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.5 Logo Generation
-
-Strong logo generation comes from clear brand constraints and simplicity. Describe the brand’s personality and use case, then ask for a clean, original mark with strong shape, balanced negative space, and scalability across sizes.
-
-You can specify parameter "n" to denote the number of variations you would like to generate.
-
-```python
-prompt = """
-Create an original, non-infringing logo for a company called Field & Flour, a local bakery.
-The logo should feel warm, simple, and timeless. Use clean, vector-like shapes, a strong silhouette, and balanced negative space.
-Favor simplicity over detail so it reads clearly at small and large sizes. Flat design, minimal strokes, no gradients unless essential.
-Plain background. Deliver a single centered logo with generous padding. No watermark.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
- n=4 # Generate 4 versions of the logo
-)
-
-# Save all 4 images to separate files
-for i, item in enumerate(result.data, start=1):
- image_base64 = item.b64_json
- image_bytes = base64.b64decode(image_base64)
- with open(f"../../images/output_images/logo_generation_{i}_gpt-image-2.png", "wb") as f:
- f.write(image_bytes)
-```
-
-Output Images:
-
-| Option 1 | Option 2 | Option 3 | Option 4 |
-|:--------:|:--------:|:--------:|:--------:|
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_|
-
-## 4.6 Ads Generation
-
-Ad generation works best when the prompt is written like a creative brief rather than a purely technical image spec. Describe the brand, audience, culture, concept, composition, and exact copy, then let the model make taste-driven creative decisions inside those boundaries. This is useful for early campaign exploration because the model can interpret audience cues, infer art direction, and propose visual details that make the ad feel considered rather than merely rendered.
-
-For stronger results, include the brand positioning, desired vibe, target audience, scene, and tagline in the same prompt. If the text must appear in the image, quote it exactly and ask for clean, legible typography.
-
-```python
-prompt = """
-Give me a cool in culture ad / fashion shot for a brand called Thread.
-It's a hip young street brand. The ad shows a group of friends hanging out together with the tagline "Yours to Create."
-Make it feel like a polished campaign image for a youth streetwear audience: stylish, contemporary, energetic, and tasteful.
-Use clean composition, strong color direction, natural poses, and premium fashion photography cues.
-Render the tagline exactly once, clearly and legibly, integrated into the ad layout.
-No extra text, no watermarks, no unrelated logos.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "thread_ad_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.7 Story-to-Comic Strip
-
-For story-to-comic generation, define the narrative as a sequence of clear visual beats, one per panel. Keep descriptions concrete and action-focused so the model can translate the story into readable, well-paced panels.
-
-```python
-prompt = """
-Create a short vertical comic-style reel with 4 equal-sized panels.
-Panel 1: The owner leaves through the front door. The pet is framed in the window behind them, small against the glass, eyes wide, paws pressed high, the house suddenly quiet.
-Panel 2: The door clicks shut. Silence breaks. The pet slowly turns toward the empty house, posture shifting, eyes sharp with possibility.
-Panel 3: The house transformed. The pet sprawls across the couch like it owns the place, crumbs nearby, sunlight cutting across the room like a spotlight.
-Panel 4: The door opens. The pet is seated perfectly by the entrance, alert and composed, as if nothing happened.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "comic_reel-gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.8 UI Mockups
-
-UI mockups work best when you describe the product as if it already exists. Focus on layout, hierarchy, spacing, and real interface elements, and avoid concept art language so the result looks like a usable, shipped interface rather than a design sketch.
-
-```python
-prompt = """
-Create a realistic mobile app UI mockup for a local farmers market.
-Show today’s market with a simple header, a short list of vendors with small photos and categories, a small “Today’s specials” section, and basic information for location and hours.
-Design it to be practical, and easy to use. White background, subtle natural accent colors, clear typography, and minimal decoration.
-It should look like a real, well-designed, beautiful app for a small local market.
-Place the UI mockup in an iPhone frame.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "ui_farmers_market_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.9 Scientific / Educational Visuals
-
-Scientific and educational visuals are strong fits for biology, chemistry, classroom explainers, flat scientific icon systems, diagrams, and learning assets. Prompt them like an instructional design brief: define the audience, lesson objective, visual format, required labels, and scientific constraints. For best results, ask for a clean, flat visual system with consistent icon style, clear arrows, readable labels, and enough white space for students to scan the concept quickly.
-
-When accuracy matters, list the required components explicitly and say what should not be included. Use `quality="high"` for dense labels, diagrams, or assets that will be used in slides or course materials.
-
-```python
-prompt = """
-Create a simple biology diagram titled "Cellular Respiration at a Glance" for high school students.
-
-Show how glucose turns into energy inside a cell. Include glycolysis, the Krebs cycle, and the electron transport chain.
-Use arrows to connect the steps, and label the main molecules: glucose, pyruvate, ATP, NADH, FADH2, CO2, O2, and H2O.
-Make it look like a clean classroom handout or slide, with a white background, simple icons, clear labels, and easy-to-read text.
-
-Avoid tiny text, extra decoration, or anything that makes the diagram hard to understand.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1536x1024",
- quality="high",
-)
-
-save_image(result, "scientific_educational_cellular_respiration_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 4.10 Slides, Diagrams, Charts, and Productivity Images
-
-Productivity visuals work best when the prompt is written like an artifact spec rather than an illustration request. Name the exact deliverable (slide, workflow diagram, chart, page image), define the canvas and hierarchy, provide the real text or data, and describe the visual language. These prompts should include practical constraints: readable typography, polished spacing, no decorative clutter, and no generic stock-photo treatment.
-
-For slides, charts, and diagram-heavy assets, include the numbers and labels directly in the prompt. Use a landscape size for deck-style outputs and `quality="high"` when the image contains small text, legends, axes, or footnotes.
-
-```python
-prompt = """
-Create one pitch-deck slide titled **"Market Opportunity"** that feels like a real Series A fundraising slide from a YC-backed startup.
-
-Use a clean white background, modern sans-serif typography like Inter, and a crisp, minimal layout. The slide should include:
-
-* A TAM/SAM/SOM concentric-circle diagram in muted blues and grays
-* Specific, believable market sizing numbers:
-
- * **TAM:** $42B
- * **SAM:** $8.7B
- * **SOM:** $340M
-* A clean bar chart below showing market growth from **2021 to 2026**, with a subtle upward trend
-* Small footnotes: **"AGI Research, 2024"** and **"Internal analysis"**
-* A company logo placeholder in the bottom-right corner
-
-The design should look like it belongs in a deck that actually raised money: highly readable text, clear data hierarchy, polished spacing, and professional startup-style visual language.
-
-Avoid clip art, stock photography, gradients, shadows, decorative elements, or anything that feels generic or overdesigned.
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1536x864",
- quality="high",
-)
-
-save_image(result, "market_opportunity_slide_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5. Use cases — Edit (text + image → image)
-
-## 5.1 Style Transfer
-
-Style transfer is useful when you want to keep the *visual language* of a reference image (palette, texture, brushwork, film grain, etc.) while changing the subject or scene. For best results, describe what must stay consistent (style cues) and what must change (new content), and add hard constraints like background, framing, and “no extra elements” to prevent drift.
-
-```python
-prompt = """
-Use the same style from the input image and generate a man riding a motorcycle on a white background.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/pixels.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "motorcycle_gpt-image-2.png")
-```
-
-Input Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.2 Virtual Clothing Try-On
-
-Virtual try-on is ideal for ecommerce previews where identity preservation is critical. The key is to explicitly lock the person (face, body shape, pose, hair, expression) and allow changes *only* to garments, then require realistic fit (draping, folds, occlusion) plus consistent lighting/shadows so the outfit looks naturally worn—not pasted on.
-
-```python
-prompt = """
-Edit the image to dress the woman using the provided clothing images. Do not change her face, facial features, skin tone, body shape, pose, or identity in any way. Preserve her exact likeness, expression, hairstyle, and proportions. Replace only the clothing, fitting the garments naturally to her existing pose and body geometry with realistic fabric behavior. Match lighting, shadows, and color temperature to the original photo so the outfit integrates photorealistically, without looking pasted on. Do not change the background, camera angle, framing, or image quality, and do not add accessories, text, logos, or watermarks.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/woman_in_museum.png", "rb"),
- open("../../images/input_images/tank_top.png", "rb"),
- open("../../images/input_images/jacket.png", "rb"),
- open("../../images/input_images/tank_top.png", "rb"),
- open("../../images/input_images/boots.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "outfit_gpt-image-2.png")
-```
-
-Input Images:
-
-| Full Body | Item 1 |
-|:------------:|:--------------:|
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ |
-| Item 2 | Item 3 |
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ |
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.3 Drawing → Image (Rendering)
-
-Sketch-to-render workflows are great for turning rough drawings into photorealistic concepts while keeping the original intent. Treat the prompt like a spec: preserve layout and perspective, then *add realism* by specifying plausible materials, lighting, and environment. Include “do not add new elements/text” to avoid creative reinterpretations.
-
-```python
-prompt = """
-Turn this drawing into a photorealistic image.
-Preserve the exact layout, proportions, and perspective.
-Choose realistic materials and lighting consistent with the sketch intent.
-Do not add new elements or text.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/drawings.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "realistic_valley_gpt-image-2.png")
-```
-
-Input Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.4 Product Mockups (clean background + label integrity)
-
-Product extraction and mockup prep is commonly used for catalogs, marketplaces, and design systems. Success depends on edge quality (clean silhouette, no fringing/halos) and label integrity (text stays sharp and unchanged). For `gpt-image-2`, keep the output background opaque and use a downstream background-removal step if you need a final transparent asset. If you want realism without re-styling, ask for only light polishing and optionally a subtle contact shadow on a plain background.
-
-```python
-prompt = """
-Extract the product from the input image and place it on a plain white opaque background.
-Output: centered product, crisp silhouette, no halos/fringing.
-Preserve product geometry and label legibility exactly.
-Add only light polishing and a subtle realistic contact shadow.
-Do not restyle the product; only remove background and lightly polish.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/shampoo.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
- background="opaque",
-)
-
-save_image(result, "extract_product_gpt-image-2.png")
-```
-
-Input Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.5 Marketing Creatives with Real Text In-Image
-
-Marketing creatives with real in-image text are great for rapid ad concepting, but typography needs explicit constraints. Put the exact copy in quotes, demand verbatim rendering (no extra characters), and describe placement and font style. If text fidelity is imperfect, keep the prompt strict and iterate—small wording/layout tweaks usually improve legibility.
-
-```python
-prompt = """
-Create a realistic billboard mockup of the shampoo on a highway scene during sunset.
-Billboard text (EXACT, verbatim, no extra characters):
-"Fresh and clean"
-Typography: bold sans-serif, high contrast, centered, clean kerning.
-Ensure text appears once and is perfectly legible.
-No watermarks, no logos.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/shampoo.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "billboard_gpt-image-2.png")
-```
-
-Input Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.6 Lighting and Weather Transformation
-
-Used to re-stage a photo for different moods, seasons, or time-of-day variants (e.g., sunny → overcast, daytime → dusk, clear → snowy) while keeping the scene composition intact. The key is to change only environmental conditions—lighting direction/quality, shadows, atmosphere, precipitation, and ground wetness—while preserving identity, geometry, camera angle, and object placement so it still reads as the same original photo.
-
-```python
-prompt = """
-Make it look like a winter evening with snowfall.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- input_fidelity="high",
- image=[
- open("../../images/output_images/billboard_gpt-image-2.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "billboard_winter_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 5.7 Object Removal
-
-Person-in-scene compositing is useful for storyboards, campaigns, and “what if” scenarios where facial/identity preservation matters. Anchor realism by specifying a grounded photographic look (natural lighting, believable detail, no cinematic grading), and lock what must not change about the subject. When available, higher input fidelity helps maintain likeness during larger scene edits.
-
-```python
-prompt = """
-Remove the flower from man's hand. Do not change anything else.
-"""
-
-
-result = client.images.edit(
- model="gpt-image-2",
- input_fidelity="high",
- image=[
- open("../../images/output_images/man_with_blue_hat.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "man_with_no_flower_gpt-image-2.png")
-```
-
-Input and output images:
-
-| Original Input | Output Image |
-|:------------:|:--------------:|
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ |
-
-## 5.8 Insert the Person Into a Scene
-
-Person-in-scene compositing is useful for storyboards, campaigns, and “what if” scenarios where facial/identity preservation matters. Anchor realism by specifying a grounded photographic look (natural lighting, believable detail, no cinematic grading), and lock what must not change about the subject. When available, higher input fidelity helps maintain likeness during larger scene edits.
-
-```python
-prompt = """
-Generate a highly realistic action scene where this person is running away from a large, realistic brown bear attacking a campsite. The image should look like a real photograph someone could have taken, not an overly enhanced or cinematic movie-poster image.
-She is centered in the image but looking away from the camera, wearing outdoorsy camping attire, with dirt on her face and tears in her clothing. She is clearly afraid but focused on escaping, running away from the bear as it destroys the campsite behind her.
-The campsite is in Yosemite National Park, with believable natural details. The time of day is dusk, with natural lighting and realistic colors. Everything should feel grounded, authentic, and unstyled, as if captured in a real moment. Avoid cinematic lighting, dramatic color grading, or stylized composition.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- input_fidelity="high",
- image=[
- open("../../images/input_images/woman_in_museum.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "scene_gpt-image-2.png")
-```
-
-Output Image:
-
-```python
-from IPython.display import Image, display
-display(Image(filename="../../images/output_images/scene_gpt-image-2.png", width=500))
-```
-
-## 5.9 Multi-Image Referencing and Compositing
-
-Used to combine elements from multiple inputs into a single, believable image—great for “insert this object/person into that scene” workflows without re-generating everything. The key is to clearly specify what to transplant (the dog from image 2), where it should go (right next to the woman in image 1), and what must remain unchanged (scene, background, framing), while matching lighting, perspective, scale, and shadows so the composite looks naturally captured in the original photo.
-
-```python
-prompt = """
-Place the dog from the second image into the setting of image 1, right next to the woman, use the same style of lighting, composition and background. Do not change anything else.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- input_fidelity="high",
- image=[
- open("../../images/output_images/test_woman.png", "rb"),
- open("../../images/output_images/test_woman_2.png", "rb"),
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-
-save_image(result, "test_woman_with_dog_gpt-image-2.png")
-```
-
-Input and output images:
-
-| Original Input | Remove Red Stripes | Change Hat Color |
-|:--------------:|:------------------:|:----------------:|
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ |
-
-## 6. Additional High-Value Use Cases
-
-## 6.1 Interior design “swap” (precision edits)
-Used for visualizing furniture or decor changes in real spaces without re-rendering the entire scene. The goal is surgical realism: swap a single object while preserving camera angle, lighting, shadows, and surrounding context so the edit looks like a real photograph, not a redesign.
-
-```python
-prompt = """
-In this room photo, replace ONLY white with chairs made of wood.
-Preserve camera angle, room lighting, floor shadows, and surrounding objects.
-Keep all other aspects of the image unchanged.
-Photorealistic contact shadows and fabric texture.
-"""
-
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/input_images/kitchen.jpeg", "rb"),
- ],
- prompt=prompt,
- size="1536x1024",
- quality="medium",
-)
-
-save_image(result, "kitchen-chairs_gpt-image-2.png")
-```
-
-Input and output images:
-
-| Input Image | Output Image |
-|------------|--------------|
-| > _[OpenAI cookbook rendered output — see original notebook for the image]_ | > _[OpenAI cookbook rendered output — see original notebook for the image]_ |
-
-## 6.2 3D pop-up holiday card (product-style mock)
-Ideal for seasonal marketing concepts and print previews. Emphasizes tactile realism—paper layers, fibers, folds, and soft studio lighting—so the result reads as a photographed physical product rather than a flat illustration.
-
-```python
-scene_description = (
- "a cozy Christmas scene with an old teddy bear sitting inside a keepsake box, "
- "slightly worn fur, soft stitching repairs, placed near a window with falling snow outside. "
- "The scene suggests the child has grown up, but the memories remain."
-)
-
-short_copy = "Merry Christmas — some memories never fade."
-
-prompt = f"""
-Create a Christmas holiday card illustration.
-
-Scene:
-{scene_description}
-
-Mood:
-Warm, nostalgic, gentle, emotional.
-
-Style:
-Premium holiday card photography, soft cinematic lighting,
-realistic textures, shallow depth of field,
-tasteful bokeh lights, high print-quality composition.
-
-Constraints:
-- Original artwork only
-- No trademarks
-- No watermarks
-- No logos
-
-Include ONLY this card text (verbatim):
-"{short_copy}"
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "christmas_holiday_card_teddy_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 6.3 Collectible Action Figure / Plush Keychain (merch concept)
-
-Used for early merch ideation and pitch visuals. Focuses on premium product photography cues (materials, packaging, print clarity) while keeping designs original and non-infringing. Works well for testing multiple character or packaging variants quickly.
-
-```python
-# ---- Inputs ----
-character_description = (
- "a vintage-style toy propeller airplane with rounded wings, "
- "a front-mounted spinning propeller, slightly worn paint edges, "
- "classic childhood proportions, designed as a nostalgic holiday collectible"
-)
-
-short_copy = "Christmas Memories Edition"
-
-# ---- Prompt ----
-prompt = f"""
-Create a collectible action figure of {character_description}, in blister packaging.
-
-Concept:
-A nostalgic holiday collectible inspired by the simple toy airplanes
-children used to play with during winter holidays.
-Evokes warmth, imagination, and childhood wonder.
-
-Style:
-Premium toy photography, realistic plastic and painted metal textures,
-studio lighting, shallow depth of field,
-sharp label printing, high-end retail presentation.
-
-Constraints:
-- Original design only
-- No trademarks
-- No watermarks
-- No logos
-
-Include ONLY this packaging text (verbatim):
-"{short_copy}"
-"""
-
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "christmas_collectible_toy_airplane_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## 6.4 Children’s Book Art with Character Consistency (multi-image workflow)
-Designed for multi-page illustration pipelines where character drift is unacceptable. A reusable “character anchor” ensures visual continuity across scenes, poses, and pages while allowing environmental and narrative variation.
-
-1️⃣ Character Anchor — establish the reusable main character
-
-Goal: Lock the character’s appearance, proportions, outfit, and tone.
-
-```python
-# ---- Inputs ----
-prompt = """
-Create a children’s book illustration introducing a main character.
-
-Character:
-A young, storybook-style hero inspired by a little forest outlaw,
-wearing a simple green hooded tunic, soft brown boots, and a small belt pouch.
-The character has a kind expression, gentle eyes, and a brave but warm demeanor.
-Carries a small wooden bow used only for helping, never harming.
-
-Theme:
-The character protects and rescues small forest animals like squirrels, birds, and rabbits.
-
-Style:
-Children’s book illustration, hand-painted watercolor look,
-soft outlines, warm earthy colors, whimsical and friendly.
-Proportions suitable for picture books (slightly oversized head, expressive face).
-
-Constraints:
-- Original character (no copyrighted characters)
-- No text
-- No watermarks
-- Plain forest background to clearly showcase the character
-"""
-
-# ---- Image generation ----
-result = client.images.generate(
- model="gpt-image-2",
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "childrens_book_illustration_1_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-2️⃣ Story continuation — reuse character, advance the narrative
-
-Goal: Same character, new scene + action.
-Character appearance must remain unchanged.
-
-```python
-# ---- Inputs ----
-prompt = """
-Continue the children’s book story using the same character.
-
-Scene:
-The same young forest hero is gently helping a frightened squirrel
-out of a fallen tree after a winter storm.
-The character kneels beside the squirrel, offering reassurance.
-
-Character Consistency:
-- Same green hooded tunic
-- Same facial features, proportions, and color palette
-- Same gentle, heroic personality
-
-Style:
-Children’s book watercolor illustration,
-soft lighting, snowy forest environment,
-warm and comforting mood.
-
-Constraints:
-- Do not redesign the character
-- No text
-- No watermarks
-"""
-# ---- Image generation ----
-result = client.images.edit(
- model="gpt-image-2",
- image=[
- open("../../images/output_images/childrens_book_illustration_1_gpt-image-2.png", "rb"), # use image from step 1
- ],
- prompt=prompt,
- size="1024x1536",
- quality="medium",
-)
-
-save_image(result, "childrens_book_illustration_2_gpt-image-2.png")
-```
-
-Output Image:
-
-> _[OpenAI cookbook rendered output — see original notebook for the image]_
-
-## Conclusion
-
-In this notebook, we demonstrate how to use gpt-image generation models to build high-quality, controllable image generation and editing workflows that hold up in real production settings. The cookbook emphasizes prompt structure, explicit constraints, and small iterative changes as the primary tools for controlling realism, layout, text accuracy, and identity preservation. We cover both generation and editing patterns, ranging from infographics, photorealism, UI mockups, and logos to translation, style transfer, virtual try-on, compositing, and lighting changes. Throughout the examples, the cookbook reinforces the importance of clearly separating what should change from what must remain invariant, and of restating those invariants on every iteration to prevent drift. We also highlight how quality and input-fidelity settings enable deliberate tradeoffs between latency and visual precision depending on the use case. Together, these examples form a practical, repeatable playbook for applying gpt-image generation models in production image workflows.
-
----
-
-## Parameter coverage cross-check (for gpt-image-cli maintainers)
-
-Parameters used in the cookbook notebook:
-
-| Notebook parameter | Used in | CLI flag | Exposed? |
-|---|---|---|---|
-| `model` | both | `--model` | ✅ |
-| `prompt` | both | `-p` / `--prompt` | ✅ |
-| `size` | both | `--size` | ✅ |
-| `quality` | both | `--quality` | ✅ |
-| `n` | both | `-n` / `--n` | ✅ |
-| `background` | generations + edits | `--background` | ✅ |
-| `moderation` | generations | `--moderation` | ✅ |
-| `output_format` | both | `--format` | ✅ |
-| `output_compression` | both | `--compression` | ✅ |
-| `input_fidelity` | edits | `--input-fidelity` | ✅ (ignored by gpt-image-2) |
-| `image` | edits | `-i` / `--image` | ✅ |
-| `mask` | edits | `-m` / `--mask` | ✅ |
-| `user` | both | `--user` | ✅ |
-
-**All cookbook parameters covered by `gpt-image-cli` v1.3+.** No gaps.
-
----
-
-*Captured from the official OpenAI cookbook for local skill reference. See the source URL above for the living document.*
+- [OpenAI Images 2.5 announcement](https://openai.com/index/introducing-chatgpt-images-2-5/)
+- [Original Cookbook notebook](https://github.com/openai/openai-cookbook/blob/main/examples/multimodal/image-gen-models-prompting-guide.ipynb), previously mirrored under its MIT license.
+- [Original community skill](https://github.com/wuyoscar/GPT-Image2-Skill/blob/main/skills/gpt-image/SKILL.md), whose current routing distinguishes gallery-heavy Image 2 workflows from targeted reference use for 2.5. Community patterns remain examples; this skill's execution uses EAPIL.
diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py
index 170f8ddd..bb9614a4 100644
--- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py
+++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/scripts/eapil_gpt2_image.py
@@ -9,6 +9,7 @@ from __future__ import annotations
import argparse
import base64
+import binascii
import json
import mimetypes
import os
@@ -49,6 +50,41 @@ def compact(values: dict[str, Any]) -> dict[str, Any]:
return {key: value for key, value in values.items() if value is not None}
+def resolve_prompt(args: argparse.Namespace) -> str:
+ prompt_file = getattr(args, "prompt_file", None)
+ if prompt_file is not None:
+ try:
+ with Path(prompt_file).open(encoding="utf-8-sig", newline="") as source:
+ prompt = source.read()
+ except (OSError, UnicodeError) as err:
+ fail(f"could not read UTF-8 prompt file: {err}")
+ else:
+ prompt = args.prompt
+ if not isinstance(prompt, str) or not prompt.strip():
+ fail("prompt must not be blank")
+ return prompt
+
+
+def positive_integer(value: str) -> int:
+ try:
+ number = int(value)
+ except ValueError:
+ raise argparse.ArgumentTypeError("must be a positive integer") from None
+ if number < 1:
+ raise argparse.ArgumentTypeError("must be a positive integer")
+ return number
+
+
+def compression_percentage(value: str) -> int:
+ try:
+ number = int(value)
+ except ValueError:
+ raise argparse.ArgumentTypeError("must be an integer from 0 to 100") from None
+ if not 0 <= number <= 100:
+ raise argparse.ArgumentTypeError("must be an integer from 0 to 100")
+ return number
+
+
def output_path(value: str | None, default_name: str) -> Path:
path = Path(value or default_name)
if not path.is_absolute():
@@ -138,19 +174,50 @@ def read_json_response(request: urllib.request.Request) -> dict[str, Any]:
fail(f"response was not JSON: {err}")
+def decode_image_base64(value: str, expected_type: str | None = None) -> bytes:
+ """Validate base64 and a supported image signature, not the full image encoding."""
+ try:
+ image_bytes = base64.b64decode(value, validate=True)
+ except (binascii.Error, ValueError):
+ fail("image response contained malformed base64")
+ if not image_bytes:
+ fail("image response contained empty image data")
+ if image_bytes.startswith(b"\x89PNG\r\n\x1a\n"):
+ image_type = "image/png"
+ elif image_bytes.startswith(b"\xff\xd8\xff"):
+ image_type = "image/jpeg"
+ elif (
+ len(image_bytes) >= 12
+ and image_bytes[:4] == b"RIFF"
+ and image_bytes[8:12] == b"WEBP"
+ ):
+ image_type = "image/webp"
+ else:
+ fail("image response did not have a PNG, JPEG, or WebP signature")
+ if expected_type is not None and image_type != expected_type:
+ fail("image data URL type did not match its image signature")
+ return image_bytes
+
+
def extract_image_bytes_from_item(item: Any) -> bytes:
if not isinstance(item, dict):
fail("response data item was not an object")
b64_value = item.get("b64_json")
- if isinstance(b64_value, str) and b64_value.strip():
- return base64.b64decode(b64_value)
+ if isinstance(b64_value, str):
+ return decode_image_base64(b64_value)
url_value = item.get("url")
if isinstance(url_value, str) and url_value.startswith("data:"):
- _, _, payload = url_value.partition(",")
- if payload:
- return base64.b64decode(payload)
+ header, separator, payload = url_value.partition(",")
+ parts = header[5:].lower().split(";")
+ if (
+ not separator
+ or parts[0] not in ("image/png", "image/jpeg", "image/webp")
+ or parts[-1] != "base64"
+ ):
+ fail("response contained an unsupported image data URL")
+ return decode_image_base64(payload, parts[0])
fail("response item did not contain b64_json or a data URL")
@@ -172,13 +239,13 @@ def save_response_images(response: dict[str, Any], path: Path, n: int) -> list[P
if not isinstance(data, list) or not data:
fail("response did not contain data")
+ images = [extract_image_bytes_from_item(item) for item in data]
written: list[Path] = []
byte_counts: list[int] = []
use_suffix = n != 1 or len(data) != 1
path.parent.mkdir(parents=True, exist_ok=True)
- for index, item in enumerate(data):
- image_bytes = extract_image_bytes_from_item(item)
+ for index, image_bytes in enumerate(images):
target = numbered_output_path(path, index) if use_suffix else path
target.write_bytes(image_bytes)
written.append(target)
@@ -202,11 +269,12 @@ def save_response_image(response: dict[str, Any], path: Path) -> None:
def command_generate(args: argparse.Namespace) -> None:
+ prompt = resolve_prompt(args)
key = resolve_api_key(args.api_key)
payload = compact(
{
"model": MODEL,
- "prompt": args.prompt,
+ "prompt": prompt,
"size": args.size,
"quality": args.quality,
"n": args.n,
@@ -224,11 +292,12 @@ def command_generate(args: argparse.Namespace) -> None:
def command_edit(args: argparse.Namespace) -> None:
+ prompt = resolve_prompt(args)
key = resolve_api_key(args.api_key)
fields = compact(
{
"model": MODEL,
- "prompt": args.prompt,
+ "prompt": prompt,
"size": args.size,
"quality": args.quality,
"n": args.n,
@@ -250,18 +319,21 @@ def command_edit(args: argparse.Namespace) -> None:
def add_common_options(
command: argparse.ArgumentParser, *, include_moderation: bool = False
) -> None:
+ prompts = command.add_mutually_exclusive_group(required=True)
+ prompts.add_argument("--prompt", help="Exact prompt text.")
+ prompts.add_argument("--prompt-file", help="UTF-8 prompt file; an optional BOM is accepted.")
command.add_argument(
"--api-key",
help="Temporary API key. Prefer EAPIL_API_KEY for repeated use; OPENAI_API_KEY is also accepted.",
)
command.add_argument("--size", default="1024x1024")
command.add_argument("--quality", choices=["auto", "low", "medium", "high"])
- command.add_argument("--n", type=int, default=1)
+ command.add_argument("--n", type=positive_integer, default=1)
command.add_argument("--background")
if include_moderation:
command.add_argument("--moderation")
command.add_argument("--output-format", choices=["png", "jpeg", "webp"])
- command.add_argument("--output-compression", type=int)
+ command.add_argument("--output-compression", type=compression_percentage)
command.add_argument("--user")
@@ -272,13 +344,11 @@ def parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers(dest="command", required=True)
generate = subparsers.add_parser("generate", help="generate a new image")
- generate.add_argument("--prompt", required=True)
generate.add_argument("--output")
add_common_options(generate, include_moderation=True)
generate.set_defaults(func=command_generate)
edit = subparsers.add_parser("edit", help="edit an existing image")
- edit.add_argument("--prompt", required=True)
edit.add_argument("--image", action="append", required=True)
edit.add_argument("--mask")
edit.add_argument("--output")
diff --git a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py
index 77d88155..e36f94ad 100644
--- a/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py
+++ b/plugins/codex/plugins/eapil-gpt2-image/skills/eapil-gpt2-image/tests/test_eapil_gpt2_image.py
@@ -4,17 +4,24 @@ import base64
import contextlib
import importlib.util
import io
+import json
import os
import tempfile
import unittest
+from email import policy
+from email.parser import BytesParser
from pathlib import Path
-from types import SimpleNamespace
from unittest import mock
-
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "eapil_gpt2_image.py"
-SKILL = ROOT / "SKILL.md"
+# Complete 1x1 PNG. Tests never make an image-generation request.
+PNG_BASE64 = (
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8"
+ "/x8AAwMCAO+jRZkAAAAASUVORK5CYII="
+)
+PNG = base64.b64decode(PNG_BASE64)
+PROMPT = '海报文字:"春日茶会"\r\nKeep "TEA & CO."; literal $HOME, `code`, \\path.\n'
def load_module():
@@ -28,109 +35,228 @@ def load_module():
class EapilGpt2ImageTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
+ self.network = mock.patch.object(self.module.urllib.request, "urlopen")
+ self.urlopen = self.network.start()
+ self.addCleanup(self.network.stop)
+ self.urlopen.side_effect = AssertionError("unexpected network call")
- def test_api_key_prefers_cli_key_then_eapil_then_openai(self):
- with mock.patch.dict(
- os.environ,
- {"EAPIL_API_KEY": "eapil", "OPENAI_API_KEY": "openai"},
- clear=True,
- ):
- self.assertEqual(self.module.resolve_api_key("direct"), "direct")
- self.assertEqual(self.module.resolve_api_key(None), "eapil")
+ def response(self):
+ self.urlopen.side_effect = None
+ self.urlopen.return_value = io.BytesIO(json.dumps({
+ "data": [{"b64_json": PNG_BASE64}]
+ }).encode("utf-8"))
+
+ def run_command(self, argv):
+ args = self.module.parser().parse_args(argv)
+ with contextlib.redirect_stdout(io.StringIO()):
+ args.func(args)
+
+ def assert_fails(self, operation):
+ with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
+ operation()
+
+ def test_api_key_priority_and_whitespace(self):
+ with mock.patch.dict(os.environ, {
+ "EAPIL_API_KEY": " eapil ", "OPENAI_API_KEY": "openai"
+ }, clear=True):
+ self.assertEqual(self.module.resolve_api_key(" direct "), "direct")
+ self.assertEqual(self.module.resolve_api_key(" "), "eapil")
with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "openai"}, clear=True):
self.assertEqual(self.module.resolve_api_key(None), "openai")
- def test_missing_api_key_mentions_all_supported_sources(self):
+ def test_missing_key_reports_supported_sources(self):
with mock.patch.dict(os.environ, {}, clear=True):
stderr = io.StringIO()
with contextlib.redirect_stderr(stderr), self.assertRaises(SystemExit):
self.module.resolve_api_key(None)
- message = stderr.getvalue()
- self.assertIn("--api-key", message)
- self.assertIn("EAPIL_API_KEY", message)
- self.assertIn("OPENAI_API_KEY", message)
+ for source in ("--api-key", "EAPIL_API_KEY", "OPENAI_API_KEY"):
+ self.assertIn(source, stderr.getvalue())
- def test_generation_payload_omits_absent_optional_values_and_keeps_supplied_values(self):
- captured = {}
+ def test_json_request_preserves_file_prompt_size_and_omits_absent_options(self):
+ for bom in (b"", b"\xef\xbb\xbf"):
+ with self.subTest(bom=bool(bom)), tempfile.TemporaryDirectory() as tmp:
+ prompt_file = Path(tmp) / "prompt.txt"
+ prompt_file.write_bytes(bom + PROMPT.encode("utf-8"))
+ output = Path(tmp) / "result.png"
+ self.response()
+ self.run_command([
+ "generate", "--prompt-file", str(prompt_file), "--api-key", "direct",
+ "--size", "1024x1536", "--output", str(output),
+ ])
+ request = self.urlopen.call_args.args[0]
+ self.assertEqual(request.full_url, self.module.BASE_URL + "/images/generations")
+ self.assertEqual(request.get_method(), "POST")
+ self.assertEqual(request.get_header("Content-type"), "application/json")
+ self.assertEqual(request.get_header("Authorization"), "Bearer direct")
+ self.assertEqual(json.loads(request.data), {
+ "model": "gpt-image-2", "prompt": PROMPT, "size": "1024x1536",
+ "n": 1, "response_format": "b64_json",
+ })
+ self.assertEqual(output.read_bytes(), PNG)
- def fake_request_json(path, payload, key):
- captured["path"] = path
- captured["payload"] = payload
- captured["key"] = key
- return {
- "data": [{"b64_json": base64.b64encode(b"png").decode("ascii")}]
+ def test_generation_supplied_options_reach_wire_payload(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ self.response()
+ self.run_command([
+ "generate", "--prompt", PROMPT, "--api-key", "direct", "--n", "2",
+ "--quality", "medium", "--background", "opaque", "--moderation", "low",
+ "--output-format", "webp", "--output-compression", "80", "--user", "tester",
+ "--output", str(Path(tmp) / "result.png"),
+ ])
+ payload = json.loads(self.urlopen.call_args.args[0].data)
+ for name, value in {
+ "prompt": PROMPT, "n": 2, "quality": "medium", "background": "opaque",
+ "moderation": "low", "output_format": "webp",
+ "output_compression": 80, "user": "tester",
+ }.items():
+ self.assertEqual(payload[name], value)
+
+ def test_edit_wire_prompt_image_order_mask_and_boundary(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ directory = Path(tmp)
+ prompt_file = directory / "prompt.txt"
+ prompt_file.write_bytes(PROMPT.encode("utf-8"))
+ files = [directory / name for name in ("subject.png", "style.png", "mask.png")]
+ for index, path in enumerate(files):
+ path.write_bytes(PNG + bytes([index]))
+ self.response()
+ self.run_command([
+ "edit", "--prompt-file", str(prompt_file), "--api-key", "direct",
+ "--size", "1536x1024", "--image", str(files[0]), "--image", str(files[1]),
+ "--mask", str(files[2]), "--output", str(directory / "edited.png"),
+ ])
+ request = self.urlopen.call_args.args[0]
+ self.assertEqual(request.full_url, self.module.BASE_URL + "/images/edits")
+ content_type = request.get_header("Content-type")
+ message = BytesParser(policy=policy.default).parsebytes(
+ f"Content-Type: {content_type}\r\nMIME-Version: 1.0\r\n\r\n".encode()
+ + request.data
+ )
+ self.assertTrue(message.is_multipart())
+ boundary = message.get_boundary().encode()
+ self.assertTrue(request.data.endswith(b"--" + boundary + b"--\r\n"))
+ parts = list(message.iter_parts())
+ fields = {
+ part.get_param("name", header="content-disposition"):
+ part.get_payload(decode=True).decode("utf-8")
+ for part in parts if part.get_filename() is None
}
+ self.assertEqual(fields, {
+ "model": "gpt-image-2", "prompt": PROMPT, "size": "1536x1024",
+ "n": "1", "response_format": "b64_json",
+ })
+ uploads = [part for part in parts if part.get_filename() is not None]
+ self.assertEqual([part.get_filename() for part in uploads], [p.name for p in files])
+ self.assertEqual([p.get_param("name", header="content-disposition") for p in uploads],
+ ["image", "image", "mask"])
+ self.assertEqual([p.get_payload(decode=True) for p in uploads],
+ [p.read_bytes() for p in files])
- self.module.request_json = fake_request_json
- self.module.save_response_images = lambda response, path, n: None
- args = SimpleNamespace(
- prompt="test prompt",
- size="1024x1024",
- output=None,
- api_key="direct",
- quality="medium",
- n=2,
- background="opaque",
- moderation="low",
- output_format="webp",
- output_compression=80,
- user="tester",
- )
+ def test_prompt_sources_required_and_mutually_exclusive(self):
+ for operation in ("generate", "edit"):
+ base = [operation] + (["--image", "input.png"] if operation == "edit" else [])
+ for extra in ([], ["--prompt", "text", "--prompt-file", "prompt.txt"]):
+ with self.subTest(operation=operation, extra=extra):
+ self.assert_fails(lambda: self.module.parser().parse_args(base + extra))
+ self.urlopen.assert_not_called()
- self.module.command_generate(args)
+ def test_bad_prompt_inputs_fail_before_request(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ directory = Path(tmp)
+ blank, invalid = directory / "blank.txt", directory / "invalid.txt"
+ blank.write_bytes(b" \r\n\t")
+ invalid.write_bytes(b"\xff\xfe")
+ for operation in ("generate", "edit"):
+ base = [operation, "--api-key", "direct"]
+ if operation == "edit":
+ base += ["--image", "input.png"]
+ for options in (
+ ["--prompt", " \n\t"], ["--prompt-file", str(blank)],
+ ["--prompt-file", str(invalid)],
+ ["--prompt-file", str(directory / "missing.txt")],
+ ["--prompt-file", str(directory)],
+ ):
+ with self.subTest(operation=operation, options=options):
+ self.assert_fails(lambda: self.run_command(base + options))
+ self.urlopen.assert_not_called()
- self.assertEqual(captured["path"], "/images/generations")
- self.assertEqual(captured["key"], "direct")
- self.assertEqual(captured["payload"]["quality"], "medium")
- self.assertEqual(captured["payload"]["n"], 2)
- self.assertEqual(captured["payload"]["background"], "opaque")
- self.assertEqual(captured["payload"]["moderation"], "low")
- self.assertEqual(captured["payload"]["output_format"], "webp")
- self.assertEqual(captured["payload"]["output_compression"], 80)
- self.assertEqual(captured["payload"]["user"], "tester")
+ def test_n_and_compression_bounds_fail_locally(self):
+ for flag, values in (("--n", ("0", "-1", "1.5")),
+ ("--output-compression", ("-1", "101", "half"))):
+ for value in values:
+ with self.subTest(flag=flag, value=value):
+ self.assert_fails(lambda: self.run_command([
+ "generate", "--prompt", "test", "--api-key", "direct", flag, value
+ ]))
+ for value in ("0", "100"):
+ args = self.module.parser().parse_args([
+ "generate", "--prompt", "test", "--output-compression", value,
+ ])
+ self.assertEqual(args.output_compression, int(value))
+ self.urlopen.assert_not_called()
- def test_save_response_images_suffixes_multiple_outputs(self):
- response = {
- "data": [
- {"b64_json": base64.b64encode(b"one").decode("ascii")},
- {"b64_json": base64.b64encode(b"two").decode("ascii")},
- ]
- }
+ def test_supported_signatures_and_png_data_url(self):
+ # JPEG/WebP samples test only signatures, not full image decoding.
+ for data in (PNG, b"\xff\xd8\xff\xe0signature", b"RIFF\x04\x00\x00\x00WEBP"):
+ with self.subTest(prefix=data[:12]):
+ self.assertEqual(self.module.extract_image_bytes_from_item({
+ "b64_json": base64.b64encode(data).decode("ascii")
+ }), data)
+ self.assertEqual(self.module.extract_image_bytes_from_item({
+ "url": "data:image/png;base64," + PNG_BASE64
+ }), PNG)
+
+ def test_malformed_empty_and_nonimage_data_rejected_without_writes(self):
+ bad_items = [
+ {"b64_json": "!!!!"}, {"b64_json": ""}, {"b64_json": "abcd"},
+ {"b64_json": PNG_BASE64[:-1]}, {"b64_json": "图像"},
+ {"url": "data:image/png;base64,"},
+ {"url": "data:text/plain;base64," + PNG_BASE64},
+ {"url": "data:image/png," + PNG_BASE64},
+ {"url": "data:image/jpeg;base64," + PNG_BASE64},
+ {"url": "data:image/png;base64,!!!!"},
+ {"url": "https://example.invalid/image.png"}, {}, "not an object",
+ ]
with tempfile.TemporaryDirectory() as tmp:
target = Path(tmp) / "image.png"
- with contextlib.redirect_stdout(io.StringIO()):
- outputs = self.module.save_response_images(response, target, 2)
+ for item in bad_items:
+ with self.subTest(item=item):
+ self.assert_fails(lambda: self.module.save_response_images({"data": [item]}, target, 1))
+ self.assertFalse(target.exists())
+
+ def test_bad_later_result_preserves_existing_first_output(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ target = Path(tmp) / "image.png"
+ existing = Path(tmp) / "image_0.png"
+ existing.write_bytes(b"existing user output")
+ self.assert_fails(lambda: self.module.save_response_images({"data": [
+ {"b64_json": PNG_BASE64}, {"b64_json": "!!!!"}
+ ]}, target, 2))
+ self.assertEqual(existing.read_bytes(), b"existing user output")
+ self.assertFalse((Path(tmp) / "image_1.png").exists())
+
+ def test_multiple_images_saved_and_actual_outputs_summarized(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ stdout = io.StringIO()
+ with contextlib.redirect_stdout(stdout):
+ outputs = self.module.save_response_images({"data": [
+ {"b64_json": PNG_BASE64}, {"url": "data:image/png;base64," + PNG_BASE64}
+ ]}, Path(tmp) / "image.png", 2)
self.assertEqual([p.name for p in outputs], ["image_0.png", "image_1.png"])
- self.assertEqual(outputs[0].read_bytes(), b"one")
- self.assertEqual(outputs[1].read_bytes(), b"two")
+ self.assertEqual([p.read_bytes() for p in outputs], [PNG, PNG])
+ summary = json.loads(stdout.getvalue())
+ self.assertEqual(summary["outputs"], [str(p) for p in outputs])
+ self.assertEqual(summary["bytes"], [len(PNG), len(PNG)])
+ self.assertEqual(summary["data_items"], 2)
+ self.assertNotIn(PNG_BASE64, stdout.getvalue())
- def test_parser_accepts_repeated_reference_images(self):
- args = self.module.parser().parse_args(
- [
- "edit",
- "--prompt",
- "combine",
- "--image",
- "one.png",
- "--image",
- "two.png",
- ]
- )
- self.assertEqual(args.image, ["one.png", "two.png"])
-
- def test_skill_documents_prompt_enhancement_gate_and_key_fallbacks(self):
- text = SKILL.read_text(encoding="utf-8")
- self.assertIn("OPENAI_API_KEY", text)
- self.assertIn("--api-key", text)
- self.assertIn("prompt enhancement", text.lower())
- self.assertIn("confirm", text.lower())
- self.assertIn("references/gallery.md", text)
- self.assertIn("references/craft.md", text)
- self.assertIn("API execution prompt", text)
- self.assertIn("中文对照", text)
- self.assertIn("In-image text", text)
- self.assertIn("review only", text.lower())
- self.assertIn("user-requested language", text.lower())
+ def test_empty_or_missing_response_data_rejected(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ for response in ({}, {"data": []}, {"data": "image"}):
+ with self.subTest(response=response):
+ self.assert_fails(lambda: self.module.save_response_images(
+ response, Path(tmp) / "image.png", 1
+ ))
if __name__ == "__main__":
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json
index af24a840..61d66a29 100644
--- a/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/eapil-llmapi-structured/.codex-plugin/plugin.json
@@ -1,13 +1,13 @@
{
"name": "eapil-llmapi-structured",
- "version": "0.1.0",
- "description": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。",
+ "version": "1.0.0",
+ "description": "易瞳 LLM API 通用调用指南:选择、构建和实测 Chat、Responses、结构化输出、工具、视觉、流式、Embedding、Rerank 与 Decisions 请求,核对模型和中转渠道的实际兼容性。",
"author": {
"name": "EAPIL",
"url": "https://git.playones.com/arechen/EapilSkillMarket"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
+ "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-llmapi-structured",
+ "repository": "https://git.playones.com/chenmingxuan/ManySkills",
"license": "Proprietary",
"keywords": [
"eapil",
@@ -16,9 +16,9 @@
],
"skills": "./skills/",
"interface": {
- "displayName": "易瞳LLM结构化调用",
- "shortDescription": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。",
- "longDescription": "指导AI以生成结构化 JSON 输出的指南,内容涵盖视觉标注、严格的 JSON 架构要求、推理耗时分析、令牌/延迟诊断以及网关故障排除等主题。适用于在构建、调试等类似兼容 OpenAI 的结构化输出请求时使用。",
+ "displayName": "易瞳LLM调用指南",
+ "shortDescription": "Chat、Responses、结构化输出、Embedding、Rerank 和 Decisions 的调用与实测指南。",
+ "longDescription": "按任务选择易瞳 LLM API 调用模式,提供端点参数、工具与视觉输入、流式解析、结构化校验、检索和决策示例。配套单请求探测脚本与有日期的实测记录,明确模型和渠道差异;保留原插件 ID 兼容已有引用。",
"developerName": "EAPIL",
"category": "Developer Tools",
"capabilities": [
@@ -26,7 +26,7 @@
"Write"
],
"defaultPrompt": [
- "使用 易瞳LLM结构化调用 帮我完成这个任务。"
+ "使用 易瞳LLM调用指南 帮我选择端点、构建请求并验证结果。"
],
"websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
"privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..822ce355
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "eapil-llmapi-structured",
+ "repo": "https://git.playones.com/chenmingxuan/ManySkills.git",
+ "ref": "v1.0.0",
+ "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d",
+ "adapter": "codex-plugin",
+ "sourcePath": "plugins/eapil-llmapi-structured",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md
index 8b88613e..69ae2817 100644
--- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/SKILL.md
@@ -1,175 +1,41 @@
---
name: eapil-llmapi-structured
-description: Guidance for EAPIL LLM API/OpenAI-compatible GPT-5.5 structured JSON output calls through /v1/responses or /v1/chat/completions, especially vision labeling, strict JSON schema, reasoning_effort, token/latency diagnostics, and gateway troubleshooting. Use when building, debugging, or reviewing structured-output requests to EAPIL LLM API relay domains such as api-n-cd.playones.com or similar OpenAI-compatible relays.
+description: Build, test, or debug EAPIL LLM API requests across Chat, Responses, structured output, tools, streaming, embeddings, reranking, and typed Decisions.
---
-# EAPIL LLMAPI Structured Output
+# EAPIL LLM API guide
-Use this skill when implementing or debugging structured JSON calls against the EAPIL LLM API OpenAI-compatible gateway.
+Choose the protocol for the task and model, then verify the deployed gateway's behavior. The historical skill ID remains for compatibility; this guide covers general LLM API calls, not only structured JSON.
-## Default Choice
+## Choose the mode
-Prefer `/v1/responses` with strict JSON schema for GPT-5.5 teacher labeling.
+| Need | EAPIL endpoint | Read when relevant |
+|---|---|---|
+| Conversation, compatible clients, vision or client tools | `/v1/chat/completions` | [Chat and Responses](references/chat-responses.md) |
+| Responses-native input/output, tools or streaming | `/v1/responses` | [Chat and Responses](references/chat-responses.md) |
+| JSON conforming to a schema | Either text endpoint, if the model/channel supports it | [Structured output](references/structured-output.md) |
+| Text vectors for retrieval or similarity | `/v1/embeddings` | [Embedding and Rerank](references/retrieval.md) |
+| Reorder candidate documents against a query | `/v1/rerank` | [Embedding and Rerank](references/retrieval.md) |
+| Probabilistic yes/no, categorical choice or ordinal scoring | `/v1/alpha/decisions`, alias `/api/alpha/decisions` | [Decisions](references/decisions.md) |
-Keep `/v1/chat/completions` as a fallback for compatibility testing, not the default, unless live smoke tests prove it is cheaper and faster for the same schema.
+Chat is a first-class mode, not a universal fallback. Responses is not inherently cheaper or faster. Decisions uses its own `state`/`questions` protocol; the inspected gateway does not automatically convert Chat/Responses requests into Decisions. Image and voice workflows have their own repository skills.
-## Known Good Responses Shape
+## Use the actual integration
-Use this shape for low-latency structured output:
+Default base URL is `https://api-n-cd.playones.com/v1`. Respect the user's intended deployment and available model; do not force a model/version from an old example. Query `/v1/models` when availability matters. A listed ID does not prove each endpoint, parameter, tool, schema or modality is supported. Model aliases and channel conversion can differ from the public provider's defaults.
-```json
-{
- "model": "gpt-5.5",
- "input": [
- {
- "role": "user",
- "content": [
- { "type": "input_text", "text": "只输出一个 JSON object,不要 markdown。" },
- { "type": "input_image", "image_url": "data:image/jpeg;base64,...", "detail": "low" }
- ]
- }
- ],
- "text": {
- "format": {
- "type": "json_schema",
- "name": "task_schema_name",
- "strict": true,
- "schema": {
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "ok": { "type": "boolean" },
- "score": { "type": "number", "minimum": 0, "maximum": 1 },
- "label": { "type": "string", "maxLength": 64 }
- },
- "required": ["ok", "score", "label"]
- }
- }
- },
- "reasoning": { "effort": "none" },
- "truncation": "disabled",
- "max_output_tokens": 700
-}
-```
+Use `EAPIL_API_KEY`, then `OPENAI_API_KEY`, or the user's specified environment source. Keep credentials, private inputs, vectors and raw provider responses out of committed artifacts and routine logs. Use synthetic small probes for compatibility; do not replay production data or fan out broad paid benchmarks merely to verify an example.
-Call `POST {base_url}/responses`, where `base_url` may already include `/v1`.
+Read only the relevant mode reference. Keep endpoint-specific field shapes distinct: Responses `input` and `text.format`, Chat `messages` and `response_format`, Decisions `state` and `questions`. Pass explicit supported values without dropping `0`, `false` or empty collections that carry meaning. Omit optional model-specific parameters when their support is unknown.
-## Chat Fallback Shape
+## Test the contract
-Use this only for fallback comparison:
+Use [smoke_api.py](scripts/smoke_api.py) with an explicit UTF-8 JSON payload for each mode. It supports dry runs and Chat/Responses SSE, omits generated text and vectors from summaries, and performs mode-specific structural checks. Decisions summaries include labels/answers; dry runs print the payload. It makes one request and executes no returned tools. For a ready-made structured/vision probe, use [smoke_structured_output.py](scripts/smoke_structured_output.py).
-```json
-{
- "model": "gpt-5.5",
- "messages": [
- {
- "role": "user",
- "content": [
- { "type": "text", "text": "只输出一个 JSON object,不要 markdown。" },
- { "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,...", "detail": "low" } }
- ]
- }
- ],
- "response_format": {
- "type": "json_schema",
- "json_schema": {
- "name": "task_schema_name",
- "strict": true,
- "schema": {
- "type": "object",
- "additionalProperties": false,
- "properties": {
- "ok": { "type": "boolean" },
- "score": { "type": "number", "minimum": 0, "maximum": 1 },
- "label": { "type": "string", "maxLength": 64 }
- },
- "required": ["ok", "score", "label"]
- }
- }
- },
- "reasoning_effort": "none",
- "temperature": 0,
- "max_tokens": 700
-}
-```
+Check the result, not only HTTP 200: completed text or a tool call, usable indices/dimensions, expected typed answers, or valid application JSON. Parsing JSON alone does not validate a schema. For streaming, consume deltas/items and require a terminal success; truncated output, refusals and error events are separate outcomes.
-Call `POST {base_url}/chat/completions`.
+Start with one request and a small supported output budget. A timeout can leave an unknown billed outcome; inspect status before retrying. Compare latency and usage only for equivalent prompts, inputs, settings and successful results. Reasoning token fields, encrypted reasoning and route conversion are diagnostic evidence, not universal proof of a forced reasoning setting.
-## Schema Rules
+## Deliver
-- Keep schemas compact. Prefer arrays of present tags over objects containing every boolean flag.
-- Use `additionalProperties: false` and `required` for every field in strict schemas.
-- Bound free text with `maxLength`; keep notes short.
-- Use enum arrays for tags:
-
-```json
-{
- "negative_tags": {
- "type": "array",
- "maxItems": 8,
- "items": {
- "type": "string",
- "enum": ["empty_frame", "screen_distraction", "subject_too_small"]
- }
- }
-}
-```
-
-## Prompt Rules
-
-- State that the model must output only one JSON object, no markdown and no explanation.
-- If human decisions are already fixed, state that the model must not change them.
-- For visual labeling, require all camera IDs to be present.
-- Tell the model to list only actually present negative tags; use `[]` when none are visible.
-- Avoid asking for long rationales. Use short notes for human audit only.
-
-## Token And Latency Diagnostics
-
-First compare visible output with usage:
-
-- `raw_text_chars` should roughly match a small structured JSON response.
-- `usage.output_tokens_details.reasoning_tokens` should be `0` when reasoning effort is `none`.
-- Responses output should not contain an `output` item with `type: "reasoning"` or `encrypted_content`.
-
-If `output_tokens` is thousands while visible JSON is small:
-
-1. Check EAPIL gateway settings for forced reasoning overrides such as XHIGH.
-2. Re-test with `/v1/responses`, strict schema, and `reasoning: {"effort":"none"}`.
-3. Inspect whether `encrypted_content` appears in the raw response.
-4. Do not blame the prompt until gateway forced reasoning is ruled out.
-
-Known good reference from this project after gateway fix:
-
-```text
-endpoint: /v1/responses
-model: gpt-5.5
-reasoning: none
-input_tokens: 1704
-output_tokens: 230
-reasoning_tokens: 0
-visible JSON: about 786 chars
-elapsed: about 8s
-raw response: no encrypted_content
-```
-
-## Operational Rules
-
-- Start teacher-labeling concurrency at 1-3. Raise only after observing stable latency and no 429/5xx bursts.
-- Persist job status so interrupted labeling can resume from pending/error jobs.
-- Log request mode, reasoning effort, usage, elapsed seconds, visible JSON length, parse status, and whether hidden reasoning exists.
-- Keep real API keys out of logs and saved request artifacts.
-
-## Smoke Test Script
-
-Use `scripts/smoke_structured_output.py` to verify gateway behavior before large labeling runs:
-
-```bash
-python /root/.codex/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py \
- --base-url https://api-n-cd.playones.com/v1 \
- --model gpt-5.5 \
- --endpoint responses \
- --reasoning-effort none \
- --api-key-env OPENAI_API_KEY
-```
-
-Add `--image /path/to/frame.jpg` one or more times for vision tests. Add `--dry-run` to print the request shape without calling the API.
+Identify endpoint, deployed model alias, relevant parameters, parsing/validation and observed limitations. Use the dated [verification record](references/verification.md) to distinguish source inspection, this run's live tests and untested features. A tiny smoke test proves the tested contract, not accuracy or reliability for all workloads.
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml
index 6c2e27be..d5f18f60 100644
--- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "EAPIL LLMAPI Structured"
- short_description: "EAPIL LLM API structured JSON guide"
- default_prompt: "Use $eapil-llmapi-structured to build or debug an EAPIL LLM API structured-output request."
+ display_name: "EAPIL LLM API"
+ short_description: "EAPIL text, retrieval and Decisions API guide"
+ default_prompt: "Use $eapil-llmapi-structured to build, test or debug an EAPIL LLM API request using the appropriate endpoint."
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md
new file mode 100644
index 00000000..91970865
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/chat-responses.md
@@ -0,0 +1,98 @@
+# Chat, Responses, tools and streaming
+
+Use the model and protocol supported by the selected channel. Examples use `gpt-6-luna`, tested on this deployment on 2026-09-30; it is an example, not a permanent default. See [verification](verification.md) for limitations.
+
+## Ordinary text
+
+Chat request to `/v1/chat/completions`:
+
+```json
+{
+ "model": "gpt-6-luna",
+ "messages": [{"role": "user", "content": "Reply with exactly PONG."}],
+ "reasoning_effort": "low",
+ "max_completion_tokens": 256
+}
+```
+
+Read `choices[*].message.content` for text, or `message.tool_calls` for requested tools. Check `finish_reason`; `length`, content filtering and refusals are not ordinary successful answers. A `stop` with empty content and no tool call does not prove the task succeeded.
+
+Responses request to `/v1/responses`:
+
+```json
+{
+ "model": "gpt-6-luna",
+ "input": "Reply with exactly PONG.",
+ "reasoning": {"effort": "low"},
+ "max_output_tokens": 256,
+ "store": false
+}
+```
+
+Read message items with `content[*].type == "output_text"` from `output`; an SDK's `output_text` convenience property need not exist in raw JSON. Other output items can be reasoning or function calls. Check `status`, refusal parts, `error` and `incomplete_details`. Do not parse a serialized copy of the entire response as if it were the assistant's text.
+
+Reasoning effort values, token limits, sampling controls and context limits depend on the model/channel. Do not send `temperature: 0` or `reasoning: none` universally. Legacy Chat models may use `max_tokens`; current reasoning models commonly use `max_completion_tokens`. Responses uses `max_output_tokens`. These budgets may include reasoning, so a small budget can truncate before visible output.
+
+## Vision
+
+Chat uses:
+
+```json
+{"type":"image_url","image_url":{"url":"data:image/png;base64,...","detail":"low"}}
+```
+
+Responses uses:
+
+```json
+{"type":"input_image","image_url":"data:image/png;base64,...","detail":"low"}
+```
+
+Place these alongside `text` or `input_text` content respectively. Preserve reference order and ensure MIME matches the file. Choose image detail according to the task and model; low detail is not appropriate for every OCR or small-object task. Remote URLs, files, audio and video formats have model-specific support; a text probe does not establish them.
+
+## Client function tools
+
+Chat tool definition:
+
+```json
+{"type":"function","function":{"name":"echo","description":"Echo a string.","parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},"strict":true}}
+```
+
+Responses tool definition:
+
+```json
+{"type":"function","name":"echo","description":"Echo a string.","parameters":{"type":"object","properties":{"text":{"type":"string"}},"required":["text"],"additionalProperties":false},"strict":true}
+```
+
+Chat tool choice wraps the function name in `function`; Responses puts `name` directly on the choice. This deployment accepted both shapes in live probes. Validate tool names and JSON arguments before invoking the application's authorized action. The gateway and smoke script do not execute client tools for you.
+
+For continuation, Chat appends the assistant tool-call message and a `role: tool` message using the matching `tool_call_id`. Responses supplies the function-call item and `type: function_call_output`, with its matching `call_id` and string `output`. Keep relevant earlier input/output in the next request when stored state is unavailable. A `previous_response_id` depends on provider storage and routing support; `store:false` is not a guarantee of remote conversation persistence. Do not synthesize a mismatched call ID.
+
+Hosted tools, WebSocket `/v1/responses`, realtime, `/v1/responses/compact`, `/v1/alpha/search` and `/v1/memories/trace_summarize` exist in the inspected source or upstream protocols, but this guide's live coverage does not establish all of them. Use their actual deployment/provider protocol before adding them to an application; do not infer support from an ordinary HTTP Responses call.
+
+## SSE
+
+Set `stream:true` for supported Chat/Responses requests. Chat can request `stream_options:{"include_usage":true}` when supported. Consume SSE frames, not individual network chunks; join multiline `data:` and decode complete UTF-8 lines.
+
+Chat delivers content/tool argument deltas and a finishing choice, often followed by a separate usage chunk and `[DONE]`. Responses delivers typed events such as `response.output_text.delta`, output-item events and `response.completed`. Accumulate the items/deltas: on this gateway the final completed snapshot may have an empty `output` even though earlier events contain the full answer. Do not double-count `delta`, text-done and item-done copies.
+
+Require a terminal success and handle refusal, failed/incomplete/error events and premature EOF separately. Do not use a received first token as proof of completion. First-token latency and total elapsed time are different metrics; the bundled probe reports total time.
+
+## Smoke commands
+
+Write the selected request as UTF-8 JSON in `request.json`, then run from the project. Resolve `` from the actual installed location:
+
+```powershell
+python '/scripts/smoke_api.py' --mode chat --payload-file request.json --dry-run
+python '/scripts/smoke_api.py' --mode responses --payload-file request.json --expect-text PONG
+```
+
+Use the matching mode for the payload. Add `rtk proxy` in this repository. Credentials default to `EAPIL_API_KEY`, then `OPENAI_API_KEY`; `--api-key-env` selects another explicitly configured variable. Live calls make one request with no retry. Dry run prints the supplied payload, so use synthetic input when sharing that output.
+
+## Sources
+
+Reviewed 2026-09-30: local New-API `router/relay-router.go`, `service/relayconvert/chat_to_responses.go`, `relay/channel/openai/relay_responses.go` and mode-specific runtime probes. The source checkout includes uncommitted changes and is not a deployment identity.
+
+- [OpenAI Responses API](https://developers.openai.com/api/reference/resources/responses/methods/create)
+- [OpenAI Chat Completions API](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
+- [OpenAI function calling](https://developers.openai.com/api/docs/guides/function-calling)
+- [OpenAI streaming](https://developers.openai.com/api/docs/guides/streaming-responses)
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md
new file mode 100644
index 00000000..41d18cc6
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/decisions.md
@@ -0,0 +1,93 @@
+# Typed Decisions
+
+Use Decisions for probability judgments, categorical decisions and ordinal scores. It has a separate request/response contract from Chat and Responses; it does not return an assistant message or execute the chosen business action.
+
+## Gateway and model selection
+
+POST `/v1/alpha/decisions` or `/api/alpha/decisions` with Bearer authentication. Use `model`, a non-null `state`, and a nonempty `questions` object. Omit `stream` or set it to `false`; this gateway rejects `stream:true`. The inspected New-API implementation routes to an OpenRouter channel's `/alpha/decisions`, preserving the request body except root model mapping. It does not automatically translate Chat/Responses requests into Decisions.
+
+| Model alias | Live coverage on 2026-09-30 | Input boundary |
+|---|---|---|
+| `typesafe/jev-1.13` | `noul`, `choice`, `score` together | Ticket-shaped object tested |
+| `upstage/solar-decide` | `noul`, `choice`, `score` together | Same object tested; score can be fractional |
+| `respan/span-01` | `noul` | Conversation object below tested |
+| `respan/span-01-lite` | `noul`; `choice` rejected | Conversation object below tested |
+
+The public protocol permits broader state shapes than an individual model may accept. A successful Jev request is not a portable Respan template. Respan's observed validation restricts questions to `noul`, and accepts a state string or an object containing only `input`/`output` messages. Only the conversation object was live tested here. See [verification](verification.md) for exact coverage.
+
+## Classify and score in one request
+
+This synthetic request passed with Jev and, replacing `model`, Solar:
+
+```json
+{
+ "model": "typesafe/jev-1.13",
+ "state": {"ticket": "Checkout fails after payment. Customers cannot complete orders."},
+ "questions": {
+ "is_bug": {
+ "type": "noul",
+ "instructions": "Does the ticket describe a software defect?",
+ "criteria": {"true": "Broken or unexpected behavior", "false": "A question without a defect"}
+ },
+ "team": {
+ "type": "choice",
+ "instructions": "Which team should own this ticket?",
+ "criteria": {"payments": "Payment and checkout issues", "account": "Login and profile issues"}
+ },
+ "urgency": {
+ "type": "score",
+ "instructions": "How urgent is the issue?",
+ "criteria": ["Can wait", "Fix this week", "Blocking payment now"]
+ }
+ }
+}
+```
+
+Interpret `answers` by the original question keys and returned `type`:
+
+- `noul`: `noul` is the probability in `[0,1]` that the condition holds, not a Boolean. Choose a task-specific threshold after evaluating relevant data.
+- `choice`: `choice` identifies a criterion key; `probabilities` maps those keys to probabilities. Preserve string labels. `confidence` is a separate field, not necessarily the largest probability.
+- `score`: `criteria` is an ordered list; zero-based positions define the scale. `probabilities` uses string index keys, and `legend`, when present, identifies the descriptions. `score` may be fractional; do not cast it to an integer merely because the criteria have integer positions.
+
+Validate matching question/answer keys, types, finite numbers, ranges, probability labels and sums. The bundled probe checks the object/list criteria shapes above; additional protocol forms require corresponding handling. Preserve `usage` fields when available; monetary `cost` and token counts are different measures. One arithmetic or ticket test does not prove calibration or classification quality.
+
+## Respan conversation evaluation
+
+Tested with both Respan aliases:
+
+```json
+{
+ "model": "respan/span-01-lite",
+ "state": {
+ "input": [{"role": "user", "content": "What is 2 plus 2?"}],
+ "output": {"role": "assistant", "content": "The answer is 4."}
+ },
+ "questions": {
+ "correct": {
+ "type": "noul",
+ "instructions": "The assistant gives the correct arithmetic answer.",
+ "criteria": {"true": "Correct answer", "false": "Incorrect answer"}
+ }
+ }
+}
+```
+
+The observed state validator requires string `content`, roles `system`, `user`, `assistant` or `tool`, and an assistant role for `output`. `state:{"messages":[...]}` failed. Do not put Chat's multimodal message parts or arbitrary extra state fields into this Respan object.
+
+A `choice` request returned a generic error mentioning plain-string instructions/criteria. The `noul` request above with a true/false criteria object nevertheless succeeded, so that error does not establish that all criteria objects are rejected. Keep the tested shape or verify the alternative explicitly; do not extrapolate a `choice` failure into a different schema requirement.
+
+## Probe
+
+Save the selected synthetic request as UTF-8 `request.json`:
+
+```powershell
+python '/scripts/smoke_api.py' --mode decisions --payload-file request.json --dry-run
+python '/scripts/smoke_api.py' --mode decisions --payload-file request.json
+python '/scripts/smoke_api.py' --mode decisions --payload-file request.json --decisions-alias
+```
+
+In this repository prefix external commands with `rtk proxy`. The summary includes decision labels/answers, so use synthetic questions for shared diagnostics. The helper performs no chosen action and makes no automatic retry. HTTP 400 should lead to checking model-specific state/question support, not silently substituting a Chat call.
+
+## Sources
+
+Reviewed 2026-09-30: [OpenRouter Decisions protocol](https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request), model pages for [Jev](https://openrouter.ai/typesafe/jev-1.13), [Solar](https://openrouter.ai/upstage/solar-decide) and [Respan lite](https://openrouter.ai/respan/span-01-lite). Local New-API evidence: `router/relay-router.go`, `dto/openrouter_decisions.go`, `relay/helper/valid_request.go`, `relay/openrouter_decisions_handler.go`. Deployment observations are recorded separately in [verification](verification.md).
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md
new file mode 100644
index 00000000..bee8be37
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/retrieval.md
@@ -0,0 +1,62 @@
+# Embedding and Rerank
+
+These are retrieval operations, not conversations. Use a model enabled for the actual endpoint. Model names and scores do not establish dimensionality, compatibility or calibrated probabilities.
+
+## Embedding
+
+POST `/v1/embeddings`:
+
+```json
+{
+ "model": "text-embedding-3-small",
+ "input": ["blue", "red"],
+ "encoding_format": "float"
+}
+```
+
+Read each `data[*].embedding` and associate it with the input using its `index`. Confirm item count, finite values, actual vector length and consistent dimensions. Preserve the embedding model/version and preprocessing used to build the index; do not mix vectors from incompatible spaces. Base64 encodings and token-array inputs need their own provider-compatible handling; the bundled probe checks float vectors from text input.
+
+The live gateway returned **1024 dimensions** for this alias. Adding `dimensions:128` still returned 1024, so the dimension option was not honored in this tested route. Do not size a vector database solely from a public model name or assume every accepted parameter affects the result. Treat a required dimension mismatch as a failed contract. Other deployments/channels may behave differently.
+
+`dimensions` is an optional upstream feature of certain models. This gateway's source forwards it, but code forwarding alone cannot prove implementation. Batch input order must be recovered by indices even if response rows arrive in a different order. Context/input and batch limits also depend on the provider.
+
+## Rerank
+
+POST `/v1/rerank`:
+
+```json
+{
+ "model": "bge-reranker-v2-m3-Q8_0.gguf",
+ "query": "How do I reset my password?",
+ "documents": [
+ "Fresh apples for sale.",
+ "Open account settings and choose reset password.",
+ "Rain is expected tomorrow."
+ ],
+ "top_n": 2,
+ "return_documents": true
+}
+```
+
+Read `results[*].index` and `relevance_score`; the index identifies the original candidate, not its new rank. Preserve candidate IDs separately and map them by index. This deployment returned `[1,0]` for the example and scores about `1.77` and `-11.02`; these are ranking scores, not probabilities bounded by 0–1. Do not invent a universal confidence threshold across models or queries.
+
+Although `return_documents:true` was supplied, this route returned only indices/scores. Recover source documents from your original list. Object-shaped documents, chunk limits and additional options depend on the selected adapter; string documents are the tested baseline. The source preserves explicit `top_n` and `return_documents` through pointer fields, including zero/false values, but their accepted semantics remain provider-specific.
+
+Embedding retrieval finds candidates; reranking reorders a candidate set against the current query. Choose how many candidates to retrieve/rerank for the application's quality and cost requirements rather than inheriting a fixed number from an example.
+
+## Probes
+
+Put the matching request into UTF-8 `request.json`:
+
+```powershell
+python '/scripts/smoke_api.py' --mode embedding --payload-file request.json
+python '/scripts/smoke_api.py' --mode rerank --payload-file request.json
+```
+
+The helper does not dump vectors or documents. It validates indices, counts and finite numeric data; it reports actual dimensions and rejects a requested dimension mismatch. A valid vector or a sorted result is not proof of application retrieval quality.
+
+## Sources
+
+Reviewed 2026-09-30: local New-API `dto/embedding.go`, `dto/rerank.go`, `relay/embedding_handler.go`, `relay/rerank_handler.go`, `relay/common_handler/rerank.go` and the dated live [verification](verification.md).
+
+[OpenAI embedding guide](https://developers.openai.com/api/docs/guides/embeddings) describes upstream embedding behavior; the alias-specific observations above come from this gateway's tests.
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md
new file mode 100644
index 00000000..a89a0ba9
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/structured-output.md
@@ -0,0 +1,59 @@
+# Structured JSON output
+
+Use for a machine-readable result, not as the default for all LLM tasks. Select a model/channel that actually supports the needed schema. The live example below passed through Responses; the same model's Chat strict-schema probe returned an empty answer. See [verification](verification.md).
+
+## Distinct field shapes
+
+Responses puts the format under `text.format`:
+
+```json
+{
+ "model": "gpt-6-luna",
+ "input": "Return ok=true and label=smoke.",
+ "text": {"format": {
+ "type": "json_schema", "name": "gateway_smoke", "strict": true,
+ "schema": {
+ "type": "object", "additionalProperties": false,
+ "properties": {"ok": {"type": "boolean"}, "label": {"type": "string", "enum": ["smoke"]}},
+ "required": ["ok", "label"]
+ }
+ }},
+ "reasoning": {"effort": "low"},
+ "max_output_tokens": 256,
+ "store": false
+}
+```
+
+The analogous Chat field is:
+
+```json
+{"response_format":{"type":"json_schema","json_schema":{"name":"gateway_smoke","strict":true,"schema":{"type":"object","additionalProperties":false,"properties":{"ok":{"type":"boolean"},"label":{"type":"string","enum":["smoke"]}},"required":["ok","label"]}}}}
+```
+
+Combine it with a normal Chat request's `model` and `messages`; do not copy Responses `text.format` there. This describes the protocol, not a claim that every model accepts it. A supported function tool uses its tool parameter schema instead of the final-answer format.
+
+## Schema and result checks
+
+Use only the JSON Schema subset supported by the selected model. Strict OpenAI examples require object fields to be listed as required and disallow additional properties; optional concepts can be represented by a nullable type where supported. Do not universally add `maxLength`, numeric bounds or array limits from an old task: supported constraints vary, and application validation still matters.
+
+JSON mode (`json_object`) aims for valid JSON but does not enforce a particular schema. A prompt saying “only JSON” alone establishes neither valid syntax nor adherence. If strict enforcement is required, do not silently downgrade to prose or JSON mode after an error.
+
+Extract actual output text, check completion/refusal, parse JSON, validate against the application schema with a real schema validator, and check task-specific values. Parsing any dictionary is insufficient: missing/extra keys, wrong field types, invalid enum values and truncated output must fail. Do not repair invalid business data by inventing values. JSON numbers and booleans are distinct even when a programming language treats `True == 1`.
+
+The generic probe's `--expect-json` checks parsing only; it is not a general JSON Schema validator. The convenience probe validates its own small synthetic task's exact keys/types/values, not arbitrary schemas:
+
+```powershell
+python '/scripts/smoke_structured_output.py' --model gpt-6-luna --endpoint responses --reasoning-effort low
+```
+
+`--image` can repeat for a vision-shaped smoke request, but its synthetic answer does not measure image understanding. For a visual task, verify predictions against known image content. `--dry-run` needs no key. Model selection is now explicit and reasoning defaults to omission; old CLI flags remain, but the old fixed `gpt-5.5`/`none` defaults have been removed.
+
+## Diagnostics
+
+Record endpoint/model/settings, success category, visible text length, elapsed time and available usage fields. A small visible answer can coexist with substantial reasoning tokens. Inspect the model's effort support, the gateway's channel overrides or conversion, and the actual usage before attributing cost to the prompt. Missing usage does not equal zero tokens; encrypted reasoning is not itself an error or proof of forced XHIGH. Keep benchmark inputs/settings equivalent and exclude failed outputs from success comparisons.
+
+For HTTP 400, check the endpoint field shape, supported schema subset and model-specific options. For HTTP 200 with an empty/incomplete answer, do not mark the test as passed. Choose another verified model or endpoint deliberately when the application permits it.
+
+## Sources
+
+Reviewed 2026-09-30: [OpenAI structured outputs](https://developers.openai.com/api/docs/guides/structured-outputs), local conversion source and live probes described in [verification](verification.md).
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md
new file mode 100644
index 00000000..93759170
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/references/verification.md
@@ -0,0 +1,51 @@
+# Verification record
+
+Date: **2026-09-30**. Live target: `https://api-n-cd.playones.com`. Requests used existing environment credentials and tiny synthetic inputs. No production inputs or credentials are stored here. These observations describe the tested alias/channel/request combination; they are not promises for every deployment or model.
+
+## Live requests
+
+| Case | Model / endpoint | Observed result |
+|---|---|---|
+| Model discovery | GET `/v1/models` | Passed; 121 IDs returned. Listing does not establish feature support. |
+| Ordinary text | `gpt-6-luna`, Chat and Responses | Both returned exact `PONG`; normal completion. Low reasoning, 256-token budget; Responses `store:false`. |
+| Text SSE | Same model, both endpoints | Both passed exact `PONG` and terminal completion. Responses final snapshot had empty `output`; earlier deltas/items contained the answer. Parser repaired and retested successfully. |
+| Strict JSON schema | Same model, Chat | Two probes returned HTTP 200, `stop`, empty content and zero completion usage. **Failed usable-output check**; cause not established. |
+| Strict JSON schema | Same model, Responses | Passed expected keys/types/values, including the convenience script's `ok=true`, `label="smoke"`, `score=1`. |
+| Client function tools | Same model, both endpoints, nonstream | Each returned one `echo` call with parsed arguments `{"text":"ping"}`. Tool execution and continuation were not tested. |
+| Vision + strict JSON | Same model, Responses | Supplied synthetic 32×32 red PNG; returned exactly `{"color":"red"}` against red/blue enum. Chat vision not tested. |
+| Float embeddings | `text-embedding-3-small`, `/v1/embeddings` | Two text inputs returned indices 0/1 and two finite 1024-dimension vectors. |
+| Requested embedding dimension | Same alias, `dimensions:128` | HTTP 200, still 1024 dimensions. **Requested dimension not honored**; helper rejects this mismatch. |
+| Rerank | `bge-reranker-v2-m3-Q8_0.gguf`, `/v1/rerank` | Password query over three documents, `top_n:2`: indices `[1,0]`; ranking scores approximately `1.77`, `-11.02`. No documents returned despite `return_documents:true`. |
+| Three Decisions types | `typesafe/jev-1.13`, `/v1/alpha/decisions` | `noul`, `choice`, `score` together passed. Bug probability .97, team payments, urgency score 2. |
+| Three Decisions types | `upstage/solar-decide`, same endpoint | All three passed. Bug probability .984022; team payments probability .993307, confidence .942033; urgency score **1.967132**. Confidence differs from maximum probability. |
+| Respan `noul` | `respan/span-01`, `/v1/alpha/decisions`; lite, `/api/alpha/decisions` | Both passed using conversation `input`/assistant `output`; arithmetic correctness probability .95715207. Both gateway paths were exercised, not every model on both paths. |
+| Respan invalid state | lite, `state.messages` | HTTP 400; requires string state or the constrained `input`/`output` shape. |
+| Respan categorical question | lite, `choice` | HTTP 400; error states Respan accepts only `noul`. Successful `noul` used true/false criteria object. |
+| Decisions streaming | `stream:true` | HTTP 400: streaming not supported. |
+
+Examples matching the inputs above are in [Chat/Responses](chat-responses.md), [structured output](structured-output.md), [retrieval](retrieval.md) and [Decisions](decisions.md). No broad model benchmark, quality evaluation, latency comparison or automatic retry was performed.
+
+## Source inspection
+
+Read-only source: WSL Debian `/home/code/new-api`, HEAD `8651bc59`, with existing uncommitted Decisions changes. This checkout is **not a verified identity of deployed service code**. Source was not modified.
+
+- Router exposes Chat, Responses, Embedding, Rerank, `/v1/alpha/decisions` and its `/api` alias.
+- Decisions validation requires model, non-null state, nonempty questions and nonstream mode. The handler uses an OpenRouter channel, forwards the raw body with root model mapping, and checks answers/usage/cost. No Chat/Responses-to-Decisions conversion exists in the inspected code.
+- General Chat/Responses conversion exists, but channel settings and model support determine which features survive it. Source presence does not prove a deployed route works.
+- Embedding dimensions/encoding and optional Rerank values are represented and forwarded in source. Live probes demonstrate that accepted options may still be ignored downstream.
+
+Responses compact/WebSocket, realtime, hosted tools, search/memory routes, stored continuation, streamed function tools, base64/token-array embeddings and other aliases were **not live verified**. Image and audio workflows remain covered by separate skills.
+
+## Local regression checks
+
+Run the network-mocked tests from this repository:
+
+```powershell
+rtk proxy python -m unittest discover -s plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests -v
+```
+
+All **15 tests passed**. Coverage includes wire shapes and explicit zero/false/Unicode values, endpoint joining, empty/truncated/refused outputs, UTF-8 SSE and terminal detection, tool-call identity/argument accumulation, empty final Responses snapshots, vector indices/dimensions, Rerank logits, fractional Decisions scores and concrete synthetic JSON validation. These tests validate helper behavior, independently of live service availability.
+
+Skill frontmatter validation, root AGENTS.md mechanical validation, all five plugin manifests/version/assets and 27 local documentation links also passed. Plugin/skill IDs remain `eapil-llmapi-structured`; the display name is now a general LLM API guide, and the repository baseline remains 1.0.0.
+
+`--expect-json` only checks JSON parsing. The convenience script checks its own fixed schema and expected values. Neither replaces application-specific schema validation or quality evaluation.
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py
new file mode 100644
index 00000000..46289e5f
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_api.py
@@ -0,0 +1,365 @@
+#!/usr/bin/env python3
+"""Small, explicit EAPIL API probes. No retries or automatic tool execution."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import re
+import sys
+import time
+from pathlib import Path
+from typing import Any
+from urllib import error, parse, request
+
+
+PATHS = {
+ "models": "models", "chat": "chat/completions", "responses": "responses",
+ "embedding": "embeddings", "rerank": "rerank", "decisions": "alpha/decisions",
+}
+
+
+def endpoint_url(base_url: str, mode: str, decisions_alias: bool = False) -> str:
+ parts = parse.urlsplit(base_url)
+ if parts.scheme not in {"http", "https"} or not parts.netloc:
+ raise ValueError("base URL must be HTTP or HTTPS")
+ if parts.username or parts.password or parts.query or parts.fragment:
+ raise ValueError("base URL must not contain credentials, query or fragment")
+ base = base_url.rstrip("/")
+ if decisions_alias:
+ if mode != "decisions":
+ raise ValueError("the /api alias applies only to decisions")
+ if base.endswith(("/v1", "/api")):
+ base = base.rsplit("/", 1)[0]
+ return base + "/api/alpha/decisions"
+ if base.endswith("/api"):
+ if mode != "decisions":
+ raise ValueError("use the service root or /v1 for this mode")
+ elif not base.endswith("/v1"):
+ base += "/v1"
+ return base + "/" + PATHS[mode]
+
+
+def read_payload(path: str) -> dict[str, Any]:
+ payload = json.loads(Path(path).read_text(encoding="utf-8-sig"))
+ if not isinstance(payload, dict):
+ raise ValueError("payload must be a JSON object")
+ return payload
+
+
+def extract_text(mode: str, response: dict[str, Any]) -> str:
+ if mode == "chat":
+ choices = response.get("choices") or []
+ if choices and isinstance(choices[0], dict):
+ message = choices[0].get("message") or {}
+ text = message.get("content") if isinstance(message, dict) else None
+ return text if isinstance(text, str) else ""
+ return ""
+ texts = []
+ for item in response.get("output") or []:
+ if isinstance(item, dict) and item.get("type") == "message":
+ for part in item.get("content") or []:
+ if isinstance(part, dict) and part.get("type") == "output_text":
+ if isinstance(part.get("text"), str):
+ texts.append(part["text"])
+ return "\n".join(texts)
+
+
+def usage_summary(response: dict[str, Any]) -> dict[str, Any]:
+ usage = response.get("usage") or {}
+ if not isinstance(usage, dict):
+ return {}
+ keys = ("input_tokens", "output_tokens", "prompt_tokens", "completion_tokens",
+ "total_tokens", "cost")
+ result = {key: usage[key] for key in keys if key in usage}
+ for key in ("input_tokens_details", "output_tokens_details",
+ "prompt_tokens_details", "completion_tokens_details"):
+ if isinstance(usage.get(key), dict):
+ result[key] = usage[key]
+ return result
+
+
+def finite_number(value: Any) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
+
+
+def validate_response(mode: str, body: dict[str, Any], payload: dict[str, Any]) -> dict[str, Any]:
+ if not isinstance(body, dict) or body.get("error"):
+ raise ValueError("response is not a successful JSON object")
+ result: dict[str, Any] = {"usage": usage_summary(body)}
+ if mode == "models":
+ rows = body.get("data")
+ if not isinstance(rows, list):
+ raise ValueError("models response has no data array")
+ result["models"] = [row["id"] for row in rows if isinstance(row, dict) and isinstance(row.get("id"), str)]
+ elif mode == "chat":
+ choices = body.get("choices")
+ if not isinstance(choices, list) or not choices:
+ raise ValueError("chat response has no choices")
+ reasons = [c.get("finish_reason") for c in choices if isinstance(c, dict)]
+ if len(reasons) != len(choices) or any(r not in {"stop", "tool_calls", "function_call"} for r in reasons):
+ raise ValueError("chat response did not finish normally")
+ messages = [c.get("message") for c in choices]
+ if any(not isinstance(m, dict) for m in messages):
+ raise ValueError("chat response has no assistant message")
+ result.update(finish_reasons=reasons, text_chars=len(extract_text(mode, body)),
+ tool_calls=sum(len(m.get("tool_calls") or []) for m in messages),
+ refusal=any(bool(m.get("refusal")) for m in messages))
+ if result["refusal"]:
+ raise ValueError("chat response refused the request")
+ if not result["text_chars"] and not result["tool_calls"] and not any(m.get("function_call") for m in messages):
+ raise ValueError("chat finished with no text or tool call")
+ elif mode == "responses":
+ if body.get("status") != "completed" or not isinstance(body.get("output"), list) or not body["output"]:
+ raise ValueError("Responses result is not completed")
+ result.update(status=body["status"], text_chars=len(extract_text(mode, body)),
+ output_types=[i.get("type") for i in body["output"] if isinstance(i, dict)])
+ if any(isinstance(c, dict) and c.get("type") == "refusal"
+ for i in body["output"] if isinstance(i, dict) for c in i.get("content") or []):
+ raise ValueError("Responses result refused the request")
+ calls = [i for i in body["output"] if isinstance(i, dict)
+ and isinstance(i.get("type"), str) and i["type"].endswith("_call")]
+ if not result["text_chars"] and not calls:
+ raise ValueError("Responses completed with no text or tool call")
+ if any(i.get("status") in {"failed", "incomplete", "in_progress"} for i in calls):
+ raise ValueError("Responses tool call did not complete")
+ elif mode == "embedding":
+ rows = body.get("data")
+ inputs = payload.get("input")
+ count = len(inputs) if isinstance(inputs, list) else 1
+ if not isinstance(rows, list) or len(rows) != count or not rows:
+ raise ValueError("embedding item count does not match input")
+ indices = [r.get("index") for r in rows if isinstance(r, dict)]
+ if len(indices) != count or any(type(i) is not int for i in indices) or sorted(indices) != list(range(count)):
+ raise ValueError("embedding indices do not match input")
+ vectors = [r.get("embedding") for r in rows]
+ if not all(isinstance(v, list) and v and all(finite_number(x) for x in v) for v in vectors):
+ raise ValueError("probe expects finite float embedding vectors; use encoding_format=float")
+ dimensions = [len(v) for v in vectors]
+ if len(set(dimensions)) != 1:
+ raise ValueError("embedding dimensions are inconsistent")
+ result.update(items=count, dimensions=dimensions, indices=indices)
+ if payload.get("dimensions") is not None:
+ result["requested_dimensions_honored"] = all(d == payload["dimensions"] for d in dimensions)
+ if not result["requested_dimensions_honored"]:
+ raise ValueError("requested embedding dimensions were not honored")
+ elif mode == "rerank":
+ rows = body.get("results")
+ documents = payload.get("documents") or []
+ if not isinstance(rows, list) or not rows:
+ raise ValueError("rerank response has no results")
+ indices = [r.get("index") for r in rows if isinstance(r, dict)]
+ if len(indices) != len(rows) or any(type(i) is not int or not 0 <= i < len(documents) for i in indices) or len(set(indices)) != len(indices):
+ raise ValueError("rerank indices do not identify the input documents")
+ scores = [r.get("relevance_score") for r in rows]
+ if not all(finite_number(s) for s in scores):
+ raise ValueError("rerank scores are not finite numbers")
+ result.update(indices=indices, scores=scores,
+ documents_returned=all("document" in r for r in rows))
+ elif mode == "decisions":
+ questions, answers = payload.get("questions"), body.get("answers")
+ if not isinstance(questions, dict) or not isinstance(answers, dict) or set(answers) != set(questions):
+ raise ValueError("decision answer keys do not match question keys")
+ for name, question in questions.items():
+ answer = answers[name]
+ if not isinstance(question, dict) or not isinstance(answer, dict) or answer.get("type") != question.get("type"):
+ raise ValueError("decision answer type does not match its question")
+ kind, criteria = question["type"], question.get("criteria")
+ if kind == "noul":
+ value = answer.get("noul")
+ if not finite_number(value) or not 0 <= value <= 1:
+ raise ValueError("noul is not a probability")
+ elif kind in {"choice", "score"}:
+ labels = set(criteria) if kind == "choice" and isinstance(criteria, dict) else {str(i) for i in range(len(criteria))} if isinstance(criteria, list) else set()
+ probabilities = answer.get("probabilities")
+ confidence = answer.get("confidence")
+ if not labels or not isinstance(probabilities, dict) or set(probabilities) != labels:
+ raise ValueError("decision probability labels do not match criteria")
+ if not all(finite_number(p) and 0 <= p <= 1 for p in probabilities.values()) or not math.isclose(sum(probabilities.values()), 1, abs_tol=1e-3):
+ raise ValueError("decision probabilities are invalid")
+ if not finite_number(confidence) or not 0 <= confidence <= 1:
+ raise ValueError("decision confidence is invalid")
+ if kind == "choice" and answer.get("choice") not in labels:
+ raise ValueError("decision choice is not a criterion")
+ if kind == "score" and (not finite_number(answer.get("score")) or not 0 <= answer["score"] <= len(criteria) - 1):
+ raise ValueError("decision score is outside the ordinal range")
+ else:
+ raise ValueError("unknown decision type")
+ result["answers"] = answers
+ return result
+
+
+def sse_events(response):
+ data = []
+ for raw_line in response:
+ line = raw_line.decode("utf-8").rstrip("\r\n")
+ if not line:
+ if data:
+ yield "\n".join(data)
+ data = []
+ elif line.startswith("data:"):
+ data.append(line[5:].lstrip(" "))
+ if data:
+ yield "\n".join(data)
+
+
+def read_stream(response, mode: str) -> dict[str, Any]:
+ if mode not in {"chat", "responses"}:
+ raise ValueError("stream probe supports Chat and Responses only")
+ choices, usage, done = {}, None, False
+ terminal = None
+ items = {}
+ text_parts = {}
+ for data in sse_events(response):
+ if data == "[DONE]":
+ done = True
+ continue
+ event = json.loads(data)
+ if not isinstance(event, dict) or event.get("error") or event.get("type") in {"error", "response.error", "response.failed", "response.incomplete"}:
+ raise ValueError("stream reported an error or incomplete result")
+ if mode == "responses":
+ kind = event.get("type")
+ index = event.get("output_index", 0)
+ if kind in {"response.output_item.added", "response.output_item.done"} and isinstance(event.get("item"), dict):
+ items[index] = event["item"]
+ elif kind in {"response.output_text.delta", "response.output_text.done"}:
+ part_key = (index, event.get("content_index", 0))
+ if kind.endswith(".delta") and isinstance(event.get("delta"), str):
+ text_parts[part_key] = text_parts.get(part_key, "") + event["delta"]
+ elif isinstance(event.get("text"), str):
+ text_parts[part_key] = event["text"]
+ if event.get("type") == "response.completed":
+ terminal = event.get("response")
+ else:
+ if event.get("usage"):
+ usage = event["usage"]
+ for chunk in event.get("choices") or []:
+ index = chunk.get("index", 0)
+ choice = choices.setdefault(index, {"index": index, "message": {"content": ""}, "finish_reason": None})
+ delta = chunk.get("delta") or {}
+ if isinstance(delta.get("content"), str):
+ choice["message"]["content"] += delta["content"]
+ if delta.get("refusal"):
+ choice["message"]["refusal"] = True
+ if delta.get("tool_calls"):
+ calls = choice["message"].setdefault("tool_calls", {})
+ for call in delta["tool_calls"]:
+ target = calls.setdefault(call.get("index", 0), {"function": {"arguments": ""}})
+ for field in ("id", "type"):
+ if field in call:
+ target[field] = call[field]
+ function = call.get("function") or {}
+ if "name" in function:
+ target["function"]["name"] = function["name"]
+ if isinstance(function.get("arguments"), str):
+ target["function"]["arguments"] += function["arguments"]
+ if chunk.get("finish_reason") is not None:
+ choice["finish_reason"] = chunk["finish_reason"]
+ if mode == "responses":
+ if not isinstance(terminal, dict):
+ raise ValueError("stream ended without response.completed")
+ # Some gateways omit the accumulated output from the terminal snapshot.
+ if not terminal.get("output"):
+ for (index, content_index), text in sorted(text_parts.items()):
+ item = items.setdefault(index, {"type": "message", "role": "assistant", "content": []})
+ if item.get("type") == "message":
+ content = item.setdefault("content", [])
+ while len(content) <= content_index:
+ content.append({"type": "output_text", "text": ""})
+ content[content_index] = {**content[content_index], "type": "output_text", "text": text}
+ terminal["output"] = [items[i] for i in sorted(items)]
+ return terminal
+ if not done:
+ raise ValueError("chat stream ended without [DONE]")
+ for choice in choices.values():
+ if isinstance(choice["message"].get("tool_calls"), dict):
+ choice["message"]["tool_calls"] = list(choice["message"]["tool_calls"].values())
+ return {"choices": [choices[i] for i in sorted(choices)], "usage": usage}
+
+
+def fetch_response(url: str, payload: dict[str, Any] | None, key: str, timeout: float, mode: str) -> dict[str, Any]:
+ stream = bool(payload and payload.get("stream"))
+ body = None if payload is None else json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8")
+ req = request.Request(url, data=body, headers={"Authorization": "Bearer " + key,
+ "Content-Type": "application/json", "Accept": "text/event-stream" if stream else "application/json"},
+ method="GET" if payload is None else "POST")
+ with request.urlopen(req, timeout=timeout) as response:
+ if stream:
+ if "text/event-stream" not in response.headers.get("Content-Type", ""):
+ raise ValueError("requested stream did not return text/event-stream")
+ return read_stream(response, mode)
+ return json.load(response)
+
+
+def resolve_key(env_name: str | None) -> str:
+ names = [env_name] if env_name else ["EAPIL_API_KEY", "OPENAI_API_KEY"]
+ for name in names:
+ value = os.getenv(name, "").strip()
+ if value:
+ return value
+ raise ValueError("missing API key environment variable")
+
+
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--mode", choices=PATHS, required=True)
+ parser.add_argument("--base-url", default="https://api-n-cd.playones.com/v1")
+ parser.add_argument("--payload-file")
+ parser.add_argument("--api-key-env")
+ parser.add_argument("--timeout-s", type=float, default=60)
+ parser.add_argument("--decisions-alias", action="store_true")
+ parser.add_argument("--expect-text")
+ parser.add_argument("--expect-json", action="store_true")
+ parser.add_argument("--dry-run", action="store_true")
+ args = parser.parse_args(argv)
+ key = ""
+ try:
+ if not math.isfinite(args.timeout_s) or args.timeout_s <= 0:
+ raise ValueError("timeout must be positive and finite")
+ if args.mode != "models" and not args.payload_file:
+ raise ValueError("this mode requires --payload-file")
+ if args.mode == "models" and args.payload_file:
+ raise ValueError("models is a GET request without a payload")
+ if (args.expect_json or args.expect_text is not None) and args.mode not in {"chat", "responses"}:
+ raise ValueError("text expectations apply only to Chat and Responses")
+ payload = read_payload(args.payload_file) if args.payload_file else None
+ url = endpoint_url(args.base_url, args.mode, args.decisions_alias)
+ if args.dry_run:
+ print(json.dumps({"url": url, "method": "GET" if payload is None else "POST", "payload": payload}, ensure_ascii=False, indent=2))
+ return 0
+ key = resolve_key(args.api_key_env)
+ started = time.monotonic()
+ body = fetch_response(url, payload, key, args.timeout_s, args.mode)
+ summary = validate_response(args.mode, body, payload or {})
+ if args.expect_text is not None and extract_text(args.mode, body) != args.expect_text:
+ raise ValueError("output text did not match the expectation")
+ if args.expect_json:
+ value = json.loads(extract_text(args.mode, body))
+ summary["json_kind"] = type(value).__name__
+ summary.update(mode=args.mode, model=payload.get("model") if payload else None,
+ stream=bool(payload and payload.get("stream")), elapsed_s=round(time.monotonic() - started, 3), ok=True)
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ return 0
+ except error.HTTPError as exc:
+ raw = exc.read().decode("utf-8", "replace")
+ try:
+ parsed_error = json.loads(raw).get("error", {})
+ code = parsed_error.get("code") if isinstance(parsed_error, dict) else None
+ except (ValueError, AttributeError):
+ code = None
+ print(json.dumps({"ok": False, "http_status": exc.code, "error_code": code,
+ "note": "inspect the provider error locally; raw body omitted"}), file=sys.stderr)
+ return 1
+ except (error.URLError, TimeoutError, OSError, ValueError, TypeError, KeyError, IndexError, AttributeError, OverflowError) as exc:
+ message = str(exc)
+ if key:
+ message = message.replace(key, "[redacted]")
+ message = re.sub(r"Bearer\s+\S+", "Bearer [redacted]", message)
+ print(json.dumps({"ok": False, "error_type": type(exc).__name__, "message": message[:240]}, ensure_ascii=False), file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py
index 278d7e83..d8a27754 100644
--- a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/scripts/smoke_structured_output.py
@@ -1,220 +1,106 @@
#!/usr/bin/env python3
-"""Smoke-test EAPIL/OpenAI-compatible structured output behavior."""
-
+"""Convenience structured/vision probe; smoke_api.py covers other API modes."""
from __future__ import annotations
import argparse
import base64
import json
+import math
import mimetypes
-import os
import sys
import time
from pathlib import Path
-from typing import Any
-from urllib import request
+from smoke_api import endpoint_url, extract_text, fetch_response, resolve_key, validate_response
-SCHEMA: dict[str, Any] = {
- "type": "object",
- "additionalProperties": False,
- "properties": {
- "ok": {"type": "boolean"},
- "label": {"type": "string", "maxLength": 64},
- "score": {"type": "number", "minimum": 0, "maximum": 1},
- },
+SCHEMA = {
+ "type": "object", "additionalProperties": False,
+ "properties": {"ok": {"type": "boolean"}, "label": {"type": "string"}, "score": {"type": "number"}},
"required": ["ok", "label", "score"],
}
def data_url(path: Path) -> str:
- mime = mimetypes.guess_type(path.name)[0] or "image/jpeg"
+ mime = mimetypes.guess_type(path.name)[0]
+ if mime not in {"image/png", "image/jpeg", "image/webp", "image/gif"}:
+ raise ValueError("image must be PNG, JPEG, WebP or GIF")
return f"data:{mime};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"
-def endpoint_url(base_url: str, endpoint: str) -> str:
- stripped = base_url.rstrip("/")
- if stripped.endswith("/v1"):
- return f"{stripped}/{endpoint}"
- return f"{stripped}/v1/{endpoint}"
+def build_payload(args) -> dict:
+ prompt = 'Return one object with ok=true, label="smoke", and score=1. No markdown.'
+ if args.endpoint == "responses":
+ content = [{"type": "input_text", "text": prompt}]
+ content.extend({"type": "input_image", "image_url": data_url(Path(p)), "detail": args.image_detail} for p in args.image)
+ body = {"model": args.model, "input": [{"role": "user", "content": content}],
+ "text": {"format": {"type": "json_schema", "name": "gateway_smoke", "strict": True, "schema": SCHEMA}},
+ "max_output_tokens": args.max_output_tokens, "store": False}
+ if args.reasoning_effort not in {"omit", "auto", "default"}:
+ body["reasoning"] = {"effort": args.reasoning_effort}
+ else:
+ content = [{"type": "text", "text": prompt}]
+ content.extend({"type": "image_url", "image_url": {"url": data_url(Path(p)), "detail": args.image_detail}} for p in args.image)
+ body = {"model": args.model, "messages": [{"role": "user", "content": content}],
+ "response_format": {"type": "json_schema", "json_schema": {"name": "gateway_smoke", "strict": True, "schema": SCHEMA}},
+ "max_completion_tokens": args.max_output_tokens}
+ if args.reasoning_effort not in {"omit", "auto", "default"}:
+ body["reasoning_effort"] = args.reasoning_effort
+ return body
-def responses_payload(args: argparse.Namespace) -> dict[str, Any]:
- content: list[dict[str, Any]] = [
- {
- "type": "input_text",
- "text": (
- "只输出一个 JSON object,不要 markdown。"
- "判断输入是否可用于结构化输出 smoke test。"
- ),
- }
- ]
- for image in args.image:
- content.append(
- {
- "type": "input_image",
- "image_url": data_url(Path(image)),
- "detail": args.image_detail,
- }
- )
- payload: dict[str, Any] = {
- "model": args.model,
- "input": [{"role": "user", "content": content}],
- "text": {
- "format": {
- "type": "json_schema",
- "name": "eapil_llmapi_structured_smoke",
- "strict": True,
- "schema": SCHEMA,
- }
- },
- "truncation": "disabled",
- "max_output_tokens": args.max_output_tokens,
- }
- if args.reasoning_effort not in {"auto", "default", "omit"}:
- payload["reasoning"] = {"effort": args.reasoning_effort}
- return payload
+def validate_smoke_object(value) -> None:
+ if not isinstance(value, dict) or set(value) != {"ok", "label", "score"}:
+ raise ValueError("structured result has wrong keys")
+ if type(value["ok"]) is not bool or not isinstance(value["label"], str):
+ raise ValueError("structured result has wrong field types")
+ score = value["score"]
+ if isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score):
+ raise ValueError("structured score must be finite")
+ if value != {"ok": True, "label": "smoke", "score": 1}:
+ raise ValueError("structured result did not match the synthetic task")
-def chat_payload(args: argparse.Namespace) -> dict[str, Any]:
- content: list[dict[str, Any]] = [
- {
- "type": "text",
- "text": (
- "只输出一个 JSON object,不要 markdown。"
- "判断输入是否可用于结构化输出 smoke test。"
- ),
- }
- ]
- for image in args.image:
- content.append(
- {
- "type": "image_url",
- "image_url": {"url": data_url(Path(image)), "detail": args.image_detail},
- }
- )
- payload: dict[str, Any] = {
- "model": args.model,
- "messages": [{"role": "user", "content": content}],
- "response_format": {
- "type": "json_schema",
- "json_schema": {
- "name": "eapil_llmapi_structured_smoke",
- "strict": True,
- "schema": SCHEMA,
- },
- },
- "temperature": 0,
- "max_tokens": args.max_output_tokens,
- }
- if args.reasoning_effort not in {"auto", "default", "omit"}:
- payload["reasoning_effort"] = args.reasoning_effort
- return payload
-
-
-def extract_responses_text(response: dict[str, Any]) -> str:
- if isinstance(response.get("output_text"), str):
- return str(response["output_text"])
- texts: list[str] = []
- for item in response.get("output") or []:
- if not isinstance(item, dict):
- continue
- for content in item.get("content") or []:
- if isinstance(content, dict) and isinstance(content.get("text"), str):
- texts.append(str(content["text"]))
- return "\n".join(texts) if texts else json.dumps(response, ensure_ascii=False)
-
-
-def extract_chat_text(response: dict[str, Any]) -> str:
- choices = response.get("choices")
- if isinstance(choices, list) and choices:
- message = choices[0].get("message") if isinstance(choices[0], dict) else None
- content = message.get("content") if isinstance(message, dict) else None
- if isinstance(content, str):
- return content
- return json.dumps(response, ensure_ascii=False)
-
-
-def post_json(url: str, payload: dict[str, Any], api_key: str, timeout_s: float) -> dict[str, Any]:
- body = json.dumps(payload).encode("utf-8")
- req = request.Request(
- url,
- data=body,
- headers={
- "Authorization": f"Bearer {api_key}",
- "Content-Type": "application/json",
- },
- method="POST",
- )
- with request.urlopen(req, timeout=timeout_s) as response: # noqa: S310
- return json.loads(response.read().decode("utf-8"))
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser()
+def main(argv=None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base-url", default="https://api-n-cd.playones.com/v1")
- parser.add_argument("--model", default="gpt-5.5")
+ parser.add_argument("--model", required=True)
parser.add_argument("--endpoint", choices=["responses", "chat"], default="responses")
- parser.add_argument("--reasoning-effort", default="none")
- parser.add_argument("--max-output-tokens", type=int, default=300)
+ parser.add_argument("--reasoning-effort", default="omit")
+ parser.add_argument("--max-output-tokens", type=int, default=256)
parser.add_argument("--image", action="append", default=[])
parser.add_argument("--image-detail", default="low")
- parser.add_argument("--api-key-env", default="OPENAI_API_KEY")
- parser.add_argument("--timeout-s", type=float, default=240.0)
+ parser.add_argument("--api-key-env")
+ parser.add_argument("--timeout-s", type=float, default=60)
parser.add_argument("--dry-run", action="store_true")
- return parser.parse_args()
-
-
-def main() -> int:
- args = parse_args()
- payload = responses_payload(args) if args.endpoint == "responses" else chat_payload(args)
- path = "responses" if args.endpoint == "responses" else "chat/completions"
- url = endpoint_url(args.base_url, path)
- if args.dry_run:
- print(json.dumps({"url": url, "payload": payload}, ensure_ascii=False, indent=2))
- return 0
- api_key = os.getenv(args.api_key_env)
- if not api_key:
- print(f"Missing API key env: {args.api_key_env}", file=sys.stderr)
- return 2
- started = time.perf_counter()
- response = post_json(url, payload, api_key, args.timeout_s)
- elapsed = time.perf_counter() - started
- raw_text = (
- extract_responses_text(response)
- if args.endpoint == "responses"
- else extract_chat_text(response)
- )
+ args = parser.parse_args(argv)
+ key = ""
try:
- parsed = json.loads(raw_text)
- parse_ok = isinstance(parsed, dict)
- except json.JSONDecodeError:
- parse_ok = False
- output_types = [
- item.get("type")
- for item in response.get("output", [])
- if isinstance(item, dict)
- ]
- print(
- json.dumps(
- {
- "endpoint": args.endpoint,
- "elapsed_s": round(elapsed, 3),
- "usage": response.get("usage"),
- "raw_text_chars": len(raw_text),
- "raw_text_preview": raw_text[:240],
- "parse_ok": parse_ok,
- "has_encrypted_reasoning": "encrypted_content" in json.dumps(
- response,
- ensure_ascii=False,
- ),
- "output_item_types": output_types,
- },
- ensure_ascii=False,
- indent=2,
- )
- )
- return 0 if parse_ok else 1
+ if args.max_output_tokens <= 0 or not math.isfinite(args.timeout_s) or args.timeout_s <= 0:
+ raise ValueError("token limit and timeout must be positive")
+ body = build_payload(args)
+ url = endpoint_url(args.base_url, args.endpoint)
+ if args.dry_run:
+ print(json.dumps({"url": url, "payload": body}, ensure_ascii=False, indent=2))
+ return 0
+ key = resolve_key(args.api_key_env)
+ started = time.monotonic()
+ response = fetch_response(url, body, key, args.timeout_s, args.endpoint)
+ summary = validate_response(args.endpoint, response, body)
+ validate_smoke_object(json.loads(extract_text(args.endpoint, response)))
+ summary.update(ok=True, schema_checked=True, endpoint=args.endpoint,
+ model=args.model, elapsed_s=round(time.monotonic()-started, 3))
+ print(json.dumps(summary, ensure_ascii=False, indent=2))
+ return 0
+ except Exception as exc:
+ # Deliberately avoid raw provider bodies, prompts, images and credentials.
+ message = str(exc).replace(key, "[redacted]") if key else str(exc)
+ report = {"ok": False, "error_type": type(exc).__name__}
+ if hasattr(exc, "code"):
+ report["http_status"] = exc.code
+ else:
+ report["message"] = message[:240]
+ print(json.dumps(report, ensure_ascii=False), file=sys.stderr)
+ return 1
if __name__ == "__main__":
diff --git a/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py
new file mode 100644
index 00000000..52bd41ae
--- /dev/null
+++ b/plugins/codex/plugins/eapil-llmapi-structured/skills/eapil-llmapi-structured/tests/test_smoke_api.py
@@ -0,0 +1,195 @@
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import smoke_api as api
+import smoke_structured_output as structured
+
+
+def stream(*events):
+ lines = []
+ for event in events:
+ value = event if isinstance(event, str) else json.dumps(event, ensure_ascii=False)
+ lines.append(("data: " + value + "\r\n\r\n").encode("utf-8"))
+ return io.BytesIO(b"".join(lines))
+
+
+class APIProbeTests(unittest.TestCase):
+ def setUp(self):
+ self.network = mock.patch.object(api.request, "urlopen", side_effect=AssertionError("unexpected network"))
+ self.urlopen = self.network.start()
+ self.addCleanup(self.network.stop)
+
+ def test_endpoint_joining_preserves_prefix_and_supports_decisions_alias(self):
+ for base in ("https://example.test", "https://example.test/v1", "https://example.test/v1/"):
+ self.assertEqual(api.endpoint_url(base, "chat"), "https://example.test/v1/chat/completions")
+ self.assertEqual(api.endpoint_url("https://example.test/relay/v1", "responses"), "https://example.test/relay/v1/responses")
+ self.assertEqual(api.endpoint_url("https://example.test/v1", "decisions", True), "https://example.test/api/alpha/decisions")
+ self.assertEqual(api.endpoint_url("https://example.test/api", "decisions"), "https://example.test/api/alpha/decisions")
+ for base in ("https://key@example.test", "https://example.test?key=secret", "file:///tmp"):
+ with self.subTest(base=base), self.assertRaises(ValueError):
+ api.endpoint_url(base, "chat")
+
+ def test_wire_request_preserves_zero_false_unicode_and_exact_mode_shape(self):
+ payload = {"model": "alias", "messages": [{"role": "user", "content": '中文 "quoted" $HOME'}], "temperature": 0, "stream": False}
+ response = io.BytesIO(b'{"choices":[]}')
+ response.headers = {"Content-Type": "application/json"}
+ self.urlopen.side_effect = None
+ self.urlopen.return_value = response
+ api.fetch_response("https://example.test/v1/chat/completions", payload, "test-key", 12, "chat")
+ req = self.urlopen.call_args.args[0]
+ self.assertEqual(req.get_method(), "POST")
+ self.assertEqual(req.get_header("Authorization"), "Bearer test-key")
+ self.assertEqual(json.loads(req.data), payload)
+ self.assertEqual(self.urlopen.call_args.kwargs["timeout"], 12)
+
+ def test_models_is_get_and_has_no_body(self):
+ response = io.BytesIO(b'{"data":[]}')
+ response.headers = {}
+ self.urlopen.side_effect = None
+ self.urlopen.return_value = response
+ api.fetch_response("https://example.test/v1/models", None, "test-key", 12, "models")
+ req = self.urlopen.call_args.args[0]
+ self.assertEqual(req.get_method(), "GET")
+ self.assertIsNone(req.data)
+
+ def test_responses_text_does_not_fall_back_to_serializing_raw_response(self):
+ self.assertEqual(api.extract_text("responses", {"output": [{"type": "reasoning", "content": [{"text": "private reasoning"}]}]}), "")
+ self.assertEqual(api.extract_text("responses", {"output": [{"type": "message", "content": [{"type": "output_text", "text": "答案"}]}]}), "答案")
+
+ def test_empty_chat_truncation_refusal_and_incomplete_responses_fail(self):
+ bad = [
+ ("chat", {"choices": [{"message": {"content": ""}, "finish_reason": "stop"}]}),
+ ("chat", {"choices": [{"message": {"content": "partial"}, "finish_reason": "length"}]}),
+ ("chat", {"choices": [{"message": {"refusal": "refused"}, "finish_reason": "stop"}]}),
+ ("responses", {"status": "incomplete", "output": []}),
+ ("responses", {"status": "completed", "output": []}),
+ ("responses", {"status": "completed", "output": [{"type": "reasoning"}]}),
+ ("responses", {"status": "completed", "output": [{"type": "message", "content": []}]}),
+ ("responses", {"status": "completed", "output": [{"type": "web_search_call", "status": "failed"}]}),
+ ("responses", {"status": "completed", "output": [{"type": "message", "content": [{"type": "refusal", "refusal": "no"}]}]}),
+ ]
+ for mode, body in bad:
+ with self.subTest(mode=mode, body=body), self.assertRaises(ValueError):
+ api.validate_response(mode, body, {})
+
+ def test_tool_only_chat_and_responses_are_valid_without_text(self):
+ chat = {"choices": [{"message": {"content": None, "tool_calls": [{"function": {"name": "echo", "arguments": "{}"}}]}, "finish_reason": "tool_calls"}]}
+ self.assertEqual(api.validate_response("chat", chat, {})["tool_calls"], 1)
+ response = {"status": "completed", "output": [{"type": "function_call", "name": "echo", "arguments": "{}"}]}
+ self.assertEqual(api.validate_response("responses", response, {})["output_types"], ["function_call"])
+
+ def test_chat_sse_assembles_utf8_and_usage_and_requires_done(self):
+ events = [
+ {"choices": [{"index": 0, "delta": {"content": "中"}, "finish_reason": None}]},
+ {"choices": [{"index": 0, "delta": {"content": "文"}, "finish_reason": "stop"}]},
+ {"choices": [], "usage": {"total_tokens": 5}},
+ ]
+ result = api.read_stream(stream(*events, "[DONE]"), "chat")
+ self.assertEqual(api.extract_text("chat", result), "中文")
+ self.assertEqual(api.validate_response("chat", result, {})["usage"]["total_tokens"], 5)
+ with self.assertRaises(ValueError):
+ api.read_stream(stream(*events), "chat")
+
+ def test_chat_tool_sse_preserves_call_identity_and_joins_arguments(self):
+ result = api.read_stream(stream(
+ {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "call_1", "type": "function", "function": {"name": "echo", "arguments": '{"text":'}}]}}]},
+ {"choices": [{"index": 0, "delta": {"tool_calls": [{"index": 0, "function": {"arguments": '"ping"}'}}]}, "finish_reason": "tool_calls"}]},
+ "[DONE]",
+ ), "chat")
+ call = result["choices"][0]["message"]["tool_calls"][0]
+ self.assertEqual(call["id"], "call_1")
+ self.assertEqual(call["type"], "function")
+ self.assertEqual(call["function"]["name"], "echo")
+ self.assertEqual(json.loads(call["function"]["arguments"]), {"text": "ping"})
+ self.assertEqual(api.validate_response("chat", result, {})["tool_calls"], 1)
+
+ def test_responses_sse_recovers_items_when_terminal_output_is_empty(self):
+ item = {"type": "message", "content": [{"type": "output_text", "text": "PONG"}]}
+ result = api.read_stream(stream(
+ {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "P"},
+ {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "ONG"},
+ {"type": "response.output_text.done", "output_index": 0, "content_index": 0, "text": "PONG"},
+ {"type": "response.output_item.done", "output_index": 0, "item": item},
+ {"type": "response.completed", "response": {"status": "completed", "output": [], "usage": {"output_tokens": 6}}},
+ ), "responses")
+ self.assertEqual(api.extract_text("responses", result), "PONG")
+ self.assertEqual(api.validate_response("responses", result, {})["usage"]["output_tokens"], 6)
+
+ def test_responses_sse_deltas_without_item_snapshot_and_failure_terminals(self):
+ result = api.read_stream(stream(
+ {"type": "response.output_text.delta", "output_index": 0, "content_index": 0, "delta": "answer"},
+ {"type": "response.completed", "response": {"status": "completed", "output": []}},
+ ), "responses")
+ self.assertEqual(api.extract_text("responses", result), "answer")
+ for kind in ("error", "response.failed", "response.incomplete"):
+ with self.subTest(kind=kind), self.assertRaises(ValueError):
+ api.read_stream(stream({"type": kind}), "responses")
+ with self.assertRaises(ValueError):
+ api.read_stream(stream({"type": "response.output_text.delta", "delta": "partial"}), "responses")
+
+ def test_embedding_indices_dimensions_and_finite_vectors(self):
+ payload = {"input": ["one", "two"]}
+ body = {"data": [{"index": 1, "embedding": [0.1, 0.2]}, {"index": 0, "embedding": [0.3, 0.4]}]}
+ self.assertEqual(api.validate_response("embedding", body, payload)["dimensions"], [2, 2])
+ with self.assertRaises(ValueError):
+ api.validate_response("embedding", body, dict(payload, dimensions=128))
+ for vector in ([], [float("nan")], [True], "base64"):
+ with self.subTest(vector=vector), self.assertRaises(ValueError):
+ api.validate_response("embedding", {"data": [{"index": 0, "embedding": vector}]}, {"input": "one"})
+ with self.assertRaises(ValueError):
+ api.validate_response("embedding", {"data": [{"index": False, "embedding": [1]}]}, {"input": "one"})
+
+ def test_rerank_accepts_logits_and_maps_original_indices(self):
+ result = api.validate_response("rerank", {"results": [{"index": 1, "relevance_score": 1.77}, {"index": 0, "relevance_score": -11.02}]}, {"documents": ["apple", "password"]})
+ self.assertEqual(result["indices"], [1, 0])
+ self.assertFalse(result["documents_returned"])
+ for index in (2, -1, True):
+ with self.subTest(index=index), self.assertRaises(ValueError):
+ api.validate_response("rerank", {"results": [{"index": index, "relevance_score": 1}]}, {"documents": ["one"]})
+
+ def test_decision_fractional_score_confidence_and_question_correspondence(self):
+ payload = {"questions": {
+ "team": {"type": "choice", "criteria": {"a": "one", "b": "two"}},
+ "urgency": {"type": "score", "criteria": ["wait", "soon", "now"]},
+ "correct": {"type": "noul", "criteria": "correct answer"},
+ }}
+ answers = {"team": {"type": "choice", "choice": "a", "probabilities": {"a": .99, "b": .01}, "confidence": .94},
+ "urgency": {"type": "score", "score": 1.97, "probabilities": {"0": .01, "1": .01, "2": .98}, "confidence": .87},
+ "correct": {"type": "noul", "noul": .96}}
+ self.assertEqual(api.validate_response("decisions", {"answers": answers}, payload)["answers"], answers)
+ with self.assertRaises(ValueError):
+ api.validate_response("decisions", {"answers": {"correct": answers["correct"]}}, payload)
+ bad = {**answers, "correct": {"type": "noul", "noul": 2}}
+ with self.assertRaises(ValueError):
+ api.validate_response("decisions", {"answers": bad}, payload)
+
+ def test_structured_validation_does_not_confuse_boolean_with_numeric_or_accept_extra_keys(self):
+ structured.validate_smoke_object({"ok": True, "label": "smoke", "score": 1})
+ for value in ({"ok": 1, "label": "smoke", "score": 1},
+ {"ok": True, "label": "smoke", "score": True},
+ {"ok": True, "label": "smoke", "score": 1, "extra": 0}):
+ with self.subTest(value=value), self.assertRaises(ValueError):
+ structured.validate_smoke_object(value)
+
+ def test_dry_run_needs_no_key_makes_no_network_call_and_reads_bom(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = Path(tmp) / "payload.json"
+ path.write_text(json.dumps({"model": "alias", "input": "中文"}), encoding="utf-8-sig")
+ output = io.StringIO()
+ with contextlib.redirect_stdout(output), mock.patch.dict(api.os.environ, {}, clear=True):
+ self.assertEqual(api.main(["--mode", "responses", "--payload-file", str(path), "--dry-run"]), 0)
+ self.assertEqual(json.loads(output.getvalue())["payload"]["input"], "中文")
+ self.urlopen.assert_not_called()
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json
index 5ec5cd7a..a8b92dd0 100644
--- a/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/eapil-voice/.codex-plugin/plugin.json
@@ -1,13 +1,13 @@
{
"name": "eapil-voice",
- "version": "0.1.0",
- "description": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。",
+ "version": "1.0.0",
+ "description": "易瞳音频服务调用指南:语音设计、朗读、克隆、ASR、MossFormer2/RE-USE 降噪、Stable Audio 音乐与音效、Yue2 歌曲及批量导出。包含请求校验、错误处理和实测记录。",
"author": {
"name": "EAPIL",
"url": "https://git.playones.com/arechen/EapilSkillMarket"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
+ "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/eapil-voice",
+ "repository": "https://git.playones.com/chenmingxuan/ManySkills",
"license": "Proprietary",
"keywords": [
"eapil",
@@ -16,9 +16,9 @@
],
"skills": "./skills/",
"interface": {
- "displayName": "易瞳语音转录|设计|克隆|降噪",
- "shortDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。",
- "longDescription": "当任务需要合成语音、根据指令设计语音、从参考音频克隆语音、使用 VoxCPM2 或 Qwen 提供商的服务、对音频进行增强或去噪、通过该服务转录音频、批量进行 ASR/警报音克隆、将生成的语音导出为 WAV/MP3 格式、检查模型状态,或生成用于 Eapil-Voice API 的 curl/Python 调用时,应使用此服务。",
+ "displayName": "易瞳音频:语音 / 转录 / 降噪 / 音乐 / 音效",
+ "shortDescription": "语音、ASR、降噪、音乐、音效与歌曲的调用及验证指南。",
+ "longDescription": "按需选择 Qwen/VoxCPM2 语音、转录、两类降噪、Stable Audio 和 Yue2 接口。配套单请求客户端与顺序批处理,校验 WAV/JSON 响应、使用准确参考转写,并明确超时不可盲目重发及部署限制。保留 eapil-voice 插件 ID。",
"developerName": "EAPIL",
"category": "Creativity",
"capabilities": [
@@ -26,7 +26,7 @@
"Write"
],
"defaultPrompt": [
- "使用 易瞳语音转录|设计|克隆|降噪 帮我完成这个任务。"
+ "使用 易瞳音频 帮我选择接口并完成音频任务。"
],
"websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
"privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
diff --git a/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..9378c334
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "eapil-voice",
+ "repo": "https://git.playones.com/chenmingxuan/ManySkills.git",
+ "ref": "v1.0.0",
+ "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d",
+ "adapter": "codex-plugin",
+ "sourcePath": "plugins/eapil-voice",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md
index 816b0049..8310731e 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/SKILL.md
@@ -1,120 +1,47 @@
---
name: eapil-voice
-description: Guide agents to call the hosted Eapil-Voice/Qwen3 Voice Design FastAPI service by domain for audio generation workflows. Use when a task asks to synthesize speech, design a voice from an instruction, clone a voice from reference audio, use VoxCPM2 or Qwen providers, enhance or denoise audio, transcribe audio through this service, batch-ASR/clone alert sounds, export generated speech as WAV/MP3, check model health, or produce curl/Python calls for the Eapil-Voice API.
+description: Build, test or use EAPIL audio API calls for speech design and cloning, transcription, denoising, music, sound effects and lyric-conditioned songs; includes sequential batch speech export.
---
-# Eapil-Voice
+# EAPIL audio service
-## Purpose
+Use the hosted service at `https://ades.playones.com`, or the deployment chosen by the user. Keep the historical skill/plugin ID for compatibility; this guide covers the whole audio service.
-Use this skill to call the hosted Eapil-Voice API by domain. Prefer the existing API surface over inventing client contracts.
+| Need | Route | Read when relevant |
+|---|---|---|
+| Describe a voice and speak text | `/v1/voice/design` | [Speech](references/speech.md) |
+| Ordinary Qwen text reading | `/v1/voice/synthesize` | [Speech](references/speech.md) |
+| Clone from reference speech | `/v1/voice/clone` | [Speech](references/speech.md) |
+| Transcribe speech | `/v1/asr/transcribe` | [ASR and enhancement](references/recognition-enhancement.md) |
+| MossFormer2 enhancement / alias | `/v1/audio/enhance`, `/v1/audio/denoise` | [ASR and enhancement](references/recognition-enhancement.md) |
+| RE-USE denoising | `/v1/audio/reuse` | [ASR and enhancement](references/recognition-enhancement.md) |
+| Instrumental music or sound effects | `/v1/audio/music`, `/v1/audio/sfx` | [Stable Audio](references/music.md) |
+| Songs from lyrics or ABC arrangement | `/v1/audio/music/yue2` | [Yue2](references/yue2.md) |
+| Directory-scale ASR/clone/export | Sequential calls | [Batch audio](references/batch-audio.md) |
-Default base URL is `https://ades.playones.com`. Override it only when the user gives another domain or `QWEN3_VOICE_API_BASE_URL`.
+Read [transport and errors](references/api.md) for client integration or troubleshooting. Consult `/openapi.json` and `/models` when deployment facts matter; a registered route or healthy service does not prove a model can infer. Dated evidence is in [verification](references/verification.md). Read only the mode needed.
-Use `OPENAI_API_KEY` as the API key environment variable. If it is not set and the user did not provide a key directly, tell the user to get their key from `https://kinfo.playones.com`. Direct key use is allowed with `--token` or an `Authorization: Bearer ` header.
+## Call the actual contract
-## Call Workflow
+Use `AUDIO_API_BASE` and `AUDIO_API_TOKEN`; old `QWEN3_VOICE_API_BASE_URL` and `OPENAI_API_KEY` remain fallback aliases. Explicit flags take precedence. An available legacy key is not proof of authorization on this deployment. Inference requires Bearer authentication; health/models are public. Obtain missing credentials from the provider without putting them in chat or tracked files.
-1. Resolve the base URL first. Use `https://ades.playones.com` unless the user gives another domain.
-2. Check `GET /health` before expensive generation.
-3. Add `Authorization: Bearer ` from an explicit user-provided key or `OPENAI_API_KEY`.
-4. Choose the endpoint by desired output:
- - Voice from style instruction: `POST /v1/voice/design`.
- - Base TTS by speaker name: `POST /v1/voice/synthesize`.
- - Voice clone from reference audio: `POST /v1/voice/clone`.
- - Speech enhancement or denoise: `POST /v1/audio/enhance` or `POST /v1/audio/denoise`.
- - Transcription: `POST /v1/asr/transcribe` returns JSON, not audio.
-5. Save audio-output responses as `.wav`; these endpoints return `audio/wav`.
-6. Read `/openapi.json` from the active base URL when exact current form fields matter.
-7. Read `references/api.md` before writing detailed client code, exposing all services, troubleshooting errors, or selecting Qwen vs VoxCPM2 parameters.
-8. Read `references/batch-audio.md` before batch ASR/clone/export work, especially game filter alert sounds.
+Speech design/synthesis, music/SFX and Yue2 take JSON. Clone, ASR and enhancement use multipart with file field `file`; let the client create the boundary. Audio success is complete WAV bytes, not JSON/Base64/a URL or inference streaming. ASR returns JSON. Preserve explicit false/0; keep provider and endpoint controls separate.
-## Provider Rules
+[call_voice_api.py](scripts/call_voice_api.py) makes one validated request. Existing command names remain; new commands are reuse, music, sfx, yue2. Dry run prints the payload without a key/network call, so share only synthetic inputs. Resolve the actual skill directory:
-Use `provider=qwen` by default. Only `/v1/voice/design` and `/v1/voice/clone` accept `provider=voxcpm2`.
+~~~powershell
+python '/scripts/call_voice_api.py' design --text '你好。' --instruct '温暖自然、清晰的女声。' --language Chinese --max-tokens 512 --out voice.wav
+python '/scripts/call_voice_api.py' asr --file speech.wav --language zh
+~~~
-For VoxCPM2:
-- Pass `provider=voxcpm2`.
-- Use `cfg_value`, `inference_timesteps`, `normalize`, `denoise`, and `retry_badcase` for generation controls.
-- Set `denoise=true` only when the server is configured with `VOXCPM2_LOAD_DENOISER=true`.
-- For clone with `ref_text`, expect Hi-Fi transcript-guided cloning.
-- For clone without `ref_text`, use `instruct` for controllable cloning.
+Prefix external commands with `rtk proxy` in this repository.
-For Qwen clone, always provide both `text` and `ref_text`.
+## Verify and deliver
-## Batch Audio Rules
+Check status/media type and complete WAV structure, then verify what matters: spoken content, accurate reference transcript, intelligibility/noise or musical content/duration. A valid WAV alone does not prove voice similarity or listening quality. Read actual rate/channels from the header; MP3 requires local transcoding.
-- For many short alert sounds, ask only if the output mode is genuinely ambiguous: generated-only files, full replacement files, or overlay/mix.
-- Do not assume ASR segment timestamps are source-separation masks. ASR can locate/transcribe speech; it cannot remove an old voice from mixed music/SFX by itself.
-- If the user wants clean replacement assets for another tool, export generated speech only, named after the source files. Use `scripts/batch_clone_generated_audio.py`.
-- If the user wants to preserve music/SFX while replacing embedded speech, state that source separation or stems are required for clean removal. Do not mix the original voice under the generated voice unless the user explicitly asks for overlay.
-- Normalize generated text before clone: add expressive punctuation when useful; if the final character is not `。!?.!?~~`, append `。` for Chinese alert text.
-- For final MP3 delivery, convert the generated WAVs with `ffmpeg` and explicit gain such as `volume=4dB` only when requested.
+Do not invent transcripts from filenames, manufacture speech after empty ASR, silently rewrite requested text or treat ASR timestamps as source separation. Punctuation normalization, gain and overlays apply only when requested.
-## Quick Examples
+Requests wait for the final result. There is no job ID, polling/cancel endpoint or idempotency key. Timeout/504 may leave inference running: do not automatically resubmit. Keep calls sequential initially; only an explicit busy rejection is eligible for bounded backoff.
-Use JSON for voice design and synthesis:
-
-```bash
-BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}"
-
-curl -X POST "$BASE_URL/v1/voice/design" \
- -H "Authorization: Bearer $OPENAI_API_KEY" \
- -H 'Content-Type: application/json' \
- -d '{"provider":"qwen","text":"Hello world","instruct":"Warm, calm, natural narration","language":"auto","temperature":0.9,"max_tokens":4096,"top_k":50,"top_p":1.0,"repetition_penalty":1.05}' \
- --output voice_design.wav
-```
-
-Use multipart form data for clone, enhancement, denoise, and ASR:
-
-```bash
-BASE_URL="${QWEN3_VOICE_API_BASE_URL:-https://ades.playones.com}"
-
-curl -X POST "$BASE_URL/v1/voice/clone" \
- -H "Authorization: Bearer $OPENAI_API_KEY" \
- -F 'file=@reference.wav' \
- -F 'provider=qwen' \
- -F 'text=This is the cloned sentence.' \
- -F 'ref_text=Transcript of the reference audio.' \
- -F 'language=auto' \
- -F 'temperature=0.9' \
- -F 'max_tokens=4096' \
- -F 'top_k=50' \
- -F 'top_p=1.0' \
- -F 'repetition_penalty=1.05' \
- --output voice_clone.wav
-```
-
-Use the bundled dependency-free client when convenient:
-
-```bash
-python ~/.codex/skills/eapil-voice/scripts/call_voice_api.py design \
- --text "Hello world" \
- --instruct "Warm, calm, natural narration" \
- --out voice_design.wav
-```
-
-Batch clone generated speech only, preserving source names:
-
-```bash
-python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \
- --base-url https://ades.playones.com \
- --token "$OPENAI_API_KEY" \
- --input-dir "/path/to/filter-sounds" \
- --reference-audio reference.wav \
- --output-dir "/path/to/generated-mp3" \
- --format mp3 \
- --gain-db 4
-```
-
-On Windows, prefer direct command execution or a single quoted `Start-Process -ArgumentList` string for paths containing spaces or CJK characters.
-
-## Source Anchors
-
-When a local source checkout is available and the task depends on implementation details, use it only as source reference. Do not prefer local service startup over the hosted domain:
-
-- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/voice.py`
-- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/audio.py`
-- `/Users/eapil/git/QWEN3-Voice-Design/app/api/routes/asr.py`
-- `/Users/eapil/git/QWEN3-Voice-Design/app/schemas/voice.py`
-- `/Users/eapil/git/QWEN3-Voice-Design/README.md`
+Return usable local audio, endpoint/provider and limitations. In Codex, preview delivered audio with an absolute path. Record actual coverage; keep credentials, probe outputs and private transcripts outside the repository.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml
index 73e1eafa..35188b44 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Eapil-Voice"
- short_description: "Call hosted Eapil voice APIs"
- default_prompt: "Use $eapil-voice to transcribe, clone, or batch-export speech through the hosted Eapil-Voice API."
+ display_name: "EAPIL Audio"
+ short_description: "EAPIL speech, ASR, denoising, music and song APIs"
+ default_prompt: "Use $eapil-voice to choose and call the EAPIL audio endpoint for speech, transcription, denoising, music or songs."
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md
index 404b0220..2fddb284 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/api.md
@@ -1,359 +1,37 @@
-# Eapil-Voice API Reference
+# Transport and deployed contract
-## Defaults
+Source: maintainer's API usage guide dated 2026-10-06 and live [OpenAPI](https://ades.playones.com/openapi.json). Schema limits, deployment caps and model recommendations differ; deployment settings can change.
-- Base URL: `https://ades.playones.com`
-- Override env: `QWEN3_VOICE_API_BASE_URL`
-- API key env: `OPENAI_API_KEY`
-- Auth header: `Authorization: Bearer `
-- If no key is available, get one from `https://kinfo.playones.com`.
-- Audio-output media type: `audio/wav`
-- Local source checkouts are source references only unless the user explicitly asks to run a local service.
+## Requests and credentials
-## Endpoint Summary
+Base URL is the service root without /v1. Client precedence: explicit flags; AUDIO_API_BASE/AUDIO_API_TOKEN; legacy QWEN3_VOICE_API_BASE_URL/OPENAI_API_KEY; default hosted URL. --api-key-env NAME selects exactly that variable. --token remains for old scripts; environment credentials avoid process-argument exposure. GET health/models require no token.
-| Task | Method | Path | Input | Output |
-| --- | --- | --- | --- | --- |
-| Voice design TTS | `POST` | `/v1/voice/design` | JSON | WAV |
-| Base TTS synthesis | `POST` | `/v1/voice/synthesize` | JSON | WAV |
-| Voice clone | `POST` | `/v1/voice/clone` | multipart form | WAV |
-| Speech enhancement | `POST` | `/v1/audio/enhance` | multipart form | WAV |
-| Denoise alias | `POST` | `/v1/audio/denoise` | multipart form | WAV |
-| ASR transcription | `POST` | `/v1/asr/transcribe` | multipart form | JSON |
-| Health | `GET` | `/health` | none | JSON |
-| Models | `GET` | `/models` | none | JSON |
+Inference uses Bearer auth. Serialize UTF-8 JSON, including multiline lyrics. File workflows prefer multipart with field file, automatically generated boundary and form-valued numbers/booleans. Raw binary with query fields is also supported by the API; the bundled client uses multipart to keep long text out of URLs.
-## Parameter Limits
+Size limit: 26,214,400 bytes (25 MiB). Qwen clone, ASR, MossFormer2 and RE-USE enforce <=60 decoded seconds. VoxCPM2 shares the size limit but not that decoded-duration check; a clear 5–15 second reference is usually sufficient. A .wav extension is not conversion. WAV inputs can be preflight checked; other decoders depend on the server.
-- `provider`: `qwen` or `voxcpm2`
-- `text`: 1 to 4096 characters
-- `instruct`: 1 to 1024 characters; optional for clone
-- `temperature`: 0 to 2
-- `max_tokens`: 1 to 8192
-- `top_k`: 0 to 1000
-- `top_p`: 0 to 1
-- `repetition_penalty`: 0.1 to 2
-- `cfg_value`: 0 to 10
-- `inference_timesteps`: 1 to 200
-- Uploaded audio defaults to a 25 MB and 60 second server-side limit, depending on deployment environment variables.
+## Results
-## Voice Design
+Audio success is complete binary audio/wav. The helper checks media type and complete RIFF format/data chunks before saving. It handles PCM/IEEE float WAV; RF64/compressed WAV needs another validated decoder. ASR must have string text; an empty transcription can represent silence, but cannot drive a batch clone.
-`POST /v1/voice/design`
+Read actual rate/channels. Music/SFX currently use 44.1 kHz stereo PCM16; Yue2 48 kHz stereo PCM16. Provider speech rates vary. RE-USE encodes the input rate; enhancement/RE-USE mix channels to mono and are not stereo mastering tools.
-Use this for text-to-speech driven by a style instruction. Body is JSON.
+Server-side JavaScript reads response.arrayBuffer() for audio and response.json() for ASR, once per response body. Current deployment lacks general browser CORS; use a backend proxy or configured CORS. Keep service tokens on that backend.
-Required:
-- `text`: text to speak
-- `instruct`: style, emotion, voice, or delivery instruction
+## Timeouts and concurrency
-Common optional fields:
-- `provider`: `qwen` default, or `voxcpm2`
-- `language`: `auto` default, used by Qwen
-- `temperature`: `0.9` default, used by Qwen
-- `max_tokens`: `4096` default, used by Qwen
-- `top_k`: `50` default, used by Qwen
-- `top_p`: `1.0` default, used by Qwen
-- `repetition_penalty`: `1.05` default, used by Qwen
-- `cfg_value`: `2.0` default, used by VoxCPM2
-- `inference_timesteps`: `10` default, used by VoxCPM2
-- `normalize`: `false` default, used by VoxCPM2
-- `denoise`: `false` default, used by VoxCPM2
-- `retry_badcase`: `true` default, used by VoxCPM2
+Speech/ASR/enhancement timeout starts at 600 seconds; music/SFX/Yue2 at 1800. Client timeouts cannot extend a gateway timeout. HTTP chunked transport is not progressive generation.
-Qwen example:
+There is no async job, task query, cancellation endpoint or idempotency key. Timeout/504 does not establish that inference stopped; investigate before repeating. Do not invent a status endpoint.
-```bash
-curl -X POST https://ades.playones.com/v1/voice/design \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -H 'Content-Type: application/json' \
- -d '{
- "provider": "qwen",
- "text": "Hello world",
- "instruct": "Warm, calm, natural narration",
- "language": "auto",
- "temperature": 0.9,
- "max_tokens": 4096,
- "top_k": 50,
- "top_p": 1.0,
- "repetition_penalty": 1.05
- }' \
- --output voice_design.wav
-```
+Qwen design can reject busy with 503 and Retry-After. Stable Audio music/SFX share a queue. Qwen synthesis/clone share serialized execution; ASR, VoxCPM2 and Yue2 are separately serialized; MossFormer2/RE-USE share execution. Loading/connection failures can also be 503: status alone is insufficient.
-VoxCPM2 example:
+The single-call helper never retries. Batch --retries defaults to 1 total attempt, capped at 5. Additional attempts only follow explicit busy/running 503 detail without load/error/timeout indicators. It honors numeric Retry-After up to 60 seconds; longer backoff is left to the application, and fallback delays are bounded. Upload bodies are rebuilt in full. Ambiguous errors are failures, not retry signals.
-```bash
-curl -X POST https://ades.playones.com/v1/voice/design \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -H 'Content-Type: application/json' \
- -d '{
- "text": "Hello world",
- "instruct": "Warm, calm, natural narration",
- "provider": "voxcpm2",
- "cfg_value": 2.0,
- "inference_timesteps": 10,
- "normalize": false,
- "denoise": false,
- "retry_badcase": true
- }' \
- --output voxcpm2_voice_design.wav
-```
+## Errors and state
-## Base TTS Synthesis
+Failure detail can be a string/array or gateway HTML. Check status/media before parsing. 400/422 usually require request correction; 401 credential correction; 413 smaller/shorter audio. 404 includes removed MiniMax. 500/501/502/503 need detail to distinguish input errors, unsupported backend, loading and busy. Yue2 upstream 4xx may appear as 502.
-`POST /v1/voice/synthesize`
+Library callers can inspect APIError.status/detail/busy. CLI logs omit raw provider detail because it can echo private inputs; inspect locally and redact before sharing.
-Use this for base Qwen TTS with an optional named speaker. Body is JSON. This endpoint does not accept `provider`.
-
-Required:
-- `text`
-
-Optional:
-- `speaker`: e.g. `Chelsie`; omit for model default
-- `language`: `auto` default
-- `temperature`: `0.9` default
-- `max_tokens`: `4096` default
-- `top_k`: `50` default
-- `top_p`: `1.0` default
-- `repetition_penalty`: `1.05` default
-
-Example:
-
-```bash
-curl -X POST https://ades.playones.com/v1/voice/synthesize \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -H 'Content-Type: application/json' \
- -d '{
- "text": "Hello world",
- "speaker": "Chelsie",
- "language": "auto",
- "temperature": 0.9,
- "max_tokens": 4096,
- "top_k": 50,
- "top_p": 1.0,
- "repetition_penalty": 1.05
- }' \
- --output synthesize.wav
-```
-
-## Voice Clone
-
-`POST /v1/voice/clone`
-
-Use this for cloning from reference audio. Send multipart form upload with `file=@reference.wav`.
-
-Audio input:
-- `file`: reference audio upload
-
-Shared fields:
-- `text`: required output text
-- `provider`: `qwen` default, or `voxcpm2`
-- `language`: `auto` default, used by Qwen
-
-Qwen required fields:
-- `text`
-- `ref_text`: transcript of the reference audio
-- `file`
-
-Qwen optional fields:
-- `temperature`: default `0.9`
-- `max_tokens`: default `4096`
-- `top_k`: default `50`
-- `top_p`: default `1.0`
-- `repetition_penalty`: default `1.05`
-
-VoxCPM2 required fields:
-- `text`
-- `file`
-- `provider=voxcpm2`
-
-VoxCPM2 optional fields:
-- `ref_text`: when present, enables transcript-guided Hi-Fi clone
-- `instruct`: used only when `ref_text` is absent
-- `cfg_value`: default `2.0`
-- `inference_timesteps`: default `10`
-- `normalize`: default `false`
-- `denoise`: default `false`
-- `retry_badcase`: default `true`
-
-Qwen clone example:
-
-```bash
-curl -X POST https://ades.playones.com/v1/voice/clone \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@reference.wav' \
- -F 'provider=qwen' \
- -F 'text=This is the cloned sentence.' \
- -F 'ref_text=This is the transcript of the reference audio.' \
- -F 'language=auto' \
- -F 'temperature=0.9' \
- -F 'max_tokens=4096' \
- -F 'top_k=50' \
- -F 'top_p=1.0' \
- -F 'repetition_penalty=1.05' \
- --output voice_clone.wav
-```
-
-VoxCPM2 controllable clone without transcript:
-
-```bash
-curl -X POST https://ades.playones.com/v1/voice/clone \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@reference.wav' \
- -F 'text=This is the cloned sentence.' \
- -F 'instruct=Keep the same speaker identity with a calm delivery.' \
- -F 'provider=voxcpm2' \
- -F 'cfg_value=2.0' \
- -F 'inference_timesteps=10' \
- -F 'normalize=false' \
- -F 'denoise=false' \
- -F 'retry_badcase=true' \
- --output voxcpm2_controllable_clone.wav
-```
-
-VoxCPM2 Hi-Fi clone with transcript:
-
-```bash
-curl -X POST https://ades.playones.com/v1/voice/clone \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@reference.wav' \
- -F 'text=This is the cloned sentence.' \
- -F 'ref_text=This is the transcript of the reference audio.' \
- -F 'provider=voxcpm2' \
- -F 'cfg_value=2.0' \
- -F 'inference_timesteps=10' \
- -F 'normalize=false' \
- -F 'denoise=false' \
- -F 'retry_badcase=true' \
- --output voxcpm2_hifi_clone.wav
-```
-
-## Speech Enhancement And Denoise
-
-`POST /v1/audio/enhance`
-
-`POST /v1/audio/denoise`
-
-These two paths call the same service and return `enhanced.wav`. Send multipart form upload with `file=@noisy.wav`.
-
-Optional:
-- `chunked`: form or query bool. Accepted truthy values include `1`, `true`, `yes`, `on`; falsy values include `0`, `false`, `no`, `off`.
-
-Example:
-
-```bash
-curl -X POST https://ades.playones.com/v1/audio/enhance \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@noisy.wav' \
- -F 'chunked=false' \
- --output enhanced.wav
-```
-
-Denoise alias:
-
-```bash
-curl -X POST https://ades.playones.com/v1/audio/denoise \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@noisy.wav' \
- -F 'chunked=false' \
- --output denoise.wav
-```
-
-## ASR Transcription
-
-`POST /v1/asr/transcribe`
-
-This is not audio generation. It returns JSON:
-
-```json
-{
- "text": "...",
- "segments": [],
- "language": "English",
- "prompt_tokens": 0,
- "generation_tokens": 0,
- "total_tokens": 0,
- "prompt_tps": 0,
- "generation_tps": 0,
- "total_time": 0
-}
-```
-
-Input is multipart form upload with `file=@speech.wav`.
-
-Optional fields can be form or query params:
-- `language`: normalized aliases include `zh`, `zh-cn`, `en`, `ja`, `ko`; default is `English`
-- `temperature`: default `0.0`
-- `max_tokens`: default `8192`
-- `top_k`: default `0`
-- `top_p`: default `1.0`
-- `repetition_penalty`: default omitted
-
-Example:
-
-```bash
-curl -X POST https://ades.playones.com/v1/asr/transcribe \
- -H 'Authorization: Bearer $OPENAI_API_KEY' \
- -F 'file=@speech.wav' \
- -F 'language=auto' \
- -F 'temperature=0.0' \
- -F 'max_tokens=8192' \
- -F 'top_k=0' \
- -F 'top_p=1.0'
-```
-
-## Health And Models
-
-```bash
-curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/health
-curl -H 'Authorization: Bearer $OPENAI_API_KEY' https://ades.playones.com/models
-```
-
-Model status keys:
-- `voice_design_tts`
-- `base_tts`
-- `asr`
-- `speech_enhancement`
-- `voxcpm2`
-
-Models are lazy-loaded on first request, so `loaded: false` can be normal before use.
-
-## Errors And Troubleshooting
-
-- `401 unauthorized`: the request lacks a valid `Authorization: Bearer `. Use `OPENAI_API_KEY`, pass `--token`, or get a key from `https://kinfo.playones.com`.
-- `400 provider must be one of: qwen, voxcpm2`: invalid or empty provider.
-- `400 audio file is required`: missing audio input for enhancement or ASR.
-- `400 reference audio file is required`: missing clone reference audio.
-- `400 text and ref_text are required`: Qwen clone requires both.
-- `400 text is required`: VoxCPM2 clone requires `text`.
-- `400 denoise=true requires VOXCPM2_LOAD_DENOISER=true`: retry with `denoise=false` or reconfigure server.
-- `501 VOXCPM2_BACKEND=vllm_omni is not implemented in this build`: local ARM Mac path supports `official`.
-- `503 failed to load ... model`: model package, model id, local weights, or optional `voxcpm` dependency is unavailable.
-- `504 Gateway Time-out`: the request passed HTTP validation but generation exceeded the gateway timeout. Retry after model warmup, reduce `max_tokens` for short test requests, or inspect server logs.
-- `500 Internal Server Error`: the request reached the application but failed internally. Re-run with `curl -i` and no `--output` to capture the response body; if the body is only `Internal Server Error`, inspect server logs.
-
-## Environment Variables
-
-Server-side:
-
-```env
-OPENAI_API_KEY=your_key_here
-TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-1.7B-VoiceDesign-bf16
-BASE_TTS_MODEL_ID=mlx-community/Qwen3-TTS-12Hz-0.6B-Base-8bit
-ASR_MODEL_ID=mlx-community/Qwen3-ASR-1.7B-8bit
-SE_MODEL_ID=starkdmi/MossFormer2_SE_48K_MLX
-SE_MODEL_PRECISION=int8
-VOXCPM2_MODEL_ID=openbmb/VoxCPM2
-VOXCPM2_BACKEND=official
-VOXCPM2_LOAD_DENOISER=false
-VOXCPM2_DEVICE=auto
-VOXCPM2_OPTIMIZE=true
-```
-
-Client-side convenience:
-
-```env
-QWEN3_VOICE_API_BASE_URL=https://ades.playones.com
-OPENAI_API_KEY=your_key_here
-```
+Health ok means API responsiveness. Models keys include voice_design_tts, base_tts, asr, speech_enhancement, reuse, stable_audio_3, yue2, voxcpm2. Loaded=false or Yue2 stopped can be normal lazy loading. Hosted API use does not require local model startup.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md
index 55dce960..ff62658b 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/batch-audio.md
@@ -1,60 +1,25 @@
-# Batch Audio Workflows
+# Sequential batch speech export
-Use this reference for directory-scale ASR, clone, and export jobs such as game filter alert sounds.
+ASR each source, then clone speech under the source stem. Output is generated speech only, without overlay or embedded music/SFX preservation. Clean voice replacement inside mixed audio requires stems/source separation; ASR timestamps are not removal masks.
-## Pick The Output Mode First
+~~~powershell
+python '/scripts/batch_clone_generated_audio.py' --input-dir source-audio --reference-audio reference.wav --reference-text-file reference.txt --output-dir generated --provider qwen --asr-language zh --clone-language Chinese --format wav
+~~~
-Clarify or infer the output mode before generating many files:
+## Transcripts and content
-- **Generated-only assets**: create cloned speech files named after the source audio. This is the safest default when the user wants files they can manually replace elsewhere.
-- **Overlay/mix**: mix generated speech with the source audio only when the user explicitly asks for overlay.
-- **Speech replacement inside mixed audio**: do not promise clean removal from mixed music/SFX using ASR timestamps alone. Clean replacement requires source separation, stems, or a dedicated vocal-removal model.
+Supply reference-text/reference-text-file containing the recording's actual words. Otherwise transcribe the reference once with reference-language (default zh). Review ASR for important work; automatic recognition does not establish accuracy.
-If the user says the old and new voices are both audible, stop overlay work and export generated-only assets instead unless they ask for source separation.
+Each source uses asr-language (default zh). Empty/invalid ASR fails that file, never substitutes its name. Recognized text is preserved apart from surrounding whitespace. Optional normalize-text collapses whitespace/adds terminal punctuation without invented interjections or rewriting semantics.
-## Generated-Only Batch Export
+Both batch providers use transcript-guided clone. Use the single-call client for VoxCPM2 instruct-only clone; Qwen clone-language is not sent to VoxCPM2.
-Prefer the bundled script:
+## Files and retries
-```bash
-python ~/.codex/skills/eapil-voice/scripts/batch_clone_generated_audio.py \
- --base-url https://ades.playones.com \
- --token "$OPENAI_API_KEY" \
- --input-dir "/path/to/source-audio" \
- --reference-audio reference.wav \
- --output-dir "/path/to/generated-output" \
- --format mp3 \
- --gain-db 4 \
- --overwrite
-```
+Requests are sequential. Retries defaults to **1 total attempt**, only confirmed busy 503 permits extra attempts (max 5). Timeout, 500/502/504 and ambiguous load failures are not replayed. [Transport](api.md) explains classification. Multipart is rebuilt from the entire file.
-The script:
+Preflight checks file presence/size, WAV limits where possible, output-name collisions, overlap with input/reference paths and required ffmpeg. Compressed decoding is server-dependent. Rename colliding stems before generation. Existing outputs skip unless overwrite is requested.
-- ASRs each source file.
-- Normalizes alert text punctuation.
-- Uses the reference audio stem as `ref_text`.
-- Calls `/v1/voice/clone` with `provider=qwen` by default.
-- Saves `_raw_wav/` originals plus final WAV/MP3 named after the source files.
-- Writes `_manifests/` and `conversion_summary.json`.
+Raw WAVs go to _raw_wav; final outputs keep source stems. Per-file _manifests and conversion_summary.json track progress, saved after every file. Failed files do not become successes. Rerun skips completed files; review unknown outcomes before resubmitting failed files. This is local progress tracking, not server task resume. Keep private transcripts/manifests outside the repository.
-If `OPENAI_API_KEY` is not set, ask the user to get their key from `https://kinfo.playones.com` or pass the key directly with `--token`.
-
-## Text And Punctuation
-
-For Chinese alert text, normalize whitespace and punctuation before clone. Add expressive punctuation when it matches the phrase. If the final character is not one of `。!?.!?~~`, append `。`.
-
-## Gain And Format
-
-Service clone responses are WAV. For MP3 delivery, use ffmpeg after generation:
-
-```bash
-ffmpeg -y -i generated.wav -filter:a "volume=4dB" -codec:a libmp3lame -q:a 2 generated.mp3
-```
-
-Use the user's requested gain literally. Do not normalize or limit unless requested.
-
-## Windows Notes
-
-Paths with spaces or CJK characters are common. Prefer direct shell invocation. If launching a background job with PowerShell `Start-Process`, pass a single fully quoted `-ArgumentList` string; arrays can be split incorrectly.
-
-Use `subprocess.run(..., encoding="utf-8", errors="replace")` in Python when capturing ffmpeg output on Windows to avoid GBK decode crashes.
+WAV/no gain is copied; MP3 or requested gain uses ffmpeg. Export writes a temporary file and replaces the final output only after success; failure clears the temporary file and preserves any old completed output. Defaults are WAV and 0 dB. Set format mp3 or gain-db 4 only when wanted. No automatic normalization, limiter or overlay; subprocess capture uses UTF-8 for Windows CJK paths. For redirected Python output on Windows, use python -X utf8 so CJK diagnostics are encoded as UTF-8 too.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md
new file mode 100644
index 00000000..6d908b8e
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/music.md
@@ -0,0 +1,24 @@
+# Stable Audio music and sound effects
+
+JSON music uses Medium + SAME-L; sfx uses Small-SFX + SAME-S. Text-to-audio only: no dedicated lyrics, reference audio, inpainting or continuation. Use Yue2 for explicit lyrics. MiniMax was removed.
+
+| Field | Default | Schema / effective meaning |
+|---|---|---|
+| prompt | required | Nonblank 1–2000 characters; model uses <=256 tokenizer tokens |
+| negative_prompt | null | <=2000 characters; active only when cfg != 1 |
+| seconds | music 30, SFX 5 | >0 and <=380; current music cap 380 |
+| steps | 8 | Schema 1–64; current deployment cap **16** |
+| seed | null/random | 0–2147483647 |
+| cfg | 1 | 0–10; 1 is single-branch lower latency |
+| apg | 1 | 0–1; active only when cfg != 1 |
+
+Effective limits are min(380, configured seconds) and min(64, configured steps). SFX shares the 380-second schema but its recommended window remains <=120 seconds. Schema acceptance is not a model recommendation.
+
+Use one or two concrete sentences: source/genre, instruments, BPM, mood, development, space. Defaults or 4-step short previews are starting points; more steps/CFG do not guarantee quality. Fix seed to compare. Instrumental/no vocals is guidance, not a guarantee. Activate negative_prompt with cfg !=1 (e.g. 2.5), increasing compute. APG does nothing at cfg=1.
+
+~~~powershell
+python '/scripts/call_voice_api.py' music --prompt 'Instrumental piano and warm strings, 90 BPM, no vocals' --seconds 5 --steps 4 --seed 7 --out music.wav
+python '/scripts/call_voice_api.py' sfx --prompt 'Three footsteps on wet gravel, realistic transients, no music' --seconds 5 --steps 8 --seed 7 --out sfx.wav
+~~~
+
+Current output is 44.1 kHz stereo PCM16. Check duration, clipping, content and unwanted vocals by listening. Longer output increases wait/memory; music/SFX share a queue, so do not concurrently benchmark them merely to verify integration.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md
new file mode 100644
index 00000000..39889c19
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/recognition-enhancement.md
@@ -0,0 +1,24 @@
+# ASR and speech enhancement
+
+## ASR
+
+Multipart /v1/asr/transcribe: file plus optional form fields. Default language is **English**, not automatic detection. Chinese recordings should explicitly use zh. Aliases: zh/zh-cn/zh-tw → Chinese, en → English, ja/jp → Japanese, ko/kr → Korean. Other values go to the model directly; auto is not an auto-detection guarantee.
+
+Controls: temperature=0 (0–2), max_tokens=8192 (1–8192), top_k=0 (0–1000), top_p=1 (0–1), optional repetition_penalty (.1–2). Do not inherit TTS sampling settings without reason.
+
+Text is the primary result; segments may be empty and does not guarantee word/sentence timestamps or speaker separation. Token/TPS and total_time are model statistics, not audio duration or total HTTP latency. Review uncertain transcription before cloning.
+
+~~~powershell
+python '/scripts/call_voice_api.py' asr --file speech.wav --language zh
+~~~
+
+## Enhancement
+
+MossFormer2 enhance/denoise are aliases. Optional chunked controls model chunking, not HTTP streaming; use form true/false. RE-USE requires only file and has no public strength/chunk/provider control.
+
+~~~powershell
+python '/scripts/call_voice_api.py' enhance --file noisy.wav --chunked true --out enhanced.wav
+python '/scripts/call_voice_api.py' reuse --file noisy.wav --out reuse.wav
+~~~
+
+Both return complete WAV and mix channels to mono; input is limited to 25 MiB/60 decoded seconds. Evaluate intelligibility, residual noise and speech damage with known speech/noise. Headers are not a quality score. These endpoints are not source separation, clean old-voice replacement inside mixed music, or stereo spatial preservation.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md
new file mode 100644
index 00000000..dbb30efa
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/speech.md
@@ -0,0 +1,37 @@
+# Speech design, reading and cloning
+
+Design takes JSON text (spoken content) and instruct (voice/delivery), provider qwen by default or voxcpm2. Reading takes JSON text, Qwen only; optional speaker is meaningful only with an actual supported list. Omit it otherwise; do not invent speaker names or use instruct/provider here.
+
+Clone takes multipart file (reference speech), text (new utterance) and ref_text (accurate reference transcript), with provider rules below. Text/ref_text limit 4096 characters, instruct 1024. Reject blank speech; split long content at complete sentences and verify continuity rather than assuming identical delivery.
+
+## Qwen
+
+TTS language names such as Chinese/English or auto go to the model; ASR alias normalization differs.
+
+| Control | Default | Request range |
+|---|---|---|
+| temperature | .9 | 0–2 |
+| max_tokens | 4096 | 1–8192 |
+| top_k | 50 | 0–1000 |
+| top_p | 1 | 0–1 |
+| repetition_penalty | 1.05 | .1–2 |
+
+Effective current caps: **design 512**, reading/clone **4096** tokens. Budgets are not characters/seconds; a higher accepted request cannot bypass the cap. Small budgets can truncate. Defaults are a starting point, not a quality guarantee.
+
+Qwen clone requires nonblank accurate ref_text; instruct is ignored. Use the recording's actual text, a UTF-8 transcript file, or reviewed ASR, never a filename/stem.
+
+~~~powershell
+python '/scripts/call_voice_api.py' clone --provider qwen --file reference.wav --text '这是新的朗读内容。' --ref-text-file reference.txt --language Chinese --out clone.wav
+~~~
+
+## VoxCPM2
+
+Only design/clone support it. Controls: cfg_value=2 (0–10), inference_timesteps=10 (1–200), normalize=false, denoise=false, retry_badcase=true. More steps/CFG can increase time without better sound.
+
+Current deployment has no built-in denoiser; denoise=true returns 400. Qwen language/sampling/token controls do not reach this provider. The helper rejects wrong-provider controls. Retry_badcase is a model option, separate from HTTP retries.
+
+Clone with ref_text is transcript-guided Hi-Fi; instruct does not apply. Without ref_text, instruct guides delivery and audio supplies the reference. Design instruct is combined with text by this provider; verify the actual spoken utterance.
+
+~~~powershell
+python '/scripts/call_voice_api.py' clone --provider voxcpm2 --file reference.wav --text '今天的故事从这里开始。' --instruct '保持音色,轻松平静地朗读。' --denoise false --out clone.wav
+~~~
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md
new file mode 100644
index 00000000..66abbe78
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/verification.md
@@ -0,0 +1,50 @@
+# Verification record
+
+Date: **2026-10-06**. Target: `https://ades.playones.com`. Basis: maintainer's API usage document dated the same day, public health/models/OpenAPI inspection, actual synthetic requests and offline behavior tests. No service source or deployment configuration was modified.
+
+## Live coverage
+
+Existing environment credentials authenticated successfully via the legacy OPENAI_API_KEY alias. Inputs were a short Chinese utterance, its generated reference WAV, deterministic low-amplitude added noise, short music/SFX prompts and synthetic lyrics. Requests ran sequentially, without retries. All generated media and detailed diagnostics remain outside the repository.
+
+| Operation | Probe and observed result |
+|---|---|
+| Qwen ordinary reading | Chinese text, 256-token budget; valid mono PCM16 24 kHz WAV, 2.64 s. This became the reference. |
+| Chinese ASR | Reference with language=zh; nonblank JSON text, known words matched. |
+| Qwen design | Short text/style, Chinese, 256-token budget; mono 24 kHz, 2.08 s. |
+| VoxCPM2 design | Short text/style, denoise=false; mono 48 kHz, 3.36 s. Generated utterance checked by ASR. |
+| Qwen clone | Accurate reference text and new utterance; mono 24 kHz, 1.60 s. Utterance checked by ASR. |
+| VoxCPM2 Hi-Fi clone | Same reference with ref_text; mono 48 kHz, 2.08 s. Utterance checked by ASR. |
+| VoxCPM2 controlled clone | No ref_text, delivery instruct, denoise=false; mono 48 kHz, 1.92 s. Utterance checked by ASR. |
+| MossFormer2 enhance | Noisy synthetic reference, chunked=true; mono 48 kHz, 2.64 s. |
+| RE-USE | Same noisy reference; mono 24 kHz, 2.64 s. |
+| Stable Audio music | 5 s, 4 steps, seed 7, cfg=1; stereo 44.1 kHz PCM16, exactly 5 s. |
+| Stable Audio SFX | Same timing/settings, footsteps prompt; stereo 44.1 kHz PCM16, exactly 5 s. |
+| Yue2 | Synthetic tagged Chinese lyrics, direct, 4 steps, seed 7, min 8/max 12 s; stereo 48 kHz PCM16, 11.999 s. |
+| Real single-file batch | Explicit accurate reference text; source ASR followed by Qwen clone succeeded, CJK output names/raw WAV/manifest created. Rerun skipped the completed output. |
+| Local MP3 | Generated batch WAV converted with explicit +4 dB, produced a usable nonempty MP3. Export was rechecked locally after the atomic-write fix. |
+
+There were 18 small inference calls including four ASR content rechecks and the two batch calls. After punctuation removal, those four transcripts matched the intended utterances. All initial generated WAVs had nonzero PCM samples and no samples at PCM16 clipping extremes. These checks establish playable-format/signal/content evidence; they do **not** establish perceptual quality, speaker similarity, genre correctness, lyric completeness or denoising improvement.
+
+Public GETs confirmed service responsiveness and current route/schema availability. Models not loaded or Yue2 stopped before inference were treated as lazy loading, not a failed model test. Stable Audio's OpenAPI step cap was 64 while the supplied deployment guide states effective cap 16; schema presence alone does not override runtime constraints.
+
+## Local and independent validation
+
+Run from the repository root:
+
+```powershell
+rtk proxy python -X utf8 -m unittest discover -s plugins/eapil-voice/skills/eapil-voice/tests -v
+```
+
+**22 regression tests passed**, with network mocked. Coverage includes public GET without credentials, environment precedence, Unicode/multiline/zero/false wire values, real multipart boundary/file content, provider controls, Yue2 mode/ABC constraints, WAV media/header/truncation, decoded duration, ASR shape/empty text, string/array/HTML errors, explicit busy-only retries/Retry-After, whole-file reupload, name collisions, overlap, per-file continuation, existing-output skip and failed conversion cleanup/preservation/manual rerun.
+
+An independent offline use pass constructed VoxCPM2 clone, 5-second music and UTF-8 lyrics/ABC melody requests through the actual CLI. Mock HTTP drove actual multipart and batch processing; local ffmpeg exported two MP3s, WAVs and manifests. Empty ASR did not clone, a timeout was not replayed, and mixed successes/failures produced the correct summary/exit status. It discovered the partial-MP3 skip problem; atomic export and targeted failure/rerun tests fixed it. This pass did not call live models.
+
+Skill frontmatter/UI metadata, local documentation links, root AGENTS.md and all five plugin manifests/assets/version were checked. IDs remain eapil-voice and version remains 1.0.0. Changed scripts also passed Ruff checks.
+
+## Limits of this run
+
+- No blind listening test, human voice-similarity evaluation, objective denoising score or long-audio benchmark was performed.
+- Yue2 direct was live tested; melody/full/external ABC were checked offline only. No claim of live coverage for those modes.
+- Denoise alias was checked through the client/offline routing; live enhancement used the primary enhance endpoint.
+- Deployment caps, maximum-duration behavior, denied denoise=true, invalid-token behavior, real busy/503 handling, raw-binary uploads and browser CORS were not load-tested. Their guidance comes from the supplied contract/public schema; retry/error behavior was simulated offline.
+- No broad concurrency tests, timeout resubmission, API/service reconfiguration, commit, push, tag or market publication occurred.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md
new file mode 100644
index 00000000..768afef9
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/references/yue2.md
@@ -0,0 +1,26 @@
+# Yue2 songs and ABC arrangements
+
+JSON /v1/audio/music/yue2, current Yue2 3B Q8. Required nonblank style (1–4000 characters) and lyrics (1–30000). Unknown fields are rejected: Stable Audio prompt/seconds/cfg and legacy duration_seconds do not belong here.
+
+| Field | Default | Range / meaning |
+|---|---|---|
+| mode | full | direct, melody, full |
+| abc | null | Nonblank if supplied, <=100000 characters; melody/full only |
+| steps | 8 | 1–64 acoustic denoising steps |
+| seed | 831001 | 0–9223372036854775807 |
+| cfg_scale | 1 | 0–20 |
+| min_duration_seconds | 8 | Integer 8–360 |
+| max_duration_seconds | 120 | Integer 8–360, >=minimum |
+
+Direct skips ABC planning. Melody plans melody or uses supplied ABC; full plans melody/harmony or uses supplied ABC. No WAV/MP3 reference is accepted; score arrangement is not recording-to-recording singing conversion.
+
+Duration bounds map to 25 semantic tokens/second; they guide stopping, not exact length or natural endings. Large minimum can force repetition; short maximum can cut lyrics. Start at minimum 8, maximum 60–120 for real songs. Short probes do not establish full-song quality; fixed-length products may need requested trimming/fades.
+
+Use [Verse]/[Chorus]/[Bridge] and UTF-8 lyric/ABC files. JS Number cannot represent all allowed 64-bit seeds; keep interoperable seeds <=2147483647 or handle exact integers.
+
+~~~powershell
+python '/scripts/call_voice_api.py' yue2 --style 'Mandopop ballad, female vocal, piano and strings' --lyrics-file lyrics.txt --mode full --steps 8 --seed 7 --min-duration-seconds 8 --max-duration-seconds 60 --out song.wav
+python '/scripts/call_voice_api.py' yue2 --style 'Acoustic folk, intimate vocal' --lyrics-file lyrics.txt --mode melody --abc-file melody.abc --max-duration-seconds 30 --out arrangement.wav
+~~~
+
+Lyrics must not be blank/absent. [Instrumental] is experimental, not a guarantee; prefer Stable Audio for pure instrumentals. Current output is 48 kHz stereo PCM16. Lazy startup is handled by the service. Verify lyrics/music by listening; one direct probe does not establish melody/full/ABC.
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py
index fd36a4a2..e92b2c70 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/batch_clone_generated_audio.py
@@ -1,249 +1,326 @@
#!/usr/bin/env python3
-"""Batch ASR + Eapil-Voice clone and export generated speech by source name."""
+"""Sequential ASR/clone export. Generated speech only; no timeout retries."""
from __future__ import annotations
-
import argparse
import json
-import mimetypes
-import os
+import math
import re
+import shutil
import subprocess
import sys
import time
+import tempfile
from pathlib import Path
-from typing import Any
+from types import SimpleNamespace
+import call_voice_api as api
-import requests
-
-
-DEFAULT_BASE_URL = "https://ades.playones.com"
-KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com"
AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac", ".wma"}
-END_PUNCTUATION = "。!?.!?~~"
SAFE_NAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--base-url", default=os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL))
- parser.add_argument("--token", default=os.getenv("OPENAI_API_KEY"), required=False)
- parser.add_argument("--input-dir", required=True, help="Directory of source alert audio files")
- parser.add_argument("--reference-audio", required=True, help="Voice clone reference audio")
- parser.add_argument("--output-dir", required=True, help="Directory for generated files")
- parser.add_argument("--format", choices=["wav", "mp3"], default="wav")
- parser.add_argument("--gain-db", type=float, default=0.0, help="Output gain applied during export")
- parser.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen")
- parser.add_argument("--asr-language", default="zh")
- parser.add_argument("--clone-language", default="auto")
- parser.add_argument("--timeout", type=int, default=900)
- parser.add_argument("--retries", type=int, default=3)
- parser.add_argument("--overwrite", action="store_true")
- return parser.parse_args()
+def parse_args(argv=None):
+ p = argparse.ArgumentParser(description=__doc__)
+ p.add_argument("--base-url", default=api.env_base_url())
+ p.add_argument("--token", help="legacy direct token; prefer environment")
+ p.add_argument("--api-key-env")
+ p.add_argument("--input-dir", required=True)
+ p.add_argument("--reference-audio", required=True)
+ ref = p.add_mutually_exclusive_group()
+ ref.add_argument("--reference-text")
+ ref.add_argument("--reference-text-file")
+ p.add_argument("--reference-language", default="zh")
+ p.add_argument("--output-dir", required=True)
+ p.add_argument("--format", choices=["wav", "mp3"], default="wav")
+ p.add_argument("--gain-db", type=float, default=0)
+ p.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen")
+ p.add_argument("--asr-language", default="zh")
+ p.add_argument("--clone-language", default="auto")
+ p.add_argument("--timeout", type=float, default=600)
+ p.add_argument(
+ "--retries",
+ type=int,
+ default=1,
+ help="maximum attempts for explicit busy 503 only; default 1",
+ )
+ p.add_argument(
+ "--normalize-text",
+ action="store_true",
+ help="opt in to whitespace normalization and terminal punctuation",
+ )
+ p.add_argument("--overwrite", action="store_true")
+ return p.parse_args(argv)
-def log(message: str) -> None:
- print(message, flush=True)
-
-
-def bearer_token(token: str | None) -> str:
- if not token:
- raise SystemExit(KEY_HELP)
- token = token.strip()
- if token.lower().startswith("bearer "):
- token = token[7:].strip()
- if not token:
- raise SystemExit(KEY_HELP)
- return token
-
-
-def request_headers(token: str | None) -> dict[str, str]:
- return {"Authorization": f"Bearer {bearer_token(token)}"}
-
-
-def audio_files(input_dir: Path) -> list[Path]:
+def audio_files(input_dir):
return sorted(
- [path for path in input_dir.iterdir() if path.is_file() and path.suffix.lower() in AUDIO_EXTENSIONS],
- key=lambda path: path.name.casefold(),
+ (
+ p
+ for p in input_dir.iterdir()
+ if p.is_file() and p.suffix.lower() in AUDIO_EXTENSIONS
+ ),
+ key=lambda p: p.name.casefold(),
)
-def safe_stem(text: str) -> str:
- cleaned = SAFE_NAME_RE.sub("_", text).strip().strip(".")
- return cleaned or "untitled"
+def safe_stem(text):
+ name = SAFE_NAME_RE.sub("_", text).strip().strip(".")
+ if not name:
+ name = "untitled"
+ if re.fullmatch(r"(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])", name, re.I):
+ name = "_" + name
+ return name
-def normalize_text(raw: str, fallback: str) -> str:
- text = " ".join((raw or "").replace("\u3000", " ").split()).strip() or fallback.strip()
- text = text.translate(str.maketrans({",": ",", ":": ":", ";": ";", "(": "(", ")": ")"}))
- if text.startswith("哇") and len(text) > 1 and text[1] not in ",,。!?!?":
- text = "哇," + text[1:]
- if text.startswith("拜托") and "," not in text[:4] and len(text) > 2:
- text = "拜托," + text[2:]
- if text and text[-1] not in END_PUNCTUATION:
+def normalize_text(raw):
+ if not isinstance(raw, str) or not raw.strip():
+ raise ValueError(
+ "ASR returned no usable transcript; file names are not transcripts"
+ )
+ text = " ".join(raw.split())
+ if text[-1] not in "。!?.!?~~":
text += "。"
return text
-def request_with_retries(
- method: str,
- url: str,
- *,
- retries: int,
- timeout: int,
- **kwargs: Any,
-) -> requests.Response:
- last_error: Exception | None = None
- for attempt in range(1, retries + 1):
+def call_with_busy_retry(operation, attempts):
+ for index in range(attempts):
try:
- response = requests.request(method, url, timeout=timeout, **kwargs)
- if response.status_code < 500:
- return response
- last_error = RuntimeError(f"HTTP {response.status_code}: {response.text[:500]}")
- except Exception as exc: # noqa: BLE001
- last_error = exc
- wait = min(30, 2**attempt)
- log(f"retry attempt={attempt}/{retries} wait={wait}s error={last_error}")
- time.sleep(wait)
- if last_error:
- raise last_error
- raise RuntimeError("request failed")
+ return operation()
+ except api.APIError as exc:
+ if not exc.busy or index + 1 >= attempts:
+ raise
+ delay = (
+ exc.retry_after
+ if exc.retry_after is not None
+ else min(30, 2 ** (index + 1))
+ )
+ if delay > 60:
+ raise # Leave unusually long backoff to the calling application.
+ time.sleep(delay)
+ raise ValueError("attempt count must be at least 1")
-def transcribe(args: argparse.Namespace, source: Path, headers: dict[str, str]) -> dict[str, Any]:
- mime = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
- with source.open("rb") as handle:
- response = request_with_retries(
- "POST",
- f"{args.base_url.rstrip('/')}/v1/asr/transcribe",
- retries=args.retries,
- timeout=args.timeout,
- headers=headers,
- files={"file": (source.name, handle, mime)},
- data={"language": args.asr_language, "temperature": "0"},
- )
- if response.status_code != 200:
- raise RuntimeError(f"ASR HTTP {response.status_code}: {response.text[:1000]}")
- return response.json()
+def transcribe(args, source):
+ # Each optional busy retry rebuilds multipart from the complete file.
+ data = call_with_busy_retry(
+ lambda: api.post_multipart(
+ args,
+ api.PATHS["asr"],
+ {"language": args.asr_language, "temperature": 0},
+ str(source),
+ ),
+ args.retries,
+ )
+ result = json.loads(data)
+ if not isinstance(result.get("text"), str) or not result["text"].strip():
+ raise ValueError("ASR returned an empty transcript")
+ return result
-def clone(args: argparse.Namespace, reference_audio: Path, ref_text: str, text: str, headers: dict[str, str]) -> bytes:
- mime = mimetypes.guess_type(reference_audio.name)[0] or "audio/wav"
- with reference_audio.open("rb") as handle:
- response = request_with_retries(
- "POST",
- f"{args.base_url.rstrip('/')}/v1/voice/clone",
- retries=args.retries,
- timeout=args.timeout,
- headers=headers,
- files={"file": (reference_audio.name, handle, mime)},
- data={
- "text": text,
- "ref_text": ref_text,
- "language": args.clone_language,
- "provider": args.provider,
- },
- )
- if response.status_code != 200:
- raise RuntimeError(f"CLONE HTTP {response.status_code}: {response.text[:1000]}")
- return response.content
-
-
-def export_audio(raw_wav: Path, output: Path, gain_db: float, output_format: str) -> None:
- output.parent.mkdir(parents=True, exist_ok=True)
- if output_format == "wav":
- command = [
- "ffmpeg",
- "-hide_banner",
- "-loglevel",
- "error",
- "-y",
- "-i",
- str(raw_wav),
- "-filter:a",
- f"volume={gain_db:.6f}dB",
- str(output),
- ]
+def reference_transcript(args):
+ if args.reference_text_file:
+ text = Path(args.reference_text_file).read_text(encoding="utf-8-sig")
+ elif args.reference_text is not None:
+ text = args.reference_text
else:
- command = [
- "ffmpeg",
- "-hide_banner",
- "-loglevel",
- "error",
- "-y",
- "-i",
- str(raw_wav),
- "-filter:a",
- f"volume={gain_db:.6f}dB",
- "-codec:a",
- "libmp3lame",
- "-q:a",
- "2",
- str(output),
+ ref_args = SimpleNamespace(**vars(args))
+ ref_args.asr_language = args.reference_language
+ text = transcribe(ref_args, Path(args.reference_audio))["text"]
+ if not isinstance(text, str) or not text.strip() or len(text) > 4096:
+ raise ValueError(
+ "reference transcript must be nonblank and at most 4096 characters"
+ )
+ return text.strip()
+
+
+def clone(args, ref_text, text):
+ fields = {"text": text, "ref_text": ref_text, "provider": args.provider}
+ if args.provider == "qwen":
+ fields["language"] = args.clone_language
+ api.validate_payload("clone", fields)
+ return call_with_busy_retry(
+ lambda: api.post_multipart(
+ args, api.PATHS["clone"], fields, args.reference_audio
+ ),
+ args.retries,
+ )
+
+
+def export_audio(raw_wav, output, gain_db, output_format):
+ output.parent.mkdir(parents=True, exist_ok=True)
+ with tempfile.NamedTemporaryFile(
+ dir=output.parent, prefix=".audio-export-", suffix=output.suffix, delete=False
+ ) as handle:
+ temporary = Path(handle.name)
+ try:
+ if output_format == "wav" and gain_db == 0:
+ api.wav_info(raw_wav.read_bytes())
+ temporary.write_bytes(raw_wav.read_bytes())
+ else:
+ command = [
+ "ffmpeg",
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-i",
+ str(raw_wav),
+ ]
+ if gain_db != 0:
+ command += ["-filter:a", f"volume={gain_db:.6f}dB"]
+ if output_format == "mp3":
+ command += ["-codec:a", "libmp3lame", "-q:a", "2"]
+ command.append(str(temporary))
+ if shutil.which("rtk"):
+ command = ["rtk", "proxy", *command]
+ result = subprocess.run(
+ command,
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ )
+ if result.returncode or not temporary.stat().st_size:
+ raise RuntimeError("ffmpeg export failed; inspect conversion locally")
+ if output_format == "wav":
+ api.wav_info(temporary.read_bytes())
+ temporary.replace(output)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def prepare(args):
+ if (
+ not math.isfinite(args.gain_db)
+ or not math.isfinite(args.timeout)
+ or args.timeout <= 0
+ or args.retries < 1
+ or args.retries > 5
+ ):
+ raise ValueError("invalid gain/timeout; retries must be 1–5")
+ api.build_url(args.base_url, api.PATHS["clone"])
+ source = Path(args.input_dir)
+ if not source.is_dir():
+ raise ValueError("input directory does not exist")
+ api.preflight_audio(
+ args.reference_audio,
+ args.provider == "qwen"
+ or not (args.reference_text or args.reference_text_file),
+ )
+ if (args.format == "mp3" or args.gain_db != 0) and not shutil.which("ffmpeg"):
+ raise ValueError("ffmpeg is required for MP3 or gain conversion")
+ files = audio_files(source)
+ names = [safe_stem(p.stem).casefold() for p in files]
+ if len(names) != len(set(names)):
+ raise ValueError(
+ "source names collide after output normalization; rename before generating"
+ )
+ output = Path(args.output_dir)
+ inputs = {p.resolve() for p in files} | {Path(args.reference_audio).resolve()}
+ for p in files:
+ for target in (
+ output / f"{safe_stem(p.stem)}.{args.format}",
+ output / "_raw_wav" / f"{safe_stem(p.stem)}.wav",
+ ):
+ if target.resolve() in inputs:
+ raise ValueError("output must not overwrite input/reference audio")
+ api.preflight_audio(str(p))
+ return files
+
+
+def main(argv=None):
+ args = parse_args(argv)
+ try:
+ files = prepare(args)
+ import os
+
+ args.token = args.token or (
+ os.getenv(args.api_key_env) if args.api_key_env else api.env_token()
+ )
+ api.bearer_token(args.token)
+ output_dir = Path(args.output_dir)
+ raw_dir, manifest_dir = output_dir / "_raw_wav", output_dir / "_manifests"
+ for path in (output_dir, raw_dir, manifest_dir):
+ path.mkdir(parents=True, exist_ok=True)
+ pending = [
+ p
+ for p in files
+ if args.overwrite
+ or not (output_dir / f"{safe_stem(p.stem)}.{args.format}").exists()
]
- result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace")
- if result.returncode != 0:
- raise RuntimeError(result.stderr[-2000:] or result.stdout[-2000:] or "ffmpeg failed")
-
-
-def main() -> int:
- args = parse_args()
- if not args.token:
- log(KEY_HELP)
- return 2
-
- input_dir = Path(args.input_dir)
- reference_audio = Path(args.reference_audio)
- output_dir = Path(args.output_dir)
- raw_dir = output_dir / "_raw_wav"
- manifest_dir = output_dir / "_manifests"
- output_dir.mkdir(parents=True, exist_ok=True)
- raw_dir.mkdir(parents=True, exist_ok=True)
- manifest_dir.mkdir(parents=True, exist_ok=True)
-
- headers = request_headers(args.token)
- files = audio_files(input_dir)
- ref_text = reference_audio.stem
- summary: list[dict[str, Any]] = []
-
- log(f"base_url={args.base_url.rstrip('/')}")
- log(f"input_files={len(files)} output_dir={output_dir} format={args.format} gain_db={args.gain_db}")
-
- for index, source in enumerate(files, 1):
- output = output_dir / f"{safe_stem(source.stem)}.{args.format}"
- raw_output = raw_dir / f"{safe_stem(source.stem)}.wav"
- try:
+ ref_text = reference_transcript(args) if pending else ""
+ summary = []
+ for source in files:
+ output = output_dir / f"{safe_stem(source.stem)}.{args.format}"
+ raw = raw_dir / f"{safe_stem(source.stem)}.wav"
if output.exists() and not args.overwrite:
- record = {"source": str(source), "output": str(output), "status": "skipped", "reason": "exists"}
- else:
- asr = transcribe(args, source, headers)
- text = normalize_text(str(asr.get("text") or ""), source.stem)
- raw_output.write_bytes(clone(args, reference_audio, ref_text, text, headers))
- export_audio(raw_output, output, args.gain_db, args.format)
record = {
"source": str(source),
"output": str(output),
- "raw_wav": str(raw_output),
- "status": "ok",
- "text": text,
- "asr": asr,
+ "status": "skipped",
+ "reason": "exists",
}
- (manifest_dir / f"{safe_stem(source.stem)}.json").write_text(
- json.dumps(record, ensure_ascii=False, indent=2),
- encoding="utf-8",
- )
- log(f"{index}/{len(files)} {record['status'].upper()} {source.name}")
- except Exception as exc: # noqa: BLE001
- record = {"source": str(source), "status": "error", "error": str(exc)}
- log(f"{index}/{len(files)} ERROR {source.name}: {exc}")
- summary.append(record)
-
- summary_path = output_dir / "conversion_summary.json"
- summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
- errors = sum(1 for record in summary if record.get("status") == "error")
- log(f"done files={len(files)} errors={errors} summary={summary_path}")
- return 1 if errors else 0
+ else:
+ try:
+ asr = transcribe(args, source)
+ text = (
+ normalize_text(asr["text"])
+ if args.normalize_text
+ else asr["text"].strip()
+ )
+ audio = clone(args, ref_text, text)
+ api.write_output(str(raw), audio)
+ export_audio(raw, output, args.gain_db, args.format)
+ record = {
+ "source": str(source),
+ "output": str(output),
+ "raw_wav": str(raw),
+ "status": "ok",
+ "text": text,
+ "reference_text": ref_text,
+ "asr": asr,
+ "audio": api.wav_info(audio),
+ }
+ (manifest_dir / f"{safe_stem(source.stem)}.json").write_text(
+ json.dumps(record, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ except (api.APIError, OSError, ValueError, RuntimeError) as exc:
+ record = {
+ "source": str(source),
+ "status": "error",
+ "error_type": type(exc).__name__,
+ }
+ if isinstance(exc, api.APIError):
+ record["http_status"] = exc.status
+ summary.append(record)
+ # Persist progress after each file; existing successful outputs are skipped on rerun.
+ (output_dir / "conversion_summary.json").write_text(
+ json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
+ )
+ print(
+ json.dumps(
+ {"file": source.name, "status": record["status"]},
+ ensure_ascii=False,
+ ),
+ flush=True,
+ )
+ if not files:
+ (output_dir / "conversion_summary.json").write_text("[]", encoding="utf-8")
+ return int(any(r["status"] == "error" for r in summary))
+ except (api.APIError, OSError, ValueError, RuntimeError) as exc:
+ report = {"ok": False, "error_type": type(exc).__name__}
+ if isinstance(exc, api.APIError):
+ report["http_status"] = exc.status
+ else:
+ message = str(exc)
+ if args.token:
+ message = message.replace(args.token, "[redacted]")
+ report["message"] = message[:240]
+ print(json.dumps(report), file=sys.stderr)
+ return 1
if __name__ == "__main__":
- sys.exit(main())
+ raise SystemExit(main())
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py
index 2372131c..3bf11673 100644
--- a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/scripts/call_voice_api.py
@@ -1,42 +1,107 @@
#!/usr/bin/env python3
-"""Dependency-free client for the hosted Eapil-Voice API."""
+"""Dependency-free EAPIL audio client. One request, no inference retries."""
from __future__ import annotations
-
import argparse
import json
+import math
import mimetypes
import os
+import struct
import sys
import uuid
+from http.client import HTTPException
from pathlib import Path
from urllib.error import HTTPError, URLError
-from urllib.parse import urljoin
+from urllib.parse import urlsplit
from urllib.request import Request, urlopen
-
DEFAULT_BASE_URL = "https://ades.playones.com"
-KEY_HELP = "missing API key: set OPENAI_API_KEY, pass --token directly, or get a key from https://kinfo.playones.com"
+MAX_UPLOAD_BYTES = 26_214_400
+PATHS = {
+ "health": "/health",
+ "models": "/models",
+ "design": "/v1/voice/design",
+ "synthesize": "/v1/voice/synthesize",
+ "clone": "/v1/voice/clone",
+ "asr": "/v1/asr/transcribe",
+ "enhance": "/v1/audio/enhance",
+ "denoise": "/v1/audio/denoise",
+ "reuse": "/v1/audio/reuse",
+ "music": "/v1/audio/music",
+ "sfx": "/v1/audio/sfx",
+ "yue2": "/v1/audio/music/yue2",
+}
+QWEN = ("language", "temperature", "max_tokens", "top_k", "top_p", "repetition_penalty")
+VOX = ("cfg_value", "inference_timesteps", "normalize", "denoise", "retry_badcase")
-def env_base_url() -> str:
- return os.getenv("QWEN3_VOICE_API_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
+class APIError(RuntimeError):
+ def __init__(self, status, detail, retry_after=None):
+ self.status, self.detail = status, detail
+ try:
+ delay = float(retry_after)
+ self.retry_after = delay if math.isfinite(delay) and delay >= 0 else None
+ except (TypeError, ValueError):
+ self.retry_after = None
+ text = detail.lower() if isinstance(detail, str) else ""
+ self.busy = (
+ status == 503
+ and ("busy" in text or "already running" in text)
+ and not any(
+ word in text
+ for word in ("failed", "error", "load", "connect", "timeout")
+ )
+ )
+ super().__init__(f"HTTP {status}; provider detail omitted, no automatic retry")
-def env_token() -> str | None:
- return os.getenv("OPENAI_API_KEY")
+def env_base_url():
+ return (
+ os.getenv("AUDIO_API_BASE")
+ or os.getenv("QWEN3_VOICE_API_BASE_URL")
+ or DEFAULT_BASE_URL
+ )
-def add_common_args(parser: argparse.ArgumentParser) -> None:
- parser.add_argument("--base-url", default=env_base_url(), help="API base URL")
- parser.add_argument("--token", default=env_token(), help="Bearer API key; defaults to OPENAI_API_KEY")
+def env_token():
+ return os.getenv("AUDIO_API_TOKEN") or os.getenv("OPENAI_API_KEY")
-def clean_payload(values: dict[str, object | None]) -> dict[str, object]:
- return {key: value for key, value in values.items() if value is not None}
+def bearer_token(token):
+ value = (token or "").strip()
+ if value.lower().startswith("bearer "):
+ value = value[7:].strip()
+ if not value or "\r" in value or "\n" in value:
+ raise ValueError(
+ "set AUDIO_API_TOKEN (legacy OPENAI_API_KEY supported), or --api-key-env"
+ )
+ return value
-def bool_value(raw: str | bool | None) -> bool | None:
+def build_url(base_url, path):
+ parts = urlsplit(base_url)
+ if (
+ parts.scheme not in {"http", "https"}
+ or not parts.netloc
+ or parts.username
+ or parts.password
+ or parts.query
+ or parts.fragment
+ ):
+ raise ValueError(
+ "base URL must be an HTTP(S) service root without credentials/query/fragment"
+ )
+ if parts.path.rstrip("/").endswith("/v1"):
+ raise ValueError("audio base URL is the service root, without /v1")
+ return base_url.rstrip("/") + path
+
+
+def clean_payload(values):
+ return {k: v for k, v in values.items() if v is not None}
+
+
+def bool_value(raw):
if raw is None or isinstance(raw, bool):
return raw
value = raw.strip().lower()
@@ -44,326 +109,458 @@ def bool_value(raw: str | bool | None) -> bool | None:
return True
if value in {"0", "false", "no", "off"}:
return False
- raise argparse.ArgumentTypeError(f"invalid boolean value: {raw}")
+ raise argparse.ArgumentTypeError("boolean must be true or false")
-def bearer_token(token: str | None) -> str:
- if not token:
- raise SystemExit(KEY_HELP)
- token = token.strip()
- if token.lower().startswith("bearer "):
- token = token[7:].strip()
- if not token:
- raise SystemExit(KEY_HELP)
- return token
+def upload_bytes(file_path):
+ path = Path(file_path)
+ if not path.is_file() or not 0 < path.stat().st_size <= MAX_UPLOAD_BYTES:
+ raise ValueError("upload must be a nonempty file of at most 25 MiB")
+ return path.read_bytes()
-def request_headers(token: str | None, extra: dict[str, str] | None = None) -> dict[str, str]:
- headers = dict(extra or {})
- headers["Authorization"] = f"Bearer {bearer_token(token)}"
- return headers
+def preflight_audio(file_path, limit_seconds=True):
+ data = upload_bytes(file_path)
+ if Path(file_path).suffix.lower() == ".wav":
+ info = wav_info(data)
+ if limit_seconds and info["frames"] / info["sample_rate"] > 60:
+ raise ValueError("decoded WAV exceeds the current 60-second input limit")
-def build_url(base_url: str, path: str) -> str:
- return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
+def multipart_body(*, fields, file_path, file_field="file"):
+ audio = upload_bytes(file_path)
+ boundary = "----eapil-audio-" + uuid.uuid4().hex
+ parts = []
+ for name, value in clean_payload(fields).items():
+ if any(c in name for c in '\r\n"'):
+ raise ValueError("invalid multipart field name")
+ value = str(value).lower() if isinstance(value, bool) else str(value)
+ parts.append(
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n{value}\r\n'.encode(
+ "utf-8"
+ )
+ )
+ filename = (
+ Path(file_path).name.replace('"', "_").replace("\r", "_").replace("\n", "_")
+ )
+ mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
+ parts.append(
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{file_field}"; filename="{filename}"\r\nContent-Type: {mime}\r\n\r\n'.encode(
+ "utf-8"
+ )
+ )
+ parts.extend((audio, f"\r\n--{boundary}--\r\n".encode("ascii")))
+ return b"".join(parts), f"multipart/form-data; boundary={boundary}"
+
+
+def wav_info(data):
+ """Check complete little-endian PCM/float WAV, including extensible headers."""
+ if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WAVE":
+ raise ValueError("response is not RIFF/WAVE")
+ end = struct.unpack_from(" end:
+ raise ValueError("truncated WAV chunk")
+ if kind == b"fmt ":
+ if size < 16:
+ raise ValueError("invalid WAV format")
+ fmt = struct.unpack_from(" bytes:
- request = Request(
- build_url(base_url, path),
- data=body,
- method=method,
- headers=request_headers(token, headers),
- )
+ method,
+ base_url,
+ path,
+ token=None,
+ body=None,
+ headers=None,
+ timeout=600,
+ expected="json",
+):
+ if not math.isfinite(timeout) or timeout <= 0:
+ raise ValueError("timeout must be positive and finite")
+ hdr = dict(headers or {})
+ if path.startswith("/v1/"):
+ hdr["Authorization"] = "Bearer " + bearer_token(token)
+ hdr["Accept"] = "audio/wav" if expected == "wav" else "application/json"
+ req = Request(build_url(base_url, path), data=body, method=method, headers=hdr)
try:
- with urlopen(request) as response:
- return response.read()
+ with urlopen(req, timeout=timeout) as response:
+ media = (
+ response.headers.get("Content-Type", "")
+ .split(";", 1)[0]
+ .lower()
+ .strip()
+ )
+ data = response.read()
except HTTPError as exc:
- detail = exc.read().decode("utf-8", errors="replace")
- raise SystemExit(f"HTTP {exc.code} {exc.reason}: {detail}") from exc
- except URLError as exc:
- raise SystemExit(f"request failed: {exc.reason}") from exc
+ raw = exc.read().decode("utf-8", "replace")
+ try:
+ parsed = json.loads(raw)
+ detail = (
+ parsed.get("detail", parsed) if isinstance(parsed, dict) else parsed
+ )
+ except ValueError:
+ detail = "non-JSON error response"
+ raise APIError(
+ exc.code, detail, exc.headers.get("Retry-After") if exc.headers else None
+ ) from exc
+ except HTTPException as exc:
+ raise RuntimeError(
+ "incomplete HTTP response; outcome unknown, no automatic retry"
+ ) from exc
+ if expected == "wav":
+ if media not in {"audio/wav", "audio/x-wav", "audio/wave"}:
+ raise ValueError("successful response is not WAV media")
+ wav_info(data)
+ else:
+ if media != "application/json":
+ raise ValueError("successful response is not JSON media")
+ value = json.loads(data)
+ if not isinstance(value, dict):
+ raise ValueError("expected a JSON object")
+ if path == PATHS["asr"] and not isinstance(value.get("text"), str):
+ raise ValueError("ASR response must contain string text")
+ return data
-def post_json(args: argparse.Namespace, path: str, payload: dict[str, object]) -> bytes:
- body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
+def post_json(args, path, payload):
return http_request(
method="POST",
base_url=args.base_url,
path=path,
token=args.token,
- body=body,
+ body=json.dumps(payload, ensure_ascii=False, allow_nan=False).encode("utf-8"),
headers={"Content-Type": "application/json"},
+ timeout=args.timeout,
+ expected="wav",
)
-def multipart_body(
- *,
- fields: dict[str, object | None],
- file_field: str | None = None,
- file_path: str | None = None,
-) -> tuple[bytes, str]:
- boundary = f"----eapil-voice-{uuid.uuid4().hex}"
- chunks: list[bytes] = []
-
- def add_line(value: str) -> None:
- chunks.append(value.encode("utf-8"))
-
- def field_value(value: object) -> str:
- if isinstance(value, bool):
- return "true" if value else "false"
- return str(value)
-
- for name, value in clean_payload(fields).items():
- add_line(f"--{boundary}\r\n")
- add_line(f'Content-Disposition: form-data; name="{name}"\r\n\r\n')
- add_line(f"{field_value(value)}\r\n")
-
- if file_field and file_path:
- path = Path(file_path)
- content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
- add_line(f"--{boundary}\r\n")
- add_line(
- f'Content-Disposition: form-data; name="{file_field}"; filename="{path.name}"\r\n'
- )
- add_line(f"Content-Type: {content_type}\r\n\r\n")
- chunks.append(path.read_bytes())
- add_line("\r\n")
-
- add_line(f"--{boundary}--\r\n")
- return b"".join(chunks), f"multipart/form-data; boundary={boundary}"
-
-
-def post_multipart(
- args: argparse.Namespace,
- path: str,
- fields: dict[str, object | None],
- file_path: str | None,
-) -> bytes:
- body, content_type = multipart_body(fields=fields, file_field="file", file_path=file_path)
+def post_multipart(args, path, fields, file_path):
+ body, media = multipart_body(fields=fields, file_path=file_path)
return http_request(
method="POST",
base_url=args.base_url,
path=path,
token=args.token,
body=body,
- headers={"Content-Type": content_type},
+ headers={"Content-Type": media},
+ timeout=args.timeout,
+ expected="json" if path == PATHS["asr"] else "wav",
)
-def write_output(path: str, data: bytes) -> None:
- Path(path).write_bytes(data)
- print(path)
+def write_output(path, data):
+ wav_info(data)
+ target = Path(path)
+ if target.suffix.lower() != ".wav":
+ raise ValueError("API output is WAV; transcode separately for MP3")
+ target.parent.mkdir(parents=True, exist_ok=True)
+ target.write_bytes(data)
-def cmd_health(args: argparse.Namespace) -> None:
- data = http_request(
- method="GET",
- base_url=args.base_url,
- path="/health",
- token=args.token,
- )
- print_json(data)
+def validate_payload(command, payload):
+ required = {
+ "design": ("text", "instruct"),
+ "synthesize": ("text",),
+ "clone": ("text",),
+ "music": ("prompt",),
+ "sfx": ("prompt",),
+ "yue2": ("style", "lyrics"),
+ }.get(command, ())
+ limits = {
+ "text": 4096,
+ "ref_text": 4096,
+ "instruct": 1024,
+ "prompt": 2000,
+ "negative_prompt": 2000,
+ "style": 4000,
+ "lyrics": 30000,
+ "abc": 100000,
+ }
+ for key in required:
+ if not isinstance(payload.get(key), str) or not payload[key].strip():
+ raise ValueError(f"{key} must be nonblank text")
+ for key, limit in limits.items():
+ if key in payload and (
+ not isinstance(payload[key], str)
+ or len(payload[key]) > limit
+ or key in {"ref_text", "abc"}
+ and not payload[key].strip()
+ ):
+ raise ValueError(f"invalid {key} or exceeds {limit} characters")
+ ranges = {
+ "temperature": (0, 2),
+ "max_tokens": (1, 8192),
+ "top_k": (0, 1000),
+ "top_p": (0, 1),
+ "repetition_penalty": (0.1, 2),
+ "cfg_value": (0, 10),
+ "inference_timesteps": (1, 200),
+ "cfg": (0, 10),
+ "apg": (0, 1),
+ "steps": (1, 64),
+ "cfg_scale": (0, 20),
+ "min_duration_seconds": (8, 360),
+ "max_duration_seconds": (8, 360),
+ "seed": (0, 9223372036854775807 if command == "yue2" else 2147483647),
+ }
+ for key, (low, high) in ranges.items():
+ if key in payload and (
+ isinstance(payload[key], bool)
+ or not isinstance(payload[key], (int, float))
+ or not math.isfinite(payload[key])
+ or not low <= payload[key] <= high
+ ):
+ raise ValueError(f"{key} must be between {low} and {high}")
+ if "seconds" in payload and not (
+ math.isfinite(payload["seconds"]) and 0 < payload["seconds"] <= 380
+ ):
+ raise ValueError("seconds must be greater than 0 and at most 380")
+ if command in {"design", "clone"}:
+ provider = payload.get("provider", "qwen")
+ if provider not in {"qwen", "voxcpm2"}:
+ raise ValueError("provider must be qwen or voxcpm2")
+ ignored = VOX if provider == "qwen" else QWEN
+ if any(k in payload for k in ignored):
+ raise ValueError("control fields must match the selected provider")
+ if command == "clone":
+ if provider == "qwen" and not payload.get("ref_text", "").strip():
+ raise ValueError(
+ "Qwen clone requires the accurate reference transcript"
+ )
+ if "instruct" in payload and (provider == "qwen" or "ref_text" in payload):
+ raise ValueError(
+ "clone instruct only applies to VoxCPM2 without ref_text"
+ )
+ if command == "yue2":
+ if payload.get("mode", "full") == "direct" and "abc" in payload:
+ raise ValueError("ABC is allowed only in melody/full mode")
+ if payload.get("max_duration_seconds", 120) < payload.get(
+ "min_duration_seconds", 8
+ ):
+ raise ValueError("maximum duration must not be less than minimum")
-def cmd_models(args: argparse.Namespace) -> None:
- data = http_request(
- method="GET",
- base_url=args.base_url,
- path="/models",
- token=args.token,
- )
- print_json(data)
-
-
-def print_json(data: bytes) -> None:
- try:
- parsed = json.loads(data.decode("utf-8"))
- except json.JSONDecodeError:
- sys.stdout.buffer.write(data)
- return
- print(json.dumps(parsed, ensure_ascii=False, indent=2))
-
-
-def cmd_design(args: argparse.Namespace) -> None:
- payload = clean_payload(
- {
- "text": args.text,
- "instruct": args.instruct,
- "provider": args.provider,
- "language": args.language,
- "temperature": args.temperature,
- "max_tokens": args.max_tokens,
- "top_k": args.top_k,
- "top_p": args.top_p,
- "repetition_penalty": args.repetition_penalty,
- "cfg_value": args.cfg_value,
- "inference_timesteps": args.inference_timesteps,
- "normalize": args.normalize,
- "denoise": args.denoise,
- "retry_badcase": args.retry_badcase,
- }
- )
- write_output(args.out, post_json(args, "/v1/voice/design", payload))
-
-
-def cmd_synthesize(args: argparse.Namespace) -> None:
- payload = clean_payload(
- {
- "text": args.text,
- "speaker": args.speaker,
- "language": args.language,
- "temperature": args.temperature,
- "max_tokens": args.max_tokens,
- "top_k": args.top_k,
- "top_p": args.top_p,
- "repetition_penalty": args.repetition_penalty,
- }
- )
- write_output(args.out, post_json(args, "/v1/voice/synthesize", payload))
-
-
-def cmd_clone(args: argparse.Namespace) -> None:
- fields = clean_payload(
- {
- "text": args.text,
- "ref_text": args.ref_text,
- "language": args.language,
- "temperature": args.temperature,
- "max_tokens": args.max_tokens,
- "top_k": args.top_k,
- "top_p": args.top_p,
- "repetition_penalty": args.repetition_penalty,
- "provider": args.provider,
- "instruct": args.instruct,
- "cfg_value": args.cfg_value,
- "inference_timesteps": args.inference_timesteps,
- "normalize": args.normalize,
- "denoise": args.denoise,
- "retry_badcase": args.retry_badcase,
- }
- )
- write_output(args.out, post_multipart(args, "/v1/voice/clone", fields, args.file))
-
-
-def cmd_enhance(args: argparse.Namespace) -> None:
- fields = {"chunked": args.chunked}
- write_output(args.out, post_multipart(args, "/v1/audio/enhance", fields, args.file))
-
-
-def cmd_denoise(args: argparse.Namespace) -> None:
- fields = {"chunked": args.chunked}
- write_output(args.out, post_multipart(args, "/v1/audio/denoise", fields, args.file))
-
-
-def cmd_asr(args: argparse.Namespace) -> None:
- fields = clean_payload(
- {
- "language": args.language,
- "temperature": args.temperature,
- "max_tokens": args.max_tokens,
- "top_k": args.top_k,
- "top_p": args.top_p,
- "repetition_penalty": args.repetition_penalty,
- }
- )
- print_json(post_multipart(args, "/v1/asr/transcribe", fields, args.file))
-
-
-def add_generation_args(parser: argparse.ArgumentParser) -> None:
- parser.add_argument("--language", default=None)
- parser.add_argument("--temperature", type=float, default=None)
- parser.add_argument("--max-tokens", type=int, default=None)
- parser.add_argument("--top-k", type=int, default=None)
- parser.add_argument("--top-p", type=float, default=None)
- parser.add_argument("--repetition-penalty", type=float, default=None)
-
-
-def add_voxcpm2_args(parser: argparse.ArgumentParser) -> None:
- parser.add_argument("--cfg-value", type=float, default=None)
- parser.add_argument("--inference-timesteps", type=int, default=None)
- parser.add_argument("--normalize", type=bool_value, default=None)
- parser.add_argument("--denoise", type=bool_value, default=None)
- parser.add_argument("--retry-badcase", type=bool_value, default=None)
-
-
-def build_parser() -> argparse.ArgumentParser:
+def build_parser():
parser = argparse.ArgumentParser(description=__doc__)
- subparsers = parser.add_subparsers(dest="command", required=True)
-
- health = subparsers.add_parser("health", help="Check service health")
- add_common_args(health)
- health.set_defaults(func=cmd_health)
-
- models = subparsers.add_parser("models", help="Show configured model status")
- add_common_args(models)
- models.set_defaults(func=cmd_models)
-
- design = subparsers.add_parser("design", help="Generate voice from text and instruction")
- add_common_args(design)
- design.add_argument("--text", required=True)
- design.add_argument("--instruct", required=True)
- design.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None)
- design.add_argument("--out", default="voice_design.wav")
- add_generation_args(design)
- add_voxcpm2_args(design)
- design.set_defaults(func=cmd_design)
-
- synthesize = subparsers.add_parser("synthesize", help="Base TTS synthesis")
- add_common_args(synthesize)
- synthesize.add_argument("--text", required=True)
- synthesize.add_argument("--speaker", default=None)
- synthesize.add_argument("--out", default="synthesize.wav")
- add_generation_args(synthesize)
- synthesize.set_defaults(func=cmd_synthesize)
-
- clone = subparsers.add_parser("clone", help="Clone a voice from reference audio")
- add_common_args(clone)
- clone.add_argument("--file", required=True, help="Reference audio path")
- clone.add_argument("--text", required=True)
- clone.add_argument("--ref-text", default=None)
- clone.add_argument("--provider", choices=["qwen", "voxcpm2"], default=None)
- clone.add_argument("--instruct", default=None)
- clone.add_argument("--out", default="voice_clone.wav")
- add_generation_args(clone)
- add_voxcpm2_args(clone)
- clone.set_defaults(func=cmd_clone)
-
- enhance = subparsers.add_parser("enhance", help="Enhance speech audio")
- add_common_args(enhance)
- enhance.add_argument("--file", required=True)
- enhance.add_argument("--chunked", type=bool_value, default=None)
- enhance.add_argument("--out", default="enhanced.wav")
- enhance.set_defaults(func=cmd_enhance)
-
- denoise = subparsers.add_parser("denoise", help="Denoise speech audio")
- add_common_args(denoise)
- denoise.add_argument("--file", required=True)
- denoise.add_argument("--chunked", type=bool_value, default=None)
- denoise.add_argument("--out", default="enhanced.wav")
- denoise.set_defaults(func=cmd_denoise)
-
- asr = subparsers.add_parser("asr", help="Transcribe audio to JSON")
- add_common_args(asr)
- asr.add_argument("--file", required=True)
- add_generation_args(asr)
- asr.set_defaults(func=cmd_asr)
-
+ subs = parser.add_subparsers(dest="command", required=True)
+ for command in PATHS:
+ sub = subs.add_parser(command)
+ sub.add_argument("--base-url", default=env_base_url())
+ sub.add_argument("--token", help="legacy direct token; prefer environment")
+ sub.add_argument("--api-key-env", help="explicit token environment variable")
+ sub.add_argument(
+ "--timeout",
+ type=float,
+ default=1800 if command in {"music", "sfx", "yue2"} else 600,
+ )
+ sub.add_argument("--dry-run", action="store_true")
+ if command in {"health", "models"}:
+ continue
+ if command != "asr":
+ sub.add_argument(
+ "--out",
+ default={
+ "design": "voice_design.wav",
+ "clone": "voice_clone.wav",
+ "enhance": "enhanced.wav",
+ "denoise": "enhanced.wav",
+ }.get(command, f"{command}.wav"),
+ )
+ if command in {"clone", "asr", "enhance", "denoise", "reuse"}:
+ sub.add_argument("--file", required=True)
+ if command in {"design", "synthesize", "clone"}:
+ sub.add_argument("--text", required=True)
+ if command in {"design", "clone"}:
+ sub.add_argument("--instruct", required=command == "design")
+ sub.add_argument("--provider", choices=["qwen", "voxcpm2"], default="qwen")
+ for key in VOX:
+ kind = (
+ bool_value
+ if key in {"normalize", "denoise", "retry_badcase"}
+ else int
+ if key == "inference_timesteps"
+ else float
+ )
+ sub.add_argument("--" + key.replace("_", "-"), type=kind)
+ if command == "clone":
+ sub.add_argument("--ref-text")
+ sub.add_argument("--ref-text-file")
+ if command == "synthesize":
+ sub.add_argument("--speaker")
+ if command in {"design", "synthesize", "clone", "asr"}:
+ for key in QWEN:
+ kind = (
+ str
+ if key == "language"
+ else int
+ if key in {"max_tokens", "top_k"}
+ else float
+ )
+ sub.add_argument("--" + key.replace("_", "-"), type=kind)
+ if command in {"enhance", "denoise"}:
+ sub.add_argument("--chunked", type=bool_value)
+ if command in {"music", "sfx"}:
+ sub.add_argument("--prompt", required=True)
+ sub.add_argument("--negative-prompt")
+ sub.add_argument(
+ "--seconds", type=float, default=30 if command == "music" else 5
+ )
+ sub.add_argument("--steps", type=int, default=8)
+ sub.add_argument("--seed", type=int)
+ sub.add_argument("--cfg", type=float, default=1.0)
+ sub.add_argument("--apg", type=float, default=1.0)
+ if command == "yue2":
+ sub.add_argument("--style", required=True)
+ lyrics = sub.add_mutually_exclusive_group(required=True)
+ lyrics.add_argument("--lyrics")
+ lyrics.add_argument("--lyrics-file")
+ sub.add_argument(
+ "--mode", choices=["direct", "melody", "full"], default="full"
+ )
+ sub.add_argument("--abc-file")
+ sub.add_argument("--steps", type=int, default=8)
+ sub.add_argument("--seed", type=int, default=831001)
+ sub.add_argument("--cfg-scale", type=float, default=1.0)
+ sub.add_argument("--min-duration-seconds", type=int, default=8)
+ sub.add_argument("--max-duration-seconds", type=int, default=120)
return parser
-def main() -> None:
- args = build_parser().parse_args()
- args.func(args)
+def payload_from_args(args):
+ excluded = {
+ "command",
+ "base_url",
+ "token",
+ "api_key_env",
+ "timeout",
+ "dry_run",
+ "out",
+ "file",
+ "ref_text_file",
+ "lyrics_file",
+ "abc_file",
+ }
+ payload = clean_payload({k: v for k, v in vars(args).items() if k not in excluded})
+ for key in ("ref_text", "lyrics", "abc"):
+ path = getattr(args, key + "_file", None)
+ if path:
+ if key in payload:
+ raise ValueError(f"supply {key} text or file, not both")
+ payload[key] = Path(path).read_text(encoding="utf-8-sig")
+ validate_payload(args.command, payload)
+ return payload
+
+
+def main(argv=None):
+ args = build_parser().parse_args(argv)
+ try:
+ url = build_url(args.base_url, PATHS[args.command])
+ if not math.isfinite(args.timeout) or args.timeout <= 0:
+ raise ValueError("timeout must be positive and finite")
+ payload = payload_from_args(args)
+ file = getattr(args, "file", None)
+ if file:
+ preflight_audio(
+ file, not (args.command == "clone" and args.provider == "voxcpm2")
+ )
+ if hasattr(args, "out") and Path(args.out).suffix.lower() != ".wav":
+ raise ValueError("use a .wav output path")
+ if args.dry_run:
+ print(
+ json.dumps(
+ {
+ "url": url,
+ "payload": payload,
+ "file": file,
+ "timeout": args.timeout,
+ },
+ ensure_ascii=False,
+ indent=2,
+ )
+ )
+ return 0
+ args.token = args.token or (
+ os.getenv(args.api_key_env) if args.api_key_env else env_token()
+ )
+ if args.command in {"health", "models"}:
+ data = http_request(
+ method="GET",
+ base_url=args.base_url,
+ path=PATHS[args.command],
+ timeout=args.timeout,
+ )
+ elif file:
+ data = post_multipart(args, PATHS[args.command], payload, file)
+ else:
+ data = post_json(args, PATHS[args.command], payload)
+ if args.command in {"asr", "health", "models"}:
+ print(json.dumps(json.loads(data), ensure_ascii=False, indent=2))
+ else:
+ write_output(args.out, data)
+ print(
+ json.dumps(
+ {
+ "ok": True,
+ "output": str(Path(args.out).resolve()),
+ **wav_info(data),
+ },
+ ensure_ascii=False,
+ )
+ )
+ return 0
+ except (APIError, URLError, OSError, ValueError, RuntimeError) as exc:
+ report = {"ok": False, "error_type": type(exc).__name__}
+ if isinstance(exc, APIError):
+ report.update(http_status=exc.status, confirmed_busy=exc.busy)
+ elif isinstance(exc, (URLError, TimeoutError)):
+ report["message"] = (
+ "connection/timeout; outcome unknown, no automatic retry"
+ )
+ else:
+ message = str(exc)
+ if args.token:
+ message = message.replace(args.token, "[redacted]")
+ report["message"] = message[:240]
+ print(json.dumps(report, ensure_ascii=False), file=sys.stderr)
+ return 1
if __name__ == "__main__":
- main()
+ raise SystemExit(main())
diff --git a/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py
new file mode 100644
index 00000000..642e093d
--- /dev/null
+++ b/plugins/codex/plugins/eapil-voice/skills/eapil-voice/tests/test_audio_clients.py
@@ -0,0 +1,486 @@
+from __future__ import annotations
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+import wave
+from email.parser import BytesParser
+from email.policy import default
+from pathlib import Path
+from unittest import mock
+from urllib.error import HTTPError
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
+import call_voice_api as api
+import batch_clone_generated_audio as batch
+
+
+def wav(seconds=0.02):
+ out = io.BytesIO()
+ with wave.open(out, "wb") as w:
+ w.setnchannels(1)
+ w.setsampwidth(2)
+ w.setframerate(24000)
+ w.writeframes(b"\x01\x00" * int(24000 * seconds))
+ return out.getvalue()
+
+
+def response(data, media):
+ r = io.BytesIO(data)
+ r.headers = {"Content-Type": media}
+ return r
+
+
+class ClientTests(unittest.TestCase):
+ def setUp(self):
+ p = mock.patch.object(
+ api, "urlopen", side_effect=AssertionError("unexpected network")
+ )
+ self.net = p.start()
+ self.addCleanup(p.stop)
+
+ def test_public_status_needs_no_auth(self):
+ self.net.side_effect = None
+ self.net.return_value = response(b'{"status":"ok"}', "application/json")
+ api.http_request(method="GET", base_url="https://example.test", path="/health")
+ req = self.net.call_args.args[0]
+ self.assertIsNone(req.get_header("Authorization"))
+ self.assertEqual(self.net.call_args.kwargs["timeout"], 600)
+
+ def test_environment_priority_and_explicit_variable(self):
+ with mock.patch.dict(
+ os.environ,
+ {
+ "AUDIO_API_TOKEN": "new",
+ "OPENAI_API_KEY": "old",
+ "AUDIO_API_BASE": "https://new.test",
+ "QWEN3_VOICE_API_BASE_URL": "https://old.test",
+ },
+ clear=True,
+ ):
+ self.assertEqual(api.env_token(), "new")
+ self.assertEqual(api.env_base_url(), "https://new.test")
+ for base in (
+ "file:///tmp",
+ "https://token@example.test",
+ "https://example.test/v1",
+ "https://example.test?q=secret",
+ ):
+ with self.subTest(base=base), self.assertRaises(ValueError):
+ api.build_url(base, "/health")
+
+ def test_json_wire_preserves_multiline_unicode_zero_and_false(self):
+ args = api.build_parser().parse_args(
+ [
+ "yue2",
+ "--style",
+ "中文",
+ "--lyrics",
+ "[Verse]\n海风",
+ "--seed",
+ "0",
+ "--cfg-scale",
+ "0",
+ ]
+ )
+ args.token = "test-key"
+ payload = api.payload_from_args(args)
+ self.net.side_effect = None
+ self.net.return_value = response(wav(), "audio/wav")
+ api.post_json(args, api.PATHS["yue2"], payload)
+ req = self.net.call_args.args[0]
+ self.assertEqual(json.loads(req.data), payload)
+ self.assertEqual(payload["seed"], 0)
+ self.assertEqual(payload["lyrics"], "[Verse]\n海风")
+ self.assertNotIn("seconds", payload)
+ vox = api.build_parser().parse_args(
+ [
+ "design",
+ "--provider",
+ "voxcpm2",
+ "--text",
+ "你好",
+ "--instruct",
+ "平静",
+ "--denoise",
+ "false",
+ "--cfg-value",
+ "0",
+ ]
+ )
+ self.assertIs(api.payload_from_args(vox)["denoise"], False)
+
+ def test_multipart_has_real_boundary_complete_file_and_false_zero(self):
+ with tempfile.TemporaryDirectory() as d:
+ file = Path(d) / "参考.wav"
+ file.write_bytes(wav())
+ body, media = api.multipart_body(
+ fields={"chunked": False, "temperature": 0, "ref_text": "你好"},
+ file_path=str(file),
+ )
+ msg = BytesParser(policy=default).parsebytes(
+ ("Content-Type: " + media + "\r\nMIME-Version: 1.0\r\n\r\n").encode()
+ + body
+ )
+ parts = list(msg.iter_parts())
+ values = {
+ p.get_param("name", header="content-disposition"): p.get_payload(
+ decode=True
+ )
+ for p in parts
+ }
+ self.assertEqual(values["file"], file.read_bytes())
+ self.assertEqual(values["chunked"], b"false")
+ self.assertEqual(values["temperature"], b"0")
+ self.assertEqual(values["ref_text"].decode(), "你好")
+
+ def test_success_media_and_truncated_wav_fail_before_writing(self):
+ cases = [
+ (b'{"error":"bad"}', "application/json"),
+ (b"error", "text/html"),
+ (b"garbage", "audio/wav"),
+ (wav()[:-2], "audio/wav"),
+ ]
+ for data, media in cases:
+ with self.subTest(media=media), self.assertRaises(ValueError):
+ self.net.side_effect = None
+ self.net.return_value = response(data, media)
+ api.http_request(
+ method="POST",
+ base_url="https://example.test",
+ path=api.PATHS["music"],
+ token="test",
+ expected="wav",
+ )
+
+ def test_wav_header_durations_and_preflight(self):
+ self.assertEqual(api.wav_info(wav())["sample_rate"], 24000)
+ with tempfile.TemporaryDirectory() as d:
+ file = Path(d) / "long.wav"
+ file.write_bytes(wav(60.1))
+ with self.assertRaises(ValueError):
+ api.preflight_audio(str(file))
+ api.preflight_audio(str(file), False)
+ file.write_bytes(wav(60 + 1 / 24000))
+ with self.assertRaises(ValueError):
+ api.preflight_audio(str(file))
+ file.write_bytes(b"not a WAV")
+ with self.assertRaises(ValueError):
+ api.preflight_audio(str(file))
+ with tempfile.TemporaryDirectory() as d:
+ out = Path(d) / "out.wav"
+ with self.assertRaises(ValueError):
+ api.write_output(str(out), wav()[:-1])
+ self.assertFalse(out.exists())
+
+ def test_asr_invalid_json_or_wrong_text_type_fails(self):
+ for data in (b"not json", b'{"text":7}', b"[]"):
+ self.net.side_effect = None
+ self.net.return_value = response(data, "application/json")
+ with self.assertRaises(ValueError):
+ api.http_request(
+ method="POST",
+ base_url="https://example.test",
+ path=api.PATHS["asr"],
+ token="test",
+ )
+
+ def test_error_detail_string_array_html_and_no_retry(self):
+ for raw in (
+ b'{"detail":"busy"}',
+ b'{"detail":[{"msg":"invalid"}]}',
+ b"bad",
+ ):
+ self.net.reset_mock()
+ self.net.side_effect = HTTPError(
+ "https://example.test", 503, "error", {}, io.BytesIO(raw)
+ )
+ with self.assertRaises(api.APIError) as caught:
+ api.http_request(
+ method="POST",
+ base_url="https://example.test",
+ path=api.PATHS["music"],
+ token="test",
+ )
+ self.assertEqual(caught.exception.status, 503)
+ self.net.assert_called_once()
+ self.assertFalse(caught.exception.busy)
+
+ def test_provider_constraints_and_yue2_combinations(self):
+ for command, payload in [
+ ("clone", {"provider": "qwen", "text": "new"}),
+ (
+ "design",
+ {
+ "provider": "voxcpm2",
+ "text": "new",
+ "instruct": "style",
+ "temperature": 0,
+ },
+ ),
+ (
+ "clone",
+ {
+ "provider": "voxcpm2",
+ "text": "new",
+ "ref_text": "reference",
+ "instruct": "ignored",
+ },
+ ),
+ (
+ "yue2",
+ {"style": "folk", "lyrics": "song", "mode": "direct", "abc": "X:1"},
+ ),
+ (
+ "yue2",
+ {
+ "style": "folk",
+ "lyrics": "song",
+ "min_duration_seconds": 30,
+ "max_duration_seconds": 8,
+ },
+ ),
+ ("music", {"prompt": " "}),
+ ("synthesize", {"text": "new", "temperature": float("nan")}),
+ ]:
+ with self.subTest(command=command), self.assertRaises(ValueError):
+ api.validate_payload(command, payload)
+
+ def test_new_routes_and_lyric_files_dry_run_require_no_key(self):
+ with tempfile.TemporaryDirectory() as d:
+ file = Path(d) / "speech.wav"
+ file.write_bytes(wav())
+ lyrics = Path(d) / "歌词.txt"
+ lyrics.write_text("[Verse]\n你好", encoding="utf-8-sig")
+ commands = [
+ ["reuse", "--file", str(file)],
+ ["music", "--prompt", "piano"],
+ ["sfx", "--prompt", "rain"],
+ ["yue2", "--style", "folk", "--lyrics-file", str(lyrics)],
+ ]
+ for argv in commands:
+ output = io.StringIO()
+ with (
+ mock.patch.dict(os.environ, {}, clear=True),
+ contextlib.redirect_stdout(output),
+ ):
+ self.assertEqual(api.main(argv + ["--dry-run"]), 0)
+ value = json.loads(output.getvalue())
+ self.assertTrue(value["url"].endswith(api.PATHS[argv[0]]))
+ self.assertNotIn("token", value)
+ self.net.assert_not_called()
+
+
+class BatchTests(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.tmp.cleanup)
+ self.root = Path(self.tmp.name)
+ (self.root / "inputs").mkdir()
+ self.ref = self.root / "misleading-name.wav"
+ self.ref.write_bytes(wav())
+ self.src = self.root / "inputs" / "source.wav"
+ self.src.write_bytes(wav())
+ self.argv = [
+ "--input-dir",
+ str(self.root / "inputs"),
+ "--reference-audio",
+ str(self.ref),
+ "--output-dir",
+ str(self.root / "out"),
+ "--token",
+ "test",
+ ]
+ p = mock.patch.object(
+ api, "urlopen", side_effect=AssertionError("unexpected network")
+ )
+ self.net = p.start()
+ self.addCleanup(p.stop)
+
+ def test_reference_uses_explicit_or_one_asr_never_stem(self):
+ args = batch.parse_args(self.argv)
+ with mock.patch.object(
+ batch, "transcribe", return_value={"text": "actual words"}
+ ) as transcribe:
+ self.assertEqual(batch.reference_transcript(args), "actual words")
+ transcribe.assert_called_once()
+ args.reference_text = "explicit actual words"
+ with mock.patch.object(batch, "transcribe") as transcribe:
+ self.assertEqual(batch.reference_transcript(args), "explicit actual words")
+ transcribe.assert_not_called()
+
+ def test_empty_asr_never_clones_and_next_file_continues(self):
+ (self.root / "inputs" / "second.wav").write_bytes(wav())
+ with (
+ mock.patch.object(
+ batch, "transcribe", side_effect=[ValueError("empty"), {"text": "你好"}]
+ ),
+ mock.patch.object(batch, "clone", return_value=wav()) as clone,
+ contextlib.redirect_stdout(io.StringIO()),
+ ):
+ code = batch.main(self.argv + ["--reference-text", "known reference"])
+ self.assertEqual(code, 1)
+ clone.assert_called_once()
+ summary = json.loads(
+ (self.root / "out" / "conversion_summary.json").read_text(encoding="utf-8")
+ )
+ self.assertEqual([r["status"] for r in summary], ["error", "ok"])
+ self.assertEqual(summary[1]["text"], "你好")
+
+ def test_successful_outputs_skip_without_reference_asr_or_clone(self):
+ (self.root / "out").mkdir()
+ (self.root / "out" / "source.wav").write_bytes(wav())
+ with (
+ mock.patch.object(batch, "transcribe") as transcribe,
+ mock.patch.object(batch, "clone") as clone,
+ contextlib.redirect_stdout(io.StringIO()),
+ ):
+ self.assertEqual(batch.main(self.argv), 0)
+ transcribe.assert_not_called()
+ clone.assert_not_called()
+
+ def test_empty_recognition_does_not_fall_back_to_name(self):
+ args = batch.parse_args(self.argv)
+ with (
+ mock.patch.object(api, "post_multipart", return_value=b'{"text":""}'),
+ self.assertRaises(ValueError),
+ ):
+ batch.transcribe(args, self.src)
+ with self.assertRaises(ValueError):
+ batch.normalize_text("")
+ self.assertEqual(batch.normalize_text("你好"), "你好。")
+
+ def test_only_confirmed_busy_retries_and_last_failure_never_sleeps(self):
+ op = mock.Mock(side_effect=[api.APIError(503, "model busy"), b"ok"])
+ with mock.patch.object(batch.time, "sleep") as sleep:
+ self.assertEqual(batch.call_with_busy_retry(op, 2), b"ok")
+ sleep.assert_called_once()
+ for err in [
+ api.APIError(500, "busy"),
+ api.APIError(502, "busy"),
+ api.APIError(504, "timeout"),
+ api.APIError(503, "failed to load model"),
+ TimeoutError("unknown outcome"),
+ ]:
+ op = mock.Mock(side_effect=err)
+ with (
+ mock.patch.object(batch.time, "sleep") as sleep,
+ self.assertRaises(type(err)),
+ ):
+ batch.call_with_busy_retry(op, 3)
+ op.assert_called_once()
+ sleep.assert_not_called()
+
+ def test_busy_retry_reuploads_full_file(self):
+ args = batch.parse_args(self.argv)
+ seen = []
+
+ def send(**kw):
+ seen.append(kw["body"])
+ if len(seen) == 1:
+ raise api.APIError(503, "busy")
+ return b'{"text":"known"}'
+
+ with (
+ mock.patch.object(api, "http_request", side_effect=send),
+ mock.patch.object(batch.time, "sleep"),
+ ):
+ args.retries = 2
+ batch.transcribe(args, self.src)
+ self.assertEqual(len(seen), 2)
+ self.assertTrue(all(self.src.read_bytes() in body for body in seen))
+
+ def test_busy_retry_honors_server_delay_and_stops_on_long_delay(self):
+ op = mock.Mock(side_effect=[api.APIError(503, "busy", "7"), b"ok"])
+ with mock.patch.object(batch.time, "sleep") as sleep:
+ batch.call_with_busy_retry(op, 2)
+ sleep.assert_called_once_with(7)
+ op = mock.Mock(side_effect=api.APIError(503, "busy", "120"))
+ with (
+ mock.patch.object(batch.time, "sleep") as sleep,
+ self.assertRaises(api.APIError),
+ ):
+ batch.call_with_busy_retry(op, 2)
+ op.assert_called_once()
+ sleep.assert_not_called()
+
+ def test_collision_overlap_and_missing_file_fail_before_inference(self):
+ args = batch.parse_args(self.argv)
+ (self.root / "inputs" / "source.mp3").write_bytes(b"compressed")
+ with self.assertRaises(ValueError):
+ batch.prepare(args)
+ (self.root / "inputs" / "source.mp3").unlink()
+ args.output_dir = str(self.root / "inputs")
+ with self.assertRaises(ValueError):
+ batch.prepare(args)
+ args.output_dir = str(self.root / "out")
+ args.reference_audio = str(self.root / "missing.wav")
+ with self.assertRaises(ValueError):
+ batch.prepare(args)
+ self.net.assert_not_called()
+
+ def test_vox_batch_does_not_send_qwen_language(self):
+ args = batch.parse_args(self.argv + ["--provider", "voxcpm2"])
+ with mock.patch.object(api, "post_multipart", return_value=wav()) as send:
+ batch.clone(args, "reference words", "new text")
+ self.assertNotIn("language", send.call_args.args[2])
+ self.assertEqual(send.call_args.args[2]["ref_text"], "reference words")
+
+ def test_wav_no_gain_export_preserves_bytes_without_ffmpeg(self):
+ raw = self.root / "raw.wav"
+ out = self.root / "out.wav"
+ raw.write_bytes(wav())
+ with mock.patch.object(batch.subprocess, "run") as run:
+ batch.export_audio(raw, out, 0, "wav")
+ run.assert_not_called()
+ self.assertEqual(out.read_bytes(), raw.read_bytes())
+
+ def test_failed_conversion_leaves_no_partial_final_and_preserves_old_output(self):
+ raw = self.root / "raw.wav"
+ raw.write_bytes(wav())
+ output = self.root / "out.mp3"
+
+ def failed(command, **kwargs):
+ Path(command[-1]).write_bytes(b"partial invalid MP3")
+ return mock.Mock(returncode=1)
+
+ with (
+ mock.patch.object(batch.subprocess, "run", side_effect=failed),
+ self.assertRaises(RuntimeError),
+ ):
+ batch.export_audio(raw, output, 0, "mp3")
+ self.assertFalse(output.exists())
+ self.assertEqual(list(self.root.glob(".audio-export-*")), [])
+ output.write_bytes(b"existing completed output")
+ with (
+ mock.patch.object(batch.subprocess, "run", side_effect=failed),
+ self.assertRaises(RuntimeError),
+ ):
+ batch.export_audio(raw, output, 0, "mp3")
+ self.assertEqual(output.read_bytes(), b"existing completed output")
+
+ def test_failed_conversion_is_not_skipped_on_manual_rerun(self):
+ def failed(command, **kwargs):
+ Path(command[-1]).write_bytes(b"partial MP3")
+ return mock.Mock(returncode=1)
+
+ argv = self.argv + ["--reference-text", "known", "--format", "mp3"]
+ with (
+ mock.patch.object(
+ batch, "transcribe", return_value={"text": "known"}
+ ) as transcribe,
+ mock.patch.object(batch, "clone", return_value=wav()),
+ mock.patch.object(batch.subprocess, "run", side_effect=failed),
+ contextlib.redirect_stdout(io.StringIO()),
+ ):
+ self.assertEqual(batch.main(argv), 1)
+ self.assertFalse((self.root / "out" / "source.mp3").exists())
+ self.assertEqual(batch.main(argv), 1)
+ self.assertEqual(transcribe.call_count, 2)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json b/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json
index 034d8b9e..50bf2d05 100644
--- a/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/find-skills/.codex-plugin/plugin.json
@@ -1,39 +1,35 @@
{
"name": "find-skills",
"version": "0.1.0",
- "description": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。",
+ "description": "从 Vercel Skills 生态发现和安装适合任务的 Agent Skills。",
"author": {
- "name": "EAPIL",
- "url": "https://git.playones.com/arechen/EapilSkillMarket"
+ "name": "Vercel",
+ "url": "https://github.com/vercel-labs"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
- "license": "Proprietary",
- "keywords": [
- "eapil",
- "codex-skill",
- "find-skills"
- ],
+ "homepage": "https://github.com/vercel-labs/skills",
+ "repository": "https://github.com/vercel-labs/skills",
+ "license": "MIT",
+ "keywords": [],
"skills": "./skills/",
"interface": {
"displayName": "find-skills",
- "shortDescription": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。",
- "longDescription": "当用户提出“如何执行X操作”、“寻找适用于X的技能”、“是否有可以完成……的技能”之类的问题,或表示希望扩展功能时,该技能可帮助用户发现并安装相应的智能体技能。当用户正在寻找可能以可安装技能形式存在的功能时,应使用此技能。",
- "developerName": "EAPIL",
+ "shortDescription": "查找并选择适合任务的 Agent Skills。",
+ "longDescription": "跟随 vercel-labs/skills 的 find-skills 原始技能,使用 Skills CLI 搜索并核对来源。需要 Node.js 和 npx。",
+ "developerName": "Vercel",
"category": "Productivity",
"capabilities": [
"Read",
"Write"
],
"defaultPrompt": [
- "使用 find-skills 帮我完成这个任务。"
+ "使用 find-skills 查找适合当前任务的技能。"
],
- "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket",
+ "websiteURL": "https://github.com/vercel-labs/skills",
+ "privacyPolicyURL": "https://github.com/vercel-labs/skills",
+ "termsOfServiceURL": "https://github.com/vercel-labs/skills",
"brandColor": "#2563EB",
"screenshots": [],
- "composerIcon": "./assets/market-icon.svg",
- "logo": "./assets/market-icon.svg"
+ "logo": "./assets/market-icon.svg",
+ "composerIcon": "./assets/market-icon.svg"
}
}
diff --git a/plugins/codex/plugins/find-skills/LICENSE b/plugins/codex/plugins/find-skills/LICENSE
new file mode 100644
index 00000000..eabe3008
--- /dev/null
+++ b/plugins/codex/plugins/find-skills/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Vercel, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..b968644e
--- /dev/null
+++ b/plugins/codex/plugins/find-skills/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "find-skills",
+ "repo": "https://github.com/vercel-labs/skills.git",
+ "ref": "main",
+ "commit": "14cf84aa922cccc7e5b11fee5af9a01b3750fddf",
+ "adapter": "claude-skill",
+ "sourcePath": "skills/find-skills",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md b/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md
index 114c6637..5caf394a 100644
--- a/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md
+++ b/plugins/codex/plugins/find-skills/skills/find-skills/SKILL.md
@@ -1,6 +1,6 @@
---
name: find-skills
-description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
+description: "当用户想查找、选择或安装现成 Agent Skill 时,搜索技能生态并核对来源与适用性。"
---
# Find Skills
@@ -24,9 +24,8 @@ The Skills CLI (`npx skills`) is the package manager for the open agent skills e
**Key commands:**
-- `npx skills find [query]` - Search for skills interactively or by keyword
+- `npx skills find [query] [--owner ]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
- `npx skills add ` - Install a skill from GitHub or other sources
-- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
@@ -54,7 +53,7 @@ For example, top skills for web development include:
If the leaderboard doesn't cover the user's need, run the find command:
```bash
-npx skills find [query]
+npx skills find [query] [--owner ]
```
For example:
diff --git a/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json b/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json
index f2e2f223..675dec41 100644
--- a/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/generate-agents-md/.codex-plugin/plugin.json
@@ -1,13 +1,13 @@
{
"name": "generate-agents-md",
- "version": "0.1.0",
- "description": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。",
+ "version": "1.0.0",
+ "description": "创建、更新或审查 AGENTS.md;基于仓库事实生成精炼、准确的 Agent 指南。",
"author": {
"name": "EAPIL",
"url": "https://git.playones.com/arechen/EapilSkillMarket"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
+ "homepage": "https://git.playones.com/chenmingxuan/ManySkills/src/branch/main/plugins/generate-agents-md",
+ "repository": "https://git.playones.com/chenmingxuan/ManySkills",
"license": "Proprietary",
"keywords": [
"eapil",
@@ -17,8 +17,8 @@
"skills": "./skills/",
"interface": {
"displayName": "AGENTS.md生成器",
- "shortDescription": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。",
- "longDescription": "当需要创建、更新、标准化、审核或迁移 AGENTS.md 文件以及仓库级的 AI 智能体配置时使用,这些配置包括顶层指南或嵌套指南、项目入门相关内容、与 Claude/Copilot/Cursor 的兼容性设置,以及冗余上下文的清理工作。",
+ "shortDescription": "基于仓库事实创建、更新或审查精炼准确的 AGENTS.md。",
+ "longDescription": "为 Codex 创建或维护 AGENTS.md,保留项目特有约束、明确命令工作目录、按需引用资料,并提供机械校验。其他 Agent 的兼容规则按需核对。",
"developerName": "EAPIL",
"category": "Developer Tools",
"capabilities": [
diff --git a/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..2516eb95
--- /dev/null
+++ b/plugins/codex/plugins/generate-agents-md/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "generate-agents-md",
+ "repo": "https://git.playones.com/chenmingxuan/ManySkills.git",
+ "ref": "v1.0.0",
+ "commit": "e869eb169eeb5a7cd9e58c0e514fa2dbbf50987d",
+ "adapter": "codex-plugin",
+ "sourcePath": "plugins/generate-agents-md",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md
index 08d851a3..51ffa075 100644
--- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md
+++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/SKILL.md
@@ -1,67 +1,28 @@
---
name: generate-agents-md
-description: Use when Codex needs to create, refresh, standardize, audit, or migrate AGENTS.md files or repository-level AI agent instructions, including root or nested guides, project onboarding context, Claude/Copilot/Cursor compatibility, and context-bloat cleanup.
+description: Create, revise, or audit AGENTS.md repository instructions when explicitly requested.
---
# Generate AGENTS.md
-## Overview
+Produce a concise guide that changes the agent's decisions: verified commands with working directories, non-obvious constraints, and links to context needed for specific tasks. Use the project's language and structure; no required headings or line count.
-Create lean, scoped, verifiable `AGENTS.md` files from repository facts. Treat root `AGENTS.md` as always-on context: a map and guardrail, not an encyclopedia.
+## Approach
-## Workflow
+Inspect the existing guide and the repository evidence relevant to the request. A small edit needs a small investigation; a new guide may need manifests, CI, and contributor docs. Preserve useful user rules. Remove stale or redundant guidance when the evidence supports it; surface unresolved conflicts rather than inventing facts or permissions.
-1. **Scan facts before drafting.** Inspect existing `AGENTS.md`, `CLAUDE.md`, `.github/copilot-instructions.md`, tool rules, manifests, CI, key directories, and docs. Use `rg --files` first. Record unknowns instead of guessing.
-2. **Extract only verifiable facts.** Commands must come from files such as `package.json`, `Makefile`, `pyproject.toml`, `Cargo.toml`, `go.mod`, CI workflows, or repo docs. Paths must exist. Omit any command, path, or section that cannot be verified; report the gap in the final response instead of adding placeholders.
-3. **Choose the scope model.**
- - Use one root file for small repos with shared commands and conventions.
- - Use root plus child `AGENTS.md` files for monorepos or directories with distinct commands, risks, or conventions.
- - Keep complex task workflows in skills or references, not in always-on `AGENTS.md`.
-4. **Draft with the standard contract.** Read `references/agents-md-reference.md` for root and child templates, section guidance, and validation criteria.
-5. **Update conservatively.** If `AGENTS.md` already exists, merge rather than replace. Preserve user-written sections unless they are clearly stale or conflict with verified facts. Show conflicts before changing them when possible.
-6. **Validate before finishing.** Run the bundled validator when applicable. Use `--local` for child guides, and use `--warnings-as-errors` only when the user or repo explicitly wants strict gating:
+Keep shared rules at the root. Add a child guide only when that area needs distinct instructions. Link long procedures with their read conditions. Do not copy a repository map, formatter rules, or generic engineering advice into always-loaded context.
+
+For a new guide, scope changes, command ambiguity, or a substantial audit, consult [the reference](references/agents-md-reference.md). It covers evidence, Codex loading, optional content, and validation limits. Ordinary wording fixes do not need the full reference.
+
+## Checks and delivery
+
+Review whether each retained instruction is accurate, scoped, useful, and consistent with the user's authorization. Check only what the change requires. For mechanical checks, run the bundled helper without executing the guide's commands:
```bash
-python path/to/generate-agents-md/scripts/validate_agents_md.py --repo /path/to/repo --file /path/to/repo/AGENTS.md
+python scripts/validate_agents_md.py --repo /path/to/repo --file /path/to/repo/AGENTS.md
```
-Treat validator warnings as review prompts. Do not add unverifiable sections just to silence a warning. Also run repo-native markdown/prose checks if they exist.
+Resolve the script relative to this skill directory. Use `--local` for a child guide whose inline paths and commands are local; use `--cwd` when they refer to another directory. Markdown links are always relative to the guide. Warnings require judgment; use `--warnings-as-errors` only for an explicitly strict check.
-## Content Placement
-
-| Content | Put it in |
-|---|---|
-| Global project purpose, key paths, commands, completion checks, risk boundaries | Root `AGENTS.md` |
-| Directory-specific commands, naming, tests, generated files, local risks | Child `AGENTS.md` |
-| Long release/migration/audit workflows, repeated procedures, tool recipes | Skill or reference file |
-| Human contributor background, product explanation, broad docs | `README.md` or `docs/` |
-
-## Quality Bar
-
-- Keep root files concise, usually under 200 lines.
-- Use exact commands, exact paths, and concrete rules.
-- Keep recommended sections only when they contain verified content.
-- Prefer `Always`, `Ask first`, and `Never` boundaries for risk.
-- State user instructions override repo guidance; child files override parent files for their directory.
-- Do not include secrets, internal credentials, or unverifiable environment assumptions.
-- Do not invent commands, paths, tests, package managers, ownership, or architecture.
-
-## Final Response
-
-Report:
-
-- Files created or updated.
-- Evidence used for commands and key paths.
-- Validation commands run and results.
-- Unknowns or items that need human confirmation.
-
-## Common Mistakes
-
-| Mistake | Fix |
-|---|---|
-| Root file repeats docs or long workflows | Move detail to child guides, skills, or references |
-| Commands are tool names instead of runnable commands | Use exact invocations like `pnpm test` or `make check` |
-| Child guide repeats parent rules | Keep only local differences |
-| Existing custom guidance is overwritten | Merge and preserve non-conflicting user content |
-| Placeholder text remains | Replace with verified facts or write `Unknown` in review notes, not in final guide |
-| Validator warnings are treated as required content | Investigate warnings, but omit sections that lack verified facts |
+Report changed files, relevant evidence, checks performed, and any remaining uncertainty. Passing the helper does not prove command execution, instruction loading, or improved agent performance.
diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml
index 3e82ca99..983ce2c8 100644
--- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml
+++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "Generate AGENTS.md"
- short_description: "Create lean project agent guides"
- default_prompt: "Use $generate-agents-md to create a lean, verified AGENTS.md for this repository."
+ short_description: "Create or audit concise repository instructions"
+ default_prompt: "Use $generate-agents-md to create or revise this repository's AGENTS.md using verified facts and task-specific context."
diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md
index 54a46878..452cb2da 100644
--- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md
+++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/references/agents-md-reference.md
@@ -1,134 +1,80 @@
-# AGENTS.md Reference
+# AGENTS.md reference
-Use this reference when drafting, updating, or auditing `AGENTS.md`.
+Use for creation, substantial audits, scope changes, or ambiguous commands. Small wording edits can stay in the entrypoint.
-## Fact Sources
+## Evidence and content selection
-| Need | Prefer these sources |
-|---|---|
-| Package manager and scripts | `package.json`, `pnpm-lock.yaml`, `yarn.lock`, `package-lock.json`, `bun.lockb` |
-| Python commands | `pyproject.toml`, `uv.lock`, `poetry.lock`, `requirements*.txt`, `tox.ini`, `noxfile.py` |
-| Go/Rust/.NET/Java commands | `go.mod`, `Cargo.toml`, `*.csproj`, `pom.xml`, `build.gradle*` |
-| CI expectations | `.github/workflows/*`, `.gitlab-ci.yml`, `azure-pipelines.yml`, `Jenkinsfile` |
-| Architecture and boundaries | `README.md`, `CONTRIBUTING.md`, `docs/`, existing instruction files |
-| Generated or dangerous paths | `.gitignore`, generator configs, migration dirs, infra dirs, lockfiles |
+Keep facts that change decisions and are costly to rediscover. Verify commands against the relevant package manifest, build file, CI, or maintained contributor docs; state their working directory and required environment. A listed script exists is weaker evidence than a successful run. If running a command requires unavailable services or credentials, disclose that limit instead of claiming validation.
-Never promote a guessed command or path into final `AGENTS.md`. If a fact cannot be confirmed, omit it or list it as a final-response unknown.
+Useful content, when supported:
-## Scope Decision
+- A surprising service boundary, generated-file rule, data invariant, or environment trap.
+- The shortest relevant setup or verification command and its working directory.
+- A task-conditioned pointer to maintained architecture, schema, or release documentation.
+- A project-specific completion condition or authorization boundary.
-Choose **root only** when:
+Omit obvious directory inventories, generic advice, redundant style rules enforced by tools, and facts that are readily read from code. Unverified facts belong in the handoff, not a fill-in template. AGENTS.md is ordinary Markdown: headings and length are choices, not correctness criteria.
-- The repo has one main stack and one set of commands.
-- Directory conventions are uniform.
-- A concise root guide can cover common tasks without local exceptions.
+## Scope and loading
-Choose **root plus child guides** when:
+Choose one root guide for shared instructions. Add a child guide when its directory has distinct commands or constraints; keep only the local differences. State whether commands run from the repository root or the child directory. Do not duplicate the parent's full text.
-- The repo is a monorepo or has separate apps, services, docs, packages, or examples.
-- Commands differ by directory.
-- Generated files, migrations, security code, or public API contracts are localized.
-- Parent guidance would become long because of local exceptions.
+Codex discovers instructions at startup. Its documented chain includes the global Codex home and project directories from the repository root toward the current working directory. At each level it selects the first applicable file: AGENTS.override.md, AGENTS.md, then configured fallback names. Later, deeper instructions take precedence for conflicts. An override does not concatenate with AGENTS.md in the same directory. Non-empty files form a combined instruction chain subject to a configurable size limit.
-Root files should orient. Child files should specialize.
+This discovery rule does not guarantee that a newly created child guide will be loaded merely because a running agent edits a file there. Check the target working directory, active session, and host behavior when loading matters. After changing instructions, use a fresh session to verify which files it actually receives; use the target host's current documentation for configuration limits.
-## Root Template
+Do not claim an AGENTS.md can override system or developer instructions. Preserve explicit user requirements and established authorization. Host-specific rules belong in a compatibility note only when the user asks for that host. For Claude, Cursor, or Copilot migration, check that host's current primary documentation before adding loader files, imports, or symlinks; do not rename or duplicate guides by default.
+
+## Editing and boundaries
+
+Preserve the user's valid conventions and language. Remove a rule only when it is obsolete, redundant, contradicted by verified facts, or covered by the requested cleanup. Surface conflicts whose intended resolution cannot be established.
+
+Replace unconditional reading lists with conditions: read architecture notes for service-boundary changes, schema notes for schema work, and release notes for a release. Keep a fixed sequence only where deviation breaks correctness or a fragile operation.
+
+Describe concrete effects and authorization boundaries. Do not add an approval checklist for every dependency, API, CI, or schema edit. State safe autonomous actions only when their safety is verified; a fixture being disposable or a command having no production access must come from evidence. Keep genuinely necessary data, credential, and deployment constraints.
+
+## Small examples
+
+These illustrate selection, not a mandatory template:
```markdown
-# Project Agent Guide
+# Project instructions
-## Project Overview
-[1-3 factual sentences: project type, primary stack, most important working constraint.]
-
-## Scope
-- This file applies to the whole repository.
-- More specific `AGENTS.md` files in subdirectories override this file for their paths.
-- User instructions in the current task override repository guidance.
-
-## Quick Commands
-- Install: `[exact command]`
-- Dev: `[exact command]`
-- Test: `[exact command]`
-- Lint: `[exact command]`
-- Build: `[exact command]`
-- Typecheck: `[exact command]`
-
-## Key Paths
-- `path/` - purpose
-- `path/file.ext` - purpose
-
-## Architecture Boundaries
-- [Concrete boundary or dependency rule.]
-- [High-risk area and why it matters.]
-
-## Conventions
-- [Style, naming, formatting, import, or documentation rule that is specific to this repo.]
-- [Testing expectation that is actually supported by the repo.]
-
-## Definition of Done
-- Run `[exact command]` before finishing changes that touch [scope].
-- If a required command cannot run, report the reason and the expected follow-up.
-
-## Boundaries
-- Always: [concrete safe behavior]
-- Ask first: [dependency, schema, CI, public API, security, data migration, billing, destructive operation]
-- Never: [secrets, generated/vendor edits, broad refactors without request]
-
-## Child Guides
-- `subsystem/AGENTS.md` - local rules for [subsystem]
-
-## References
-- `docs/file.md` - when to read it
+Run frontend checks from apps/web with pnpm run check.
+Database changes use the transaction rules in docs/database.md.
+The schema client is generated; edit its source schema instead.
```
-Remove sections that have no verified content. Do not leave placeholder text. `Project Overview` and `Scope` usually belong in every root guide; the other template sections are recommended when the repository has facts to support them.
+Only use commands and paths that exist in the target project. Link the real document using a path relative to the guide, and add a child guide only if its local rules justify one.
-## Child Template
+## Mechanical validation
-```markdown
-# Local Agent Guide
+The dependency-free helper checks readable inputs, obvious unfinished prose markers, literal paths and Markdown links, and common package-script commands. It has no fixed heading requirements and never runs a documented command.
-## Local Purpose
-[What this directory owns. Keep it factual.]
+- `--repo`: repository boundary.
+- `--file`: guide path, resolved from the process working directory.
+- `--local`: resolve inline paths and commands from the guide's directory instead of the repository root.
+- `--cwd`: override that inline-path and command base; a relative value is resolved from the repository root. Markdown links still resolve from the guide's directory.
+- `--warnings-as-errors`: optional strict exit status. Ordinary warnings are review prompts.
-## Inherits
-- Follow the nearest parent `AGENTS.md` unless this file gives a more specific local rule.
+The command check reads only the nearest applicable package.json within the repository, not every package or dependency tree. It recognizes common npm/pnpm/yarn/bun script forms, rtk wrappers, explicit directory flags, and a simple leading cd. Workspace selectors, pipelines, shell expansion, and complex control flow are reported as unverified when recognized; they are not interpreted as a successful check. Run the appropriate project check separately when execution is authorized and useful.
-## Local Commands
-- Test: `[exact command]`
-- Lint: `[exact command]`
+It does not infer a command's directory from surrounding prose. Use the relevant `--cwd` or an explicit directory flag when checking that command. A root guide describing several package directories may need manual command checks; a warning is not proof that its instructions are wrong. Ambiguous slash-prefixed routes such as /billing are not treated as filesystem references.
-## Local Conventions
-- [Naming, code organization, generated files, docs, or test placement specific to this path.]
+The helper cannot establish command safety, runtime behavior, instruction priority, conflicting prose, or whether the guide improves outcomes. Review those separately. A missing-path warning may refer to a generated artifact; confirm its purpose before changing the guide.
-## Local Risks
-- Ask first: [local high-risk changes]
-- Never: [local forbidden edits]
-```
+When changing the helper, run its regression suite from this skill directory: `python -B -m unittest discover -s tests -p 'test_*.py'`.
-If a child directory has no distinct commands, omit `Local Commands` rather than inventing commands. Keep `Local Purpose` and `Inherits` in normal child guides; include `Local Conventions` and `Local Risks` only when there are local facts worth capturing.
+## Behavioral acceptance
-## Scoring Checklist
+For a substantial rewrite, try a representative creation or audit task in an isolated repository. Check that it preserves user constraints, chooses the right scope, uses real commands, avoids invented sections, and gives a usable result. Compare old and new behavior only when both runs were actually captured. Text reduction alone is not evidence of faster or better work.
-A strong guide passes these checks:
+## Sources
-- **Specific:** Every command and path is concrete.
-- **Verified:** Commands and paths trace to repository files.
-- **Scoped:** Root guidance stays global; child guidance contains only local differences.
-- **No filler:** Missing recommended sections are acceptable when there is no verified content.
-- **Short:** Root is usually under 200 lines and avoids long procedures.
-- **Actionable:** A new agent can find the right files, run the right checks, and avoid high-risk edits.
-- **Compatible:** It states parent/child precedence and user-instruction precedence.
-- **Maintainable:** It avoids duplicated docs and points to references for deep background.
+Reviewed 2026-09-30. These sources inform the guidance; their examples are not universal requirements.
-## Audit Signals
-
-Flag these during updates:
-
-- `TODO`, `TBD`, `FIXME`, bracket placeholders, or template language.
-- Commands that do not exist in manifests, Makefiles, CI, or docs.
-- Paths that no longer exist.
-- Duplicated or conflicting parent/child rules.
-- Long release, migration, or audit procedures in the root guide.
-- Tool-specific assumptions presented as cross-tool rules.
-- Secrets, internal credentials, or sensitive environment details.
+- [OpenAI: Rethinking skills and prompts for GPT-6 Astra](https://developers.openai.com/blog/rethinking-skills-and-prompts-for-gpt-6-astra), 2026-09-11: precise discovery, progressive disclosure, conditional reading, proportional boundaries.
+- [OpenAI: Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md): Codex discovery and precedence; recheck for host-specific loading questions.
+- [AGENTS.md](https://agents.md/): plain Markdown, optional sections, scoped instructions.
+- [Anthropic: Claude Code best practices](https://code.claude.com/docs/en/best-practices): concise persistent context and observed behavior; not a Codex loader specification.
+- [OpenAI: Testing Agent Skills Systematically with Evals](https://developers.openai.com/blog/eval-skills), 2026-01-22: observable outcomes and targeted evaluation.
diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py
index a1e2593d..13477c6c 100644
--- a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py
+++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/scripts/validate_agents_md.py
@@ -1,158 +1,213 @@
#!/usr/bin/env python3
-"""Lightweight AGENTS.md validator.
-
-Checks for common problems before an agent guide is treated as done:
-missing recommended sections, placeholders, nonexistent referenced paths, and
-package manager commands that do not map to package.json scripts.
-"""
+"""Conservative, dependency-free AGENTS.md checks; never execute guide commands."""
from __future__ import annotations
import argparse
import json
import re
+import shlex
import sys
from pathlib import Path
+from urllib.parse import unquote
-
-PLACEHOLDER_RE = re.compile(
- r"\b(TODO|TBD|FIXME|XXX|REVIEW_NEEDED)\b|"
- r"\{\{[^}]+\}\}|"
- r"<[^>\n]+>|"
- r"\[[^\]\n]*(exact command|path|project|真实|项目类型|关键)[^\]\n]*\]",
- re.IGNORECASE,
-)
-
-CODE_SPAN_RE = re.compile(r"`([^`\n]+)`")
-HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
-
-ROOT_RECOMMENDED_HEADINGS = {
- "Project Overview",
- "Scope",
- "Quick Commands",
- "Key Paths",
- "Definition of Done",
- "Boundaries",
-}
-
-LOCAL_RECOMMENDED_HEADINGS = {
- "Local Purpose",
- "Inherits",
- "Local Conventions",
- "Local Risks",
-}
-
-PATH_HINT_RE = re.compile(r"(^\.{1,2}[\\/]|[\\/]|^[\w.-]+\.[A-Za-z0-9]+$)")
-COMMAND_PREFIX_RE = re.compile(
- r"^(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?([\w:.-]+)(?:\s|$)"
+INLINE = re.compile(r"`([^`\n]+)`")
+LINK = re.compile(r"!?\[[^\]\n]*\]\(\s*(<[^>\n]+>|[^\s)]+)(?:\s+[^)]*)?\)")
+MARKER = re.compile(
+ r"^\s*(?:[-*+]\s+|#{1,6}\s+)?(?:TODO|TBD|FIXME|REVIEW_NEEDED)"
+ r"(?:\s*[::].*|\s*)$|\[TODO:[^\]\n]*\]",
+ re.IGNORECASE | re.MULTILINE,
)
+SHELL_LANGS = {"bash", "sh", "shell", "zsh", "powershell", "pwsh", "ps1", "cmd", "bat", "console", ""}
+MANAGERS = {"npm", "pnpm", "yarn", "bun"}
+BUILTINS = {"install", "add", "remove", "exec", "dlx", "create", "init", "run", "publish", "version", "list", "audit", "help", "cache", "config"}
-def load_package_scripts(repo: Path) -> dict[str, set[str]]:
- scripts: dict[str, set[str]] = {}
- for package_file in repo.rglob("package.json"):
- if "node_modules" in package_file.parts:
+def markdown_parts(text: str) -> tuple[str, list[str]]:
+ """Separate prose/inline snippets from fenced commands and HTML comments."""
+ text = re.sub(r"", "", text, flags=re.DOTALL)
+ prose, commands = [], []
+ marker, width, language = "", 0, ""
+ for line in text.splitlines():
+ fence = re.match(r"^\s*(`{3,}|~{3,})(.*)$", line)
+ if fence:
+ run, info = fence.groups()
+ if not marker:
+ marker, width = run[0], len(run)
+ language = info.strip().lower()
+ elif run[0] == marker and len(run) >= width and not info.strip():
+ marker = ""
continue
- try:
- data = json.loads(package_file.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
- continue
- package_scripts = data.get("scripts")
- if isinstance(package_scripts, dict):
- scripts[str(package_file.relative_to(repo))] = set(package_scripts)
- return scripts
+ if marker:
+ if language in SHELL_LANGS and line.strip() and not line.lstrip().startswith("#"):
+ commands.append(line.strip())
+ else:
+ prose.append(line)
+ plain = "\n".join(prose)
+ return plain, INLINE.findall(plain) + commands
-def normalize_path_token(token: str) -> str | None:
- if token.startswith(("http://", "https://", "$")):
+def literal_path(token: str) -> str | None:
+ token = token.strip()
+ if re.match(r"^[a-z][a-z0-9+.-]*:", token, re.IGNORECASE) and not re.match(r"^[A-Za-z]:[\\/]", token):
return None
- if any(part in token for part in ("&&", "||", " --", " -")):
+ if any(char.isspace() for char in token) or any(char in token for char in "$~*?[]{}<>()|;&"):
return None
- if any(char in token for char in "*?[]"):
- return None
- if token in {"AGENTS.md", "CLAUDE.md", "README.md"}:
- return token
- if PATH_HINT_RE.search(token):
- return token.rstrip("/")
+ token = re.sub(r"(?:#L\d+(?:-L\d+)?|:\d+(?::\d+)?(?:-\d+)?)$", "", token)
+ if token.startswith("/") and not re.search(r"\.[A-Za-z][A-Za-z0-9]*$", token):
+ return None # A bare /billing or /v1/users may be a route, not a file.
+ if "/" in token or "\\" in token or re.search(r"\.[A-Za-z][A-Za-z0-9]*$", token):
+ return token.replace("\\", "/")
return None
-def path_exists(repo: Path, token: str) -> bool:
- candidate = (repo / token).resolve()
+def path_warning(repo: Path, base: Path, token: str) -> str | None:
+ target = (base / token).resolve()
+ if not target.is_relative_to(repo):
+ return f"External path not verified: {token}"
+ if not target.exists():
+ return f"Referenced path may not exist (base {base.relative_to(repo) or '.'}): {token}"
+ return None
+
+
+def package_warning(command: str, repo: Path, base: Path) -> str | None:
+ """Check common script forms only. Unsupported shell/workspace semantics stay unverified."""
+ command = command.removeprefix("$ ").strip()
+ cd = re.match(r"^cd\s+(\"[^\"]+\"|'[^']+'|\S+)\s*&&\s*(.+)$", command)
+ if cd:
+ base = (base / cd[1].strip("\"'")).resolve()
+ command = cd[2]
try:
- candidate.relative_to(repo.resolve())
+ tokens = [word.strip("\"'") for word in shlex.split(command, posix=False)]
except ValueError:
- return False
- return candidate.exists()
+ return f"Command quoting not verified: {command}" if re.search(r"\b(?:npm|pnpm|yarn|bun)\b", command) else None
+ if tokens[:1] == ["rtk"]:
+ tokens = tokens[1:]
+ if tokens[:1] == ["proxy"]:
+ tokens = tokens[1:]
+ if not tokens or tokens[0] not in MANAGERS:
+ return None
+ if re.search(r"[|;&$`]|\*|\?", command) or any(t.split("=")[0] in {"--workspace", "--workspaces", "-w", "--filter", "--recursive", "-r"} for t in tokens):
+ return f"Complex or workspace command not verified: {command}"
+ manager, args = tokens[0], tokens[1:]
+ cleaned, index = [], 0
+ while index < len(args):
+ token = args[index]
+ flag = token.split("=", 1)[0]
+ if flag in {"--prefix", "--dir", "-C", "--cwd"}:
+ if "=" in token:
+ directory = token.split("=", 1)[1]
+ elif index + 1 < len(args):
+ index += 1
+ directory = args[index]
+ else:
+ return f"Directory flag not verified: {command}"
+ base = (base / directory).resolve()
+ else:
+ cleaned.append(token)
+ index += 1
+ words = [word for word in cleaned if not word.startswith("-")]
+ if not words:
+ return None
+ action = words[0]
+ if action in {"run", "run-script"}:
+ if len(words) < 2:
+ return None
+ script = words[1]
+ elif manager == "npm":
+ if action not in {"test", "start", "stop", "restart"}:
+ return None
+ script = action
+ elif manager == "bun" and action in {"build", "test"}:
+ return None # Bun has native build/test commands, not package-script aliases.
+ elif action not in BUILTINS:
+ script = action
+ else:
+ return None
+ if not base.is_relative_to(repo) or not base.is_dir():
+ return f"Command working directory not verified: {base}"
+ package = None
+ for directory in (base, *base.parents):
+ candidate = directory / "package.json"
+ if candidate.is_file():
+ package = candidate
+ break
+ if directory == repo:
+ break
+ if package is None:
+ return f"Package command not verified (no package.json from {base.relative_to(repo)}): {command}"
+ try:
+ data = json.loads(package.read_text(encoding="utf-8-sig"))
+ scripts = data.get("scripts", {})
+ if not isinstance(scripts, dict):
+ scripts = {}
+ except (OSError, UnicodeError, ValueError, AttributeError) as exc:
+ return f"Cannot read {package.relative_to(repo)}: {exc}"
+ if script not in scripts:
+ if "--if-present" in cleaned:
+ return None
+ if manager == "npm" and action in {"start", "restart", "stop"}:
+ return f"npm lifecycle fallback not verified: {command}"
+ return f"Missing package script '{script}' in {package.relative_to(repo)}: {command}"
+ return None
def validate(args: argparse.Namespace) -> tuple[list[str], list[str]]:
- repo = Path(args.repo).resolve()
- guide = Path(args.file).resolve()
- errors: list[str] = []
- warnings: list[str] = []
-
- if not repo.exists():
- return [f"Repository path does not exist: {repo}"], warnings
- if not guide.exists():
- return [f"Guide file does not exist: {guide}"], warnings
-
- text = guide.read_text(encoding="utf-8")
- headings = set(HEADING_RE.findall(text))
-
- recommended = LOCAL_RECOMMENDED_HEADINGS if args.local else ROOT_RECOMMENDED_HEADINGS
- missing = sorted(recommended - headings)
- if missing:
- warnings.append("Missing recommended headings: " + ", ".join(missing))
-
- placeholders = sorted(set(match.group(0) for match in PLACEHOLDER_RE.finditer(text)))
- if placeholders:
- errors.append("Placeholder or review marker remains: " + ", ".join(placeholders[:10]))
-
- code_spans = CODE_SPAN_RE.findall(text)
- for token in code_spans:
- path_token = normalize_path_token(token.strip())
- if path_token and not path_exists(repo, path_token):
- warnings.append(f"Referenced path may not exist: `{token}`")
-
- package_scripts = load_package_scripts(repo)
- known_scripts = set().union(*package_scripts.values()) if package_scripts else set()
- for token in code_spans:
- match = COMMAND_PREFIX_RE.match(token.strip())
- if not match:
+ repo, guide = Path(args.repo).resolve(), Path(args.file).resolve()
+ if not repo.is_dir():
+ return [f"Repository directory does not exist: {repo}"], []
+ if not guide.is_file() or not guide.is_relative_to(repo):
+ return [f"Guide must be a file inside the repository: {guide}"], []
+ base = guide.parent if args.local else repo
+ if getattr(args, "cwd", None):
+ base = (repo / args.cwd).resolve()
+ if not base.is_dir() or not base.is_relative_to(repo):
+ return [f"Working directory must exist inside the repository: {base}"], []
+ try:
+ text = guide.read_text(encoding="utf-8-sig")
+ except (OSError, UnicodeError) as exc:
+ return [f"Cannot read guide: {exc}"], []
+ prose, snippets = markdown_parts(text)
+ errors, warnings = [], []
+ if not prose.strip() and not snippets:
+ errors.append("Guide has no instruction content")
+ for marker in MARKER.finditer(INLINE.sub("", prose)):
+ errors.append(f"Unfinished prose marker: {marker.group().strip()}")
+ for match in LINK.finditer(INLINE.sub("", prose)):
+ token = unquote(match[1].strip("<>").split("#", 1)[0])
+ if not token or re.match(r"^[a-z][a-z0-9+.-]*:", token, re.IGNORECASE):
continue
- script_name = match.group(1)
- if script_name in {"install", "add", "remove", "exec", "dlx", "create"}:
- continue
- if known_scripts and script_name not in known_scripts:
- warnings.append(
- f"Package command references missing package.json script: `{token}`"
- )
-
- return errors, warnings
+ warning = path_warning(repo, guide.parent, token)
+ if warning:
+ warnings.append(warning)
+ for snippet in snippets:
+ token = literal_path(snippet)
+ if token:
+ warning = path_warning(repo, base, token)
+ if warning:
+ warnings.append(warning)
+ warning = package_warning(snippet, repo, base)
+ if warning:
+ warnings.append(warning)
+ return list(dict.fromkeys(errors)), list(dict.fromkeys(warnings))
def main() -> int:
- parser = argparse.ArgumentParser(description="Validate AGENTS.md guardrails.")
- parser.add_argument("--repo", required=True, help="Repository root to validate against.")
- parser.add_argument("--file", required=True, help="AGENTS.md file to validate.")
- parser.add_argument("--local", action="store_true", help="Validate a child/local guide.")
- parser.add_argument(
- "--warnings-as-errors",
- action="store_true",
- help="Exit nonzero when warnings are present.",
- )
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--repo", required=True, help="Repository boundary")
+ parser.add_argument("--file", required=True, help="Guide path, relative to process cwd")
+ parser.add_argument("--local", action="store_true", help="Inline paths/commands are relative to guide directory")
+ parser.add_argument("--cwd", help="Inline-path/command base, relative to repo or absolute")
+ parser.add_argument("--warnings-as-errors", action="store_true", help="Fail on heuristic warnings as well as errors")
args = parser.parse_args()
-
errors, warnings = validate(args)
for error in errors:
print(f"ERROR: {error}", file=sys.stderr)
for warning in warnings:
print(f"WARNING: {warning}", file=sys.stderr)
-
if errors or (args.warnings_as_errors and warnings):
return 1
- print("AGENTS.md validation passed")
+ print("Mechanical checks passed" + (" with warnings; review required" if warnings else ""))
return 0
diff --git a/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py
new file mode 100644
index 00000000..96c1cbeb
--- /dev/null
+++ b/plugins/codex/plugins/generate-agents-md/skills/generate-agents-md/tests/test_validate_agents_md.py
@@ -0,0 +1,140 @@
+"""Behavioral regressions using isolated repositories; no third-party dependencies."""
+
+from __future__ import annotations
+
+import argparse
+import importlib.util
+import json
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "validate_agents_md.py"
+SPEC = importlib.util.spec_from_file_location("guide_validator", SCRIPT)
+validator = importlib.util.module_from_spec(SPEC)
+SPEC.loader.exec_module(validator)
+
+
+class GuideValidationTests(unittest.TestCase):
+ def setUp(self):
+ self.temp = tempfile.TemporaryDirectory()
+ self.addCleanup(self.temp.cleanup)
+ self.repo = Path(self.temp.name).resolve()
+ self.guide = self.repo / "AGENTS.md"
+
+ def write(self, relative, text):
+ path = self.repo / relative
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(text, encoding="utf-8")
+ return path
+
+ def package(self, directory, scripts):
+ self.write(str(Path(directory) / "package.json"), json.dumps({"scripts": scripts}))
+
+ def check(self, text, *, guide="AGENTS.md", local=False, cwd=None):
+ path = self.write(guide, text)
+ return validator.validate(argparse.Namespace(repo=str(self.repo), file=str(path), local=local, cwd=cwd))
+
+ def test_chinese_guide_has_no_heading_contract(self):
+ self.assertEqual(self.check("# 项目约定\n不要覆盖已有用户修改。\n"), ([], []))
+
+ def test_html_jsx_and_literal_markers_are_not_placeholders(self):
+ self.assertEqual(self.check("# Guide\n\nUse `` and `TODO` only in examples.\n"), ([], []))
+
+ def test_real_unfinished_marker_fails(self):
+ errors, warnings = self.check("# Guide\n- TODO: add the actual build command\n")
+ self.assertTrue(errors)
+ self.assertFalse(warnings)
+
+ def test_empty_guide_fails(self):
+ self.assertTrue(self.check("")[0])
+
+ def test_command_and_line_reference_are_not_whole_paths(self):
+ self.write("scripts/check.py", "print('ok')\n")
+ self.write("src/main.py", "pass\n")
+ self.assertEqual(self.check("Run `python scripts/check.py`; see `src/main.py:42`."), ([], []))
+
+ def test_missing_literal_path_warns(self):
+ self.assertTrue(self.check("See `src/missing.py`.")[1])
+
+ def test_route_constraint_is_not_an_external_file_warning(self):
+ self.assertEqual(self.check("Keep route `/billing`; preserve `/v1/users`."), ([], []))
+
+ def test_child_paths_and_links_have_correct_bases(self):
+ self.write("apps/web/src/main.py", "pass\n")
+ self.write("docs/api.md", "# API\n")
+ self.assertEqual(self.check("Use `src/main.py` and [API](../../docs/api.md).", guide="apps/web/AGENTS.md", local=True), ([], []))
+
+ def test_explicit_cwd_does_not_move_markdown_links(self):
+ self.write("apps/web/src/main.py", "pass\n")
+ self.write("docs/api.md", "# API\n")
+ self.assertEqual(self.check("Use `src/main.py` and [API](docs/api.md).", cwd="apps/web"), ([], []))
+
+ def test_missing_markdown_link_and_url_encoded_spaces(self):
+ self.write("docs/My Guide.md", "# Details\n")
+ self.assertEqual(self.check("[Details](docs/My%20Guide.md#notes)"), ([], []))
+ self.assertTrue(self.check("[Details](docs/missing.md)")[1])
+
+ def test_missing_package_script_cannot_borrow_from_sibling(self):
+ self.package("apps/a", {})
+ self.package("apps/b", {"test": "echo ok"})
+ errors, warnings = self.check("Run `npm run test`.", guide="apps/a/AGENTS.md", local=True)
+ self.assertFalse(errors)
+ self.assertEqual(len(warnings), 1)
+ self.assertIn("Missing package script", warnings[0])
+
+ def test_no_package_does_not_scan_dependency_tree(self):
+ self.package("node_modules/dependency", {"test": "echo ok"})
+ errors, warnings = self.check("Run `npm run test`.")
+ self.assertFalse(errors)
+ self.assertIn("no package.json", warnings[0])
+
+ def test_fenced_commands_cd_wrappers_and_directory_flags(self):
+ self.package("apps/web", {"check": "echo ok"})
+ commands = ["cd apps/web && npm run check", "rtk proxy pnpm -C apps/web run check", "npm --prefix=apps/web run check", "yarn --cwd apps/web check"]
+ for command in commands:
+ with self.subTest(command=command):
+ self.assertEqual(self.check("```sh\n" + command + "\n```\n"), ([], []))
+
+ def test_unsupported_workspace_or_pipeline_is_not_claimed_verified(self):
+ self.package(".", {"test": "echo ok"})
+ for command in ["npm run test --workspace apps/web", "npm run test | cat"]:
+ with self.subTest(command=command):
+ self.assertIn("not verified", self.check("`" + command + "`")[1][0])
+
+ def test_validation_never_executes_documented_commands(self):
+ self.package(".", {"test": "python -c \"open('executed', 'w').close()\""})
+ self.assertEqual(self.check("Run `npm run test`."), ([], []))
+ self.assertFalse((self.repo / "executed").exists())
+
+ def test_bare_build_alias_and_bun_native_command_differ(self):
+ self.package(".", {})
+ for manager in ["yarn", "pnpm"]:
+ with self.subTest(manager=manager):
+ self.assertIn("Missing package script", self.check("`" + manager + " build`")[1][0])
+ self.assertEqual(self.check("`bun build src/index.ts`"), ([], []))
+
+ def test_optional_missing_script_and_malformed_package(self):
+ self.package(".", {})
+ self.assertEqual(self.check("`npm run --if-present check`"), ([], []))
+ self.write("package.json", "not json")
+ self.assertIn("Cannot read", self.check("`npm run check`")[1][0])
+
+ def test_outside_working_directory_fails(self):
+ self.assertTrue(self.check("# Guide\nKeep user work.\n", cwd="..")[0])
+
+ def test_cli_warning_and_strict_exit_status(self):
+ self.write("AGENTS.md", "See `missing.py`.\n")
+ command = [sys.executable, "-B", str(SCRIPT), "--repo", str(self.repo), "--file", str(self.guide)]
+ ordinary = subprocess.run(command, capture_output=True, text=True)
+ strict = subprocess.run(command + ["--warnings-as-errors"], capture_output=True, text=True)
+ self.assertEqual(ordinary.returncode, 0)
+ self.assertIn("WARNING", ordinary.stderr)
+ self.assertEqual(strict.returncode, 1)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
index b8900aea..c02fafeb 100644
--- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
+++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作",
"version": "20260605",
"keySource": "none",
- "syncedAt": "2026-10-05T16:02:10Z"
+ "syncedAt": "2026-10-06T07:29:21Z"
}
diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
index dc1c677b..68668f83 100644
--- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
+++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json
@@ -2,8 +2,8 @@
"sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git",
"ref": "canary",
- "commit": "41215d77762375b4b5154b12cfbb4372a835f8a0",
+ "commit": "8619c015f8cc9b26fbe10c5e97b0f8cb9ed5cb92",
"adapter": "skill-collection",
"sourcePath": "skills",
- "syncedAt": "2026-10-05T16:00:00Z"
+ "syncedAt": "2026-10-06T07:26:41Z"
}
diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md
new file mode 100644
index 00000000..4ea06954
--- /dev/null
+++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/SKILL.md
@@ -0,0 +1,90 @@
+---
+name: next-browser-initial-load-optimizer
+description: >
+ Audit and reduce Next.js browser initial-load work. Use for slow route startup,
+ oversized client bundles, duplicate browser dependencies, or features that can
+ wait for interaction.
+---
+
+# Browser initial-load optimizer
+
+## 1. Set the scope
+
+Analyze the whole app by default, covering all routes and shared client dependencies. Narrow the scope only when the user specifies a route, dependency, feature or other subset. Choose **audit** or **fix** mode. Audit is the default: generate analyzer artifacts and report candidates while leaving application source, dependencies and lockfiles unchanged. Fix mode requires an explicit request to change the app. Ask before changing visible behavior, timing, compatibility or a trust boundary. For a route's static App Shell, use `next-cache-components-optimizer`; for navigation prefetch work, use `next-partial-prefetching-optimizer`.
+
+**Done:** the whole-app or user-specified scope, mode and intended behavior are recorded. A request to capture or export data is audit mode, not permission to fix.
+
+## 2. Capture and export a baseline
+
+Run from the app directory with its package manager. Read `next analyze --help` for capture options and `next analyze export --help` for replay options in the installed CLI. Reuse a selected saved snapshot, or capture with a distinctive name first; then export it through gzip at its default compression level. Keep before/after `.jsonl.gz` files separate.
+
+**Sandbox requirement:** If the agent’s sandbox blocks TCP port binding (as Codex’s does), it MUST run the `next analyze --output` capture outside the sandbox. Do not attempt the capture inside that sandbox, even though `--output` does not serve the analyzer UI.
+
+Run the pipeline in Bash:
+
+```bash
+set -o pipefail
+# Capture only when a new baseline is needed
+pnpm exec next analyze --output --snapshot 'audit-before-unique-1'
+pnpm exec next analyze export --snapshot 'audit-before-unique-1' | gzip > /tmp/analyze-app-before.jsonl.gz
+```
+
+`--output` builds and saves binary/UI artifacts without serving. `next analyze export` reads a saved snapshot without building; stdout is one typed JSON record per line, with errors on stderr. Omit `--route` for whole-app analysis; add it for a user-specified route. The route filter keeps the whole-app module graph, so scope it in the next step.
+
+For a custom `distDir`, replay with `--dist-dir ` (relative or absolute): capture loads the app config, replay does not. `--snapshot ` selects a retained name; omission selects the newest snapshot. Capture generates a unique timestamp name when omitted, and **replaces** an existing capture when an explicit name is reused. Use distinct before/after names, including names with spaces, to preserve both baselines. For interactive exploration, capture without `--output` to serve the UI; `next build --analyze` also produces replayable data.
+
+**Done:** the chosen capture is identified, export succeeded, and the baseline file, snapshot name and analysis scope are recorded. Check the whole pipeline's exit status before processing the compressed file: `pipefail` prevents gzip from hiding a failed export. Export validates one route at a time, so a failure can leave a partial archive even if `gzip -t` accepts it. Discard output when export or gzip fails.
+
+## 3. Interpret the evidence
+
+Resolve the schema from the app's **installed Next.js**, using its Node launcher:
+
+```bash
+pnpm exec node -p "require.resolve('next/analyze/graph-v1.schema.json')"
+```
+
+Read its descriptions for record meanings, joins, attribution and coverage. Stream decompression with `gzip -dc /tmp/analyze-app-before.jsonl.gz` into a line-oriented analysis script rather than loading the whole dump into context. Use the schema to interpret client/server contributions across every route in scope and choose the metric.
+
+**Done:** the baseline's route-attributed client/server contributions and the metric are identified. Treat this as **build evidence**: claims about observed browser requests, timing or transfer savings need separate evidence.
+
+## 4. Explain a candidate
+
+Inspect actual project source and exact importers. Rank client-output contributions across the whole app, or within the user-specified scope, by attributed size, repetition, likely runtime cost, need before interaction and correctness risk.
+
+### Community → min-cut pass
+
+For broad audits seeking opportunities across multiple features or a large contributor list, run **community detection → target selection → directed min-cut → source validation** after the initial ranking. Read [Graph methods](references/graph-methods.md) before constructing the graph or running solvers.
+
+1. Rank detected communities by scoped client-output attribution and inspect their inbound importers.
+2. Select large communities, or optional feature regions within them, that can plausibly wait for interaction. Record why each selected region is optional under the route's render conditions.
+3. Run directed min-cuts from the verified client roots to those targets to find small sets of importer changes that could detach them.
+4. Validate the cut edges against source and behavior constraints; account for each investigated target as a candidate, rejected cut or evidence gap.
+
+For a narrow audit of a named dependency or feature, use direct importer reasoning when it fully accounts for the relevant paths and proposed boundary; record why the paired pass adds no useful target discovery. If graph evidence or tooling blocks the pass, record the blocker and limit the conclusions accordingly.
+
+### Graph-evidence checklist
+
+Before proposing an edit, record these items for each candidate. Mark an inapplicable item with its reason; give missing evidence an explicit gap.
+
+- **Scope:** name the affected routes, snapshot, render conditions, client/server output class, exact target identities and metric. For reachability claims, identify every selected client root, including applicable client references.
+- **Coverage:** summarize relevant unsupported outputs and absent group triggers, and their effect on the claim. Scope conclusions to the known subgraph when completeness is uncertain.
+- **Attribution:** count each selected output contribution once. Record repeated-record handling and distinguish source paths from module identities; explain any mapping used for solver weights and preserve unknown weights as gaps.
+- **Reachability:** for a lazy boundary, check **all** synchronous root-to-target paths, alternate importers and cycles, including a target that is itself a root. Record which paths the proposed boundary severs and which stay reachable. Verify async and erased type-only imports against source.
+- **Source checks:** inspect import triggers, mount-time preloading, module side effects and shared routes. Record the conditions under which an async import executes. The graph establishes indexed reachability; an after snapshot verifies emitted outputs and attribution changes.
+- **Method:** record the chosen method, its parameters and why it fits the candidate.
+
+For lazy interaction features, duplicate packages, server-rendered display work or other attributed assets, read the matching section in [Fix patterns](references/fix-patterns.md) before proposing an edit.
+
+**Done:** the paired pass and its target dispositions are recorded, or the narrow-scope skip reason or blocker is explicit. Every candidate has a completed checklist, exact source/importer, proposed edit and named behavior checks. Label heuristics as heuristics and qualify conclusions affected by evidence gaps; the report step completes **audit** mode.
+
+## 5. Verify one change — fix mode only
+
+Make one small, cohesive change. Capture/export an after snapshot with a new name and compare the **same analysis scope, output class and metric** with the baseline. For whole-app analysis, account for changes across all routes, including shared dependencies. Run relevant behavior tests and type-check; use `next-dev-loop` when verifying the edit in the running app.
+
+**Done:** retain the change only when the scoped metric improves and the intended behavior passes its checks. Revert a change that fails either condition. If checks are blocked, report the unverified edit and blocker rather than accepting it. Record the accepted after snapshot as the next baseline before another edit.
+
+## 6. Report and stop
+
+Audit reports candidates; fix reports accepted edits and any reverted or unverified attempts. Include snapshot names, scoped attribution/deltas, exact source/importer, behavior-check results and blockers. For a graph cut, include its route/render conditions, roots/target and unknowns. Distinguish source facts, build evidence, heuristics and separately observed runtime facts.
+
+**Done:** every scoped candidate or attempted edit is accounted for, with evidence or a stated gap.
diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md
new file mode 100644
index 00000000..6050f209
--- /dev/null
+++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/fix-patterns.md
@@ -0,0 +1,31 @@
+# Fix patterns
+
+Read the section matching the candidate before proposing a change. Each section ends with the evidence needed to take that candidate into the main workflow's edit/measure/behavior loop.
+
+## Lazy interaction feature
+
+For a large component such as an editor, chart or dialog needed after interaction, inspect its actual importer and confirm the feature is not already async. Add one lazy boundary with a stable placeholder, preserving loading/error states, keyboard/focus, direct visits and client/server behavior.
+
+Preload on intent only after measuring its benefit. Idle preloading can waste data/battery and compete with important requests.
+
+**Ready to test:** the synchronous paths that keep the feature reachable are accounted for, and each preserved behavior has a named check.
+
+## Duplicate browser dependency
+
+Verify the shipped browser versions and inspect the package-manager graph (`pnpm why` or `npm ls`). Distinguish duplicate shipped JavaScript from module variants and server/client copies. Prefer compatible direct-range or parent upgrades and dedupe before overrides. If resolving duplicates requires a major-version upgrade, verify API compatibility and behavior for every affected consumer.
+
+Check React/singleton peer constraints and hydration behavior. Recheck the resolved dependency graph and shipped browser code to ensure the change reduces duplication rather than introducing more duplicates through dependencies.
+
+**Ready to test:** the duplicated browser code is evidenced, the resolution change is compatible with every affected consumer, and checks cover peer/hydration behavior and the resulting shipped duplication.
+
+## Server-rendered display work
+
+For Markdown/MDX, parsers, registries or display-only work in a Client Component, establish whether it needs live editing, offline use or browser-only inputs. Move eligible display work to server rendering with a small interactive island, or keep a lazy parser for editing. Preserve sanitizer and authorization boundaries and verify hydration.
+
+**Ready to test:** the source work selected for relocation has no required browser-only behavior, and rendering, interaction, hydration and trust-boundary checks are named.
+
+## Other attributed assets
+
+Inspect polyfills, barrels, layout imports, CSS/fonts/media/WASM when the scoped attribution warrants it. Keep source/build attribution separate from observed requests: a generic asset source alone does not establish a browser request.
+
+**Ready to test:** the exact source/importer and output contribution explain the proposed edit, and its effects on shared routes and runtime side effects are covered.
diff --git a/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md
new file mode 100644
index 00000000..9ffa1555
--- /dev/null
+++ b/plugins/codex/plugins/next-skills/skills/next-browser-initial-load-optimizer/references/graph-methods.md
@@ -0,0 +1,20 @@
+# Graph methods
+
+Use these methods for the main workflow's community → min-cut pass or when direct importer inspection needs graph analysis. Read the installed schema for field meanings; construct a route-scoped graph from exact identities and verified client roots. Missing or unknown evidence stays an explicit gap.
+
+## Clustering — find logical communities
+
+Use Louvain/Leiden or another community algorithm on the scoped, deduplicated ordinary import graph. Keep synchronous, asynchronous and traced relationships distinct. If symmetrizing direction, record the weights, resolution, seed and stability across runs.
+
+**Prioritize deferral candidates:** use scoped client-output attribution to rank communities whose features are optional for the selected route or needed only after interaction. Inspect their actual importers and behavior constraints before proposing a cut.
+
+**Done:** each proposed community has reproducible parameters, scoped attributed contributions and inspected project importers. Treat it as a candidate to investigate, not an emitted chunk or a valid client/server boundary.
+
+## Conditional min cut — detach a target
+
+1. **Define the scenario:** record the route/render conditions, verified client roots and heavy target. Use directed synchronous importer→imported edges and exact output→module memberships.
+2. **Establish completeness:** check every relevant output's coverage and group triggers against route output membership. Account for unknown client roles, uncertain triggers and overlapping/cumulative group memberships. A group that cannot isolate one reference or an unsupported membership may supply another path. Output membership and group role establish build provenance, not cold-request scope.
+3. **Choose the claim:** make a definitive cut only if every possibly relevant root→target path is accounted for. Otherwise report a cut of the **known subgraph** as provisional, identify the missing evidence and give a verification plan.
+4. **Solve and check:** connect selected roots to a super-source and targets to a sink. Choose unit-edge or size/edit-cost capacities deliberately. Protect shared runtime and required initial UI edges with uncuttable capacities or equivalent constraints; record the protected edges and reasons. If no feasible cut remains, report that result. Condense strongly connected components if needed. Check that every synchronous path in the claimed scenario is severed, including alternate roots and cycles.
+
+**Done:** the cut records its scenario, graph scope, capacities, inspected importer edges and completeness limits. A mathematical cut is a proposed source change, not automatically a safe `import()` or a measured browser saving. Return to the main workflow's behavior checks before accepting a fix.
diff --git a/plugins/codex/plugins/pdf/.codex-plugin/plugin.json b/plugins/codex/plugins/pdf/.codex-plugin/plugin.json
index 60cb6d1f..f169459d 100644
--- a/plugins/codex/plugins/pdf/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/pdf/.codex-plugin/plugin.json
@@ -1,39 +1,40 @@
{
"name": "pdf",
- "version": "1.0.2",
- "description": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。",
+ "version": "2.0.0",
+ "description": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。",
"author": {
- "name": "EAPIL",
- "url": "https://git.playones.com/arechen/EapilSkillMarket"
+ "name": "Anthropic; EAPIL localization",
+ "url": "https://github.com/anthropics"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
+ "homepage": "https://github.com/anthropics/skills",
+ "repository": "https://github.com/anthropics/skills",
"license": "Proprietary",
"keywords": [
- "eapil",
- "codex-skill",
- "pdf"
+ "pdf",
+ "documents",
+ "zh-CN",
+ "windows"
],
"skills": "./skills/",
"interface": {
"displayName": "pdf",
- "shortDescription": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。",
- "longDescription": "这是一个功能全面的PDF处理工具包,可用于提取文本和表格、创建新的PDF文件、合并/拆分文档,以及处理表单。当AI需要填写PDF表单,或需要以编程方式大规模处理、生成或分析PDF文件时,这个工具包就能派上用场了。",
- "developerName": "EAPIL",
+ "shortDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。",
+ "longDescription": "读取、创建和填写 PDF,支持中文字体与免 Poppler 预览。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。",
+ "developerName": "Anthropic; EAPIL",
"category": "Productivity",
"capabilities": [
"Read",
"Write"
],
"defaultPrompt": [
- "使用 pdf 帮我完成这个任务。"
+ "使用 pdf 处理这份中文文档,并检查最终输出。"
],
- "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket",
+ "websiteURL": "https://github.com/anthropics/skills",
+ "privacyPolicyURL": "https://github.com/anthropics/skills",
+ "termsOfServiceURL": "https://github.com/anthropics/skills",
"brandColor": "#2563EB",
"screenshots": [],
- "composerIcon": "./assets/market-icon.svg",
- "logo": "./assets/market-icon.svg"
+ "logo": "./assets/market-icon.svg",
+ "composerIcon": "./assets/market-icon.svg"
}
}
diff --git a/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..45c6e45d
--- /dev/null
+++ b/plugins/codex/plugins/pdf/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "pdf",
+ "repo": "https://github.com/anthropics/skills.git",
+ "ref": "main",
+ "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202",
+ "adapter": "claude-skill",
+ "sourcePath": "skills/pdf",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/pdf/skills/pdf/SKILL.md b/plugins/codex/plugins/pdf/skills/pdf/SKILL.md
index b65c5f1a..4fa05ab3 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/SKILL.md
+++ b/plugins/codex/plugins/pdf/skills/pdf/SKILL.md
@@ -1,314 +1,35 @@
---
name: pdf
-description: Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. Use when Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale, especially on Windows and with Chinese/CJK text.
+description: 读取、创建、合并、拆分、旋转、OCR 或填写 PDF 表单,处理简体中文字体与页面预览;适用于 Windows PowerShell 和跨平台终端。
license: Proprietary. LICENSE.txt has complete terms
---
-# PDF Processing Guide
+# PDF 文档
-## Overview
+先读取 [references/platform.md](references/platform.md),确认中文字体、Python 与实际需要的依赖。PDF 不等于扫描图片,先判断页面是否有可提取文字。
-This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions.
+| 任务 | 工具 |
+| --- | --- |
+| 文本、页面预览、坐标检查 | PyMuPDF |
+| 合并、拆分、旋转、元数据、加密 | pypdf |
+| 表格提取 | pdfplumber,提取后核对表头、合并单元格与换行 |
+| 新建中文 PDF | ReportLab 或可用的 Office/HTML 转 PDF 工具 |
+| 扫描页 OCR | 本机可用的 OCR;Tesseract 中文需 `chi_sim` 数据 |
-## Windows and Chinese-language defaults
+## 中文与排版
-- Prefer quoted Windows paths: `"C:\docs\contract-zh.pdf"`
-- In Windows PowerShell, set UTF-8 mode before Python PDF scripts: `$env:PYTHONUTF8='1'`
-- For Chinese text extraction, validate output in UTF-8 files instead of relying only on console rendering
-- For generated PDFs containing Chinese text, register and use CJK-capable fonts (`Microsoft YaHei`, `SimSun`, `Noto Sans CJK SC`)
+新文档默认简体中文、A4;已有版式或用户指定版式优先。中文必须使用实际可用的中文字体。优先嵌入授权允许的字体;ReportLab 简单文档可注册 `UnicodeCIDFont('STSong-Light')`。复制文本不乱码、渲染无缺字都需检查。
-## Quick Start
+OCR 只针对扫描页或提取失败的页面;中文可用 `chi_sim+eng`,未装语言包时报告缺失。OCR 结果需核对数字、单位、姓名与表格关系,不凭猜测补全。
-```python
-from pypdf import PdfReader, PdfWriter
+## 表单
-# Read a PDF
-reader = PdfReader("document.pdf")
-print(f"Pages: {len(reader.pages)}")
+涉及填写时按需读取 [forms.md](forms.md),先判断是否有 AcroForm 字段。可填表单使用随附 `extract_form_field_info.py`、`fill_fillable_fields.py`;非可填表单按页面坐标叠加,并检查原内容与填写区域。不要把签名图片视作数字签名。
-# Extract text
-text = ""
-for page in reader.pages:
- text += page.extract_text()
+## 预览与检查
+
+```powershell
+python "$skill\scripts\convert_pdf_to_images.py" ".\输入 中文.pdf" ".\preview"
```
-## Python Libraries
-
-### pypdf - Basic Operations
-
-#### Merge PDFs
-```python
-from pypdf import PdfWriter, PdfReader
-
-writer = PdfWriter()
-for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
- reader = PdfReader(pdf_file)
- for page in reader.pages:
- writer.add_page(page)
-
-with open("merged.pdf", "wb") as output:
- writer.write(output)
-```
-
-#### Split PDF
-```python
-reader = PdfReader("input.pdf")
-for i, page in enumerate(reader.pages):
- writer = PdfWriter()
- writer.add_page(page)
- with open(f"page_{i+1}.pdf", "wb") as output:
- writer.write(output)
-```
-
-#### Extract Metadata
-```python
-reader = PdfReader("document.pdf")
-meta = reader.metadata
-print(f"Title: {meta.title}")
-print(f"Author: {meta.author}")
-print(f"Subject: {meta.subject}")
-print(f"Creator: {meta.creator}")
-```
-
-#### Rotate Pages
-```python
-reader = PdfReader("input.pdf")
-writer = PdfWriter()
-
-page = reader.pages[0]
-page.rotate(90) # Rotate 90 degrees clockwise
-writer.add_page(page)
-
-with open("rotated.pdf", "wb") as output:
- writer.write(output)
-```
-
-### pdfplumber - Text and Table Extraction
-
-#### Extract Text with Layout
-```python
-import pdfplumber
-
-with pdfplumber.open("document.pdf") as pdf:
- for page in pdf.pages:
- text = page.extract_text()
- print(text)
-```
-
-#### Extract Tables
-```python
-with pdfplumber.open("document.pdf") as pdf:
- for i, page in enumerate(pdf.pages):
- tables = page.extract_tables()
- for j, table in enumerate(tables):
- print(f"Table {j+1} on page {i+1}:")
- for row in table:
- print(row)
-```
-
-#### Advanced Table Extraction
-```python
-import pandas as pd
-
-with pdfplumber.open("document.pdf") as pdf:
- all_tables = []
- for page in pdf.pages:
- tables = page.extract_tables()
- for table in tables:
- if table: # Check if table is not empty
- df = pd.DataFrame(table[1:], columns=table[0])
- all_tables.append(df)
-
-# Combine all tables
-if all_tables:
- combined_df = pd.concat(all_tables, ignore_index=True)
- combined_df.to_excel("extracted_tables.xlsx", index=False)
-```
-
-### reportlab - Create PDFs
-
-#### Chinese Font Setup (ReportLab)
-```python
-from reportlab.pdfbase import pdfmetrics
-from reportlab.pdfbase.ttfonts import TTFont
-
-pdfmetrics.registerFont(TTFont('MicrosoftYaHei', r'C:\Windows\Fonts\msyh.ttc'))
-
-# Then use: canvas.setFont('MicrosoftYaHei', 11)
-```
-
-#### Basic PDF Creation
-```python
-from reportlab.lib.pagesizes import letter
-from reportlab.pdfgen import canvas
-
-c = canvas.Canvas("hello.pdf", pagesize=letter)
-width, height = letter
-
-# Add text
-c.drawString(100, height - 100, "Hello World!")
-c.drawString(100, height - 120, "This is a PDF created with reportlab")
-
-# Add a line
-c.line(100, height - 140, 400, height - 140)
-
-# Save
-c.save()
-```
-
-#### Create PDF with Multiple Pages
-```python
-from reportlab.lib.pagesizes import letter
-from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
-from reportlab.lib.styles import getSampleStyleSheet
-
-doc = SimpleDocTemplate("report.pdf", pagesize=letter)
-styles = getSampleStyleSheet()
-story = []
-
-# Add content
-title = Paragraph("Report Title", styles['Title'])
-story.append(title)
-story.append(Spacer(1, 12))
-
-body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
-story.append(body)
-story.append(PageBreak())
-
-# Page 2
-story.append(Paragraph("Page 2", styles['Heading1']))
-story.append(Paragraph("Content for page 2", styles['Normal']))
-
-# Build PDF
-doc.build(story)
-```
-
-## Command-Line Tools
-
-### pdftotext (poppler-utils)
-```bash
-# Extract text
-pdftotext input.pdf output.txt
-
-# Extract text preserving layout
-pdftotext -layout input.pdf output.txt
-
-# Force UTF-8 output encoding
-pdftotext -enc UTF-8 input.pdf output.txt
-
-# Extract specific pages
-pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
-```
-
-### qpdf
-```bash
-# Merge PDFs
-qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
-
-# Split pages
-qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
-qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
-
-# Rotate pages
-qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
-
-# Remove password
-qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
-```
-
-### pdftk (if available)
-```bash
-# Merge
-pdftk file1.pdf file2.pdf cat output merged.pdf
-
-# Split
-pdftk input.pdf burst
-
-# Rotate
-pdftk input.pdf rotate 1east output rotated.pdf
-```
-
-## Common Tasks
-
-### Extract Text from Scanned PDFs
-```python
-# Requires: pip install pytesseract pdf2image
-import pytesseract
-from pdf2image import convert_from_path
-
-# Convert PDF to images
-images = convert_from_path('scanned.pdf')
-
-# OCR each page
-text = ""
-for i, image in enumerate(images):
- text += f"Page {i+1}:\n"
- text += pytesseract.image_to_string(image, lang='chi_sim+eng')
- text += "\n\n"
-
-print(text)
-```
-
-### Add Watermark
-```python
-from pypdf import PdfReader, PdfWriter
-
-# Create watermark (or load existing)
-watermark = PdfReader("watermark.pdf").pages[0]
-
-# Apply to all pages
-reader = PdfReader("document.pdf")
-writer = PdfWriter()
-
-for page in reader.pages:
- page.merge_page(watermark)
- writer.add_page(page)
-
-with open("watermarked.pdf", "wb") as output:
- writer.write(output)
-```
-
-### Extract Images
-```bash
-# Using pdfimages (poppler-utils)
-pdfimages -j input.pdf output_prefix
-
-# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
-```
-
-### Password Protection
-```python
-from pypdf import PdfReader, PdfWriter
-
-reader = PdfReader("input.pdf")
-writer = PdfWriter()
-
-for page in reader.pages:
- writer.add_page(page)
-
-# Add password
-writer.encrypt("userpassword", "ownerpassword")
-
-with open("encrypted.pdf", "wb") as output:
- writer.write(output)
-```
-
-## Quick Reference
-
-| Task | Best Tool | Command/Code |
-|------|-----------|--------------|
-| Merge PDFs | pypdf | `writer.add_page(page)` |
-| Split PDFs | pypdf | One page per file |
-| Extract text | pdfplumber | `page.extract_text()` |
-| Extract tables | pdfplumber | `page.extract_tables()` |
-| Create PDFs | reportlab | Canvas or Platypus |
-| Command line merge | qpdf | `qpdf --empty --pages ...` |
-| OCR scanned PDFs | pytesseract | Convert to image first |
-| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md |
-
-## Next Steps
-
-- For advanced pypdfium2 usage, see reference.md
-- For JavaScript libraries (pdf-lib), see reference.md
-- If you need to fill out a PDF form, follow the instructions in forms.md
-- For troubleshooting guides, see reference.md
+此脚本使用 PyMuPDF,无需 Poppler,逐页渲染以避免一次加载整个大文档。合并或拆分后核对页面数量和顺序;填写后检查可见内容、字段值与页面边界。更多提取与转换方案见 [reference.md](reference.md),旧平台专用命令以本地环境指导为准。
diff --git a/plugins/codex/plugins/pdf/skills/pdf/forms.md b/plugins/codex/plugins/pdf/skills/pdf/forms.md
index 711cfe3d..6e7e1e0d 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/forms.md
+++ b/plugins/codex/plugins/pdf/skills/pdf/forms.md
@@ -3,12 +3,6 @@
If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory:
`python scripts/check_fillable_fields `, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions.
-## Windows and Chinese-language defaults
-- Prefer quoted paths on Windows (for example: `"C:\docs\input-form.pdf"`)
-- In Windows PowerShell, enable UTF-8 mode before running scripts: `$env:PYTHONUTF8='1'`
-- For Chinese text, visually verify the output PDF after filling (font fallback can affect spacing)
-- If `python` points to Python 2 on Windows, use `py -3`
-
# Fillable fields
If the PDF has fillable form fields:
- Run this script from this file's directory: `python scripts/extract_form_field_info.py `. It will create a JSON file with a list of fields in this format:
@@ -82,130 +76,219 @@ Then analyze the images to determine the purpose of each form field (make sure t
This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again.
# Non-fillable fields
-If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below.
-- Convert the PDF to PNG images and determine field bounding boxes.
-- Create a JSON file with field information and validation images showing the bounding boxes.
-- Validate the the bounding boxes.
-- Use the bounding boxes to fill in the form.
+If the PDF doesn't have fillable form fields, you'll add text annotations. First try to extract coordinates from the PDF structure (more accurate), then fall back to visual estimation if needed.
-## Step 1: Visual Analysis (REQUIRED)
-- Convert the PDF to PNG images. Run this script from this file's directory:
-`python scripts/convert_pdf_to_images.py `
-The script will create a PNG image for each page in the PDF.
-- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text.
+## Step 1: Try Structure Extraction First
-These are some examples of form structures that you might see:
+Run this script to extract text labels, lines, and checkboxes with their exact PDF coordinates:
+`python scripts/extract_form_structure.py form_structure.json`
-*Label inside box*
-```
-┌────────────────────────┐
-│ Name: │
-└────────────────────────┘
-```
-The input area should be to the right of the "Name" label and extend to the edge of the box.
+This creates a JSON file containing:
+- **labels**: Every text element with exact coordinates (x0, top, x1, bottom in PDF points)
+- **lines**: Horizontal lines that define row boundaries
+- **checkboxes**: Small square rectangles that are checkboxes (with center coordinates)
+- **row_boundaries**: Row top/bottom positions calculated from horizontal lines
-*Label before line*
-```
-Email: _______________________
-```
-The input area should be above the line and include its entire width.
+**Check the results**: If `form_structure.json` has meaningful labels (text elements that correspond to form fields), use **Approach A: Structure-Based Coordinates**. If the PDF is scanned/image-based and has few or no labels, use **Approach B: Visual Estimation**.
-*Label under line*
-```
-_________________________
-Name
-```
-The input area should be above the line and include the entire width of the line. This is common for signature and date fields.
+---
-*Label above line*
-```
-Please enter any special requests:
-________________________________________________
-```
-The input area should extend from the bottom of the label to the line, and should include the entire width of the line.
+## Approach A: Structure-Based Coordinates (Preferred)
-*Checkboxes*
-```
-Are you a US citizen? Yes □ No □
-```
-For checkboxes:
-- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels.
-- Distinguish between label text ("Yes", "No") and the clickable checkbox squares.
-- The entry bounding box should cover ONLY the small square, not the text label.
+Use this when `extract_form_structure.py` found text labels in the PDF.
-### Step 2: Create fields.json and validation images (REQUIRED)
-- Create a file named `fields.json` with information for the form fields and bounding boxes in this format:
-```
+### A.1: Analyze the Structure
+
+Read form_structure.json and identify:
+
+1. **Label groups**: Adjacent text elements that form a single label (e.g., "Last" + "Name")
+2. **Row structure**: Labels with similar `top` values are in the same row
+3. **Field columns**: Entry areas start after label ends (x0 = label.x1 + gap)
+4. **Checkboxes**: Use the checkbox coordinates directly from the structure
+
+**Coordinate system**: PDF coordinates where y=0 is at TOP of page, y increases downward.
+
+### A.2: Check for Missing Elements
+
+The structure extraction may not detect all form elements. Common cases:
+- **Circular checkboxes**: Only square rectangles are detected as checkboxes
+- **Complex graphics**: Decorative elements or non-standard form controls
+- **Faded or light-colored elements**: May not be extracted
+
+If you see form fields in the PDF images that aren't in form_structure.json, you'll need to use **visual analysis** for those specific fields (see "Hybrid Approach" below).
+
+### A.3: Create fields.json with PDF Coordinates
+
+For each field, calculate entry coordinates from the extracted structure:
+
+**Text fields:**
+- entry x0 = label x1 + 5 (small gap after label)
+- entry x1 = next label's x0, or row boundary
+- entry top = same as label top
+- entry bottom = row boundary line below, or label bottom + row_height
+
+**Checkboxes:**
+- Use the checkbox rectangle coordinates directly from form_structure.json
+- entry_bounding_box = [checkbox.x0, checkbox.top, checkbox.x1, checkbox.bottom]
+
+Create fields.json using `pdf_width` and `pdf_height` (signals PDF coordinates):
+```json
{
"pages": [
- {
- "page_number": 1,
- "image_width": (first page image width in pixels),
- "image_height": (first page image height in pixels),
- },
- {
- "page_number": 2,
- "image_width": (second page image width in pixels),
- "image_height": (second page image height in pixels),
- }
- // additional pages
+ {"page_number": 1, "pdf_width": 612, "pdf_height": 792}
],
"form_fields": [
- // Example for a text field.
{
"page_number": 1,
- "description": "The user's last name should be entered here",
- // Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap.
- "field_label": "Last name",
- "label_bounding_box": [30, 125, 95, 142],
- "entry_bounding_box": [100, 125, 280, 142],
- "entry_text": {
- "text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location
- "font_size": 14, // optional, defaults to 14
- "font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black)
- }
+ "description": "Last name entry field",
+ "field_label": "Last Name",
+ "label_bounding_box": [43, 63, 87, 73],
+ "entry_bounding_box": [92, 63, 260, 79],
+ "entry_text": {"text": "Smith", "font_size": 10}
},
- // Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT
{
- "page_number": 2,
- "description": "Checkbox that should be checked if the user is over 18",
- "entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square
+ "page_number": 1,
+ "description": "US Citizen Yes checkbox",
"field_label": "Yes",
- "label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text
- // Use "X" to check a checkbox.
- "entry_text": {
- "text": "X",
- }
+ "label_bounding_box": [260, 200, 280, 210],
+ "entry_bounding_box": [285, 197, 292, 205],
+ "entry_text": {"text": "X"}
}
- // additional form field entries
]
}
```
-Create validation images by running this script from this file's directory for each page:
-`python scripts/create_validation_image.py `
+**Important**: Use `pdf_width`/`pdf_height` and coordinates directly from form_structure.json.
-The validation images will have red rectangles where text should be entered, and blue rectangles covering label text.
+### A.4: Validate Bounding Boxes
-### Step 3: Validate Bounding Boxes (REQUIRED)
-#### Automated intersection check
-- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the `check_bounding_boxes.py` script (run from this file's directory):
-`python scripts/check_bounding_boxes.py `
+Before filling, check your bounding boxes for errors:
+`python scripts/check_bounding_boxes.py fields.json`
-If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not.
+This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling.
-#### Manual image inspection
-**CRITICAL: Do not proceed without visually inspecting validation images**
-- Red rectangles must ONLY cover input areas
-- Red rectangles MUST NOT contain any text
-- Blue rectangles should contain label text
-- For checkboxes:
- - Red rectangle MUST be centered on the checkbox square
- - Blue rectangle should cover the text label for the checkbox
+---
-- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate.
+## Approach B: Visual Estimation (Fallback)
+Use this when the PDF is scanned/image-based and structure extraction found no usable text labels (e.g., all text shows as "(cid:X)" patterns).
-### Step 4: Add annotations to the PDF
-Run this script from this file's directory to create a filled-out PDF using the information in fields.json:
-`python scripts/fill_pdf_form_with_annotations.py `
+### B.1: Convert PDF to Images
+
+`python scripts/convert_pdf_to_images.py `
+
+### B.2: Initial Field Identification
+
+Examine each page image to identify form sections and get **rough estimates** of field locations:
+- Form field labels and their approximate positions
+- Entry areas (lines, boxes, or blank spaces for text input)
+- Checkboxes and their approximate locations
+
+For each field, note approximate pixel coordinates (they don't need to be precise yet).
+
+### B.3: Zoom Refinement (CRITICAL for accuracy)
+
+For each field, crop a region around the estimated position to refine coordinates precisely.
+
+**Create a zoomed crop using ImageMagick:**
+```bash
+magick -crop x++ +repage
+```
+
+Where:
+- `, ` = top-left corner of crop region (use your rough estimate minus padding)
+- `, ` = size of crop region (field area plus ~50px padding on each side)
+
+**Example:** To refine a "Name" field estimated around (100, 150):
+```bash
+magick images_dir/page_1.png -crop 300x80+50+120 +repage crops/name_field.png
+```
+
+(Note: if the `magick` command isn't available, try `convert` with the same arguments).
+
+**Examine the cropped image** to determine precise coordinates:
+1. Identify the exact pixel where the entry area begins (after the label)
+2. Identify where the entry area ends (before next field or edge)
+3. Identify the top and bottom of the entry line/box
+
+**Convert crop coordinates back to full image coordinates:**
+- full_x = crop_x + crop_offset_x
+- full_y = crop_y + crop_offset_y
+
+Example: If the crop started at (50, 120) and the entry box starts at (52, 18) within the crop:
+- entry_x0 = 52 + 50 = 102
+- entry_top = 18 + 120 = 138
+
+**Repeat for each field**, grouping nearby fields into single crops when possible.
+
+### B.4: Create fields.json with Refined Coordinates
+
+Create fields.json using `image_width` and `image_height` (signals image coordinates):
+```json
+{
+ "pages": [
+ {"page_number": 1, "image_width": 1700, "image_height": 2200}
+ ],
+ "form_fields": [
+ {
+ "page_number": 1,
+ "description": "Last name entry field",
+ "field_label": "Last Name",
+ "label_bounding_box": [120, 175, 242, 198],
+ "entry_bounding_box": [255, 175, 720, 218],
+ "entry_text": {"text": "Smith", "font_size": 10}
+ }
+ ]
+}
+```
+
+**Important**: Use `image_width`/`image_height` and the refined pixel coordinates from the zoom analysis.
+
+### B.5: Validate Bounding Boxes
+
+Before filling, check your bounding boxes for errors:
+`python scripts/check_bounding_boxes.py fields.json`
+
+This checks for intersecting bounding boxes and entry boxes that are too small for the font size. Fix any reported errors before filling.
+
+---
+
+## Hybrid Approach: Structure + Visual
+
+Use this when structure extraction works for most fields but misses some elements (e.g., circular checkboxes, unusual form controls).
+
+1. **Use Approach A** for fields that were detected in form_structure.json
+2. **Convert PDF to images** for visual analysis of missing fields
+3. **Use zoom refinement** (from Approach B) for the missing fields
+4. **Combine coordinates**: For fields from structure extraction, use `pdf_width`/`pdf_height`. For visually-estimated fields, you must convert image coordinates to PDF coordinates:
+ - pdf_x = image_x * (pdf_width / image_width)
+ - pdf_y = image_y * (pdf_height / image_height)
+5. **Use a single coordinate system** in fields.json - convert all to PDF coordinates with `pdf_width`/`pdf_height`
+
+---
+
+## Step 2: Validate Before Filling
+
+**Always validate bounding boxes before filling:**
+`python scripts/check_bounding_boxes.py fields.json`
+
+This checks for:
+- Intersecting bounding boxes (which would cause overlapping text)
+- Entry boxes that are too small for the specified font size
+
+Fix any reported errors in fields.json before proceeding.
+
+## Step 3: Fill the Form
+
+The fill script auto-detects the coordinate system and handles conversion:
+`python scripts/fill_pdf_form_with_annotations.py fields.json `
+
+## Step 4: Verify Output
+
+Convert the filled PDF to images and verify text placement:
+`python scripts/convert_pdf_to_images.py `
+
+If text is mispositioned:
+- **Approach A**: Check that you're using PDF coordinates from form_structure.json with `pdf_width`/`pdf_height`
+- **Approach B**: Check that image dimensions match and coordinates are accurate pixels
+- **Hybrid**: Ensure coordinate conversions are correct for visually-estimated fields
diff --git a/plugins/codex/plugins/pdf/skills/pdf/reference.md b/plugins/codex/plugins/pdf/skills/pdf/reference.md
index b9e63bde..41400bf4 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/reference.md
+++ b/plugins/codex/plugins/pdf/skills/pdf/reference.md
@@ -2,12 +2,6 @@
This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions.
-## Windows and Chinese-language defaults
-- Quote Windows file paths in commands and scripts
-- Prefer UTF-8 output files when validating extracted Chinese text
-- For OCR on Chinese scans, use Tesseract language packs with `chi_sim+eng`
-- For generated Chinese PDFs, use embedded CJK fonts instead of default Helvetica
-
## pypdfium2 Library (Apache/BSD License)
### Overview
@@ -144,29 +138,6 @@ async function createPDF() {
}
```
-#### CJK Font Embedding (for Chinese text)
-```javascript
-import { PDFDocument } from 'pdf-lib';
-import fs from 'fs';
-
-async function createChinesePDF() {
- const pdfDoc = await PDFDocument.create();
- const page = pdfDoc.addPage([595, 842]);
-
- const fontBytes = fs.readFileSync('NotoSansCJKsc-Regular.otf');
- const cjkFont = await pdfDoc.embedFont(fontBytes);
-
- page.drawText('Chinese text sample', {
- x: 50,
- y: 760,
- size: 14,
- font: cjkFont
- });
-
- fs.writeFileSync('cjk-output.pdf', await pdfDoc.save());
-}
-```
-
#### Advanced Merge and Split Operations
```javascript
import { PDFDocument } from 'pdf-lib';
@@ -300,9 +271,6 @@ async function extractAnnotations() {
# Extract text with bounding box coordinates (essential for structured data)
pdftotext -bbox-layout document.pdf output.xml
-# Force UTF-8 text output when needed
-pdftotext -enc UTF-8 document.pdf output.txt
-
# The XML output contains precise coordinates for each text element
```
@@ -628,7 +596,7 @@ def extract_text_with_ocr(pdf_path):
images = convert_from_path(pdf_path)
text = ""
for i, image in enumerate(images):
- text += pytesseract.image_to_string(image, lang='chi_sim+eng')
+ text += pytesseract.image_to_string(image)
return text
```
diff --git a/plugins/codex/plugins/pdf/skills/pdf/references/platform.md b/plugins/codex/plugins/pdf/skills/pdf/references/platform.md
new file mode 100644
index 00000000..c3bb862f
--- /dev/null
+++ b/plugins/codex/plugins/pdf/skills/pdf/references/platform.md
@@ -0,0 +1,41 @@
+# 简体中文与 Windows 环境
+
+以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。
+
+```powershell
+$env:PYTHONUTF8 = "1"
+[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+python --version
+node --version
+```
+
+若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。
+
+需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。
+
+## 字体与文本
+
+- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。
+- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。
+- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。
+- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。
+- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。
+
+## 转换与预览
+
+Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。
+
+```powershell
+# 仅在自动检测不到时设置;不要求添加系统 PATH
+$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com"
+python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx"
+python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages"
+```
+
+按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。
+
+使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。
+
+## 验证与交付
+
+生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py
index 4154ef65..2cc5e348 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes.py
@@ -3,8 +3,6 @@ import json
import sys
-# Script to check that the `fields.json` file that Claude creates when analyzing PDFs
-# does not have overlapping bounding boxes. See forms.md.
@dataclass
@@ -14,7 +12,6 @@ class RectAndField:
field: dict
-# Returns a list of messages that are printed to stdout for Claude to read.
def get_bounding_box_messages(fields_json_stream) -> list[str]:
messages = []
fields = json.load(fields_json_stream)
@@ -32,7 +29,6 @@ def get_bounding_box_messages(fields_json_stream) -> list[str]:
has_error = False
for i, ri in enumerate(rects_and_fields):
- # This is O(N^2); we can optimize if it becomes a problem.
for j in range(i + 1, len(rects_and_fields)):
rj = rects_and_fields[j]
if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect):
@@ -63,8 +59,7 @@ if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: check_bounding_boxes.py [fields.json]")
sys.exit(1)
- # Input file should be in the `fields.json` format described in forms.md.
- with open(sys.argv[1], encoding="utf-8") as f:
+ with open(sys.argv[1]) as f:
messages = get_bounding_box_messages(f)
for msg in messages:
print(msg)
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py
deleted file mode 100644
index 1dbb463c..00000000
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_bounding_boxes_test.py
+++ /dev/null
@@ -1,226 +0,0 @@
-import unittest
-import json
-import io
-from check_bounding_boxes import get_bounding_box_messages
-
-
-# Currently this is not run automatically in CI; it's just for documentation and manual checking.
-class TestGetBoundingBoxMessages(unittest.TestCase):
-
- def create_json_stream(self, data):
- """Helper to create a JSON stream from data"""
- return io.StringIO(json.dumps(data))
-
- def test_no_intersections(self):
- """Test case with no bounding box intersections"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 30]
- },
- {
- "description": "Email",
- "page_number": 1,
- "label_bounding_box": [10, 40, 50, 60],
- "entry_bounding_box": [60, 40, 150, 60]
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("SUCCESS" in msg for msg in messages))
- self.assertFalse(any("FAILURE" in msg for msg in messages))
-
- def test_label_entry_intersection_same_field(self):
- """Test intersection between label and entry of the same field"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 60, 30],
- "entry_bounding_box": [50, 10, 150, 30] # Overlaps with label
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages))
- self.assertFalse(any("SUCCESS" in msg for msg in messages))
-
- def test_intersection_between_different_fields(self):
- """Test intersection between bounding boxes of different fields"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 30]
- },
- {
- "description": "Email",
- "page_number": 1,
- "label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes
- "entry_bounding_box": [160, 10, 250, 30]
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages))
- self.assertFalse(any("SUCCESS" in msg for msg in messages))
-
- def test_different_pages_no_intersection(self):
- """Test that boxes on different pages don't count as intersecting"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 30]
- },
- {
- "description": "Email",
- "page_number": 2,
- "label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page
- "entry_bounding_box": [60, 10, 150, 30]
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("SUCCESS" in msg for msg in messages))
- self.assertFalse(any("FAILURE" in msg for msg in messages))
-
- def test_entry_height_too_small(self):
- """Test that entry box height is checked against font size"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 20], # Height is 10
- "entry_text": {
- "font_size": 14 # Font size larger than height
- }
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages))
- self.assertFalse(any("SUCCESS" in msg for msg in messages))
-
- def test_entry_height_adequate(self):
- """Test that adequate entry box height passes"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 30], # Height is 20
- "entry_text": {
- "font_size": 14 # Font size smaller than height
- }
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("SUCCESS" in msg for msg in messages))
- self.assertFalse(any("FAILURE" in msg for msg in messages))
-
- def test_default_font_size(self):
- """Test that default font size is used when not specified"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 20], # Height is 10
- "entry_text": {} # No font_size specified, should use default 14
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages))
- self.assertFalse(any("SUCCESS" in msg for msg in messages))
-
- def test_no_entry_text(self):
- """Test that missing entry_text doesn't cause height check"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("SUCCESS" in msg for msg in messages))
- self.assertFalse(any("FAILURE" in msg for msg in messages))
-
- def test_multiple_errors_limit(self):
- """Test that error messages are limited to prevent excessive output"""
- fields = []
- # Create many overlapping fields
- for i in range(25):
- fields.append({
- "description": f"Field{i}",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30], # All overlap
- "entry_bounding_box": [20, 15, 60, 35] # All overlap
- })
-
- data = {"form_fields": fields}
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- # Should abort after ~20 messages
- self.assertTrue(any("Aborting" in msg for msg in messages))
- # Should have some FAILURE messages but not hundreds
- failure_count = sum(1 for msg in messages if "FAILURE" in msg)
- self.assertGreater(failure_count, 0)
- self.assertLess(len(messages), 30) # Should be limited
-
- def test_edge_touching_boxes(self):
- """Test that boxes touching at edges don't count as intersecting"""
- data = {
- "form_fields": [
- {
- "description": "Name",
- "page_number": 1,
- "label_bounding_box": [10, 10, 50, 30],
- "entry_bounding_box": [50, 10, 150, 30] # Touches at x=50
- }
- ]
- }
-
- stream = self.create_json_stream(data)
- messages = get_bounding_box_messages(stream)
- self.assertTrue(any("SUCCESS" in msg for msg in messages))
- self.assertFalse(any("FAILURE" in msg for msg in messages))
-
-
-if __name__ == '__main__':
- unittest.main()
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py
index df5c7b60..36dfb951 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/check_fillable_fields.py
@@ -1,35 +1,11 @@
import sys
+from pypdf import PdfReader
-# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md.
-def has_fillable_fields(pdf_path: str) -> bool:
- try:
- from pypdf import PdfReader
- except ModuleNotFoundError as exc:
- raise RuntimeError("Missing dependency: pypdf") from exc
-
- reader = PdfReader(pdf_path)
- return bool(reader.get_fields())
-
-
-def main(argv: list[str] | None = None) -> int:
- args = argv if argv is not None else sys.argv[1:]
- if len(args) != 1:
- print("Usage: check_fillable_fields.py [input pdf]")
- return 1
-
- try:
- if has_fillable_fields(args[0]):
- print("This PDF has fillable form fields")
- else:
- print("This PDF does not have fillable form fields; you will need to visually determine where to enter data")
- return 0
- except RuntimeError as exc:
- print(str(exc), file=sys.stderr)
- return 1
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
+reader = PdfReader(sys.argv[1])
+if (reader.get_fields()):
+ print("This PDF has fillable form fields")
+else:
+ print("This PDF does not have fillable form fields; you will need to visually determine where to enter data")
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py
index 31fe382c..4820bbdc 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/convert_pdf_to_images.py
@@ -1,55 +1,29 @@
-import sys
+"""Render PDF pages without Poppler; requires PyMuPDF."""
+
+import argparse
from pathlib import Path
-from pdf2image import convert_from_path
-from pdf2image.exceptions import PDFInfoNotInstalledError
+import pymupdf
-# Converts each page of a PDF to a PNG image.
-
-
-def convert(pdf_path, output_dir, max_dim=1000):
- output_path = Path(output_dir)
- output_path.mkdir(parents=True, exist_ok=True)
-
- try:
- images = convert_from_path(pdf_path, dpi=200)
- except PDFInfoNotInstalledError:
- raise RuntimeError(
- "Poppler is required. Install Poppler and ensure `pdfinfo` is available in PATH."
- )
-
- for i, image in enumerate(images):
- # Scale image if needed to keep width/height under `max_dim`
- width, height = image.size
- if width > max_dim or height > max_dim:
- scale_factor = min(max_dim / width, max_dim / height)
- new_width = int(width * scale_factor)
- new_height = int(height * scale_factor)
- image = image.resize((new_width, new_height))
-
- image_path = output_path / f"page_{i+1}.png"
- image.save(str(image_path))
- print(f"Saved page {i+1} as {image_path} (size: {image.size})")
-
- print(f"Converted {len(images)} pages to PNG images")
-
-
-def main(argv: list[str] | None = None) -> int:
- args = argv if argv is not None else sys.argv[1:]
- if len(args) != 2:
- print("Usage: convert_pdf_to_images.py [input pdf] [output directory]")
- return 1
-
- pdf_path = args[0]
- output_directory = args[1]
- try:
- convert(pdf_path, output_directory)
- return 0
- except RuntimeError as exc:
- print(str(exc), file=sys.stderr)
- return 1
+def convert(pdf_path, output_dir, max_dim=1600):
+ if max_dim <= 0:
+ raise ValueError("max_dim must be positive")
+ output = Path(output_dir)
+ output.mkdir(parents=True, exist_ok=True)
+ with pymupdf.open(pdf_path) as document:
+ for index, page in enumerate(document):
+ scale = min(max_dim / page.rect.width, max_dim / page.rect.height)
+ pixmap = page.get_pixmap(matrix=pymupdf.Matrix(scale, scale), alpha=False)
+ target = output / f"page_{index + 1}.png"
+ pixmap.save(str(target))
+ print(target)
if __name__ == "__main__":
- raise SystemExit(main())
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("input")
+ parser.add_argument("output")
+ parser.add_argument("--max-dim", type=int, default=1600)
+ args = parser.parse_args()
+ convert(args.input, args.output, args.max_dim)
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py
index 683c3d91..10eadd81 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/create_validation_image.py
@@ -4,32 +4,26 @@ import sys
from PIL import Image, ImageDraw
-# Creates "validation" images with rectangles for the bounding box information that
-# Claude creates when determining where to add text annotations in PDFs. See forms.md.
def create_validation_image(page_number, fields_json_path, input_path, output_path):
- # Input file should be in the `fields.json` format described in forms.md.
- with open(fields_json_path, "r", encoding="utf-8") as file_obj:
- data = json.load(file_obj)
+ with open(fields_json_path, 'r') as f:
+ data = json.load(f)
- with Image.open(input_path) as image:
- draw = ImageDraw.Draw(image)
+ img = Image.open(input_path)
+ draw = ImageDraw.Draw(img)
num_boxes = 0
-
+
for field in data["form_fields"]:
- if field["page_number"] != page_number:
- continue
- entry_box = field["entry_bounding_box"]
- label_box = field["label_bounding_box"]
- # Draw red rectangle over entry bounding box and blue rectangle over the label.
- draw.rectangle(entry_box, outline="red", width=2)
- draw.rectangle(label_box, outline="blue", width=2)
- num_boxes += 2
-
- image.save(output_path)
-
- print(f"Created validation image at {output_path} with {num_boxes} bounding boxes")
+ if field["page_number"] == page_number:
+ entry_box = field['entry_bounding_box']
+ label_box = field['label_bounding_box']
+ draw.rectangle(entry_box, outline='red', width=2)
+ draw.rectangle(label_box, outline='blue', width=2)
+ num_boxes += 2
+
+ img.save(output_path)
+ print(f"Created validation image at {output_path} with {num_boxes} bounding boxes")
if __name__ == "__main__":
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py
index 88f40445..64cd4703 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_field_info.py
@@ -4,11 +4,8 @@ import sys
from pypdf import PdfReader
-# Extracts data for the fillable form fields in a PDF and outputs JSON that
-# Claude uses to fill the fields. See forms.md.
-# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods.
def get_full_annotation_field_id(annotation):
components = []
while annotation:
@@ -25,12 +22,9 @@ def make_field_dict(field, field_id):
if ft == "/Tx":
field_dict["type"] = "text"
elif ft == "/Btn":
- field_dict["type"] = "checkbox" # radio groups handled separately
+ field_dict["type"] = "checkbox"
states = field.get("/_States_", [])
if len(states) == 2:
- # "/Off" seems to always be the unchecked value, as suggested by
- # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448
- # It can be either first or second in the "/_States_" list.
if "/Off" in states:
field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1]
field_dict["unchecked_value"] = "/Off"
@@ -50,15 +44,6 @@ def make_field_dict(field, field_id):
return field_dict
-# Returns a list of fillable PDF fields:
-# [
-# {
-# "field_id": "name",
-# "page": 1,
-# "type": ("text", "checkbox", "radio_group", or "choice")
-# // Per-type additional fields described in forms.md
-# },
-# ]
def get_field_info(reader: PdfReader):
fields = reader.get_fields()
@@ -66,19 +51,13 @@ def get_field_info(reader: PdfReader):
possible_radio_names = set()
for field_id, field in fields.items():
- # Skip if this is a container field with children, except that it might be
- # a parent group for radio button options.
if field.get("/Kids"):
if field.get("/FT") == "/Btn":
possible_radio_names.add(field_id)
continue
field_info_by_id[field_id] = make_field_dict(field, field_id)
- # Bounding rects are stored in annotations in page objects.
- # Radio button options have a separate annotation for each choice;
- # all choices have the same field name.
- # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html
radio_fields_by_id = {}
for page_index, page in enumerate(reader.pages):
@@ -90,8 +69,6 @@ def get_field_info(reader: PdfReader):
field_info_by_id[field_id]["rect"] = ann.get('/Rect')
elif field_id in possible_radio_names:
try:
- # ann['/AP']['/N'] should have two items. One of them is '/Off',
- # the other is the active value.
on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"]
except KeyError:
continue
@@ -104,17 +81,11 @@ def get_field_info(reader: PdfReader):
"page": page_index + 1,
"radio_options": [],
}
- # Note: at least on macOS 15.7, Preview.app doesn't show selected
- # radio buttons correctly. (It does if you remove the leading slash
- # from the value, but that causes them not to appear correctly in
- # Chrome/Firefox/Acrobat/etc).
radio_fields_by_id[field_id]["radio_options"].append({
"value": on_values[0],
"rect": rect,
})
- # Some PDFs have form field definitions without corresponding annotations,
- # so we can't tell where they are. Ignore these fields for now.
fields_with_location = []
for field_info in field_info_by_id.values():
if "page" in field_info:
@@ -122,7 +93,6 @@ def get_field_info(reader: PdfReader):
else:
print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring")
- # Sort by page number, then Y position (flipped in PDF coordinate system), then X.
def sort_key(f):
if "radio_options" in f:
rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0]
@@ -140,8 +110,8 @@ def get_field_info(reader: PdfReader):
def write_field_info(pdf_path: str, json_output_path: str):
reader = PdfReader(pdf_path)
field_info = get_field_info(reader)
- with open(json_output_path, "w", encoding="utf-8") as f:
- json.dump(field_info, f, indent=2, ensure_ascii=False)
+ with open(json_output_path, "w") as f:
+ json.dump(field_info, f, indent=2)
print(f"Wrote {len(field_info)} fields to {json_output_path}")
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py
new file mode 100755
index 00000000..f219e7d5
--- /dev/null
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/extract_form_structure.py
@@ -0,0 +1,115 @@
+"""
+Extract form structure from a non-fillable PDF.
+
+This script analyzes the PDF to find:
+- Text labels with their exact coordinates
+- Horizontal lines (row boundaries)
+- Checkboxes (small rectangles)
+
+Output: A JSON file with the form structure that can be used to generate
+accurate field coordinates for filling.
+
+Usage: python extract_form_structure.py
+"""
+
+import json
+import sys
+import pdfplumber
+
+
+def extract_form_structure(pdf_path):
+ structure = {
+ "pages": [],
+ "labels": [],
+ "lines": [],
+ "checkboxes": [],
+ "row_boundaries": []
+ }
+
+ with pdfplumber.open(pdf_path) as pdf:
+ for page_num, page in enumerate(pdf.pages, 1):
+ structure["pages"].append({
+ "page_number": page_num,
+ "width": float(page.width),
+ "height": float(page.height)
+ })
+
+ words = page.extract_words()
+ for word in words:
+ structure["labels"].append({
+ "page": page_num,
+ "text": word["text"],
+ "x0": round(float(word["x0"]), 1),
+ "top": round(float(word["top"]), 1),
+ "x1": round(float(word["x1"]), 1),
+ "bottom": round(float(word["bottom"]), 1)
+ })
+
+ for line in page.lines:
+ if abs(float(line["x1"]) - float(line["x0"])) > page.width * 0.5:
+ structure["lines"].append({
+ "page": page_num,
+ "y": round(float(line["top"]), 1),
+ "x0": round(float(line["x0"]), 1),
+ "x1": round(float(line["x1"]), 1)
+ })
+
+ for rect in page.rects:
+ width = float(rect["x1"]) - float(rect["x0"])
+ height = float(rect["bottom"]) - float(rect["top"])
+ if 5 <= width <= 15 and 5 <= height <= 15 and abs(width - height) < 2:
+ structure["checkboxes"].append({
+ "page": page_num,
+ "x0": round(float(rect["x0"]), 1),
+ "top": round(float(rect["top"]), 1),
+ "x1": round(float(rect["x1"]), 1),
+ "bottom": round(float(rect["bottom"]), 1),
+ "center_x": round((float(rect["x0"]) + float(rect["x1"])) / 2, 1),
+ "center_y": round((float(rect["top"]) + float(rect["bottom"])) / 2, 1)
+ })
+
+ lines_by_page = {}
+ for line in structure["lines"]:
+ page = line["page"]
+ if page not in lines_by_page:
+ lines_by_page[page] = []
+ lines_by_page[page].append(line["y"])
+
+ for page, y_coords in lines_by_page.items():
+ y_coords = sorted(set(y_coords))
+ for i in range(len(y_coords) - 1):
+ structure["row_boundaries"].append({
+ "page": page,
+ "row_top": y_coords[i],
+ "row_bottom": y_coords[i + 1],
+ "row_height": round(y_coords[i + 1] - y_coords[i], 1)
+ })
+
+ return structure
+
+
+def main():
+ if len(sys.argv) != 3:
+ print("Usage: extract_form_structure.py ")
+ sys.exit(1)
+
+ pdf_path = sys.argv[1]
+ output_path = sys.argv[2]
+
+ print(f"Extracting structure from {pdf_path}...")
+ structure = extract_form_structure(pdf_path)
+
+ with open(output_path, "w") as f:
+ json.dump(structure, f, indent=2)
+
+ print(f"Found:")
+ print(f" - {len(structure['pages'])} pages")
+ print(f" - {len(structure['labels'])} text labels")
+ print(f" - {len(structure['lines'])} horizontal lines")
+ print(f" - {len(structure['checkboxes'])} checkboxes")
+ print(f" - {len(structure['row_boundaries'])} row boundaries")
+ print(f"Saved to {output_path}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py
index 573057ac..51c2600f 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_fillable_fields.py
@@ -6,13 +6,11 @@ from pypdf import PdfReader, PdfWriter
from extract_form_field_info import get_field_info
-# Fills fillable form fields in a PDF. See forms.md.
def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str):
- with open(fields_json_path, encoding="utf-8") as f:
+ with open(fields_json_path) as f:
fields = json.load(f)
- # Group by page number.
fields_by_page = {}
for field in fields:
if "value" in field:
@@ -48,8 +46,6 @@ def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path:
for page, field_values in fields_by_page.items():
writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False)
- # This seems to be necessary for many PDF viewers to format the form values correctly.
- # It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes.
writer.set_need_appearances_writer(True)
with open(output_pdf_path, "wb") as f:
@@ -75,18 +71,6 @@ def validation_error_for_field_value(field_info, field_value):
return None
-# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field.
-# In _writer.py around line 966:
-#
-# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0:
-# txt = "\n".join(annotation.get_inherited(FA.Opt, []))
-#
-# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like
-# [["value1", "Text 1"], ["value2", "Text 2"], ...]
-# This causes `join` to throw a TypeError because it expects an iterable of strings.
-# The horrible workaround is to patch `get_inherited` to return a list of the value strings.
-# We call the original method and adjust the return value only if the argument to `get_inherited`
-# is `FA.Opt` and if the return value is a list of two-element lists.
def monkeypatch_pydpf_method():
from pypdf.generic import DictionaryObject
from pypdf.constants import FieldDictionaryAttributes
diff --git a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py
index ea6c60cb..b430069f 100644
--- a/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py
+++ b/plugins/codex/plugins/pdf/skills/pdf/scripts/fill_pdf_form_with_annotations.py
@@ -5,64 +5,67 @@ from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
-# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md.
-def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height):
- """Transform bounding box from image coordinates to PDF coordinates"""
- # Image coordinates: origin at top-left, y increases downward
- # PDF coordinates: origin at bottom-left, y increases upward
+def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_height):
x_scale = pdf_width / image_width
y_scale = pdf_height / image_height
-
+
left = bbox[0] * x_scale
right = bbox[2] * x_scale
-
- # Flip Y coordinates for PDF
+
top = pdf_height - (bbox[1] * y_scale)
bottom = pdf_height - (bbox[3] * y_scale)
-
+
return left, bottom, right, top
+def transform_from_pdf_coords(bbox, pdf_height):
+ left = bbox[0]
+ right = bbox[2]
+
+ pypdf_top = pdf_height - bbox[1]
+ pypdf_bottom = pdf_height - bbox[3]
+
+ return left, pypdf_bottom, right, pypdf_top
+
+
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
- """Fill the PDF form with data from fields.json"""
- # `fields.json` format described in forms.md.
- with open(fields_json_path, "r", encoding="utf-8") as f:
+ with open(fields_json_path, "r") as f:
fields_data = json.load(f)
- # Open the PDF
reader = PdfReader(input_pdf_path)
writer = PdfWriter()
- # Copy all pages to writer
writer.append(reader)
- # Get PDF dimensions for each page
pdf_dimensions = {}
for i, page in enumerate(reader.pages):
mediabox = page.mediabox
pdf_dimensions[i + 1] = [mediabox.width, mediabox.height]
- # Process each form field
annotations = []
for field in fields_data["form_fields"]:
page_num = field["page_number"]
-
- # Get page dimensions and transform coordinates.
+
page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num)
- image_width = page_info["image_width"]
- image_height = page_info["image_height"]
pdf_width, pdf_height = pdf_dimensions[page_num]
+
+ if "pdf_width" in page_info:
+ transformed_entry_box = transform_from_pdf_coords(
+ field["entry_bounding_box"],
+ float(pdf_height)
+ )
+ else:
+ image_width = page_info["image_width"]
+ image_height = page_info["image_height"]
+ transformed_entry_box = transform_from_image_coords(
+ field["entry_bounding_box"],
+ image_width, image_height,
+ float(pdf_width), float(pdf_height)
+ )
- transformed_entry_box = transform_coordinates(
- field["entry_bounding_box"],
- image_width, image_height,
- pdf_width, pdf_height
- )
-
- # Skip empty fields
if "entry_text" not in field or "text" not in field["entry_text"]:
continue
entry_text = field["entry_text"]
@@ -74,8 +77,6 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
font_size = str(entry_text.get("font_size", 14)) + "pt"
font_color = entry_text.get("font_color", "000000")
- # Font size/color seems to not work reliably across viewers:
- # https://github.com/py-pdf/pypdf/issues/2084
annotation = FreeText(
text=text,
rect=transformed_entry_box,
@@ -86,10 +87,8 @@ def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
background_color=None,
)
annotations.append(annotation)
- # page_number is 0-based for pypdf
writer.add_annotation(page_number=page_num - 1, annotation=annotation)
- # Save the filled PDF
with open(output_pdf_path, "wb") as output:
writer.write(output)
@@ -105,4 +104,4 @@ if __name__ == "__main__":
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
- fill_pdf_form(input_pdf, fields_json, output_pdf)
\ No newline at end of file
+ fill_pdf_form(input_pdf, fields_json, output_pdf)
diff --git a/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md b/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md
new file mode 100644
index 00000000..d3e046a5
--- /dev/null
+++ b/plugins/codex/plugins/pdf/skills/pdf/upstream-guide.md
@@ -0,0 +1,314 @@
+---
+name: pdf
+description: Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill.
+license: Proprietary. LICENSE.txt has complete terms
+---
+
+# PDF Processing Guide
+
+## Overview
+
+This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions.
+
+## Quick Start
+
+```python
+from pypdf import PdfReader, PdfWriter
+
+# Read a PDF
+reader = PdfReader("document.pdf")
+print(f"Pages: {len(reader.pages)}")
+
+# Extract text
+text = ""
+for page in reader.pages:
+ text += page.extract_text()
+```
+
+## Python Libraries
+
+### pypdf - Basic Operations
+
+#### Merge PDFs
+```python
+from pypdf import PdfWriter, PdfReader
+
+writer = PdfWriter()
+for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
+ reader = PdfReader(pdf_file)
+ for page in reader.pages:
+ writer.add_page(page)
+
+with open("merged.pdf", "wb") as output:
+ writer.write(output)
+```
+
+#### Split PDF
+```python
+reader = PdfReader("input.pdf")
+for i, page in enumerate(reader.pages):
+ writer = PdfWriter()
+ writer.add_page(page)
+ with open(f"page_{i+1}.pdf", "wb") as output:
+ writer.write(output)
+```
+
+#### Extract Metadata
+```python
+reader = PdfReader("document.pdf")
+meta = reader.metadata
+print(f"Title: {meta.title}")
+print(f"Author: {meta.author}")
+print(f"Subject: {meta.subject}")
+print(f"Creator: {meta.creator}")
+```
+
+#### Rotate Pages
+```python
+reader = PdfReader("input.pdf")
+writer = PdfWriter()
+
+page = reader.pages[0]
+page.rotate(90) # Rotate 90 degrees clockwise
+writer.add_page(page)
+
+with open("rotated.pdf", "wb") as output:
+ writer.write(output)
+```
+
+### pdfplumber - Text and Table Extraction
+
+#### Extract Text with Layout
+```python
+import pdfplumber
+
+with pdfplumber.open("document.pdf") as pdf:
+ for page in pdf.pages:
+ text = page.extract_text()
+ print(text)
+```
+
+#### Extract Tables
+```python
+with pdfplumber.open("document.pdf") as pdf:
+ for i, page in enumerate(pdf.pages):
+ tables = page.extract_tables()
+ for j, table in enumerate(tables):
+ print(f"Table {j+1} on page {i+1}:")
+ for row in table:
+ print(row)
+```
+
+#### Advanced Table Extraction
+```python
+import pandas as pd
+
+with pdfplumber.open("document.pdf") as pdf:
+ all_tables = []
+ for page in pdf.pages:
+ tables = page.extract_tables()
+ for table in tables:
+ if table: # Check if table is not empty
+ df = pd.DataFrame(table[1:], columns=table[0])
+ all_tables.append(df)
+
+# Combine all tables
+if all_tables:
+ combined_df = pd.concat(all_tables, ignore_index=True)
+ combined_df.to_excel("extracted_tables.xlsx", index=False)
+```
+
+### reportlab - Create PDFs
+
+#### Basic PDF Creation
+```python
+from reportlab.lib.pagesizes import letter
+from reportlab.pdfgen import canvas
+
+c = canvas.Canvas("hello.pdf", pagesize=letter)
+width, height = letter
+
+# Add text
+c.drawString(100, height - 100, "Hello World!")
+c.drawString(100, height - 120, "This is a PDF created with reportlab")
+
+# Add a line
+c.line(100, height - 140, 400, height - 140)
+
+# Save
+c.save()
+```
+
+#### Create PDF with Multiple Pages
+```python
+from reportlab.lib.pagesizes import letter
+from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
+from reportlab.lib.styles import getSampleStyleSheet
+
+doc = SimpleDocTemplate("report.pdf", pagesize=letter)
+styles = getSampleStyleSheet()
+story = []
+
+# Add content
+title = Paragraph("Report Title", styles['Title'])
+story.append(title)
+story.append(Spacer(1, 12))
+
+body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
+story.append(body)
+story.append(PageBreak())
+
+# Page 2
+story.append(Paragraph("Page 2", styles['Heading1']))
+story.append(Paragraph("Content for page 2", styles['Normal']))
+
+# Build PDF
+doc.build(story)
+```
+
+#### Subscripts and Superscripts
+
+**IMPORTANT**: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes.
+
+Instead, use ReportLab's XML markup tags in Paragraph objects:
+```python
+from reportlab.platypus import Paragraph
+from reportlab.lib.styles import getSampleStyleSheet
+
+styles = getSampleStyleSheet()
+
+# Subscripts: use tag
+chemical = Paragraph("H2O", styles['Normal'])
+
+# Superscripts: use tag
+squared = Paragraph("x2 + y2", styles['Normal'])
+```
+
+For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts.
+
+## Command-Line Tools
+
+### pdftotext (poppler-utils)
+```bash
+# Extract text
+pdftotext input.pdf output.txt
+
+# Extract text preserving layout
+pdftotext -layout input.pdf output.txt
+
+# Extract specific pages
+pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
+```
+
+### qpdf
+```bash
+# Merge PDFs
+qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
+
+# Split pages
+qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
+qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
+
+# Rotate pages
+qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
+
+# Remove password
+qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
+```
+
+### pdftk (if available)
+```bash
+# Merge
+pdftk file1.pdf file2.pdf cat output merged.pdf
+
+# Split
+pdftk input.pdf burst
+
+# Rotate
+pdftk input.pdf rotate 1east output rotated.pdf
+```
+
+## Common Tasks
+
+### Extract Text from Scanned PDFs
+```python
+# Requires: pip install pytesseract pdf2image
+import pytesseract
+from pdf2image import convert_from_path
+
+# Convert PDF to images
+images = convert_from_path('scanned.pdf')
+
+# OCR each page
+text = ""
+for i, image in enumerate(images):
+ text += f"Page {i+1}:\n"
+ text += pytesseract.image_to_string(image)
+ text += "\n\n"
+
+print(text)
+```
+
+### Add Watermark
+```python
+from pypdf import PdfReader, PdfWriter
+
+# Create watermark (or load existing)
+watermark = PdfReader("watermark.pdf").pages[0]
+
+# Apply to all pages
+reader = PdfReader("document.pdf")
+writer = PdfWriter()
+
+for page in reader.pages:
+ page.merge_page(watermark)
+ writer.add_page(page)
+
+with open("watermarked.pdf", "wb") as output:
+ writer.write(output)
+```
+
+### Extract Images
+```bash
+# Using pdfimages (poppler-utils)
+pdfimages -j input.pdf output_prefix
+
+# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
+```
+
+### Password Protection
+```python
+from pypdf import PdfReader, PdfWriter
+
+reader = PdfReader("input.pdf")
+writer = PdfWriter()
+
+for page in reader.pages:
+ writer.add_page(page)
+
+# Add password
+writer.encrypt("userpassword", "ownerpassword")
+
+with open("encrypted.pdf", "wb") as output:
+ writer.write(output)
+```
+
+## Quick Reference
+
+| Task | Best Tool | Command/Code |
+|------|-----------|--------------|
+| Merge PDFs | pypdf | `writer.add_page(page)` |
+| Split PDFs | pypdf | One page per file |
+| Extract text | pdfplumber | `page.extract_text()` |
+| Extract tables | pdfplumber | `page.extract_tables()` |
+| Create PDFs | reportlab | Canvas or Platypus |
+| Command line merge | qpdf | `qpdf --empty --pages ...` |
+| OCR scanned PDFs | pytesseract | Convert to image first |
+| Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md |
+
+## Next Steps
+
+- For advanced pypdfium2 usage, see REFERENCE.md
+- For JavaScript libraries (pdf-lib), see REFERENCE.md
+- If you need to fill out a PDF form, follow the instructions in FORMS.md
+- For troubleshooting guides, see REFERENCE.md
diff --git a/plugins/codex/plugins/postgres/.codex-plugin/plugin.json b/plugins/codex/plugins/postgres/.codex-plugin/plugin.json
deleted file mode 100644
index e2448203..00000000
--- a/plugins/codex/plugins/postgres/.codex-plugin/plugin.json
+++ /dev/null
@@ -1,39 +0,0 @@
-{
- "name": "postgres",
- "version": "1.0.0",
- "description": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。",
- "author": {
- "name": "EAPIL",
- "url": "https://git.playones.com/arechen/EapilSkillMarket"
- },
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
- "license": "Proprietary",
- "keywords": [
- "eapil",
- "codex-skill",
- "postgres"
- ],
- "skills": "./skills/",
- "interface": {
- "displayName": "postgres",
- "shortDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。",
- "longDescription": "针对多个PostgreSQL数据库执行 只读 SQL查询。适用于以下场景:(1) 查询PostgreSQL数据库中的数据;(2) 查看数据库架构及表结构;(3) 运行SELECT语句进行数据分析;(4) 检查数据库内容。该工具支持同时连接多个数据库,并提供相应的描述信息以便用户智能选择目标数据库。为确保数据安全,该工具会阻止所有写操作(如INSERT、UPDATE、DELETE、DROP等)。",
- "developerName": "EAPIL",
- "category": "Data & Analytics",
- "capabilities": [
- "Read",
- "Write"
- ],
- "defaultPrompt": [
- "使用 postgres 帮我完成这个任务。"
- ],
- "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "brandColor": "#2563EB",
- "screenshots": [],
- "composerIcon": "./assets/market-icon.svg",
- "logo": "./assets/market-icon.svg"
- }
-}
diff --git a/plugins/codex/plugins/postgres/assets/market-icon.svg b/plugins/codex/plugins/postgres/assets/market-icon.svg
deleted file mode 100644
index c6bf091c..00000000
--- a/plugins/codex/plugins/postgres/assets/market-icon.svg
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/plugins/codex/plugins/postgres/skills/postgres/.gitignore b/plugins/codex/plugins/postgres/skills/postgres/.gitignore
deleted file mode 100644
index d99e056f..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/.gitignore
+++ /dev/null
@@ -1,29 +0,0 @@
-# Credentials - NEVER commit actual connection configs
-connections.json
-
-# Python
-__pycache__/
-*.py[cod]
-*$py.class
-*.so
-.Python
-*.egg
-*.egg-info/
-dist/
-build/
-
-# Virtual environments
-.venv/
-venv/
-ENV/
-
-# Testing
-.pytest_cache/
-.coverage
-htmlcov/
-
-# IDE
-.idea/
-.vscode/
-*.swp
-*.swo
diff --git a/plugins/codex/plugins/postgres/skills/postgres/README.md b/plugins/codex/plugins/postgres/skills/postgres/README.md
deleted file mode 100644
index 3b8dacc8..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/README.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# postgres
-
-Read-only PostgreSQL query skill. Query multiple databases safely with write protection.
-
-## Setup
-
-1. Copy the example config:
-```bash
-cp connections.example.json connections.json
-```
-
-2. Add your database credentials:
-```json
-{
- "databases": [
- {
- "name": "prod",
- "description": "Production - users, orders, transactions",
- "host": "db.example.com",
- "port": 5432,
- "database": "app_prod",
- "user": "readonly",
- "password": "secret",
- "sslmode": "require"
- }
- ]
-}
-```
-
-3. Secure the config:
-```bash
-chmod 600 connections.json
-```
-
-## Usage
-
-```bash
-# List configured databases
-python3 scripts/query.py --list
-
-# List tables
-python3 scripts/query.py --db prod --tables
-
-# Show schema
-python3 scripts/query.py --db prod --schema
-
-# Run query
-python3 scripts/query.py --db prod --query "SELECT * FROM users" --limit 100
-```
-
-## Config Fields
-
-| Field | Required | Default | Description |
-|-------|----------|---------|-------------|
-| name | Yes | - | Database identifier |
-| description | Yes | - | What data it contains (for auto-selection) |
-| host | Yes | - | Hostname |
-| port | No | 5432 | Port |
-| database | Yes | - | Database name |
-| user | Yes | - | Username |
-| password | Yes | - | Password |
-| sslmode | No | prefer | disable, allow, prefer, require, verify-ca, verify-full |
-
-## Safety Features
-
-- **Read-only sessions**: PostgreSQL `readonly=True` mode blocks writes at database level
-- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH allowed
-- **Single statement**: No multi-statement queries (prevents `SELECT 1; DROP TABLE`)
-- **Timeouts**: 30s query timeout, 10s connection timeout
-- **Memory cap**: Max 10,000 rows per query
-- **Credential protection**: Passwords sanitized from error messages
-
-## Requirements
-
-```bash
-pip install psycopg2-binary
-```
diff --git a/plugins/codex/plugins/postgres/skills/postgres/SKILL.md b/plugins/codex/plugins/postgres/skills/postgres/SKILL.md
deleted file mode 100644
index 3355580e..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/SKILL.md
+++ /dev/null
@@ -1,125 +0,0 @@
----
-name: postgres
-description: "Execute read-only SQL queries against multiple PostgreSQL databases. Use when: (1) querying PostgreSQL databases, (2) exploring database schemas/tables, (3) running SELECT queries for data analysis, (4) checking database contents. Supports multiple database connections with descriptions for intelligent auto-selection. Blocks all write operations (INSERT, UPDATE, DELETE, DROP, etc.) for safety."
----
-
-# PostgreSQL Read-Only Query Skill
-
-Execute safe, read-only queries against configured PostgreSQL databases.
-
-## Requirements
-
-- Python 3.8+
-- psycopg2-binary: `pip install -r requirements.txt`
-
-## Setup
-
-Create `connections.json` in the skill directory or `~/.config/claude/postgres-connections.json`.
-
-**Security**: Set file permissions to `600` since it contains credentials:
-```bash
-chmod 600 connections.json
-```
-
-```json
-{
- "databases": [
- {
- "name": "production",
- "description": "Main app database - users, orders, transactions",
- "host": "db.example.com",
- "port": 5432,
- "database": "app_prod",
- "user": "readonly_user",
- "password": "your-password",
- "sslmode": "require"
- }
- ]
-}
-```
-
-### Config Fields
-
-| Field | Required | Description |
-|-------|----------|-------------|
-| name | Yes | Identifier for the database (case-insensitive) |
-| description | Yes | What data this database contains (used for auto-selection) |
-| host | Yes | Database hostname |
-| port | No | Port number (default: 5432) |
-| database | Yes | Database name |
-| user | Yes | Username |
-| password | Yes | Password |
-| sslmode | No | SSL mode: disable, allow, prefer (default), require, verify-ca, verify-full |
-
-## Usage
-
-### List configured databases
-```bash
-python3 scripts/query.py --list
-```
-
-### Query a database
-```bash
-python3 scripts/query.py --db production --query "SELECT * FROM users LIMIT 10"
-```
-
-### List tables
-```bash
-python3 scripts/query.py --db production --tables
-```
-
-### Show schema
-```bash
-python3 scripts/query.py --db production --schema
-```
-
-### Limit results
-```bash
-python3 scripts/query.py --db production --query "SELECT * FROM orders" --limit 100
-```
-
-## Database Selection
-
-Match user intent to database `description`:
-
-| User asks about | Look for description containing |
-|-----------------|--------------------------------|
-| users, accounts | users, accounts, customers |
-| orders, sales | orders, transactions, sales |
-| analytics, metrics | analytics, metrics, reports |
-| logs, events | logs, events, audit |
-
-If unclear, run `--list` and ask user which database.
-
-## Safety Features
-
-- **Read-only session**: Connection uses PostgreSQL `readonly=True` mode (primary protection)
-- **Query validation**: Only SELECT, SHOW, EXPLAIN, WITH queries allowed
-- **Single statement**: Multiple statements per query rejected
-- **SSL support**: Configurable SSL mode for encrypted connections
-- **Query timeout**: 30-second statement timeout enforced
-- **Memory protection**: Max 10,000 rows per query to prevent OOM
-- **Column width cap**: 100 char max per column for readable output
-- **Credential sanitization**: Error messages don't leak passwords
-
-## Troubleshooting
-
-| Error | Solution |
-|-------|----------|
-| Config not found | Create `connections.json` in skill directory |
-| Authentication failed | Check username/password in config |
-| Connection timeout | Verify host/port, check firewall/VPN |
-| SSL error | Try `"sslmode": "disable"` for local databases |
-| Permission warning | Run `chmod 600 connections.json` |
-
-## Exit Codes
-
-- **0**: Success
-- **1**: Error (config missing, auth failed, invalid query, database error)
-
-## Workflow
-
-1. Run `--list` to show available databases
-2. Match user intent to database description
-3. Run `--tables` or `--schema` to explore structure
-4. Execute query with appropriate LIMIT
diff --git a/plugins/codex/plugins/postgres/skills/postgres/connections.example.json b/plugins/codex/plugins/postgres/skills/postgres/connections.example.json
deleted file mode 100644
index b41c5680..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/connections.example.json
+++ /dev/null
@@ -1,34 +0,0 @@
-{
- "databases": [
- {
- "name": "production",
- "description": "Main production database - users, orders, transactions, accounts",
- "host": "prod-db.example.com",
- "port": 5432,
- "database": "app_prod",
- "user": "readonly_user",
- "password": "your-password-here",
- "sslmode": "require"
- },
- {
- "name": "analytics",
- "description": "Analytics warehouse - aggregated metrics, reports, historical data",
- "host": "analytics-db.example.com",
- "port": 5432,
- "database": "analytics",
- "user": "analyst",
- "password": "your-password-here",
- "sslmode": "require"
- },
- {
- "name": "staging",
- "description": "Staging environment - mirrors production for testing",
- "host": "localhost",
- "port": 5432,
- "database": "app_staging",
- "user": "dev",
- "password": "dev-password",
- "sslmode": "prefer"
- }
- ]
-}
diff --git a/plugins/codex/plugins/postgres/skills/postgres/requirements.txt b/plugins/codex/plugins/postgres/skills/postgres/requirements.txt
deleted file mode 100644
index cbeb9412..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/requirements.txt
+++ /dev/null
@@ -1 +0,0 @@
-psycopg2-binary>=2.9,<3.0
diff --git a/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py b/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py
deleted file mode 100644
index 53461858..00000000
--- a/plugins/codex/plugins/postgres/skills/postgres/scripts/query.py
+++ /dev/null
@@ -1,262 +0,0 @@
-#!/usr/bin/env python3
-"""
-Read-only PostgreSQL query executor.
-Connects to configured databases and executes SELECT queries only.
-"""
-
-import json
-import os
-import re
-import stat
-import sys
-import argparse
-from pathlib import Path
-from typing import Optional
-
-try:
- import psycopg2
-except ImportError:
- print("Error: psycopg2 not installed. Run: pip install psycopg2-binary")
- sys.exit(1)
-
-# Constants
-SCRIPT_DIR = Path(__file__).parent.parent
-CONFIG_LOCATIONS = [
- SCRIPT_DIR / "connections.json",
- Path.home() / ".config" / "claude" / "postgres-connections.json",
-]
-MAX_ROWS = 10000
-MAX_COLUMN_WIDTH = 100
-QUERY_TIMEOUT_MS = 30000
-CONNECTION_TIMEOUT_SEC = 10
-NULL_DISPLAY = ""
-
-
-def is_read_only(query: str) -> bool:
- """Basic client-side check. Primary protection is readonly=True session."""
- query_upper = query.upper().strip()
- safe_starts = ('SELECT', 'SHOW', 'DESCRIBE', 'EXPLAIN', 'WITH', '\\D')
- return any(query_upper.startswith(cmd) for cmd in safe_starts)
-
-
-def validate_single_statement(query: str) -> bool:
- """Check query contains only one statement."""
- # Remove trailing semicolon and whitespace, then check for remaining semicolons
- clean = query.rstrip().rstrip(';')
- return ';' not in clean
-
-
-def validate_config_permissions(path: Path) -> None:
- """Warn if config file has insecure permissions (Unix only)."""
- if os.name != 'nt': # Skip on Windows
- mode = path.stat().st_mode
- if bool(mode & stat.S_IRWXG) or bool(mode & stat.S_IRWXO):
- print(f"WARNING: {path} has insecure permissions!")
- print(f"Config contains credentials. Run: chmod 600 {path}")
-
-
-def validate_db_config(db: dict) -> None:
- """Validate required fields exist in database config."""
- required = ['name', 'host', 'database', 'user', 'password']
- missing = [f for f in required if f not in db]
- if missing:
- print(f"Error: Database config missing fields: {', '.join(missing)}")
- sys.exit(1)
-
-
-def find_config() -> Optional[Path]:
- """Find config file in supported locations."""
- for path in CONFIG_LOCATIONS:
- if path.exists():
- return path
- return None
-
-
-def load_config(config_path: Optional[Path] = None) -> dict:
- """Load database connections from JSON config."""
- path = config_path or find_config()
- if not path:
- print("Config not found. Searched:")
- for loc in CONFIG_LOCATIONS:
- print(f" - {loc}")
- print("\nCreate connections.json with format:")
- print(json.dumps({
- "databases": [{
- "name": "mydb",
- "description": "Description of database contents",
- "host": "localhost",
- "port": 5432,
- "database": "mydb",
- "user": "user",
- "password": "password",
- "sslmode": "prefer"
- }]
- }, indent=2))
- sys.exit(1)
-
- validate_config_permissions(path)
-
- with open(path) as f:
- return json.load(f)
-
-
-def list_databases(config: dict) -> None:
- """List all configured databases."""
- print("Configured databases:\n")
- for db in config.get("databases", []):
- validate_db_config(db)
- print(f" [{db['name']}]")
- print(f" Host: {db['host']}:{db.get('port', 5432)}")
- print(f" Database: {db['database']}")
- print(f" Description: {db.get('description', 'No description')}")
- print()
-
-
-def execute_query(db_config: dict, query: str, limit: Optional[int] = None) -> None:
- """Execute a read-only query against the specified database."""
- if not is_read_only(query):
- print("Error: Only read-only queries (SELECT, SHOW, EXPLAIN) are allowed.")
- sys.exit(1)
-
- if not validate_single_statement(query):
- print("Error: Multiple statements not allowed. Execute queries separately.")
- sys.exit(1)
-
- # Apply limit using regex to avoid false positives from string content
- if limit and not re.search(r'\bLIMIT\s+\d+', query, re.IGNORECASE):
- query = f"{query.rstrip(';')} LIMIT {limit}"
-
- conn = None
- try:
- conn = psycopg2.connect(
- host=db_config['host'],
- port=db_config.get('port', 5432),
- database=db_config['database'],
- user=db_config['user'],
- password=db_config['password'],
- sslmode=db_config.get('sslmode', 'prefer'),
- connect_timeout=CONNECTION_TIMEOUT_SEC,
- options=f'-c statement_timeout={QUERY_TIMEOUT_MS}'
- )
- # Primary safety: readonly session prevents any write operations
- conn.set_session(readonly=True, autocommit=True)
-
- with conn.cursor() as cur:
- cur.execute(query)
- if cur.description:
- columns = [desc[0] for desc in cur.description]
- rows = cur.fetchmany(MAX_ROWS)
- truncated = len(rows) == MAX_ROWS
-
- # Calculate column widths with cap
- widths = [min(len(col), MAX_COLUMN_WIDTH) for col in columns]
- for row in rows:
- for i, val in enumerate(row):
- val_str = str(val) if val is not None else NULL_DISPLAY
- widths[i] = min(max(widths[i], len(val_str)), MAX_COLUMN_WIDTH)
-
- # Print header
- header = " | ".join(col[:MAX_COLUMN_WIDTH].ljust(widths[i]) for i, col in enumerate(columns))
- print(header)
- print("-" * len(header))
-
- # Print rows
- for row in rows:
- cells = []
- for i, val in enumerate(row):
- val_str = str(val) if val is not None else NULL_DISPLAY
- if len(val_str) > MAX_COLUMN_WIDTH:
- val_str = val_str[:MAX_COLUMN_WIDTH-3] + "..."
- cells.append(val_str.ljust(widths[i]))
- print(" | ".join(cells))
-
- msg = f"\n({len(rows)} rows)"
- if truncated:
- msg += f" [truncated at {MAX_ROWS}]"
- print(msg)
- else:
- print("Query executed (no result set returned)")
-
- except psycopg2.Error as e:
- error_msg = str(e)
- # Sanitize to avoid leaking credentials
- if 'password' in error_msg.lower() or 'authentication' in error_msg.lower():
- error_msg = "Authentication failed. Check credentials in connections.json"
- print(f"Database error: {error_msg}")
- sys.exit(1)
- finally:
- if conn:
- conn.close()
-
-
-def find_database(config: dict, name: str) -> dict:
- """Find database config by name (case-insensitive)."""
- for db in config.get("databases", []):
- if db.get('name', '').lower() == name.lower():
- validate_db_config(db)
- return db
- available = [db.get('name', 'unnamed') for db in config.get("databases", [])]
- print(f"Database '{name}' not found.")
- print(f"Available: {', '.join(available)}")
- sys.exit(1)
-
-
-def main() -> None:
- """Main entry point."""
- parser = argparse.ArgumentParser(
- description="Execute read-only PostgreSQL queries",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
-Examples:
- %(prog)s --list
- %(prog)s --db mydb --tables
- %(prog)s --db mydb --query "SELECT * FROM users" --limit 100
- """
- )
- parser.add_argument("--config", "-c", type=Path, help="Path to config JSON")
- parser.add_argument("--db", "-d", help="Database name to query")
- parser.add_argument("--query", "-q", help="SQL query to execute")
- parser.add_argument("--limit", "-l", type=int, help="Limit rows returned")
- parser.add_argument("--list", action="store_true", help="List configured databases")
- parser.add_argument("--schema", "-s", action="store_true", help="Show database schema")
- parser.add_argument("--tables", "-t", action="store_true", help="List tables")
-
- args = parser.parse_args()
- config = load_config(args.config)
-
- if args.list:
- list_databases(config)
- return
-
- if not args.db:
- print("Error: --db required. Use --list to see available databases.")
- sys.exit(1)
-
- db_config = find_database(config, args.db)
-
- if args.tables:
- query = """
- SELECT table_schema, table_name, table_type
- FROM information_schema.tables
- WHERE table_schema NOT IN ('pg_catalog', 'information_schema')
- ORDER BY table_schema, table_name
- """
- execute_query(db_config, query, args.limit)
- elif args.schema:
- query = """
- SELECT c.table_schema, c.table_name, c.column_name, c.data_type, c.is_nullable
- FROM information_schema.columns c
- JOIN information_schema.tables t ON c.table_name = t.table_name AND c.table_schema = t.table_schema
- WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
- ORDER BY c.table_schema, c.table_name, c.ordinal_position
- """
- execute_query(db_config, query, args.limit)
- elif args.query:
- execute_query(db_config, args.query, args.limit)
- else:
- print("Error: --query, --tables, or --schema required")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/plugins/codex/plugins/pptx/.codex-plugin/plugin.json b/plugins/codex/plugins/pptx/.codex-plugin/plugin.json
index 536f47d2..54410bfd 100644
--- a/plugins/codex/plugins/pptx/.codex-plugin/plugin.json
+++ b/plugins/codex/plugins/pptx/.codex-plugin/plugin.json
@@ -1,39 +1,40 @@
{
"name": "pptx",
- "version": "1.0.2",
- "description": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。",
+ "version": "2.0.0",
+ "description": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。",
"author": {
- "name": "EAPIL",
- "url": "https://git.playones.com/arechen/EapilSkillMarket"
+ "name": "Anthropic; EAPIL localization",
+ "url": "https://github.com/anthropics"
},
- "homepage": "https://git.playones.com/arechen/EapilSkillMarket",
- "repository": "https://git.playones.com/arechen/EapilSkillMarket",
+ "homepage": "https://github.com/anthropics/skills",
+ "repository": "https://github.com/anthropics/skills",
"license": "Proprietary",
"keywords": [
- "eapil",
- "codex-skill",
- "pptx"
+ "pptx",
+ "documents",
+ "zh-CN",
+ "windows"
],
"skills": "./skills/",
"interface": {
"displayName": "pptx",
- "shortDescription": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。",
- "longDescription": "(需要安装node环境)演示文稿的创建、编辑与分析。当AI需要处理.pptx格式的演示文稿时,他可以执行以下操作:(1) 创建新的演示文稿;(2) 修改或编辑内容;(3) 调整布局;(4) 添加注释或演讲者备注;以及完成其他与演示文稿相关的任务。",
- "developerName": "EAPIL",
+ "shortDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。",
+ "longDescription": "创建和编辑 PowerPoint,保留模板与图表,适配中文和 Windows。 包含最新上游工具及简体中文入口、PowerShell 指引和跨平台辅助脚本。依赖需按任务检查并安装。",
+ "developerName": "Anthropic; EAPIL",
"category": "Productivity",
"capabilities": [
"Read",
"Write"
],
"defaultPrompt": [
- "使用 pptx 帮我完成这个任务。"
+ "使用 pptx 处理这份中文文档,并检查最终输出。"
],
- "websiteURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "privacyPolicyURL": "https://git.playones.com/arechen/EapilSkillMarket",
- "termsOfServiceURL": "https://git.playones.com/arechen/EapilSkillMarket",
+ "websiteURL": "https://github.com/anthropics/skills",
+ "privacyPolicyURL": "https://github.com/anthropics/skills",
+ "termsOfServiceURL": "https://github.com/anthropics/skills",
"brandColor": "#2563EB",
"screenshots": [],
- "composerIcon": "./assets/market-icon.svg",
- "logo": "./assets/market-icon.svg"
+ "logo": "./assets/market-icon.svg",
+ "composerIcon": "./assets/market-icon.svg"
}
}
diff --git a/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json
new file mode 100644
index 00000000..a2a7ad5d
--- /dev/null
+++ b/plugins/codex/plugins/pptx/THIRD_PARTY_SOURCE.json
@@ -0,0 +1,9 @@
+{
+ "sourceId": "pptx",
+ "repo": "https://github.com/anthropics/skills.git",
+ "ref": "main",
+ "commit": "683bc88e56f3e09ba94f7055977f3d3aa499f202",
+ "adapter": "claude-skill",
+ "sourcePath": "skills/pptx",
+ "syncedAt": "2026-10-06T07:26:41Z"
+}
diff --git a/plugins/codex/plugins/pptx/skills/pptx/SKILL.md b/plugins/codex/plugins/pptx/skills/pptx/SKILL.md
index df5000e1..50cce731 100644
--- a/plugins/codex/plugins/pptx/skills/pptx/SKILL.md
+++ b/plugins/codex/plugins/pptx/skills/pptx/SKILL.md
@@ -1,232 +1,34 @@
---
name: pptx
-description: "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill."
+description: 创建、读取、编辑和检查 PowerPoint 演示文稿或模板(.pptx/.potx),保留模板与可编辑图表,适配简体中文字体和 Windows PowerShell。
license: Proprietary. LICENSE.txt has complete terms
---
-# PPTX Skill
+# PowerPoint 演示文稿
-## Quick Reference
+先读取 [references/platform.md](references/platform.md),确认字体、Node/Python 与需要的转换工具。先看用户模板或参考稿,再决定布局、配色与内容密度。
-| Task | Guide |
-|------|-------|
-| Read/analyze content | `python -m markitdown presentation.pptx` |
-| Edit or create from template | Read [editing.md](editing.md) |
-| Create from scratch | Read [pptxgenjs.md](pptxgenjs.md) |
+| 任务 | 选择 |
+| --- | --- |
+| 新建幻灯片 | `pptxgenjs`;简单任务也可使用 `python-pptx` |
+| 提取文本 | `python-pptx` 或 markitdown;检查备注与隐藏页 |
+| 复杂模板、母版或原生图表编辑 | OOXML 加随附脚本 |
+| .ppt 旧格式 | LibreOffice 先转 .pptx |
----
+## 中文与画布
-## Reading Content
+新建稿未指定比例时用 16:9,设置画布后再添加元素。用户模板比例和主题优先。使用本机可用的微软雅黑、等线或思源黑体,同时设置主题字体与正文文本字体;简体中文语言标记使用 `zh-CN`。长中文标题主动换行,表格与数据标签保留足够空间,不能只缩小字号隐藏溢出。
-```bash
-# Text extraction
-python -m markitdown presentation.pptx
+`pptxgenjs` 颜色使用六位 HEX,不带 `#`;每次添加元素使用独立选项对象。文本框 margin 与字体测量要一致;原生图表、表格和文本保持可编辑。坐标单位为英寸,必须落在实际画布内。库限制、组合图表和模板 OOXML 细节按需读取 [upstream-guide.md](upstream-guide.md)。
-# Visual overview
-python scripts/thumbnail.py presentation.pptx
+## 模板编辑
-# Raw XML
-python scripts/office/unpack.py presentation.pptx unpacked/
+先完成复制、删除或重排幻灯片,再改正文。使用 `scripts/add_slide.py` 注册复制页,最终页序确定后运行 `scripts/clean.py` 清理失去引用的部分。不要只复制 slide XML;关系、媒体、母版和内容类型必须一起处理。用 `scripts/office_package.py` 解包和重打包,避免依赖 Bash ZIP 命令。
+
+```powershell
+python "$skill\scripts\office\validate.py" ".\输出.pptx" --original ".\模板.pptx"
+python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\输出.pptx"
+python "$skill\scripts\render_pdf.py" ".\preview\输出.pdf" ".\preview\pages"
```
----
-
-## Editing Workflow
-
-**Read [editing.md](editing.md) for full details.**
-
-1. Analyze template with `thumbnail.py`
-2. Unpack → manipulate slides → edit content → clean → pack
-
----
-
-## Creating from Scratch
-
-**Read [pptxgenjs.md](pptxgenjs.md) for full details.**
-
-Use when no template or reference presentation is available.
-
----
-
-## Design Ideas
-
-**Don't create boring slides.** Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide.
-
-### Before Starting
-
-- **Pick a bold, content-informed color palette**: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices.
-- **Dominance over equality**: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight.
-- **Dark/light contrast**: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel.
-- **Commit to a visual motif**: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles, thick single-side borders. Carry it across every slide.
-
-### Color Palettes
-
-Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration:
-
-| Theme | Primary | Secondary | Accent |
-|-------|---------|-----------|--------|
-| **Midnight Executive** | `1E2761` (navy) | `CADCFC` (ice blue) | `FFFFFF` (white) |
-| **Forest & Moss** | `2C5F2D` (forest) | `97BC62` (moss) | `F5F5F5` (cream) |
-| **Coral Energy** | `F96167` (coral) | `F9E795` (gold) | `2F3C7E` (navy) |
-| **Warm Terracotta** | `B85042` (terracotta) | `E7E8D1` (sand) | `A7BEAE` (sage) |
-| **Ocean Gradient** | `065A82` (deep blue) | `1C7293` (teal) | `21295C` (midnight) |
-| **Charcoal Minimal** | `36454F` (charcoal) | `F2F2F2` (off-white) | `212121` (black) |
-| **Teal Trust** | `028090` (teal) | `00A896` (seafoam) | `02C39A` (mint) |
-| **Berry & Cream** | `6D2E46` (berry) | `A26769` (dusty rose) | `ECE2D0` (cream) |
-| **Sage Calm** | `84B59F` (sage) | `69A297` (eucalyptus) | `50808E` (slate) |
-| **Cherry Bold** | `990011` (cherry) | `FCF6F5` (off-white) | `2F3C7E` (navy) |
-
-### For Each Slide
-
-**Every slide needs a visual element** — image, chart, icon, or shape. Text-only slides are forgettable.
-
-**Layout options:**
-- Two-column (text left, illustration on right)
-- Icon + text rows (icon in colored circle, bold header, description below)
-- 2x2 or 2x3 grid (image on one side, grid of content blocks on other)
-- Half-bleed image (full left or right side) with content overlay
-
-**Data display:**
-- Large stat callouts (big numbers 60-72pt with small labels below)
-- Comparison columns (before/after, pros/cons, side-by-side options)
-- Timeline or process flow (numbered steps, arrows)
-
-**Visual polish:**
-- Icons in small colored circles next to section headers
-- Italic accent text for key stats or taglines
-
-### Typography
-
-**Choose an interesting font pairing** — don't default to Arial. Pick a header font with personality and pair it with a clean body font.
-
-| Header Font | Body Font |
-|-------------|-----------|
-| Georgia | Calibri |
-| Arial Black | Arial |
-| Calibri | Calibri Light |
-| Cambria | Calibri |
-| Trebuchet MS | Calibri |
-| Impact | Arial |
-| Palatino | Garamond |
-| Consolas | Calibri |
-
-| Element | Size |
-|---------|------|
-| Slide title | 36-44pt bold |
-| Section header | 20-24pt bold |
-| Body text | 14-16pt |
-| Captions | 10-12pt muted |
-
-### Spacing
-
-- 0.5" minimum margins
-- 0.3-0.5" between content blocks
-- Leave breathing room—don't fill every inch
-
-### Avoid (Common Mistakes)
-
-- **Don't repeat the same layout** — vary columns, cards, and callouts across slides
-- **Don't center body text** — left-align paragraphs and lists; center only titles
-- **Don't skimp on size contrast** — titles need 36pt+ to stand out from 14-16pt body
-- **Don't default to blue** — pick colors that reflect the specific topic
-- **Don't mix spacing randomly** — choose 0.3" or 0.5" gaps and use consistently
-- **Don't style one slide and leave the rest plain** — commit fully or keep it simple throughout
-- **Don't create text-only slides** — add images, icons, charts, or visual elements; avoid plain title + bullets
-- **Don't forget text box padding** — when aligning lines or shapes with text edges, set `margin: 0` on the text box or offset the shape to account for padding
-- **Don't use low-contrast elements** — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds
-- **NEVER use accent lines under titles** — these are a hallmark of AI-generated slides; use whitespace or background color instead
-
----
-
-## QA (Required)
-
-**Assume there are problems. Your job is to find them.**
-
-Your first render is almost never correct. Approach QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you weren't looking hard enough.
-
-### Content QA
-
-```bash
-python -m markitdown output.pptx
-```
-
-Check for missing content, typos, wrong order.
-
-**When using templates, check for leftover placeholder text:**
-
-```bash
-python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|this.*(page|slide).*layout"
-```
-
-If grep returns results, fix them before declaring success.
-
-### Visual QA
-
-**⚠️ USE SUBAGENTS** — even for 2-3 slides. You've been staring at the code and will see what you expect, not what's there. Subagents have fresh eyes.
-
-Convert slides to images (see [Converting to Images](#converting-to-images)), then use this prompt:
-
-```
-Visually inspect these slides. Assume there are issues — find them.
-
-Look for:
-- Overlapping elements (text through shapes, lines through words, stacked elements)
-- Text overflow or cut off at edges/box boundaries
-- Decorative lines positioned for single-line text but title wrapped to two lines
-- Source citations or footers colliding with content above
-- Elements too close (< 0.3" gaps) or cards/sections nearly touching
-- Uneven gaps (large empty area in one place, cramped in another)
-- Insufficient margin from slide edges (< 0.5")
-- Columns or similar elements not aligned consistently
-- Low-contrast text (e.g., light gray text on cream-colored background)
-- Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle)
-- Text boxes too narrow causing excessive wrapping
-- Leftover placeholder content
-
-For each slide, list issues or areas of concern, even if minor.
-
-Read and analyze these images:
-1. /path/to/slide-01.jpg (Expected: [brief description])
-2. /path/to/slide-02.jpg (Expected: [brief description])
-
-Report ALL issues found, including minor ones.
-```
-
-### Verification Loop
-
-1. Generate slides → Convert to images → Inspect
-2. **List issues found** (if none found, look again more critically)
-3. Fix issues
-4. **Re-verify affected slides** — one fix often creates another problem
-5. Repeat until a full pass reveals no new issues
-
-**Do not declare success until you've completed at least one fix-and-verify cycle.**
-
----
-
-## Converting to Images
-
-Convert presentations to individual slide images for visual inspection:
-
-```bash
-python scripts/office/soffice.py --headless --convert-to pdf output.pptx
-pdftoppm -jpeg -r 150 output.pdf slide
-```
-
-This creates `slide-01.jpg`, `slide-02.jpg`, etc.
-
-To re-render specific slides after fixes:
-
-```bash
-pdftoppm -jpeg -r 150 -f N -l N output.pdf slide-fixed
-```
-
----
-
-## Dependencies
-
-- `pip install "markitdown[pptx]"` - text extraction
-- `pip install Pillow` - thumbnail grids
-- `npm install -g pptxgenjs` - creating from scratch
-- LibreOffice (`soffice`) - PDF conversion (auto-configured for sandboxed environments via `scripts/office/soffice.py`)
-- Poppler (`pdftoppm`) - PDF to images
+没有模板时省略 `--original`。每页检查文字溢出、缺字、对齐、遮挡、图表标签、图片分辨率和页序。需要模板缩略图且 Poppler 可用时运行 `scripts/thumbnail.py` 并指定独立输出前缀;否则用上面的免 Poppler 逐页预览。
diff --git a/plugins/codex/plugins/pptx/skills/pptx/editing.md b/plugins/codex/plugins/pptx/skills/pptx/editing.md
deleted file mode 100644
index f873e8a0..00000000
--- a/plugins/codex/plugins/pptx/skills/pptx/editing.md
+++ /dev/null
@@ -1,205 +0,0 @@
-# Editing Presentations
-
-## Template-Based Workflow
-
-When using an existing presentation as a template:
-
-1. **Analyze existing slides**:
- ```bash
- python scripts/thumbnail.py template.pptx
- python -m markitdown template.pptx
- ```
- Review `thumbnails.jpg` to see layouts, and markitdown output to see placeholder text.
-
-2. **Plan slide mapping**: For each content section, choose a template slide.
-
- ⚠️ **USE VARIED LAYOUTS** — monotonous presentations are a common failure mode. Don't default to basic title + bullet slides. Actively seek out:
- - Multi-column layouts (2-column, 3-column)
- - Image + text combinations
- - Full-bleed images with text overlay
- - Quote or callout slides
- - Section dividers
- - Stat/number callouts
- - Icon grids or icon + text rows
-
- **Avoid:** Repeating the same text-heavy layout for every slide.
-
- Match content type to layout style (e.g., key points → bullet slide, team info → multi-column, testimonials → quote slide).
-
-3. **Unpack**: `python scripts/office/unpack.py template.pptx unpacked/`
-
-4. **Build presentation** (do this yourself, not with subagents):
- - Delete unwanted slides (remove from ``)
- - Duplicate slides you want to reuse (`add_slide.py`)
- - Reorder slides in ``
- - **Complete all structural changes before step 5**
-
-5. **Edit content**: Update text in each `slide{N}.xml`.
- **Use subagents here if available** — slides are separate XML files, so subagents can edit in parallel.
-
-6. **Clean**: `python scripts/clean.py unpacked/`
-
-7. **Pack**: `python scripts/office/pack.py unpacked/ output.pptx --original template.pptx`
-
----
-
-## Scripts
-
-| Script | Purpose |
-|--------|---------|
-| `unpack.py` | Extract and pretty-print PPTX |
-| `add_slide.py` | Duplicate slide or create from layout |
-| `clean.py` | Remove orphaned files |
-| `pack.py` | Repack with validation |
-| `thumbnail.py` | Create visual grid of slides |
-
-### unpack.py
-
-```bash
-python scripts/office/unpack.py input.pptx unpacked/
-```
-
-Extracts PPTX, pretty-prints XML, escapes smart quotes.
-
-### add_slide.py
-
-```bash
-python scripts/add_slide.py unpacked/ slide2.xml # Duplicate slide
-python scripts/add_slide.py unpacked/ slideLayout2.xml # From layout
-```
-
-Prints `` to add to `` at desired position.
-
-### clean.py
-
-```bash
-python scripts/clean.py unpacked/
-```
-
-Removes slides not in ``, unreferenced media, orphaned rels.
-
-### pack.py
-
-```bash
-python scripts/office/pack.py unpacked/ output.pptx --original input.pptx
-```
-
-Validates, repairs, condenses XML, re-encodes smart quotes.
-
-### thumbnail.py
-
-```bash
-python scripts/thumbnail.py input.pptx [output_prefix] [--cols N]
-```
-
-Creates `thumbnails.jpg` with slide filenames as labels. Default 3 columns, max 12 per grid.
-
-**Use for template analysis only** (choosing layouts). For visual QA, use `soffice` + `pdftoppm` to create full-resolution individual slide images—see SKILL.md.
-
----
-
-## Slide Operations
-
-Slide order is in `ppt/presentation.xml` → ``.
-
-**Reorder**: Rearrange `` elements.
-
-**Delete**: Remove ``, then run `clean.py`.
-
-**Add**: Use `add_slide.py`. Never manually copy slide files—the script handles notes references, Content_Types.xml, and relationship IDs that manual copying misses.
-
----
-
-## Editing Content
-
-**Subagents:** If available, use them here (after completing step 4). Each slide is a separate XML file, so subagents can edit in parallel. In your prompt to subagents, include:
-- The slide file path(s) to edit
-- **"Use the Edit tool for all changes"**
-- The formatting rules and common pitfalls below
-
-For each slide:
-1. Read the slide's XML
-2. Identify ALL placeholder content—text, images, charts, icons, captions
-3. Replace each placeholder with final content
-
-**Use the Edit tool, not sed or Python scripts.** The Edit tool forces specificity about what to replace and where, yielding better reliability.
-
-### Formatting Rules
-
-- **Bold all headers, subheadings, and inline labels**: Use `b="1"` on ``. This includes:
- - Slide titles
- - Section headers within a slide
- - Inline labels like (e.g.: "Status:", "Description:") at the start of a line
-- **Never use unicode bullets (•)**: Use proper list formatting with `` or ``
-- **Bullet consistency**: Let bullets inherit from the layout. Only specify `` or ``.
-
----
-
-## Common Pitfalls
-
-### Template Adaptation
-
-When source content has fewer items than the template:
-- **Remove excess elements entirely** (images, shapes, text boxes), don't just clear text
-- Check for orphaned visuals after clearing text content
-- Run visual QA to catch mismatched counts
-
-When replacing text with different length content:
-- **Shorter replacements**: Usually safe
-- **Longer replacements**: May overflow or wrap unexpectedly
-- Test with visual QA after text changes
-- Consider truncating or splitting content to fit the template's design constraints
-
-**Template slots ≠ Source items**: If template has 4 team members but source has 3 users, delete the 4th member's entire group (image + text boxes), not just the text.
-
-### Multi-Item Content
-
-If source has multiple items (numbered lists, multiple sections), create separate `` elements for each — **never concatenate into one string**.
-
-**❌ WRONG** — all items in one paragraph:
-```xml
-
- Step 1: Do the first thing. Step 2: Do the second thing.
-
-```
-
-**✅ CORRECT** — separate paragraphs with bold headers:
-```xml
-
-
- Step 1
-
-
-
- Do the first thing.
-
-
-
- Step 2
-
-
-```
-
-Copy `` from the original paragraph to preserve line spacing. Use `b="1"` on headers.
-
-### Smart Quotes
-
-Handled automatically by unpack/pack. But the Edit tool converts smart quotes to ASCII.
-
-**When adding new text with quotes, use XML entities:**
-
-```xml
-the “Agreement”
-```
-
-| Character | Name | Unicode | XML Entity |
-|-----------|------|---------|------------|
-| `“` | Left double quote | U+201C | `“` |
-| `”` | Right double quote | U+201D | `”` |
-| `‘` | Left single quote | U+2018 | `‘` |
-| `’` | Right single quote | U+2019 | `’` |
-
-### Other
-
-- **Whitespace**: Use `xml:space="preserve"` on `` with leading/trailing spaces
-- **XML parsing**: Use `defusedxml.minidom`, not `xml.etree.ElementTree` (corrupts namespaces)
diff --git a/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md b/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md
deleted file mode 100644
index 6bfed908..00000000
--- a/plugins/codex/plugins/pptx/skills/pptx/pptxgenjs.md
+++ /dev/null
@@ -1,420 +0,0 @@
-# PptxGenJS Tutorial
-
-## Setup & Basic Structure
-
-```javascript
-const pptxgen = require("pptxgenjs");
-
-let pres = new pptxgen();
-pres.layout = 'LAYOUT_16x9'; // or 'LAYOUT_16x10', 'LAYOUT_4x3', 'LAYOUT_WIDE'
-pres.author = 'Your Name';
-pres.title = 'Presentation Title';
-
-let slide = pres.addSlide();
-slide.addText("Hello World!", { x: 0.5, y: 0.5, fontSize: 36, color: "363636" });
-
-pres.writeFile({ fileName: "Presentation.pptx" });
-```
-
-## Layout Dimensions
-
-Slide dimensions (coordinates in inches):
-- `LAYOUT_16x9`: 10" × 5.625" (default)
-- `LAYOUT_16x10`: 10" × 6.25"
-- `LAYOUT_4x3`: 10" × 7.5"
-- `LAYOUT_WIDE`: 13.3" × 7.5"
-
----
-
-## Text & Formatting
-
-```javascript
-// Basic text
-slide.addText("Simple Text", {
- x: 1, y: 1, w: 8, h: 2, fontSize: 24, fontFace: "Arial",
- color: "363636", bold: true, align: "center", valign: "middle"
-});
-
-// Character spacing (use charSpacing, not letterSpacing which is silently ignored)
-slide.addText("SPACED TEXT", { x: 1, y: 1, w: 8, h: 1, charSpacing: 6 });
-
-// Rich text arrays
-slide.addText([
- { text: "Bold ", options: { bold: true } },
- { text: "Italic ", options: { italic: true } }
-], { x: 1, y: 3, w: 8, h: 1 });
-
-// Multi-line text (requires breakLine: true)
-slide.addText([
- { text: "Line 1", options: { breakLine: true } },
- { text: "Line 2", options: { breakLine: true } },
- { text: "Line 3" } // Last item doesn't need breakLine
-], { x: 0.5, y: 0.5, w: 8, h: 2 });
-
-// Text box margin (internal padding)
-slide.addText("Title", {
- x: 0.5, y: 0.3, w: 9, h: 0.6,
- margin: 0 // Use 0 when aligning text with other elements like shapes or icons
-});
-```
-
-**Tip:** Text boxes have internal margin by default. Set `margin: 0` when you need text to align precisely with shapes, lines, or icons at the same x-position.
-
----
-
-## Lists & Bullets
-
-```javascript
-// ✅ CORRECT: Multiple bullets
-slide.addText([
- { text: "First item", options: { bullet: true, breakLine: true } },
- { text: "Second item", options: { bullet: true, breakLine: true } },
- { text: "Third item", options: { bullet: true } }
-], { x: 0.5, y: 0.5, w: 8, h: 3 });
-
-// ❌ WRONG: Never use unicode bullets
-slide.addText("• First item", { ... }); // Creates double bullets
-
-// Sub-items and numbered lists
-{ text: "Sub-item", options: { bullet: true, indentLevel: 1 } }
-{ text: "First", options: { bullet: { type: "number" }, breakLine: true } }
-```
-
----
-
-## Shapes
-
-```javascript
-slide.addShape(pres.shapes.RECTANGLE, {
- x: 0.5, y: 0.8, w: 1.5, h: 3.0,
- fill: { color: "FF0000" }, line: { color: "000000", width: 2 }
-});
-
-slide.addShape(pres.shapes.OVAL, { x: 4, y: 1, w: 2, h: 2, fill: { color: "0000FF" } });
-
-slide.addShape(pres.shapes.LINE, {
- x: 1, y: 3, w: 5, h: 0, line: { color: "FF0000", width: 3, dashType: "dash" }
-});
-
-// With transparency
-slide.addShape(pres.shapes.RECTANGLE, {
- x: 1, y: 1, w: 3, h: 2,
- fill: { color: "0088CC", transparency: 50 }
-});
-
-// Rounded rectangle (rectRadius only works with ROUNDED_RECTANGLE, not RECTANGLE)
-// ⚠️ Don't pair with rectangular accent overlays — they won't cover rounded corners. Use RECTANGLE instead.
-slide.addShape(pres.shapes.ROUNDED_RECTANGLE, {
- x: 1, y: 1, w: 3, h: 2,
- fill: { color: "FFFFFF" }, rectRadius: 0.1
-});
-
-// With shadow
-slide.addShape(pres.shapes.RECTANGLE, {
- x: 1, y: 1, w: 3, h: 2,
- fill: { color: "FFFFFF" },
- shadow: { type: "outer", color: "000000", blur: 6, offset: 2, angle: 135, opacity: 0.15 }
-});
-```
-
-Shadow options:
-
-| Property | Type | Range | Notes |
-|----------|------|-------|-------|
-| `type` | string | `"outer"`, `"inner"` | |
-| `color` | string | 6-char hex (e.g. `"000000"`) | No `#` prefix, no 8-char hex — see Common Pitfalls |
-| `blur` | number | 0-100 pt | |
-| `offset` | number | 0-200 pt | **Must be non-negative** — negative values corrupt the file |
-| `angle` | number | 0-359 degrees | Direction the shadow falls (135 = bottom-right, 270 = upward) |
-| `opacity` | number | 0.0-1.0 | Use this for transparency, never encode in color string |
-
-To cast a shadow upward (e.g. on a footer bar), use `angle: 270` with a positive offset — do **not** use a negative offset.
-
-**Note**: Gradient fills are not natively supported. Use a gradient image as a background instead.
-
----
-
-## Images
-
-### Image Sources
-
-```javascript
-// From file path
-slide.addImage({ path: "images/chart.png", x: 1, y: 1, w: 5, h: 3 });
-
-// From URL
-slide.addImage({ path: "https://example.com/image.jpg", x: 1, y: 1, w: 5, h: 3 });
-
-// From base64 (faster, no file I/O)
-slide.addImage({ data: "image/png;base64,iVBORw0KGgo...", x: 1, y: 1, w: 5, h: 3 });
-```
-
-### Image Options
-
-```javascript
-slide.addImage({
- path: "image.png",
- x: 1, y: 1, w: 5, h: 3,
- rotate: 45, // 0-359 degrees
- rounding: true, // Circular crop
- transparency: 50, // 0-100
- flipH: true, // Horizontal flip
- flipV: false, // Vertical flip
- altText: "Description", // Accessibility
- hyperlink: { url: "https://example.com" }
-});
-```
-
-### Image Sizing Modes
-
-```javascript
-// Contain - fit inside, preserve ratio
-{ sizing: { type: 'contain', w: 4, h: 3 } }
-
-// Cover - fill area, preserve ratio (may crop)
-{ sizing: { type: 'cover', w: 4, h: 3 } }
-
-// Crop - cut specific portion
-{ sizing: { type: 'crop', x: 0.5, y: 0.5, w: 2, h: 2 } }
-```
-
-### Calculate Dimensions (preserve aspect ratio)
-
-```javascript
-const origWidth = 1978, origHeight = 923, maxHeight = 3.0;
-const calcWidth = maxHeight * (origWidth / origHeight);
-const centerX = (10 - calcWidth) / 2;
-
-slide.addImage({ path: "image.png", x: centerX, y: 1.2, w: calcWidth, h: maxHeight });
-```
-
-### Supported Formats
-
-- **Standard**: PNG, JPG, GIF (animated GIFs work in Microsoft 365)
-- **SVG**: Works in modern PowerPoint/Microsoft 365
-
----
-
-## Icons
-
-Use react-icons to generate SVG icons, then rasterize to PNG for universal compatibility.
-
-### Setup
-
-```javascript
-const React = require("react");
-const ReactDOMServer = require("react-dom/server");
-const sharp = require("sharp");
-const { FaCheckCircle, FaChartLine } = require("react-icons/fa");
-
-function renderIconSvg(IconComponent, color = "#000000", size = 256) {
- return ReactDOMServer.renderToStaticMarkup(
- React.createElement(IconComponent, { color, size: String(size) })
- );
-}
-
-async function iconToBase64Png(IconComponent, color, size = 256) {
- const svg = renderIconSvg(IconComponent, color, size);
- const pngBuffer = await sharp(Buffer.from(svg)).png().toBuffer();
- return "image/png;base64," + pngBuffer.toString("base64");
-}
-```
-
-### Add Icon to Slide
-
-```javascript
-const iconData = await iconToBase64Png(FaCheckCircle, "#4472C4", 256);
-
-slide.addImage({
- data: iconData,
- x: 1, y: 1, w: 0.5, h: 0.5 // Size in inches
-});
-```
-
-**Note**: Use size 256 or higher for crisp icons. The size parameter controls the rasterization resolution, not the display size on the slide (which is set by `w` and `h` in inches).
-
-### Icon Libraries
-
-Install: `npm install -g react-icons react react-dom sharp`
-
-Popular icon sets in react-icons:
-- `react-icons/fa` - Font Awesome
-- `react-icons/md` - Material Design
-- `react-icons/hi` - Heroicons
-- `react-icons/bi` - Bootstrap Icons
-
----
-
-## Slide Backgrounds
-
-```javascript
-// Solid color
-slide.background = { color: "F1F1F1" };
-
-// Color with transparency
-slide.background = { color: "FF3399", transparency: 50 };
-
-// Image from URL
-slide.background = { path: "https://example.com/bg.jpg" };
-
-// Image from base64
-slide.background = { data: "image/png;base64,iVBORw0KGgo..." };
-```
-
----
-
-## Tables
-
-```javascript
-slide.addTable([
- ["Header 1", "Header 2"],
- ["Cell 1", "Cell 2"]
-], {
- x: 1, y: 1, w: 8, h: 2,
- border: { pt: 1, color: "999999" }, fill: { color: "F1F1F1" }
-});
-
-// Advanced with merged cells
-let tableData = [
- [{ text: "Header", options: { fill: { color: "6699CC" }, color: "FFFFFF", bold: true } }, "Cell"],
- [{ text: "Merged", options: { colspan: 2 } }]
-];
-slide.addTable(tableData, { x: 1, y: 3.5, w: 8, colW: [4, 4] });
-```
-
----
-
-## Charts
-
-```javascript
-// Bar chart
-slide.addChart(pres.charts.BAR, [{
- name: "Sales", labels: ["Q1", "Q2", "Q3", "Q4"], values: [4500, 5500, 6200, 7100]
-}], {
- x: 0.5, y: 0.6, w: 6, h: 3, barDir: 'col',
- showTitle: true, title: 'Quarterly Sales'
-});
-
-// Line chart
-slide.addChart(pres.charts.LINE, [{
- name: "Temp", labels: ["Jan", "Feb", "Mar"], values: [32, 35, 42]
-}], { x: 0.5, y: 4, w: 6, h: 3, lineSize: 3, lineSmooth: true });
-
-// Pie chart
-slide.addChart(pres.charts.PIE, [{
- name: "Share", labels: ["A", "B", "Other"], values: [35, 45, 20]
-}], { x: 7, y: 1, w: 5, h: 4, showPercent: true });
-```
-
-### Better-Looking Charts
-
-Default charts look dated. Apply these options for a modern, clean appearance:
-
-```javascript
-slide.addChart(pres.charts.BAR, chartData, {
- x: 0.5, y: 1, w: 9, h: 4, barDir: "col",
-
- // Custom colors (match your presentation palette)
- chartColors: ["0D9488", "14B8A6", "5EEAD4"],
-
- // Clean background
- chartArea: { fill: { color: "FFFFFF" }, roundedCorners: true },
-
- // Muted axis labels
- catAxisLabelColor: "64748B",
- valAxisLabelColor: "64748B",
-
- // Subtle grid (value axis only)
- valGridLine: { color: "E2E8F0", size: 0.5 },
- catGridLine: { style: "none" },
-
- // Data labels on bars
- showValue: true,
- dataLabelPosition: "outEnd",
- dataLabelColor: "1E293B",
-
- // Hide legend for single series
- showLegend: false,
-});
-```
-
-**Key styling options:**
-- `chartColors: [...]` - hex colors for series/segments
-- `chartArea: { fill, border, roundedCorners }` - chart background
-- `catGridLine/valGridLine: { color, style, size }` - grid lines (`style: "none"` to hide)
-- `lineSmooth: true` - curved lines (line charts)
-- `legendPos: "r"` - legend position: "b", "t", "l", "r", "tr"
-
----
-
-## Slide Masters
-
-```javascript
-pres.defineSlideMaster({
- title: 'TITLE_SLIDE', background: { color: '283A5E' },
- objects: [{
- placeholder: { options: { name: 'title', type: 'title', x: 1, y: 2, w: 8, h: 2 } }
- }]
-});
-
-let titleSlide = pres.addSlide({ masterName: "TITLE_SLIDE" });
-titleSlide.addText("My Title", { placeholder: "title" });
-```
-
----
-
-## Common Pitfalls
-
-⚠️ These issues cause file corruption, visual bugs, or broken output. Avoid them.
-
-1. **NEVER use "#" with hex colors** - causes file corruption
- ```javascript
- color: "FF0000" // ✅ CORRECT
- color: "#FF0000" // ❌ WRONG
- ```
-
-2. **NEVER encode opacity in hex color strings** - 8-char colors (e.g., `"00000020"`) corrupt the file. Use the `opacity` property instead.
- ```javascript
- shadow: { type: "outer", blur: 6, offset: 2, color: "00000020" } // ❌ CORRUPTS FILE
- shadow: { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.12 } // ✅ CORRECT
- ```
-
-3. **Use `bullet: true`** - NEVER unicode symbols like "•" (creates double bullets)
-
-4. **Use `breakLine: true`** between array items or text runs together
-
-5. **Avoid `lineSpacing` with bullets** - causes excessive gaps; use `paraSpaceAfter` instead
-
-6. **Each presentation needs fresh instance** - don't reuse `pptxgen()` objects
-
-7. **NEVER reuse option objects across calls** - PptxGenJS mutates objects in-place (e.g. converting shadow values to EMU). Sharing one object between multiple calls corrupts the second shape.
- ```javascript
- const shadow = { type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 };
- slide.addShape(pres.shapes.RECTANGLE, { shadow, ... }); // ❌ second call gets already-converted values
- slide.addShape(pres.shapes.RECTANGLE, { shadow, ... });
-
- const makeShadow = () => ({ type: "outer", blur: 6, offset: 2, color: "000000", opacity: 0.15 });
- slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... }); // ✅ fresh object each time
- slide.addShape(pres.shapes.RECTANGLE, { shadow: makeShadow(), ... });
- ```
-
-8. **Don't use `ROUNDED_RECTANGLE` with accent borders** - rectangular overlay bars won't cover rounded corners. Use `RECTANGLE` instead.
- ```javascript
- // ❌ WRONG: Accent bar doesn't cover rounded corners
- slide.addShape(pres.shapes.ROUNDED_RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } });
- slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } });
-
- // ✅ CORRECT: Use RECTANGLE for clean alignment
- slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 3, h: 1.5, fill: { color: "FFFFFF" } });
- slide.addShape(pres.shapes.RECTANGLE, { x: 1, y: 1, w: 0.08, h: 1.5, fill: { color: "0891B2" } });
- ```
-
----
-
-## Quick Reference
-
-- **Shapes**: RECTANGLE, OVAL, LINE, ROUNDED_RECTANGLE
-- **Charts**: BAR, LINE, PIE, DOUGHNUT, SCATTER, BUBBLE, RADAR
-- **Layouts**: LAYOUT_16x9 (10"×5.625"), LAYOUT_16x10, LAYOUT_4x3, LAYOUT_WIDE
-- **Alignment**: "left", "center", "right"
-- **Chart data labels**: "outEnd", "inEnd", "center"
diff --git a/plugins/codex/plugins/pptx/skills/pptx/references/platform.md b/plugins/codex/plugins/pptx/skills/pptx/references/platform.md
new file mode 100644
index 00000000..c3bb862f
--- /dev/null
+++ b/plugins/codex/plugins/pptx/skills/pptx/references/platform.md
@@ -0,0 +1,41 @@
+# 简体中文与 Windows 环境
+
+以下命令中的 `$skill` 是当前已安装 Skill 的真实目录;不要硬编码开发者机器路径。脚本使用 Python 3.10+。先检查现有 Python/Node 环境,缺少哪个依赖再安装哪个。
+
+```powershell
+$env:PYTHONUTF8 = "1"
+[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
+python --version
+node --version
+```
+
+若只有 `py`,把命令中的 `python` 替换为 `py -3`。PowerShell 调用带空格的可执行文件路径使用 `& "路径"`;Python 脚本路径作为独立参数传入,不拼接 shell 命令。中文路径、空格路径均需引号。
+
+需要独立依赖环境时,在任务工作目录创建 `.venv`,使用 `& ".\.venv\Scripts\python.exe" -m pip install <缺失包>`。无需改变系统执行策略或激活脚本。Node 库安装到任务目录,不默认全局安装。不要假设 Codex、Claude 或容器已预装所有依赖。
+
+## 字体与文本
+
+- 用户未指定语言时,中文用户的说明、标题、图例和交付文案使用简体中文;引文、产品名、代码和原文专有名词保持原样。
+- 新文件优先使用本机可用的微软雅黑、等线、宋体或思源黑体。先查字体;声明字体名不等于字体已安装。Word 配置 `w:rFonts/@w:eastAsia` 与 `zh-CN`,PPT 配置主题字体和文本框字体,Excel 使用 `Microsoft YaHei` 等实际存在的字体。
+- PDF 优先嵌入有再分发许可的中文字体;ReportLab 简单中文文档可用 `UnicodeCIDFont('STSong-Light')`,交付前仍检查目标阅读器效果。不要用 Helvetica 绘制中文,也不要把方块字当正常输出。
+- CSV 面向 Windows Excel 时可用 `utf-8-sig`;先保留已有文件编码,不要自动改写所有输入。XML、JSON 和新建脚本使用 UTF-8。
+- 标点、单位、日期、人民币格式、身份证号和前导零按用户语义保留;不要默认套用美式日期或把长编号转为数字。
+
+## 转换与预览
+
+Office 文档转 PDF 使用随附的跨平台 `scripts/office/soffice.py`。它按 `SOFFICE_PATH`、PATH、Windows Program Files、macOS 应用目录寻找 LibreOffice,并为转换创建独立临时 profile。
+
+```powershell
+# 仅在自动检测不到时设置;不要求添加系统 PATH
+$env:SOFFICE_PATH = "C:\Program Files\LibreOffice\program\soffice.com"
+python "$skill\scripts\office\soffice.py" --headless --convert-to pdf --outdir ".\preview" ".\中文 文档.docx"
+python "$skill\scripts\render_pdf.py" ".\preview\中文 文档.pdf" ".\preview\pages"
+```
+
+按实际需要安装 LibreOffice、`pymupdf`、`defusedxml`、`lxml` 等缺失依赖。PDF 的 `scripts/convert_pdf_to_images.py` 与其他 Skill 的 `scripts/render_pdf.py` 均使用 PyMuPDF,不需要 Poppler。`pptx` 上游缩略图脚本仍需 Poppler;无 Poppler 时走 PDF 渲染流程。
+
+使用 Python `scripts/office_package.py unpack <输入> <空目录>` 和 `pack <包根目录> <输出>` 处理 Office ZIP,无需 `unzip`、`zip`、`rm` 或 Bash。输出必须在解包目录之外。
+
+## 验证与交付
+
+生成或编辑后保存到新文件;先检查内容、公式或 OOXML 结构,再渲染并查看页面,检查中文缺字、表格越界、文本遮挡和分页。转换失败或字体缺失时说明具体原因,不把“文件保存成功”当成渲染通过。保留输入,除非用户明确要求覆盖。
diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py
old mode 100644
new mode 100755
index 13700df0..f013ea94
--- a/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py
+++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/add_slide.py
@@ -1,51 +1,41 @@
-"""Add a new slide to an unpacked PPTX directory.
+"""Add a slide to a PPTX: duplicate an existing slide or instantiate a layout.
-Usage: python add_slide.py
+Does all of the package bookkeeping, so the deck stays valid:
+ - writes the new ppt/slides/slideN.xml (and its .rels, minus any
+ notesSlide reference, so the source's speaker notes aren't shared)
+ - registers it in [Content_Types].xml
+ - adds a slide relationship with a fresh rId to presentation.xml.rels
+ - inserts with a fresh id into
+ — at the end, or after --after SLIDE
-The source can be:
- - A slide file (e.g., slide2.xml) - duplicates the slide
- - A layout file (e.g., slideLayout2.xml) - creates from layout
+Works on an unpacked directory (during an editing session) or directly on a
+.pptx/.potx file (extracted to a temp dir, then rezipped atomically; the
+temp dir is discarded, so unpack the output if you still need to edit the
+new slide's content).
-Examples:
- python add_slide.py unpacked/ slide2.xml
- # Duplicates slide2, creates slide5.xml
+Usage:
+ python add_slide.py unpacked/ slide2.xml # duplicate slide2
+ python add_slide.py unpacked/ slideLayout3.xml # new slide from a layout
+ python add_slide.py unpacked/ slide2.xml --after slide2.xml
+ python add_slide.py deck.pptx slide2.xml # rewrite deck.pptx in place
+ python add_slide.py deck.pptx slide2.xml -o out.pptx
- python add_slide.py unpacked/ slideLayout2.xml
- # Creates slide5.xml from slideLayout2.xml
-
-To see available layouts: ls unpacked/ppt/slideLayouts/
-
-Prints the element to add to presentation.xml.
+A duplicated slide still holds the source's content: edit ppt/slides/slideN.xml
+(printed on success) to change it. To list layouts: ls /ppt/slideLayouts/
"""
+import argparse
import re
import shutil
import sys
+from typing import NoReturn
+import tempfile
+import zipfile
from pathlib import Path
+from office.helpers import rezip, safe_extract
-def get_next_slide_number(slides_dir: Path) -> int:
- existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml")
- if (m := re.match(r"slide(\d+)\.xml", f.name))]
- return max(existing) + 1 if existing else 1
-
-
-def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
- slides_dir = unpacked_dir / "ppt" / "slides"
- rels_dir = slides_dir / "_rels"
- layouts_dir = unpacked_dir / "ppt" / "slideLayouts"
-
- layout_path = layouts_dir / layout_file
- if not layout_path.exists():
- print(f"Error: {layout_path} not found", file=sys.stderr)
- sys.exit(1)
-
- next_num = get_next_slide_number(slides_dir)
- dest = f"slide{next_num}.xml"
- dest_slide = slides_dir / dest
- dest_rels = rels_dir / f"{dest}.rels"
-
- slide_xml = '''
+MINIMAL_SLIDE_XML = '''
@@ -68,98 +58,25 @@ def create_slide_from_layout(unpacked_dir: Path, layout_file: str) -> None:
'''
- dest_slide.write_text(slide_xml, encoding="utf-8")
- rels_dir.mkdir(exist_ok=True)
- rels_xml = f'''
-
-
-'''
- dest_rels.write_text(rels_xml, encoding="utf-8")
+SHARED_PART_TYPES = ("chart", "diagramData", "oleObject", "package")
- _add_to_content_types(unpacked_dir, dest)
+NOTES_SLIDE_TYPE_RE = re.compile(r"""Type=["'][^"']*/relationships/notesSlide["']""")
+RELATIONSHIP_RE = re.compile(r"]*?(?:/>|>.*?)", re.DOTALL)
- rid = _add_to_presentation_rels(unpacked_dir, dest)
-
- next_slide_id = _get_next_slide_id(unpacked_dir)
-
- print(f"Created {dest} from {layout_file}")
- print(f'Add to presentation.xml : ')
+SLIDE_ID_MIN = 256
+SLIDE_ID_MAX = 2147483647
-def duplicate_slide(unpacked_dir: Path, source: str) -> None:
- slides_dir = unpacked_dir / "ppt" / "slides"
- rels_dir = slides_dir / "_rels"
-
- source_slide = slides_dir / source
-
- if not source_slide.exists():
- print(f"Error: {source_slide} not found", file=sys.stderr)
- sys.exit(1)
-
- next_num = get_next_slide_number(slides_dir)
- dest = f"slide{next_num}.xml"
- dest_slide = slides_dir / dest
-
- source_rels = rels_dir / f"{source}.rels"
- dest_rels = rels_dir / f"{dest}.rels"
-
- shutil.copy2(source_slide, dest_slide)
-
- if source_rels.exists():
- shutil.copy2(source_rels, dest_rels)
-
- rels_content = dest_rels.read_text(encoding="utf-8")
- rels_content = re.sub(
- r'\s*]*Type="[^"]*notesSlide"[^>]*/>\s*',
- "\n",
- rels_content,
- )
- dest_rels.write_text(rels_content, encoding="utf-8")
-
- _add_to_content_types(unpacked_dir, dest)
-
- rid = _add_to_presentation_rels(unpacked_dir, dest)
-
- next_slide_id = _get_next_slide_id(unpacked_dir)
-
- print(f"Created {dest} from {source}")
- print(f'Add to presentation.xml : ')
+def _die(msg: str) -> NoReturn:
+ print(f"Error: {msg}", file=sys.stderr)
+ sys.exit(1)
-def _add_to_content_types(unpacked_dir: Path, dest: str) -> None:
- content_types_path = unpacked_dir / "[Content_Types].xml"
- content_types = content_types_path.read_text(encoding="utf-8")
-
- new_override = f''
-
- if f"/ppt/slides/{dest}" not in content_types:
- content_types = content_types.replace("", f" {new_override}\n")
- content_types_path.write_text(content_types, encoding="utf-8")
-
-
-def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str:
- pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
- pres_rels = pres_rels_path.read_text(encoding="utf-8")
-
- rids = [int(m) for m in re.findall(r'Id="rId(\d+)"', pres_rels)]
- next_rid = max(rids) + 1 if rids else 1
- rid = f"rId{next_rid}"
-
- new_rel = f''
-
- if f"slides/{dest}" not in pres_rels:
- pres_rels = pres_rels.replace("", f" {new_rel}\n")
- pres_rels_path.write_text(pres_rels, encoding="utf-8")
-
- return rid
-
-
-def _get_next_slide_id(unpacked_dir: Path) -> int:
- pres_path = unpacked_dir / "ppt" / "presentation.xml"
- pres_content = pres_path.read_text(encoding="utf-8")
- slide_ids = [int(m) for m in re.findall(r']*id="(\d+)"', pres_content)]
- return max(slide_ids) + 1 if slide_ids else 256
+def get_next_slide_number(slides_dir: Path) -> int:
+ existing = [int(m.group(1)) for f in slides_dir.glob("slide*.xml")
+ if (m := re.match(r"slide(\d+)\.xml", f.name))]
+ return max(existing) + 1 if existing else 1
def parse_source(source: str) -> tuple[str, str | None]:
@@ -169,27 +86,282 @@ def parse_source(source: str) -> tuple[str, str | None]:
return ("slide", None)
-if __name__ == "__main__":
- if len(sys.argv) != 3:
- print("Usage: python add_slide.py ", file=sys.stderr)
- print("", file=sys.stderr)
- print("Source can be:", file=sys.stderr)
- print(" slide2.xml - duplicate an existing slide", file=sys.stderr)
- print(" slideLayout2.xml - create from a layout template", file=sys.stderr)
- print("", file=sys.stderr)
- print("To see available layouts: ls /ppt/slideLayouts/", file=sys.stderr)
- sys.exit(1)
+def create_slide_from_layout(unpacked_dir: Path, layout_file: str, after: str | None = None) -> str:
+ slides_dir = unpacked_dir / "ppt" / "slides"
+ rels_dir = slides_dir / "_rels"
+ layout_path = unpacked_dir / "ppt" / "slideLayouts" / layout_file
- unpacked_dir = Path(sys.argv[1])
- source = sys.argv[2]
+ if not layout_path.exists():
+ _die(f"{layout_path} not found")
- if not unpacked_dir.exists():
- print(f"Error: {unpacked_dir} not found", file=sys.stderr)
- sys.exit(1)
+ next_num = get_next_slide_number(slides_dir)
+ dest = f"slide{next_num}.xml"
+ after_rid = _precheck_registration(unpacked_dir, after, dest)
+ slides_dir.mkdir(parents=True, exist_ok=True)
- source_type, layout_file = parse_source(source)
+ (slides_dir / dest).write_text(MINIMAL_SLIDE_XML, encoding="utf-8")
- if source_type == "layout" and layout_file is not None:
- create_slide_from_layout(unpacked_dir, layout_file)
+ rels_dir.mkdir(exist_ok=True)
+ rels_xml = f'''
+
+
+'''
+ (rels_dir / f"{dest}.rels").write_text(rels_xml, encoding="utf-8")
+
+ _register_slide(unpacked_dir, dest, layout_file, after_rid)
+ return dest
+
+
+def duplicate_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str:
+ slides_dir = unpacked_dir / "ppt" / "slides"
+ rels_dir = slides_dir / "_rels"
+ source_slide = slides_dir / source
+
+ if not source_slide.exists():
+ _die(f"{source_slide} not found")
+
+ next_num = get_next_slide_number(slides_dir)
+ dest = f"slide{next_num}.xml"
+ after_rid = _precheck_registration(unpacked_dir, after, dest)
+
+ shutil.copy2(source_slide, slides_dir / dest)
+
+ source_rels = rels_dir / f"{source}.rels"
+ shared_parts: list[str] = []
+ if source_rels.exists():
+ dest_rels = rels_dir / f"{dest}.rels"
+ shutil.copy2(source_rels, dest_rels)
+ rels_content = dest_rels.read_text(encoding="utf-8")
+ rels_content = RELATIONSHIP_RE.sub(
+ lambda m: "" if NOTES_SLIDE_TYPE_RE.search(m.group(0)) else m.group(0),
+ rels_content,
+ )
+ dest_rels.write_text(rels_content, encoding="utf-8")
+ shared_parts = sorted({
+ t for t in re.findall(r'Type="[^"]*/relationships/(\w+)"', rels_content)
+ if t in SHARED_PART_TYPES
+ })
+
+ _register_slide(unpacked_dir, dest, source, after_rid)
+ if shared_parts:
+ print(
+ f"Note: {dest} shares its {', '.join(shared_parts)} part(s) with {source} "
+ f"(they are referenced, not copied) — editing those parts changes both slides"
+ )
+ return dest
+
+
+def _precheck_registration(unpacked_dir: Path, after: str | None, dest: str) -> str | None:
+ pres_path = unpacked_dir / "ppt" / "presentation.xml"
+ if not pres_path.exists():
+ _die(f"{pres_path} not found — is this an unpacked PPTX?")
+ xml = pres_path.read_text(encoding="utf-8")
+
+ has_slot = (
+ "" in xml
+ or re.search(r"", xml)
+ or "" in xml
+ )
+ if not has_slot:
+ _die("presentation.xml has no (or to anchor a new one)")
+
+ stale = []
+ content_types = unpacked_dir / "[Content_Types].xml"
+ if content_types.exists() and f'PartName="/ppt/slides/{dest}"' in content_types.read_text(encoding="utf-8"):
+ stale.append("[Content_Types].xml")
+ pres_rels = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
+ if pres_rels.exists() and _find_slide_relationship(
+ pres_rels.read_text(encoding="utf-8"), dest
+ ):
+ stale.append("presentation.xml.rels")
+ if stale:
+ _die(
+ f"{dest} is still registered in {' and '.join(stale)} but absent from ppt/slides/ — "
+ f"run clean.py first"
+ )
+
+ if not after:
+ return None
+ after_rid = _rid_for_slide(unpacked_dir, after)
+ if not re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml):
+ _die(f"{after} ({after_rid}) is not listed in ")
+ return after_rid
+
+
+def _register_slide(unpacked_dir: Path, dest: str, source_desc: str, after_rid: str | None) -> None:
+ _add_to_content_types(unpacked_dir, dest)
+ rid = _add_to_presentation_rels(unpacked_dir, dest)
+ slide_id = _get_next_slide_id(unpacked_dir)
+ pos, total = _insert_into_sld_id_lst(unpacked_dir, slide_id, rid, after_rid)
+
+ print(f"Created ppt/slides/{dest} from {source_desc}")
+ print(
+ f'Inserted into '
+ f"at position {pos} of {total}"
+ )
+
+
+def _add_to_content_types(unpacked_dir: Path, dest: str) -> None:
+ content_types_path = unpacked_dir / "[Content_Types].xml"
+ content_types = content_types_path.read_text(encoding="utf-8")
+
+ new_override = f''
+
+ if f'PartName="/ppt/slides/{dest}"' not in content_types:
+ content_types = content_types.replace("", f" {new_override}\n")
+ content_types_path.write_text(content_types, encoding="utf-8")
+
+
+def _add_to_presentation_rels(unpacked_dir: Path, dest: str) -> str:
+ pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
+ pres_rels = pres_rels_path.read_text(encoding="utf-8")
+
+ existing = _find_slide_relationship(pres_rels, dest)
+ if existing:
+ return existing
+
+ pres_xml = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8")
+ used = {int(n) for n in re.findall(r'\bId="rId(\d+)"', pres_rels)}
+ used |= {int(n) for n in re.findall(r'\br:id="rId(\d+)"', pres_xml)}
+ rid = f"rId{max(used) + 1 if used else 1}"
+
+ new_rel = f''
+ pres_rels = pres_rels.replace("", f" {new_rel}\n")
+ pres_rels_path.write_text(pres_rels, encoding="utf-8")
+
+ return rid
+
+
+def _find_slide_relationship(pres_rels: str, slide_name: str) -> str | None:
+ for m in re.finditer(r"]*>", pres_rels):
+ element = m.group(0)
+ if re.search(rf'Target="(?:/ppt/)?slides/{re.escape(slide_name)}"', element):
+ id_match = re.search(r'\bId="([^"]+)"', element)
+ if id_match:
+ return id_match.group(1)
+ return None
+
+
+def _get_next_slide_id(unpacked_dir: Path) -> int:
+ pres_content = (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8")
+ used = {int(m) for m in re.findall(r']*\bid="(\d+)"', pres_content)}
+
+ candidate = max((i for i in used if i >= SLIDE_ID_MIN), default=SLIDE_ID_MIN - 1) + 1
+ if candidate <= SLIDE_ID_MAX and candidate not in used:
+ return candidate
+ for i in range(SLIDE_ID_MIN, SLIDE_ID_MAX + 1):
+ if i not in used:
+ return i
+ _die("no slide id available in [256, 2147483647] — the deck is full")
+
+
+def _insert_into_sld_id_lst(
+ unpacked_dir: Path, slide_id: int, rid: str, after_rid: str | None = None
+) -> tuple[int, int]:
+ pres_path = unpacked_dir / "ppt" / "presentation.xml"
+ xml = pres_path.read_text(encoding="utf-8")
+ entry = f''
+
+ if f'r:id="{rid}"' in xml:
+ _die(f"presentation.xml already references {rid}; refusing to add a duplicate")
+
+ if after_rid:
+ open_tag = re.search(rf']*r:id="{re.escape(after_rid)}"[^>]*>', xml)
+ if not open_tag:
+ _die(f"{after_rid} is not listed in ")
+ end = open_tag.end()
+ if not open_tag.group(0).endswith("/>"):
+ close = xml.find("", end)
+ if close == -1:
+ _die(f"unclosed for {after_rid} in presentation.xml")
+ end = close + len("")
+ xml = xml[:end] + entry + xml[end:]
+ elif "" in xml:
+ xml = xml.replace("", f"{entry}", 1)
+ elif re.search(r"", xml):
+ xml = re.sub(r"", f"{entry}", xml, count=1)
+ elif "" in xml:
+ xml = xml.replace(
+ "", f"{entry}", 1
+ )
else:
- duplicate_slide(unpacked_dir, source)
+ _die("presentation.xml has no (or to anchor a new one)")
+
+ pres_path.write_text(xml, encoding="utf-8")
+
+ lst = re.search(r"(.*)", xml, re.DOTALL)
+ entries = re.findall(r"]*>", lst.group(1)) if lst else []
+ position = next(
+ (i for i, e in enumerate(entries, 1) if f'r:id="{rid}"' in e), len(entries)
+ )
+ return position, len(entries)
+
+
+def _rid_for_slide(unpacked_dir: Path, slide_name: str) -> str:
+ pres_rels_path = unpacked_dir / "ppt" / "_rels" / "presentation.xml.rels"
+ rid = _find_slide_relationship(pres_rels_path.read_text(encoding="utf-8"), slide_name)
+ if not rid:
+ _die(f"{slide_name} has no relationship in presentation.xml.rels")
+ return rid
+
+
+def add_slide(unpacked_dir: Path, source: str, after: str | None = None) -> str:
+ source_type, layout_file = parse_source(source)
+ if source_type == "layout" and layout_file is not None:
+ return create_slide_from_layout(unpacked_dir, layout_file, after)
+ return duplicate_slide(unpacked_dir, source, after)
+
+
+def add_slide_to_package(
+ package: Path, source: str, after: str | None = None, output: Path | None = None
+) -> str:
+ out = output or package
+ with tempfile.TemporaryDirectory() as tmp:
+ tmp_path = Path(tmp)
+ with zipfile.ZipFile(package) as zf:
+ safe_extract(zf, tmp_path)
+ dest = add_slide(tmp_path, source, after)
+ rezip(tmp_path, out)
+ print(f"Wrote {out} — the new slide is ppt/slides/{dest} inside it (unpack to edit its content)")
+ return dest
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(
+ description="Add a slide to a PPTX: duplicate a slide or instantiate a layout. "
+ "Registers content types, relationships, and ."
+ )
+ parser.add_argument("target", help="Unpacked PPTX directory OR a .pptx/.potx file")
+ parser.add_argument(
+ "source",
+ help="slideN.xml to duplicate, or slideLayoutN.xml to create from a layout "
+ "(list layouts with: ls /ppt/slideLayouts/)",
+ )
+ parser.add_argument(
+ "--after",
+ metavar="SLIDE",
+ help="insert after this slide, e.g. slide2.xml (default: append at the end)",
+ )
+ parser.add_argument(
+ "-o",
+ "--output",
+ help="output file (only with a .pptx/.potx target; default: rewrite the input in place)",
+ )
+ args = parser.parse_args()
+
+ target = Path(args.target)
+ if target.is_dir():
+ if args.output:
+ parser.error("--output is only valid for .pptx/.potx input; a directory is modified in place")
+ add_slide(target, args.source, args.after)
+ elif target.is_file() and target.suffix.lower() in (".pptx", ".potx"):
+ try:
+ add_slide_to_package(target, args.source, args.after, Path(args.output) if args.output else None)
+ except (OSError, ValueError, zipfile.BadZipFile) as e:
+ _die(str(e))
+ else:
+ _die(f"{target} is neither a directory nor a .pptx/.potx file")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py
old mode 100644
new mode 100755
index 3d13994c..551dd231
--- a/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py
+++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/clean.py
@@ -15,13 +15,30 @@ This script removes:
- Content-Type overrides for deleted files
"""
+import posixpath
+import re
import sys
from pathlib import Path
import defusedxml.minidom
+from office.helpers import SLIDE_REL_TYPE, opc_target, rels_source_part
-import re
+
+def _slide_rids(pres_rels_path: Path, unpacked_dir: Path) -> dict[str, str]:
+ source_part = rels_source_part(pres_rels_path, unpacked_dir)
+ rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
+
+ rids: dict[str, str] = {}
+ for rel in rels_dom.getElementsByTagName("Relationship"):
+ if rel.getAttribute("Type") != SLIDE_REL_TYPE:
+ continue
+ part = opc_target(
+ rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
+ )
+ if part is not None:
+ rids[rel.getAttribute("Id")] = part
+ return rids
def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
@@ -31,19 +48,20 @@ def get_slides_in_sldidlst(unpacked_dir: Path) -> set[str]:
if not pres_path.exists() or not pres_rels_path.exists():
return set()
- rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
- rid_to_slide = {}
- for rel in rels_dom.getElementsByTagName("Relationship"):
- rid = rel.getAttribute("Id")
- target = rel.getAttribute("Target")
- rel_type = rel.getAttribute("Type")
- if "slide" in rel_type and target.startswith("slides/"):
- rid_to_slide[rid] = target.replace("slides/", "")
+ rid_to_slide = _slide_rids(pres_rels_path, unpacked_dir)
pres_content = pres_path.read_text(encoding="utf-8")
referenced_rids = set(re.findall(r']*r:id="([^"]+)"', pres_content))
- return {rid_to_slide[rid] for rid in referenced_rids if rid in rid_to_slide}
+ return {
+ posixpath.basename(rid_to_slide[rid])
+ for rid in referenced_rids
+ if rid in rid_to_slide
+ }
+
+
+class RefusedToClean(Exception):
+ """The package does not look the way a readable package should."""
def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
@@ -55,9 +73,25 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
return []
referenced_slides = get_slides_in_sldidlst(unpacked_dir)
+ on_disk = sorted(slides_dir.glob("slide*.xml"))
+
+ if on_disk and not any(s.name in referenced_slides for s in on_disk):
+ listed = re.findall(
+ r']*r:id="([^"]+)"',
+ (unpacked_dir / "ppt" / "presentation.xml").read_text(encoding="utf-8")
+ if (unpacked_dir / "ppt" / "presentation.xml").exists()
+ else "",
+ )
+ if listed:
+ raise RefusedToClean(
+ f" lists {len(listed)} slide(s) and none of the "
+ f"{len(on_disk)} slide(s) on disk match any of them. Refusing to "
+ f"delete them all — this is a parse failure, not an empty deck."
+ )
+
removed = []
- for slide_file in slides_dir.glob("slide*.xml"):
+ for slide_file in on_disk:
if slide_file.name not in referenced_slides:
rel_path = slide_file.relative_to(unpacked_dir)
slide_file.unlink()
@@ -70,16 +104,21 @@ def remove_orphaned_slides(unpacked_dir: Path) -> list[str]:
if removed and pres_rels_path.exists():
rels_dom = defusedxml.minidom.parse(str(pres_rels_path))
+ source_part = rels_source_part(pres_rels_path, unpacked_dir)
changed = False
for rel in list(rels_dom.getElementsByTagName("Relationship")):
- target = rel.getAttribute("Target")
- if target.startswith("slides/"):
- slide_name = target.replace("slides/", "")
- if slide_name not in referenced_slides:
- if rel.parentNode:
- rel.parentNode.removeChild(rel)
- changed = True
+ if rel.getAttribute("Type") != SLIDE_REL_TYPE:
+ continue
+ part = opc_target(
+ rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
+ )
+ if part is None:
+ continue
+ if posixpath.basename(part) not in referenced_slides:
+ if rel.parentNode:
+ rel.parentNode.removeChild(rel)
+ changed = True
if changed:
with open(pres_rels_path, "wb") as f:
@@ -103,24 +142,18 @@ def remove_trash_directory(unpacked_dir: Path) -> list[str]:
return removed
-def get_slide_referenced_files(unpacked_dir: Path) -> set:
+def _referenced_by(rels_files, unpacked_dir: Path) -> set:
referenced = set()
- slides_rels_dir = unpacked_dir / "ppt" / "slides" / "_rels"
- if not slides_rels_dir.exists():
- return referenced
-
- for rels_file in slides_rels_dir.glob("*.rels"):
+ for rels_file in rels_files:
+ source_part = rels_source_part(rels_file, unpacked_dir)
dom = defusedxml.minidom.parse(str(rels_file))
for rel in dom.getElementsByTagName("Relationship"):
- target = rel.getAttribute("Target")
- if not target:
- continue
- target_path = (rels_file.parent.parent / target).resolve()
- try:
- referenced.add(target_path.relative_to(unpacked_dir.resolve()))
- except ValueError:
- pass
+ part = opc_target(
+ rel.getAttribute("Target"), source_part, rel.getAttribute("TargetMode")
+ )
+ if part is not None:
+ referenced.add(Path(part))
return referenced
@@ -128,7 +161,6 @@ def get_slide_referenced_files(unpacked_dir: Path) -> set:
def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
resource_dirs = ["charts", "diagrams", "drawings"]
removed = []
- slide_referenced = get_slide_referenced_files(unpacked_dir)
for dir_name in resource_dirs:
rels_dir = unpacked_dir / "ppt" / dir_name / "_rels"
@@ -137,35 +169,15 @@ def remove_orphaned_rels_files(unpacked_dir: Path) -> list[str]:
for rels_file in rels_dir.glob("*.rels"):
resource_file = rels_dir.parent / rels_file.name.replace(".rels", "")
- try:
- resource_rel_path = resource_file.resolve().relative_to(unpacked_dir.resolve())
- except ValueError:
- continue
-
- if not resource_file.exists() or resource_rel_path not in slide_referenced:
+ if not resource_file.exists():
rels_file.unlink()
- rel_path = rels_file.relative_to(unpacked_dir)
- removed.append(str(rel_path))
+ removed.append(str(rels_file.relative_to(unpacked_dir)))
return removed
def get_referenced_files(unpacked_dir: Path) -> set:
- referenced = set()
-
- for rels_file in unpacked_dir.rglob("*.rels"):
- dom = defusedxml.minidom.parse(str(rels_file))
- for rel in dom.getElementsByTagName("Relationship"):
- target = rel.getAttribute("Target")
- if not target:
- continue
- target_path = (rels_file.parent.parent / target).resolve()
- try:
- referenced.add(target_path.relative_to(unpacked_dir.resolve()))
- except ValueError:
- pass
-
- return referenced
+ return _referenced_by(sorted(unpacked_dir.rglob("*.rels")), unpacked_dir)
def remove_orphaned_files(unpacked_dir: Path, referenced: set) -> list[str]:
@@ -241,6 +253,12 @@ def update_content_types(unpacked_dir: Path, removed_files: list[str]) -> None:
def clean_unused_files(unpacked_dir: Path) -> list[str]:
all_removed = []
+ if list(unpacked_dir.rglob("*.rels")) and not get_referenced_files(unpacked_dir):
+ raise RefusedToClean(
+ "no relationship in this package names a part we can resolve. "
+ "Refusing to treat every file as unreferenced."
+ )
+
slides_removed = remove_orphaned_slides(unpacked_dir)
all_removed.extend(slides_removed)
@@ -276,7 +294,12 @@ if __name__ == "__main__":
print(f"Error: {unpacked_dir} not found", file=sys.stderr)
sys.exit(1)
- removed = clean_unused_files(unpacked_dir)
+ try:
+ removed = clean_unused_files(unpacked_dir)
+ except (RefusedToClean, ValueError) as e:
+ print(f"Error: {e}", file=sys.stderr)
+ print("Nothing was deleted.", file=sys.stderr)
+ sys.exit(1)
if removed:
print(f"Removed {len(removed)} unreferenced files:")
diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py
index e69de29b..d3c5817c 100644
--- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py
+++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/__init__.py
@@ -0,0 +1,111 @@
+import os
+import posixpath
+import re
+import stat
+import tempfile
+import urllib.parse
+import zipfile
+from pathlib import Path
+
+OOXML_FAMILY = {
+ ".docx": "docx",
+ ".dotx": "docx",
+ ".pptx": "pptx",
+ ".potx": "pptx",
+ ".xlsx": "xlsx",
+ ".xltx": "xlsx",
+}
+
+_SCHEME_RE = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:")
+
+SLIDE_REL_TYPE = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide"
+
+
+def opc_target(target: str, source_part: str, target_mode: str = "") -> str | None:
+ if not target:
+ return None
+ if target_mode.lower() == "external":
+ return None
+ if _SCHEME_RE.match(target):
+ return None
+
+ target = urllib.parse.unquote(target)
+
+ if "\\" in target:
+ raise ValueError(f"relationship target is not a POSIX part name: {target!r}")
+
+ if target.startswith("/"):
+ joined = target.lstrip("/")
+ else:
+ joined = posixpath.join(posixpath.dirname(source_part), target)
+
+ parts: list[str] = []
+ for segment in posixpath.normpath(joined).split("/"):
+ if segment in ("", "."):
+ continue
+ if segment == "..":
+ if not parts:
+ raise ValueError(f"relationship target escapes the package: {target!r}")
+ parts.pop()
+ else:
+ parts.append(segment)
+
+ if not parts:
+ raise ValueError(f"relationship target resolves to nothing: {target!r}")
+ return "/".join(parts)
+
+
+def rels_source_part(rels_file: Path, unpacked_dir: Path) -> str:
+ owner_dir = rels_file.parent.parent.relative_to(unpacked_dir)
+ return posixpath.join(owner_dir.as_posix(), rels_file.name[: -len(".rels")]).lstrip("./")
+
+
+def part_text(data: bytes) -> str:
+ return data.decode("utf-8", "surrogateescape")
+
+
+XML_SPACE = " \t\r\n"
+
+
+def rendered_text(text: str, preserve: bool) -> str:
+ return text if preserve else text.strip(XML_SPACE)
+
+
+def safe_extract(zf: zipfile.ZipFile, dest: Path) -> None:
+ dest = dest.resolve()
+ for m in zf.infolist():
+ if stat.S_ISLNK(m.external_attr >> 16):
+ raise ValueError(f"symlink archive entry not allowed: {m.filename!r}")
+ target = (dest / m.filename).resolve()
+ if not target.is_relative_to(dest):
+ raise ValueError(f"unsafe archive entry: {m.filename!r}")
+ zf.extract(m, dest)
+
+
+def rezip(src_dir: Path, out_path: Path) -> None:
+ files = sorted(p for p in src_dir.rglob("*") if p.is_file())
+ ct = src_dir / "[Content_Types].xml"
+ fd, tmp_name = tempfile.mkstemp(
+ prefix=out_path.name + ".", suffix=".tmp", dir=out_path.parent
+ )
+ tmp_out = Path(tmp_name)
+ try:
+ with os.fdopen(fd, "wb") as fh:
+ with zipfile.ZipFile(fh, "w", zipfile.ZIP_DEFLATED) as zf:
+ if ct.exists():
+ zf.write(ct, ct.relative_to(src_dir), compress_type=zipfile.ZIP_STORED)
+ for f in files:
+ if f == ct:
+ continue
+ zf.write(f, f.relative_to(src_dir))
+ if out_path.exists():
+ mode = out_path.stat().st_mode & 0o777
+ else:
+ umask = os.umask(0)
+ os.umask(umask)
+ mode = 0o666 & ~umask
+ os.chmod(tmp_out, mode)
+ os.replace(tmp_out, out_path)
+ finally:
+ if tmp_out.exists():
+ tmp_out.unlink()
diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py
deleted file mode 100644
index ad7c25ee..00000000
--- a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/merge_runs.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""Merge adjacent runs with identical formatting in DOCX.
-
-Merges adjacent elements that have identical properties.
-Works on runs in paragraphs and inside tracked changes (, ).
-
-Also:
-- Removes rsid attributes from runs (revision metadata that doesn't affect rendering)
-- Removes proofErr elements (spell/grammar markers that block merging)
-"""
-
-from pathlib import Path
-
-import defusedxml.minidom
-
-
-def merge_runs(input_dir: str) -> tuple[int, str]:
- doc_xml = Path(input_dir) / "word" / "document.xml"
-
- if not doc_xml.exists():
- return 0, f"Error: {doc_xml} not found"
-
- try:
- dom = defusedxml.minidom.parseString(doc_xml.read_text(encoding="utf-8"))
- root = dom.documentElement
-
- _remove_elements(root, "proofErr")
- _strip_run_rsid_attrs(root)
-
- containers = {run.parentNode for run in _find_elements(root, "r")}
-
- merge_count = 0
- for container in containers:
- merge_count += _merge_runs_in(container)
-
- doc_xml.write_bytes(dom.toxml(encoding="UTF-8"))
- return merge_count, f"Merged {merge_count} runs"
-
- except Exception as e:
- return 0, f"Error: {e}"
-
-
-
-
-def _find_elements(root, tag: str) -> list:
- results = []
-
- def traverse(node):
- if node.nodeType == node.ELEMENT_NODE:
- name = node.localName or node.tagName
- if name == tag or name.endswith(f":{tag}"):
- results.append(node)
- for child in node.childNodes:
- traverse(child)
-
- traverse(root)
- return results
-
-
-def _get_child(parent, tag: str):
- for child in parent.childNodes:
- if child.nodeType == child.ELEMENT_NODE:
- name = child.localName or child.tagName
- if name == tag or name.endswith(f":{tag}"):
- return child
- return None
-
-
-def _get_children(parent, tag: str) -> list:
- results = []
- for child in parent.childNodes:
- if child.nodeType == child.ELEMENT_NODE:
- name = child.localName or child.tagName
- if name == tag or name.endswith(f":{tag}"):
- results.append(child)
- return results
-
-
-def _is_adjacent(elem1, elem2) -> bool:
- node = elem1.nextSibling
- while node:
- if node == elem2:
- return True
- if node.nodeType == node.ELEMENT_NODE:
- return False
- if node.nodeType == node.TEXT_NODE and node.data.strip():
- return False
- node = node.nextSibling
- return False
-
-
-
-
-def _remove_elements(root, tag: str):
- for elem in _find_elements(root, tag):
- if elem.parentNode:
- elem.parentNode.removeChild(elem)
-
-
-def _strip_run_rsid_attrs(root):
- for run in _find_elements(root, "r"):
- for attr in list(run.attributes.values()):
- if "rsid" in attr.name.lower():
- run.removeAttribute(attr.name)
-
-
-
-
-def _merge_runs_in(container) -> int:
- merge_count = 0
- run = _first_child_run(container)
-
- while run:
- while True:
- next_elem = _next_element_sibling(run)
- if next_elem and _is_run(next_elem) and _can_merge(run, next_elem):
- _merge_run_content(run, next_elem)
- container.removeChild(next_elem)
- merge_count += 1
- else:
- break
-
- _consolidate_text(run)
- run = _next_sibling_run(run)
-
- return merge_count
-
-
-def _first_child_run(container):
- for child in container.childNodes:
- if child.nodeType == child.ELEMENT_NODE and _is_run(child):
- return child
- return None
-
-
-def _next_element_sibling(node):
- sibling = node.nextSibling
- while sibling:
- if sibling.nodeType == sibling.ELEMENT_NODE:
- return sibling
- sibling = sibling.nextSibling
- return None
-
-
-def _next_sibling_run(node):
- sibling = node.nextSibling
- while sibling:
- if sibling.nodeType == sibling.ELEMENT_NODE:
- if _is_run(sibling):
- return sibling
- sibling = sibling.nextSibling
- return None
-
-
-def _is_run(node) -> bool:
- name = node.localName or node.tagName
- return name == "r" or name.endswith(":r")
-
-
-def _can_merge(run1, run2) -> bool:
- rpr1 = _get_child(run1, "rPr")
- rpr2 = _get_child(run2, "rPr")
-
- if (rpr1 is None) != (rpr2 is None):
- return False
- if rpr1 is None:
- return True
- return rpr1.toxml() == rpr2.toxml()
-
-
-def _merge_run_content(target, source):
- for child in list(source.childNodes):
- if child.nodeType == child.ELEMENT_NODE:
- name = child.localName or child.tagName
- if name != "rPr" and not name.endswith(":rPr"):
- target.appendChild(child)
-
-
-def _consolidate_text(run):
- t_elements = _get_children(run, "t")
-
- for i in range(len(t_elements) - 1, 0, -1):
- curr, prev = t_elements[i], t_elements[i - 1]
-
- if _is_adjacent(prev, curr):
- prev_text = prev.firstChild.data if prev.firstChild else ""
- curr_text = curr.firstChild.data if curr.firstChild else ""
- merged = prev_text + curr_text
-
- if prev.firstChild:
- prev.firstChild.data = merged
- else:
- prev.appendChild(run.ownerDocument.createTextNode(merged))
-
- if merged.startswith(" ") or merged.endswith(" "):
- prev.setAttribute("xml:space", "preserve")
- elif prev.hasAttribute("xml:space"):
- prev.removeAttribute("xml:space")
-
- run.removeChild(curr)
diff --git a/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py
new file mode 100644
index 00000000..209cb7c5
--- /dev/null
+++ b/plugins/codex/plugins/pptx/skills/pptx/scripts/office/helpers/pptx_chart.py
@@ -0,0 +1,170 @@
+"""Find chart XML that PowerPoint refuses but the schema accepts.
+
+Detection only: for either fault more than one repair is valid, and only the
+author knows which was meant.
+"""
+
+
+from __future__ import annotations
+
+import re
+from typing import Mapping
+
+from . import part_text
+
+
+_CHART_PART_RE = re.compile(r"ppt/charts/chart\d+\.xml")
+
+_GROUPING_RE = re.compile(r"""]*?\bval=["'](\w+)["']""")
+_DLBL_POS_RE = re.compile(r"""]*?\bval=["'](\w+)["']""")
+
+def _strip_ext_lst(text: str) -> str:
+ out, cursor = [], 0
+ for lo, hi in _ext_lst_spans(text):
+ out.append(text[cursor:lo])
+ cursor = hi
+ out.append(text[cursor:])
+ return "".join(out)
+
+_BAR_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL)
+
+STACKED_GROUPINGS = frozenset({"stacked", "percentStacked"})
+ILLEGAL_ON_STACKED = frozenset({"outEnd"})
+LEGAL_ON_STACKED = ("ctr", "inEnd", "inBase")
+
+
+def _check_stacked_label_positions(part: str, xml: str) -> list[str]:
+ problems: list[str] = []
+ for match in _BAR_GROUP_RE.finditer(xml):
+ block = _strip_ext_lst(match.group(0))
+ group = match.group(1)
+
+ grouping = _GROUPING_RE.search(block)
+ if grouping is None or grouping.group(1) not in STACKED_GROUPINGS:
+ continue
+
+ bad = [p for p in _DLBL_POS_RE.findall(block) if p in ILLEGAL_ON_STACKED]
+ for pos in sorted(set(bad)):
+ problems.append(
+ f'{part}: {bad.count(pos)} data label(s) use dLblPos="{pos}" on a '
+ f"{grouping.group(1)} {group}; PowerPoint allows only "
+ f"{', '.join(LEGAL_ON_STACKED)} there"
+ )
+ return problems
+
+
+
+_ANY_CHART_GROUP_RE = re.compile(r"]*(?.*?", re.DOTALL)
+
+_AXID_RE = re.compile(
+ r"""\s*]*?\bval=["'](-?\d+)["']\s*(?:/>|>\s*)"""
+)
+
+_AXIS_DECL_RE = re.compile(
+ r"""]*(?\s*]*?\bval=["'](-?\d+)["']"""
+)
+
+AXID_LIMIT = {
+ "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2,
+ "bubbleChart": 2, "radarChart": 2, "stockChart": 2,
+ "bar3DChart": 3, "line3DChart": 3, "area3DChart": 3,
+ "surfaceChart": 3, "surface3DChart": 3,
+}
+
+AXID_MINIMUM = {
+ "barChart": 2, "lineChart": 2, "areaChart": 2, "scatterChart": 2,
+ "bubbleChart": 2, "radarChart": 2, "stockChart": 2,
+ "bar3DChart": 2, "area3DChart": 2, "surfaceChart": 2,
+ "line3DChart": 3, "surface3DChart": 3,
+}
+
+
+def _declared_axes(xml: str) -> dict[str, list[str]]:
+ axes: dict[str, list[str]] = {}
+ for kind, axid in _AXIS_DECL_RE.findall(xml):
+ axes.setdefault(kind, []).append(axid)
+ return axes
+
+
+def _canonical_ids(axes: dict[str, list[str]], limit: int) -> list[str] | None:
+ category = axes.get("catAx", []) + axes.get("dateAx", [])
+ value = axes.get("valAx", [])
+ series = axes.get("serAx", [])
+ if len(category) != 1 or len(value) != 1 or len(series) > 1:
+ return None
+ ids = [category[0], value[0]]
+ if limit >= 3 and series:
+ ids.append(series[0])
+ return ids
+
+
+def _undeclared_axes(kind: str, block: str, axes: dict[str, list[str]]) -> list[str] | None:
+ if kind not in AXID_LIMIT:
+ return None
+ ids = _AXID_RE.findall(block)
+ declared = {i for group in axes.values() for i in group}
+ if len([i for i in ids if i in declared]) >= 2:
+ return None
+ return ids
+
+
+def _check_chart_axis_references(part: str, xml: str) -> list[str]:
+ axes = _declared_axes(xml)
+ problems: list[str] = []
+ declared = {i for group in axes.values() for i in group}
+ for match in _ANY_CHART_GROUP_RE.finditer(xml):
+ kind, block = match.group(1), match.group(0)
+ ids = _undeclared_axes(kind, block, axes)
+ if ids is None:
+ continue
+ if not ids:
+ problems.append(
+ f"{part}: declares no this part can resolve; a chart "
+ f"group needs {AXID_MINIMUM[kind]}, and PowerPoint discards one with fewer"
+ )
+ continue
+ dead = [i for i in ids if i not in declared]
+ canonical = _canonical_ids(axes, AXID_LIMIT[kind])
+ if canonical is not None and len(canonical) >= AXID_MINIMUM[kind]:
+ hint = f"Fix: point them at the axes this part declares ({', '.join(canonical)})"
+ else:
+ hint = ("Fix: the part declares several axes of a kind -- declare the "
+ "secondary axes the series expects, or drop them")
+ detail = (f"of which {', '.join(dead)} name no declared axis"
+ if dead else f"only {len(ids)} of which this part declares")
+ problems.append(
+ f"{part}: