From 49cd559c8aa3fbf49be15b88384ca92f075f9aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 7 Mar 2026 11:55:38 +0000 Subject: [PATCH] tool registry, claude tool awareness, and model selector fix - Add agent-agnostic tool registry (tool-registry.ts) that discovers tools from disk - Embed tool-loader extension as platform infrastructure (ensure-tool-loader.ts) - Inject tool context into Claude prompts on first message - Add marketplace tool sync (sync-marketplace.ts) - Fix model selector defaulting to claude-code when no model explicitly selected - Exclude tool-loader-source.ts from tsconfig (Pi-specific deps) Co-Authored-By: Claude Opus 4.6 --- src/servers/bootstrap.ts | 10 +- src/servers/ensure-tool-loader.ts | 17 ++ src/servers/sidecar/claude/claude-manager.ts | 37 ++- src/servers/sync-marketplace.ts | 97 ++++++++ src/servers/tool-loader-source.ts | 235 ++++++++++++++++++ src/servers/tool-registry.ts | 169 +++++++++++++ .../apps/Chat/components/ModelSelector.tsx | 8 + tsconfig.json | 3 +- 8 files changed, 565 insertions(+), 11 deletions(-) create mode 100644 src/servers/ensure-tool-loader.ts create mode 100644 src/servers/sync-marketplace.ts create mode 100644 src/servers/tool-loader-source.ts create mode 100644 src/servers/tool-registry.ts diff --git a/src/servers/bootstrap.ts b/src/servers/bootstrap.ts index ee3c630c..a7195a4d 100644 --- a/src/servers/bootstrap.ts +++ b/src/servers/bootstrap.ts @@ -2,9 +2,8 @@ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { DATA_PATH } from './data-path'; -import { syncSeedSkills } from './sync-skills'; -import { syncSeedTools } from './sync-tools'; -import { syncSeedExtensions } from './sync-extensions'; +import { syncMarketplaceTools } from './sync-marketplace'; +import { ensureToolLoader } from './ensure-tool-loader'; // Queue is now owned by the sidecar process import { startDiscordBotIfConfigured } from './channels/discord/bot'; import { startTelegramBotIfConfigured } from './channels/telegram/bot'; @@ -68,9 +67,8 @@ async function installPi(): Promise { } } - syncSeedSkills(); - syncSeedTools(); - syncSeedExtensions(); + await syncMarketplaceTools(); + ensureToolLoader(); // Queue is initialized by the sidecar process await startDiscordBotIfConfigured().catch((err) => { diff --git a/src/servers/ensure-tool-loader.ts b/src/servers/ensure-tool-loader.ts new file mode 100644 index 00000000..ea07c74b --- /dev/null +++ b/src/servers/ensure-tool-loader.ts @@ -0,0 +1,17 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { DATA_PATH } from './data-path'; + +const SOURCE_PATH = resolve(import.meta.dir, 'tool-loader-source.ts'); + +export function ensureToolLoader(): void { + const targetDir = join(DATA_PATH, 'extensions', 'tool-loader'); + const targetFile = join(targetDir, 'index.ts'); + + mkdirSync(targetDir, { recursive: true }); + + const source = readFileSync(SOURCE_PATH, 'utf-8'); + writeFileSync(targetFile, source, 'utf-8'); + + console.log('[bootstrap] Wrote tool-loader extension →', targetFile); +} diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index 115e9ec0..597c6e33 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -4,6 +4,8 @@ import type { PiEvent, MessageCost } from '../../api/pi/types'; import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol'; import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state'; import { getProxySecret } from './proxy'; +import { discoverTools } from '../../tool-registry'; +import type { ToolDefinition } from '../../tool-registry'; const SEND_TIMEOUT_MS = 5 * 60 * 1000; const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051'; @@ -11,6 +13,27 @@ const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); const getHomeDir = (email: string) => join(DATA_PATH, email, 'home'); +function buildToolContext(email: string): string { + const tools = discoverTools(email); + if (tools.length === 0) return ''; + + const sections = tools.map((tool) => { + const lines = [`## ${tool.name}`, tool.description]; + const inputEntries = Object.entries(tool.inputs); + if (inputEntries.length > 0) { + const inputParts = inputEntries.map(([name, input]) => { + const opt = input.optional ? ', optional' : ''; + const vals = input.values ? `, values: ${input.values}` : ''; + return `${name} (${input.type}${vals}${opt}): ${input.description}`; + }); + lines.push(`Inputs: ${inputParts.join('; ')}`); + } + return lines.join('\n'); + }); + + return `The following tools are available on this platform:\n\n${sections.join('\n\n---\n\n')}\n\n---\n\n`; +} + const toShellUsername = (username: string, email: string): string => { const raw = username || email.split('@')[0]!; return ( @@ -66,12 +89,15 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise; + implementation: string; +}; + +type MarketplaceResponse = { + categories: Array<{ name: string; tools: MarketplaceTool[] }>; + uncategorized: MarketplaceTool[]; +}; + +function buildToolMd(tool: MarketplaceTool): string { + const lines = ['---']; + lines.push(`name: ${tool.name}`); + lines.push(`label: ${tool.label}`); + lines.push(`description: ${tool.description}`); + lines.push(`version: ${tool.version}`); + lines.push(`language: ${tool.language}`); + + if (tool.inputs && Object.keys(tool.inputs).length > 0) { + lines.push('inputs:'); + for (const [key, input] of Object.entries(tool.inputs)) { + lines.push(` ${key}:`); + lines.push(` type: ${input.type}`); + if (input.values) { + lines.push(` values: ${input.values}`); + } + lines.push(` description: "${input.description}"`); + } + } + + lines.push('---'); + lines.push(''); + lines.push(tool.body); + + return lines.join('\n'); +} + +export async function syncMarketplaceTools(): Promise { + try { + const res = await fetch(`${MARKETPLACE_URL}/api/tools/native`); + if (!res.ok) { + console.error(`[marketplace] Failed to fetch tools: ${res.status} ${res.statusText}`); + return; + } + + const data = (await res.json()) as MarketplaceResponse; + + mkdirSync(GLOBAL_TOOLS_DIR, { recursive: true }); + + const allTools: MarketplaceTool[] = []; + for (const category of data.categories) { + allTools.push(...category.tools); + } + if (data.uncategorized) { + allTools.push(...data.uncategorized); + } + + for (const tool of allTools) { + const targetDir = join(GLOBAL_TOOLS_DIR, tool.dirName); + const targetToolMd = join(targetDir, 'TOOL.md'); + + if (existsSync(targetToolMd)) { + const existingVersion = parseSeedVersion(readFileSync(targetToolMd, 'utf-8')); + if (tool.version <= existingVersion) continue; + } + + mkdirSync(targetDir, { recursive: true }); + writeFileSync(targetToolMd, buildToolMd(tool), 'utf-8'); + writeFileSync(join(targetDir, 'index.ts'), tool.implementation, 'utf-8'); + + console.log(`[marketplace] Synced tool: ${tool.dirName} (v${tool.version})`); + } + } catch (err) { + console.error('[marketplace] Failed to sync tools:', err instanceof Error ? err.message : err); + } +} diff --git a/src/servers/tool-loader-source.ts b/src/servers/tool-loader-source.ts new file mode 100644 index 00000000..f8993c5e --- /dev/null +++ b/src/servers/tool-loader-source.ts @@ -0,0 +1,235 @@ +import type { ExtensionAPI } from '@mariozechner/pi-coding-agent'; +import { Type, type TSchema } from '@sinclair/typebox'; +import { readdirSync, existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +type ToolParamType = 'string' | 'number' | 'boolean' | 'enum'; + +type ToolParam = { + type: ToolParamType; + description: string; + values?: string[]; + default?: unknown; + optional?: boolean; +}; + +type ToolMeta = { + name: string; + label: string; + description: string; + language: 'typescript' | 'bash' | 'python'; + inputs: Record; +}; + +function parseFrontmatter(content: string): { meta: Partial; body: string } { + const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { meta: {}, body: content }; + + const yamlBlock = match[1]!; + const body = match[2]!; + const meta: Record = {}; + + const lines = yamlBlock.split('\n'); + let currentKey: string | null = null; + let currentObj: Record | null = null; + let currentSubKey: string | null = null; + let currentSubObj: Record | null = null; + + for (const line of lines) { + const topMatch = line.match(/^(\w[\w-]*):\s*(.*)$/); + if (topMatch && !line.startsWith(' ')) { + if (currentSubObj && currentSubKey && currentObj) { + currentObj[currentSubKey] = currentSubObj; + currentSubObj = null; + currentSubKey = null; + } + if (currentObj && currentKey) { + meta[currentKey] = currentObj; + currentObj = null; + currentKey = null; + } + const [, key, value] = topMatch; + if (!value || value.trim() === '') { + currentKey = key!; + currentObj = {}; + } else { + meta[key!] = value.trim(); + } + continue; + } + + const midMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/); + if (midMatch && currentObj !== null) { + if (currentSubObj && currentSubKey) { + currentObj[currentSubKey] = currentSubObj; + currentSubObj = null; + currentSubKey = null; + } + const [, key, value] = midMatch; + if (!value || value.trim() === '') { + currentSubKey = key!; + currentSubObj = {}; + } else { + currentObj[key!] = value.trim(); + } + continue; + } + + const deepMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/); + if (deepMatch && currentSubObj !== null) { + const [, key, value] = deepMatch; + currentSubObj[key!] = value!.trim(); + continue; + } + + const arrayMatch = line.match(/^ - (.+)$/); + if (arrayMatch && currentSubObj !== null) { + const key = Object.keys(currentSubObj).at(-1); + if (key) { + const arr = currentSubObj[key]; + if (Array.isArray(arr)) { + arr.push(arrayMatch[1]!.trim()); + } else { + currentSubObj[key] = [arrayMatch[1]!.trim()]; + } + } + } + } + + if (currentSubObj && currentSubKey && currentObj) { + currentObj[currentSubKey] = currentSubObj; + } + if (currentObj && currentKey) { + meta[currentKey] = currentObj; + } + + return { meta: meta as Partial, body }; +} + +function buildSchema(inputs: Record): TSchema { + const props: Record = {}; + + for (const [paramName, param] of Object.entries(inputs)) { + let schema: TSchema; + + switch (param.type) { + case 'enum': { + const raw = param.values; + const values = Array.isArray(raw) + ? raw + : typeof raw === 'string' + ? raw.split(',').map((v) => v.trim()) + : []; + schema = Type.Union( + values.map((v) => Type.Literal(v)), + { description: param.description }, + ); + break; + } + case 'number': + schema = Type.Number({ description: param.description }); + break; + case 'boolean': + schema = Type.Boolean({ description: param.description }); + break; + default: + schema = Type.String({ description: param.description }); + } + + props[paramName] = param.optional ? Type.Optional(schema) : schema; + } + + return Type.Object(props); +} + +function discoverTools(dir: string): Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> { + if (!existsSync(dir)) return []; + + const discovered: Array<{ toolDir: string; entryFile: string; meta: ToolMeta }> = []; + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const toolDir = join(dir, entry.name); + const toolMdPath = join(toolDir, 'TOOL.md'); + if (!existsSync(toolMdPath)) continue; + + const indexTs = join(toolDir, 'index.ts'); + const indexJs = join(toolDir, 'index.js'); + const entryFile = existsSync(indexTs) ? indexTs : existsSync(indexJs) ? indexJs : null; + if (!entryFile) { + console.warn(`[tool-loader] Skipping ${entry.name}: no index.ts or index.js found`); + continue; + } + + const content = readFileSync(toolMdPath, 'utf-8'); + const { meta } = parseFrontmatter(content); + + if (!meta.name || !meta.description) { + console.warn(`[tool-loader] Skipping ${entry.name}: missing name or description in TOOL.md`); + continue; + } + + discovered.push({ toolDir, entryFile, meta: meta as ToolMeta }); + } + + return discovered; +} + +export default function (pi: ExtensionAPI) { + const rawDirs = process.env.PI_TOOLS_DIRS ?? ''; + const toolDirs = rawDirs.split(':').filter(Boolean); + + if (toolDirs.length === 0) { + console.warn('[tool-loader] PI_TOOLS_DIRS not set — no custom tools will be loaded'); + return; + } + + const seen = new Set(); + + for (const dir of toolDirs) { + const tools = discoverTools(dir); + + for (const { entryFile, meta } of tools) { + if (seen.has(meta.name)) continue; + seen.add(meta.name); + + const schema = buildSchema(meta.inputs ?? {}); + const capturedEntry = entryFile; + + pi.registerTool({ + name: meta.name, + label: meta.label ?? meta.name, + description: meta.description, + parameters: schema, + + async execute(toolCallId, params, signal, onUpdate, ctx) { + let executeFn: Function | undefined; + try { + const mod = await import(capturedEntry); + executeFn = mod.execute ?? mod.default?.execute; + } catch (err) { + return { + content: [{ type: 'text', text: `[tool-loader] Failed to load ${meta.name}: ${String(err)}` }], + details: { error: String(err) }, + isError: true, + }; + } + + if (typeof executeFn !== 'function') { + return { + content: [{ type: 'text', text: `[tool-loader] ${meta.name}/index.ts must export an "execute" function` }], + details: {}, + isError: true, + }; + } + + return executeFn(toolCallId, params, signal, onUpdate, ctx); + }, + }); + + console.log(`[tool-loader] Registered tool: ${meta.name} (${capturedEntry})`); + } + } +} diff --git a/src/servers/tool-registry.ts b/src/servers/tool-registry.ts new file mode 100644 index 00000000..f0794aec --- /dev/null +++ b/src/servers/tool-registry.ts @@ -0,0 +1,169 @@ +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { DATA_PATH } from './data-path'; + +type ToolInput = { + type: string; + description: string; + values?: string; + optional?: boolean; +}; + +export type ToolDefinition = { + name: string; + label: string; + description: string; + language: string; + inputs: Record; + implementationPath: string; + body: string; +}; + +type ToolMeta = { + name: string; + label: string; + description: string; + language: string; + inputs: Record; +}; + +function parseFrontmatter(content: string): { meta: Partial; body: string } { + const match = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); + if (!match) return { meta: {}, body: content }; + + const yamlBlock = match[1]!; + const body = match[2]!; + const meta: Record = {}; + + const lines = yamlBlock.split('\n'); + let currentKey: string | null = null; + let currentObj: Record | null = null; + let currentSubKey: string | null = null; + let currentSubObj: Record | null = null; + + for (const line of lines) { + const topMatch = line.match(/^(\w[\w-]*):\s*(.*)$/); + if (topMatch && !line.startsWith(' ')) { + if (currentSubObj && currentSubKey && currentObj) { + currentObj[currentSubKey] = currentSubObj; + currentSubObj = null; + currentSubKey = null; + } + if (currentObj && currentKey) { + meta[currentKey] = currentObj; + currentObj = null; + currentKey = null; + } + const [, key, value] = topMatch; + if (!value || value.trim() === '') { + currentKey = key!; + currentObj = {}; + } else { + meta[key!] = value.trim(); + } + continue; + } + + const midMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/); + if (midMatch && currentObj !== null) { + if (currentSubObj && currentSubKey) { + currentObj[currentSubKey] = currentSubObj; + currentSubObj = null; + currentSubKey = null; + } + const [, key, value] = midMatch; + if (!value || value.trim() === '') { + currentSubKey = key!; + currentSubObj = {}; + } else { + currentObj[key!] = value.trim(); + } + continue; + } + + const deepMatch = line.match(/^ (\w[\w-]*):\s*(.*)$/); + if (deepMatch && currentSubObj !== null) { + const [, key, value] = deepMatch; + currentSubObj[key!] = value!.trim(); + continue; + } + + const arrayMatch = line.match(/^ - (.+)$/); + if (arrayMatch && currentSubObj !== null) { + const key = Object.keys(currentSubObj).at(-1); + if (key) { + const arr = currentSubObj[key]; + if (Array.isArray(arr)) { + arr.push(arrayMatch[1]!.trim()); + } else { + currentSubObj[key] = [arrayMatch[1]!.trim()]; + } + } + } + } + + if (currentSubObj && currentSubKey && currentObj) { + currentObj[currentSubKey] = currentSubObj; + } + if (currentObj && currentKey) { + meta[currentKey] = currentObj; + } + + return { meta: meta as Partial, body }; +} + +function discoverToolsInDir(dir: string): ToolDefinition[] { + if (!existsSync(dir)) return []; + + const tools: ToolDefinition[] = []; + const entries = readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const toolDir = join(dir, entry.name); + const toolMdPath = join(toolDir, 'TOOL.md'); + if (!existsSync(toolMdPath)) continue; + + const indexTs = join(toolDir, 'index.ts'); + const indexJs = join(toolDir, 'index.js'); + const entryFile = existsSync(indexTs) ? indexTs : existsSync(indexJs) ? indexJs : null; + if (!entryFile) continue; + + const content = readFileSync(toolMdPath, 'utf-8'); + const { meta, body } = parseFrontmatter(content); + + if (!meta.name || !meta.description) continue; + + tools.push({ + name: meta.name, + label: meta.label ?? meta.name, + description: meta.description, + language: meta.language ?? 'typescript', + inputs: (meta.inputs ?? {}) as Record, + implementationPath: entryFile, + body, + }); + } + + return tools; +} + +export function discoverTools(email: string): ToolDefinition[] { + const globalDir = join(DATA_PATH, 'tools'); + const userDir = join(DATA_PATH, email, 'tools'); + + const globalTools = discoverToolsInDir(globalDir); + const userTools = discoverToolsInDir(userDir); + + // User tools override global tools with the same name + const seen = new Map(); + for (const tool of globalTools) { + seen.set(tool.name, tool); + } + for (const tool of userTools) { + seen.set(tool.name, tool); + } + + return Array.from(seen.values()); +} diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx index ff30fa5d..c2c04601 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ModelSelector.tsx @@ -1,3 +1,4 @@ +import { useEffect } from 'react'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import type { ModelOption } from 'state/useModels'; import { getProviderDisplayName } from 'state/useModels'; @@ -54,6 +55,13 @@ export function ModelSelector({ const providerModels = availableModels.filter((m) => m.provider === activeProvider); const fallbackModelId = providerModels[0]?.id ?? null; + // Sync actual selection when UI shows a fallback provider but nothing is selected + useEffect(() => { + if (!selectedModel && !model && fallbackModelId) { + onModelChange(fallbackModelId); + } + }, [selectedModel, model, fallbackModelId]); + // Lock after session has started const isLocked = hasStarted || isGenerating || !isConnected; diff --git a/tsconfig.json b/tsconfig.json index eccc77ac..1eca1d06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -47,6 +47,7 @@ "exclude": [ "dist", "node_modules", - "src/videos" + "src/videos", + "src/servers/tool-loader-source.ts" ] }