This commit is contained in:
seb
2026-10-05 11:14:31 +02:00
parent 9831bea110
commit 25266cfcec
12 changed files with 1991 additions and 58 deletions

View File

@@ -11,18 +11,71 @@ import { createDashbackRequestTool } from '../tools/dashback-request.js';
import { createAskUserTool } from '../tools/ask-user.js';
import { createJavascriptExecuteTool } from '../tools/javascript.js';
import { createResetVfsTool } from '../tools/reset-vfs.js';
import { resolveVfsRoot } from '../tools/vfs.js';
import { listSkillFiles } from '../tools/skill.js';
import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissues.js';
import { createFindFilesTool } from '../tools/find-files.js';
import { createReadFileTool } from '../tools/read-file.js';
import { createPatchTool } from '../tools/patch.js';
import { createRmTool } from '../tools/rm.js';
import { createSearchImagesTool } from '../tools/search-images.js';
import { createAnalyzeImageTool } from '../tools/analyze-image.js';
import { resolveOpenRouterKey } from '../tools/llm-key.js';
const KEEP_ALIVE_MS = 20_000;
const ROUTES_DOC_PATH = resolve(dirname(fileURLToPath(import.meta.url)), '../../docs/api/routes.md');
/**
* System prompt: base instructions plus the generated API routes reference
* (docs/api/routes.md), read on each run so it stays current.
* Builds the SKILL index block for the system prompt.
*
* Listing the skills inline means the model knows what guidance exists BEFORE it decides what
* to do, so it can reach for `read_file` on the right skill instead of guessing a procedure
* from scratch. Only the filename, title and line count are included — the body is read on
* demand, which keeps the prompt small no matter how many skills exist.
*
* @param {string} vfsRoot
* @returns {string}
*/
function buildSystemPrompt() {
function buildSkillIndex(vfsRoot) {
// A null root means the VFS could not be resolved (no company selected, or a store
// error). Say so plainly instead of pretending the company has no skills — otherwise the
// model would conclude no guidance exists and invent its own procedure.
if (!vfsRoot) {
return 'AVAILABLE SKILLS: unknown (the virtual filesystem could not be read this turn). Call find_files({ skills: true }) to check.';
}
let skills;
try {
skills = listSkillFiles(vfsRoot);
} catch (err) {
console.warn('conversation: failed to list skill files:', err.message);
return 'AVAILABLE SKILLS: could not be read this turn. Call find_files({ skills: true }) to check.';
}
if (skills.length === 0) {
return [
'AVAILABLE SKILLS: none. This company has no SKILL files yet.',
].join('\n');
}
const width = Math.max(...skills.map((s) => s.path.length));
return [
`AVAILABLE SKILLS (${skills.length}): stored in the VFS root as flat <slug>.SKILL files.`,
'Before starting a task that any of these cover, read the matching skill with read_file and follow it.',
'',
...skills.map((s) => ` ${s.path.padEnd(width)} ${String(s.lines).padStart(4)} lines | ${s.title}`),
].join('\n');
}
/**
* System prompt: base instructions plus the generated API routes reference
* (docs/api/routes.md), read on each run so it stays current.
*
* @param {string} vfsRoot Resolved VFS root for this user's company, used to list SKILL files.
* @returns {string}
*/
async function buildSystemPrompt(vfsRoot) {
let routesDoc = '';
try {
routesDoc = readFileSync(ROUTES_DOC_PATH, 'utf8').trim();
@@ -37,42 +90,47 @@ function buildSystemPrompt() {
'YOUR TOOLS:',
'1. dashback_request: Execute HTTP requests (GET, POST, PUT, PATCH, DELETE) against Dashback endpoints.',
'2. ask_user: Ask clarifying questions or request decisions from the user if their intent is ambiguous or requires choices. When you call ask_user, execution pauses immediately and waits for the user to provide their response.',
'3. javascript_execute: Run JavaScript snippets in a sandbox to compute, aggregate, transform, or analyze data. Assign module.exports to return a value. With allow_network: true, snippets can directly call Dashback API routes using fetch() (e.g. await fetch("/article/2")) and search images using search_for_images(query). A sandboxed virtual filesystem (fs / vfs) is available to store and retrieve artifacts across runs.',
'4. reset_vfs: Delete all files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage or clean up.',
'5. apisuggestionsorissues: Report missing API endpoints, bugs, or issues to be triaged. Automatically writes an entry to the company history table for review.',
'3. javascript_execute: Run JavaScript snippets in a sandbox to compute, aggregate, transform, or analyze data. Assign module.exports to return a value. Snippets can call Dashback API routes using fetch() (e.g. await fetch("/article/2")). A sandboxed virtual filesystem (fs / vfs) is available to store and retrieve artifacts across runs.',
'4. find_files: List what is stored in the virtual filesystem (VFS). Call find_files({ skills: true }) to list SKILL files only.',
'5. read_file: Read a VFS file as text, windowed with seek/limit for large files.',
'6. patch: Create, overwrite, or search-and-replace text in a VFS file. This is the only text write path — it also creates SKILL files.',
'7. search_images: Search Google Images and store the candidates in the VFS as AVIF files.',
'8. analyze_image: Have a vision model look at up to 9 images (VFS paths or "image/<fullHash>") and return its judgement. This is how you "see" — never claim to have inspected an image yourself.',
'9. reset_vfs: Delete files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage or clean up. SKILL files are exempt and survive.',
'10. rm: Delete a single file from the VFS. This is how a SKILL file is deleted on purpose, since reset_vfs always spares skills.',
'11. apisuggestionsorissues: Report missing API endpoints, bugs, or issues to be triaged. Automatically writes an entry to the company history table for review.',
'',
'VIRTUAL FILESYSTEM:',
'- You have a persistent per-company VFS. Paths are relative to the VFS root and always use forward slashes, e.g. "labels/sheet1.png".',
'- To READ text: find_files (list) then read_file (content). To WRITE text: patch. Binary artifacts (images, PDFs, buffers) go through javascript_execute.',
'- Never invent a VFS path. Always confirm with find_files before reading or referencing a file.',
'',
'SKILL FILES:',
'- A SKILL file is a reusable instruction document: a file named "<slug>.SKILL" at the VFS root whose FIRST LINE is its title and whose remaining lines are markdown.',
'- SKILL file names may NOT contain "/" — skills are flat, at the root only. Use find_files({ skills: true }) to discover them; this listing is not affected by pagination or by how many artifacts exist.',
'- Read a skill with read_file BEFORE starting a task it covers, and follow it. If you learn a better procedure during the task, offer to write it back as a new SKILL file.',
'- SKILL files are exempt from reset_vfs: artifacts are disposable, skills are not. Never call reset_vfs expecting to clear skills, and never put throwaway output in a .SKILL file.',
'- To delete a skill (or any single file) use rm — it is the only way to remove one. Deletion is final.',
'',
buildSkillIndex(vfsRoot),
'',
'IMAGES:',
'- You cannot see images yourself. To evaluate one, call analyze_image and report what the vision model says.',
'- Typical flow: search_images("query") → analyze_image({ paths: [...candidates], prompt: "…" }) → upload the winner with dashback_request.',
'- The `image/` folder holds database images and is READ-ONLY. Generated images go anywhere else (e.g. "images/…").',
'',
'OPERATING GUIDELINES:',
'- Refer to the DASHBACK API ROUTES documentation below to find appropriate endpoints, parameters, and HTTP methods.',
'- SELLING PRICES: In article responses (GET /article/{sku} and returned article), the selling price field is named net (NOT price). The structure of entries in article.prices is { minmultiplier, net, taxRate, customergroupId }.',
'- GROSS vs NET PRICES: Users almost always mean GROSS (brutto, incl. VAT) prices when they specify a price (e.g. "1,60 €"), but the API stores and accepts NET prices only. Convert: net = gross / (1 + taxRate/100). Send net with high precision (at least 4–6 decimal places) so rounding back gives the exact gross. Calculate this in javascript_execute.',
'- DATA TYPES & WIRE FORMAT: Dashback strictly validates types (numeric values like weight, price, quantity, IDs must be numbers; flags must be booleans).',
'- VIRTUAL FILESYSTEM & ARTIFACT PERSISTENCE: Inside javascript_execute, you have access to a persistent virtual filesystem via globals fs and vfs. Artifacts persist across runs for the company.',
'- SHOWING GENERATED IMAGES: Markdown images ARE supported and render inline in the chat. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), reference the exact path you wrote with standard markdown `![title](artifacts/example.png)`, which renders inline. The title text is the alt text, so make it descriptive. Use this whenever you generate or modify an image so the user actually sees the result. Other file types (PDF, JSON, CSV) are NOT rendered inline — link to them instead and offer them as a download. NOTE: the `image/` folder is reserved for database images and is read-only; write generated images anywhere else.',
'- SHOWING GENERATED IMAGES: Markdown images ARE supported and render inline in the chat. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)`), reference the exact path you wrote with standard markdown `![title](artifacts/example.png)`, which renders inline. Make the alt text descriptive. Other file types (PDF, JSON, CSV) are NOT rendered inline — link to them instead and offer them as a download.',
'- LABEL TEMPLATES (/labeltemplate): Label templates are NOT ZPL. content is Dashback\'s JSON label schema: { "objects": [ ... ] }. Do NOT write or suggest ZPL.',
'- Format responses clearly using markdown tables, bullet lists, or bold key-value pairs where appropriate.',
...(routesDoc ? ['', 'DASHBACK API ROUTES:', routesDoc] : []),
].join('\n');
}
/**
* Resolve the OpenRouter API key: company setting `keys.openrouter`, then the
* server-wide `OPENROUTER_FREE_KEY` (same order as delivery parsing).
* @param {number} googleAuthId
* @returns {Promise<string>}
*/
async function resolveOpenRouterKey(googleAuthId) {
let apiKey;
try {
const setting = await dbCompanySetting.getByKey(googleAuthId, 'keys.openrouter', { maskWriteonly: false });
apiKey = setting?.value?.trim();
} catch (err) {
console.error('conversation: failed to read OpenRouter key:', err);
}
if (!apiKey) apiKey = env.openrouterFreeKey?.trim();
if (!apiKey) throw new Error('OpenRouter API key not configured (companysetting: keys.openrouter)');
return apiKey;
}
/**
* Per-conversation manager. Owns the SSE subscribers of one conversation and
* runs the LLM (via LLMOrbiter) for each posted user message.
@@ -291,8 +349,18 @@ export class ConversationManager {
// Transcript comes from the database (includes user, assistant, tool, reasoning).
const history = await db.listMessages(googleAuthId, this.conversationId);
// Resolve the VFS root so the prompt can list the company's SKILL files. A failure
// here must not abort the turn — the prompt simply falls back to "no skills".
let vfsRoot = null;
try {
vfsRoot = await resolveVfsRoot(googleAuthId);
} catch (err) {
console.warn('conversation: could not resolve VFS root for skill index:', err.message);
}
const messages = [
{ role: 'system', content: buildSystemPrompt() },
{ role: 'system', content: await buildSystemPrompt(vfsRoot) },
...history.map(toLlmMessage),
];
@@ -404,8 +472,17 @@ export class ConversationManager {
tools: [
createDashbackRequestTool({ googleAuthId }),
createAskUserTool(),
createJavascriptExecuteTool({ googleAuthId, companyId: this.companyId }),
createResetVfsTool({ googleAuthId, companyId: this.companyId }),
// VFS tools take no companyId on purpose — each resolves the user's
// currently selected company itself, so the on-disk VFS and the database
// can never end up pointed at different companies.
createJavascriptExecuteTool({ googleAuthId }),
createFindFilesTool({ googleAuthId }),
createReadFileTool({ googleAuthId }),
createPatchTool({ googleAuthId }),
createRmTool({ googleAuthId }),
createSearchImagesTool({ googleAuthId }),
createAnalyzeImageTool({ googleAuthId, model: conversation.model }),
createResetVfsTool({ googleAuthId }),
createApiSuggestionsOrIssuesTool({ googleAuthId, companyId: this.companyId }),
],
});

419
src/tools/analyze-image.js Normal file
View File

@@ -0,0 +1,419 @@
import fs from 'fs';
import path from 'path';
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 { resolveOpenRouterKey } from './llm-key.js';
/**
* analyze_image — lets a vision model actually LOOK at images held in the VFS.
*
* The agent itself is text-only, so "seeing" an image requires a second, tool-free model
* call with the image attached. This tool is that bridge:
*
* - 1 image → resized to fit 400×400 and sent as a single WebP.
* - 2–9 → composited into a labelled grid (2x1/3x1/2x2/3x2/3x3) with the file name
* printed under each cell, so one cheap call can compare many candidates.
* - Database images are addressed as `image/<fullHash>` and fetched from `/image/:hash`.
*
* The prompt is assembled from the caller's `prompt` plus a layout summary, so the model
* knows which cell is which file.
*/
const GRID_CONFIGS = {
'1x1': { cols: 1, rows: 1, totalWidth: 400, totalHeight: 400 },
'2x1': { cols: 2, rows: 1, totalWidth: 600, totalHeight: 300 },
'3x1': { cols: 3, rows: 1, totalWidth: 800, totalHeight: 300 },
'2x2': { cols: 2, rows: 2, totalWidth: 600, totalHeight: 600 },
'3x2': { cols: 3, rows: 2, totalWidth: 800, totalHeight: 600 },
'3x3': { cols: 3, rows: 3, totalWidth: 800, totalHeight: 800 },
};
const MAX_IMAGES = 9;
const SINGLE_BOX = 400;
const WEBP_QUALITY = 80;
const IMAGE_TIMEOUT_MS = 10_000;
const LABEL_HEIGHT = 26;
const LABEL_PAD = 5;
/**
* Picks a grid shape: the caller's choice when valid, otherwise one that fits the count.
* @param {number} count
* @param {string} [requested]
* @returns {{ cols: number, rows: number, name: string }}
*/
function resolveGrid(count, requested) {
if (requested) {
const match = String(requested).trim().toLowerCase();
if (GRID_CONFIGS[match]) {
const [c, r] = match.split('x').map(Number);
return { cols: c, rows: r, name: match };
}
}
if (count <= 1) return { cols: 1, rows: 1, name: '1x1' };
if (count === 2) return { cols: 2, rows: 1, name: '2x1' };
if (count === 3) return { cols: 3, rows: 1, name: '3x1' };
if (count === 4) return { cols: 2, rows: 2, name: '2x2' };
if (count <= 6) return { cols: 3, rows: 2, name: '3x2' };
return { cols: 3, rows: 3, name: '3x3' };
}
/**
* Escapes text for safe embedding in the label SVG.
* @param {string} unsafe
* @returns {string}
*/
function escapeXml(unsafe) {
return String(unsafe).replace(/[<>&'"]/g, (c) => {
switch (c) {
case '<': return '&lt;';
case '>': return '&gt;';
case '&': return '&amp;';
case "'": return '&apos;';
case '"': return '&quot;';
default: return c;
}
});
}
/**
* Shortens a label for display, keeping the extension visible so `a.avif` and `b.avif`
* never truncate to the same text.
* @param {string} name
* @param {number} maxChars
* @returns {string}
*/
function formatLabelText(name, maxChars = 28) {
if (!name || name.length <= maxChars) return name || '';
const extMatch = name.match(/(\.[a-zA-Z0-9]{3,5})$/);
if (extMatch) {
const ext = extMatch[1];
const avail = maxChars - ext.length - 1;
if (avail > 5) return `${name.slice(0, avail)}…${ext}`;
}
return `${name.slice(0, maxChars - 1)}…`;
}
/**
* Renders the dark caption bar drawn beneath each grid cell.
* @param {string} name
* @param {number} width
* @param {number} height
* @param {number} fontSize
* @returns {Buffer}
*/
function createLabelSvg(name, width, height, fontSize = 11) {
const maxChars = Math.max(8, Math.floor(width / (fontSize * 0.65)));
const text = escapeXml(formatLabelText(name, maxChars));
const textY = Math.round(height / 2 + fontSize / 3 + 1);
return Buffer.from(
`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">` +
`<rect width="100%" height="100%" fill="#1e2025" rx="3" stroke="#333842" stroke-width="1"/>` +
`<text x="${Math.round(width / 2)}" y="${textY}" font-family="system-ui,-apple-system,sans-serif" ` +
`font-size="${fontSize}" font-weight="600" fill="#f1f5f9" text-anchor="middle">${text}</text>` +
`</svg>`
);
}
/**
* Composites the images into a single labelled grid WebP.
* @param {Array<{ name: string, rawBuffer: Buffer }>} items
* @param {{ cols: number, rows: number, totalWidth: number, totalHeight: number }} config
* @returns {Promise<Buffer>}
*/
async function renderGrid(items, config) {
const { cols, totalWidth, totalHeight } = config;
const overlays = [];
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
const col = i % cols;
const row = Math.floor(i / cols);
const cellX = Math.round((col * totalWidth) / cols);
const cellW = Math.round(((col + 1) * totalWidth) / cols) - cellX;
const cellY = Math.round((row * totalHeight) / config.rows);
const cellH = Math.round(((row + 1) * totalHeight) / config.rows) - cellY;
const maxW = cellW - 2 * LABEL_PAD;
const maxH = cellH - LABEL_HEIGHT - 2 * LABEL_PAD;
const resized = await sharp(item.rawBuffer)
.rotate()
.resize(maxW, maxH, { fit: 'inside', withoutEnlargement: true })
.toBuffer({ resolveWithObject: true });
overlays.push({
input: resized.data,
left: cellX + LABEL_PAD + Math.round((maxW - resized.info.width) / 2),
top: cellY + LABEL_PAD + Math.round((maxH - resized.info.height) / 2),
});
const labelW = cellW - 2 * LABEL_PAD;
overlays.push({
input: createLabelSvg(item.name, labelW, LABEL_HEIGHT, cols === 2 ? 12 : 11),
left: cellX + LABEL_PAD,
top: cellY + cellH - LABEL_HEIGHT - LABEL_PAD,
});
}
return sharp({
create: {
width: totalWidth,
height: totalHeight,
channels: 4,
background: { r: 20, g: 21, b: 24, alpha: 1 },
},
})
.composite(overlays)
.webp({ quality: WEBP_QUALITY })
.toBuffer();
}
/**
* Normalises the caller's arguments into a list of `{ path, name }` items.
*
* Deliberately permissive on input shape (`path` / `paths` / `file` / `images`, string or
* array, comma-separated) because models are inconsistent here, while keeping the schema
* itself strict.
*
* @param {object} args
* @returns {Array<{ path: string, name: string }>}
*/
function extractImageInputs(args = {}) {
const items = [];
const add = (entry) => {
if (!entry) return;
if (typeof entry === 'string') {
const trimmed = entry.trim();
if (!trimmed) return;
// Some models send a JSON array as a string.
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
const parsed = JSON.parse(trimmed);
if (Array.isArray(parsed)) {
parsed.forEach(add);
return;
}
} catch { /* not JSON, treat as a plain path */ }
}
if (trimmed.includes(',')) {
trimmed.split(',').map((s) => s.trim()).filter(Boolean).forEach((p) => {
items.push({ path: p, name: path.posix.basename(p) || p });
});
return;
}
items.push({ path: trimmed, name: path.posix.basename(trimmed) || trimmed });
return;
}
if (typeof entry === 'object') {
const p = String(entry.path || entry.file || entry.filename || '').trim();
if (!p) return;
const name = String(entry.name || entry.title || path.posix.basename(p) || p).trim();
items.push({ path: p, name });
}
};
for (const key of ['paths', 'images', 'path', 'file', 'filename']) {
const value = args[key];
if (Array.isArray(value)) value.forEach(add);
else if (typeof value === 'string') add(value);
}
if (items.length === 1 && typeof args.name === 'string' && args.name.trim()) {
items[0].name = args.name.trim();
}
return items;
}
/**
* Fetches a database image by its content hash, authenticated as the calling user.
* @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());
}
/**
* Creates the analyze_image tool.
* @param {{ googleAuthId: number, model: string }} ctx
* `model` is the vision model id from the conversation record; the same model that is
* driving the conversation is used for inspection so the agent does not silently switch
* vendors mid-task.
*/
export function createAnalyzeImageTool({ googleAuthId, model }) {
return defineTool({
name: 'analyze_image',
description:
'Look at one or up to 9 images with a vision model and get its written judgement back. ' +
'Paths are VFS-relative (e.g. "images/bild.de-1.avif" from search_images) or ' +
'"image/<fullHash>" for a database image. ' +
'Passing several paths renders them as a labelled grid in one call — use that to compare ' +
'search candidates side by side. The returned text is the vision model\'s answer, so judge from it ' +
'rather than claiming to have seen the image yourself.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['paths'],
properties: {
paths: {
type: 'array',
description:
'1–9 image paths. VFS files (e.g. ["images/a-1.avif"]) or database images (e.g. ["image/<fullHash>"]). ' +
'Several paths are rendered as one labelled grid.',
items: { type: 'string' },
minItems: 1,
maxItems: MAX_IMAGES,
},
prompt: {
type: 'string',
description:
'What to inspect or compare, e.g. "Which of these is a clean product shot on white, ' +
'without watermark or text overlay?"',
},
grid: {
type: 'string',
enum: Object.keys(GRID_CONFIGS),
description: 'Force a grid layout. Omit to pick one from the image count.',
},
},
},
invoke: async ({ paths, prompt, grid } = {}) => {
const inputs = extractImageInputs({ paths });
if (inputs.length === 0) {
throw new ToolError(
'analyze_image requires "paths": an array of 1–9 VFS paths (e.g. ["images/a-1.avif"]) ' +
'or database paths (e.g. ["image/<fullHash>"]). Get candidates from search_images first.'
);
}
if (inputs.length > MAX_IMAGES) {
throw new ToolError(`analyze_image takes at most ${MAX_IMAGES} images per call (received ${inputs.length}). Split into batches.`);
}
const vfsRoot = await prepareVfs({ googleAuthId });
// ── Load the bytes ────────────────────────────────────────────
const items = [];
for (const item of inputs) {
// Only a path that actually carries a 64-char content hash is a database image.
// A bare "images/…" prefix is just the VFS folder, so it must NOT be treated as
// a DB lookup or `images/candidates/x.avif` would be misread as a hash path.
const hash = extractFullHash(item.path);
let buf = null;
if (!hash) {
const abs = path.resolve(vfsRoot, item.path.replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, ''));
const rel = path.relative(vfsRoot, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${item.path}" escapes virtual filesystem boundary.`);
}
if (fs.existsSync(abs) && fs.statSync(abs).isFile()) {
buf = fs.readFileSync(abs);
}
} else {
const cached = path.join(vfsRoot, 'images', `${hash}.avif`);
if (fs.existsSync(cached)) {
buf = fs.readFileSync(cached);
} else {
buf = await fetchDatabaseImage(hash, googleAuthId);
}
}
if (!buf || buf.length === 0) {
throw new ToolError(
`Image not found or empty: "${item.path}". ` +
(hash
? 'Database images must be addressed as "image/<fullHash>" with a 64-char hash.'
: 'Store it first (search_images writes to "images/…") or check the path with find_files.')
);
}
items.push({ ...item, rawBuffer: buf });
}
// ── Compose ───────────────────────────────────────────────────
const shape = resolveGrid(items.length, grid);
let webp;
if (items.length === 1 && shape.name === '1x1') {
webp = await sharp(items[0].rawBuffer)
.rotate()
.resize(SINGLE_BOX, SINGLE_BOX, { fit: 'inside', withoutEnlargement: true })
.webp({ quality: WEBP_QUALITY })
.toBuffer();
} else {
webp = await renderGrid(items, GRID_CONFIGS[shape.name]);
}
// ── Ask the vision model ──────────────────────────────────────
const question = typeof prompt === 'string' && prompt.trim() ? prompt.trim() : null;
const text = items.length > 1
? `${question || 'Compare these images, judge visual quality, flag watermarks, text overlays or defects, and say which one is best and why.'}\n\n` +
`The grid is ${shape.name}. Each image has its file name printed beneath it:\n` +
items.map((it, idx) => {
const row = Math.floor(idx / GRID_CONFIGS[shape.name].cols) + 1;
const col = (idx % GRID_CONFIGS[shape.name].cols) + 1;
return `- Row ${row}, Col ${col}: "${it.name}"`;
}).join('\n')
: question || 'Describe this image, judge its visual quality, flag any watermark, text overlay or defect, and say whether it is usable as a product or category image.';
const apiKey = await resolveOpenRouterKey(googleAuthId);
const provider = new OpenRouterProvider({ apiKey });
const response = await provider.chatCompletions({
model,
messages: [
{
role: 'user',
content: [
{ type: 'text', text },
{ type: 'image_url', imageUrl: { url: `data:image/webp;base64,${webp.toString('base64')}` } },
],
},
],
signal: AbortSignal.timeout(120_000),
});
const analysis = (response?.content || '').trim();
return {
images: items.map((it) => it.path),
grid: shape.name,
prompt: text,
analysis: analysis || '(The vision model returned no text.)',
usage: response?.usage
? { input: response.usage.input, output: response.usage.output, cost: response.usage.cost }
: undefined,
};
},
});
}
/**
* 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;
}

223
src/tools/find-files.js Normal file
View File

@@ -0,0 +1,223 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, listSkillFiles, SKILL_EXT } from './skill.js';
import { prepareVfs } from './vfs.js';
/**
* find_files — lists the contents of the virtual filesystem (VFS).
*
* Two dedicated switches beyond plain listing:
* - `skills: true` → returns ONLY the SKILL files at the VFS root, each with its title
* (first line) parsed out. Never paginated away by other artifacts.
* - `skillsOnly` is deliberately NOT a name — SKILL files are addressed by bare name and
* live at the VFS root only, so `dirname` must stay "." or empty for a skill listing.
*
* SKILL files use the `.SKILL` extension and are exempt from `reset_vfs`; see skill.js.
*/
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 500;
const RESERVED_FOLDERS = new Set(['image', 'images']);
/**
* Recursively collects entries under `absDir`, relative to `vfsRoot`.
*
* @param {string} absDir
* @param {string} vfsRoot
* @param {boolean} recursive
* @param {number} depth
* @param {number} maxDepth
* @returns {Array<{ path: string, name: string, type: 'file'|'dir', size: number|null, modified: string|null }>}
*/
function walkVfs(absDir, vfsRoot, recursive, depth, maxDepth) {
let dirents;
try {
dirents = fs.readdirSync(absDir, { withFileTypes: true });
} catch {
return [];
}
const out = [];
for (const dirent of dirents) {
// Symlinks are never followed: the VFS has no notion of a link target, and following
// one would let a stored artifact point outside the sandbox.
if (dirent.isSymbolicLink()) continue;
const full = path.join(absDir, dirent.name);
const rel = path.relative(vfsRoot, full).split(path.sep).join('/');
if (dirent.isDirectory()) {
out.push({ path: `${rel}/`, name: dirent.name, type: 'dir', size: null, modified: null });
if (recursive && depth < maxDepth) {
out.push(...walkVfs(full, vfsRoot, recursive, depth + 1, maxDepth));
}
} else if (dirent.isFile()) {
let size = null;
let modified = null;
try {
const stat = fs.statSync(full);
size = stat.size;
modified = stat.mtime.toISOString();
} catch { /* leave null */ }
out.push({
path: rel,
name: dirent.name,
type: 'file',
size,
modified,
skill: isSkillPath(dirent.name),
});
}
}
return out;
}
/**
* Creates the find_files tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createFindFilesTool({ googleAuthId }) {
return defineTool({
name: 'find_files',
description:
'List files stored in the virtual filesystem (VFS). ' +
'VFS paths are relative to the VFS root and use forward slashes, e.g. "labels/sheet1.png". ' +
'Set skills=true to list ONLY the SKILL files: reusable, hand-authored instruction ' +
'documents that are EXEMPT from reset_vfs. A SKILL file is a flat, root-level file ' +
'named "<slug>.SKILL" whose first line is its title and whose remaining lines are markdown. ' +
'Read a skill with read_file before acting on a task it covers. ' +
'Pass no arguments to list the VFS root.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
properties: {
dirname: {
type: 'string',
description:
'Directory to list, relative to the VFS root (e.g. "labels"). Defaults to the root ".". ' +
'Must be "." or empty when skills=true — SKILL files always live at the root.',
},
recursive: {
type: 'boolean',
description: 'true = walk the whole subtree. Default false (immediate contents only).',
},
skills: {
type: 'boolean',
description:
'true = return ONLY SKILL files at the VFS root with their titles parsed. ' +
'Overrides dirname/recursive. Default false.',
},
seek: {
type: 'number',
description: 'Entries to skip (pagination). Default 0.',
},
limit: {
type: 'number',
description: `Max entries to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
},
maxDepth: {
type: 'number',
description: 'Max directory depth to descend when recursive=true. Default 3.',
},
},
},
invoke: async ({ dirname = '.', recursive = false, skills = false, seek = 0, limit = DEFAULT_LIMIT, maxDepth = 3 } = {}) => {
if (typeof dirname !== 'string') {
throw new ToolError(`"dirname" must be a string (received ${JSON.stringify(dirname)}).`);
}
if (!Number.isInteger(seek) || seek < 0) {
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
}
if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
}
if (!Number.isInteger(maxDepth) || maxDepth < 1) {
throw new ToolError(`"maxDepth" must be a positive integer (received ${maxDepth}).`);
}
const vfsRoot = await prepareVfs({ googleAuthId });
// ── Dedicated SKILL listing ───────────────────────────────────
// Deliberately independent of the generic listing: skills must stay discoverable
// no matter how many throwaway artifacts sit in the VFS root, and they must never
// be paginated out of the model's view.
if (skills) {
const clean = dirname.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '').replace(/\/+$/, '');
if (clean && clean !== '.') {
throw new ToolError(
`SKILL files live at the VFS root only, so "dirname" must be "." when skills=true (received "${dirname}"). ` +
'SKILL file names may not contain "/" — list them with find_files({ skills: true }).'
);
}
const skillsFound = listSkillFiles(vfsRoot);
return {
kind: 'skills',
extension: SKILL_EXT,
total: skillsFound.length,
returned: skillsFound.length,
skills: skillsFound,
...(skillsFound.length === 0
? { note: 'No SKILL files stored yet. Write one with patch as "<slug>.SKILL" — title on line 1, markdown below.' }
: {}),
};
}
// ── Generic listing ───────────────────────────────────────────
const vfsLimit = Math.min(limit, MAX_LIMIT);
const cleanDir = dirname.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '') || '.';
// `image`/`images` are the reserved read-only database image folders. They are
// virtual, not on disk, so listing them would always return [] and read as "the
// folder is empty" rather than "this folder does not exist".
if (RESERVED_FOLDERS.has(cleanDir.replace(/\/+$/, '').toLowerCase())) {
throw new ToolError(
`"${dirname}" is the read-only database image folder. Database images are addressed by ` +
'fullHash (e.g. "image/<fullHash>"), not browsed as a directory — use analyze_image with ' +
'that path instead. SKILL files live at the VFS root and are listed with find_files({ skills: true }).'
);
}
const absDir = path.resolve(vfsRoot, cleanDir);
const rel = path.relative(vfsRoot, absDir);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${dirname}" escapes virtual filesystem boundary.`);
}
if (!fs.existsSync(absDir)) {
throw new ToolError(`Directory not found in virtual filesystem: "${dirname}"`);
}
if (!fs.statSync(absDir).isDirectory()) {
throw new ToolError(`"${dirname}" is not a directory.`);
}
const all = walkVfs(absDir, vfsRoot, recursive === true, 0, maxDepth);
// Directories first, then alphabetical — makes a truncated listing predictable
// instead of arbitrary.
all.sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === 'dir' ? -1 : 1));
const page = all.slice(seek, seek + vfsLimit);
const truncated = all.length - (seek + page.length);
const result = {
kind: 'files',
dirname: cleanDir,
recursive: recursive === true,
total: all.length,
returned: page.length,
seek,
entries: page,
};
if (truncated > 0) {
result.truncated = truncated;
result.truncationNote = `${truncated} entries not shown. Continue with seek=${seek + page.length}.`;
}
const skillCount = page.filter((e) => e.skill).length;
if (skillCount > 0) {
result.skillEntries = skillCount;
result.skillNote = `${skillCount} entry/entries in this page are SKILL files — exempt from reset_vfs. Read them with read_file.`;
}
return result;
},
});
}

View File

@@ -116,9 +116,9 @@ function formatSandboxError(code, report) {
/**
* Creates the javascript_execute tool bound to the authenticated user and their company.
* @param {{ googleAuthId: number, companyId: number }} ctx
* @param {{ googleAuthId: number }} ctx
*/
export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
export function createJavascriptExecuteTool({ googleAuthId }) {
return defineTool({
name: 'javascript_execute',
description:
@@ -130,10 +130,13 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
'To query the Dashback REST API from JavaScript: call `await fetch("/route")` (e.g. `const res = await fetch("/article/2"); const data = await res.json();`). ' +
'Relative paths automatically resolve against Dashback and are authenticated with your user credentials. ' +
'A virtual filesystem (fs / vfs) is available to store and retrieve artifacts (files, images, JSON data, buffers) across runs (e.g. `fs.writeFileSync("artifact.json", data)`). ' +
'IMAGES WRITTEN TO THE VFS ARE DISPLAYED INLINE IN THE CHAT. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)` or `sharp(...).toFile("images/example.png")`), ' +
'IMAGES WRITTEN TO THE VFS ARE DISPLAYED INLINE IN THE CHAT. After writing an image into the virtual filesystem (e.g. `fs.writeFileSync("artifacts/example.png", buf)`), ' +
'reference the exact path you wrote with standard markdown `![title](artifacts/example.png)` and it renders as an image inline. ' +
'Note the `image/` folder is reserved for database images and is read-only; write generated images anywhere else. ' +
'Always embed images you generate this way so the user sees them. Other file types do not render inline. ' +
'For TEXT use the dedicated find_files / read_file / patch tools instead of hand-rolling fs calls — they validate paths and persist to the database. ' +
'Use this sandbox for BINARY work and computation: images, PDFs, buffers, charts, encoding. ' +
'To find or inspect images, use the search_images and analyze_image tools — they are not available inside the sandbox. ' +
'The `sharp` image library is a pre-injected GLOBAL. ' +
'Also global: `validateLabelObjects(objects)` and `validateLabelObject(object)` from react-canvas-label-editor. ' +
'CRYPTO: only the WebCrypto global `crypto` is available. Node\'s `crypto` module is NOT installed, so ' +
@@ -154,7 +157,8 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
'JavaScript source to run in a CommonJS wrapper. Set `module.exports` to return a value; ' +
'for async code assign the PROMISE, e.g. `module.exports = (async () => { ... })()`. ' +
'To query Dashback REST API: `const res = await fetch("/article/2"); const data = await res.json();`. ' +
'To persist artifacts between runs: use `fs.writeFileSync("name.ext", data)` and `fs.readFileSync("name.ext")`. ' +
'To persist BINARY artifacts between runs: use `fs.writeFileSync("name.ext", buffer)` and `fs.readFileSync("name.ext")`. ' +
'For text files prefer the find_files / read_file / patch tools. ' +
'Bare top-level `await` is a syntax error here — always wrap it in that async IIFE. ' +
'Globals available: `fetch`, `console`, `fs`/`vfs`, `sharp`, `performance`, `URL`, `URLSearchParams`, `Headers`, ' +
'`Request`, `Response`, `FormData`, `Blob`, `File`, `TextEncoder`, `TextDecoder`, `AbortController`, ' +
@@ -178,12 +182,12 @@ export function createJavascriptExecuteTool({ googleAuthId, companyId }) {
const hardTimeout = Math.min(Math.max(1000, Number(timeout) || DEFAULT_TIMEOUT_MS), MAX_TIMEOUT_MS);
const maxOutput = Math.max(1000, Number(max_output) || DEFAULT_MAX_OUTPUT);
const vfsRoot = await prepareVfs({ companyId, googleAuthId });
const vfsRoot = await prepareVfs({ googleAuthId });
let report;
try {
report = await runInWorker({ code, timeoutMs: hardTimeout, vfsRoot, googleAuthId });
} finally {
await syncVfsToDb({ googleAuthId, companyId, vfsRoot });
await syncVfsToDb({ googleAuthId, vfsRoot });
}
if (report.timedOut) {

26
src/tools/llm-key.js Normal file
View File

@@ -0,0 +1,26 @@
import * as dbCompanySetting from '../db/companysetting.js';
import env from '../config/env.js';
/**
* Resolve the OpenRouter API key: company setting `keys.openrouter`, then the
* server-wide `OPENROUTER_FREE_KEY`.
*
* Lives in its own module (rather than in conversationManager) so the image tools can
* authenticate their vision calls without importing the manager — which imports them.
*
* @param {number} googleAuthId
* @returns {Promise<string>}
* @throws {Error} when no key is configured
*/
export async function resolveOpenRouterKey(googleAuthId) {
let apiKey;
try {
const setting = await dbCompanySetting.getByKey(googleAuthId, 'keys.openrouter', { maskWriteonly: false });
apiKey = setting?.value?.trim();
} catch (err) {
console.error('openrouter-key: failed to read company key:', err.message);
}
if (!apiKey) apiKey = env.openrouterFreeKey?.trim();
if (!apiKey) throw new Error('OpenRouter API key not configured (companysetting: keys.openrouter)');
return apiKey;
}

317
src/tools/patch.js Normal file
View File

@@ -0,0 +1,317 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isReadOnlyDatabasePath } from './vfs.js';
import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
/**
* patch — applies edit operations to files in the virtual filesystem (VFS).
*
* Deliberately the ONLY write path into the VFS. It is the counterpart to `read_file`:
* every `search` string is validated against the file as `read_file` returned it, and the
* resulting file is synced straight to `modelvfs` so the write survives a server restart.
*
* SKILL files get first-class support:
* - A `search` that does not match returns the file's current title so the model can see
* what it was actually editing.
* - `create`/`update` variants accept `{ title, markdown }` for a SKILL file, so a skill can
* be authored without hand-counting the leading blank line. Writing a title through this
* path always re-emits it as line 1, which is what `read_file` and `find_files` expect.
*/
const MAX_CHARS_PER_FILE = 2_000_000;
/**
* Extracts a `{ title, markdown }` object out of an op's `replace` value, if the caller
* supplied one. A SKILL op may pass either the object form or a raw markdown string.
*
* @param {object} op
* @returns {{ title?: string, markdown: string }|null}
*/
function extractSkillBody(op) {
const raw = op.replace;
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
if (typeof raw.markdown === 'string' || typeof raw.title === 'string') {
return {
title: typeof raw.title === 'string' ? raw.title : undefined,
markdown: typeof raw.markdown === 'string' ? raw.markdown : '',
};
}
return null;
}
if (typeof raw === 'string' && typeof op.title === 'string') {
return { title: op.title, markdown: raw };
}
return null;
}
/**
* Counts lines in a string the way `read_file` does, so `skiplines` offsets line up with
* the line numbers the model saw.
* @param {string} text
* @returns {string[]}
*/
function toLines(text) {
if (!text) return [];
const lines = text.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/**
* Creates the patch tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createPatchTool({ googleAuthId }) {
return defineTool({
name: 'patch',
description:
'Create, overwrite, or search-and-replace text in a virtual filesystem (VFS) file. ' +
'This is the only way to write VFS files — javascript_execute writes stay for bulk binary artifacts.\n' +
'Each op needs "file" and "replace"; "search" selects the mode:\n' +
' - search omitted/null → create the file, or overwrite it entirely (directories are auto-created).\n' +
' - search given → replace the FIRST occurrence of that exact text. "skiplines" skips N lines before\n' +
' matching, which is how you target a later duplicate.\n' +
'Create a SKILL file (title on line 1, markdown below; exempt from reset_vfs):\n' +
'[{"file": "image-pipeline.SKILL", "search": null, "replace": "Image pipeline\\n\\n# Steps\\n1. …"}]\n' +
'or, letting the tool render the title line for you:\n' +
'[{"file": "image-pipeline.SKILL", "search": null, "title": "Image pipeline", "replace": "# Steps\\n1. …"}]\n' +
'Read a file with read_file first and copy its text into "search" verbatim — the match is exact.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['operations'],
properties: {
operations: {
type: 'array',
description: 'Operations, applied in order. If one fails, earlier ones are kept and the error names the op.',
items: {
type: 'object',
additionalProperties: false,
required: ['file', 'replace'],
properties: {
file: {
type: 'string',
description:
'VFS path, relative to the root, forward slashes (e.g. "notes.md", "image-pipeline.SKILL"). ' +
'SKILL file names may not contain "/" — they live at the root.',
},
search: {
type: ['string', 'null'],
description: 'Exact existing text to replace. null/omitted = create or overwrite the whole file.',
},
replace: {
type: 'string',
description:
'Text to write. For a SKILL file you may pass "{ title, markdown }" and the title line is rendered for you.',
},
title: {
type: 'string',
description:
'Optional title for a SKILL file written with a raw markdown string in "replace". ' +
'Becomes line 1 of the file.',
},
skiplines: {
type: 'integer',
description: 'Skip this many lines before searching for "search". Use when the text occurs more than once.',
},
},
},
},
},
},
invoke: async ({ operations } = {}) => {
if (!Array.isArray(operations) || operations.length === 0) {
throw new ToolError('"operations" must be a non-empty array of objects.');
}
const vfsRoot = await prepareVfs({ googleAuthId });
/** Resolve + guard a VFS-relative path. Mirrors read_file exactly. */
const resolveInVfs = (p) => {
if (typeof p !== 'string' || !p.trim()) {
throw new ToolError('Each operation needs a non-empty "file" path.');
}
const clean = p.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
if (!clean) {
throw new ToolError('Each operation needs a non-empty "file" path.');
}
const abs = path.resolve(vfsRoot, clean);
const rel = path.relative(vfsRoot, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${p}" escapes virtual filesystem boundary.`);
}
return { clean, abs };
};
// ── Validate every op before touching the filesystem ───────────
// Fast-fail means a malformed batch never leaves the VFS half-written.
const validated = [];
for (let i = 0; i < operations.length; i += 1) {
const op = operations[i];
if (!op || typeof op !== 'object') {
throw new ToolError(`Operation at index ${i} is not an object.`);
}
const { clean, abs } = resolveInVfs(op.file);
if (op.replace === undefined || op.replace === null) {
throw new ToolError(`Operation at index ${i} ("${clean}") is missing "replace".`);
}
// Writing into the read-only database image space is refused here rather than
// deep inside a file write, so the model gets the message before anything moved.
if (isReadOnlyDatabasePath(clean)) {
throw new ToolError(
`Access denied: "${clean}" matches the read-only database image pattern and cannot be written. ` +
'Save artifacts under a custom filename (e.g. "candidate.avif" or "images/candidate.avif").'
);
}
// A SKILL file must be flat — reject an attempted nested path up front.
const looksSkillByExt = clean.toLowerCase().endsWith(SKILL_EXT.toLowerCase());
if (looksSkillByExt && clean.includes('/')) {
throw new ToolError(
`"${clean}" is not a legal SKILL path: SKILL files are flat and must live at the VFS root. ` +
`Use "${skillFileName(path.posix.basename(clean))}" instead.`
);
}
if (looksSkillByExt && !isSkillPath(clean)) {
throw new ToolError(
`"${clean}" is not a legal SKILL file name: it needs a non-empty name before "${SKILL_EXT}".`
);
}
const skillBody = looksSkillByExt ? extractSkillBody(op) : null;
if (looksSkillByExt && !skillBody && typeof op.replace !== 'string') {
throw new ToolError(
`Operation at index ${i} ("${clean}") must set "replace" to a string or a { title, markdown } object.`
);
}
validated.push({ index: i, op, clean, abs, isSkill: looksSkillByExt, skillBody });
}
// ── Apply ──────────────────────────────────────────────────────
const applied = [];
const touchedSkills = new Set();
for (const v of validated) {
const { op, clean, abs, isSkill } = v;
const isCreate = op.search === null || op.search === undefined || op.search === '';
const exists = fs.existsSync(abs);
let updated;
if (isCreate) {
updated = isSkill && v.skillBody
? renderSkill({ title: v.skillBody.title ?? readExistingTitle(abs, exists), markdown: v.skillBody.markdown })
: typeof op.replace === 'string'
? op.replace
: JSON.stringify(op.replace, null, 2);
if (!updated.endsWith('\n')) updated += '\n';
} else {
if (!exists) {
throw new ToolError(
`File not found: "${clean}". Create it first with a "search": null operation, ` +
'or read it with read_file to get its exact current text.'
);
}
if (fs.statSync(abs).size > MAX_CHARS_PER_FILE) {
throw new ToolError(
`"${clean}" is larger than the ${MAX_CHARS_PER_FILE} character patch limit. ` +
'Use javascript_execute with fs.readFileSync to work on it.'
);
}
const original = fs.readFileSync(abs, 'utf8');
// CRLF-tolerant matching: a model copying text out of a read_file result
// must not fail purely because the stored file uses Windows line endings.
const haystack = original.includes('\r\n') ? original.replace(/\r\n/g, '\n') : original;
const needle = typeof op.search === 'string' ? op.search.replace(/\r\n/g, '\n') : '';
if (!needle) {
throw new ToolError(`Operation at index ${v.index} ("${clean}") has an empty "search". Use "search": null to overwrite the file.`);
}
let offset = 0;
const skip = Number.isInteger(op.skiplines) && op.skiplines > 0 ? op.skiplines : 0;
if (skip > 0) {
const lines = toLines(haystack);
if (skip >= lines.length) {
throw new ToolError(
`skiplines=${skip} is past the end of "${clean}" (${lines.length} lines).`
);
}
offset = lines.slice(0, skip).join('\n').length + 1;
}
const at = haystack.indexOf(needle, offset);
if (at === -1) {
const detail = isSkill
? ` Current title: "${readExistingTitle(abs, true)}".`
: '';
throw new ToolError(
`SEARCH/REPLACE failed in "${clean}": the search text was not found` +
(skip > 0 ? ` after skipping ${skip} line(s)` : '') + `.${detail} ` +
'Read the file again with read_file and copy its exact current text into "search".'
);
}
const replacement = isSkill && v.skillBody
? renderSkill({ title: v.skillBody.title, markdown: v.skillBody.markdown })
: (typeof op.replace === 'string' ? op.replace : JSON.stringify(op.replace, null, 2));
updated = haystack.slice(0, at) + replacement + haystack.slice(at + needle.length);
// Restore the file's original line endings if we normalised to match.
if (original.includes('\r\n')) {
updated = updated.replace(/\r?\n/g, '\r\n');
}
}
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, updated, 'utf8');
applied.push(`${isCreate ? (exists ? 'OVERWRITE' : 'CREATE') : 'PATCH'} ${clean}`);
if (isSkill) touchedSkills.add(clean);
}
// Persist immediately — unlike javascript_execute, there is no trailing sync, so a
// text edit would otherwise live only in the on-disk cache until some other tool ran.
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
console.error('[patch] failed to sync VFS to db:', err.message);
throw new ToolError(
`Patch applied to disk but could not be persisted: ${err.message}. ` +
`Files changed: ${applied.join(', ')}.`
);
}
const result = {
success: true,
applied,
filesChanged: applied.length,
};
if (touchedSkills.size > 0) {
result.skills = [...touchedSkills];
result.note = 'SKILL files are exempt from reset_vfs and persist until explicitly deleted.';
}
return result;
},
});
}
/**
* Reads a SKILL file's title line, tolerating a missing file.
* @param {string} abs
* @param {boolean} exists
* @returns {string}
*/
function readExistingTitle(abs, exists) {
if (!exists) return 'Untitled';
try {
return parseSkill(fs.readFileSync(abs, 'utf8')).title;
} catch {
return 'Untitled';
}
}

