u
This commit is contained in:
264
SANDBOX.md
Normal file
264
SANDBOX.md
Normal file
@@ -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/<cid>/ │
|
||||
└──────────────────┬─────────────────────┘
|
||||
│
|
||||
Mounts host path│ (scoped to company)
|
||||
data/vfs/<cid> │ 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 <vfs_root>:/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/<companyId>` 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.
|
||||
@@ -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 });
|
||||
|
||||
@@ -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: ``. 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(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -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<Buffer>}
|
||||
*/
|
||||
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/<hash>").',
|
||||
@@ -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/<hash>` 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;
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 "/"');
|
||||
|
||||
165
src/tools/ean-checksum.js
Normal file
165
src/tools/ean-checksum.js
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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<Buffer>}
|
||||
*/
|
||||
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/<hash>").',
|
||||
},
|
||||
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-<timestamp>.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-<timestamp>.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/<hash>"). ` +
|
||||
'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/<hash>"). ` +
|
||||
'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 });
|
||||
|
||||
@@ -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}).`);
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
190
src/tools/net-gross-calc.js
Normal file
190
src/tools/net-gross-calc.js
Normal file
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}).`);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}).`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user