feat: add in-chat skill discovery and local registry install command
This commit is contained in:
+136
-1
@@ -23,9 +23,11 @@ import type { RoutingPolicy } from '../routing/index.js';
|
||||
import type { HookEngine } from '../hooks/index.js';
|
||||
import { createClientFromConfig } from './models.js';
|
||||
import { matchReactionPrompt } from '../automation/reactions.js';
|
||||
import type { SkillRegistry } from '../skills/index.js';
|
||||
import { loadSkillRegistryCatalog } from '../skills/index.js';
|
||||
import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySource } from '../skills/index.js';
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { dirname, resolve } from 'path';
|
||||
|
||||
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
|
||||
const providerConfigs: Partial<Record<ModelProvider, ModelConfig>> = {};
|
||||
@@ -98,6 +100,47 @@ function isTtsEnabledForChannel(config: Config, channel: string): boolean {
|
||||
}
|
||||
return enabledChannels.includes(channel);
|
||||
}
|
||||
|
||||
function resolveRegistrySource(config: Config): { source?: SkillRegistrySource; error?: string } {
|
||||
const raw = config.skills.registry_source?.trim() || process.env.FLYNN_SKILLS_REGISTRY_SOURCE?.trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
error: 'Skills registry is not configured. Set `skills.registry_source` (or FLYNN_SKILLS_REGISTRY_SOURCE).',
|
||||
};
|
||||
}
|
||||
if (raw.startsWith('http://')) {
|
||||
return { error: `Registry URL must use https:// (${raw})` };
|
||||
}
|
||||
if (raw.startsWith('https://')) {
|
||||
return { source: { type: 'url', url: raw } };
|
||||
}
|
||||
return { source: { type: 'file', path: raw } };
|
||||
}
|
||||
|
||||
function resolveRegistryEntryLocalPath(entry: SkillRegistryEntry, registrySource: SkillRegistrySource): string | null {
|
||||
const source = entry.source.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git+https://')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source.startsWith('file://')) {
|
||||
try {
|
||||
return decodeURIComponent(new URL(source).pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (registrySource.type === 'file' && (source.startsWith('./') || source.startsWith('../'))) {
|
||||
return resolve(dirname(resolve(registrySource.path)), source);
|
||||
}
|
||||
|
||||
return resolve(source);
|
||||
}
|
||||
/**
|
||||
* Create the unified message handler for the channel registry.
|
||||
* Each channel+sender pair gets its own AgentOrchestrator backed by a persistent session.
|
||||
@@ -121,6 +164,7 @@ export function createMessageRouter(deps: {
|
||||
intentRegistry?: ComponentRegistry;
|
||||
routingPolicy?: RoutingPolicy;
|
||||
skillRegistry?: SkillRegistry;
|
||||
skillInstaller?: SkillInstaller;
|
||||
externalBackends?: Partial<Record<ExternalBackendName, ExternalBackend>>;
|
||||
defaultName?: ExternalBackendName;
|
||||
}): {
|
||||
@@ -1018,6 +1062,97 @@ export function createMessageRouter(deps: {
|
||||
? `Denied: ${selected.tool} (${selected.id}) — ${reason}`
|
||||
: `Approval request is no longer pending: ${selected.id}`;
|
||||
},
|
||||
|
||||
skillCommand: async (inputRaw: string) => {
|
||||
const input = inputRaw.trim();
|
||||
const [actionRaw, ...rest] = input.split(/\s+/).filter(Boolean);
|
||||
const action = actionRaw?.toLowerCase();
|
||||
|
||||
if (!action || action === 'help') {
|
||||
return [
|
||||
'Usage: /skill <list|search|install>',
|
||||
'/skill list',
|
||||
'/skill search <term>',
|
||||
'/skill install <registry-id>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (action === 'list') {
|
||||
const skills = deps.skillRegistry?.listAvailable() ?? [];
|
||||
if (skills.length === 0) {
|
||||
return 'No available skills are currently loaded.';
|
||||
}
|
||||
return [
|
||||
`Available skills (${skills.length}):`,
|
||||
...skills.map((skill) => `- ${skill.manifest.name} (${skill.manifest.tier}, v${skill.manifest.version})`),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
if (action === 'search') {
|
||||
const term = rest.join(' ').trim().toLowerCase();
|
||||
if (!term) {
|
||||
return 'Usage: /skill search <term>';
|
||||
}
|
||||
const sourceResolved = resolveRegistrySource(deps.config);
|
||||
if (!sourceResolved.source) {
|
||||
return sourceResolved.error ?? 'Failed to resolve registry source.';
|
||||
}
|
||||
try {
|
||||
const catalog = await loadSkillRegistryCatalog(sourceResolved.source);
|
||||
const matches = catalog.skills.filter((entry) => {
|
||||
const haystack = `${entry.id} ${entry.name} ${entry.summary} ${entry.publisher ?? ''}`.toLowerCase();
|
||||
return haystack.includes(term);
|
||||
}).slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
return `No registry skills found for "${term}".`;
|
||||
}
|
||||
return [
|
||||
`Registry matches (${matches.length}):`,
|
||||
...matches.map((entry) => `- ${entry.id} (${entry.version}) — ${entry.summary}`),
|
||||
].join('\n');
|
||||
} catch (error) {
|
||||
return `Registry lookup failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (action === 'install') {
|
||||
const registryId = rest.join(' ').trim().toLowerCase();
|
||||
if (!registryId) {
|
||||
return 'Usage: /skill install <registry-id>';
|
||||
}
|
||||
if (!deps.skillInstaller || !deps.skillRegistry) {
|
||||
return 'Skill installation is unavailable in this runtime.';
|
||||
}
|
||||
|
||||
const sourceResolved = resolveRegistrySource(deps.config);
|
||||
if (!sourceResolved.source) {
|
||||
return sourceResolved.error ?? 'Failed to resolve registry source.';
|
||||
}
|
||||
|
||||
try {
|
||||
const catalog = await loadSkillRegistryCatalog(sourceResolved.source);
|
||||
const entry = catalog.skills.find((item) => item.id.toLowerCase() === registryId);
|
||||
if (!entry) {
|
||||
return `Registry skill not found: ${registryId}`;
|
||||
}
|
||||
const localPath = resolveRegistryEntryLocalPath(entry, sourceResolved.source);
|
||||
if (!localPath) {
|
||||
return `Registry entry '${entry.id}' points to a remote source. Use CLI install for remote sources.`;
|
||||
}
|
||||
|
||||
const installed = deps.skillInstaller.install(localPath);
|
||||
if (!installed) {
|
||||
return `Failed to install skill from ${entry.source}`;
|
||||
}
|
||||
deps.skillRegistry.register(installed);
|
||||
return `Installed skill '${installed.manifest.name}' (${installed.manifest.version}) from registry id '${entry.id}'.`;
|
||||
} catch (error) {
|
||||
return `Skill install failed: ${error instanceof Error ? error.message : String(error)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'Unknown skill action. Use: list, search, install';
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user