191
src/tools/read-file.js Normal file
View File

@@ -0,0 +1,191 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
import { prepareVfs } from './vfs.js';
const DEFAULT_LIMIT = 400;
const MAX_LIMIT = 5000;
const MAX_CHARS = 200_000;
/**
* read_file — reads a file from the virtual filesystem (VFS).
*
* Two behaviours worth knowing:
* - **SKILL files are parsed.** When the path names a `.SKILL` file, the title line is
* split off and returned as a separate `title` field alongside the markdown `content`,
* so the model gets the title both as structure and inside the content. Line numbering
* (`seek`/`limit`) applies to the whole raw file, title included, so the numbers line up
* with what a subsequent `patch` call expects.
* - **Binary files are refused**, not base64-dumped: a 4 MB AVIF would blow the context
* for zero benefit. Use `analyze_image` for images and `search_images` to find them.
*/
/**
* Splits content into lines, dropping a single trailing newline so a file ending in "\n"
* does not report one phantom extra line.
* @param {string} text
* @returns {string[]}
*/
function splitLines(text) {
if (!text) return [];
const lines = text.split('\n');
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
return lines;
}
/**
* Heuristic binary sniff: a NUL byte or a high share of non-printable bytes in the first
* kilobyte. Kept deliberately cheap — this runs on every read.
* @param {Buffer} buf
* @returns {boolean}
*/
function looksBinary(buf) {
const sample = buf.subarray(0, Math.min(buf.length, 1024));
for (const byte of sample) {
if (byte === 0) return true;
}
let suspicious = 0;
for (const byte of sample) {
const printable = byte === 9 || byte === 10 || byte === 13 || (byte >= 32 && byte !== 127);
if (!printable) suspicious += 1;
}
return suspicious / sample.length > 0.3;
}
/**
* Creates the read_file tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createReadFileTool({ googleAuthId }) {
return defineTool({
name: 'read_file',
description:
'Read a file from the virtual filesystem (VFS). Paths are relative to the VFS root ' +
'and use forward slashes, e.g. "labels/sheet1.png" or "image-pipeline.SKILL". ' +
'Reading a SKILL file returns its `title` (first line) separately from the markdown `content`. ' +
'Large files are windowed with seek/limit (lines, 0-indexed) and the result reports ' +
'how many lines remain. Binary files cannot be read — use analyze_image for images.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['filename'],
properties: {
filename: {
type: 'string',
description: 'File path relative to the VFS root, using forward slashes (e.g. "image-pipeline.SKILL").',
},
seek: {
type: 'number',
description: 'First line to return, 0-indexed. Default 0.',
},
limit: {
type: 'number',
description: `Maximum number of lines to return. Default ${DEFAULT_LIMIT}, max ${MAX_LIMIT}.`,
},
},
},
invoke: async ({ filename, seek = 0, limit = DEFAULT_LIMIT } = {}) => {
if (typeof filename !== 'string' || !filename.trim()) {
throw new ToolError('"filename" must be a non-empty string.');
}
if (!Number.isInteger(seek) || seek < 0) {
throw new ToolError(`"seek" must be a non-negative integer (received ${seek}).`);
}
if (!Number.isInteger(limit) || limit < 1) {
throw new ToolError(`"limit" must be a positive integer (received ${limit}).`);
}
const vfsRoot = await prepareVfs({ googleAuthId });
const clean = filename.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
if (!clean) {
throw new ToolError('"filename" must be a non-empty string.');
}
const absPath = path.resolve(vfsRoot, clean);
const rel = path.relative(vfsRoot, absPath);
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
}
if (!fs.existsSync(absPath)) {
throw new ToolError(
`File not found in virtual filesystem: "${clean}". ` +
'Call find_files to see what is stored, or find_files({ skills: true }) for SKILL files.'
);
}
if (!fs.statSync(absPath).isFile()) {
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
}
const raw = fs.readFileSync(absPath);
if (looksBinary(raw)) {
const ext = path.extname(clean).toLowerCase();
throw new ToolError(
`"${clean}" is a binary file (${raw.length} bytes${ext ? `, ${ext}` : ''}) and cannot be read as text. ` +
(['.png', '.jpg', '.jpeg', '.avif', '.webp', '.gif', '.bmp'].includes(ext)
? 'Use analyze_image({ path: ' + JSON.stringify(clean) + ' }) to have a vision model inspect it.'
: 'Fetch it as bytes from javascript_execute with fs.readFileSync if you need its contents.')
);
}
const text = raw.toString('utf8');
const lines = splitLines(text);
const totalLines = lines.length;
if (seek >= totalLines && totalLines > 0) {
throw new ToolError(
`seek=${seek} is past the end of "${clean}" (${totalLines} lines). ` +
`Valid range is 0..${Math.max(0, totalLines - 1)}.`
);
}
const cappedLimit = Math.min(limit, MAX_LIMIT);
const slice = lines.slice(seek, seek + cappedLimit);
const remaining = totalLines - (seek + slice.length);
let content = slice.join('\n');
let charTruncated = false;
if (content.length > MAX_CHARS) {
content = content.slice(0, MAX_CHARS);
charTruncated = true;
}
const isSkill = isSkillPath(clean);
const result = {
filename: clean,
size: raw.length,
totalLines,
seek,
returnedLines: slice.length,
content,
};
if (isSkill) {
// Parse from the FULL raw text, not the windowed slice: with a `seek` the
// title line is not in `content`, yet the caller still needs the title.
const parsed = parseSkill(text);
result.type = 'skill';
result.title = parsed.title;
result.extension = SKILL_EXT;
if (seek === 0 && slice.length > 0) {
// Drop the title line from the markdown body so it is not duplicated.
const firstLineEnd = text.indexOf('\n');
const body = firstLineEnd === -1 ? '' : text.slice(firstLineEnd + 1);
result.markdown = slice.length === totalLines ? body.replace(/^\r?\n/, '').replace(/\s+$/, '') : '(paged — read again without seek for the full body)';
}
}
if (remaining > 0) {
result.truncatedLines = remaining;
result.truncationNote = `${remaining} more line(s) not shown. Continue with seek=${seek + slice.length}.`;
}
if (charTruncated) {
result.truncatedChars = true;
result.charTruncationNote = `Content was capped at ${MAX_CHARS} characters.`;
}
return result;
},
});
}

View File

@@ -2,38 +2,95 @@ import fs from 'fs';
import path from 'path';
import { defineTool } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isSkillPath } from './skill.js';
import { resolveVfsRoot } from './vfs.js';
/**
* Creates the reset_vfs tool to wipe artifacts stored in the virtual filesystem.
* @param {{ googleAuthId: number, companyId: number }} ctx
* reset_vfs — clears throwaway artifacts out of the virtual filesystem.
*
* SKILL files are EXEMPT. A SKILL file is authored, reusable knowledge (title on line 1,
* markdown below), not scratch output; wiping it because the agent cleaned up candidate
* images would silently destroy the operator's instructions. They are removed only when
* `include_skills: true` is passed explicitly.
*
* That exemption is why this tool deletes row-by-row instead of calling the store's
* `modelvfs.reset`, which is an unconditional "delete everything".
*
* Note there is no `companyId` parameter, by design. The database half scopes itself by
* `google_auth.selected_company`, read inside the store at call time. An earlier version
* took a `companyId` for the disk half only, which let the two halves target DIFFERENT
* companies: rows for the selected company were deleted while files elsewhere were wiped.
* Both halves now derive the company from the same place, at the same moment.
*
* @param {{ googleAuthId: number }} ctx
*/
export function createResetVfsTool({ googleAuthId, companyId }) {
export function createResetVfsTool({ googleAuthId }) {
return defineTool({
name: 'reset_vfs',
description: 'Delete all files, images, and artifacts stored in the virtual filesystem (VFS) for this company.',
description:
'Delete files, images, and artifacts stored in the virtual filesystem (VFS) to free up storage. ' +
'SKILL files are NOT deleted — reusable instruction documents survive a reset. ' +
'Use find_files({ skills: true }) to see which SKILL files were kept.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
properties: {},
properties: {
include_skills: {
type: 'boolean',
description:
'Also delete SKILL files. Default false — they are exempt unless you explicitly opt in.',
},
},
},
invoke: async () => {
invoke: async ({ include_skills = false } = {}) => {
const store = await getStore();
const res = await store.modelvfs.reset(googleAuthId);
const keepSkills = include_skills !== true;
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
const keptSkills = [];
const existing = await store.modelvfs.list(googleAuthId);
// Phase 1 — clear the database, but only for the names we may delete. Done before
// touching disk so a store failure cannot leave the disk wiped while the database
// still claims the files exist.
for (const file of existing) {
if (keepSkills && isSkillPath(file.name)) {
keptSkills.push(file.name);
continue;
}
await store.modelvfs.delete(googleAuthId, file.name);
}
// Phase 2 — mirror the same decision onto the on-disk cache. The root comes from
// the same lookup the store used above, so the two phases cannot drift apart.
const vfsRoot = await resolveVfsRoot(googleAuthId);
let deletedCount = 0;
if (fs.existsSync(vfsRoot)) {
try {
const entries = fs.readdirSync(vfsRoot);
for (const e of entries) {
fs.rmSync(path.join(vfsRoot, e), { recursive: true, force: true });
for (const entry of fs.readdirSync(vfsRoot)) {
// Only root-level entries can be SKILL files, so the bare name is enough
// to decide here — nested directories are always disposable.
if (keepSkills && isSkillPath(entry)) {
continue;
}
try {
fs.rmSync(path.join(vfsRoot, entry), { recursive: true, force: true });
deletedCount += 1;
} catch (err) {
console.warn(`[reset_vfs] could not remove "${entry}": ${err.message}`);
}
} catch (err) {
console.warn('[reset_vfs] local wipe warning:', err.message);
}
}
return { success: true, deletedCount: res?.deletedCount || 0 };
const result = {
success: true,
deletedCount,
keptSkills: keptSkills.length,
};
if (keptSkills.length > 0) {
result.skills = keptSkills;
result.note = `${keptSkills.length} SKILL file(s) preserved. Pass include_skills=true to delete them too.`;
}
return result;
},
});
}

129
src/tools/rm.js Normal file
View File

@@ -0,0 +1,129 @@
import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { getStore } from '../db/store.js';
import { isReadOnlyDatabasePath, prepareVfs } from './vfs.js';
import { isSkillPath } from './skill.js';
/**
* rm — deletes a single file from the virtual filesystem (VFS).
*
* This is the tool that makes a SKILL file removable. `reset_vfs` deliberately spares skills
* so a routine cleanup cannot destroy authored knowledge; `rm` is the explicit, single-file
* escape hatch for when a skill really should go (it is outdated, wrong, or superseded).
*
* Ported from fidoca's `rm`, with three differences forced by this environment:
*
* - **No undo stack.** fidoca has `pushUndo`; dashback has no undo facility, so a delete here
* is immediate and final. The tool says so plainly so the model does not describe it as
* reversible.
* - **Deletes in both places.** fidoca only unlinks from disk. Here the VFS has two halves,
* so the database row must go too — otherwise the file reappears on the next
* `prepareVfs` hydration, which would make a "successful" delete silently undone.
* - **Guards the read-only image space**, matching `patch`, so a delete cannot be used to
* remove database images by writing to their path.
*
* Directories are refused: deleting a tree recursively is a bulk operation, and
* `reset_vfs` already covers that case (while still sparing skills).
*/
/**
* Validates a VFS-relative path and returns what is on disk at it.
*
* Performs no deletion itself — the caller sequences the database and disk writes, because
* the order matters and only the tool wrapper knows about both halves.
*
* @param {string} vfsRoot
* @param {string} relPath VFS-relative path, forward slashes
* @returns {{ abs: string, size: number }} absolute path and size of the file
* @throws {Error} on traversal, a missing path, or a directory
*/
export function deleteVfsFile(vfsRoot, relPath) {
const abs = path.resolve(vfsRoot, relPath);
const rel = path.relative(vfsRoot, abs);
// Defence in depth: callers already check, but this helper is exported, so re-check here
// rather than trusting the caller with a path-traversal primitive.
if (rel.startsWith('..') || path.isAbsolute(rel)) {
throw new Error(`Access denied: path "${relPath}" escapes virtual filesystem boundary.`);
}
if (!fs.existsSync(abs)) {
throw new Error(`File not found: "${relPath}"`);
}
if (!fs.statSync(abs).isFile()) {
throw new Error(`"${relPath}" is not a file. Use reset_vfs to clear directories (it keeps SKILL files).`);
}
return { abs, size: fs.statSync(abs).size };
}
/**
* Creates the rm tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createRmTool({ googleAuthId }) {
return defineTool({
name: 'rm',
description:
'Delete a single file from the virtual filesystem (VFS). ' +
'Use this to remove a SKILL file: reset_vfs intentionally preserves skills, so rm is how a skill is ' +
'deleted on purpose. Also use it to drop one unwanted artifact. ' +
'Deletion is immediate and cannot be undone. Use reset_vfs to clear many files or a directory at once.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['filename'],
properties: {
filename: {
type: 'string',
description:
'File to delete, relative to the VFS root using forward slashes ' +
'(e.g. "image-pipeline.SKILL", "notes.md", "labels/sheet1.png").',
},
},
},
invoke: async ({ filename } = {}) => {
if (typeof filename !== 'string' || !filename.trim()) {
throw new ToolError('rm requires a non-empty "filename".');
}
const clean = filename.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
if (!clean) {
throw new ToolError('rm requires a non-empty "filename".');
}
if (isReadOnlyDatabasePath(clean)) {
throw new ToolError(
`Access denied: "${clean}" matches the read-only database image pattern and cannot be deleted.`
);
}
const vfsRoot = await prepareVfs({ googleAuthId });
const { abs, size } = deleteVfsFile(vfsRoot, clean);
// Database first. If this throws, the file is still on disk — recoverable, and the row
// still exists to describe it. Doing it the other way round would leave a blob on
// disk that the next prepareVfs hydration would restore, silently undoing the delete.
const store = await getStore();
const res = await store.modelvfs.delete(googleAuthId, clean);
fs.unlinkSync(abs);
const wasSkill = isSkillPath(clean);
const result = {
success: true,
deleted: res?.deleted !== false,
filename: clean,
size,
message: `Deleted "${clean}" (${size} bytes). This cannot be undone.`,
};
if (wasSkill) {
result.type = 'skill';
result.note = 'SKILL file deleted. It will not be restored by reset_vfs.';
}
return result;
},
});
}

257
src/tools/search-images.js Normal file
View File

@@ -0,0 +1,257 @@
import fs from 'fs';
import path from 'path';
import sharp from 'sharp';
import { defineTool, ToolError } from 'libllmorbiter';
import env from '../config/env.js';
import { prepareVfs, syncVfsToDb } from './vfs.js';
const SERPER_ENDPOINT = 'https://google.serper.dev/images';
const SEARCH_TIMEOUT_MS = 15_000;
const DOWNLOAD_TIMEOUT_MS = 8_000;
const MAX_EDGE = 1000;
const AVIF_QUALITY = 80;
const DEFAULT_COUNT = 4;
const MAX_COUNT = 9;
const IMAGE_FOLDER = 'images';
/**
* search_images — Google image search via Serper.dev, normalised into the VFS.
*
* Results are written to the VFS as AVIF candidates and returned as paths, NOT as inline
* base64 blobs. That is the whole point: the model can hand several candidates to
* `analyze_image`, which grids them into one vision call, and only the chosen one is
* committed to the database. Dumping 9 images into the transcript would be both enormous
* and useless, because the model cannot look at an image without asking the vision model to.
*
* Every downloaded candidate lands in `images/<domain>-<n>.avif` and is persisted to
* `modelvfs`, so a follow-up turn can still reference them.
*/
const USER_AGENT =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
const ACCEPT_HEADER = 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8';
/**
* Slugifies a hostname into a safe, readable file-name fragment.
* @param {string} host
* @returns {string}
*/
function slugifyHost(host) {
const cleaned = String(host || '')
.toLowerCase()
.replace(/^www\./, '')
.replace(/[^a-z0-9.]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
return cleaned.slice(0, 40) || 'source';
}
/**
* Queries Serper for image results.
*
* @param {string} query
* @param {string} apiKey
* @param {{ gl?: string, hl?: string, signal?: AbortSignal }} [options]
* @returns {Promise<Array<object>>} raw `images` array from Serper
*/
async function serperImageSearch(query, apiKey, { gl = 'de', hl = 'de', signal } = {}) {
const headers = { 'X-API-KEY': apiKey, 'Content-Type': 'application/json' };
// Cap the whole search at SEARCH_TIMEOUT_MS even if the caller's signal never fires, so a
// hung Serper connection cannot wedge the agent turn indefinitely.
const timeoutSignal = AbortSignal.timeout(SEARCH_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
let response;
try {
response = await fetch(SERPER_ENDPOINT, {
method: 'POST',
headers,
body: JSON.stringify({ q: query, gl, hl }),
redirect: 'follow',
signal: combined,
});
} catch (err) {
throw new ToolError(`search_images: network failure contacting Serper (${query}): ${err.message}`);
}
if (!response.ok) {
let detail = `${response.status} ${response.statusText}`;
try {
const body = await response.text();
if (body) detail += ` - ${body.slice(0, 300)}`;
} catch { /* body already consumed or unreadable */ }
throw new ToolError(`search_images: Serper API error: ${detail}`);
}
const data = await response.json();
return Array.isArray(data?.images) ? data.images : [];
}
/**
* Downloads one candidate and converts it to a ratio-preserving AVIF buffer.
*
* @param {object} img raw Serper image entry
* @param {{ signal?: AbortSignal }} [options]
* @returns {Promise<Buffer|null>} AVIF bytes, or null if the image was unusable
*/
async function toAvif(img, { signal } = {}) {
const urls = [img?.imageUrl, img?.thumbnailUrl].filter((u) => typeof u === 'string' && u.trim());
if (urls.length === 0) return null;
for (const url of urls) {
if (signal?.aborted) return null;
try {
const downloadSignal = AbortSignal.any(
signal ? [signal, AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)] : [AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS)]
);
const res = await fetch(url, {
headers: { 'User-Agent': USER_AGENT, Accept: ACCEPT_HEADER },
signal: downloadSignal,
redirect: 'follow',
});
if (!res.ok) continue;
const input = Buffer.from(await res.arrayBuffer());
if (input.length === 0) continue;
return await sharp(input)
.rotate() // honour EXIF orientation before resizing
.resize(MAX_EDGE, MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
.avif({ quality: AVIF_QUALITY })
.toBuffer();
} catch {
// Try the next candidate URL; a dead imageUrl with a live thumbnail is common.
}
}
return null;
}
/**
* Creates the search_images tool bound to a company's virtual filesystem.
* @param {{ googleAuthId: number }} ctx
*/
export function createSearchImagesTool({ googleAuthId }) {
return defineTool({
name: 'search_images',
description:
'Search Google Images and store the results in the virtual filesystem as AVIF candidates. ' +
'Returns VFS paths, not inline images — then pass those paths to analyze_image to actually look at them ' +
'(up to 9 at once, rendered as a labelled grid) and pick the best one before uploading it to an article ' +
'or category. Candidates are cached, so a second search of the same term is cheap.',
strict: false,
parameters: {
type: 'object',
additionalProperties: false,
required: ['query'],
properties: {
query: {
type: 'string',
description: 'Search term, e.g. " Mineralwasser Flasche Glas ".',
},
count: {
type: 'number',
description: `How many candidate images to return. Default ${DEFAULT_COUNT}, max ${MAX_COUNT}.`,
},
gl: {
type: 'string',
description: 'Geolocation country code. Default "de".',
},
hl: {
type: 'string',
description: 'Result language code. Default "de".',
},
},
},
invoke: async ({ query, count = DEFAULT_COUNT, gl = 'de', hl = 'de' } = {}) => {
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) {
throw new ToolError(`"count" must be a positive integer (received ${count}).`);
}
const wanted = Math.min(count, MAX_COUNT);
const apiKey = env.serperApiKey?.trim();
if (!apiKey) {
throw new ToolError(
'search_images is unavailable: SERPER_API_KEY is not configured on the server. ' +
'Use a direct image URL with javascript_execute instead.'
);
}
const raw = await serperImageSearch(term, apiKey, { gl, hl });
if (raw.length === 0) {
return {
query: term,
total: 0,
returned: 0,
images: [],
note: `No image results for "${term}". Try a broader or more generic term.`,
};
}
const vfsRoot = await prepareVfs({ googleAuthId });
const imagesDir = path.join(vfsRoot, IMAGE_FOLDER);
fs.mkdirSync(imagesDir, { recursive: true });
const results = [];
const failures = [];
// Take a generous slice from Serper: downloads fail often enough (hotlink
// protection, dead URLs) that stopping at `wanted` raw hits under-fills the batch.
for (const img of raw.slice(0, Math.max(wanted * 3, wanted))) {
if (results.length >= wanted) break;
const host = img?.domain || (() => {
try { return img?.imageUrl ? new URL(img.imageUrl).hostname : ''; } catch { return ''; }
})();
const bytes = await toAvif(img);
if (!bytes || bytes.length === 0) {
failures.push({ source: img?.link || img?.imageUrl || '', reason: 'download or conversion failed' });
continue;
}
const name = `${slugifyHost(host)}-${results.length + 1}.avif`;
const relPath = `${IMAGE_FOLDER}/${name}`;
fs.writeFileSync(path.join(imagesDir, name), bytes);
results.push({
path: relPath,
domain: host,
title: typeof img?.title === 'string' ? img.title : '',
sourcePage: typeof img?.link === 'string' ? img.link : '',
bytes: bytes.length,
});
}
if (results.length > 0) {
try {
await syncVfsToDb({ googleAuthId, vfsRoot });
} catch (err) {
// Non-fatal: the files are on disk and will be picked up by the next
// javascript_execute / patch sync. Losing the images because a DB write
// failed would be a worse outcome than a slightly stale store.
console.warn('[search_images] failed to persist candidates:', err.message);
}
}
const result = {
query: term,
total: results.length,
returned: results.length,
images: results,
nextStep: 'Call analyze_image with these paths (grid) to compare them, then upload the winner.',
};
if (failures.length > 0) result.skipped = failures.length;
if (results.length === 0) {
result.note = `Found ${raw.length} result(s) for "${term}" but none could be downloaded. Try different wording.`;
}
return result;
},
});
}

188
src/tools/skill.js Normal file
View File

@@ -0,0 +1,188 @@
import fs from 'fs';
import path from 'path';
/**
* skill.js — the `SKILL` VFS file type.
*
* A SKILL file is a plain markdown document with a machine-readable title on the very
* first line:
*
* Image pipeline
* ==============
*
* Use `search_images` … then `analyze_image` …
*
* Rules that make SKILL files special compared to every other VFS file:
*
* 1. **Flat only.** A SKILL file name may NOT contain a path separator. SKILL files are
* addressed by bare name at the VFS root (`image-pipeline.SKILL`), never nested in a
* sub-directory. That keeps the whole skill set enumerable with a single `readdir`
* and makes the title the only addressable identity of a skill.
* 2. **Exempt from reset.** `reset_vfs` clears artifacts but must never destroy skills:
* they are authored knowledge, not scratch output. See `reset-vfs.js`, which deletes
* row-by-row instead of calling `modelvfs.reset` so skills survive.
* 3. **Self-describing.** Line 1 is the title; the remainder is the markdown body. Read
* with `read_file`, written with `patch`.
*/
/** Canonical extension for SKILL files, including the leading dot. */
export const SKILL_EXT = '.SKILL';
/** Upper bound on a title line so a runaway first line cannot flood the prompt. */
const SKILL_TITLE_MAX = 200;
/**
* Strips the VFS-root prefixes a caller may prepend (`./`, `/`, `C:\`) so the remainder is
* a bare VFS-relative path.
* @param {string} p
* @returns {string}
*/
export function normalizeVfsPath(p) {
if (typeof p !== 'string') return '';
return p.trim().replace(/^([a-zA-Z]:|\/)+/, '').replace(/^\.\/+/, '');
}
/**
* Checks whether a bare file name is a SKILL file name.
* Rejects any name containing a path separator — SKILL files are flat by contract.
* @param {string} name
* @returns {boolean}
*/
export function isSkillName(name) {
if (typeof name !== 'string') return false;
const clean = name.trim();
if (!clean) return false;
if (clean.includes('/') || clean.includes('\\')) return false;
if (!clean.toLowerCase().endsWith(SKILL_EXT.toLowerCase())) return false;
return clean.slice(0, -SKILL_EXT.length).trim().length > 0;
}
/**
* Checks whether a VFS path points at a SKILL file, tolerating leading `./` or `/`.
* @param {string} p
* @returns {boolean}
*/
export function isSkillPath(p) {
return isSkillName(normalizeVfsPath(p));
}
/**
* Derives the SKILL file name for a skill title, e.g. `Image pipeline` →
* `image-pipeline.SKILL`. Existing extension and any path separators are stripped first so
* the result is always a legal flat SKILL name.
* @param {string} title
* @returns {string}
*/
export function skillFileName(title) {
const base = String(title || '')
.trim()
.replace(SKILL_EXT, '')
.replace(/[\\/]+/g, '-')
.replace(/[^a-zA-Z0-9._\- ]+/g, '')
// Lowercased so a title written as "Image pipeline" and one written as "image pipeline"
// cannot produce two files that differ only by case — on case-insensitive filesystems
// those would collide, and on Linux they would read as two separate skills.
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-.]+|[-.]+$/g, '')
.slice(0, 80);
return `${base || 'skill'}${SKILL_EXT}`;
}
/**
* Splits a SKILL file into its title line and markdown body.
*
* The first line is the title. Everything after it is the markdown body, with the blank
* separator line that conventionally follows the title removed. A file whose entire content
* is a single line is a title with an empty body.
*
* @param {string|Buffer} raw
* @returns {{ title: string, markdown: string, raw: string }}
*/
export function parseSkill(raw) {
const text = typeof raw === 'string' ? raw : Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw ?? '');
const nl = text.indexOf('\n');
const firstLine = (nl === -1 ? text : text.slice(0, nl)).replace(/\r$/, '');
const rest = nl === -1 ? '' : text.slice(nl + 1).replace(/^\r?\n/, '');
return {
title: (firstLine.trim() || 'Untitled').slice(0, SKILL_TITLE_MAX),
markdown: rest.replace(/\s+$/, ''),
raw: text,
};
}
/**
* Serializes a title + markdown body back into SKILL file content.
* The title always occupies line 1, followed by a blank line, then the body.
* @param {{ title?: string, markdown?: string }} parts
* @returns {string}
*/
export function renderSkill({ title, markdown } = {}) {
const t = typeof title === 'string' && title.trim() ? title.trim().slice(0, SKILL_TITLE_MAX) : 'Untitled';
const body = typeof markdown === 'string' ? markdown.replace(/^\r?\n+/, '').replace(/\s+$/, '') : '';
return body ? `${t}\n\n${body}\n` : `${t}\n`;
}
/**
* Reads the title of a SKILL file without pulling in the whole body.
* @param {string} absPath
* @returns {string}
*/
export function readSkillTitle(absPath) {
try {
return parseSkill(fs.readFileSync(absPath, 'utf8')).title;
} catch {
return '';
}
}
/**
* Enumerates every SKILL file at the VFS root. SKILL files are flat by contract, so this is
* a single `readdir` — no recursion.
* @param {string} vfsRoot
* @returns {Array<{ path: string, title: string, lines: number, size: number, updated: string|null }>}
*/
export function listSkillFiles(vfsRoot) {
const out = [];
if (!vfsRoot || !fs.existsSync(vfsRoot)) return out;
let dirents;
try {
dirents = fs.readdirSync(vfsRoot, { withFileTypes: true });
} catch {
return out;
}
for (const dirent of dirents) {
// Symlinks are skipped: a SKILL file must be a real, flat, readable file.
if (!dirent.isFile()) continue;
if (!isSkillName(dirent.name)) continue;
const absPath = path.join(vfsRoot, dirent.name);
let size = 0;
let updated = null;
try {
const stat = fs.statSync(absPath);
size = stat.size;
updated = stat.mtime.toISOString();
} catch { /* leave size 0 */ }
// Read once and derive both the title and the line count from it, rather than
// reading the file twice (readSkillTitle re-reads it).
let title = '';
let lines = 0;
try {
const raw = fs.readFileSync(absPath, 'utf8');
title = parseSkill(raw).title;
const split = raw.split('\n');
if (split[split.length - 1] === '') split.pop();
lines = split.length;
} catch { /* leave title '' and lines 0 */ }
out.push({ path: dirent.name, title, lines, size, updated });
}
out.sort((a, b) => a.path.localeCompare(b.path));
return out;
}

