dsh-anydoc-markdown
Document → Markdown + vision image description plugin for DeepSeek Harness (dsh). Converts Word/PPT/Excel/ODT/RTF/EPUB/CSV/PDF via firecrawl-anydoc and describes embedded images with a VLM.
- Stars
- 1
- Language
- Python
- Created
- Aug 29, 2026
- Updated
- Aug 31, 2026
Introduction
dsh-anydoc-markdown
Document → Markdown + vision image description plugin for DeepSeek Harness (dsh).
Note: This plugin has only been tested against Deepseek harness version v0.1.1-rc.2. It might not work on the latest version. Ask agent to modify the plugin if it is incompatible with your version.
Converts documents (Word, PowerPoint, Excel, OpenDocument, RTF, EPUB, CSV, PDF)
into clean GitHub-Flavored Markdown using the Rust
firecrawl-anydoc crate, and describes
every embedded image with a vision model so a text-only model can reason about
the document's imagery.
Design target. anydoc is a pure local, dependency-free Rust converter: its core has no network calls and no
llm_clientparameter. But it exposesanydoc.to_document(), which returns the full document model plus the embedded binary assets (images, MIME types, and alt-text markers) onDocument.assets. This plugin replicates the MarkItDownllm_client+llm_modelpattern for anydoc: intercept the image assets, pass them to a VLM (OpenAI'sgpt-4oor a local OpenAI-compatible model), and substitute the image placeholders in the output Markdown.
Features
- One shared Markdown output for every format — anydoc funnels every format through one document model and GFM serializer, so headings, tables, lists, equations, links, footnotes, and speaker notes come out consistently.
- Embedded-image description (vision pipeline) — images are described by a
VLM and substituted into the Markdown as
. Images the converter drops (empty alt text) are appended as a described list. - Batched vision requests (the important part) — images are never all sent
in one API request. They are split into batches of
maxImagesPerRequest(default 10) and each batch is a separate request, so vision performance is not degraded by a giant multi-image prompt. Configurable. convert_documenttool for agents — the agent converts any document to Markdown on demand (offset/limit-free; returns the full Markdown, capped).- Composer document attach (native drop + dock strip) — documents attach through the same whole-page drag the harness natively uses for images (there is no composer tool-row paperclip button and no competing drag overlay, which previously froze the harness's own drop overlay). Dropped documents show as attachment cards in a slim strip in the composer's input dock, above the composer card. Documents are stored and the agent is handed the source file path; images keep the harness's built-in image path.
- Extracted images saved as PNG — when
markdownOutputDiris set,convert_documentcreates a subfolder there named after the Markdown file (e.g.report/) and saves both the.mdfile and every extracted embedded image as a PNG inside it. The Markdown references those on-disk PNGs by relative filename, so the.mdand its images are portable together. - Agent-driven conversion — the plugin never injects the converted Markdown.
It hands the model the saved source file path(s) and lets the agent call
convert_documenton each. The converter saves the Markdown (and its images) tomarkdownOutputDirand returns the.mdfile path. - Offline-first — when no VLM endpoint/model is configured, the converter still produces Markdown and still batches, using an honest offline metadata description for each image. No network call, no API key required.
- Self-contained — the host half has no
@deepseek-ai/*imports (always loads), and the client half rides the standard__ModuleLoader__slot/RPC seams.
Install
pip install -r converter/requirements.txt # firecrawl-anydoc + pdfplumber + Pillow
dsh plugin --profile web add dsh-anydoc-markdown
# restart dsh web
pdfplumber + Pillow are only needed to describe images embedded in PDFs.
If they're unavailable, PDFs still convert (text only) and the rest of the vision
pipeline is unaffected.
Usage
- The agent calls the
convert_documenttool with the path to a document:convert_document(file_path: "report.pptx"). - The plugin runs the bundled Python wrapper (
converter/anydoc_vlm.py) againstanydoc, then describes every embedded image via the VLM in batches. - The tool returns the Markdown (with image descriptions substituted) to the model.
Composer document attach (native drop / multi-file)
Documents attach through the same whole-page drag the harness uses for
images: drag any number of documents (.docx .pdf .pptx .xlsx .odt .rtf .epub .csv .txt .md .html) onto the page and release. The harness's own drop overlay
(the one that says "Drag images and documents here to add them") invites the
drop, and the plugin shows the attached documents as cards in the composer's
input dock strip (a line above the composer card). Images dropped at the same
time keep using the harness's built-in image attach.
- Drag documents onto the page (or drop them) → they stream to the host's
attach.uploadRPC channel (/dsh-anydocby default). Each uploaded document is written toattachmentsDirand shown as a card (name + size + remove) in the dock strip. The cards clear automatically once you submit the message — the documents are already saved to disk, so the agent still gets their paths on the next step. - On the next agent step, the plugin hands the model the saved source file
path(s) as a model-visible pre-step message ("Use
convert_documenton each path below"). It never injects the converted Markdown. - The agent calls
convert_document(file_path: "<source path>")on each. The tool converts the document, saves the Markdown tomarkdownOutputDir, and returns the.mdfile path (savedPath) alongside the Markdown. - Failed/oversized/unsupported files are reported individually and never break the rest of a batch.
Configuration
All fields have sensible defaults — install and use without touching any. Tune only what you need. Every knob is a validated
cordis.patch.ymlfield; none are hardcoded.
| Field | Default | Description |
|---|---|---|
converterPython | python | Python interpreter used to run the bundled wrapper. |
converterPath | converter/anydoc_vlm.py | Path to the bundled wrapper (in this dir). |
maxImagesPerRequest | 10 | Max images per vision request. Setup requirement — never lump all images into one request. |
maxChars | 60000 | Markdown truncation cap returned to the model. |
timeoutMs | 120000 | Cooperative tool-call timeout budget. |
visionEndpoint | '' | OpenAI-compatible vision endpoint; empty = offline metadata descriptions. |
visionModel | '' | Vision model id (e.g. gpt-4o); empty = offline metadata descriptions. |
visionApiKeyEnv | OPENAI_API_KEY | Env var supplying the vision API key. |
attachmentChannel | /dsh-anydoc | Generic RPC channel the composer uses to attach documents. |
attachmentsDir | <DSH_HOME>/attachments | Directory uploaded SOURCE documents are written to; the agent is handed these paths. |
markdownOutputDir | '' | Directory where convert_document creates a subfolder named after the Markdown file (e.g. report/) and saves both the .md file and every extracted embedded image (as a PNG) inside it, returning the .md file path (savedPath) plus imageDir/savedImages. Empty = return the Markdown inline and save nothing. |
maxFileBytes | 26214400 (25 MiB) | Per-upload byte cap for document attachments (decoded size). |
allowedAttachExtensions | ['.docx','.doc','.pdf','.pptx','.ppt','.xlsx','.xls','.odt','.rtf','.epub','.csv','.txt','.md','.html','.htm'] | Document extensions the composer accepts. |
- id: dsh-anydoc-markdown
config:
maxImagesPerRequest: 10
visionEndpoint: https://api.openai.com/v1
visionModel: gpt-4o
How images are handled
| Detected image | What happens |
|---|---|
Embedded asset (bytes in Document.assets) | described by the VLM and substituted as . When markdownOutputDir is set it is also re-encoded to a PNG and saved into the per-document subfolder, and the reference points at that file. |
| Embedded, empty alt (anydoc drops it) | appended as a described reference list at the end. Also persisted as a PNG when markdownOutputDir is set. |
| PDF embedded raster | PDFs have no anydoc document model, so each raster is recovered from the PDF bytes with pdfplumber, re-encoded, and appended as a described reference list. Also persisted as a PNG when markdownOutputDir is set. |
| External URL image | left as an ordinary Markdown image. |
The VLM call is OpenAI-compatible; point visionEndpoint/visionModel at any
endpoint speaking the chat-completions wire format (OpenAI, a local
Ollama VL model, an OpenAI-compatible proxy, etc.). With
visionEndpoint: '' (default) the wrapper uses offline metadata descriptions.
Vision pipeline (converter details)
The Python wrapper converter/anydoc_vlm.py is where
the conversion + image description happens (see
converter/README.md for the full write-up):
- Batched requests (the hard requirement). Images are never all sent in one
API request — that degrades vision performance.
describe_many()slices them into groups ofmaxImagesPerRequest(default 10) and issues one chat-completions request per group, so 23 images yield[10, 10, 3]across 3 requests rather than one giant multi-image prompt. - Retries + tolerant parsing (fail-closed). Each batch is retried up to 3
times (with backoff) on an HTTP error, a timeout, or a response that isn't a
JSON array of exactly
Ndescriptions. Array responses wrapped in prose or a fenced JSON block are still parsed. Only if every retry fails does that batch degrade to an offline metadata description — and the note then states why (vision request failed after retries/vision unavailable after retries) instead of claiming the model is unconfigured. - PDF support. PDFs carry no anydoc document model, so their embedded raster
images are recovered from the PDF bytes with
pdfplumberand described the same way. Needspdfplumber+Pillow; fails closed to text-only Markdown if they're missing. Scanned pages need--ocr hosted(Firecrawl Parse). - Offline-first, honest metadata. With no endpoint/model configured, the
wrapper still produces Markdown and still batches, using an honest offline
metadata description per image (
[Offline metadata description - no vision model was configured.]). No network call, no key required.
Converter CLI
python converter/anydoc_vlm.py <path> \
[--format NAME] [--ocr reject|hosted] [--max-images N] \
[--vision-endpoint URL] [--vision-model MODEL] [--api-key KEY] \
[--save-images-dir DIR] [--mock-vision] [--json] [--out FILE]
| Option | Default | Description |
|---|---|---|
--format | auto | Explicit format name (only needed for signature-less formats like CSV). |
--ocr | reject | hosted routes scanned PDF pages to Firecrawl Parse. |
--max-images | 10 | Max images per vision request. |
--vision-endpoint | env ANYDOC_VISION_ENDPOINT | OpenAI-compatible vision endpoint. |
--vision-model | env ANYDOC_VISION_MODEL | Vision model id (e.g. gpt-4o). |
--api-key | env OPENAI_API_KEY | Vision API key. |
--save-images-dir | unset | Persist every extracted embedded image as a PNG in this directory and rewrite the Markdown image references to point at it. |
--mock-vision | off | Force the offline description (for testing). |
--json | off | Emit {"markdown", "meta"} instead of plain Markdown. |
--out | stdout | Write the Markdown to a file. |
Verify the batching with the bundled test document (23 images, max 10):
python converter/anydoc_vlm.py tests/multi.docx --mock-vision --max-images 10 --json
# vision_batch_sizes: [10, 10, 3], vision_requests: 3
Architecture
dsh-anydoc-markdown/
├── index.js # host plugin: `convert_document` tool + `attach.upload` RPC channel
├── lib/client.js # web client: document drop + dock-strip cards (no tool-row button/overlay)
├── cordis.patch.yml # bundle config (all knobs)
├── package.json # dsh.bundle.patch + dsh.client, files
├── converter/
│ ├── anydoc_vlm.py # anydoc + batched-vision engine (the "llm_client" seam)
│ ├── requirements.txt # firecrawl-anydoc + pdfplumber + Pillow (PDF images)
│ └── README.md # wrapper docs + how the batching works
└── README.md
The Python wrapper is where the real work happens (see
converter/README.md): anydoc.to_document() yields the
embedded assets, VisionClient.describe_many() batches them (≤ 10 per request),
and substitute() swaps the image placeholders for the descriptions.
Development
python converter/anydoc_vlm.py tests/multi.docx --mock-vision --max-images 10 --json
node --check index.js
python -m py_compile converter/anydoc_vlm.py
Security & notes
- anydoc runs conversion locally; only scanned PDF pages opted into
--ocr hostedleave the machine (Firecrawl Parse). The vision call itself goes only to the configuredvisionEndpoint. - The plugin fails closed: if the interpreter is missing, anydoc isn't installed, or a conversion errors, that call fails loudly while the harness keeps working the way dsh-file-upload's design does.
- Uploaded documents are written to
attachmentsDir(persistent) and the converted Markdown (plus its extracted image PNGs) to a per-document subfolder insidemarkdownOutputDir; both are configurable and written only when you configure them. Filenames are sanitized (no path traversal), non-allowlisted extensions are rejected, and files are capped bymaxFileBytes.
License
MIT