diff --git a/src/api/routes/company.js b/src/api/routes/company.js index f035b39..85be584 100644 --- a/src/api/routes/company.js +++ b/src/api/routes/company.js @@ -1,6 +1,7 @@ import * as db from '../../db/company.js'; import * as googleAuth from '../../db/googleAuth.js'; import { clearCachedUser } from '../middleware/auth.js'; +import { initCompanyVfs } from '../../tools/vfs.js'; function userPayload(authRow) { return { @@ -169,6 +170,11 @@ export function register(app, basePath = '') { const company = await db.createCompany(req.user.id, body); await googleAuth.setSelectedCompany(req.user.id, company.id); clearCachedUser(req.user.id); + try { + await initCompanyVfs({ googleAuthId: req.user.id }); + } catch (vfsErr) { + console.error('Failed to initialize company VFS:', vfsErr); + } const authRow = await googleAuth.getAllowedEmail(req.user.email); res.status(201).json({ company, user: userPayload(authRow) }); } catch (error) { diff --git a/src/defaultSkills/image-search-inspect-present.SKILL b/src/defaultSkills/image-search-inspect-present.SKILL new file mode 100644 index 0000000..627dce9 --- /dev/null +++ b/src/defaultSkills/image-search-inspect-present.SKILL @@ -0,0 +1,11 @@ +Efficient image search, inspection, and presentation + +# Goal +Find relevant online images, inspect the best candidates, and show the user the results with minimal turns and tool overhead. + +# Procedure +1. Search with concise, specific queries. For Categories or brands photos down to 300x300 are ok, but prouct photos should be 800+ +2. **Search in parallel.** For multiple distinct subjects, call `search_images` concurrently, requesting a few candidates per query. +3. Use the paths returned by `search_images`. If needed, check `find_files` to confirm paths. +4. **Inspect candidates in one call.** Pass up to nine candidate paths to one `analyze_image` call. +5. When you upload a photo using the API you also must show the photo to the user in markdown when summarizing. `![Descriptive alt text](images/candidate.avif)` diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index ada84ef..2c3150f 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -11,7 +11,7 @@ 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 { prepareVfs, resolveVfsRoot } from '../tools/vfs.js'; import { listSkillFiles } from '../tools/skill.js'; import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissues.js'; import { createFindFilesTool } from '../tools/find-files.js'; @@ -446,13 +446,16 @@ 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". + // Resolve and prepare 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); + vfsRoot = await prepareVfs({ googleAuthId }); } catch (err) { - console.warn('conversation: could not resolve VFS root for skill index:', err.message); + console.warn('conversation: could not prepare VFS root for skill index:', err.message); + try { + vfsRoot = await resolveVfsRoot(googleAuthId); + } catch { /* ignore fallback error */ } } const messages = [ diff --git a/src/tools/reset-vfs.js b/src/tools/reset-vfs.js index c7142dc..cbe3808 100644 --- a/src/tools/reset-vfs.js +++ b/src/tools/reset-vfs.js @@ -50,6 +50,9 @@ export function createResetVfsTool({ googleAuthId }) { // 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 (file.name === '.vfs_initialized') { + continue; + } if (keepSkills && isSkillPath(file.name)) { keptSkills.push(file.name); continue; @@ -63,6 +66,9 @@ export function createResetVfsTool({ googleAuthId }) { let deletedCount = 0; if (fs.existsSync(vfsRoot)) { for (const entry of fs.readdirSync(vfsRoot)) { + if (entry === '.vfs_initialized') { + continue; + } // 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)) { diff --git a/src/tools/skill.js b/src/tools/skill.js index d00411a..865c331 100644 --- a/src/tools/skill.js +++ b/src/tools/skill.js @@ -1,5 +1,13 @@ import fs from 'fs'; import path from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export const DEFAULT_SKILLS_DIR = fs.existsSync(path.resolve(__dirname, '../defaultSkills')) + ? path.resolve(__dirname, '../defaultSkills') + : path.resolve(process.cwd(), 'defaultSkills'); /** * skill.js — the `SKILL` VFS file type. @@ -185,4 +193,32 @@ export function listSkillFiles(vfsRoot) { out.sort((a, b) => a.path.localeCompare(b.path)); return out; +} + +/** + * Reads all default skill files from DEFAULT_SKILLS_DIR. + * @returns {Array<{ name: string, content: string }>} + */ +export function getDefaultSkills() { + if (!fs.existsSync(DEFAULT_SKILLS_DIR)) return []; + let dirents; + try { + dirents = fs.readdirSync(DEFAULT_SKILLS_DIR, { withFileTypes: true }); + } catch { + return []; + } + + const out = []; + for (const dirent of dirents) { + if (!dirent.isFile() || !isSkillName(dirent.name)) continue; + const absPath = path.join(DEFAULT_SKILLS_DIR, dirent.name); + try { + const content = fs.readFileSync(absPath, 'utf8'); + out.push({ name: dirent.name, content }); + } catch (err) { + console.warn(`[skill] failed to read default skill "${dirent.name}":`, err.message); + } + } + out.sort((a, b) => a.name.localeCompare(b.name)); + return out; } \ No newline at end of file diff --git a/src/tools/vfs.js b/src/tools/vfs.js index 4ad4e71..00e155b 100644 --- a/src/tools/vfs.js +++ b/src/tools/vfs.js @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { getStore } from '../db/store.js'; import env from '../config/env.js'; +import { isSkillPath, getDefaultSkills } from './skill.js'; const FULL_HASH_REGEX = /^[a-fA-F0-9]{64}$/; const VIRTUAL_IMAGE_FOLDERS = new Set(['image', 'images']); @@ -96,8 +97,72 @@ async function vfsRootFor(store, googleAuthId) { return path.resolve(process.cwd(), 'data/vfs', String(companyId)); } +/** + * Loads default SKILL files into the selected company's VFS (both modelvfs table and disk cache). + * + * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx + * @returns {Promise>} List of loaded skill file names + */ +export async function loadDefaultSkills({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { + const store = passedStore || await getStore(); + const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); + fs.mkdirSync(vfsRoot, { recursive: true }); + + const defaultSkills = getDefaultSkills(); + const loaded = []; + + for (const skill of defaultSkills) { + try { + await store.modelvfs.write(googleAuthId, { + name: skill.name, + mime: 'text/markdown', + content: Buffer.from(skill.content, 'utf8'), + }); + } catch (err) { + console.error(`[vfs] failed to write default skill "${skill.name}" to modelvfs:`, err.message); + } + + try { + const diskPath = path.resolve(vfsRoot, skill.name); + fs.writeFileSync(diskPath, skill.content, 'utf8'); + loaded.push(skill.name); + } catch (err) { + console.error(`[vfs] failed to write default skill "${skill.name}" to disk:`, err.message); + } + } + + return loaded; +} + +/** + * Initializes the VFS for the selected company with default SKILL files and marks it initialized. + * + * @param {{ googleAuthId: number, store?: object, vfsRoot?: string }} ctx + * @returns {Promise} vfsRoot directory path + */ +export async function initCompanyVfs({ googleAuthId, store: passedStore, vfsRoot: passedVfsRoot } = {}) { + const store = passedStore || await getStore(); + const vfsRoot = passedVfsRoot || await vfsRootFor(store, googleAuthId); + fs.mkdirSync(vfsRoot, { recursive: true }); + + await loadDefaultSkills({ googleAuthId, store, vfsRoot }); + + try { + await store.modelvfs.write(googleAuthId, { + name: '.vfs_initialized', + mime: 'text/plain', + content: Buffer.from('1', 'utf8'), + }); + } catch (err) { + console.warn('[vfs] failed to mark .vfs_initialized in modelvfs:', err.message); + } + + return vfsRoot; +} + /** * Ensure the local directory for this company's VFS exists and is hydrated from modelvfs. + * If the company has no initialized VFS and no skills yet, loads default skills automatically. * * @param {{ googleAuthId: number }} ctx * @returns {Promise} vfsRoot directory path @@ -108,8 +173,25 @@ export async function prepareVfs({ googleAuthId }) { fs.mkdirSync(vfsRoot, { recursive: true }); try { - const files = await store.modelvfs.list(googleAuthId); + let files = await store.modelvfs.list(googleAuthId); + const hasInitMarker = files.some((f) => f.name === '.vfs_initialized'); + const hasAnySkills = files.some((f) => isSkillPath(f.name)); + + if (!hasInitMarker && !hasAnySkills) { + await initCompanyVfs({ googleAuthId, store, vfsRoot }); + files = await store.modelvfs.list(googleAuthId); + } else if (!hasInitMarker) { + try { + await store.modelvfs.write(googleAuthId, { + name: '.vfs_initialized', + mime: 'text/plain', + content: Buffer.from('1', 'utf8'), + }); + } catch { /* ignore */ } + } + for (const file of files) { + if (file.name.startsWith('.')) continue; const diskPath = path.resolve(vfsRoot, file.name); if (!diskPath.startsWith(vfsRoot)) continue; if (!fs.existsSync(diskPath)) { @@ -127,6 +209,7 @@ export async function prepareVfs({ googleAuthId }) { return vfsRoot; } + /** * Syncs any modified or newly written files from the disk vfsRoot back to the modelvfs table. *