u
This commit is contained in:
419
src/tools/analyze-image.js
Normal file
419
src/tools/analyze-image.js
Normal 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 '<';
|
||||
case '>': return '>';
|
||||
case '&': return '&';
|
||||
case "'": return ''';
|
||||
case '"': return '"';
|
||||
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
223
src/tools/find-files.js
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 `` 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
26
src/tools/llm-key.js
Normal 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
317
src/tools/patch.js
Normal 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
191
src/tools/read-file.js
Normal 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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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
129
src/tools/rm.js
Normal 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
257
src/tools/search-images.js
Normal 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
188
src/tools/skill.js
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user