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..12776b3 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. @@ -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/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..e96199f --- /dev/null +++ b/src/media.ts @@ -0,0 +1,238 @@ +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; + role?: string; + sectionNumber?: number; + slideNumber?: number; + inNotes: boolean; +} + +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", + "image/webp", +]); + +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.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}`); + if (context.inNotes) parts.push("Notes"); + return parts.join(" - ") || "Document body"; +} + +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 [index, node] of nodes.entries()) { + 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; + } + 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; + } + if (sectionBoundaries?.[index] && index < nodes.length - 1) { + inherited.sectionNumber = (inherited.sectionNumber ?? 1) + 1; + } + } +} + +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"; +} + +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, + abortSignal?: AbortSignal, +): Promise { + abortSignal?.throwIfAborted(); + const locations = new Map(); + 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, + 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 }); + const entry: MediaIndexEntry = { + label, + type: attachment.type, + originalName: attachment.name, + mimeType: mediaMimeType(attachment), + temporaryPath, + location: locations.get(attachment.name) ?? "Document body", + }; + records.push({ entry, attachment }); + } + + abortSignal?.throwIfAborted(); + 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"; + } +} + +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 []; + + 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 = recordsBySelector.get(selector); + if (!record) { + throw new MediaSelectionError(`Unknown media selector "${selector}". Available media labels: ${available}.`); + } + if (record.attachment.type !== "image" || !isSupportedImageMimeType(record.entry.mimeType)) { + throw new MediaSelectionError( + `Media selector "${selector}" does not name a supported image and cannot be attached.`, + ); + } + if (!selectedLabels.has(record.entry.label)) { + selectedLabels.add(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..cde33bd --- /dev/null +++ b/src/reader.ts @@ -0,0 +1,436 @@ +import { constants } from "node:fs"; +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"; +import type { OfficeContentNode, OfficeParserAST } from "officeparser"; +import { formatForExtension, formatRegistry } from "./registry.ts"; +import { READER_LIMITS, parserLimits } from "./limits.ts"; +import { + MediaSelectionError, + mediaTable, + selectedAttachments, + selectMedia, + sectionBoundariesFor, + 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 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 + ? 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 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(); + 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 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.`, + ); + } + } + 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", + `Document is not readable: ${documentInput} (${errorMessage(error)})`, + { cause: error }, + ); + } +} + +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) { + 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 = sectionBoundariesFor(ast) ?? []; + 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, + context: ReaderToolContext, + sourcePath: string, + issues: ParseIssue[], +): Promise { + try { + context.abort.throwIfAborted(); + const ast = 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, + includeRawContent: format.parserType === "docx", + }); + context.abort.throwIfAborted(); + return ast; + } catch (error) { + if (context.abort.aborted) context.abort.throwIfAborted(); + throw parserError(error, sourcePath); + } +} + +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, 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); + } +} + +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[], + abortSignal: AbortSignal, +) { + try { + 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); + } +} + +export async function readRichDocument( + args: ReadRichDocumentArgs, + 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) { + 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.`, + ); + } + + const input = await readValidatedDocument(resolved.absolutePath, args.path, context.abort); + + const issues: ParseIssue[] = []; + const ast = await parseDocument(format, input, context, args.path, issues); + 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, + context.abort, + ); + temporaryDirectory = extraction.temporaryDirectory; + context.abort.throwIfAborted(); + const selected = selectRequestedMedia(extraction.records, args.media); + 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" }); + + 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; + context.abort.throwIfAborted(); + 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..30b261a --- /dev/null +++ b/src/types.ts @@ -0,0 +1,72 @@ +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 = Omit, "metadata"> & { + metadata?: RichDocumentResultMetadata; +}; + +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..0941ecf --- /dev/null +++ b/test/fixtures.ts @@ -0,0 +1,147 @@ +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 + + + Section boundary. + Second Section + + +`, + "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/charts/chart1.xml": ` +Sales chart`, + "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..4eff756 --- /dev/null +++ b/test/read-rich-document.test.ts @@ -0,0 +1,418 @@ +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 { 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"; +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 1/); + assert.match(result.output, /## Section 2/); + 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("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("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); + + 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.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 () => { + 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("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); + 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), `${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: relative(fixtures.root, outside) }, 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, + ); + }); + + 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); + 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, + ); + }); +}); 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"] +}