feat(skills): target watcher updates with safe fallback
This commit is contained in:
+97
-5
@@ -9,9 +9,18 @@ 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, SkillsWatcher, loadAllSkills } from '../skills/index.js';
|
||||
import {
|
||||
SkillRegistry,
|
||||
SkillInstaller,
|
||||
SkillsWatcher,
|
||||
loadAllSkills,
|
||||
loadSkill,
|
||||
discoverSkills,
|
||||
type Skill,
|
||||
type SkillTier,
|
||||
} from '../skills/index.js';
|
||||
import { assembleSystemPrompt } from '../prompt/index.js';
|
||||
import { resolve } from 'path';
|
||||
import { join, relative, resolve, sep } from 'path';
|
||||
import { homedir } from 'os';
|
||||
import type { MemoryStore } from '../memory/store.js';
|
||||
import type { CommandRegistry } from '../commands/index.js';
|
||||
@@ -30,12 +39,89 @@ 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);
|
||||
const tierPriority: Record<SkillTier, number> = {
|
||||
bundled: 0,
|
||||
managed: 1,
|
||||
workspace: 2,
|
||||
};
|
||||
const skillSources: Array<{ dir: string; tier: SkillTier }> = [
|
||||
{ dir: config.skills.bundled_dir, tier: 'bundled' },
|
||||
{ dir: config.skills.managed_dir ?? defaultManagedDir, tier: 'managed' },
|
||||
{ dir: config.skills.workspace_dir, tier: 'workspace' },
|
||||
].filter((source): source is { dir: string; tier: SkillTier } => Boolean(source.dir));
|
||||
const skillLoadConfig = {
|
||||
bundledDir: config.skills.bundled_dir,
|
||||
managedDir: config.skills.managed_dir ?? defaultManagedDir,
|
||||
workspaceDir: config.skills.workspace_dir,
|
||||
};
|
||||
|
||||
const reloadAllSkills = (reason: string): void => {
|
||||
const reloadedSkills = loadAllSkills(skillLoadConfig);
|
||||
skillRegistry.reset(reloadedSkills);
|
||||
console.log(`Skills watcher full reload (${reason}); now tracking ${reloadedSkills.length} skill(s)`);
|
||||
};
|
||||
|
||||
const resolveChangedSkillDir = (changedPath: string): { dir: string; tier: SkillTier } | null => {
|
||||
const absolutePath = resolve(changedPath);
|
||||
|
||||
for (const source of skillSources) {
|
||||
const rel = relative(source.dir, absolutePath);
|
||||
if (rel.startsWith('..') || rel === '') {
|
||||
continue;
|
||||
}
|
||||
const [skillDirName] = rel.split(sep);
|
||||
if (!skillDirName) {
|
||||
continue;
|
||||
}
|
||||
return { dir: join(source.dir, skillDirName), tier: source.tier };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resolveBestSkillByName = (name: string): Skill | null => {
|
||||
let selected: Skill | null = null;
|
||||
for (const source of skillSources) {
|
||||
const candidate = discoverSkills(source.dir, source.tier).find((skill) => skill.manifest.name === name);
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (!selected || tierPriority[candidate.manifest.tier] >= tierPriority[selected.manifest.tier]) {
|
||||
selected = candidate;
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
};
|
||||
|
||||
const applyTargetedSkillChange = (changedPath: string): boolean => {
|
||||
const resolved = resolveChangedSkillDir(changedPath);
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const loaded = loadSkill(resolved.dir, resolved.tier);
|
||||
if (loaded) {
|
||||
const existing = skillRegistry.get(loaded.manifest.name);
|
||||
if (existing && tierPriority[existing.manifest.tier] > tierPriority[loaded.manifest.tier]) {
|
||||
return true;
|
||||
}
|
||||
skillRegistry.register(loaded);
|
||||
return true;
|
||||
}
|
||||
|
||||
const existingAtDir = skillRegistry.list().find((skill) => resolve(skill.directory) === resolve(resolved.dir));
|
||||
if (!existingAtDir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
skillRegistry.unregister(existingAtDir.manifest.name);
|
||||
const replacement = resolveBestSkillByName(existingAtDir.manifest.name);
|
||||
if (replacement) {
|
||||
skillRegistry.register(replacement);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const skills = loadAllSkills(skillLoadConfig);
|
||||
|
||||
for (const skill of skills) {
|
||||
@@ -62,9 +148,15 @@ export function initSkills(config: Config, lifecycle?: Lifecycle): SkillsResult
|
||||
skillDirs,
|
||||
debounceMs: config.skills.load.watch_debounce_ms,
|
||||
onSkillsChanged: ({ changedPaths }) => {
|
||||
const reloadedSkills = loadAllSkills(skillLoadConfig);
|
||||
skillRegistry.reset(reloadedSkills);
|
||||
console.log(`Skills watcher reloaded ${reloadedSkills.length} skill(s) after ${changedPaths.length} change(s)`);
|
||||
let targetedCount = 0;
|
||||
for (const changedPath of changedPaths) {
|
||||
if (!applyTargetedSkillChange(changedPath)) {
|
||||
reloadAllSkills(`ambiguous change path: ${changedPath}`);
|
||||
return;
|
||||
}
|
||||
targetedCount += 1;
|
||||
}
|
||||
console.log(`Skills watcher applied targeted updates for ${targetedCount} change(s)`);
|
||||
},
|
||||
});
|
||||
skillsWatcher.start();
|
||||
|
||||
Reference in New Issue
Block a user