feat(skills): enable watcher wiring through daemon lifecycle

This commit is contained in:
William Valentin
2026-02-12 17:18:22 -08:00
parent 95091cc198
commit b773e2bbf3
6 changed files with 171 additions and 9 deletions
+17 -2
View File
@@ -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",
+28
View File
@@ -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] },
+9
View File
@@ -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({
+26 -4
View File
@@ -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<DaemonContext> {
});
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<DaemonContext> {
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<DaemonContext> {
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;
+57
View File
@@ -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<string, unknown> = {}) {
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);
});
});
+34 -3
View File
@@ -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 ─────────────────────────────────────────────────────────