This commit is contained in:
seb
2026-10-07 19:16:42 +02:00
parent 1743cef11c
commit 857963b1c3
20 changed files with 892 additions and 182 deletions

View File

@@ -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;
}

View File

@@ -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.',
},
},
},

View File

@@ -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,
},

View File

@@ -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
View 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);
},
});
}

View File

@@ -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 });

View File

@@ -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}).`);
}

View File

@@ -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
View 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);
},
});
}

View File

@@ -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.',
},
},
},

View File

@@ -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;

View File

@@ -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}).`);
}

View File

@@ -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.',
},
},
},

View File

@@ -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 = (

View File

@@ -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,

View File

@@ -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),

View File

@@ -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}).`);
}