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 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,8 @@ import { mkdirSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { DATA_PATH } from './data-path';
|
import { DATA_PATH } from './data-path';
|
||||||
import { syncSeedSkills } from './sync-skills';
|
import { syncMarketplaceTools } from './sync-marketplace';
|
||||||
import { syncSeedTools } from './sync-tools';
|
import { ensureToolLoader } from './ensure-tool-loader';
|
||||||
import { syncSeedExtensions } from './sync-extensions';
|
|
||||||
// Queue is now owned by the sidecar process
|
// Queue is now owned by the sidecar process
|
||||||
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
import { startDiscordBotIfConfigured } from './channels/discord/bot';
|
||||||
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
|
import { startTelegramBotIfConfigured } from './channels/telegram/bot';
|
||||||
@@ -68,9 +67,8 @@ async function installPi(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncSeedSkills();
|
await syncMarketplaceTools();
|
||||||
syncSeedTools();
|
ensureToolLoader();
|
||||||
syncSeedExtensions();
|
|
||||||
// Queue is initialized by the sidecar process
|
// Queue is initialized by the sidecar process
|
||||||
|
|
||||||
await startDiscordBotIfConfigured().catch((err) => {
|
await startDiscordBotIfConfigured().catch((err) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import type { PiEvent, MessageCost } from '../../api/pi/types';
|
|||||||
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
import type { ClaudeSpawnParams, ClaudeSpawnStreamingParams, ClaudeCodeResult } from '../protocol';
|
||||||
import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
import { getState, setClaudeSession, clearClaudeSession, getClaudeSession } from './state';
|
||||||
import { getProxySecret } from './proxy';
|
import { getProxySecret } from './proxy';
|
||||||
|
import { discoverTools } from '../../tool-registry';
|
||||||
|
import type { ToolDefinition } from '../../tool-registry';
|
||||||
|
|
||||||
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
const SEND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||||
const PROXY_PORT = process.env.ANTHROPIC_PROXY_PORT ?? '5051';
|
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');
|
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 toShellUsername = (username: string, email: string): string => {
|
||||||
const raw = username || email.split('@')[0]!;
|
const raw = username || email.split('@')[0]!;
|
||||||
return (
|
return (
|
||||||
@@ -66,12 +89,15 @@ export async function spawnClaude(params: ClaudeSpawnParams): Promise<ClaudeCode
|
|||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
const shellUsername = toShellUsername(username, email);
|
const shellUsername = toShellUsername(username, email);
|
||||||
|
|
||||||
const claudeArgs = [CLAUDE_BIN, '-p', prompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
const existingSession = getClaudeSession(sessionKey);
|
||||||
|
const isResume = !!existingSession;
|
||||||
|
|
||||||
|
const effectivePrompt = isResume ? prompt : buildToolContext(email) + prompt;
|
||||||
|
const claudeArgs = [CLAUDE_BIN, '-p', effectivePrompt, '--dangerously-skip-permissions', '--output-format', 'json'];
|
||||||
|
|
||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
if (subModel) claudeArgs.push('--model', subModel);
|
if (subModel) claudeArgs.push('--model', subModel);
|
||||||
|
|
||||||
const existingSession = getClaudeSession(sessionKey);
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
claudeArgs.push('--resume', existingSession);
|
claudeArgs.push('--resume', existingSession);
|
||||||
}
|
}
|
||||||
@@ -157,10 +183,14 @@ export async function spawnClaudeStreaming(
|
|||||||
const homeDir = getHomeDir(email);
|
const homeDir = getHomeDir(email);
|
||||||
const workDir = cwd ?? homeDir;
|
const workDir = cwd ?? homeDir;
|
||||||
|
|
||||||
|
const existingSession = getClaudeSession(sessionKey);
|
||||||
|
const isResume = !!existingSession;
|
||||||
|
|
||||||
|
const effectivePrompt = isResume ? prompt : buildToolContext(email) + prompt;
|
||||||
const claudeArgs = [
|
const claudeArgs = [
|
||||||
CLAUDE_BIN,
|
CLAUDE_BIN,
|
||||||
'-p',
|
'-p',
|
||||||
prompt,
|
effectivePrompt,
|
||||||
'--dangerously-skip-permissions',
|
'--dangerously-skip-permissions',
|
||||||
'--output-format',
|
'--output-format',
|
||||||
'stream-json',
|
'stream-json',
|
||||||
@@ -171,7 +201,6 @@ export async function spawnClaudeStreaming(
|
|||||||
const subModel = params.model?.split('/')[1];
|
const subModel = params.model?.split('/')[1];
|
||||||
if (subModel) claudeArgs.push('--model', subModel);
|
if (subModel) claudeArgs.push('--model', subModel);
|
||||||
|
|
||||||
const existingSession = getClaudeSession(sessionKey);
|
|
||||||
if (existingSession) {
|
if (existingSession) {
|
||||||
claudeArgs.push('--resume', existingSession);
|
claudeArgs.push('--resume', existingSession);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { DATA_PATH } from './data-path';
|
||||||
|
import { parseSeedVersion } from './sync-version';
|
||||||
|
|
||||||
|
const MARKETPLACE_URL = process.env.MARKETPLACE_URL ?? 'https://marketplace.officer.dev';
|
||||||
|
const GLOBAL_TOOLS_DIR = join(DATA_PATH, 'tools');
|
||||||
|
|
||||||
|
type ToolInput = {
|
||||||
|
type: string;
|
||||||
|
description: string;
|
||||||
|
values?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MarketplaceTool = {
|
||||||
|
dirName: string;
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
body: string;
|
||||||
|
version: number;
|
||||||
|
language: string;
|
||||||
|
inputs: Record<string, ToolInput>;
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, ToolParam>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string): { meta: Partial<ToolMeta>; 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<string, unknown> = {};
|
||||||
|
|
||||||
|
const lines = yamlBlock.split('\n');
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
let currentObj: Record<string, unknown> | null = null;
|
||||||
|
let currentSubKey: string | null = null;
|
||||||
|
let currentSubObj: Record<string, unknown> | 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<ToolMeta>, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSchema(inputs: Record<string, ToolParam>): TSchema {
|
||||||
|
const props: Record<string, TSchema> = {};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
|
||||||
|
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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, ToolInput>;
|
||||||
|
implementationPath: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToolMeta = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
language: string;
|
||||||
|
inputs: Record<string, ToolInput>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseFrontmatter(content: string): { meta: Partial<ToolMeta>; 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<string, unknown> = {};
|
||||||
|
|
||||||
|
const lines = yamlBlock.split('\n');
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
let currentObj: Record<string, unknown> | null = null;
|
||||||
|
let currentSubKey: string | null = null;
|
||||||
|
let currentSubObj: Record<string, unknown> | 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<ToolMeta>, 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<string, ToolInput>,
|
||||||
|
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<string, ToolDefinition>();
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import type { ModelOption } from 'state/useModels';
|
import type { ModelOption } from 'state/useModels';
|
||||||
import { getProviderDisplayName } from 'state/useModels';
|
import { getProviderDisplayName } from 'state/useModels';
|
||||||
@@ -54,6 +55,13 @@ export function ModelSelector({
|
|||||||
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
const providerModels = availableModels.filter((m) => m.provider === activeProvider);
|
||||||
const fallbackModelId = providerModels[0]?.id ?? null;
|
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
|
// Lock after session has started
|
||||||
const isLocked = hasStarted || isGenerating || !isConnected;
|
const isLocked = hasStarted || isGenerating || !isConnected;
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -47,6 +47,7 @@
|
|||||||
"exclude": [
|
"exclude": [
|
||||||
"dist",
|
"dist",
|
||||||
"node_modules",
|
"node_modules",
|
||||||
"src/videos"
|
"src/videos",
|
||||||
|
"src/servers/tool-loader-source.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user