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