feat: add in-chat skill discovery and local registry install command
This commit is contained in:
@@ -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 <list|search|install>` | In-chat skill discovery/install (`list`, `search <term>`, `install <registry-id>`) |
|
||||
|
||||
## 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 <list|search|install>` | In-chat skill discovery/install (`list`, `search <term>`, `install <registry-id>`) |
|
||||
| `/quit` | Exit |
|
||||
|
||||
#### Runtime Model Switching
|
||||
|
||||
+19
-1
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -13,5 +13,6 @@ export {
|
||||
createApprovalsCommand,
|
||||
createApproveCommand,
|
||||
createDenyCommand,
|
||||
createSkillCommand,
|
||||
registerBuiltinCommands,
|
||||
} from './builtin/index.js';
|
||||
|
||||
@@ -38,4 +38,5 @@ export interface CommandServices {
|
||||
getApprovals?: () => Promise<string> | string;
|
||||
approvePending?: (input: string) => Promise<string> | string;
|
||||
denyPending?: (input: string) => Promise<string> | string;
|
||||
skillCommand?: (input: string) => Promise<string> | string;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+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