From 857963b1c3b740c1a1694cfcee3a19c98c85a76e Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 7 Oct 2026 19:16:42 +0200 Subject: [PATCH] u --- SANDBOX.md | 264 ++++++++++++++++++++++++++++ src/api/routes/pic.js | 8 +- src/services/conversationManager.js | 10 +- src/tools/analyze-image.js | 71 ++++---- src/tools/apisuggestionsorissues.js | 10 +- src/tools/ask-user.js | 4 +- src/tools/dashback-request.js | 10 +- src/tools/ean-checksum.js | 165 +++++++++++++++++ src/tools/edit-image.js | 133 ++++++++------ src/tools/find-files.js | 28 +-- src/tools/javascript.js | 14 +- src/tools/net-gross-calc.js | 190 ++++++++++++++++++++ src/tools/patch.js | 8 +- src/tools/pdf-read.js | 18 +- src/tools/read-file.js | 10 +- src/tools/reset-vfs.js | 4 +- src/tools/search-articles.js | 28 +-- src/tools/search-images.js | 27 +-- src/tools/vfs.js | 46 ++++- src/tools/web-search.js | 26 +-- 20 files changed, 892 insertions(+), 182 deletions(-) create mode 100644 SANDBOX.md create mode 100644 src/tools/ean-checksum.js create mode 100644 src/tools/net-gross-calc.js diff --git a/SANDBOX.md b/SANDBOX.md new file mode 100644 index 0000000..3a95024 --- /dev/null +++ b/SANDBOX.md @@ -0,0 +1,264 @@ +# Proposal: Dockerized Bash Sandbox for Dashback Agent Tools + +## 1. Overview & Motivation + +Dashback currently equips LLM agents with code execution capabilities via `javascript.js` running inside a Node.js `worker_threads` VM with a virtualized file system (`vfs.js`). While effective for lightweight JavaScript transformations, this approach has limitations: + +1. **Language & Tooling Constraints:** Agents are restricted to JavaScript. They cannot run shell scripts, Python, `jq`, `ripgrep`, `git`, or standard command-line tools. +2. **Isolation Boundaries:** Worker threads share host process memory and kernel resources. A native or non-VM shell tool running directly on the host would pose severe security and privilege escalation risks. +3. **Reproducibility:** A containerized environment guarantees identical toolchain versions and execution environments across development and production deployments. + +This proposal specifies an isolated, container-based **Bash Tool** (`bash.js`) powered by a lightweight Docker image with sandboxed access to the company's virtual file system (VFS). + +--- + +## 2. Sandboxing Architecture + +``` + ┌────────────────────────────────────────┐ + │ dashback Host │ + │ │ + │ 1. prepareVfs(googleAuthId) │ + │ └─ hydrates DB -> data/vfs// │ + └──────────────────┬─────────────────────┘ + │ + Mounts host path│ (scoped to company) + data/vfs/ │ as /home/agent/workspace + ▼ +┌────────────────────────────────────────────────────────────────────────┐ +│ Docker Sandbox Container (`dashback-sandbox`) │ +│ │ +│ User: agent (UID 1000, non-root) │ +│ Rootfs: Read-Only (no modifying /usr, /bin, /lib) │ +│ Capabilities: None (--cap-drop=ALL) │ +│ Privilege Escalation: Blocked (no-new-privileges) │ +│ Tmpfs: /tmp (64M, nosuid), /home/agent/.cache (64M, nosuid) │ +│ Limits: 512MB RAM, 1.0 CPU, 100 PIDs (fork-bomb proof) │ +│ Network: None (--network none) or Internal Bridge │ +│ │ +│ /home/agent/workspace/ <─ (Shared VFS Mount) │ +│ ├── package.json │ +│ ├── script.js │ +│ └── output.csv │ +└────────────────────────────────────┬───────────────────────────────────┘ + │ + ┌──────────────────┴─────────────────────┐ + │ 3. syncVfsToDb(googleAuthId) │ + │ └─ persists created/updated files │ + │ back to modelvfs table │ + │ 4. Return stdout/stderr to LLM │ + └────────────────────────────────────────┘ +``` + +--- + +## 3. Docker Image Specification + +### Location +`docker/sandbox/Dockerfile` + +### `Dockerfile` +```dockerfile +FROM node:22-bookworm-slim + +# Prevent interactive prompts during package install +ENV DEBIAN_FRONTEND=noninteractive + +# Install essential CLI tools and dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + jq \ + ripgrep \ + procps \ + tree \ + tar \ + unzip \ + gzip \ + python3 \ + bc \ + sed \ + gawk \ + && rm -rf /var/lib/apt/lists/* + +# Set up unprivileged user +ENV USER=agent +ENV HOME=/home/agent +ENV WORKSPACE=/home/agent/workspace + +RUN groupadd -g 1000 ${USER} && \ + useradd -u 1000 -g ${USER} -d ${HOME} -s /bin/bash -m ${USER} && \ + mkdir -p ${WORKSPACE} && \ + chown -R ${USER}:${USER} ${HOME} + +# Switch context to agent +WORKDIR ${WORKSPACE} +USER ${USER} + +# Provide local node bin in PATH +ENV PATH="${WORKSPACE}/node_modules/.bin:${HOME}/.local/bin:${PATH}" + +ENTRYPOINT ["/bin/bash"] +CMD ["-l"] +``` + +--- + +## 4. Security & Runtime Constraints + +Containers must never be invoked with default privileged parameters. The runner applies strict Linux namespace and cgroup boundaries: + +| Flag | Rationale | +| :--- | :--- | +| `--user 1000:1000` | Executes strictly as unprivileged user `agent`. Matches UID with host file permissions. | +| `--cap-drop=ALL` | Drops all Linux capabilities (prevents raw network creation, kernel module loading, chroot, ptrace). | +| `--security-opt=no-new-privileges:true` | Disallows gaining privileges via `setuid`/`setgid` binaries. | +| `--read-only` | The root filesystem is mounted read-only. The container cannot modify OS binaries, libraries, or configs. | +| `--tmpfs /tmp:rw,nosuid,nodev,size=64m` | Provides a volatile, memory-backed scratch space with `nosuid` and `nodev`. | +| `--tmpfs /home/agent/.cache:rw,nosuid,size=64m` | Allows tools like npm or python to write temporary caches without disk persistence. | +| `--pids-limit 100` | Protects host from fork bombs (`:(){ :|:& };:`). | +| `--memory 512m` | Caps RAM usage; terminates process if memory leaks or runaway allocations occur. | +| `--cpus 1.0` | Restricts CPU usage to a single core equivalent to avoid CPU starvation. | +| `--network none` | Prevents unauthorized external data exfiltration by default (can be bridged if specifically required). | +| `-v :/home/agent/workspace:rw` | Restricts file access solely to the company's workspace directory. | + +--- + +## 5. Integration with Dashback + +### 5.1 VFS Lifecycle + +Each execution turn synchronizes through the existing Dashback VFS pipeline: + +1. **Preparation:** + ```javascript + const vfsRoot = await resolveVfsRoot(googleAuthId); + await prepareVfs(googleAuthId); // Hydrates files from `modelvfs` table + ``` +2. **Mounting:** + The directory `data/vfs/` on the host is mounted to `/home/agent/workspace` inside the container. +3. **Execution:** + Command is executed with a hard timeout (e.g. default 30s, max 120s). +4. **Synchronization:** + ```javascript + await syncVfsToDb(googleAuthId); // Syncs any new or edited files back to `modelvfs` + ``` + +### 5.2 Tool Implementation (`src/tools/bash.js`) + +```javascript +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import { defineTool, ToolError } from 'libllmorbiter'; +import { resolveVfsRoot, prepareVfs, syncVfsToDb } from './vfs.js'; + +const execFileAsync = promisify(execFile); +const IMAGE_NAME = 'dashback-sandbox:latest'; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_BUFFER = 1024 * 1024; // 1 MB output cap + +export function createBashTool(googleAuthId) { + return defineTool({ + name: 'bash_execute', + description: 'Execute a bash command in a secure sandboxed Linux environment with Node.js, Python, git, jq, and ripgrep. Working directory is the company workspace.', + parameters: { + type: 'object', + properties: { + command: { + type: 'string', + description: 'The bash command line to execute (e.g., "node script.js", "jq . data.json", "rg pattern").', + }, + timeout_ms: { + type: 'integer', + description: 'Execution timeout in milliseconds (default 30000, max 120000).', + }, + }, + required: ['command'], + additionalProperties: false, + }, + invoke: async ({ command, timeout_ms = DEFAULT_TIMEOUT_MS }) => { + const vfsRoot = await resolveVfsRoot(googleAuthId); + await prepareVfs(googleAuthId); + + const effectiveTimeout = Math.min(Math.max(timeout_ms, 1000), 120000); + + const dockerArgs = [ + 'run', '--rm', '-i', + '--user', '1000:1000', + '--cap-drop=ALL', + '--security-opt=no-new-privileges:true', + '--read-only', + '--tmpfs', '/tmp:rw,nosuid,nodev,size=64m', + '--tmpfs', '/home/agent/.cache:rw,nosuid,size=64m', + '--pids-limit', '100', + '--memory', '512m', + '--cpus', '1.0', + '--network', 'none', + '-v', `${path.resolve(vfsRoot)}:/home/agent/workspace:rw`, + IMAGE_NAME, + '-c', command, + ]; + + try { + const { stdout, stderr } = await execFileAsync('docker', dockerArgs, { + timeout: effectiveTimeout, + maxBuffer: MAX_BUFFER, + }); + + await syncVfsToDb(googleAuthId); + + return { + stdout: stdout.trim(), + stderr: stderr.trim(), + exitCode: 0, + }; + } catch (err) { + // Ensure partial file modifications are synced even on exit error + try { await syncVfsToDb(googleAuthId); } catch (_) {} + + if (err.killed || err.signal === 'SIGTERM') { + throw new ToolError(`Execution timed out after ${effectiveTimeout}ms.`); + } + + return { + stdout: err.stdout ? err.stdout.trim() : '', + stderr: err.stderr ? err.stderr.trim() : err.message, + exitCode: err.code ?? 1, + }; + } + }, + }); +} +``` + +--- + +## 6. Execution Models: Ephemeral vs. Persistent Daemon + +| Consideration | Ephemeral (`docker run --rm`) | Persistent Container (`docker exec`) | +| :--- | :--- | :--- | +| **Startup Overhead** | ~200–400ms per command | ~20–50ms per command | +| **State Cleanliness** | Completely fresh OS environment every call | Processes/background jobs remain running | +| **Lifecycle Complexity** | None. Docker handles lifecycle and cleanup | Requires container pool management and reaper | +| **Failure Recovery** | Automatic | If container crashes, must be recreated | +| **Recommendation** | **Adopt for Phase 1** | Evaluate if high execution frequency warrants it | + +--- + +## 7. Implementation Roadmap + +1. **Phase 1: Docker Setup & Scripting** + - Add `docker/sandbox/Dockerfile`. + - Add build script: `npm run docker:sandbox:build` to `package.json`. + - Verify image build and base tools (`node -v`, `python3 --version`, `rg --version`, `jq --version`). +2. **Phase 2: Bash Tool Implementation** + - Create `src/tools/bash.js` conforming to `defineTool`. + - Integrate with `prepareVfs` and `syncVfsToDb`. + - Add unit tests validating command execution, timeouts, and file synchronization. +3. **Phase 3: Integration into Tool Registries** + - Register `createBashTool` in the agent execution registry alongside existing tools (`javascript.js`, `read-file.js`, `patch.js`). +4. **Phase 4: Optional Network & API Bridge** + - If commands inside the container need to invoke Dashback endpoints (`http://host.docker.internal:4208/api`), introduce an opt-in network mode using Docker custom bridge networks with short-lived JWT tokens. diff --git a/src/api/routes/pic.js b/src/api/routes/pic.js index 0ef9f04..0ad2d10 100644 --- a/src/api/routes/pic.js +++ b/src/api/routes/pic.js @@ -103,7 +103,9 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ - app.get(`${basePath}/image/:fullHash`, async (req, res) => { + const imagePaths = [`${basePath}/image/:fullHash`]; + if (basePath) imagePaths.push('/image/:fullHash'); + app.get(imagePaths, async (req, res) => { const parsed = parseFullHash(req.params.fullHash); if (parsed.error) { return res.status(400).json({ error: parsed.error }); @@ -186,7 +188,9 @@ export function register(app, basePath = '') { * schema: * $ref: '#/components/schemas/Error' */ - app.get(`${basePath}/thumb/:fullHash`, async (req, res) => { + const thumbPaths = [`${basePath}/thumb/:fullHash`]; + if (basePath) thumbPaths.push('/thumb/:fullHash'); + app.get(thumbPaths, async (req, res) => { const parsed = parseFullHash(req.params.fullHash); if (parsed.error) { return res.status(400).json({ error: parsed.error }); diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index bf308e9..a229304 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -26,6 +26,8 @@ import { createWebSearchTool } from '../tools/web-search.js'; import { createUrlGetTool } from '../tools/url-get.js'; import { createPdfReadTool } from '../tools/pdf-read.js'; import { createSearchArticlesTool } from '../tools/search-articles.js'; +import { createEanChecksumTool } from '../tools/ean-checksum.js'; +import { createNetGrossCalcTool } from '../tools/net-gross-calc.js'; import { resolveOpenRouterKey, resolveDefaultModel, resolveModelReasoning, RETIRED_MODELS, hasConfiguredOpenRouterKey } from '../tools/llm-key.js'; const KEEP_ALIVE_MS = 20_000; @@ -127,7 +129,9 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) { '13. rm: Delete single VFS file (used to delete SKILL files or specific artifacts).', '14. apisuggestionsorissues: Report missing API endpoints, bugs, or issues for triage.', '15. search_articles: Search articles across name, brand, EAN, SKU, and supplier SKU. Returns window of full articles (default 3, max 20), offset, and total count.', - '16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS.', + '16. edit_image: Generate or edit images using the configured company image model (modelslugimage). Supports attachments (low/high quality), size tiers (1K/2K/4K), and aspect ratios (1:1/4:3/3:4). Writes output to VFS (always AVIF format).', + '17. ean_checksum: Validate EAN/GTIN/UPC barcode check digits or calculate missing check digit for barcodes (EAN-13, EAN-8, UPC-A, GTIN-14).', + '18. net_gross_calc: Convert between net and gross prices with tax/VAT rate (default 19%). Provides 2-decimal display values and high-precision net prices for API article creation.', '', 'VIRTUAL FILESYSTEM:', '- Persistent per-company VFS. Paths are root-relative forward-slash (e.g. "labels/sheet1.png").', @@ -150,7 +154,7 @@ async function buildSystemPrompt(vfsRoot, googleAuthId) { '- `image/` is READ-ONLY database storage. Write generated images elsewhere (e.g. "images/…").', '', 'OPERATING GUIDELINES:', - '- GROSS vs NET PRICES: User prices are almost always GROSS (incl. VAT); API requires NET. Formula: `net = gross / (1 + taxRate / 100)`. Compute in javascript_execute with high precision (4–6 decimals) to prevent rounding error.', + '- GROSS vs NET PRICES: User prices are almost always GROSS (incl. VAT); API requires NET. Use net_gross_calc to compute high-precision (4–6 decimals) NET prices for API requests: `net = gross / (1 + taxRate / 100)`.', '- DISPLAYING GENERATED IMAGES: Inline markdown images supported. Embed written VFS image paths: `![descriptive alt](artifacts/example.png)`. Other files (PDF, JSON, CSV) do not render inline; provide download links.', '- LABEL TEMPLATES (/labeltemplate): Must use Dashback JSON schema `{ "objects": [ ... ] }`. Do NOT use ZPL.', '- Always Format output with markdown and emoji.', @@ -878,6 +882,8 @@ export class ConversationManager { createResetVfsTool({ googleAuthId }), createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }), createSearchArticlesTool({ googleAuthId, companyId: this.companyId }), + createEanChecksumTool(), + createNetGrossCalcTool(), ], }); diff --git a/src/tools/analyze-image.js b/src/tools/analyze-image.js index 8a027f2..45d799d 100644 --- a/src/tools/analyze-image.js +++ b/src/tools/analyze-image.js @@ -4,7 +4,8 @@ import sharp from 'sharp'; import jwt from 'jsonwebtoken'; import { defineTool, ToolError, OpenRouterProvider } from 'libllmorbiter'; import env from '../config/env.js'; -import { prepareVfs } from './vfs.js'; +import { getImageByHash } from '../db/pic.js'; +import { prepareVfs, extractFullHash } from './vfs.js'; import { resolveOpenRouterKey, resolveDefaultModel } from './llm-key.js'; /** @@ -234,20 +235,34 @@ function extractImageInputs(args = {}) { } /** - * Fetches a database image by its content hash, authenticated as the calling user. + * Fetches a database image by its content hash using store or authenticated local endpoint. * @param {string} hash 64-char SHA-256 hex * @param {number} googleAuthId * @returns {Promise} */ async function fetchDatabaseImage(hash, googleAuthId) { - const url = `http://127.0.0.1:${env.port}/image/${hash}`; - const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); - const res = await fetch(url, { - headers: { Cookie: `token=${token}` }, - signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS), - }); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - return Buffer.from(await res.arrayBuffer()); + try { + const image = await getImageByHash(googleAuthId, hash); + if (image?.buffer) { + return image.buffer; + } + } catch (dbErr) { + try { + const url = `http://127.0.0.1:${env.port}/api/image/${hash}`; + const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); + const res = await fetch(url, { + headers: { Cookie: `token=${token}` }, + signal: AbortSignal.timeout(IMAGE_TIMEOUT_MS), + }); + if (res.ok) { + return Buffer.from(await res.arrayBuffer()); + } + } catch { + // fallback error ignored + } + throw dbErr; + } + throw new Error('Image not found'); } /** @@ -269,6 +284,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { additionalProperties: false, required: ['paths'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this image inspection is doing (or null). This note will be displayed to the user while the images are being analyzed.', + }, paths: { type: 'array', description: '1–9 VFS paths or DB paths ("image/").', @@ -277,17 +296,17 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { maxItems: MAX_IMAGES, }, prompt: { - type: 'string', - description: 'Inspection criteria or question.', + type: ['string', 'null'], + description: 'Inspection criteria or question, or null.', }, grid: { - type: 'string', - enum: Object.keys(GRID_CONFIGS), - description: 'Grid layout. Omit for auto.', + type: ['string', 'null'], + enum: [...Object.keys(GRID_CONFIGS), null], + description: 'Grid layout or null for auto.', }, }, }, - invoke: async ({ paths, prompt, grid } = {}) => { + invoke: async ({ paths, prompt, grid, note } = {}) => { const inputs = extractImageInputs({ paths }); if (inputs.length === 0) { throw new ToolError( @@ -325,6 +344,10 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { buf = fs.readFileSync(cached); } else { buf = await fetchDatabaseImage(hash, googleAuthId); + try { + fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true }); + fs.writeFileSync(cached, buf); + } catch { /* ignore cache write failure */ } } } @@ -397,19 +420,3 @@ export function createAnalyzeImageTool({ googleAuthId, model }) { }, }); } - -/** - * Extracts a 64-char hex SHA-256 hash from a VFS image path, if present. - * Mirrors the sandbox's `extractFullHash` so `image/` resolves identically here. - * @param {string} p - * @returns {string|null} - */ -function extractFullHash(p) { - if (typeof p !== 'string') return null; - const parts = p.trim().replace(/^([a-zA-Z]:|\/)+/, '').split('/'); - for (const part of parts) { - const seg = part.replace(/\.[a-zA-Z0-9]+$/, ''); - if (/^[a-fA-F0-9]{64}$/.test(seg)) return seg.toLowerCase(); - } - return null; -} \ No newline at end of file diff --git a/src/tools/apisuggestionsorissues.js b/src/tools/apisuggestionsorissues.js index aa8f8c1..05cf06f 100644 --- a/src/tools/apisuggestionsorissues.js +++ b/src/tools/apisuggestionsorissues.js @@ -23,13 +23,13 @@ export function createApiSuggestionsOrIssuesTool({ googleAuthId, companyId }) { description: 'Issue or request details.', }, type: { - type: 'string', - enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other'], - description: 'Report category.', + type: ['string', 'null'], + enum: ['missing_api', 'bug', 'suggestion', 'issue', 'other', null], + description: 'Report category or null.', }, title: { - type: 'string', - description: 'Short summary.', + type: ['string', 'null'], + description: 'Short summary or null.', }, }, }, diff --git a/src/tools/ask-user.js b/src/tools/ask-user.js index 1c6092e..8b6ffd5 100644 --- a/src/tools/ask-user.js +++ b/src/tools/ask-user.js @@ -26,8 +26,8 @@ export function createAskUserTool() { description: 'Question prompt.', }, options: { - type: 'array', - description: 'Up to 3 option labels.', + type: ['array', 'null'], + description: 'Up to 3 option labels, or null.', items: { type: 'string' }, maxItems: 3, }, diff --git a/src/tools/dashback-request.js b/src/tools/dashback-request.js index a9a6db8..30d4c18 100644 --- a/src/tools/dashback-request.js +++ b/src/tools/dashback-request.js @@ -21,13 +21,17 @@ export function createDashbackRequestTool({ googleAuthId }) { additionalProperties: false, required: ['method', 'path'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this request is doing (or null). This note will be displayed to the user while the request is executing.', + }, method: { type: 'string', enum: METHODS, description: 'HTTP method.' }, path: { type: 'string', description: 'Endpoint path (e.g. "/article/2").' }, - query: { type: 'object', description: 'Query parameters object.' }, - body: { type: 'string', description: 'JSON request payload string.' }, + query: { type: ['object', 'null'], description: 'Query parameters object or null.' }, + body: { type: ['string', 'null'], description: 'JSON request payload string or null.' }, }, }, - invoke: async ({ method = 'GET', path, query = null, body = null }) => { + invoke: async ({ method = 'GET', path, query = null, body = null, note }) => { if (!METHODS.includes(method)) throw new Error(`Unsupported method: ${method}`); if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) { throw new Error('path must start with a single "/"'); diff --git a/src/tools/ean-checksum.js b/src/tools/ean-checksum.js new file mode 100644 index 0000000..e328b85 --- /dev/null +++ b/src/tools/ean-checksum.js @@ -0,0 +1,165 @@ +import { defineTool, ToolError } from 'libllmorbiter'; + +/** + * Calculates the standard GS1 check digit for a string of numeric digits. + * GS1 algorithm: from right to left, alternating weights 3, 1, 3, 1... + * + * @param {string} payload - Numeric string without the check digit. + * @returns {number} The check digit (0-9). + */ +export function calculateGs1CheckDigit(payload) { + let sum = 0; + for (let i = 0; i < payload.length; i++) { + const digit = parseInt(payload[payload.length - 1 - i], 10); + const weight = i % 2 === 0 ? 3 : 1; + sum += digit * weight; + } + return (10 - (sum % 10)) % 10; +} + +/** + * Resolves standard barcode format name from length. + * + * @param {number} length + * @returns {string} + */ +function resolveBarcodeFormat(length) { + switch (length) { + case 8: + return 'EAN-8'; + case 12: + return 'UPC-A'; + case 13: + return 'EAN-13'; + case 14: + return 'GTIN-14'; + default: + return `Barcode-${length}`; + } +} + +/** + * Creates the ean_checksum tool. + */ +export function createEanChecksumTool() { + return defineTool({ + name: 'ean_checksum', + description: + 'Validate EAN/GTIN/UPC barcode check digits or calculate the missing check digit for barcodes ' + + '(EAN-13, EAN-8, UPC-A, GTIN-14). Strips spaces and hyphens automatically.', + strict: false, + parameters: { + type: 'object', + additionalProperties: false, + required: ['code'], + properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this EAN checksum check is doing (or null). This note will be displayed to the user while executing.', + }, + code: { + type: 'string', + description: 'Barcode number to validate or complete (e.g. 12 digits for EAN-13 check digit calculation, or 13 digits for EAN-13 validation).', + }, + action: { + type: ['string', 'null'], + enum: ['auto', 'validate', 'calculate', null], + description: 'Optional action: "auto" (default: infer from length), "validate" (check last digit), "calculate" (compute check digit for the given payload), or null.', + }, + }, + }, + invoke: async ({ code, action = 'auto', note } = {}) => { + if (typeof code !== 'string' && typeof code !== 'number') { + throw new ToolError('ean_checksum requires a "code" (string or number).'); + } + + const clean = String(code).trim().replace(/[\s\-]/g, ''); + if (!/^\d+$/.test(clean)) { + throw new ToolError(`Invalid barcode code "${code}": must contain only digits (spaces and hyphens are stripped).`); + } + + const len = clean.length; + if (len < 6 || len > 18) { + throw new ToolError(`Invalid barcode length (${len} digits): barcodes are typically between 7 and 14 digits.`); + } + + // Determine whether to validate or calculate + let mode = action || 'auto'; + if (mode === 'auto') { + // If standard complete barcode length (8, 13, 14), default to validate. + // 12 digits is either complete UPC-A (12 digits) or incomplete EAN-13 (12 digits). + // In European retail context (EAN-13), 12 digits is very commonly incomplete EAN-13. + // We provide full results for both when len === 12. + if (len === 8 || len === 13 || len === 14) { + mode = 'validate'; + } else if (len === 7 || len === 11) { + mode = 'calculate'; + } else if (len === 12) { + mode = 'calculate'; // calculate EAN-13 by default, but also report UPC-A validation + } else { + mode = 'calculate'; + } + } + + const result = { + input: clean, + length: len, + }; + + if (mode === 'calculate') { + const checkDigit = calculateGs1CheckDigit(clean); + const fullCode = `${clean}${checkDigit}`; + const format = resolveBarcodeFormat(fullCode.length); + result.action = 'calculate'; + result.checkDigit = checkDigit; + result.fullCode = fullCode; + result.format = format; + result.message = `Calculated check digit ${checkDigit} for ${len}-digit payload. Complete ${format} is ${fullCode}.`; + + // If 12 digits, also provide UPC-A validation for convenience + if (len === 12) { + const upcPayload = clean.slice(0, 11); + const upcActual = parseInt(clean.slice(11), 10); + const upcExpected = calculateGs1CheckDigit(upcPayload); + result.asUpcAValidation = { + isValid: upcActual === upcExpected, + expectedCheckDigit: upcExpected, + actualCheckDigit: upcActual, + }; + } + } else { + // Validate mode + const payload = clean.slice(0, -1); + const actualCheckDigit = parseInt(clean.slice(-1), 10); + const expectedCheckDigit = calculateGs1CheckDigit(payload); + const isValid = actualCheckDigit === expectedCheckDigit; + const format = resolveBarcodeFormat(len); + const correctedCode = `${payload}${expectedCheckDigit}`; + + result.action = 'validate'; + result.format = format; + result.isValid = isValid; + result.actualCheckDigit = actualCheckDigit; + result.expectedCheckDigit = expectedCheckDigit; + result.correctedCode = correctedCode; + + if (isValid) { + result.message = `Valid ${format} barcode. Check digit ${actualCheckDigit} is correct.`; + } else { + result.message = `Invalid ${format} barcode: ends with ${actualCheckDigit}, but expected check digit is ${expectedCheckDigit}. Corrected code: ${correctedCode}.`; + } + + // If 12 digits, also provide EAN-13 calculation + if (len === 12) { + const ean13Check = calculateGs1CheckDigit(clean); + result.asEan13Calculation = { + checkDigit: ean13Check, + fullEan13: `${clean}${ean13Check}`, + }; + } + } + + return JSON.stringify(result, null, 2); + }, + }); +} diff --git a/src/tools/edit-image.js b/src/tools/edit-image.js index 67f6ea9..7fcacb3 100644 --- a/src/tools/edit-image.js +++ b/src/tools/edit-image.js @@ -4,6 +4,7 @@ import sharp from 'sharp'; import jwt from 'jsonwebtoken'; import { defineTool, ToolError } from 'libllmorbiter'; import env from '../config/env.js'; +import { getImageByHash } from '../db/pic.js'; import { prepareVfs, syncVfsToDb, extractFullHash, isReadOnlyDatabasePath } from './vfs.js'; import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js'; @@ -20,11 +21,12 @@ import { resolveOpenRouterKey, resolveImageModel } from './llm-key.js'; * - attachments: array of { vfsfilename, high: bool } reference images. * - size: resolution tier — 3 choices ('1K', '2K', '4K'). * - ratio: aspect ratio — 3 choices ('1:1', '4:3', '3:4'). - * - vfsoutputfilename: VFS path where the resulting image is saved. + * - vfsoutputfilename: VFS path where the resulting image is saved (always saved as AVIF). */ const DEFAULT_SIZE = '1K'; const DEFAULT_RATIO = '1:1'; +const AVIF_QUALITY = 80; const IMAGE_FETCH_TIMEOUT_MS = 10_000; const GENERATE_TIMEOUT_MS = 120_000; @@ -132,20 +134,34 @@ function resolveImagesApiUrl() { } /** - * Fetches a database image by its 64-char content hash using authenticated local endpoint. + * Fetches a database image by its 64-char content hash using store or authenticated local endpoint. * @param {string} hash * @param {number} googleAuthId * @returns {Promise} */ async function fetchDatabaseImage(hash, googleAuthId) { - const url = `http://127.0.0.1:${env.port}/image/${hash}`; - const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); - const res = await fetch(url, { - headers: { Cookie: `token=${token}` }, - signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS), - }); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); - return Buffer.from(await res.arrayBuffer()); + try { + const image = await getImageByHash(googleAuthId, hash); + if (image?.buffer) { + return image.buffer; + } + } catch (dbErr) { + try { + const url = `http://127.0.0.1:${env.port}/api/image/${hash}`; + const token = jwt.sign({ id: googleAuthId }, env.jwtSecret, { expiresIn: '5m' }); + const res = await fetch(url, { + headers: { Cookie: `token=${token}` }, + signal: AbortSignal.timeout(IMAGE_FETCH_TIMEOUT_MS), + }); + if (res.ok) { + return Buffer.from(await res.arrayBuffer()); + } + } catch { + // fallback error ignored + } + throw dbErr; + } + throw new Error('Image not found'); } /** @@ -168,7 +184,12 @@ async function loadAttachmentBytes(targetPath, vfsRoot, googleAuthId) { if (fs.existsSync(cached)) { return fs.readFileSync(cached); } - return await fetchDatabaseImage(hash, googleAuthId); + const buf = await fetchDatabaseImage(hash, googleAuthId); + try { + fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true }); + fs.writeFileSync(cached, buf); + } catch { /* ignore cache write failure */ } + return buf; } const clean = targetPath.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); @@ -233,20 +254,24 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) { description: 'Generate or edit images using the configured company image model (modelslugimage). ' + 'Supports reference image attachments fed in low or high quality, output size tier, ' + - 'aspect ratio, and saving directly to a specified VFS output path.', + 'aspect ratio, and saving directly to a specified VFS output path (always saved as AVIF).', strict: false, parameters: { type: 'object', additionalProperties: false, required: ['prompt'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this image generation or edit is doing (or null). This note will be displayed to the user while the image is being generated or edited.', + }, prompt: { type: 'string', description: 'Description of the image to generate, or edit/transformation instructions when attachments are provided.', }, attachments: { - type: 'array', - description: 'Optional reference image(s) for editing/image-to-image. Omit for text-to-image generation.', + type: ['array', 'null'], + description: 'Optional reference image(s) for editing/image-to-image or null. Omit for text-to-image generation.', items: { type: 'object', additionalProperties: false, @@ -257,29 +282,29 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) { description: 'VFS path (e.g. "images/sample.png") or database path ("image/").', }, high: { - type: 'boolean', - description: 'Feed image in high quality/resolution if true; low quality/resolution if false (default).', + type: ['boolean', 'null'], + description: 'Feed image in high quality/resolution if true; low quality/resolution if false or null (default).', }, }, }, }, size: { - type: 'string', - enum: VALID_SIZES, - description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high).', + type: ['string', 'null'], + enum: [...VALID_SIZES, null], + description: 'Resolution tier (3 choices): "1K" (standard, default), "2K" (high quality), "4K" (ultra high), or null.', }, ratio: { - type: 'string', - enum: VALID_RATIOS, - description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait).', + type: ['string', 'null'], + enum: [...VALID_RATIOS, null], + description: 'Aspect ratio (3 choices): "1:1" (square, default), "4:3" (landscape), "3:4" (portrait), or null.', }, vfsoutputfilename: { - type: 'string', - description: 'VFS path where the output image should be saved (e.g. "images/edited.png"). Defaults to "images/generated-.png".', + type: ['string', 'null'], + description: 'VFS path where the output image should be saved (e.g. "images/edited.avif") or null. Defaults to "images/generated-.avif". Output is always converted to AVIF.', }, }, }, - invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename } = {}) => { + invoke: async ({ prompt, attachments, size, ratio, vfsoutputfilename, note } = {}) => { const cleanPrompt = typeof prompt === 'string' ? prompt.trim() : ''; if (!cleanPrompt) { throw new ToolError('edit_image requires a non-empty "prompt" describing the image or edit instructions.'); @@ -402,21 +427,34 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) { throw new ToolError('edit_image: received empty image buffer from model.'); } - // ── Determine target output path ────────────────────────────── + // ── Determine target output path (always .avif) ─────────────── let targetRel = typeof vfsoutputfilename === 'string' && vfsoutputfilename.trim() ? vfsoutputfilename.trim() : ''; if (!targetRel) { const prefix = inputReferences.length > 0 ? 'edited' : 'generated'; - targetRel = `images/${prefix}-${Date.now()}.png`; + targetRel = `images/${prefix}-${Date.now()}.avif`; } - const cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); + let cleanRel = targetRel.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''); if (isReadOnlyDatabasePath(cleanRel)) { throw new ToolError( `Access denied: "${targetRel}" matches read-only database image storage ("image/"). ` + - 'Please specify a normal VFS path such as "images/output.png".' + 'Please specify a normal VFS path such as "images/output.avif".' + ); + } + + // Always enforce .avif extension + const ext = path.extname(cleanRel); + if (ext.toLowerCase() !== '.avif') { + cleanRel = ext ? `${cleanRel.slice(0, -ext.length)}.avif` : `${cleanRel}.avif`; + } + + if (isReadOnlyDatabasePath(cleanRel)) { + throw new ToolError( + `Access denied: "${cleanRel}" matches read-only database image storage ("image/"). ` + + 'Please specify a normal VFS path such as "images/output.avif".' ); } @@ -426,38 +464,17 @@ export function createEditImageTool({ googleAuthId, model: fallbackModel }) { throw new ToolError(`Access denied: path "${targetRel}" escapes virtual filesystem boundary.`); } - // ── Format conversion if requested by output extension ──────── - let ext = path.extname(cleanRel).toLowerCase(); - let finalBuffer = resultBuffer; - let mimeType = item.media_type || 'image/png'; - - if (!ext) { - // No extension supplied; infer from media_type or Sharp format - const meta = await sharp(resultBuffer).metadata().catch(() => ({})); - const format = meta.format || 'png'; - ext = `.${format}`; - targetRel = `${cleanRel}${ext}`; - mimeType = `image/${format}`; - } - + // ── Convert output to AVIF format ───────────────────────────── + let finalBuffer; try { - if (ext === '.avif') { - finalBuffer = await sharp(resultBuffer).avif({ quality: 80 }).toBuffer(); - mimeType = 'image/avif'; - } else if (ext === '.webp') { - finalBuffer = await sharp(resultBuffer).webp({ quality: 80 }).toBuffer(); - mimeType = 'image/webp'; - } else if (ext === '.jpg' || ext === '.jpeg') { - finalBuffer = await sharp(resultBuffer).jpeg({ quality: 85 }).toBuffer(); - mimeType = 'image/jpeg'; - } else if (ext === '.png') { - finalBuffer = await sharp(resultBuffer).png().toBuffer(); - mimeType = 'image/png'; - } + finalBuffer = await sharp(resultBuffer) + .rotate() + .avif({ quality: AVIF_QUALITY }) + .toBuffer(); } catch (convErr) { - console.warn('[edit_image] sharp format conversion warning:', convErr.message); - finalBuffer = resultBuffer; + throw new ToolError(`edit_image: failed to encode output image to AVIF: ${convErr.message}`); } + const mimeType = 'image/avif'; // ── Write to VFS disk and sync to database ──────────────────── fs.mkdirSync(path.dirname(absTarget), { recursive: true }); diff --git a/src/tools/find-files.js b/src/tools/find-files.js index 9267088..4ebc1a5 100644 --- a/src/tools/find-files.js +++ b/src/tools/find-files.js @@ -89,41 +89,45 @@ export function createFindFilesTool({ googleAuthId }) { additionalProperties: false, properties: { dirname: { - type: 'string', - description: 'VFS directory to list. Default "." (root). Must be "." or empty if skills=true.', + type: ['string', 'null'], + description: 'VFS directory to list or null. Default "." (root). Must be "." or empty if skills=true.', }, recursive: { - type: 'boolean', - description: 'Walk subdirectories. Default false.', + type: ['boolean', 'null'], + description: 'Walk subdirectories or null. Default false.', }, skills: { - type: 'boolean', - description: 'List only root SKILL files with parsed titles. Overrides dirname/recursive. Default false.', + type: ['boolean', 'null'], + description: 'List only root SKILL files with parsed titles or null. Overrides dirname/recursive. Default false.', }, seek: { - type: 'number', - description: 'Pagination offset. Default 0.', + type: ['number', 'null'], + description: 'Pagination offset or null. Default 0.', }, limit: { - type: 'number', - description: `Max entries to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`, + type: ['number', 'null'], + description: `Max entries to return or null. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`, }, maxDepth: { - type: 'number', - description: 'Max recursive directory depth. Default 3.', + type: ['number', 'null'], + description: 'Max recursive directory depth or null. Default 3.', }, }, }, invoke: async ({ dirname = '.', recursive = false, skills = false, seek = 0, limit = DEFAULT_LIMIT, maxDepth = 3 } = {}) => { + if (dirname == null) dirname = '.'; if (typeof dirname !== 'string') { throw new ToolError(`"dirname" must be a string (received ${JSON.stringify(dirname)}).`); } + seek = seek != null ? Number(seek) : 0; if (!Number.isInteger(seek) || seek < 0) { throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`); } + limit = limit != null ? Number(limit) : DEFAULT_LIMIT; if (!Number.isInteger(limit) || limit < 1) { throw new ToolError(`"limit" must be a positive integer (received ${limit}).`); } + maxDepth = maxDepth != null ? Number(maxDepth) : 3; if (!Number.isInteger(maxDepth) || maxDepth < 1) { throw new ToolError(`"maxDepth" must be a positive integer (received ${maxDepth}).`); } diff --git a/src/tools/javascript.js b/src/tools/javascript.js index 61745d8..9194286 100644 --- a/src/tools/javascript.js +++ b/src/tools/javascript.js @@ -133,21 +133,25 @@ export function createJavascriptExecuteTool({ googleAuthId }) { additionalProperties: false, required: ['code'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this code is doing (or null). This note will be displayed to the user while the code is executing.', + }, code: { type: 'string', description: 'JavaScript code. Assign result to `module.exports`. Async code must use IIFE. Do not use require() or import.', }, timeout: { - type: 'number', - description: `Execution timeout in ms. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`, + type: ['number', 'null'], + description: `Execution timeout in ms or null. Default ${DEFAULT_TIMEOUT_MS}, max ${MAX_TIMEOUT_MS}.`, }, max_output: { - type: 'number', - description: `Max stdout chars. Default ${DEFAULT_MAX_OUTPUT}.`, + type: ['number', 'null'], + description: `Max stdout chars or null. Default ${DEFAULT_MAX_OUTPUT}.`, }, }, }, - invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT }) => { + invoke: async ({ code, timeout = DEFAULT_TIMEOUT_MS, max_output = DEFAULT_MAX_OUTPUT, note }) => { if (typeof code !== 'string' || !code.trim()) { throw new ToolError('"code" must be a non-empty string.'); } diff --git a/src/tools/net-gross-calc.js b/src/tools/net-gross-calc.js new file mode 100644 index 0000000..c602493 --- /dev/null +++ b/src/tools/net-gross-calc.js @@ -0,0 +1,190 @@ +import { defineTool, ToolError } from 'libllmorbiter'; + +const DEFAULT_TAX_RATE = 19; +const DEFAULT_PRECISION = 6; + +/** + * Calculates net-to-gross or gross-to-net for a single price. + * + * @param {object} params + * @param {number} [params.net] + * @param {number} [params.gross] + * @param {number} [params.amount] + * @param {'net'|'gross'} [params.from] + * @param {number} [params.taxRate=19] + * @param {number} [params.precision=6] + * @returns {object} + */ +export function calculatePrice({ net, gross, amount, from, taxRate = DEFAULT_TAX_RATE, precision = DEFAULT_PRECISION }) { + const rate = taxRate != null ? Number(taxRate) : DEFAULT_TAX_RATE; + if (!Number.isFinite(rate) || rate < 0) { + throw new ToolError(`Invalid taxRate: ${taxRate}. Must be a non-negative number.`); + } + + const prec = precision != null ? Math.min(8, Math.max(2, Number(precision) || DEFAULT_PRECISION)) : DEFAULT_PRECISION; + const factor = 1 + rate / 100; + + let inputType = null; + let inputValue = null; + + if (net != null && gross == null) { + inputType = 'net'; + inputValue = Number(net); + } else if (gross != null && net == null) { + inputType = 'gross'; + inputValue = Number(gross); + } else if (amount != null) { + inputValue = Number(amount); + inputType = from === 'net' ? 'net' : 'gross'; + } else if (net != null && gross != null) { + // Both provided: validate consistency + const n = Number(net); + const g = Number(gross); + const calculatedGross = +(n * factor).toFixed(2); + const effectiveRate = n > 0 ? +(((g - n) / n) * 100).toFixed(2) : 0; + return { + direction: 'verify', + net: +n.toFixed(2), + gross: +g.toFixed(2), + taxRate: rate, + effectiveTaxRate: effectiveRate, + isConsistent: Math.abs(calculatedGross - g) <= 0.02, + taxAmount: +(g - n).toFixed(2), + }; + } else { + throw new ToolError('net_gross_calc requires "net", "gross", or "amount".'); + } + + if (!Number.isFinite(inputValue) || inputValue < 0) { + throw new ToolError(`Invalid price amount: ${inputValue}. Must be a non-negative number.`); + } + + if (inputType === 'gross') { + const grossVal = +inputValue.toFixed(2); + const exactNet = inputValue / factor; + const netRounded = +exactNet.toFixed(2); + const netPrecision = +exactNet.toFixed(prec); + const taxAmount = +(grossVal - netRounded).toFixed(2); + + return { + direction: 'gross_to_net', + gross: grossVal, + net: netRounded, + netPrecision, + netPrecise: exactNet, + taxRate: rate, + taxAmount, + apiNetValue: netPrecision, + formatted: { + gross: `${grossVal.toFixed(2)} €`, + net: `${netRounded.toFixed(2)} €`, + tax: `${taxAmount.toFixed(2)} €`, + }, + }; + } else { + const netVal = +inputValue.toFixed(2); + const exactGross = inputValue * factor; + const grossRounded = +exactGross.toFixed(2); + const taxAmount = +(grossRounded - netVal).toFixed(2); + + return { + direction: 'net_to_gross', + net: netVal, + gross: grossRounded, + grossPrecise: exactGross, + taxRate: rate, + taxAmount, + formatted: { + net: `${netVal.toFixed(2)} €`, + gross: `${grossRounded.toFixed(2)} €`, + tax: `${taxAmount.toFixed(2)} €`, + }, + }; + } +} + +/** + * Creates the net_gross_calc tool. + */ +export function createNetGrossCalcTool() { + return defineTool({ + name: 'net_gross_calc', + description: + 'Convert between net and gross prices (net to gross, gross to net) with VAT/tax rate (default 19%). ' + + 'Computes 2-decimal rounded display values and 6-decimal high-precision net values for API article creation.', + strict: false, + parameters: { + type: 'object', + additionalProperties: false, + properties: { + note: { + type: 'string', + description: 'A note explaining what this price calculation is doing. This note will be displayed to the user while executing.', + }, + gross: { + type: ['number', 'null'], + description: 'Gross price (incl. VAT) or null. When provided, computes net price and tax amount.', + }, + net: { + type: ['number', 'null'], + description: 'Net price (excl. VAT) or null. When provided, computes gross price and tax amount.', + }, + taxRate: { + type: ['number', 'null'], + description: `Tax/VAT rate percentage (e.g. 19 for 19%, 7 for 7%) or null. Default ${DEFAULT_TAX_RATE}.`, + }, + amount: { + type: ['number', 'null'], + description: 'Generic price amount or null when used with "from" ("gross" or "net").', + }, + from: { + type: ['string', 'null'], + enum: ['gross', 'net', null], + description: 'Direction when using "amount": "gross" (default) or "net".', + }, + precision: { + type: ['number', 'null'], + description: `Decimal precision for net price API value or null (default ${DEFAULT_PRECISION}, between 2 and 8).`, + }, + items: { + type: ['array', 'null'], + description: 'Optional array of prices or objects for batch calculation: [19.99, 49.95] or [{ gross: 19.99, taxRate: 7 }].', + items: { + type: 'object', + additionalProperties: false, + properties: { + gross: { type: ['number', 'null'] }, + net: { type: ['number', 'null'] }, + amount: { type: ['number', 'null'] }, + from: { type: ['string', 'null'], enum: ['gross', 'net', null] }, + taxRate: { type: ['number', 'null'] }, + }, + }, + }, + }, + }, + invoke: async ({ note, gross, net, amount, from, taxRate = DEFAULT_TAX_RATE, precision = DEFAULT_PRECISION, items } = {}) => { + if (Array.isArray(items) && items.length > 0) { + const results = items.map((item, idx) => { + const itemRate = item.taxRate != null ? item.taxRate : taxRate; + try { + return calculatePrice({ + net: item.net, + gross: item.gross, + amount: item.amount, + from: item.from || from, + taxRate: itemRate, + precision, + }); + } catch (err) { + return { itemIndex: idx, error: err.message }; + } + }); + return JSON.stringify({ count: results.length, results }, null, 2); + } + + const result = calculatePrice({ net, gross, amount, from, taxRate, precision }); + return JSON.stringify(result, null, 2); + }, + }); +} diff --git a/src/tools/patch.js b/src/tools/patch.js index 73b2039..e240d34 100644 --- a/src/tools/patch.js +++ b/src/tools/patch.js @@ -98,12 +98,12 @@ export function createPatchTool({ googleAuthId }) { description: 'Replacement text.', }, title: { - type: 'string', - description: 'Optional line-1 title for SKILL files.', + type: ['string', 'null'], + description: 'Optional line-1 title for SKILL files or null.', }, skiplines: { - type: 'integer', - description: 'Lines to skip before searching.', + type: ['integer', 'null'], + description: 'Lines to skip before searching or null.', }, }, }, diff --git a/src/tools/pdf-read.js b/src/tools/pdf-read.js index 806f351..cbfbeda 100644 --- a/src/tools/pdf-read.js +++ b/src/tools/pdf-read.js @@ -55,17 +55,17 @@ export function createPdfReadTool({ googleAuthId }) { description: 'VFS path or HTTP(S) URL.', }, format: { - type: 'string', - enum: ['txt', 'png', 'both'], - description: 'Output format: "txt" (layout text), "png" (page images), "both". Default "txt".', + type: ['string', 'null'], + enum: ['txt', 'png', 'both', null], + description: 'Output format: "txt" (layout text), "png" (page images), "both", or null. Default "txt".', }, page: { - type: 'number', - description: '1-based page number. Omit for all pages.', + type: ['number', 'null'], + description: '1-based page number or null for all pages.', }, dpi: { - type: 'number', - description: 'PNG render DPI. Default 150.', + type: ['number', 'null'], + description: 'PNG render DPI or null. Default 150.', }, }, }, @@ -76,8 +76,8 @@ export function createPdfReadTool({ googleAuthId }) { } const targetFormat = normalizeFormat(format || type); - const targetPage = Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null; - const targetDpi = Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI; + const targetPage = page != null && Number.isInteger(Number(page)) && Number(page) >= 1 ? Number(page) : null; + const targetDpi = dpi != null && Number.isInteger(Number(dpi)) && Number(dpi) >= 50 && Number(dpi) <= 600 ? Number(dpi) : DEFAULT_DPI; const vfsRoot = await prepareVfs({ googleAuthId }); let buffer = null; diff --git a/src/tools/read-file.js b/src/tools/read-file.js index 28666ec..e19021f 100644 --- a/src/tools/read-file.js +++ b/src/tools/read-file.js @@ -75,12 +75,12 @@ export function createReadFileTool({ googleAuthId }) { description: 'VFS file path.', }, seek: { - type: 'number', - description: 'Line offset (0-indexed). Default 0.', + type: ['number', 'null'], + description: 'Line offset (0-indexed) or null. Default 0.', }, limit: { - type: 'number', - description: `Max lines to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`, + type: ['number', 'null'], + description: `Max lines to return or null. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`, }, }, }, @@ -88,9 +88,11 @@ export function createReadFileTool({ googleAuthId }) { if (typeof filename !== 'string' || !filename.trim()) { throw new ToolError('"filename" must be a non-empty string.'); } + seek = seek != null ? Number(seek) : 0; if (!Number.isInteger(seek) || seek < 0) { throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`); } + limit = limit != null ? Number(limit) : DEFAULT_LIMIT; if (!Number.isInteger(limit) || limit < 1) { throw new ToolError(`"limit" must be a positive integer (received ${limit}).`); } diff --git a/src/tools/reset-vfs.js b/src/tools/reset-vfs.js index c7142dc..be7b364 100644 --- a/src/tools/reset-vfs.js +++ b/src/tools/reset-vfs.js @@ -34,8 +34,8 @@ export function createResetVfsTool({ googleAuthId }) { additionalProperties: false, properties: { include_skills: { - type: 'boolean', - description: 'Delete SKILL files too. Default false.', + type: ['boolean', 'null'], + description: 'Delete SKILL files too (or null). Default false.', }, }, }, diff --git a/src/tools/search-articles.js b/src/tools/search-articles.js index 230ba03..f95fd79 100644 --- a/src/tools/search-articles.js +++ b/src/tools/search-articles.js @@ -32,24 +32,28 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) { additionalProperties: false, properties: { searchquery: { - type: 'string', - description: 'Words that must all match across name, brand, EAN, SKU, or supplier SKU (AND logic, order-independent).', + type: ['string', 'null'], + description: 'Words that must all match across name, brand, EAN, SKU, or supplier SKU (AND logic, order-independent) or null.', }, excludewords: { - type: 'string', - description: 'Words that if all match exclude the article (AND logic, order-independent).', + type: ['string', 'null'], + description: 'Words that if all match exclude the article (AND logic, order-independent) or null.', }, offset: { - type: 'integer', - description: 'Pagination offset. Default 0.', + type: ['integer', 'null'], + description: 'Pagination offset or null. Default 0.', }, limit: { - type: 'integer', - description: `Result window size (1–${MAX_LIMIT}). Default ${DEFAULT_LIMIT}.`, + type: ['integer', 'null'], + description: `Result window size (1–${MAX_LIMIT}) or null. Default ${DEFAULT_LIMIT}.`, }, }, }, invoke: async ({ searchquery = '', excludewords = '', offset = 0, limit = DEFAULT_LIMIT } = {}) => { + const queryStr = typeof searchquery === 'string' ? searchquery : ''; + const excludeStr = typeof excludewords === 'string' ? excludewords : ''; + const resolvedOffset = offset != null ? Number(offset) : 0; + const resolvedLimit = limit != null ? Number(limit) : DEFAULT_LIMIT; const store = await getStore(); const provider = await getProvider(); @@ -133,8 +137,8 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) { }; }); - const searchWords = parseWords(searchquery); - const excludeWords = parseWords(excludewords); + const searchWords = parseWords(queryStr); + const excludeWords = parseWords(excludeStr); const filtered = searchableArticles.filter((art) => { if (searchWords.length > 0) { @@ -151,8 +155,8 @@ export function createSearchArticlesTool({ googleAuthId, companyId }) { filtered.sort((a, b) => (a.name || '').localeCompare(b.name || '', 'de', { sensitivity: 'base' })); const total = filtered.length; - const startOffset = Math.max(0, parseInt(offset, 10) || 0); - const pageSize = Math.min(Math.max(1, parseInt(limit, 10) || DEFAULT_LIMIT), MAX_LIMIT); + const startOffset = Math.max(0, parseInt(resolvedOffset, 10) || 0); + const pageSize = Math.min(Math.max(1, parseInt(resolvedLimit, 10) || DEFAULT_LIMIT), MAX_LIMIT); const windowSlice = filtered.slice(startOffset, startOffset + pageSize); const fullArticles = ( diff --git a/src/tools/search-images.js b/src/tools/search-images.js index a6f8907..5f1c500 100644 --- a/src/tools/search-images.js +++ b/src/tools/search-images.js @@ -144,33 +144,40 @@ export function createSearchImagesTool({ googleAuthId }) { additionalProperties: false, required: ['query'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this image search is doing (or null). This note will be displayed to the user while executing.', + }, query: { type: 'string', description: 'Search query.', }, count: { - type: 'number', - description: `Candidate count. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`, + type: ['number', 'null'], + description: `Candidate count or null. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`, }, gl: { - type: 'string', - description: 'Country code. Default "de".', + type: ['string', 'null'], + description: 'Country code or null. Default "de".', }, hl: { - type: 'string', - description: 'Language code. Default "de".', + type: ['string', 'null'], + description: 'Language code or null. Default "de".', }, }, }, - invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de' } = {}) => { + invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de', note } = {}) => { const term = typeof query === 'string' ? query.trim() : ''; if (!term) { throw new ToolError('search_images requires a non-empty "query" (e.g. " Mineralwasser Flasche Glas ").'); } - if (!Number.isInteger(count) || count < 1) { + const resolvedCount = count != null ? Number(count) : DEFAULT_COUNT; + if (!Number.isInteger(resolvedCount) || resolvedCount < 1) { throw new ToolError(`"count" must be a positive integer (received ${count}).`); } - const wanted = Math.min(count, MAX_COUNT); + const wanted = Math.min(resolvedCount, MAX_COUNT); + const resolvedGl = gl || 'de'; + const resolvedHl = hl || 'de'; let apiKey = null; try { @@ -187,7 +194,7 @@ export function createSearchImagesTool({ googleAuthId }) { ); } - const raw = await serperImageSearch(term, apiKey, { gl, hl }); + const raw = await serperImageSearch(term, apiKey, { gl: resolvedGl, hl: resolvedHl }); if (raw.length === 0) { return { query: term, diff --git a/src/tools/vfs.js b/src/tools/vfs.js index 037d206..4ba36d8 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -54,7 +54,7 @@ export function getMimeType(filePath) { export function extractFullHash(p) { if (typeof p !== 'string' || !p.trim()) return null; - const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, ''); + const clean = p.trim().replace(/^vfs:\/+/i, '').replace(/^([a-zA-Z]:|\/)+/, ''); const parts = clean.split('/'); const last = parts[parts.length - 1]; const withoutExt = last.replace(/\.[a-zA-Z0-9]+$/, ''); @@ -342,13 +342,14 @@ export function createVfs(vfsRoot, options = {}) { readFile: (p, opt, cb) => { const callback = typeof opt === 'function' ? opt : cb; const options = typeof opt === 'function' ? undefined : opt; - try { - const data = vfs.readFileSync(p, options); - if (typeof callback === 'function') process.nextTick(callback, null, data); - } catch (err) { - if (typeof callback === 'function') process.nextTick(callback, err); - else throw err; - } + vfs.promises.readFile(p, options) + .then((data) => { + if (typeof callback === 'function') process.nextTick(callback, null, data); + }) + .catch((err) => { + if (typeof callback === 'function') process.nextTick(callback, err); + else throw err; + }); }, createReadStream: (p, opt) => { @@ -476,7 +477,34 @@ export function createVfs(vfsRoot, options = {}) { }, promises: { - readFile: async (p, opt) => vfs.readFileSync(p, opt), + readFile: async (p, opt) => { + const hash = extractFullHash(p); + if (hash) { + const buf = syncFetchImage(hash); + if (buf) { + const encoding = typeof opt === 'string' ? opt : opt?.encoding; + return encoding ? buf.toString(encoding) : buf; + } + if (googleAuthId) { + try { + const store = await getStore(); + const img = await store.pic.getImageByHash(googleAuthId, hash); + if (img?.buffer) { + try { + const cachePath = path.join(vfsRoot, 'images', `${hash}.avif`); + fs.mkdirSync(path.join(vfsRoot, 'images'), { recursive: true }); + fs.writeFileSync(cachePath, img.buffer); + } catch { /* ignore cache write error */ } + const encoding = typeof opt === 'string' ? opt : opt?.encoding; + return encoding ? img.buffer.toString(encoding) : img.buffer; + } + } catch { + // fall through + } + } + } + return vfs.readFileSync(p, opt); + }, writeFile: async (p, data, opt) => vfs.writeFileSync(p, data, opt), appendFile: async (p, data, opt) => vfs.appendFileSync(p, data, opt), stat: async (p, opt) => vfs.statSync(p, opt), diff --git a/src/tools/web-search.js b/src/tools/web-search.js index d08bfc6..3d5d53b 100644 --- a/src/tools/web-search.js +++ b/src/tools/web-search.js @@ -96,36 +96,40 @@ export function createWebSearchTool({ googleAuthId }) { additionalProperties: false, required: ['query'], properties: { + note: { + type: ['string', 'null'], + description: 'A note explaining what this search is doing (or null). This note will be displayed to the user while the search is executing.', + }, query: { type: 'string', description: 'Search query.', }, timePeriod: { - type: 'string', - enum: ['day', 'week', 'month', 'year'], - description: 'Time filter: "day", "week", "month", or "year".', + type: ['string', 'null'], + enum: ['day', 'week', 'month', 'year', null], + description: 'Time filter: "day", "week", "month", or "year", or null.', }, gl: { - type: 'string', - description: 'Country code. Default "de".', + type: ['string', 'null'], + description: 'Country code or null. Default "de".', }, hl: { - type: 'string', - description: 'Language code. Default "de".', + type: ['string', 'null'], + description: 'Language code or null. Default "de".', }, page: { - type: 'number', - description: 'Result page number (10/page). Default 1.', + type: ['number', 'null'], + description: 'Result page number (10/page) or null. Default 1.', }, }, }, - invoke: async ({ query, timePeriod, tbs, gl = 'de', hl = 'de', page = 1 } = {}, { signal } = {}) => { + invoke: async ({ query, timePeriod, tbs, gl = 'de', hl = 'de', page = 1, note } = {}, { signal } = {}) => { const term = typeof query === 'string' ? query.trim() : ''; if (!term) { throw new ToolError('web_search requires a non-empty "query".'); } - const parsedPage = Number(page); + const parsedPage = page != null ? Number(page) : 1; if (!Number.isInteger(parsedPage) || parsedPage < 1) { throw new ToolError(`"page" must be a positive integer (received ${page}).`); }