From be89500248d139d530ae920cbf32ff7300affe55 Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 07:22:12 -0500 Subject: [PATCH 1/7] feat: add rich document reader plugin --- .github/workflows/test.yml | 26 + .gitignore | 4 + AGENTS.md | 17 + CLAUDE.md | 1 + README.md | 4 +- package-lock.json | 1003 +++++++++++++++++++++++++++++++ package.json | 49 ++ src/index.ts | 23 + src/limits.ts | 19 + src/media.ts | 164 +++++ src/path-safety.ts | 101 ++++ src/reader.ts | 273 +++++++++ src/registry.ts | 29 + src/types.ts | 70 +++ test/fixtures.ts | 140 +++++ test/read-rich-document.test.ts | 153 +++++ tsconfig.json | 14 + 17 files changed, 2088 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 120000 CLAUDE.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/limits.ts create mode 100644 src/media.ts create mode 100644 src/path-safety.ts create mode 100644 src/reader.ts create mode 100644 src/registry.ts create mode 100644 src/types.ts create mode 100644 test/fixtures.ts create mode 100644 test/read-rich-document.test.ts create mode 100644 tsconfig.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c2f4da5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,26 @@ +name: Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - name: Install dependencies + run: npm ci + - name: Typecheck + run: npm run check + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b29a38 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +coverage/ +.roam/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..672ee9e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,17 @@ +# Project agent memory + +This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. + +- Add durable project-specific notes here as they are discovered through real work. + +## Project notes + +- The published package entry point and OpenCode tool registration live in `src/index.ts`; the reader, safety checks, format registry, and media extraction are split across `src/`. +- Run `npm run check && npm test` for the local typecheck and executable fixture suite. The DOCX, ODT, and PPTX fixtures are generated as real ZIP archives by `test/fixtures.ts`. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 78fcbad..5d531d4 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ Ask the agent to call `read_rich_document` with a document path inside the curre {"path":"docs/architecture.docx"} ``` -The tool returns structure-preserving Markdown for headings, lists, tables, links, sections, slide context, notes, and supported document content. +The tool returns structure-preserving Markdown for headings, lists, tables, links, sections, slide context, notes, and supported document content. It also returns a structured media index in tool metadata. It appends an ordered embedded-media index containing each attachment's source name, MIME type, document section, and isolated temporary path. -Use the built-in `read` tool on a listed image path when vision inspection is relevant. +Use a media label from the index when vision inspection is relevant. The selected image is returned as a native OpenCode file attachment, while its isolated temporary path remains available for follow-up tools. Media is not attached by default, which prevents documents with many screenshots from inflating the model context. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..5cd2dd7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1003 @@ +{ + "name": "opencode-rich-document-reader", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "opencode-rich-document-reader", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@opencode-ai/plugin": "1.18.18", + "officeparser": "7.5.1" + }, + "devDependencies": { + "@types/node": "22.10.2", + "fflate": "0.8.3", + "typescript": "5.8.2" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-3.0.8.tgz", + "integrity": "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.6.tgz", + "integrity": "sha512-Ud4d0jLr9vSaakvfOTIjgP3hoY8kMIYgHC4mtE4L0bP3St9lf6TyCVsInvFIOEofzcUeMfDydHDy8Fs2WuWz+A==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.6", + "@napi-rs/canvas-darwin-arm64": "1.0.6", + "@napi-rs/canvas-darwin-x64": "1.0.6", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.6", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.6", + "@napi-rs/canvas-linux-arm64-musl": "1.0.6", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.6", + "@napi-rs/canvas-linux-x64-gnu": "1.0.6", + "@napi-rs/canvas-linux-x64-musl": "1.0.6", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.6", + "@napi-rs/canvas-win32-x64-msvc": "1.0.6" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.6.tgz", + "integrity": "sha512-CZUmZEyN/cmSN1OhRJ44vBE6xyb5ekBbsIF8dqNgizbnAiHSFNi8eAA4oyyIAe5KzJ72K16OKC2lAynZLhv32Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.6.tgz", + "integrity": "sha512-FGJsIngRfS9LDaX4t/lFthqn2Km6b61ONzjJFC6+yuabL1NgaK7x0fwrj8276OQFkjCvSpJZ7+CeZWgcy03F2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.6.tgz", + "integrity": "sha512-wLDrtfX9V4bu6O55/WwGvPVCjfunL2bEm59lzIFk9ZQOt7oJrPzdx/ytZiQr6bMZ9XOFwvXujszYtpoCK7yUbg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.6.tgz", + "integrity": "sha512-8TllEP/9UPDVxrBN/tnbGBsxxwG8Eo2tgyW8wDy+Gi9rm9si2I6MsDv9MlfI3a7JOuzkBcurHYDHZxwLgzAIdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.6.tgz", + "integrity": "sha512-YF15AKoGrgU7aoA03CR+cATCRPQcVt6L14Lb/a9hqK7pT7/DhIkqh4qyl+a2GLfqx++yIIJfBuZ3sHM3zlXM1Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.6.tgz", + "integrity": "sha512-zXeVWNFpj720HZln0bfAs+1/nP3Wy6Z5hUw60UJu+cvgips5jZtuX2+EjDHFk98q3BY35GaE1TQLwE9S244vPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.6.tgz", + "integrity": "sha512-kvy1Ypq4ODEI2WRNGvs588qTMcWCjlEIuZRyW8JXpiBnMUirgxQukIDFVDQEcrLlHP5cqZ9cvvfOkBJ7koOktA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.6.tgz", + "integrity": "sha512-czHnfLgSCl48iluS6bNrytGYhsbSTUuIvMucAwO0mPEj1uhkHk6SUiRI0xoqv0PSFzpGFfSnQvFpm7vV+tDO+A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.6.tgz", + "integrity": "sha512-afsg6GyfUx8qZzjsywjimXUEWcSWETY5F80hmMb2FWVSXkRbor8IpftY2gXnasq+Y42Xug/76qTHwqvti7HRTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.6.tgz", + "integrity": "sha512-byzha357piy0wgK6IZOQtU4plpt94f6CxHFS7LhJpWYrDWWT/AitQNxRMfCNNaQHZXUB3BwNhQSfH2RpiZ0WaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.6.tgz", + "integrity": "sha512-gWLOgKGZz3hZhzg49q2PJIURwy2pCWh1zvNFXmLO1yineOPhEpoL3EV6/VIjOiKa1l2P6ieP12Eo385u1MLLVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.18.18.tgz", + "integrity": "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg==", + "license": "MIT", + "dependencies": { + "@ai-sdk/provider": "3.0.8", + "@opencode-ai/sdk": "1.18.18", + "effect": "4.0.0-beta.83", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.4.5", + "@opentui/keymap": ">=0.4.5", + "@opentui/solid": ">=0.4.5" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/keymap": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.18.18", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.18.tgz", + "integrity": "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.10.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.2.tgz", + "integrity": "sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@xmldom/xmldom": { + "version": "0.9.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.11.tgz", + "integrity": "sha512-tW8bcK3hsG0/uqSnNz6TK4BkcuZSezoU7DlnYssILmZDktPnSHHuDJJFM0AJv+13gz2r0iGdrj6qqKeUnxXEDg==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.83", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.83.tgz", + "integrity": "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.8.0", + "find-my-way-ts": "^0.1.6", + "ini": "^7.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^2.0.1", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^14.0.0", + "yaml": "^2.9.0" + } + }, + "node_modules/fast-check": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", + "integrity": "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", + "integrity": "sha512-ww5Mhre0EE+jmBvOXTmXAbEMuZE7uX4a3+oRCQFNj8w++g3ev913N6tXQz0XTXbueQ5TWQfm6BdaViEHHn8bhA==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.5", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/idb-keyval": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.3.0.tgz", + "integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==", + "license": "Apache-2.0" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ini": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", + "integrity": "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w==", + "license": "ISC", + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz", + "integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.4" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multipasta": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.8.tgz", + "integrity": "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/officeparser": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/officeparser/-/officeparser-7.5.1.tgz", + "integrity": "sha512-Gv9146byEWL2e6OpdbrHY3tpgI9+irAG9TrqMvtUpB1bX/o9by7Lm+WjBLPB8pO8mIruIrXSIgCra+jtIGRLgg==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "fflate": "^0.8.3", + "file-type": "^22.0.1", + "pdfjs-dist": "6.1.200", + "tesseract.js": "^7.0.0" + }, + "bin": { + "officeparser": "dist/cli.js" + }, + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/harshankur" + }, + "peerDependenciesMeta": { + "puppeteer": { + "optional": true + } + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "license": "MIT", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, + "node_modules/pure-rand": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.2.tgz", + "integrity": "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==", + "license": "Apache-2.0" + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/toml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.3.0.tgz", + "integrity": "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", + "integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz", + "integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==", + "license": "Apache-2.0" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.8.tgz", + "integrity": "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9c31edf --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "opencode-rich-document-reader", + "version": "1.0.0", + "description": "An OpenCode plugin for reading DOCX, ODT, and PPTX documents as structured Markdown.", + "type": "module", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/ddbaron/opencode-rich-document-reader.git" + }, + "bugs": { + "url": "https://github.com/ddbaron/opencode-rich-document-reader/issues" + }, + "homepage": "https://github.com/ddbaron/opencode-rich-document-reader#readme", + "keywords": [ + "opencode", + "opencode-plugin", + "docx", + "odt", + "pptx", + "officeparser" + ], + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "files": [ + "src", + "LICENSE", + "README.md" + ], + "engines": { + "node": ">=20" + }, + "scripts": { + "check": "tsc --noEmit", + "test": "node --experimental-strip-types --test test/read-rich-document.test.ts" + }, + "dependencies": { + "@opencode-ai/plugin": "1.18.18", + "officeparser": "7.5.1" + }, + "devDependencies": { + "@types/node": "22.10.2", + "fflate": "0.8.3", + "typescript": "5.8.2" + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..799afb1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,23 @@ +import { type Plugin, tool } from "@opencode-ai/plugin"; +import { readRichDocument } from "./reader.ts"; + +export const RichDocumentReaderPlugin: Plugin = async () => ({ + tool: { + read_rich_document: tool({ + description: + "Read a DOCX, ODT, or PPTX file as structure-preserving Markdown and list its extracted media. Select image labels only when vision inspection is needed.", + args: { + path: tool.schema.string().describe("Path to a DOCX, ODT, or PPTX inside the current project."), + media: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Optional media labels from the embedded media index to attach as images."), + }, + async execute(args, context) { + return readRichDocument(args, context); + }, + }), + }, +}); + +export default RichDocumentReaderPlugin; diff --git a/src/limits.ts b/src/limits.ts new file mode 100644 index 0000000..d53b277 --- /dev/null +++ b/src/limits.ts @@ -0,0 +1,19 @@ +import type { OfficeParserConfig } from "officeparser"; + +/** Limits are deliberately conservative because documents are supplied to an agent. */ +export const READER_LIMITS = { + maxArchiveBytes: 64 * 1024 * 1024, + maxUncompressedBytes: 128 * 1024 * 1024, + maxZipEntries: 4096, + maxTableCells: 250_000, +} as const; + +export function parserLimits(): Pick { + return { + decompressionLimits: { + maxUncompressedBytes: READER_LIMITS.maxUncompressedBytes, + maxZipEntries: READER_LIMITS.maxZipEntries, + maxTableCells: READER_LIMITS.maxTableCells, + }, + }; +} diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..a7cd019 --- /dev/null +++ b/src/media.ts @@ -0,0 +1,164 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, extname, join } from "node:path"; +import type { OfficeAttachment, OfficeContentNode, OfficeParserAST } from "officeparser"; +import type { MediaIndexEntry, MediaRecord } from "./types.ts"; + +interface LocationContext { + heading?: string; + slideNumber?: number; + inNotes: boolean; +} + +function textOf(node: OfficeContentNode): string { + return (node.text ?? "").replace(/\s+/g, " ").trim(); +} + +function attachmentNameOf(node: OfficeContentNode): string | undefined { + const metadata = node.metadata; + if (metadata && "attachmentName" in metadata && typeof metadata.attachmentName === "string") { + return metadata.attachmentName; + } + return undefined; +} + +function locationFor(context: LocationContext): string { + const parts: string[] = []; + if (context.slideNumber !== undefined) parts.push(`Slide ${context.slideNumber}`); + if (context.heading) parts.push(`Section: ${context.heading}`); + if (context.inNotes) parts.push("Notes"); + return parts.join(" - ") || "Document body"; +} + +function collectAttachmentLocations(nodes: OfficeContentNode[], locations: Map, parent: LocationContext): void { + let inherited = { ...parent }; + for (const node of nodes) { + const metadata = node.metadata; + const context: LocationContext = { ...inherited }; + + if (node.type === "slide" && metadata && "slideNumber" in metadata) { + context.slideNumber = metadata.slideNumber; + } + if (node.type === "note") { + context.inNotes = true; + if (metadata && "slideNumber" in metadata && metadata.slideNumber !== undefined) { + context.slideNumber = metadata.slideNumber; + } + } + if (node.type === "heading") { + const heading = textOf(node); + if (heading) context.heading = heading; + } + + const attachmentName = attachmentNameOf(node); + if (attachmentName && !locations.has(attachmentName)) { + locations.set(attachmentName, locationFor(context)); + } + + if (node.children) collectAttachmentLocations(node.children, locations, context); + if (node.notes) collectAttachmentLocations(node.notes, locations, { ...context, inNotes: true }); + if (node.comments) collectAttachmentLocations(node.comments, locations, context); + + // Headings establish the nearest semantic section for following blocks in + // DOCX and ODT, whose AST has no standalone section node. + if (node.type === "heading" && context.heading) inherited.heading = context.heading; + if (node.type === "slide" && context.slideNumber !== undefined) { + inherited.slideNumber = context.slideNumber; + } + } +} + +function safeExtension(attachment: OfficeAttachment): string { + const fromParser = attachment.extension.replace(/[^a-zA-Z0-9]/g, "").toLowerCase(); + if (fromParser) return `.${fromParser}`; + + const fromName = extname(attachment.name).replace(/[^a-zA-Z0-9.]/g, "").toLowerCase(); + return fromName || ".bin"; +} + +export async function writeMedia( + ast: OfficeParserAST, + temporaryDirectory: string, +): Promise { + const locations = new Map(); + collectAttachmentLocations(ast.content, locations, { inNotes: false }); + if (ast.auxiliary) { + collectAttachmentLocations(ast.auxiliary.headers ?? [], locations, { inNotes: false }); + collectAttachmentLocations(ast.auxiliary.footers ?? [], locations, { inNotes: false }); + collectAttachmentLocations(ast.auxiliary.slideMasters ?? [], locations, { inNotes: false }); + } + + await mkdir(temporaryDirectory, { recursive: true, mode: 0o700 }); + const records: MediaRecord[] = []; + + for (const [index, attachment] of ast.attachments.entries()) { + const label = `media-${index + 1}`; + const temporaryPath = join(temporaryDirectory, `${label}${safeExtension(attachment)}`); + await writeFile(temporaryPath, Buffer.from(attachment.data, "base64"), { mode: 0o600 }); + const entry: MediaIndexEntry = { + label, + type: attachment.type, + originalName: attachment.name, + mimeType: String(attachment.mimeType), + temporaryPath, + location: locations.get(attachment.name) ?? "Document body", + }; + records.push({ entry, attachment }); + } + + return records; +} + +export function mediaTable(records: MediaRecord[]): string { + const cell = (value: string) => value.replace(/\|/g, "\\|").replace(/[\r\n]+/g, " "); + const rows = records.map(({ entry }) => + `| \`${cell(entry.label)}\` | ${cell(entry.type)} | ${cell(entry.originalName)} | ${cell(entry.mimeType)} | ${cell(entry.temporaryPath)} | ${cell(entry.location)} |`, + ); + + return [ + "## Embedded media", + "", + "| Label | Type | Original attachment | MIME type | Temporary path | Location |", + "| --- | --- | --- | --- | --- | --- |", + ...(rows.length ? rows : ["| _none_ | | | | | |"]), + ].join("\n"); +} + +export class MediaSelectionError extends Error { + readonly code = "MEDIA_SELECTOR_INVALID"; + + constructor(message: string) { + super(message); + this.name = "MediaSelectionError"; + } +} + +export function selectMedia(records: MediaRecord[], selectors: string[] | undefined): MediaRecord[] { + if (!selectors?.length) return []; + + const selected: MediaRecord[] = []; + for (const selector of selectors) { + const record = records.find( + ({ entry }) => entry.label === selector || entry.originalName === selector, + ); + if (!record) { + const available = records.map(({ entry }) => entry.label).join(", ") || "none"; + throw new MediaSelectionError(`Unknown media selector "${selector}". Available media labels: ${available}.`); + } + if (record.attachment.type !== "image") { + throw new MediaSelectionError( + `Media selector "${selector}" does not name an image and cannot be attached.`, + ); + } + if (!selected.some((item) => item.entry.label === record.entry.label)) selected.push(record); + } + return selected; +} + +export function selectedAttachments(records: MediaRecord[]) { + return records.map(({ entry, attachment }) => ({ + type: "file" as const, + mime: entry.mimeType, + url: `data:${entry.mimeType};base64,${attachment.data}`, + filename: basename(entry.temporaryPath), + })); +} diff --git a/src/path-safety.ts b/src/path-safety.ts new file mode 100644 index 0000000..58b7355 --- /dev/null +++ b/src/path-safety.ts @@ -0,0 +1,101 @@ +import { access, lstat, realpath, stat } from "node:fs/promises"; +import { constants } from "node:fs"; +import { extname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { ProjectPaths, ResolvedDocument } from "./types.ts"; + +export class DocumentPathError extends Error { + readonly code: + | "INVALID_PATH" + | "PROJECT_UNAVAILABLE" + | "MISSING_DOCUMENT" + | "PATH_ESCAPE" + | "SYMLINK_ESCAPE" + | "NOT_A_FILE" + | "UNREADABLE_DOCUMENT"; + + constructor(code: DocumentPathError["code"], message: string) { + super(message); + this.name = "DocumentPathError"; + this.code = code; + } +} + +function isWithin(root: string, candidate: string): boolean { + const distance = relative(root, candidate); + return distance === "" || (distance !== ".." && !distance.startsWith(`..${sep}`) && !isAbsolute(distance)); +} + +async function canonicalProjectRoot(root: string): Promise { + try { + return await realpath(resolve(root)); + } catch { + throw new DocumentPathError("PROJECT_UNAVAILABLE", `Current project is not available: ${root}`); + } +} + +export async function resolveDocumentPath(documentInput: string, rootInput: string): Promise { + if (!documentInput.trim() || documentInput.includes("\0")) { + throw new DocumentPathError("INVALID_PATH", "A non-empty document path is required."); + } + + const projectRoot = await canonicalProjectRoot(rootInput); + const lexicalDocumentPath = resolve(projectRoot, documentInput); + + if (!isWithin(projectRoot, lexicalDocumentPath)) { + throw new DocumentPathError( + "PATH_ESCAPE", + `Document path escapes the current project: ${documentInput}`, + ); + } + + try { + await lstat(lexicalDocumentPath); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new DocumentPathError("MISSING_DOCUMENT", `Document not found: ${documentInput}`); + } + throw new DocumentPathError("UNREADABLE_DOCUMENT", `Document cannot be inspected: ${documentInput}`); + } + + let documentPath: string; + try { + documentPath = await realpath(lexicalDocumentPath); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? error.code : undefined; + if (code === "ENOENT" || code === "ENOTDIR") { + throw new DocumentPathError("MISSING_DOCUMENT", `Document not found: ${documentInput}`); + } + throw new DocumentPathError("UNREADABLE_DOCUMENT", `Document cannot be resolved: ${documentInput}`); + } + + if (!isWithin(projectRoot, documentPath)) { + throw new DocumentPathError( + "SYMLINK_ESCAPE", + `Document symlink resolves outside the current project: ${documentInput}`, + ); + } + + let documentStat; + try { + documentStat = await stat(documentPath); + if (!documentStat.isFile()) { + throw new DocumentPathError("NOT_A_FILE", `Document path is not a regular file: ${documentInput}`); + } + await access(documentPath, constants.R_OK); + } catch (error) { + if (error instanceof DocumentPathError) throw error; + throw new DocumentPathError("UNREADABLE_DOCUMENT", `Document is not readable: ${documentInput}`); + } + + return { + absolutePath: documentPath, + extension: extname(documentPath).toLowerCase(), + size: documentStat.size, + }; +} + +export function projectPaths(context: { directory: string; worktree: string }, documentInput: string): ProjectPaths { + const projectRoot = context.worktree || context.directory; + return { projectRoot, documentPath: documentInput }; +} diff --git a/src/reader.ts b/src/reader.ts new file mode 100644 index 0000000..223bd82 --- /dev/null +++ b/src/reader.ts @@ -0,0 +1,273 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import type { ToolAttachment } from "@opencode-ai/plugin"; +import type { OfficeParserAST } from "officeparser"; +import { formatForExtension, formatRegistry } from "./registry.ts"; +import { READER_LIMITS, parserLimits } from "./limits.ts"; +import { + MediaSelectionError, + mediaTable, + selectedAttachments, + selectMedia, + writeMedia, +} from "./media.ts"; +import { DocumentPathError, projectPaths, resolveDocumentPath } from "./path-safety.ts"; +import type { + MediaIndexEntry, + MediaRecord, + ReadRichDocumentArgs, + ReaderToolContext, + RichDocumentResultMetadata, + RichDocumentToolResult, + ReadRichDocumentDependencies, + ParseIssue, + DocumentIssue, +} from "./types.ts"; + +export class RichDocumentError extends Error { + readonly code: string; + + constructor(code: string, message: string, options?: ErrorOptions) { + super(message, options); + this.name = "RichDocumentError"; + this.code = code; + } +} + +function issueKey(issue: DocumentIssue): string { + return `${issue.type}:${String(issue.code)}:${issue.message}`; +} + +function uniqueIssues(issues: ParseIssue[]): DocumentIssue[] { + const seen = new Set(); + const result: DocumentIssue[] = []; + for (const { issue } of issues) { + const key = issueKey(issue); + if (seen.has(key)) continue; + seen.add(key); + result.push(issue); + } + return result; +} + +function warningSection(issues: DocumentIssue[]): string { + const rows = issues.length + ? issues.map( + (issue) => + `- **${issue.type} ${String(issue.code)}** ${issue.message.replace(/[\r\n]+/g, " ")}`, + ) + : ["- None reported."]; + return ["## Conversion warnings", "", ...rows].join("\n"); +} + +function stripInlineMediaData(markdown: string): string { + return markdown.replace( + /data:[^\s,;]+(?:;[^\s,;]+)*;base64,[A-Za-z0-9+/=_-]+/g, + "[embedded media omitted; see the media index]", + ); +} + +function completeMarkdown(markdown: string, records: MediaRecord[], issues: DocumentIssue[]): string { + const body = stripInlineMediaData(markdown.trim()) || "_No readable text content was found._"; + return [body, mediaTable(records), warningSection(issues)].join("\n\n"); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message) return error.message; + return String(error); +} + +function parserError(error: unknown, sourcePath: string): Error { + if (error instanceof RichDocumentError) return error; + if (error instanceof DocumentPathError) return error; + + const issue = + error && typeof error === "object" && "officeIssue" in error && error.officeIssue + ? error.officeIssue + : undefined; + const detail = + issue && typeof issue === "object" && "message" in issue ? String(issue.message) : errorMessage(error); + const code = + issue && typeof issue === "object" && "code" in issue ? String(issue.code) : "PARSE_FAILED"; + return new RichDocumentError(code, `Could not parse document ${sourcePath}: ${detail}`, { cause: error }); +} + +function extractionError(error: unknown, sourcePath: string): RichDocumentError { + if (error instanceof RichDocumentError) return error; + return new RichDocumentError( + "MEDIA_EXTRACTION_FAILED", + `Could not extract document media from ${sourcePath}: ${errorMessage(error)}`, + { cause: error }, + ); +} + +function conversionError(error: unknown, sourcePath: string): RichDocumentError { + if (error instanceof RichDocumentError) return error; + return new RichDocumentError( + "CONVERSION_FAILED", + `Could not convert document ${sourcePath} to Markdown: ${errorMessage(error)}`, + { cause: error }, + ); +} + +function projectRootFor(context: ReaderToolContext): string { + return context.worktree || context.directory; +} + +function sourceLabel(documentInput: string, projectRoot: string, absolutePath: string): string { + const relativePath = relative(projectRoot, absolutePath); + return relativePath && !relativePath.startsWith("..") ? relativePath : documentInput; +} + +function attachmentMetadata(records: MediaRecord[]): MediaIndexEntry[] { + return records.map(({ entry }) => ({ ...entry })); +} + +function toolAttachments(records: MediaRecord[]): ToolAttachment[] { + return selectedAttachments(records); +} + +async function parseDocument( + format: NonNullable>, + input: Buffer, + context: ReaderToolContext, + sourcePath: string, + issues: ParseIssue[], +): Promise { + try { + return await format.parse(input, { + ...parserLimits(), + extractAttachments: true, + ignoreComments: false, + ignoreHeadersAndFooters: false, + ignoreNotes: false, + ignoreSlideMasters: false, + ocr: false, + onWarning: (issue) => issues.push({ issue, source: "parser" }), + abortSignal: context.abort, + }); + } catch (error) { + throw parserError(error, sourcePath); + } +} + +async function extractMedia( + ast: OfficeParserAST, + baseTemporaryDirectory: string, + sourcePath: string, +): Promise<{ temporaryDirectory?: string; records: MediaRecord[] }> { + let temporaryDirectory: string | undefined; + try { + if (ast.attachments.length) { + temporaryDirectory = await mkdtemp(join(baseTemporaryDirectory, "opencode-rich-document-")); + } + const records = temporaryDirectory ? await writeMedia(ast, temporaryDirectory) : []; + return { temporaryDirectory, records }; + } catch (error) { + throw extractionError(error, sourcePath); + } +} + +function selectRequestedMedia(records: MediaRecord[], selectors: string[] | undefined): MediaRecord[] { + try { + return selectMedia(records, selectors); + } catch (error) { + if (error instanceof MediaSelectionError) { + throw new RichDocumentError(error.code, error.message, { cause: error }); + } + throw error; + } +} + +async function convertMarkdown( + ast: OfficeParserAST, + sourcePath: string, + issues: ParseIssue[], +) { + try { + return await ast.to("md", { + includeImages: false, + includeCharts: false, + onWarning: (issue) => issues.push({ issue, source: "conversion" }), + mdConfig: { + dialect: "github", + fallbackToHtml: true, + }, + }); + } catch (error) { + throw conversionError(error, sourcePath); + } +} + +export async function readRichDocument( + args: ReadRichDocumentArgs, + context: ReaderToolContext, + dependencies: ReadRichDocumentDependencies = {}, +): Promise { + const projectRoot = projectRootFor(context); + const paths = projectPaths(context, args.path); + const resolved = await resolveDocumentPath(paths.documentPath, paths.projectRoot); + const format = formatForExtension(resolved.extension, dependencies.formats ?? formatRegistry); + + if (!format) { + throw new RichDocumentError( + "UNSUPPORTED_FORMAT", + `Unsupported document extension "${resolved.extension || "(none)"}". Supported formats are .docx, .odt, and .pptx.`, + ); + } + if (resolved.size > READER_LIMITS.maxArchiveBytes) { + throw new RichDocumentError( + "ARCHIVE_SIZE_LIMIT", + `Document archive is too large (${resolved.size} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, + ); + } + + let input: Buffer; + try { + input = await readFile(resolved.absolutePath); + } catch (error) { + throw new RichDocumentError( + "UNREADABLE_DOCUMENT", + `Document is not readable: ${args.path} (${errorMessage(error)})`, + { cause: error }, + ); + } + + const issues: ParseIssue[] = []; + const ast = await parseDocument(format, input, context, args.path, issues); + + let temporaryDirectory: string | undefined; + let keepTemporaryDirectory = false; + try { + const extraction = await extractMedia(ast, dependencies.tempDirectory ?? tmpdir(), args.path); + temporaryDirectory = extraction.temporaryDirectory; + const selected = selectRequestedMedia(extraction.records, args.media); + const conversion = await convertMarkdown(ast, args.path, issues); + + for (const issue of ast.warnings) issues.push({ issue, source: "parser" }); + for (const issue of conversion.messages) issues.push({ issue, source: "conversion" }); + + const media = attachmentMetadata(extraction.records); + const metadata: RichDocumentResultMetadata = { + format: format.parserType, + sourcePath: sourceLabel(args.path, projectRoot, resolved.absolutePath), + media, + }; + const attachments = toolAttachments(selected); + const result: RichDocumentToolResult = { + title: `Read ${sourceLabel(args.path, projectRoot, resolved.absolutePath)}`, + output: completeMarkdown(String(conversion.value), extraction.records, uniqueIssues(issues)), + metadata, + }; + if (attachments.length) result.attachments = attachments; + keepTemporaryDirectory = true; + return result; + } finally { + // Successful reads intentionally keep the temporary directory available to + // the agent. On failure, avoid leaving a partial extraction behind. + if (temporaryDirectory && !keepTemporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); + } + } +} diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..d9cafd4 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,29 @@ +import { OfficeParser } from "officeparser"; +import type { OfficeParserConfig, SupportedFileType } from "officeparser"; +import type { RichDocumentFormat } from "./types.ts"; + +function officeFormat(parserType: SupportedFileType): RichDocumentFormat { + return { + extension: `.${parserType}`, + parserType, + parse(input: Uint8Array, config: OfficeParserConfig) { + return OfficeParser.parseOffice(input, { ...config, fileType: parserType }); + }, + }; +} + +/** + * The format registry is the only format-selection surface used by the tool. + * Adding a parser-backed format does not change the OpenCode tool arguments or + * result shape. + */ +export const formatRegistry: ReadonlyMap = new Map( + ["docx", "odt", "pptx"].map((format) => { + const parserType = format as SupportedFileType; + return [`.${format}`, officeFormat(parserType)] as const; + }), +); + +export function formatForExtension(extension: string, formats = formatRegistry): RichDocumentFormat | undefined { + return formats.get(extension.toLowerCase()); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..0daa678 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,70 @@ +import type { ToolContext, ToolResult } from "@opencode-ai/plugin"; +import type { + OfficeAttachment, + OfficeContentNode, + OfficeParserAST, + OfficeParserConfig, + SupportedFileType, +} from "officeparser"; + +export interface DocumentIssue { + type: "warning" | "info" | "error" | string; + code: string; + message: string; +} + +export interface ReadRichDocumentArgs { + path: string; + media?: string[]; +} + +export type ReaderToolContext = Pick; + +export interface RichDocumentFormat { + extension: string; + parserType: SupportedFileType; + parse(input: Uint8Array, config: OfficeParserConfig): Promise; +} + +export interface MediaIndexEntry { + label: string; + type: OfficeAttachment["type"]; + originalName: string; + mimeType: string; + temporaryPath: string; + location: string; +} + +export interface RichDocumentResultMetadata { + format: SupportedFileType; + sourcePath: string; + media: MediaIndexEntry[]; +} + +export type RichDocumentToolResult = Extract; + +export interface MediaRecord { + entry: MediaIndexEntry; + attachment: OfficeAttachment; +} + +export interface ParseIssue { + issue: DocumentIssue; + source: "parser" | "conversion"; +} + +export interface ResolvedDocument { + absolutePath: string; + extension: string; + size: number; +} + +export interface ProjectPaths { + projectRoot: string; + documentPath: string; +} + +export interface ReadRichDocumentDependencies { + formats?: ReadonlyMap; + tempDirectory?: string; +} diff --git a/test/fixtures.ts b/test/fixtures.ts new file mode 100644 index 0000000..1b2cd0c --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,140 @@ +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { strToU8, zipSync } from "fflate"; + +const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", + "base64", +); + +function archive(entries: Record): Buffer { + const encoded: Record = {}; + for (const [name, value] of Object.entries(entries)) { + encoded[name] = typeof value === "string" ? strToU8(value) : value; + } + return Buffer.from(zipSync(encoded, { level: 0 })); +} + +function docxFixture(): Buffer { + return archive({ + "[Content_Types].xml": ` + + + + + + +`, + "_rels/.rels": ` + + +`, + "word/_rels/document.xml.rels": ` + + + +`, + "word/numbering.xml": ` + + + +`, + "word/document.xml": ` + + + Project Overview + DOCX introduction. + First bullet + Open reference + + HeaderValue + Row42 + + + + +`, + "word/media/image1.png": png, + }); +} + +function odtFixture(): Buffer { + return archive({ + mimetype: "application/vnd.oasis.opendocument.text", + "META-INF/manifest.xml": ` + + + +`, + "content.xml": ` + + + ODT Section + ODT introduction. + ODT bullet + + ODT HeaderODT Value + + + +`, + "styles.xml": ``, + "Pictures/image1.png": png, + }); +} + +function pptxFixture(): Buffer { + return archive({ + "[Content_Types].xml": ` + + + + + + +`, + "_rels/.rels": ` + + +`, + "ppt/presentation.xml": ` +`, + "ppt/_rels/presentation.xml.rels": ` + + +`, + "ppt/slides/slide1.xml": ` + + + + PPTX Slide One + Slide bullet + + +`, + "ppt/slides/_rels/slide1.xml.rels": ` + + + +`, + "ppt/notesSlides/notesSlide1.xml": ` +Speaker notes`, + "ppt/media/image1.png": png, + }); +} + +export async function createFixtures() { + const root = await mkdtemp(join(tmpdir(), "opencode-reader-fixtures-")); + const paths = { + docx: join(root, "structure.docx"), + odt: join(root, "structure.odt"), + pptx: join(root, "slides.pptx"), + }; + await Promise.all([ + writeFile(paths.docx, docxFixture()), + writeFile(paths.odt, odtFixture()), + writeFile(paths.pptx, pptxFixture()), + ]); + return { root, ...paths }; +} diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts new file mode 100644 index 0000000..5abab4e --- /dev/null +++ b/test/read-rich-document.test.ts @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { after, before, describe, it } from "node:test"; +import { readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { RichDocumentReaderPlugin } from "../src/index.ts"; +import { readRichDocument } from "../src/reader.ts"; +import { createFixtures } from "./fixtures.ts"; + +let fixtures: Awaited>; +const extractedDirectories = new Set(); + +function context() { + const abort = new AbortController(); + return { directory: fixtures.root, worktree: fixtures.root, abort: abort.signal }; +} + +function rememberExtraction(result: { metadata?: { media?: Array<{ temporaryPath: string }> } }) { + for (const item of result.metadata?.media ?? []) extractedDirectories.add(dirname(item.temporaryPath)); +} + +before(async () => { + fixtures = await createFixtures(); +}); + +after(async () => { + await rm(fixtures.root, { recursive: true, force: true }); + await Promise.all( + [...extractedDirectories].map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("plugin registration", () => { + it("registers exactly the read_rich_document agent tool", async () => { + const hooks = await RichDocumentReaderPlugin({} as never); + assert.deepEqual(Object.keys(hooks.tool ?? {}), ["read_rich_document"]); + assert.equal(typeof hooks.tool?.read_rich_document.execute, "function"); + }); +}); + +describe("read_rich_document", () => { + it("extracts DOCX structure, media, and section association without default attachments", async () => { + const result = await readRichDocument({ path: "structure.docx" }, context()); + rememberExtraction(result); + + assert.match(result.output, /# Project Overview/); + assert.match(result.output, /First bullet/); + assert.match(result.output, /\| Header \| Value \|/); + assert.match(result.output, /\[Open reference\]\(https:\/\/example\.com\/reference\)/); + assert.match(result.output, /`media-1`/); + assert.match(result.output, /image1\.png/); + assert.match(result.output, /image\/png/); + assert.match(result.output, /Section: Project Overview/); + assert.equal(result.attachments, undefined); + assert.ok(result.metadata); + assert.equal(result.metadata.media.length, 1); + assert.equal(await stat(result.metadata.media[0].temporaryPath).then(() => true), true); + assert.doesNotMatch(result.output, /iVBORw0KGgo/); + }); + + it("extracts ODT structure and associates media with the nearest section", async () => { + const result = await readRichDocument({ path: "structure.odt" }, context()); + rememberExtraction(result); + + assert.match(result.output, /# ODT Section/); + assert.match(result.output, /ODT bullet/); + assert.match(result.output, /ODT Header/); + assert.match(result.output, /`media-1`/); + assert.match(result.output, /Section: ODT Section/); + assert.ok(result.metadata); + assert.equal(result.metadata.format, "odt"); + }); + + it("retains PPTX slide and speaker-note context while indexing its image", async () => { + const result = await readRichDocument({ path: "slides.pptx" }, context()); + rememberExtraction(result); + + assert.match(result.output, /PPTX Slide One/); + assert.match(result.output, /Slide bullet/); + assert.match(result.output, /Speaker notes/); + assert.match(result.output, /Slide 1/); + assert.ok(result.metadata); + assert.equal(result.metadata.format, "pptx"); + assert.match(result.metadata.media[0].location, /^Slide 1/); + }); + + it("attaches only explicitly selected image media as a native file attachment", async () => { + const result = await readRichDocument({ path: "structure.docx", media: ["media-1"] }, context()); + rememberExtraction(result); + + assert.equal(result.attachments?.length, 1); + assert.equal(result.attachments?.[0].type, "file"); + assert.equal(result.attachments?.[0].mime, "image/png"); + assert.match(result.attachments?.[0].url ?? "", /^data:image\/png;base64,/); + assert.match(result.attachments?.[0].filename ?? "", /^media-1\.png$/); + assert.doesNotMatch(result.output, /iVBORw0KGgo/); + }); + + it("leaves the source document byte-for-byte unchanged", async () => { + const beforeBytes = await readFile(fixtures.docx); + const beforeStat = await stat(fixtures.docx); + const result = await readRichDocument({ path: "structure.docx" }, context()); + rememberExtraction(result); + const afterBytes = await readFile(fixtures.docx); + const afterStat = await stat(fixtures.docx); + + assert.deepEqual(afterBytes, beforeBytes); + assert.equal(afterStat.mtimeMs, beforeStat.mtimeMs); + }); + + it("rejects lexical path escapes and symlinks that resolve outside the project", async () => { + const outside = join(dirname(fixtures.root), "outside.docx"); + const link = join(fixtures.root, "escaped.docx"); + await writeFile(outside, Buffer.from("outside")); + await symlink(outside, link); + + await assert.rejects( + () => readRichDocument({ path: "../outside.docx" }, context()), + /escapes the current project/i, + ); + await assert.rejects( + () => readRichDocument({ path: "escaped.docx" }, context()), + /symlink resolves outside/i, + ); + + await rm(link, { force: true }); + await rm(outside, { force: true }); + }); + + it("rejects unsupported, missing, and malformed documents with useful errors", async () => { + await writeFile(join(fixtures.root, "notes.pdf"), Buffer.from("not supported")); + await writeFile(join(fixtures.root, "broken.docx"), Buffer.from("not a zip archive")); + + await assert.rejects( + () => readRichDocument({ path: "notes.pdf" }, context()), + /unsupported document extension/i, + ); + await assert.rejects( + () => readRichDocument({ path: "missing.docx" }, context()), + /document not found/i, + ); + await assert.rejects( + () => readRichDocument({ path: "broken.docx" }, context()), + /could not parse document|zip|corrupt|malformed/i, + ); + }); + + it("rejects an unknown media selector instead of attaching every image", async () => { + await assert.rejects( + () => readRichDocument({ path: "structure.docx", media: ["media-99"] }, context()), + /unknown media selector/i, + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..6ae3f03 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "allowImportingTsExtensions": true, + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts"] +} From 5bb026f46c98bd2e615130673c1ebbf7b16d04e9 Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 10:12:50 -0500 Subject: [PATCH 2/7] no-mistakes(review): Enforce table limits, context markers, and chart MIME --- src/media.ts | 11 ++++- src/reader.ts | 71 ++++++++++++++++++++++++++++++++- test/fixtures.ts | 17 +++++--- test/read-rich-document.test.ts | 38 +++++++++++++++++- 4 files changed, 128 insertions(+), 9 deletions(-) diff --git a/src/media.ts b/src/media.ts index a7cd019..2b26c5e 100644 --- a/src/media.ts +++ b/src/media.ts @@ -9,6 +9,9 @@ interface LocationContext { inNotes: boolean; } +const OOXML_DOCUMENT_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +const OOXML_CHART_MIME = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; + function textOf(node: OfficeContentNode): string { return (node.text ?? "").replace(/\s+/g, " ").trim(); } @@ -75,6 +78,12 @@ function safeExtension(attachment: OfficeAttachment): string { return fromName || ".bin"; } +function mediaMimeType(attachment: OfficeAttachment): string { + const mimeType = String(attachment.mimeType); + if (attachment.type === "chart" && mimeType === OOXML_DOCUMENT_MIME) return OOXML_CHART_MIME; + return mimeType; +} + export async function writeMedia( ast: OfficeParserAST, temporaryDirectory: string, @@ -98,7 +107,7 @@ export async function writeMedia( label, type: attachment.type, originalName: attachment.name, - mimeType: String(attachment.mimeType), + mimeType: mediaMimeType(attachment), temporaryPath, location: locations.get(attachment.name) ?? "Document body", }; diff --git a/src/reader.ts b/src/reader.ts index 223bd82..361d74d 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import type { ToolAttachment } from "@opencode-ai/plugin"; -import type { OfficeParserAST } from "officeparser"; +import type { OfficeContentNode, OfficeParserAST } from "officeparser"; import { formatForExtension, formatRegistry } from "./registry.ts"; import { READER_LIMITS, parserLimits } from "./limits.ts"; import { @@ -128,6 +128,71 @@ function toolAttachments(records: MediaRecord[]): ToolAttachment[] { return selectedAttachments(records); } +function enforceTableCellLimit(ast: OfficeParserAST, sourcePath: string): void { + const pending: OfficeContentNode[] = [...ast.content]; + for (const nodes of [ast.auxiliary?.headers, ast.auxiliary?.footers, ast.auxiliary?.slideMasters]) { + if (nodes) { + for (const node of nodes) pending.push(node); + } + } + + let cellCount = 0; + while (pending.length) { + const node = pending.pop(); + if (!node) continue; + if (node.type === "cell") { + cellCount += 1; + if (cellCount > READER_LIMITS.maxTableCells) { + throw new RichDocumentError( + "TABLE_CELL_LIMIT", + `Document ${sourcePath} contains too many table cells (${cellCount}). The limit is ${READER_LIMITS.maxTableCells}.`, + ); + } + } + for (const nodes of [node.children, node.notes, node.comments]) { + if (nodes) { + for (const child of nodes) pending.push(child); + } + } + } +} + +function contextHeading(text: string): OfficeContentNode { + return { + type: "heading", + text, + children: [{ type: "text", text }], + metadata: { level: 2 }, + }; +} + +function addDocumentContext(ast: OfficeParserAST): OfficeParserAST { + const sectionBoundaries = ast.type === "docx" + ? ast.content.map((node) => /)/.test(node.rawContent ?? "")) + : []; + const hasMultipleSections = sectionBoundaries.some( + (boundary, index) => boundary && index < ast.content.length - 1, + ); + const content: OfficeContentNode[] = []; + let sectionNumber = 1; + let slideIndex = 0; + + if (hasMultipleSections) content.push(contextHeading(`Section ${sectionNumber}`)); + for (const [index, node] of ast.content.entries()) { + if (node.type === "slide") { + slideIndex += 1; + content.push(contextHeading(`Slide ${node.metadata?.slideNumber ?? slideIndex}`)); + } + content.push(node); + if (hasMultipleSections && sectionBoundaries[index] && index < ast.content.length - 1) { + sectionNumber += 1; + content.push(contextHeading(`Section ${sectionNumber}`)); + } + } + + return content.length === ast.content.length ? ast : { ...ast, content }; +} + async function parseDocument( format: NonNullable>, input: Buffer, @@ -146,6 +211,7 @@ async function parseDocument( ocr: false, onWarning: (issue) => issues.push({ issue, source: "parser" }), abortSignal: context.abort, + includeRawContent: format.parserType === "docx", }); } catch (error) { throw parserError(error, sourcePath); @@ -236,6 +302,7 @@ export async function readRichDocument( const issues: ParseIssue[] = []; const ast = await parseDocument(format, input, context, args.path, issues); + enforceTableCellLimit(ast, args.path); let temporaryDirectory: string | undefined; let keepTemporaryDirectory = false; @@ -243,7 +310,7 @@ export async function readRichDocument( const extraction = await extractMedia(ast, dependencies.tempDirectory ?? tmpdir(), args.path); temporaryDirectory = extraction.temporaryDirectory; const selected = selectRequestedMedia(extraction.records, args.media); - const conversion = await convertMarkdown(ast, args.path, issues); + const conversion = await convertMarkdown(addDocumentContext(ast), args.path, issues); for (const issue of ast.warnings) issues.push({ issue, source: "parser" }); for (const issue of conversion.messages) issues.push({ issue, source: "conversion" }); diff --git a/test/fixtures.ts b/test/fixtures.ts index 1b2cd0c..0941ecf 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -47,11 +47,13 @@ function docxFixture(): Buffer { DOCX introduction. First bullet Open reference - + HeaderValue Row42 + Section boundary. + Second Section `, @@ -93,6 +95,7 @@ function pptxFixture(): Buffer { + `, "_rels/.rels": ` @@ -105,21 +108,25 @@ function pptxFixture(): Buffer { `, "ppt/slides/slide1.xml": ` - + PPTX Slide One Slide bullet + `, "ppt/slides/_rels/slide1.xml.rels": ` - - -`, + + + + `, "ppt/notesSlides/notesSlide1.xml": ` Speaker notes`, + "ppt/charts/chart1.xml": ` +Sales chart`, "ppt/media/image1.png": png, }); } diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 5abab4e..0c2a137 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -2,7 +2,9 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import type { OfficeParserAST } from "officeparser"; import { RichDocumentReaderPlugin } from "../src/index.ts"; +import { READER_LIMITS } from "../src/limits.ts"; import { readRichDocument } from "../src/reader.ts"; import { createFixtures } from "./fixtures.ts"; @@ -49,6 +51,8 @@ describe("read_rich_document", () => { assert.match(result.output, /`media-1`/); assert.match(result.output, /image1\.png/); assert.match(result.output, /image\/png/); + assert.match(result.output, /## Section 1/); + assert.match(result.output, /## Section 2/); assert.match(result.output, /Section: Project Overview/); assert.equal(result.attachments, undefined); assert.ok(result.metadata); @@ -77,10 +81,11 @@ describe("read_rich_document", () => { assert.match(result.output, /PPTX Slide One/); assert.match(result.output, /Slide bullet/); assert.match(result.output, /Speaker notes/); - assert.match(result.output, /Slide 1/); + assert.match(result.output.split("## Embedded media", 1)[0], /## Slide 1/); assert.ok(result.metadata); assert.equal(result.metadata.format, "pptx"); assert.match(result.metadata.media[0].location, /^Slide 1/); + assert.equal(result.metadata.media.find((item) => item.type === "chart")?.mimeType, "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"); }); it("attaches only explicitly selected image media as a native file attachment", async () => { @@ -150,4 +155,35 @@ describe("read_rich_document", () => { /unknown media selector/i, ); }); + + it("rejects oversized tables before conversion", async () => { + const cell = { type: "cell" } as const; + const cells = Array.from({ length: READER_LIMITS.maxTableCells + 1 }, () => cell); + const ast = { + config: {}, + type: "docx", + metadata: {}, + content: [{ type: "table", children: [{ type: "row", children: cells }] }], + attachments: [], + warnings: [], + to: async () => ({ value: "should not convert", messages: [] }), + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".docx", + { + extension: ".docx", + parserType: "docx" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "oversized.docx"), Buffer.from("fixture")); + + await assert.rejects( + () => readRichDocument({ path: "oversized.docx" }, context(), { formats }), + /too many table cells/i, + ); + }); }); From d8435a24fdda553213df10ec682550d22d5c1272 Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 10:50:29 -0500 Subject: [PATCH 3/7] no-mistakes(review): Harden document reads and section media locations --- src/media.ts | 30 +++++++++++++++-- src/reader.ts | 54 ++++++++++++++++++++++-------- test/read-rich-document.test.ts | 58 +++++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 17 deletions(-) diff --git a/src/media.ts b/src/media.ts index 2b26c5e..a5eae62 100644 --- a/src/media.ts +++ b/src/media.ts @@ -5,6 +5,7 @@ import type { MediaIndexEntry, MediaRecord } from "./types.ts"; interface LocationContext { heading?: string; + sectionNumber?: number; slideNumber?: number; inNotes: boolean; } @@ -27,14 +28,25 @@ function attachmentNameOf(node: OfficeContentNode): string | undefined { function locationFor(context: LocationContext): string { const parts: string[] = []; if (context.slideNumber !== undefined) parts.push(`Slide ${context.slideNumber}`); + if (context.sectionNumber !== undefined) parts.push(`Section ${context.sectionNumber}`); if (context.heading) parts.push(`Section: ${context.heading}`); if (context.inNotes) parts.push("Notes"); return parts.join(" - ") || "Document body"; } -function collectAttachmentLocations(nodes: OfficeContentNode[], locations: Map, parent: LocationContext): void { +export function sectionBoundariesFor(ast: OfficeParserAST): boolean[] | undefined { + if (ast.type !== "docx") return undefined; + return ast.content.map((node) => /)/.test(node.rawContent ?? "")); +} + +function collectAttachmentLocations( + nodes: OfficeContentNode[], + locations: Map, + parent: LocationContext, + sectionBoundaries?: readonly boolean[], +): void { let inherited = { ...parent }; - for (const node of nodes) { + for (const [index, node] of nodes.entries()) { const metadata = node.metadata; const context: LocationContext = { ...inherited }; @@ -67,6 +79,9 @@ function collectAttachmentLocations(nodes: OfficeContentNode[], locations: Map { const locations = new Map(); - collectAttachmentLocations(ast.content, locations, { inNotes: false }); + const sectionBoundaries = sectionBoundariesFor(ast); + const hasMultipleSections = sectionBoundaries?.some( + (boundary, index) => boundary && index < ast.content.length - 1, + ) ?? false; + collectAttachmentLocations( + ast.content, + locations, + { inNotes: false, ...(hasMultipleSections ? { sectionNumber: 1 } : {}) }, + sectionBoundaries, + ); if (ast.auxiliary) { collectAttachmentLocations(ast.auxiliary.headers ?? [], locations, { inNotes: false }); collectAttachmentLocations(ast.auxiliary.footers ?? [], locations, { inNotes: false }); diff --git a/src/reader.ts b/src/reader.ts index 361d74d..bdd3c1a 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,4 +1,5 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { constants } from "node:fs"; +import { mkdtemp, open, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import type { ToolAttachment } from "@opencode-ai/plugin"; @@ -10,6 +11,7 @@ import { mediaTable, selectedAttachments, selectMedia, + sectionBoundariesFor, writeMedia, } from "./media.ts"; import { DocumentPathError, projectPaths, resolveDocumentPath } from "./path-safety.ts"; @@ -128,6 +130,41 @@ function toolAttachments(records: MediaRecord[]): ToolAttachment[] { return selectedAttachments(records); } +async function readValidatedDocument(absolutePath: string, documentInput: string): Promise { + try { + const documentHandle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const documentStat = await documentHandle.stat(); + if (!documentStat.isFile()) { + throw new RichDocumentError("NOT_A_FILE", `Document path is not a regular file: ${documentInput}`); + } + if (documentStat.size > READER_LIMITS.maxArchiveBytes) { + throw new RichDocumentError( + "ARCHIVE_SIZE_LIMIT", + `Document archive is too large (${documentStat.size} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, + ); + } + const input = await readFile(documentHandle); + if (input.byteLength > READER_LIMITS.maxArchiveBytes) { + throw new RichDocumentError( + "ARCHIVE_SIZE_LIMIT", + `Document archive is too large (${input.byteLength} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, + ); + } + return input; + } finally { + await documentHandle.close().catch(() => undefined); + } + } catch (error) { + if (error instanceof RichDocumentError) throw error; + throw new RichDocumentError( + "UNREADABLE_DOCUMENT", + `Document is not readable: ${documentInput} (${errorMessage(error)})`, + { cause: error }, + ); + } +} + function enforceTableCellLimit(ast: OfficeParserAST, sourcePath: string): void { const pending: OfficeContentNode[] = [...ast.content]; for (const nodes of [ast.auxiliary?.headers, ast.auxiliary?.footers, ast.auxiliary?.slideMasters]) { @@ -167,9 +204,7 @@ function contextHeading(text: string): OfficeContentNode { } function addDocumentContext(ast: OfficeParserAST): OfficeParserAST { - const sectionBoundaries = ast.type === "docx" - ? ast.content.map((node) => /)/.test(node.rawContent ?? "")) - : []; + const sectionBoundaries = sectionBoundariesFor(ast) ?? []; const hasMultipleSections = sectionBoundaries.some( (boundary, index) => boundary && index < ast.content.length - 1, ); @@ -289,16 +324,7 @@ export async function readRichDocument( ); } - let input: Buffer; - try { - input = await readFile(resolved.absolutePath); - } catch (error) { - throw new RichDocumentError( - "UNREADABLE_DOCUMENT", - `Document is not readable: ${args.path} (${errorMessage(error)})`, - { cause: error }, - ); - } + const input = await readValidatedDocument(resolved.absolutePath, args.path); const issues: ParseIssue[] = []; const ast = await parseDocument(format, input, context, args.path, issues); diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 0c2a137..232e903 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -61,6 +61,64 @@ describe("read_rich_document", () => { assert.doesNotMatch(result.output, /iVBORw0KGgo/); }); + it("associates media after a DOCX section boundary with the physical section", async () => { + const ast = { + config: {}, + type: "docx", + metadata: {}, + content: [ + { + type: "heading", + text: "First section", + children: [{ type: "text", text: "First section" }], + metadata: { level: 1 }, + }, + { + type: "paragraph", + rawContent: "", + children: [], + }, + { + type: "image", + metadata: { attachmentName: "after-section.png" }, + children: [], + }, + ], + attachments: [ + { + type: "image", + name: "after-section.png", + extension: "png", + mimeType: "image/png", + data: "iVBORw0KGgo=", + }, + ], + warnings: [], + to: async () => ({ value: "after section", messages: [] }), + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".docx", + { + extension: ".docx", + parserType: "docx" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "sectioned.docx"), Buffer.from("fixture")); + + const result = await readRichDocument( + { path: "sectioned.docx" }, + context(), + { formats }, + ); + rememberExtraction(result); + + assert.equal(result.metadata?.media[0].location, "Section 2 - Section: First section"); + }); + it("extracts ODT structure and associates media with the nearest section", async () => { const result = await readRichDocument({ path: "structure.odt" }, context()); rememberExtraction(result); From dcd8a29ea5ba7bd4cecf6574e9c683506ba568da Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 15:49:07 -0500 Subject: [PATCH 4/7] no-mistakes(review): Hardened document reads, cancellation, cleanup, and media handling --- src/media.ts | 41 ++++++++-- src/reader.ts | 102 +++++++++++++++++++++---- test/read-rich-document.test.ts | 128 ++++++++++++++++++++++++++++++++ 3 files changed, 250 insertions(+), 21 deletions(-) diff --git a/src/media.ts b/src/media.ts index a5eae62..bb093eb 100644 --- a/src/media.ts +++ b/src/media.ts @@ -5,6 +5,7 @@ import type { MediaIndexEntry, MediaRecord } from "./types.ts"; interface LocationContext { heading?: string; + role?: string; sectionNumber?: number; slideNumber?: number; inNotes: boolean; @@ -12,6 +13,14 @@ interface LocationContext { const OOXML_DOCUMENT_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; const OOXML_CHART_MIME = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; +const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/bmp", + "image/tiff", + "image/svg+xml", +]); function textOf(node: OfficeContentNode): string { return (node.text ?? "").replace(/\s+/g, " ").trim(); @@ -27,6 +36,7 @@ function attachmentNameOf(node: OfficeContentNode): string | undefined { function locationFor(context: LocationContext): string { const parts: string[] = []; + if (context.role) parts.push(context.role); if (context.slideNumber !== undefined) parts.push(`Slide ${context.slideNumber}`); if (context.sectionNumber !== undefined) parts.push(`Section ${context.sectionNumber}`); if (context.heading) parts.push(`Section: ${context.heading}`); @@ -50,6 +60,9 @@ function collectAttachmentLocations( const metadata = node.metadata; const context: LocationContext = { ...inherited }; + if (metadata && "sectionNumber" in metadata && typeof metadata.sectionNumber === "number") { + context.sectionNumber = metadata.sectionNumber; + } if (node.type === "slide" && metadata && "slideNumber" in metadata) { context.slideNumber = metadata.slideNumber; } @@ -102,7 +115,9 @@ function mediaMimeType(attachment: OfficeAttachment): string { export async function writeMedia( ast: OfficeParserAST, temporaryDirectory: string, + abortSignal?: AbortSignal, ): Promise { + abortSignal?.throwIfAborted(); const locations = new Map(); const sectionBoundaries = sectionBoundariesFor(ast); const hasMultipleSections = sectionBoundaries?.some( @@ -115,15 +130,26 @@ export async function writeMedia( sectionBoundaries, ); if (ast.auxiliary) { - collectAttachmentLocations(ast.auxiliary.headers ?? [], locations, { inNotes: false }); - collectAttachmentLocations(ast.auxiliary.footers ?? [], locations, { inNotes: false }); - collectAttachmentLocations(ast.auxiliary.slideMasters ?? [], locations, { inNotes: false }); + collectAttachmentLocations(ast.auxiliary.headers ?? [], locations, { + inNotes: false, + role: "Header", + }); + collectAttachmentLocations(ast.auxiliary.footers ?? [], locations, { + inNotes: false, + role: "Footer", + }); + collectAttachmentLocations(ast.auxiliary.slideMasters ?? [], locations, { + inNotes: false, + role: "Slide master", + }); } + abortSignal?.throwIfAborted(); await mkdir(temporaryDirectory, { recursive: true, mode: 0o700 }); const records: MediaRecord[] = []; for (const [index, attachment] of ast.attachments.entries()) { + abortSignal?.throwIfAborted(); const label = `media-${index + 1}`; const temporaryPath = join(temporaryDirectory, `${label}${safeExtension(attachment)}`); await writeFile(temporaryPath, Buffer.from(attachment.data, "base64"), { mode: 0o600 }); @@ -138,6 +164,7 @@ export async function writeMedia( records.push({ entry, attachment }); } + abortSignal?.throwIfAborted(); return records; } @@ -165,6 +192,10 @@ export class MediaSelectionError extends Error { } } +function isSupportedImageMimeType(mimeType: string): boolean { + return SUPPORTED_IMAGE_MIME_TYPES.has(mimeType.toLowerCase()); +} + export function selectMedia(records: MediaRecord[], selectors: string[] | undefined): MediaRecord[] { if (!selectors?.length) return []; @@ -177,9 +208,9 @@ export function selectMedia(records: MediaRecord[], selectors: string[] | undefi const available = records.map(({ entry }) => entry.label).join(", ") || "none"; throw new MediaSelectionError(`Unknown media selector "${selector}". Available media labels: ${available}.`); } - if (record.attachment.type !== "image") { + if (record.attachment.type !== "image" || !isSupportedImageMimeType(record.entry.mimeType)) { throw new MediaSelectionError( - `Media selector "${selector}" does not name an image and cannot be attached.`, + `Media selector "${selector}" does not name a supported image and cannot be attached.`, ); } if (!selected.some((item) => item.entry.label === record.entry.label)) selected.push(record); diff --git a/src/reader.ts b/src/reader.ts index bdd3c1a..cde33bd 100644 --- a/src/reader.ts +++ b/src/reader.ts @@ -1,5 +1,5 @@ import { constants } from "node:fs"; -import { mkdtemp, open, readFile, rm } from "node:fs/promises"; +import { mkdtemp, open, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, relative } from "node:path"; import type { ToolAttachment } from "@opencode-ai/plugin"; @@ -80,9 +80,14 @@ function errorMessage(error: unknown): string { return String(error); } +function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === "AbortError"; +} + function parserError(error: unknown, sourcePath: string): Error { if (error instanceof RichDocumentError) return error; if (error instanceof DocumentPathError) return error; + if (isAbortError(error)) return error; const issue = error && typeof error === "object" && "officeIssue" in error && error.officeIssue @@ -130,8 +135,13 @@ function toolAttachments(records: MediaRecord[]): ToolAttachment[] { return selectedAttachments(records); } -async function readValidatedDocument(absolutePath: string, documentInput: string): Promise { +async function readValidatedDocument( + absolutePath: string, + documentInput: string, + abortSignal: AbortSignal, +): Promise { try { + abortSignal.throwIfAborted(); const documentHandle = await open(absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW); try { const documentStat = await documentHandle.stat(); @@ -144,18 +154,34 @@ async function readValidatedDocument(absolutePath: string, documentInput: string `Document archive is too large (${documentStat.size} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, ); } - const input = await readFile(documentHandle); - if (input.byteLength > READER_LIMITS.maxArchiveBytes) { - throw new RichDocumentError( - "ARCHIVE_SIZE_LIMIT", - `Document archive is too large (${input.byteLength} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, + const chunks: Buffer[] = []; + let totalBytes = 0; + while (totalBytes <= READER_LIMITS.maxArchiveBytes) { + abortSignal.throwIfAborted(); + const chunkSize = Math.min( + 1024 * 1024, + READER_LIMITS.maxArchiveBytes + 1 - totalBytes, ); + const chunk = Buffer.allocUnsafe(chunkSize); + const { bytesRead } = await documentHandle.read(chunk, 0, chunkSize, totalBytes); + if (!bytesRead) break; + chunks.push(chunk.subarray(0, bytesRead)); + totalBytes += bytesRead; + if (totalBytes > READER_LIMITS.maxArchiveBytes) { + throw new RichDocumentError( + "ARCHIVE_SIZE_LIMIT", + `Document archive is too large (${totalBytes} bytes). The limit is ${READER_LIMITS.maxArchiveBytes} bytes.`, + ); + } } - return input; + abortSignal.throwIfAborted(); + return Buffer.concat(chunks, totalBytes); } finally { await documentHandle.close().catch(() => undefined); } } catch (error) { + if (abortSignal.aborted) abortSignal.throwIfAborted(); + if (isAbortError(error)) throw error; if (error instanceof RichDocumentError) throw error; throw new RichDocumentError( "UNREADABLE_DOCUMENT", @@ -165,7 +191,18 @@ async function readValidatedDocument(absolutePath: string, documentInput: string } } -function enforceTableCellLimit(ast: OfficeParserAST, sourcePath: string): void { +function enforceTableCellLimit(ast: OfficeParserAST, sourcePath: string, issues: ParseIssue[]): void { + const tableLimitWarning = [...issues.map(({ issue }) => issue), ...ast.warnings].find( + (issue) => String(issue.code) === "TABLE_CELL_LIMIT_EXCEEDED", + ); + if (tableLimitWarning) { + throw new RichDocumentError( + "TABLE_CELL_LIMIT", + `Document ${sourcePath} exceeded the table cell limit during parsing. The limit is ${READER_LIMITS.maxTableCells}.`, + { cause: tableLimitWarning }, + ); + } + const pending: OfficeContentNode[] = [...ast.content]; for (const nodes of [ast.auxiliary?.headers, ast.auxiliary?.footers, ast.auxiliary?.slideMasters]) { if (nodes) { @@ -236,7 +273,8 @@ async function parseDocument( issues: ParseIssue[], ): Promise { try { - return await format.parse(input, { + context.abort.throwIfAborted(); + const ast = await format.parse(input, { ...parserLimits(), extractAttachments: true, ignoreComments: false, @@ -248,7 +286,10 @@ async function parseDocument( abortSignal: context.abort, includeRawContent: format.parserType === "docx", }); + context.abort.throwIfAborted(); + return ast; } catch (error) { + if (context.abort.aborted) context.abort.throwIfAborted(); throw parserError(error, sourcePath); } } @@ -257,15 +298,23 @@ async function extractMedia( ast: OfficeParserAST, baseTemporaryDirectory: string, sourcePath: string, + abortSignal: AbortSignal, ): Promise<{ temporaryDirectory?: string; records: MediaRecord[] }> { let temporaryDirectory: string | undefined; try { + abortSignal.throwIfAborted(); if (ast.attachments.length) { temporaryDirectory = await mkdtemp(join(baseTemporaryDirectory, "opencode-rich-document-")); } - const records = temporaryDirectory ? await writeMedia(ast, temporaryDirectory) : []; + const records = temporaryDirectory ? await writeMedia(ast, temporaryDirectory, abortSignal) : []; + abortSignal.throwIfAborted(); return { temporaryDirectory, records }; } catch (error) { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => undefined); + } + if (abortSignal.aborted) abortSignal.throwIfAborted(); + if (isAbortError(error)) throw error; throw extractionError(error, sourcePath); } } @@ -285,18 +334,25 @@ async function convertMarkdown( ast: OfficeParserAST, sourcePath: string, issues: ParseIssue[], + abortSignal: AbortSignal, ) { try { - return await ast.to("md", { + abortSignal.throwIfAborted(); + const conversion = await ast.to("md", { includeImages: false, includeCharts: false, + abortSignal, onWarning: (issue) => issues.push({ issue, source: "conversion" }), mdConfig: { dialect: "github", fallbackToHtml: true, }, }); + abortSignal.throwIfAborted(); + return conversion; } catch (error) { + if (abortSignal.aborted) abortSignal.throwIfAborted(); + if (isAbortError(error)) throw error; throw conversionError(error, sourcePath); } } @@ -306,9 +362,11 @@ export async function readRichDocument( context: ReaderToolContext, dependencies: ReadRichDocumentDependencies = {}, ): Promise { + context.abort.throwIfAborted(); const projectRoot = projectRootFor(context); const paths = projectPaths(context, args.path); const resolved = await resolveDocumentPath(paths.documentPath, paths.projectRoot); + context.abort.throwIfAborted(); const format = formatForExtension(resolved.extension, dependencies.formats ?? formatRegistry); if (!format) { @@ -324,19 +382,30 @@ export async function readRichDocument( ); } - const input = await readValidatedDocument(resolved.absolutePath, args.path); + const input = await readValidatedDocument(resolved.absolutePath, args.path, context.abort); const issues: ParseIssue[] = []; const ast = await parseDocument(format, input, context, args.path, issues); - enforceTableCellLimit(ast, args.path); + context.abort.throwIfAborted(); + enforceTableCellLimit(ast, args.path, issues); let temporaryDirectory: string | undefined; let keepTemporaryDirectory = false; try { - const extraction = await extractMedia(ast, dependencies.tempDirectory ?? tmpdir(), args.path); + const extraction = await extractMedia( + ast, + dependencies.tempDirectory ?? tmpdir(), + args.path, + context.abort, + ); temporaryDirectory = extraction.temporaryDirectory; + context.abort.throwIfAborted(); const selected = selectRequestedMedia(extraction.records, args.media); - const conversion = await convertMarkdown(addDocumentContext(ast), args.path, issues); + context.abort.throwIfAborted(); + const contextualAst = addDocumentContext(ast); + context.abort.throwIfAborted(); + const conversion = await convertMarkdown(contextualAst, args.path, issues, context.abort); + context.abort.throwIfAborted(); for (const issue of ast.warnings) issues.push({ issue, source: "parser" }); for (const issue of conversion.messages) issues.push({ issue, source: "conversion" }); @@ -354,6 +423,7 @@ export async function readRichDocument( metadata, }; if (attachments.length) result.attachments = attachments; + context.abort.throwIfAborted(); keepTemporaryDirectory = true; return result; } finally { diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 232e903..77f354b 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -119,6 +119,58 @@ describe("read_rich_document", () => { assert.equal(result.metadata?.media[0].location, "Section 2 - Section: First section"); }); + it("labels media from headers, footers, and slide masters", async () => { + const node = (attachmentName: string, sectionNumber?: number) => ({ + type: "image", + metadata: { attachmentName, ...(sectionNumber === undefined ? {} : { sectionNumber }) }, + children: [], + }); + const ast = { + config: {}, + type: "pptx", + metadata: {}, + content: [], + auxiliary: { + headers: [node("header.png", 2)], + footers: [node("footer.png")], + slideMasters: [node("master.png")], + }, + attachments: ["header.png", "footer.png", "master.png"].map((name) => ({ + type: "image" as const, + name, + extension: "png", + mimeType: "image/png", + data: "iVBORw0KGgo=", + })), + warnings: [], + to: async () => ({ value: "auxiliary media", messages: [] }), + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".pptx", + { + extension: ".pptx", + parserType: "pptx" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "auxiliary.pptx"), Buffer.from("fixture")); + + const result = await readRichDocument( + { path: "auxiliary.pptx" }, + context(), + { formats }, + ); + rememberExtraction(result); + + assert.deepEqual( + result.metadata?.media.map((item) => item.location), + ["Header - Section 2", "Footer", "Slide master"], + ); + }); + it("extracts ODT structure and associates media with the nearest section", async () => { const result = await readRichDocument({ path: "structure.odt" }, context()); rememberExtraction(result); @@ -214,6 +266,82 @@ describe("read_rich_document", () => { ); }); + it("rejects image-labeled attachments without an image MIME type", async () => { + const ast = { + config: {}, + type: "docx", + metadata: {}, + content: [{ type: "image", metadata: { attachmentName: "binary.bin" }, children: [] }], + attachments: [ + { + type: "image" as const, + name: "binary.bin", + extension: "bin", + mimeType: "application/octet-stream", + data: "AA==", + }, + ], + warnings: [], + to: async () => ({ value: "binary", messages: [] }), + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".docx", + { + extension: ".docx", + parserType: "docx" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "binary.docx"), Buffer.from("fixture")); + + await assert.rejects( + () => readRichDocument({ path: "binary.docx", media: ["media-1"] }, context(), { formats }), + /supported image/i, + ); + }); + + it("rejects parser-truncated tables before Markdown conversion", async () => { + let converted = false; + const warning = { + type: "warning" as const, + code: "TABLE_CELL_LIMIT_EXCEEDED", + message: "Table cell materialization reached its limit.", + }; + const ast = { + config: {}, + type: "odt", + metadata: {}, + content: [], + attachments: [], + warnings: [warning], + to: async () => { + converted = true; + return { value: "should not convert", messages: [] }; + }, + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".odt", + { + extension: ".odt", + parserType: "odt" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "truncated.odt"), Buffer.from("fixture")); + + await assert.rejects( + () => readRichDocument({ path: "truncated.odt" }, context(), { formats }), + /table cell limit/i, + ); + assert.equal(converted, false); + }); + it("rejects oversized tables before conversion", async () => { const cell = { type: "cell" } as const; const cells = Array.from({ length: READER_LIMITS.maxTableCells + 1 }, () => cell); From bc99e1acf337785a3d06f262772fea3c748206fc Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 17:26:04 -0500 Subject: [PATCH 5/7] no-mistakes(review): Support WebP and linearize media selector resolution --- README.md | 2 ++ src/media.ts | 20 +++++++++++---- test/read-rich-document.test.ts | 43 +++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5d531d4..12776b3 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ To attach selected image media directly to the next model turn, call the tool ag {"path":"docs/architecture.docx","media":["media-1"]} ``` +Selectable image media supports `image/jpeg`, `image/png`, `image/gif`, `image/bmp`, `image/tiff`, `image/svg+xml`, and `image/webp` attachments. + ## Safety and boundaries The source document is read without modification. diff --git a/src/media.ts b/src/media.ts index bb093eb..e96199f 100644 --- a/src/media.ts +++ b/src/media.ts @@ -20,6 +20,7 @@ const SUPPORTED_IMAGE_MIME_TYPES = new Set([ "image/bmp", "image/tiff", "image/svg+xml", + "image/webp", ]); function textOf(node: OfficeContentNode): string { @@ -199,13 +200,19 @@ function isSupportedImageMimeType(mimeType: string): boolean { export function selectMedia(records: MediaRecord[], selectors: string[] | undefined): MediaRecord[] { if (!selectors?.length) return []; + const recordsBySelector = new Map(); + for (const record of records) { + if (!recordsBySelector.has(record.entry.label)) recordsBySelector.set(record.entry.label, record); + if (!recordsBySelector.has(record.entry.originalName)) { + recordsBySelector.set(record.entry.originalName, record); + } + } + const available = records.map(({ entry }) => entry.label).join(", ") || "none"; const selected: MediaRecord[] = []; + const selectedLabels = new Set(); for (const selector of selectors) { - const record = records.find( - ({ entry }) => entry.label === selector || entry.originalName === selector, - ); + const record = recordsBySelector.get(selector); if (!record) { - const available = records.map(({ entry }) => entry.label).join(", ") || "none"; throw new MediaSelectionError(`Unknown media selector "${selector}". Available media labels: ${available}.`); } if (record.attachment.type !== "image" || !isSupportedImageMimeType(record.entry.mimeType)) { @@ -213,7 +220,10 @@ export function selectMedia(records: MediaRecord[], selectors: string[] | undefi `Media selector "${selector}" does not name a supported image and cannot be attached.`, ); } - if (!selected.some((item) => item.entry.label === record.entry.label)) selected.push(record); + if (!selectedLabels.has(record.entry.label)) { + selectedLabels.add(record.entry.label); + selected.push(record); + } } return selected; } diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 77f354b..0d5d30e 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -210,6 +210,49 @@ describe("read_rich_document", () => { assert.doesNotMatch(result.output, /iVBORw0KGgo/); }); + it("attaches selected WebP image media", async () => { + const ast = { + config: {}, + type: "docx", + metadata: {}, + content: [{ type: "image", metadata: { attachmentName: "diagram.webp" }, children: [] }], + attachments: [ + { + type: "image" as const, + name: "diagram.webp", + extension: "webp", + mimeType: "image/webp", + data: "UklGRg==", + }, + ], + warnings: [], + to: async () => ({ value: "webp", messages: [] }), + toText: () => "", + } as unknown as OfficeParserAST; + const formats = new Map([ + [ + ".docx", + { + extension: ".docx", + parserType: "docx" as const, + parse: async () => ast, + }, + ], + ]); + await writeFile(join(fixtures.root, "webp.docx"), Buffer.from("fixture")); + + const result = await readRichDocument( + { path: "webp.docx", media: ["diagram.webp", "media-1", "diagram.webp"] }, + context(), + { formats }, + ); + rememberExtraction(result); + + assert.equal(result.attachments?.length, 1); + assert.equal(result.attachments?.[0].mime, "image/webp"); + assert.match(result.attachments?.[0].filename ?? "", /^media-1\.webp$/); + }); + it("leaves the source document byte-for-byte unchanged", async () => { const beforeBytes = await readFile(fixtures.docx); const beforeStat = await stat(fixtures.docx); From 0384c00f2fc5e3bd553749d340bfe154c7f4eec1 Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 17:34:12 -0500 Subject: [PATCH 6/7] no-mistakes(test): Made symlink fixture paths unique; concurrent focused tests pass --- test/read-rich-document.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/read-rich-document.test.ts b/test/read-rich-document.test.ts index 0d5d30e..4eff756 100644 --- a/test/read-rich-document.test.ts +++ b/test/read-rich-document.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { basename, dirname, join, relative } from "node:path"; import type { OfficeParserAST } from "officeparser"; import { RichDocumentReaderPlugin } from "../src/index.ts"; import { READER_LIMITS } from "../src/limits.ts"; @@ -266,13 +266,13 @@ describe("read_rich_document", () => { }); it("rejects lexical path escapes and symlinks that resolve outside the project", async () => { - const outside = join(dirname(fixtures.root), "outside.docx"); + const outside = join(dirname(fixtures.root), `${basename(fixtures.root)}-outside.docx`); const link = join(fixtures.root, "escaped.docx"); await writeFile(outside, Buffer.from("outside")); await symlink(outside, link); await assert.rejects( - () => readRichDocument({ path: "../outside.docx" }, context()), + () => readRichDocument({ path: relative(fixtures.root, outside) }, context()), /escapes the current project/i, ); await assert.rejects( From e5f39007a230d0f818e913a9b6cb143f8d288a5e Mon Sep 17 00:00:00 2001 From: ddbaron Date: Fri, 14 Aug 2026 17:37:29 -0500 Subject: [PATCH 7/7] no-mistakes(document): Fixed result metadata typing; documentation and lint checks pass --- src/types.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/types.ts b/src/types.ts index 0daa678..30b261a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,9 @@ export interface RichDocumentResultMetadata { media: MediaIndexEntry[]; } -export type RichDocumentToolResult = Extract; +export type RichDocumentToolResult = Omit, "metadata"> & { + metadata?: RichDocumentResultMetadata; +}; export interface MediaRecord { entry: MediaIndexEntry;