This commit is contained in:
seb
2026-10-10 14:04:58 +02:00
parent 188b8efe09
commit f110173e57
8 changed files with 397 additions and 96 deletions

View File

@@ -276,7 +276,7 @@ export function createAnalyzeImageTool({ googleAuthId, model }) {
return defineTool({
name: 'analyze_image',
description:
'Inspect 1–9 images via vision model. Multiple paths render as a labelled comparison grid. ' +
'Inspect 1-9 images via vision model. Multiple paths render as a labelled comparison grid. ' +
'Paths: VFS-relative ("images/foo.avif") or DB ("image/<hash>"). Returns model evaluation.',
strict: false,
parameters: {
@@ -379,12 +379,12 @@ export function createAnalyzeImageTool({ googleAuthId, 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')
`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);

View File

@@ -2,6 +2,7 @@ import fs from 'fs';
import path from 'path';
import { defineTool, ToolError } from 'libllmorbiter';
import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js';
import { getApiRouteSkill } from './routes-skills.js';
import { prepareVfs } from './vfs.js';
const DEFAULT_LIMIT = 400;
@@ -109,17 +110,18 @@ export function createReadFileTool({ googleAuthId }) {
throw new ToolError(`Access denied: path "${filename}" escapes virtual filesystem boundary.`);
}
if (!fs.existsSync(absPath)) {
const virtualSkill = !fs.existsSync(absPath) && isSkillPath(clean) ? getApiRouteSkill(clean) : null;
if (!virtualSkill && !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()) {
if (!virtualSkill && !fs.statSync(absPath).isFile()) {
throw new ToolError(`"${clean}" is not a file. Use find_files to list directories.`);
}
const raw = fs.readFileSync(absPath);
const raw = virtualSkill ? Buffer.from(virtualSkill.content, 'utf8') : fs.readFileSync(absPath);
if (looksBinary(raw)) {
const ext = path.extname(clean).toLowerCase();
throw new ToolError(

114
src/tools/routes-skills.js Normal file
View File

@@ -0,0 +1,114 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
/**
* routes-skills.js — virtual SKILL files generated from docs/api/routes.md.
*
* routes.md is split into `### Section: /<segment>` blocks by scripts/generateApiDocs.js. Each
* block becomes one in-memory `api-<segment>.SKILL` file (never written to disk), so the model
* reads only the routes it needs. Notation and the reusable `Tn` types a section references
* (transitively) are inlined so every skill is self-contained.
*
* Nothing here imports skill.js (skill.js imports this module); the SKILL format is
* "title on line 1, blank line, markdown".
*/
const ROUTES_DOC_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../docs/api/routes.md');
let cache = { mtimeMs: -1, skills: [] };
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'root';
}
function parseRoutesDoc(text) {
const lines = text.split('\n');
let notation = [];
const types = new Map();
const sections = [];
let mode = null; // 'notation' | 'types' | 'section' | null
let current = null;
for (const line of lines) {
const heading = /^### (.*)$/.exec(line);
if (heading) {
const h = heading[1].trim();
current = null;
if (h === 'Notation') {
mode = 'notation';
} else if (h === 'Reusable types') {
mode = 'types';
} else if (h === 'Routes') {
mode = null;
} else if (h.startsWith('Section: ')) {
const name = h.slice('Section: '.length).trim();
current = { slug: slugify(name), title: `API routes: ${name}`, lines: [] };
sections.push(current);
mode = 'section';
} else {
const name = h.replace(/\s*\(.*$/, '');
current = { slug: slugify(name), title: `API routes: ${name}`, lines: [line] };
sections.push(current);
mode = 'section';
}
continue;
}
if (mode === 'notation') notation.push(line);
else if (mode === 'types') {
const m = /^- `(T\d+)` = `(.*)`$/.exec(line);
if (m) types.set(m[1], m[2]);
} else if (mode === 'section' && current) current.lines.push(line);
}
notation = notation.join('\n').trim();
return { notation, types, sections };
}
function buildSkills(text) {
const { notation, types, sections } = parseRoutesDoc(text);
return sections.map((section) => {
const body = section.lines.join('\n').trim();
const used = new Set();
const queue = [...body.matchAll(/\bT\d+\b/g)].map((m) => m[0]);
while (queue.length) {
const id = queue.pop();
if (used.has(id) || !types.has(id)) continue;
used.add(id);
queue.push(...[...types.get(id).matchAll(/\bT\d+\b/g)].map((m) => m[0]));
}
const typeLines = [...used]
.sort((a, b) => Number(a.slice(1)) - Number(b.slice(1)))
.map((id) => `- \`${id}\` = \`${types.get(id)}\``);
const parts = [section.title, '', '### Notation', '', notation];
if (typeLines.length) parts.push('', '### Reusable types', '', ...typeLines);
parts.push('', '### Routes', '', body, '');
return { name: `api-${section.slug}.SKILL`, title: section.title, content: parts.join('\n') };
});
}
/**
* Virtual API-route skills, regenerated whenever routes.md changes on disk.
* @returns {Array<{ name: string, title: string, content: string }>}
*/
export function getApiRouteSkills() {
try {
const { mtimeMs } = fs.statSync(ROUTES_DOC_PATH);
if (mtimeMs !== cache.mtimeMs) {
cache = { mtimeMs, skills: buildSkills(fs.readFileSync(ROUTES_DOC_PATH, 'utf8')) };
}
return cache.skills;
} catch (err) {
console.error('routes-skills: failed to read routes doc:', err.message);
return [];
}
}
/**
* @param {string} name bare file name, e.g. `api-article.SKILL`
* @returns {{ name: string, title: string, content: string }|null}
*/
export function getApiRouteSkill(name) {
const lower = String(name).toLowerCase();
return getApiRouteSkills().find((s) => s.name.toLowerCase() === lower) || null;
}

View File

@@ -1,6 +1,7 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { getApiRouteSkills } from './routes-skills.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -152,6 +153,19 @@ export function readSkillTitle(absPath) {
* @returns {Array<{ path: string, title: string, lines: number, size: number, updated: string|null }>}
*/
export function listSkillFiles(vfsRoot) {
const out = listDiskSkillFiles(vfsRoot);
const onDisk = new Set(out.map((s) => s.path.toLowerCase()));
for (const v of getApiRouteSkills()) {
if (onDisk.has(v.name.toLowerCase())) continue;
const split = v.content.split('\n');
if (split[split.length - 1] === '') split.pop();
out.push({ path: v.name, title: v.title, lines: split.length, size: Buffer.byteLength(v.content), updated: null, virtual: true });
}
out.sort((a, b) => a.path.localeCompare(b.path));
return out;
}
function listDiskSkillFiles(vfsRoot) {
const out = [];
if (!vfsRoot || !fs.existsSync(vfsRoot)) return out;