View File

@@ -17,6 +17,8 @@ function getMimeType(filePath) {
case '.jpeg': return 'image/jpeg';
case '.txt': return 'text/plain';
case '.csv': return 'text/csv';
case '.md': return 'text/markdown';
case '.skill': return 'text/markdown';
default: return 'application/octet-stream';
}
}
@@ -56,17 +58,56 @@ function assertNotReadOnlyDatabasePath(filePath) {
}
}
/**
* Resolve the on-disk VFS root for the user's CURRENTLY SELECTED company.
*
* This must stay in lockstep with the store, which scopes every `modelvfs` call by
* `google_auth.selected_company` read at call time — see `resolveSelectedCompanyAccess` in
* libdashstore. Callers therefore must NOT pass a `companyId`: a value captured earlier (at
* tool-construction time, or from a request made before a company switch) can disagree with
* what the store will act on, which would hydrate one company's directory from another
* company's rows and delete files from a directory the database never touched.
*
* One authority, read fresh on every call, for both the disk and the database halves.
*
* @param {number} googleAuthId
* @returns {Promise<string>} Absolute `data/vfs/<companyId>` path
* @throws {Error} 'No company selected' when the user has no company selected
*/
export async function resolveVfsRoot(googleAuthId) {
return vfsRootFor(await getStore(), googleAuthId);
}
/**
* Shared implementation of {@link resolveVfsRoot}. Takes an already-resolved store so callers
* that need it anyway (e.g. `prepareVfs`, which hydrates from the same store) do not pay for a
* second lookup.
*
* @param {object} store
* @param {number} googleAuthId
* @returns {Promise<string>}
*/
async function vfsRootFor(store, googleAuthId) {
const row = await store.google_auth.getById(googleAuthId);
const companyId = row?.selectedCompanyId;
if (companyId == null) {
throw new Error('No company selected');
}
return path.resolve(process.cwd(), 'data/vfs', String(companyId));
}
/**
* Ensure the local directory for this company's VFS exists and is hydrated from modelvfs.
* @param {{ companyId: number, googleAuthId: number }} ctx
*
* @param {{ googleAuthId: number }} ctx
* @returns {Promise<string>} vfsRoot directory path
*/
export async function prepareVfs({ companyId, googleAuthId }) {
const vfsRoot = path.resolve(process.cwd(), 'data/vfs', String(companyId));
export async function prepareVfs({ googleAuthId }) {
const store = await getStore();
const vfsRoot = await vfsRootFor(store, googleAuthId);
fs.mkdirSync(vfsRoot, { recursive: true });
try {
const store = await getStore();
const files = await store.modelvfs.list(googleAuthId);
for (const file of files) {
const diskPath = path.resolve(vfsRoot, file.name);
@@ -88,7 +129,11 @@ export async function prepareVfs({ companyId, googleAuthId }) {
/**
* Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table.
* @param {{ companyId: number, googleAuthId: number, vfsRoot: string }} ctx
*
* The store re-scopes by the currently selected company on every call, so this only stays
* correct while `vfsRoot` was produced by `prepareVfs`/`resolveVfsRoot` in the same turn.
*
* @param {{ googleAuthId: number, vfsRoot: string }} ctx
*/
export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
if (!fs.existsSync(vfsRoot)) return;
@@ -128,8 +173,8 @@ export async function syncVfsToDb({ googleAuthId, vfsRoot }) {
/**
* Creates a sandboxed VFS module object mirroring Node's `fs` and `fs/promises`.
* @param {string} vfsRoot
* @param {{ googleAuthId: number, companyId: number }} [options]
* @param {string} vfsRoot Absolute root, as returned by `prepareVfs`/`resolveVfsRoot`
* @param {{ googleAuthId: number }} [options]
*/
export function createVfs(vfsRoot, options = {}) {
const { googleAuthId } = options;