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

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;