multica/src/agent/skills/watcher.ts
Jiayuan dafe1085b4 refactor(skills): simplify loading to managed + profile sources
- Remove bundled, extraDirs, and plugin skill sources
- Skills now load from only two sources:
  1. managed (~/.super-multica/skills/) - global skills
  2. profile (~/.super-multica/agent-profiles/<id>/skills/) - profile-specific
- Add initializeManagedSkills() to copy bundled skills on first run
- Delete plugin.ts (multica.plugin.json discovery)
- Update documentation and tests

This simplifies the architecture by treating bundled skills as
managed skills that are initialized once, rather than maintaining
multiple loading sources with complex precedence.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 18:16:16 +08:00

257 lines
6.4 KiB
TypeScript

/**
* Skills Watcher Module
*
* Watches skill directories for changes and triggers reload
*/
import { join } from "node:path";
import { existsSync } from "node:fs";
import { DATA_DIR } from "../../shared/index.js";
// ============================================================================
// Types
// ============================================================================
export interface SkillsWatcherOptions {
/** Profile ID (for profile-specific skills watching) */
profileId?: string | undefined;
/** Profile base directory */
profileBaseDir?: string | undefined;
/** Debounce delay in milliseconds (default: 250) */
debounceMs?: number | undefined;
/** Whether watching is enabled (default: true) */
enabled?: boolean | undefined;
}
export interface SkillsChangeEvent {
/** Reason for the change */
reason: "watch" | "manual";
/** Path that changed (if known) */
changedPath?: string | undefined;
}
export type SkillsChangeListener = (event: SkillsChangeEvent) => void;
// ============================================================================
// State
// ============================================================================
/** Current skills version (timestamp-based) */
let currentVersion = Date.now();
/** Registered change listeners */
const listeners = new Set<SkillsChangeListener>();
/** Active watcher instance */
let watcherInstance: {
close: () => Promise<void>;
paths: string[];
} | null = null;
/** Debounce timer */
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
/** Pending change path */
let pendingChangePath: string | undefined;
// ============================================================================
// Version Management
// ============================================================================
/**
* Get the current skills version
*/
export function getSkillsVersion(): number {
return currentVersion;
}
/**
* Bump the skills version
*
* @param reason - Reason for the bump
* @param changedPath - Path that changed (optional)
* @returns New version number
*/
export function bumpSkillsVersion(
reason: SkillsChangeEvent["reason"] = "manual",
changedPath?: string,
): number {
const now = Date.now();
currentVersion = now > currentVersion ? now : currentVersion + 1;
// Notify listeners
const event: SkillsChangeEvent = { reason, changedPath };
for (const listener of listeners) {
try {
listener(event);
} catch {
// Ignore listener errors
}
}
return currentVersion;
}
// ============================================================================
// Change Listeners
// ============================================================================
/**
* Register a change listener
*
* @param listener - Callback function
* @returns Unsubscribe function
*/
export function onSkillsChange(listener: SkillsChangeListener): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
// ============================================================================
// Watcher Management
// ============================================================================
/** Paths to ignore when watching */
const IGNORED_PATTERNS = [
/(^|[\\/])\.git([\\/]|$)/,
/(^|[\\/])node_modules([\\/]|$)/,
/(^|[\\/])dist([\\/]|$)/,
/(^|[\\/])\.DS_Store$/,
];
/**
* Resolve paths to watch
*/
function resolveWatchPaths(options: SkillsWatcherOptions): string[] {
const paths: string[] = [];
// Managed skills (~/.super-multica/skills)
const managedSkills = join(DATA_DIR, "skills");
if (existsSync(managedSkills)) {
paths.push(managedSkills);
}
// Profile skills (~/.super-multica/agent-profiles/<id>/skills)
if (options.profileId) {
const profileBaseDir = options.profileBaseDir ?? join(DATA_DIR, "agent-profiles");
const profileSkills = join(profileBaseDir, options.profileId, "skills");
if (existsSync(profileSkills)) {
paths.push(profileSkills);
}
}
return paths;
}
/**
* Start watching skill directories
*
* @param options - Watcher options
* @returns Stop function
*/
export async function startSkillsWatcher(
options: SkillsWatcherOptions = {},
): Promise<() => Promise<void>> {
// Stop existing watcher if any
await stopSkillsWatcher();
if (options.enabled === false) {
return async () => {};
}
const debounceMs = options.debounceMs ?? 250;
const paths = resolveWatchPaths(options);
if (paths.length === 0) {
return async () => {};
}
// Dynamically import chokidar (optional dependency)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let chokidar: any;
try {
// @ts-expect-error - chokidar is optional, dynamically loaded
chokidar = await import("chokidar");
} catch {
// chokidar not installed, skip watching
console.warn("[skills] chokidar not installed, file watching disabled");
return async () => {};
}
const watcher = chokidar.watch(paths, {
ignoreInitial: true,
ignored: IGNORED_PATTERNS,
awaitWriteFinish: {
stabilityThreshold: debounceMs,
pollInterval: 100,
},
});
const scheduleUpdate = (changedPath?: string | undefined) => {
pendingChangePath = changedPath ?? pendingChangePath;
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => {
const path = pendingChangePath;
pendingChangePath = undefined;
debounceTimer = null;
bumpSkillsVersion("watch", path);
}, debounceMs);
};
watcher.on("add", (p: string) => scheduleUpdate(p));
watcher.on("change", (p: string) => scheduleUpdate(p));
watcher.on("unlink", (p: string) => scheduleUpdate(p));
watcher.on("error", (err: Error) => {
console.error("[skills] watcher error:", err);
});
watcherInstance = {
close: async () => {
await watcher.close();
},
paths,
};
return stopSkillsWatcher;
}
/**
* Stop the skills watcher
*/
export async function stopSkillsWatcher(): Promise<void> {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
pendingChangePath = undefined;
if (watcherInstance) {
try {
await watcherInstance.close();
} catch {
// Ignore close errors
}
watcherInstance = null;
}
}
/**
* Check if watcher is currently active
*/
export function isWatcherActive(): boolean {
return watcherInstance !== null;
}
/**
* Get currently watched paths
*/
export function getWatchedPaths(): string[] {
return watcherInstance?.paths ?? [];
}