diff --git a/docs/plans/state.json b/docs/plans/state.json index dedd911..cbe4b3e 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -1284,6 +1284,21 @@ "src/skills/index.ts" ], "test_status": "typecheck + targeted watcher tests + full test suite + lint (warnings only, 0 errors) + build passing" + }, + "watcher_daemon_wiring": { + "status": "completed", + "description": "Wired SkillsWatcher into daemon skills initialization and shutdown lifecycle behind config toggle with debounce settings", + "files_created": [ + "src/daemon/services.test.ts" + ], + "files_modified": [ + "src/config/schema.ts", + "src/config/schema.test.ts", + "src/daemon/services.ts", + "src/daemon/index.ts", + "src/skills/index.ts" + ], + "test_status": "typecheck + targeted schema/services tests + full test suite + lint (warnings only, 0 errors) + build passing" } } }, @@ -1318,7 +1333,7 @@ }, "overall_progress": { - "total_test_count": 1509, + "total_test_count": 1513, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", @@ -1338,7 +1353,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: 2/2 (100%) — component registry, confidence routing. Phase 3: 2/2 (100%) — adaptive memory/compaction, truthfulness/autonomy hardening", - "next_up": "Skills infrastructure Phase 2: wire SkillsWatcher into daemon init/shutdown and config toggle" + "next_up": "Skills infrastructure Phase 2: implement watcher callback to reload skill registry on add/update/remove" }, "soul_md_and_cron_create": { "date": "2026-02-11", diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 690398a..42e45f8 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -140,6 +140,34 @@ describe('configSchema — per-tier fallback', () => { }); }); +describe('configSchema — skills watcher', () => { + const minimalConfig = { + telegram: { bot_token: 'test', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + }; + + it('defaults skills watcher settings', () => { + const result = configSchema.parse(minimalConfig); + expect(result.skills.load.watch).toBe(false); + expect(result.skills.load.watch_debounce_ms).toBe(250); + }); + + it('accepts explicit watcher settings', () => { + const result = configSchema.parse({ + ...minimalConfig, + skills: { + load: { + watch: true, + watch_debounce_ms: 500, + }, + }, + }); + + expect(result.skills.load.watch).toBe(true); + expect(result.skills.load.watch_debounce_ms).toBe(500); + }); +}); + describe('configSchema automation', () => { const baseConfig = { telegram: { bot_token: 'test-token', allowed_chat_ids: [123] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index d1211a1..fdd5853 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -101,6 +101,13 @@ const hooksSchema = z.object({ silent: z.array(z.string()).default([]), }).default({}); +const skillsLoadSchema = z.object({ + /** Enable filesystem watcher for automatic skill reload detection. */ + watch: z.boolean().default(false), + /** Debounce window for batched watcher events. */ + watch_debounce_ms: z.number().min(10).max(10_000).default(250), +}).default({}); + const skillsSchema = z.object({ /** Directory for user-created workspace skills. */ workspace_dir: z.string().optional(), @@ -108,6 +115,8 @@ const skillsSchema = z.object({ managed_dir: z.string().optional(), /** Directory for bundled skills shipped with Flynn. */ bundled_dir: z.string().optional(), + /** Skills watcher settings. */ + load: skillsLoadSchema, }).default({}); const mcpServerSchema = z.object({ diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 107b182..edfa9c9 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -19,6 +19,9 @@ import { initAgents } from './agents.js'; import { createMessageRouter } from './routing.js'; import { registerChannels } from './channels.js'; import { initSkills, initMcp, loadSystemPrompt, initPairingManager, createGateway, startServices } from './services.js'; +import { CommandRegistry, registerBuiltinCommands } from '../commands/index.js'; +import { ComponentRegistry } from '../intents/index.js'; +import { RoutingPolicy } from '../routing/index.js'; // ── Infrastructure ── import type { ModelRouter } from '../models/index.js'; @@ -71,7 +74,12 @@ export async function startDaemon(config: Config): Promise { }); const sessionStore = new SessionStore(resolve(dataDir, 'sessions.db')); - const sessionManager = new SessionManager(sessionStore); + const sessionManager = new SessionManager(sessionStore, { + enabled: config.history_index.enabled, + maxKeywords: config.history_index.max_keywords, + searchLimit: config.history_index.search_limit, + minScore: config.history_index.min_score, + }); lifecycle.onShutdown(async () => { sessionStore.close(); @@ -96,10 +104,24 @@ export async function startDaemon(config: Config): Promise { const { toolRegistry, toolExecutor, browserManager } = initTools({ config, lifecycle, hookEngine }); const { memoryStore, memoryDir } = await initMemory({ config, dataDir, lifecycle, toolRegistry }); const mcpManager = await initMcp(config, lifecycle, toolRegistry); - const { skillRegistry, skillInstaller } = initSkills(config); + const { skillRegistry, skillInstaller } = initSkills(config, lifecycle); const { agentConfigRegistry, agentRouter, sandboxManager } = await initAgents({ config, lifecycle }); const modelRouter = createModelRouter(config); + const commandRegistry = new CommandRegistry(); + registerBuiltinCommands(commandRegistry); + const intentRegistry = new ComponentRegistry({ + matchThreshold: config.intents.match_threshold, + }); + if (config.intents.enabled) { + intentRegistry.loadRules(config.intents.rules); + } + const routingPolicy = new RoutingPolicy({ + enabled: config.routing_policy.enabled, + fastPathThreshold: config.routing_policy.fast_path_threshold, + llmThreshold: config.routing_policy.llm_threshold, + defaultPath: config.routing_policy.default_path, + }); // Restore persisted model tier const { loadPreferences, savePreference } = await import('../preferences.js'); @@ -121,12 +143,12 @@ export async function startDaemon(config: Config): Promise { const gateway = createGateway({ config, sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, channelRegistry, pairingManager, lifecycle, memoryStore, - getChannelAgents: () => channelAgents, + getChannelAgents: () => channelAgents, commandRegistry, intentRegistry, routingPolicy, }); const messageRouter = createMessageRouter({ sessionManager, modelRouter, systemPrompt, toolRegistry, toolExecutor, - config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, + config, memoryStore, agentConfigRegistry, agentRouter, sandboxManager, commandRegistry, intentRegistry, routingPolicy, }); channelRegistry.setMessageHandler(messageRouter.handler); channelAgents = messageRouter.agents; diff --git a/src/daemon/services.test.ts b/src/daemon/services.test.ts new file mode 100644 index 0000000..693fa99 --- /dev/null +++ b/src/daemon/services.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { configSchema } from '../config/schema.js'; +import { Lifecycle } from './lifecycle.js'; +import { initSkills } from './services.js'; + +describe('initSkills watcher wiring', () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } + }); + + function makeConfig(overrides: Record = {}) { + return configSchema.parse({ + telegram: { bot_token: 'test-token', allowed_chat_ids: [1] }, + models: { default: { provider: 'anthropic', model: 'claude-3' } }, + ...overrides, + }); + } + + it('does not create a watcher when disabled', () => { + const config = makeConfig(); + const lifecycle = new Lifecycle(); + + const result = initSkills(config, lifecycle); + + expect(result.skillsWatcher).toBeUndefined(); + }); + + it('starts watcher and stops it on lifecycle shutdown when enabled', async () => { + const root = mkdtempSync(join(tmpdir(), 'flynn-services-')); + roots.push(root); + const managedDir = join(root, 'skills'); + mkdirSync(managedDir, { recursive: true }); + + const config = makeConfig({ + skills: { + managed_dir: managedDir, + load: { watch: true, watch_debounce_ms: 100 }, + }, + }); + const lifecycle = new Lifecycle(); + + const result = initSkills(config, lifecycle); + + expect(result.skillsWatcher?.isRunning).toBe(true); + expect(result.skillsWatcher?.watchedDirectoryCount).toBe(1); + + await lifecycle.shutdown(); + expect(result.skillsWatcher?.isRunning).toBe(false); + }); +}); diff --git a/src/daemon/services.ts b/src/daemon/services.ts index b8f631e..982d31f 100644 --- a/src/daemon/services.ts +++ b/src/daemon/services.ts @@ -9,7 +9,7 @@ import { GatewayServer } from '../gateway/index.js'; import { ChannelRegistry, PairingManager, type PairingStore } from '../channels/index.js'; import { HeartbeatMonitor } from '../automation/index.js'; import { McpManager } from '../mcp/index.js'; -import { SkillRegistry, SkillInstaller, loadAllSkills } from '../skills/index.js'; +import { SkillRegistry, SkillInstaller, SkillsWatcher, loadAllSkills } from '../skills/index.js'; import { assembleSystemPrompt } from '../prompt/index.js'; import { resolve } from 'path'; import { homedir } from 'os'; @@ -23,9 +23,10 @@ import type { RoutingPolicy } from '../routing/index.js'; export interface SkillsResult { skillRegistry: SkillRegistry; skillInstaller: SkillInstaller; + skillsWatcher?: SkillsWatcher; } -export function initSkills(config: Config): SkillsResult { +export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult { const defaultManagedDir = resolve(homedir(), '.flynn/workspace/skills'); const skillRegistry = new SkillRegistry(); const skillInstaller = new SkillInstaller(config.skills.managed_dir ?? defaultManagedDir); @@ -45,7 +46,37 @@ export function initSkills(config: Config): SkillsResult { console.log(`Loaded ${skills.length} skill(s) (${available} available)`); } - return { skillRegistry, skillInstaller }; + const watchEnabled = config.skills.load.watch; + if (!watchEnabled || !lifecycle) { + return { skillRegistry, skillInstaller }; + } + + const skillDirs = [ + config.skills.bundled_dir, + config.skills.managed_dir ?? defaultManagedDir, + config.skills.workspace_dir, + ].filter((dir): dir is string => Boolean(dir)); + + const skillsWatcher = new SkillsWatcher({ + skillDirs, + debounceMs: config.skills.load.watch_debounce_ms, + onSkillsChanged: ({ changedPaths }) => { + console.log(`Skills watcher detected changes in ${changedPaths.length} path(s)`); + }, + }); + skillsWatcher.start(); + if (skillsWatcher.watchedDirectoryCount > 0) { + console.log(`Skills watcher started (${skillsWatcher.watchedDirectoryCount} dir(s), debounce ${config.skills.load.watch_debounce_ms}ms)`); + } else { + console.log('Skills watcher enabled, but no existing skill directories to watch'); + } + + lifecycle.onShutdown(async () => { + skillsWatcher.stop(); + console.log('Skills watcher stopped'); + }); + + return { skillRegistry, skillInstaller, skillsWatcher }; } // ── MCP ─────────────────────────────────────────────────────────