diff --git a/README.md b/README.md index b56637c..780ef6e 100644 --- a/README.md +++ b/README.md @@ -474,6 +474,7 @@ Notes: | `/approvals` | List pending approval gates for current session | | `/approve [id]` | Approve latest (or specific) pending gate | | `/deny [id] [reason]` | Deny latest (or specific) pending gate | +| `/skill ` | In-chat skill discovery/install (`list`, `search `, `install `) | ## Web UI Dashboard @@ -524,6 +525,7 @@ pnpm tui:fs | `/approvals` | List pending approval gates for current session | | `/approve [id]` | Approve latest (or specific) pending gate | | `/deny [id] [reason]` | Deny latest (or specific) pending gate | +| `/skill ` | In-chat skill discovery/install (`list`, `search `, `install `) | | `/quit` | Exit | #### Runtime Model Switching diff --git a/docs/plans/state.json b/docs/plans/state.json index 717fdd2..48fc1c1 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5305,6 +5305,24 @@ "docs/plans/state.json" ], "test_status": "pnpm test:run src/hooks/engine.test.ts src/commands/builtin/index.test.ts src/daemon/routing.test.ts src/tools/executor.test.ts + pnpm typecheck passing" + }, + "skill-discovery-in-chat-tier-b4": { + "status": "completed", + "date": "2026-02-18", + "updated": "2026-02-18", + "summary": "Completed Tier B4 in-chat discovery/install surface by adding `/skill` command (`list|search|install`) through command fast-path. Reused existing registry source/catalog infrastructure and managed-skill installer, with local registry-source install support and explicit remote-source fallback guidance to CLI.", + "files_modified": [ + "src/commands/types.ts", + "src/commands/builtin/index.ts", + "src/commands/builtin/index.test.ts", + "src/commands/index.ts", + "src/daemon/routing.ts", + "src/daemon/routing.test.ts", + "src/daemon/index.ts", + "README.md", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/commands/builtin/index.test.ts src/daemon/routing.test.ts + pnpm typecheck passing" } }, "overall_progress": { @@ -5328,7 +5346,7 @@ "gmail_auth_cli": "flynn gmail-auth command implemented with OAuth2 flow, doctor check, config routed to Telegram", "native_audio_support": "completed — smart routing for native audio (Gemini/OpenAI/GitHub) vs Whisper transcription fallback", "remaining_phases_completion": "Phase 1: 3/3 (100%) — context levels, command registry, memory structure. Phase 2: 3/3 (100%) — component registry, confidence routing, history index. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Implement Tier B4 skill discovery index (registry-backed search/install flow)" + "next_up": "Implement Tier B3 progressive web app push notifications for WebChat" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/commands/builtin/index.test.ts b/src/commands/builtin/index.test.ts index f7bc9d4..369fde8 100644 --- a/src/commands/builtin/index.test.ts +++ b/src/commands/builtin/index.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createTransferCommand } from './index.js'; +import { createApproveCommand, createApprovalsCommand, createContextCommand, createDenyCommand, createElevateCommand, createModelCommand, createQueueCommand, createResearchCommand, createSkillCommand, createTransferCommand } from './index.js'; describe('builtin /model command', () => { it('passes through the full argument string', async () => { @@ -241,3 +241,19 @@ describe('builtin approval commands', () => { expect(result).toEqual({ handled: true, text: 'denied' }); }); }); + +describe('builtin /skill command', () => { + it('passes subcommand text to skillCommand service', async () => { + const cmd = createSkillCommand(); + const skillCommand = vi.fn(() => 'ok'); + const result = await cmd.execute(['search', 'calendar'], { + channel: 'test', + senderId: 'user', + sessionId: 's1', + rawInput: '/skill search calendar', + services: { skillCommand }, + }); + expect(skillCommand).toHaveBeenCalledWith('search calendar'); + expect(result).toEqual({ handled: true, text: 'ok' }); + }); +}); diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts index aafae90..aef39b9 100644 --- a/src/commands/builtin/index.ts +++ b/src/commands/builtin/index.ts @@ -284,6 +284,22 @@ export function createDenyCommand(): CommandDefinition { }; } +export function createSkillCommand(): CommandDefinition { + return { + name: 'skill', + description: 'In-chat skill discovery and install (list/search/install)', + execute: async (args, ctx) => { + if (!ctx.services?.skillCommand) { + return notAvailable('Skill command'); + } + return { + handled: true, + text: await ctx.services.skillCommand(args.join(' ').trim()), + }; + }, + }; +} + export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createHelpCommand(registry)); registry.register(createStatusCommand()); @@ -299,4 +315,5 @@ export function registerBuiltinCommands(registry: CommandRegistry): void { registry.register(createApprovalsCommand()); registry.register(createApproveCommand()); registry.register(createDenyCommand()); + registry.register(createSkillCommand()); } diff --git a/src/commands/index.ts b/src/commands/index.ts index cc467bb..5131e9d 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -13,5 +13,6 @@ export { createApprovalsCommand, createApproveCommand, createDenyCommand, + createSkillCommand, registerBuiltinCommands, } from './builtin/index.js'; diff --git a/src/commands/types.ts b/src/commands/types.ts index 3c36dab..0f88065 100644 --- a/src/commands/types.ts +++ b/src/commands/types.ts @@ -38,4 +38,5 @@ export interface CommandServices { getApprovals?: () => Promise | string; approvePending?: (input: string) => Promise | string; denyPending?: (input: string) => Promise | string; + skillCommand?: (input: string) => Promise | string; } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 74d447d..94d7b7e 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -213,7 +213,7 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions): const messageRouter = createMessageRouter({ sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, - config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, hookEngine, intentRegistry, routingPolicy, skillRegistry, + config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, hookEngine, intentRegistry, routingPolicy, skillRegistry, skillInstaller, ...createConfiguredExternalBackends(config), }); channelRegistry.setMessageHandler(messageRouter.handler); diff --git a/src/daemon/routing.test.ts b/src/daemon/routing.test.ts index 4c0f71a..f90e2c3 100644 --- a/src/daemon/routing.test.ts +++ b/src/daemon/routing.test.ts @@ -911,6 +911,79 @@ describe('daemon command fast-path integration', () => { await expect(pendingPromise).resolves.toEqual({ approved: true }); expect(processSpy).not.toHaveBeenCalled(); }); + + it('handles /skill list via command fast-path', async () => { + const processSpy = vi.spyOn(AgentOrchestrator.prototype, 'process'); + const session = { + id: 'telegram:skill-user', + addMessage: vi.fn(), + getHistory: vi.fn(() => []), + clear: vi.fn(), + replaceHistory: vi.fn(), + getConfig: vi.fn(() => undefined), + setConfig: vi.fn(), + deleteConfig: vi.fn(), + }; + + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + + const router = createMessageRouter({ + sessionManager: { + getSession: vi.fn(() => session), + } as unknown as MessageRouterDeps['sessionManager'], + modelRouter: { + getAvailableTiers: () => ['fast', 'default', 'complex', 'local'], + getAllLabels: () => ({ fast: 'fast', default: 'default', complex: 'complex', local: 'local' }), + getLabel: (tier: string) => tier, + } as unknown as MessageRouterDeps['modelRouter'], + systemPrompt: 'test prompt', + toolRegistry: { + clone() { return this; }, + register: vi.fn(), + } as unknown as MessageRouterDeps['toolRegistry'], + toolExecutor: {} as unknown as MessageRouterDeps['toolExecutor'], + config: { + agents: { + primary_tier: 'default', + delegation: { + compaction: 'fast', + memory_extraction: 'fast', + classification: 'fast', + tool_summarisation: 'fast', + complex_reasoning: 'complex', + }, + max_delegation_depth: 3, + max_iterations: 10, + }, + compaction: { enabled: false }, + models: { default: { provider: 'anthropic', model: 'claude' } }, + } as unknown as MessageRouterDeps['config'], + commandRegistry, + skillRegistry: { + listAvailable: () => ([ + { manifest: { name: 'calendar', tier: 'managed', version: '1.2.0' } }, + { manifest: { name: 'todoist', tier: 'workspace', version: '0.4.1' } }, + ]), + } as unknown as MessageRouterDeps['skillRegistry'], + }); + + const reply = vi.fn(async (_message: OutboundMessage) => {}); + await router.handler({ + id: 'skill-1', + channel: 'telegram', + senderId: 'skill-user', + text: '/skill list', + timestamp: Date.now(), + metadata: { isCommand: true, command: 'skill', commandArgs: 'list' }, + } as MessageRouterInput, reply); + + expect(processSpy).not.toHaveBeenCalled(); + const outbound = reply.mock.calls[0]?.[0] as OutboundMessage | undefined; + expect(String(outbound?.text)).toContain('Available skills (2):'); + expect(String(outbound?.text)).toContain('calendar'); + expect(String(outbound?.text)).toContain('todoist'); + }); }); describe('daemon external backend integration', () => { diff --git a/src/daemon/routing.ts b/src/daemon/routing.ts index 89a13be..d430572 100644 --- a/src/daemon/routing.ts +++ b/src/daemon/routing.ts @@ -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> { const providerConfigs: Partial> = {}; @@ -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>; 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 ', + '/skill list', + '/skill search ', + '/skill install ', + ].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 '; + } + 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 '; + } + 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'; + }, }, });