From c330e72f3c90bd97d7d2e33ec5527bbc39d14b97 Mon Sep 17 00:00:00 2001 From: code-crusher Date: Tue, 14 Jul 2026 00:18:44 +0530 Subject: [PATCH 1/2] fix logo colors --- CHANGELOG.md | 3 +++ package.json | 2 +- src/branding.ts | 12 +++++++++--- src/ui/components/Header.tsx | 8 ++++---- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbef3b4..990ab4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 hardcoded background colors. Diff rows retain their original 5% alpha-blended backgrounds, with OrbCode green (`#3FA266`) and red (`#E34671`) used consistently across themes. +- **The OrbCode company logo is isolated from terminal theming.** Its outer + cyan (`#06E1E7`), inner cyan (`#8BF4F7`), and white core (`#ffffff`) remain + identical in every terminal theme. ## [0.4.3] - 2026-07-13 diff --git a/package.json b/package.json index 3489c7f..578c9bc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "0.4.5", + "version": "0.4.7", "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI", "type": "module", "bin": { diff --git a/src/branding.ts b/src/branding.ts index 9d541ab..e6fa8af 100644 --- a/src/branding.ts +++ b/src/branding.ts @@ -29,14 +29,20 @@ export const COLORS = { user: undefined, inputBorder: "gray", inputBorderInactive: "gray", - orbitalOuter: "cyan", - orbitalInner: "cyanBright", +} as const; + +// Immutable company-logo colors from the OrbCode artwork. Keep these separate +// from the terminal theme so light/dark palettes can never recolor the logo. +export const ORBITAL_COLORS = { + outer: "#06E1E7", + inner: "#8BF4F7", + core: "#ffffff", } as const; /** * Terminal-sized interpretation of design/orbital.svg. Characters identify * its outer cyan field, inner orbit, and white core; Header maps them to - * solid block cells using the source artwork's colors. + * solid block cells using the immutable source-artwork colors above. */ export const ORBITAL_MARK = [ " ooooo", diff --git a/src/ui/components/Header.tsx b/src/ui/components/Header.tsx index e2f09b8..f961d94 100644 --- a/src/ui/components/Header.tsx +++ b/src/ui/components/Header.tsx @@ -2,7 +2,7 @@ import * as os from "node:os" import React from "react" import { Box, Text } from "ink" -import { COLORS, ORBITAL_MARK, PRODUCT_NAME, TAGLINE, VERSION } from "../../branding.js" +import { COLORS, ORBITAL_COLORS, ORBITAL_MARK, PRODUCT_NAME, TAGLINE, VERSION } from "../../branding.js" const MARK_WIDTH = 13 const META_LABEL_WIDTH = 11 @@ -24,10 +24,10 @@ function OrbitalMark() { if (pixel === " ") return {run} const color = pixel === "o" - ? COLORS.orbitalOuter + ? ORBITAL_COLORS.outer : pixel === "i" - ? COLORS.orbitalInner - : COLORS.primary + ? ORBITAL_COLORS.inner + : ORBITAL_COLORS.core const glyph = row === 0 ? "▄" : row === ORBITAL_MARK.length - 1 ? "▀" : "█" return {glyph.repeat(run.length)} })} From 31c7717e320b2d6f8097d98e4a188313111a14ed Mon Sep 17 00:00:00 2001 From: "matterai-app[bot]" Date: Tue, 14 Jul 2026 00:44:14 +0530 Subject: [PATCH 2/2] feat(attachments): add file attachment system with drag-and-drop and document extraction Introduce a comprehensive attachment system that allows users to attach files to their prompts via the native OS file picker (/attach or Ctrl+F), drag-and-drop from the terminal, or the programmatic API. Key changes: - New src/attachments.ts module with file parsing, validation, and text extraction for CSV, DOCX, JSON, MD, PDF, TXT, XLSX, and images (PNG, JPEG, WebP) - Document text extraction using mammoth (DOCX), pdf-parse (PDF), and read-excel-file (XLSX); plain-text fallback for other formats - Image signature validation with proper MIME type detection - Size and count limits: 20 files, 10 MB per file, 25 MB total, 200K chars per file, 500K chars total - AI SDK client updated to send image parts as data URLs for vision-capable models - Agent runTurn now accepts attachments, formats them as XML context blocks, and sends images as multi-part content when the model supports them - UI composer shows attached files with visual indicators, supports backspace-to-remove and escape-to-clear - Drag-and-drop detection: multi-file paths pasted from the terminal are consumed as attachments only when every token is a supported file - Queue system updated to carry attachments through multi-turn flows - Session transcript stores attachment summaries for history/resume - Updated README with attachment feature documentation - Added test:attachments npm script and unit tests for path parsing, document extraction, and content limits - Added dependencies: mammoth, pdf-parse, read-excel-file --- README.md | 10 + package-lock.json | 527 ++++++++++++++++++++++++++++++++- package.json | 8 +- src/api/aiSdkClient.ts | 20 +- src/attachments.ts | 402 +++++++++++++++++++++++++ src/core/agent.ts | 57 +++- src/core/sessions.ts | 3 +- src/ui/App.tsx | 50 +++- src/ui/components/InputBox.tsx | 160 ++++++++-- src/ui/components/rows.tsx | 15 +- test/attachments.test.ts | 68 +++++ 11 files changed, 1264 insertions(+), 56 deletions(-) create mode 100644 src/attachments.ts create mode 100644 test/attachments.test.ts diff --git a/README.md b/README.md index f305ed1..d3d23e2 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,13 @@ MatterAI gateway untouched. as a compact Tasks panel (`□` pending / `◧` in progress / `■` done). - **@-references**: type `@` in the input to fuzzy-search workspace files; ↑/↓ to choose, enter/tab inserts the top/selected match into the prompt. +- **Attachments**: use `/attach` or `ctrl+f` to open the native file picker, or + drag files onto the input box (the terminal pastes their paths). CSV, XLSX, + DOCX, PDF, plain-text, JSON, Markdown, PNG, JPEG, and WebP are supported. + Documents are extracted to bounded text before the model call; + attached filenames stay visible in the composer, queue, transcript, and + resumed session history. Backspace on an empty prompt removes the last file, + and escape clears the composer. - **Followup questions**: `ask_followup_question` renders a selectable menu (arrow keys, number quick-pick, or free-text answer). - **Completion**: `attempt_completion` renders a bordered "✔ Task completed" @@ -287,6 +294,7 @@ MatterAI gateway untouched. | command | action | | ------------ | ----------------------------------------------------------------------------------------------------- | | `/help` | list commands | +| `/attach` | open the native file picker and add one or more attachments | | `/model` | scrollable model picker (`/model pro` / `/model mini` / full id selects directly) | | `/clear` | clear the screen only, like the terminal's `clear` — the conversation and context continue | | `/new` | start a fresh conversation/session with a clean slate | @@ -316,6 +324,7 @@ MatterAI gateway untouched. | `↑` / `↓` | input history, or navigate any open menu | | `Ctrl+A` / `Ctrl+E` | start / end of line | | `Ctrl+U` | clear the input line | +| `Ctrl+F` | open the native file picker to add attachments | ## Approvals & safety @@ -950,6 +959,7 @@ Backend/web pieces of the device-auth flow live in: ```bash node test-ui.mjs # in-process TUI test with a fake TTY node test-device-auth.mjs # device-auth polling flow against a local mock +npm run test:attachments # attachment path detection and safe extraction limits ``` - `test-ui.mjs` drives the real App (ink-testing-library technique): header, diff --git a/package-lock.json b/package-lock.json index 0d3762a..df8bb99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@matterailab/orbcode", - "version": "0.4.2", + "version": "0.4.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@matterailab/orbcode", - "version": "0.4.2", + "version": "0.4.7", "license": "MIT", "dependencies": { "@ai-sdk/anthropic": "^3.0.85", @@ -15,10 +15,13 @@ "ai": "^6.0.207", "chalk": "^5.3.0", "ink": "^5.2.1", + "mammoth": "^1.12.0", "open": "^10.1.0", "openai": "^4.78.0", + "pdf-parse": "^2.4.5", "picomatch": "^4.0.4", - "react": "^18.3.1" + "react": "^18.3.1", + "read-excel-file": "^9.2.0" }, "bin": { "orbcode": "bin/orbcode.js" @@ -619,6 +622,205 @@ } } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.80.tgz", + "integrity": "sha512-DxuT1ClnIPts1kQx8FBmkk4BQDTfI5kIzywAaMjQSXfNnra5UFU9PwurXrl+Je3bJ6BGsp/zmshVVFbCmyI+ww==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.80", + "@napi-rs/canvas-darwin-arm64": "0.1.80", + "@napi-rs/canvas-darwin-x64": "0.1.80", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.80", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.80", + "@napi-rs/canvas-linux-arm64-musl": "0.1.80", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-gnu": "0.1.80", + "@napi-rs/canvas-linux-x64-musl": "0.1.80", + "@napi-rs/canvas-win32-x64-msvc": "0.1.80" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.80.tgz", + "integrity": "sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.80.tgz", + "integrity": "sha512-O64APRTXRUiAz0P8gErkfEr3lipLJgM6pjATwavZ22ebhjYl/SUbpgM0xcWPQBNMP1n29afAC/Us5PX1vg+JNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.80.tgz", + "integrity": "sha512-FqqSU7qFce0Cp3pwnTjVkKjjOtxMqRe6lmINxpIZYaZNnVI0H5FtsaraZJ36SiTHNjZlUB69/HhxNDT1Aaa9vA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.80.tgz", + "integrity": "sha512-eyWz0ddBDQc7/JbAtY4OtZ5SpK8tR4JsCYEZjCE3dI8pqoWUC8oMwYSBGCYfsx2w47cQgQCgMVRVTFiiO38hHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.80.tgz", + "integrity": "sha512-qwA63t8A86bnxhuA/GwOkK3jvb+XTQaTiVML0vAWoHyoZYTjNs7BzoOONDgTnNtr8/yHrq64XXzUoLqDzU+Uuw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.80.tgz", + "integrity": "sha512-1XbCOz/ymhj24lFaIXtWnwv/6eFHXDrjP0jYkc6iHQ9q8oXKzUX1Lc6bu+wuGiLhGh2GS/2JlfORC5ZcXimRcg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.80.tgz", + "integrity": "sha512-XTzR125w5ZMs0lJcxRlS1K3P5RaZ9RmUsPtd1uGt+EfDyYMu4c6SEROYsxyatbbu/2+lPe7MPHOO/0a0x7L/gw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.80.tgz", + "integrity": "sha512-BeXAmhKg1kX3UCrJsYbdQd3hIMDH/K6HnP/pG2LuITaXhXBiNdh//TVVVVCBbJzVQaV5gK/4ZOCMrQW9mvuTqA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.80.tgz", + "integrity": "sha512-x0XvZWdHbkgdgucJsRxprX/4o4sEed7qo9rCQA9ugiS9qE2QvP0RIiEugtZhfLH3cyI+jIRFJHV4Fuz+1BHHMg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.80", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.80.tgz", + "integrity": "sha512-Z8jPsM6df5V8B1HrCHB05+bDiCxjE9QA//3YrkKIdVDEwn5RKaqOxCJDRJkl48cJbylcrJbW4HxZbTte8juuPg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -687,6 +889,15 @@ "node": ">= 20" } }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -839,6 +1050,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -857,6 +1077,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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": "MIT" + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, "node_modules/body-parser": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", @@ -1091,6 +1337,12 @@ "node": ">=6.6.0" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -1204,6 +1456,21 @@ "node": ">= 0.8" } }, + "node_modules/dingbat-to-unicode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz", + "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", + "license": "BSD-2-Clause" + }, + "node_modules/duck": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/duck/-/duck-0.1.12.tgz", + "integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==", + "license": "BSD", + "dependencies": { + "underscore": "^1.13.1" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1510,6 +1777,12 @@ ], "license": "BSD-3-Clause" }, + "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/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1532,16 +1805,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -1665,6 +1938,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1758,6 +2037,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/indent-string": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", @@ -1923,6 +2208,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1962,6 +2253,57 @@ "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", "license": "BSD-2-Clause" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1974,6 +2316,41 @@ "loose-envify": "cli.js" } }, + "node_modules/lop": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/lop/-/lop-0.4.2.tgz", + "integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==", + "license": "BSD-2-Clause", + "dependencies": { + "duck": "^0.1.12", + "option": "~0.2.1", + "underscore": "^1.13.1" + } + }, + "node_modules/mammoth": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.12.0.tgz", + "integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==", + "license": "BSD-2-Clause", + "dependencies": { + "@xmldom/xmldom": "^0.8.6", + "argparse": "~1.0.3", + "base64-js": "^1.5.1", + "bluebird": "~3.4.0", + "dingbat-to-unicode": "^1.0.1", + "jszip": "^3.7.1", + "lop": "^0.4.2", + "path-is-absolute": "^1.0.0", + "underscore": "^1.13.1", + "xmlbuilder": "^10.0.0" + }, + "bin": { + "mammoth": "bin/mammoth" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -2069,6 +2446,12 @@ } } }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2189,6 +2572,18 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", "license": "MIT" }, + "node_modules/option": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/option/-/option-0.2.4.tgz", + "integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==", + "license": "BSD-2-Clause" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -2207,6 +2602,15 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2226,6 +2630,38 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pdf-parse": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/pdf-parse/-/pdf-parse-2.4.5.tgz", + "integrity": "sha512-mHU89HGh7v+4u2ubfnevJ03lmPgQ5WU4CxAVmTSh/sxVTEDYd1er/dKS/A6vg77NX47KTEoihq8jZBLr8Cxuwg==", + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/canvas": "0.1.80", + "pdfjs-dist": "5.4.296" + }, + "bin": { + "pdf-parse": "bin/cli.mjs" + }, + "engines": { + "node": ">=20.16.0 <21 || >=22.3.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/mehmet-kozan" + } + }, + "node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -2247,6 +2683,12 @@ "node": ">=16.20.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2327,6 +2769,29 @@ "react": "^18.3.1" } }, + "node_modules/read-excel-file": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-9.2.0.tgz", + "integrity": "sha512-jwSi8Q9oCmswrYMkuiKvJ6VdHbAXGQnANSOaHBNQZEm2XvaXx8d0t/V7oepgHqT9YWxlQE3HEuCtDZq8BZBmvw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "fflate": "^0.8.3", + "unzipper-esm": "^0.13.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/read-excel-file/node_modules/@xmldom/xmldom": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2465,6 +2930,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -2601,6 +3072,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "license": "BSD-3-Clause" + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -2770,6 +3247,12 @@ "node": ">=14.17" } }, + "node_modules/underscore": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -2785,6 +3268,25 @@ "node": ">= 0.8" } }, + "node_modules/unzipper-esm": { + "version": "0.13.2", + "resolved": "https://registry.npmjs.org/unzipper-esm/-/unzipper-esm-0.13.2.tgz", + "integrity": "sha512-lt8GtgDYV8YcAFZNQuLyR2QvHI8C/TstpgsdjUn9ZxiWLJgn+e5uW6DsO3e/HUJVuWD57ZLLFMZ9xk26tePuHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.2", + "node-int64": "^0.4.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2899,6 +3401,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/yoga-layout": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", diff --git a/package.json b/package.json index 578c9bc..38af0f7 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "dev": "tsx src/index.tsx", "start": "node dist/index.js", + "test:attachments": "node --import tsx --test test/attachments.test.ts", "prepublishOnly": "npm run typecheck && npm run build" }, "dependencies": { @@ -42,10 +43,13 @@ "ai": "^6.0.207", "chalk": "^5.3.0", "ink": "^5.2.1", + "mammoth": "^1.12.0", "open": "^10.1.0", "openai": "^4.78.0", + "pdf-parse": "^2.4.5", "picomatch": "^4.0.4", - "react": "^18.3.1" + "react": "^18.3.1", + "read-excel-file": "^9.2.0" }, "devDependencies": { "@types/node": "^20.17.0", @@ -66,4 +70,4 @@ "overrides": { "formdata-node": "^6.0.3" } -} \ No newline at end of file +} diff --git a/src/api/aiSdkClient.ts b/src/api/aiSdkClient.ts index 48ce276..20ef782 100644 --- a/src/api/aiSdkClient.ts +++ b/src/api/aiSdkClient.ts @@ -5,6 +5,7 @@ import { streamText, tool, type AssistantContent, + type ImagePart, type LanguageModel, type LanguageModelUsage, type ModelMessage, @@ -203,9 +204,24 @@ function toModelMessages( const out: ModelMessage[] = [] for (const message of messages) { switch (message.role) { - case "user": - out.push({ role: "user", content: contentToText(message.content) }) + case "user": { + if (typeof message.content === "string") { + out.push({ role: "user", content: message.content }) + break + } + const parts: Array = [] + for (const part of message.content) { + if (part.type === "text") { + parts.push({ type: "text", text: part.text }) + } else if (part.type === "image_url") { + const url = part.image_url.url + const mediaType = /^data:([^;,]+)/.exec(url)?.[1] + parts.push({ type: "image", image: new URL(url), ...(mediaType ? { mediaType } : {}) }) + } + } + out.push({ role: "user", content: parts }) break + } case "assistant": { const parts: Array = [] if (includeReasoning) parts.push(...storedReasoningParts(message)) diff --git a/src/attachments.ts b/src/attachments.ts new file mode 100644 index 0000000..7b91f3f --- /dev/null +++ b/src/attachments.ts @@ -0,0 +1,402 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { fileURLToPath } from "node:url" +import { spawn } from "node:child_process" + +import mammoth from "mammoth" +import { PDFParse } from "pdf-parse" +import readXlsxFile from "read-excel-file/node" + +const MAX_ATTACHMENT_COUNT = 20 +const MAX_SOURCE_FILE_BYTES = 10 * 1024 * 1024 +const MAX_SOURCE_BYTES_TOTAL = 25 * 1024 * 1024 +const MAX_EXTRACTED_CHARACTERS_PER_FILE = 200_000 +const MAX_EXTRACTED_CHARACTERS_TOTAL = 500_000 + +const IMAGE_MIME_TYPES: Record = { + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".png": "image/png", + ".webp": "image/webp", +} + +const DOCUMENT_EXTENSIONS = new Set([ + ".csv", + ".docx", + ".json", + ".log", + ".md", + ".pdf", + ".text", + ".tsv", + ".txt", + ".xlsx", + ".xml", + ".yaml", + ".yml", +]) + +export interface AttachmentSummary { + name: string + kind: "document" | "image" + truncated?: boolean +} + +interface BaseAttachment extends AttachmentSummary { + path: string + size: number +} + +export interface DocumentAttachment extends BaseAttachment { + kind: "document" + text: string +} + +export interface ImageAttachment extends BaseAttachment { + kind: "image" + dataUrl: string + mediaType: string +} + +export type Attachment = DocumentAttachment | ImageAttachment + +export interface SubmittedPrompt { + text: string + attachments: Attachment[] +} + +export interface ParseAttachmentsResult { + attachments: Attachment[] + errors: string[] +} + +/** Open the host operating system's native file chooser. */ +export async function pickAttachmentPaths(cwd: string): Promise { + let output: string | null | undefined + if (process.platform === "darwin") { + const script = [ + 'set startFolder to POSIX file (system attribute "ORBCODE_PICKER_CWD")', + 'set chosenFiles to choose file with prompt "Attach files to OrbCode" default location startFolder with multiple selections allowed', + 'set selectedPaths to ""', + 'repeat with chosenFile in chosenFiles', + ' set selectedPaths to selectedPaths & POSIX path of chosenFile & linefeed', + 'end repeat', + 'return selectedPaths', + ].join("\n") + output = await runPickerCommand("osascript", ["-e", script], cwd) + } else if (process.platform === "win32") { + const script = [ + "Add-Type -AssemblyName System.Windows.Forms", + "$picker = New-Object System.Windows.Forms.OpenFileDialog", + "$picker.Multiselect = $true", + "$picker.Title = 'Attach files to OrbCode'", + "$picker.InitialDirectory = $env:ORBCODE_PICKER_CWD", + "$picker.Filter = 'Supported files|*.csv;*.docx;*.json;*.log;*.md;*.pdf;*.text;*.tsv;*.txt;*.xlsx;*.xml;*.yaml;*.yml;*.jpeg;*.jpg;*.png;*.webp|All files|*.*'", + "if ($picker.ShowDialog() -eq 'OK') { $picker.FileNames -join \"`n\" }", + ].join("; ") + output = await runPickerCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], cwd) + } else if (process.platform === "linux") { + output = await runPickerCommand( + "zenity", + [ + "--file-selection", + "--multiple", + "--separator=\n", + "--title=Attach files to OrbCode", + `--filename=${cwd}${path.sep}`, + "--file-filter=Supported files | *.csv *.docx *.json *.log *.md *.pdf *.text *.tsv *.txt *.xlsx *.xml *.yaml *.yml *.jpeg *.jpg *.png *.webp", + ], + cwd, + ) + if (output === undefined) { + output = await runPickerCommand( + "kdialog", + [ + "--getopenfilename", + cwd, + "*.csv *.docx *.json *.log *.md *.pdf *.text *.tsv *.txt *.xlsx *.xml *.yaml *.yml *.jpeg *.jpg *.png *.webp", + "--multiple", + "--separate-output", + "--title", + "Attach files to OrbCode", + ], + cwd, + ) + } + } else { + throw new Error(`File picker is not supported on ${process.platform}`) + } + + if (output === undefined) { + throw new Error("No supported system file picker was found") + } + if (output === null) return [] + return output + .split(/\r?\n/) + .map((filePath) => filePath.trim()) + .filter(Boolean) + .map((filePath) => path.resolve(filePath)) +} + +export function attachmentSummary(attachment: Attachment): AttachmentSummary { + return { + name: attachment.name, + kind: attachment.kind, + ...(attachment.truncated ? { truncated: true } : {}), + } +} + +export function isSupportedAttachmentPath(filePath: string): boolean { + const extension = path.extname(filePath).toLowerCase() + return extension in IMAGE_MIME_TYPES || DOCUMENT_EXTENSIONS.has(extension) +} + +/** + * Terminal emulators implement file drag-and-drop by pasting one or more paths. + * Only consume the input when every pasted token is an existing supported file, + * so ordinary pasted prose remains ordinary prompt text. + */ +export function droppedAttachmentPaths(input: string, cwd: string): string[] { + const cleanInput = input.replace(/\u001b\[200~/g, "").replace(/\u001b\[201~/g, "").trim() + if (!cleanInput) return [] + + const tokens = splitShellPaths(cleanInput) + if (tokens.length === 0) return [] + + const resolved = tokens.map((token) => resolvePathToken(token, cwd)) + if ( + resolved.some((filePath) => { + try { + return !isSupportedAttachmentPath(filePath) || !fs.statSync(filePath).isFile() + } catch { + return true + } + }) + ) { + return [] + } + + return resolved +} + +export async function parseAttachments( + filePaths: string[], + existing: Attachment[] = [], +): Promise { + const attachments: Attachment[] = [] + const errors: string[] = [] + const existingPaths = new Set(existing.map((attachment) => attachment.path)) + let totalSourceBytes = existing.reduce((total, attachment) => total + attachment.size, 0) + let totalExtractedCharacters = existing.reduce( + (total, attachment) => total + (attachment.kind === "document" ? attachment.text.length : 0), + 0, + ) + + for (const filePath of filePaths) { + const name = path.basename(filePath) + try { + if (existing.length + attachments.length >= MAX_ATTACHMENT_COUNT) { + throw new Error(`Only ${MAX_ATTACHMENT_COUNT} attachments can be added to one message`) + } + if (existingPaths.has(filePath) || attachments.some((attachment) => attachment.path === filePath)) { + throw new Error("File is already attached") + } + if (!isSupportedAttachmentPath(filePath)) { + throw new Error(`Unsupported file type ${path.extname(filePath) || "(none)"}`) + } + + const stat = await fs.promises.stat(filePath) + if (!stat.isFile()) throw new Error("Only files can be attached") + if (stat.size > MAX_SOURCE_FILE_BYTES) { + throw new Error("File is larger than the 10 MB attachment limit") + } + if (totalSourceBytes + stat.size > MAX_SOURCE_BYTES_TOTAL) { + throw new Error("The 25 MB total attachment limit has been reached") + } + + const extension = path.extname(filePath).toLowerCase() + const buffer = await fs.promises.readFile(filePath) + const mediaType = IMAGE_MIME_TYPES[extension] + if (mediaType) { + validateImageSignature(buffer, extension) + attachments.push({ + kind: "image", + name, + path: filePath, + size: stat.size, + mediaType, + dataUrl: `data:${mediaType};base64,${buffer.toString("base64")}`, + }) + totalSourceBytes += stat.size + continue + } + + const extracted = await extractDocumentText(buffer, extension) + if (!extracted.trim()) throw new Error("No extractable text was found") + const remainingCharacters = MAX_EXTRACTED_CHARACTERS_TOTAL - totalExtractedCharacters + if (remainingCharacters <= 0) { + throw new Error("The 500,000 character attachment limit has been reached") + } + const characterLimit = Math.min(MAX_EXTRACTED_CHARACTERS_PER_FILE, remainingCharacters) + const text = truncateExtractedText(extracted, characterLimit) + attachments.push({ + kind: "document", + name, + path: filePath, + size: stat.size, + text, + truncated: text.length < extracted.length, + }) + totalSourceBytes += stat.size + totalExtractedCharacters += text.length + } catch (error) { + errors.push(`${name}: ${error instanceof Error ? error.message : String(error)}`) + } + } + + return { attachments, errors } +} + +export function formatAttachmentContext(attachments: Attachment[]): string { + const documents = attachments.filter((attachment): attachment is DocumentAttachment => attachment.kind === "document") + if (documents.length === 0) return "" + return [ + "", + ...documents.map((document) => { + const safeName = document.name.replace(/[\r\n"]/g, " ") + return `\n${document.text}\n` + }), + "", + ].join("\n") +} + +function resolvePathToken(token: string, cwd: string): string { + if (token.startsWith("file://")) { + try { + return fileURLToPath(token) + } catch { + return token + } + } + const expanded = token === "~" || token.startsWith("~/") ? path.join(process.env.HOME ?? cwd, token.slice(2)) : token + return path.resolve(cwd, expanded) +} + +function splitShellPaths(input: string): string[] { + const tokens: string[] = [] + let current = "" + let quote: "'" | '"' | null = null + + for (let index = 0; index < input.length; index++) { + const character = input[index] + if (quote) { + if (character === quote) quote = null + else current += character + continue + } + if (character === "'" || character === '"') { + quote = character + continue + } + if (character === "\\" && index + 1 < input.length) { + current += input[++index] + continue + } + if (/\s/.test(character)) { + if (current) tokens.push(current) + current = "" + continue + } + current += character + } + if (current) tokens.push(current) + return quote ? [] : tokens +} + +async function extractDocumentText(buffer: Buffer, extension: string): Promise { + switch (extension) { + case ".pdf": { + const parser = new PDFParse({ data: buffer }) + try { + return (await parser.getText()).text + } finally { + await parser.destroy() + } + } + case ".docx": + return (await mammoth.extractRawText({ buffer })).value + case ".xlsx": { + const sheets = await readXlsxFile(buffer) + return sheets + .map( + (sheet) => + `--- Sheet: ${sheet.sheet} ---\n${sheet.data + .slice(0, 50_000) + .map((row) => row.map(formatCellValue).join("\t").trimEnd()) + .filter(Boolean) + .join("\n")}`, + ) + .join("\n\n") + } + default: + if (buffer.subarray(0, 8_192).includes(0)) throw new Error("File does not appear to contain plain text") + return buffer.toString("utf8").replace(/^\uFEFF/, "") + } +} + +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return "" + if (value instanceof Date) return value.toISOString() + return String(value).replace(/[\r\n]+/g, " ") +} + +function truncateExtractedText(text: string, limit: number): string { + if (text.length <= limit) return text + const marker = "\n[...attachment content truncated...]\n" + if (limit <= marker.length) return text.slice(0, limit) + const contentLimit = limit - marker.length + const startLength = Math.floor(contentLimit * 0.2) + return text.slice(0, startLength) + marker + text.slice(-(contentLimit - startLength)) +} + +function validateImageSignature(buffer: Buffer, extension: string): void { + const isJpeg = buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff + const isPng = buffer.length >= 8 && buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + const isWebp = + buffer.length >= 12 && + buffer.subarray(0, 4).toString("ascii") === "RIFF" && + buffer.subarray(8, 12).toString("ascii") === "WEBP" + if ((extension === ".png" && !isPng) || ((extension === ".jpg" || extension === ".jpeg") && !isJpeg) || (extension === ".webp" && !isWebp)) { + throw new Error("File contents do not match the image extension") + } +} + +/** `undefined` means the executable is absent; `null` means the picker was cancelled. */ +function runPickerCommand(command: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ORBCODE_PICKER_CWD: cwd }, + stdio: ["ignore", "pipe", "pipe"], + }) + let stdout = "" + let stderr = "" + child.stdout.setEncoding("utf8") + child.stderr.setEncoding("utf8") + child.stdout.on("data", (chunk: string) => { + if (stdout.length < 1024 * 1024) stdout += chunk + }) + child.stderr.on("data", (chunk: string) => { + if (stderr.length < 16_384) stderr += chunk + }) + child.on("error", (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") resolve(undefined) + else reject(error) + }) + child.on("close", (code) => { + if (code === 0) resolve(stdout) + else if (code === 1) resolve(null) + else reject(new Error(stderr.trim() || `File picker exited with code ${code ?? "unknown"}`)) + }) + }) +} diff --git a/src/core/agent.ts b/src/core/agent.ts index e641ab3..76e18a9 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -3,7 +3,13 @@ import * as path from "node:path" import { randomUUID } from "node:crypto" import type OpenAI from "openai" +import { + type Attachment, + attachmentSummary, + formatAttachmentContext, +} from "../attachments.js" import { REASONING_DETAILS_FIELD, type LLMClient } from "../api/llmClient.js" +import { getModel } from "../api/models.js" import { createLLMClient } from "../api/provider.js" import { buildSystemPrompt } from "../prompts/system.js" import { @@ -572,8 +578,16 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` /** Conversation history with internal markers stripped, ready for the model. */ private outgoingMessages(): OpenAI.Chat.ChatCompletionMessageParam[] { return this.messages.map((message) => - message.role === "user" && typeof message.content === "string" - ? { ...message, content: stripUserQueryTags(message.content) } + message.role === "user" + ? { + ...message, + content: + typeof message.content === "string" + ? stripUserQueryTags(message.content) + : message.content.map((part) => + part.type === "text" ? { ...part, text: stripUserQueryTags(part.text) } : part, + ), + } : message, ) } @@ -590,10 +604,14 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } } - async runTurn(userText: string): Promise { + async runTurn(userText: string, attachments: Attachment[] = []): Promise { const { onEvent } = this.options.callbacks this.abortController = new AbortController() - this.transcript.push({ kind: "user", text: userText }) + this.transcript.push({ + kind: "user", + text: userText, + ...(attachments.length > 0 ? { attachments: attachments.map(attachmentSummary) } : {}), + }) await this.maybeFireSessionStart() @@ -612,10 +630,21 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` } if (!this.title) { - this.title = userText.replace(/\s+/g, " ").trim().slice(0, 80) + this.title = (userText || attachments.map((attachment) => attachment.name).join(", ")) + .replace(/\s+/g, " ") + .trim() + .slice(0, 80) } let userContent = `\n${userText}\n` + const attachmentContext = formatAttachmentContext(attachments) + if (attachmentContext) userContent = `${userContent}\n\n${attachmentContext}` + const imageAttachments = attachments.filter((attachment) => attachment.kind === "image") + if (imageAttachments.length > 0) { + userContent = `${userContent}\n\n\n${imageAttachments + .map((attachment) => ``) + .join("\n")}\n` + } if (!this.firstMessageSent) { userContent = `${this.buildEnvironmentDetails()}\n\n${userContent}` this.firstMessageSent = true @@ -630,7 +659,23 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` if (promptContext) { userContent = `${userContent}\n\n${wrapHookContext("UserPromptSubmit", promptContext)}` } - this.messages.push({ role: "user", content: userContent }) + const supportedImages = getModel(this.options.modelId).supportsImages ? imageAttachments : [] + if (supportedImages.length !== imageAttachments.length) { + onEvent({ type: "system", message: "The current model does not support image attachments.", isError: true }) + } + this.messages.push({ + role: "user", + content: + supportedImages.length > 0 + ? [ + { type: "text", text: userContent }, + ...supportedImages.map((attachment) => ({ + type: "image_url" as const, + image_url: { url: attachment.dataUrl }, + })), + ] + : userContent, + }) try { this.stopHookActive = false diff --git a/src/core/sessions.ts b/src/core/sessions.ts index 9272e78..912f878 100644 --- a/src/core/sessions.ts +++ b/src/core/sessions.ts @@ -2,10 +2,11 @@ import * as fs from "node:fs" import * as path from "node:path" import type OpenAI from "openai" +import type { AttachmentSummary } from "../attachments.js" import { getConfigDir } from "../config/settings.js" export type SessionTranscriptEntry = - | { kind: "user"; text: string } + | { kind: "user"; text: string; attachments?: AttachmentSummary[] } | { kind: "assistant"; text: string } | { kind: "reasoning"; text: string; durationMs: number } | { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 03d7de0..583d46e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -9,6 +9,10 @@ import { Box, Text, useApp, useInput, useStdout } from "ink"; import open from "open"; import * as path from "node:path"; +import { + type SubmittedPrompt, + attachmentSummary, +} from "../attachments.js"; import { COLORS, ORBITAL_MARK, @@ -77,6 +81,7 @@ import { const SLASH_COMMANDS: SlashCommand[] = [ { name: "/help", description: "show available commands" }, + { name: "/attach", description: "choose files to attach" }, { name: "/model", description: "select the Axon model to use" }, { name: "/clear", @@ -384,7 +389,7 @@ export function App({ // FIFO queue of messages the user typed while the LLM was still streaming. // Drained one-per-turn on each `turn-end` event so multi-step work can // keep flowing without making the user wait for the previous response. - const [queuedMessages, setQueuedMessages] = useState([]); + const [queuedMessages, setQueuedMessages] = useState([]); const [modelPickerOpen, setModelPickerOpen] = useState(false); const [resumableSessions, setResumableSessions] = useState< SessionData[] | null @@ -449,7 +454,7 @@ export function App({ // Mirror of `queuedMessages` for the agent event handler (kept on a ref so // we can drain it inside `handleEvent` without re-creating that callback // on every keystroke). - const queueRef = useRef([]); + const queueRef = useRef([]); // Holds the startup prompt while we wait for a project-hook trust decision. const deferredPromptRef = useRef(null); // Guards endAndExit against double-invocation (Ctrl+D spam). @@ -462,12 +467,12 @@ export function App({ // /new or /resume) keeps the override instead of falling back to default. const systemPromptOverrideRef = useRef(systemPromptOverride); - const enqueueMessage = useCallback((text: string) => { - queueRef.current = [...queueRef.current, text]; + const enqueueMessage = useCallback((prompt: SubmittedPrompt) => { + queueRef.current = [...queueRef.current, prompt]; setQueuedMessages(queueRef.current); }, []); - const drainQueue = useCallback((): string | null => { + const drainQueue = useCallback((): SubmittedPrompt | null => { if (queueRef.current.length === 0) return null; const [next, ...rest] = queueRef.current; queueRef.current = rest; @@ -667,10 +672,14 @@ export function App({ // synchronously before emitting this event. const nextQueued = drainQueue(); if (nextQueued !== null && agentRef.current) { - pushRow({ kind: "user", text: nextQueued }); + pushRow({ + kind: "user", + text: nextQueued.text, + attachments: nextQueued.attachments.map(attachmentSummary), + }); setBusy(true); setBusyLabel("Thinking"); - void agentRef.current.runTurn(nextQueued); + void agentRef.current.runTurn(nextQueued.text, nextQueued.attachments); } break; } @@ -1095,11 +1104,16 @@ export function App({ ); const handleSubmit = useCallback( - (value: string) => { + (input: string | SubmittedPrompt) => { + const prompt = typeof input === "string" ? { text: input, attachments: [] } : input; + const { text, attachments } = prompt; smoothScrollPendingRef.current = 0; setScrollOffset(0); - if (value.startsWith("/")) { - handleCommand(value); + if (text.startsWith("/")) { + if (attachments.length > 0) { + pushRow({ kind: "info", text: "Attachments are ignored for slash commands." }); + } + handleCommand(text); return; } if (!getAuthToken(settings)) { @@ -1110,13 +1124,17 @@ export function App({ // queue instead of dropping it on the floor. `handleEvent`'s // `turn-end` case drains the queue and starts the next turn. if (busy) { - enqueueMessage(value); + enqueueMessage(prompt); return; } - pushRow({ kind: "user", text: value }); + pushRow({ + kind: "user", + text, + attachments: attachments.map(attachmentSummary), + }); setBusy(true); setBusyLabel("Thinking"); - void getAgent().runTurn(value); + void getAgent().runTurn(text, attachments); }, [settings, busy, handleCommand, enqueueMessage, getAgent, pushRow], ); @@ -1811,7 +1829,8 @@ export function App({ {queuedMessages.slice(0, 5).map((msg, i) => ( - {i + 1}. {truncateForQueue(msg).replace(/\n/g, "↵")} + {i + 1}. {truncateForQueue(msg.text || "Attached files").replace(/\n/g, "↵")} + {msg.attachments.length > 0 ? ` · 📎 ${msg.attachments.length}` : ""} ))} {queuedMessages.length > 5 && ( @@ -1824,6 +1843,7 @@ export function App({ width={wrapWidth} slashCommands={SLASH_COMMANDS} onSubmit={handleSubmit} + supportsImages={getModel(settings.model).supportsImages} onHeightChange={setInputBoxHeight} /> void + onSubmit: (value: SubmittedPrompt) => void + supportsImages: boolean /** Reports the complete rendered height, including autocomplete popups. */ onHeightChange?: (height: number) => void } @@ -88,9 +96,15 @@ function findAtToken(value: string, cursor: number): { query: string; start: num return { query: match[1], start: cursor - match[1].length - 1 } } -export function InputBox({ active, width, slashCommands, onSubmit, onHeightChange }: InputBoxProps) { +export function InputBox({ active, width, slashCommands, onSubmit, supportsImages, onHeightChange }: InputBoxProps) { const [value, setValue] = useState("") const [cursor, setCursor] = useState(0) + const [attachments, setAttachments] = useState([]) + const attachmentsRef = useRef([]) + const parseQueueRef = useRef>(Promise.resolve()) + const pendingParsesRef = useRef(0) + const composerGenerationRef = useRef(0) + const [attachmentMessage, setAttachmentMessage] = useState<{ text: string; isError: boolean } | null>(null) // Terminal-style prompt history: persisted across sessions in ~/.orbcode. const [history, setHistory] = useState(() => loadPromptHistory()) const [historyIndex, setHistoryIndex] = useState(-1) @@ -136,13 +150,97 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang const submit = (text: string) => { const trimmed = text.trim() - if (!trimmed) return - setHistory((h) => (h[h.length - 1] === trimmed ? h : [...h, trimmed])) - appendPromptHistory(trimmed) + if (trimmed === "/attach") { + setValue("") + setCursor(0) + setHistoryIndex(-1) + openAttachmentPicker() + return + } + if ((!trimmed && attachmentsRef.current.length === 0) || pendingParsesRef.current > 0) return + if (trimmed) { + setHistory((h) => (h[h.length - 1] === trimmed ? h : [...h, trimmed])) + appendPromptHistory(trimmed) + } setHistoryIndex(-1) setValue("") setCursor(0) - onSubmit(trimmed) + const submittedAttachments = attachmentsRef.current + composerGenerationRef.current++ + attachmentsRef.current = [] + setAttachments([]) + setAttachmentMessage(null) + onSubmit({ text: trimmed, attachments: submittedAttachments }) + } + + const clearComposer = () => { + composerGenerationRef.current++ + setValue("") + setCursor(0) + attachmentsRef.current = [] + setAttachments([]) + setAttachmentMessage(null) + setHistoryIndex(-1) + setFileIndex(0) + setSlashIndex(0) + setDismissedValue(null) + } + + const addDroppedAttachments = (filePaths: string[]) => { + const generation = composerGenerationRef.current + pendingParsesRef.current++ + setAttachmentMessage({ text: "Reading attachments…", isError: false }) + parseQueueRef.current = parseQueueRef.current + .then(async () => { + const result = await parseAttachments(filePaths, attachmentsRef.current) + if (generation !== composerGenerationRef.current) return + const accepted = supportsImages + ? result.attachments + : result.attachments.filter((attachment) => attachment.kind !== "image") + const unsupportedImages = result.attachments.filter((attachment) => attachment.kind === "image") + if (accepted.length > 0) { + const next = [...attachmentsRef.current, ...accepted] + attachmentsRef.current = next + setAttachments(next) + } + const errors = [ + ...result.errors, + ...unsupportedImages.map((attachment) => `${attachment.name}: the current model does not support images`), + ] + setAttachmentMessage( + errors.length > 0 + ? { text: errors.join(" · "), isError: true } + : { text: `${accepted.length} attachment${accepted.length === 1 ? "" : "s"} added`, isError: false }, + ) + }) + .catch((error: unknown) => { + if (generation !== composerGenerationRef.current) return + setAttachmentMessage({ + text: error instanceof Error ? error.message : String(error), + isError: true, + }) + }) + .finally(() => { + pendingParsesRef.current-- + }) + } + + const openAttachmentPicker = () => { + const generation = composerGenerationRef.current + setAttachmentMessage({ text: "Opening file picker…", isError: false }) + void pickAttachmentPaths(process.cwd()) + .then((filePaths) => { + if (generation !== composerGenerationRef.current) return + if (filePaths.length > 0) addDroppedAttachments(filePaths) + else setAttachmentMessage(null) + }) + .catch((error: unknown) => { + if (generation !== composerGenerationRef.current) return + setAttachmentMessage({ + text: error instanceof Error ? error.message : String(error), + isError: true, + }) + }) } const recallHistory = (index: number) => { @@ -162,13 +260,8 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang useInput( (input, key) => { if (isMouseInput(input)) return - if (key.escape && value.length > 0) { - setValue("") - setCursor(0) - setHistoryIndex(-1) - setFileIndex(0) - setSlashIndex(0) - setDismissedValue(null) + if (key.escape && (value.length > 0 || attachmentsRef.current.length > 0)) { + clearComposer() return } // While actively browsing history, arrows keep navigating history even @@ -255,6 +348,11 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang setHistoryIndex(-1) setValue((v) => v.slice(0, cursor - 1) + v.slice(cursor)) setCursor((c) => c - 1) + } else if (value.length === 0 && attachmentsRef.current.length > 0) { + const next = attachmentsRef.current.slice(0, -1) + attachmentsRef.current = next + setAttachments(next) + setAttachmentMessage(null) } return } @@ -272,10 +370,19 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang setCursor(0) return } + if (key.ctrl && input === "f") { + openAttachmentPicker() + return + } if (key.ctrl || key.meta || key.escape || key.tab) { return } if (input) { + const droppedPaths = input.length > 1 ? droppedAttachmentPaths(input, process.cwd()) : [] + if (droppedPaths.length > 0) { + addDroppedAttachments(droppedPaths) + return + } // Multi-char chunks (paste) are inserted as-is. Newlines inside // the chunk are literal newlines, not a submit signal — press // Enter when you're ready to send. @@ -306,7 +413,8 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang ) const slashPopupHeight = slashMatches.length > 0 ? slashMatches.length + 3 : 0 const filePopupHeight = fileMatches.length > 0 ? fileMatches.length + 3 : 0 - const renderedHeight = 2 + promptHeight + slashPopupHeight + filePopupHeight + const attachmentRows = attachments.length + (attachmentMessage ? 1 : 0) + const renderedHeight = 2 + promptHeight + attachmentRows + slashPopupHeight + filePopupHeight // Parent viewport calculations must use the real bottom-stack height. A // layout effect updates it before Ink paints the next frame, preventing a @@ -377,11 +485,27 @@ export function InputBox({ active, width, slashCommands, onSubmit, onHeightChang borderStyle="round" borderColor={active ? COLORS.inputBorder : COLORS.inputBorderInactive} paddingX={1} + flexDirection="column" > - - {"❯ "} - - {active ? {display} : {value || "waiting…"}} + {attachments.map((attachment) => { + const suffix = `${attachment.kind === "image" ? " · image" : ""}${attachment.truncated ? " · truncated" : ""}` + return ( + + {fitText(`📎 ${attachment.name}${suffix}`, Math.max(1, width - 4))} + + ) + })} + {attachmentMessage && ( + + {fitText(attachmentMessage.text, Math.max(1, width - 4))} + + )} + + + {"❯ "} + + {active ? {display} : {value || "waiting…"}} + ) diff --git a/src/ui/components/rows.tsx b/src/ui/components/rows.tsx index 3beb413..cb54137 100644 --- a/src/ui/components/rows.tsx +++ b/src/ui/components/rows.tsx @@ -1,13 +1,14 @@ import React from "react" import { Box, Text } from "ink" +import type { AttachmentSummary } from "../../attachments.js" import { COLORS } from "../../branding.js" import { renderMarkdown } from "../markdown.js" import { Header } from "./Header.js" export type Row = | { kind: "header"; id: string; cwd: string; modelName: string } - | { kind: "user"; id: string; text: string } + | { kind: "user"; id: string; text: string; attachments?: AttachmentSummary[] } | { kind: "assistant"; id: string; text: string } | { kind: "reasoning"; id: string; text: string; durationMs: number; expanded: boolean } | { @@ -160,11 +161,17 @@ export function formatDuration(durationMs: number): string { } /** Build a padded user block exactly as wide as the transcript. */ -export function formatUserBlock(text: string, width: number): string { +export function formatUserBlock(text: string, width: number, attachments: AttachmentSummary[] = []): string { const lineWidth = Math.max(1, width) const paddingX = Math.min(2, Math.floor((lineWidth - 1) / 2)) const contentWidth = Math.max(1, lineWidth - paddingX * 2) - const sourceLines = (`❯ ${text}`).split("\n") + const attachmentLines = attachments.map( + (attachment) => + ` 📎 ${attachment.name}${attachment.kind === "image" ? " · image" : ""}${attachment.truncated ? " · truncated" : ""}`, + ) + const sourceLines = [`❯ ${text || (attachments.length > 0 ? "Attached files" : "")}`, ...attachmentLines].flatMap( + (line) => line.split("\n"), + ) const blank = " ".repeat(lineWidth) const output: string[] = [blank] for (const sourceLine of sourceLines) { @@ -197,7 +204,7 @@ export const RowView = React.memo(function RowView({ row, width }: { row: Row; w return ( - {formatUserBlock(row.text, width)} + {formatUserBlock(row.text, width, row.attachments)} ) diff --git a/test/attachments.test.ts b/test/attachments.test.ts new file mode 100644 index 0000000..539ae62 --- /dev/null +++ b/test/attachments.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict" +import * as fs from "node:fs/promises" +import * as os from "node:os" +import * as path from "node:path" +import { afterEach, test } from "node:test" + +import { + droppedAttachmentPaths, + formatAttachmentContext, + parseAttachments, +} from "../src/attachments.js" + +const temporaryDirectories: string[] = [] + +async function temporaryDirectory(): Promise { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "orbcode-attachments-")) + temporaryDirectories.push(directory) + return directory +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))) +}) + +test("recognizes quoted and shell-escaped dropped file paths without consuming prose", async () => { + const directory = await temporaryDirectory() + const first = path.join(directory, "report (final).csv") + const second = path.join(directory, "notes.txt") + await fs.writeFile(first, "name,value\nalpha,1\n") + await fs.writeFile(second, "hello") + + assert.deepEqual(droppedAttachmentPaths(`'${first}' '${second}'`, directory), [first, second]) + assert.deepEqual(droppedAttachmentPaths(first.replace(/([\\\s()])/g, "\\$1"), directory), [first]) + assert.deepEqual(droppedAttachmentPaths("please review this report", directory), []) +}) + +test("extracts text documents and validates image contents", async () => { + const directory = await temporaryDirectory() + const csv = path.join(directory, "data.csv") + const png = path.join(directory, "pixel.png") + const fakePng = path.join(directory, "fake.png") + await fs.writeFile(csv, "name,value\nalpha,1\n") + await fs.writeFile(png, Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) + await fs.writeFile(fakePng, "not an image") + + const result = await parseAttachments([csv, png, fakePng]) + assert.equal(result.attachments.length, 2) + assert.equal(result.attachments[0]?.kind, "document") + assert.match(result.attachments[0]?.kind === "document" ? result.attachments[0].text : "", /alpha,1/) + assert.equal(result.attachments[1]?.kind, "image") + assert.match(result.attachments[1]?.kind === "image" ? result.attachments[1].dataUrl : "", /^data:image\/png;base64,/) + assert.match(result.errors[0] ?? "", /contents do not match/) +}) + +test("bounds extracted text and formats document context", async () => { + const directory = await temporaryDirectory() + const textFile = path.join(directory, "large.txt") + await fs.writeFile(textFile, `start-${"x".repeat(210_000)}-end`) + + const result = await parseAttachments([textFile]) + const attachment = result.attachments[0] + assert.equal(attachment?.kind, "document") + if (!attachment || attachment.kind !== "document") return + assert.equal(attachment.truncated, true) + assert.ok(attachment.text.length <= 200_000) + assert.match(attachment.text, /attachment content truncated/) + assert.match(formatAttachmentContext([attachment]), //) +})