mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat: disk-backed agent skills with live-reload
This commit is contained in:
parent
30356e36b1
commit
810034b13a
6 changed files with 286 additions and 25 deletions
|
|
@ -31,6 +31,7 @@ import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-co
|
||||||
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
|
||||||
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
|
||||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||||
|
import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
|
||||||
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
|
||||||
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
|
||||||
|
|
||||||
|
|
@ -345,6 +346,10 @@ app.whenReady().then(async () => {
|
||||||
// start bg-task scheduler (cron / window)
|
// start bg-task scheduler (cron / window)
|
||||||
initBackgroundTaskScheduler();
|
initBackgroundTaskScheduler();
|
||||||
|
|
||||||
|
// start disk-skills watcher: live-reload skills dropped into
|
||||||
|
// ~/.rowboat/skills or ~/.agents/skills without an app restart
|
||||||
|
startSkillsWatcher();
|
||||||
|
|
||||||
// register event consumers and start the shared event processor
|
// register event consumers and start the shared event processor
|
||||||
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
// (consumes $WorkDir/events/pending/, routes events to all consumers
|
||||||
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
// concurrently for Pass-1, then fires each consumer's candidates in parallel)
|
||||||
|
|
@ -416,6 +421,7 @@ app.on("before-quit", () => {
|
||||||
stopWorkspaceWatcher();
|
stopWorkspaceWatcher();
|
||||||
stopRunsWatcher();
|
stopRunsWatcher();
|
||||||
stopServicesWatcher();
|
stopServicesWatcher();
|
||||||
|
stopSkillsWatcher();
|
||||||
shutdownLocalSites().catch((error) => {
|
shutdownLocalSites().catch((error) => {
|
||||||
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
console.error('[LocalSites] Failed to shut down cleanly:', error);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -335,9 +335,9 @@ export async function buildCopilotInstructions(): Promise<string> {
|
||||||
const excludeIds: string[] = [];
|
const excludeIds: string[] = [];
|
||||||
if (!composioEnabled) excludeIds.push('composio-integration');
|
if (!composioEnabled) excludeIds.push('composio-integration');
|
||||||
if (!codeModeEnabled) excludeIds.push('code-with-agents');
|
if (!codeModeEnabled) excludeIds.push('code-with-agents');
|
||||||
const catalog = excludeIds.length > 0
|
// Always build from the live skill set so disk skills added/removed at
|
||||||
? buildSkillCatalog({ excludeIds })
|
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
|
||||||
: skillCatalog;
|
const catalog = buildSkillCatalog({ excludeIds });
|
||||||
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
|
||||||
const composioPrompt = await getComposioToolsPrompt();
|
const composioPrompt = await getComposioToolsPrompt();
|
||||||
cachedInstructions = composioPrompt
|
cachedInstructions = composioPrompt
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { WorkDir } from "../../../config/config.js";
|
||||||
|
import { splitFrontmatter } from "../../lib/parse-frontmatter.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A skill discovered on disk. Matches the bundled SkillDefinition shape
|
||||||
|
* (id/title/summary/content) plus provenance about where it came from.
|
||||||
|
*/
|
||||||
|
export type DiskSkill = {
|
||||||
|
id: string; // slugified folder name
|
||||||
|
title: string; // frontmatter `name`
|
||||||
|
summary: string; // frontmatter `description`
|
||||||
|
content: string; // full raw SKILL.md text
|
||||||
|
dir: string; // absolute path to the skill folder
|
||||||
|
skillFile: string; // absolute path to the SKILL.md
|
||||||
|
};
|
||||||
|
|
||||||
|
// Locations scanned for <skill-name>/SKILL.md subfolders. The rowboat root is
|
||||||
|
// derived from WorkDir so it honors the ROWBOAT_WORKDIR override (defaults to
|
||||||
|
// ~/.rowboat/skills); it is scanned first so it wins on id collisions across
|
||||||
|
// the two roots. The ~/.agents/skills root is an external shared convention,
|
||||||
|
// not tied to WorkDir. Exported so the live-reload watcher watches exactly the
|
||||||
|
// same locations.
|
||||||
|
export const SKILL_ROOTS = [
|
||||||
|
path.join(WorkDir, "skills"),
|
||||||
|
path.join(homedir(), ".agents", "skills"),
|
||||||
|
];
|
||||||
|
|
||||||
|
const slugify = (value: string): string =>
|
||||||
|
value.trim().toLowerCase().replace(/[\s_]+/g, "-");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously scan the known skill roots (one level deep) for disk skills.
|
||||||
|
* A missing/empty directory or an unreadable/invalid SKILL.md simply yields no
|
||||||
|
* skill for that folder — this function never throws.
|
||||||
|
*/
|
||||||
|
export function loadDiskSkills(): DiskSkill[] {
|
||||||
|
const skills: DiskSkill[] = [];
|
||||||
|
const seen = new Map<string, string>(); // id -> dir that claimed it
|
||||||
|
|
||||||
|
for (const root of SKILL_ROOTS) {
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
||||||
|
} catch {
|
||||||
|
// Directory missing (e.g. ~/.agents/skills absent) — nothing to scan.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dir = path.join(root, entry.name);
|
||||||
|
const skillFile = path.join(dir, "SKILL.md");
|
||||||
|
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(skillFile, "utf8");
|
||||||
|
} catch {
|
||||||
|
console.warn(`[disk-skills] Skipping '${entry.name}': no readable SKILL.md at ${skillFile}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse with the real YAML frontmatter helper so folded/multi-line
|
||||||
|
// scalars and nested keys (e.g. `metadata:`) are handled correctly.
|
||||||
|
let frontmatter: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
({ frontmatter } = splitFrontmatter(raw));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[disk-skills] Skipping '${entry.name}': failed to parse frontmatter:`, err);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coerce a scalar (including folded/multi-line) frontmatter value to a
|
||||||
|
// single trimmed string; anything non-string yields "".
|
||||||
|
const asString = (value: unknown): string =>
|
||||||
|
typeof value === "string" ? value.trim() : "";
|
||||||
|
const name = asString(frontmatter.name);
|
||||||
|
const description = asString(frontmatter.description);
|
||||||
|
if (!name || !description) {
|
||||||
|
console.warn(`[disk-skills] Skipping '${entry.name}': SKILL.md is missing required 'name' and/or 'description' frontmatter.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = slugify(entry.name);
|
||||||
|
const existing = seen.get(id);
|
||||||
|
if (existing) {
|
||||||
|
console.warn(`[disk-skills] Duplicate skill id '${id}' from ${dir} ignored; already provided by ${existing}.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.set(id, dir);
|
||||||
|
|
||||||
|
skills.push({ id, title: name, summary: description, content: raw, dir, skillFile });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return skills;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { loadDiskSkills } from "./disk-loader.js";
|
||||||
import builtinToolsSkill from "./builtin-tools/skill.js";
|
import builtinToolsSkill from "./builtin-tools/skill.js";
|
||||||
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
|
||||||
import docCollabSkill from "./doc-collab/skill.js";
|
import docCollabSkill from "./doc-collab/skill.js";
|
||||||
|
|
@ -128,32 +129,56 @@ const definitions: SkillDefinition[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const skillEntries = definitions.map((definition) => ({
|
type SkillEntry = SkillDefinition & { catalogPath: string; skillFile?: string };
|
||||||
|
|
||||||
|
const bundledEntries: SkillEntry[] = definitions.map((definition) => ({
|
||||||
...definition,
|
...definition,
|
||||||
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
|
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const catalogSections = skillEntries.map((entry) => [
|
// A disk skill may not shadow a bundled skill: on id collision, bundled wins.
|
||||||
`## ${entry.title}`,
|
const bundledIds = new Set(definitions.map((d) => d.id));
|
||||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
|
||||||
`- **Use it for:** ${entry.summary}`,
|
|
||||||
].join("\n"));
|
|
||||||
|
|
||||||
export const skillCatalog = [
|
// ---- Disk skill layer (refreshable at runtime) ----
|
||||||
"# Rowboat Skill Catalog",
|
// Bundled skills are static. Disk skills (~/.rowboat/skills, ~/.agents/skills)
|
||||||
"",
|
// can be added/edited/removed while the app runs, so they live in a mutable
|
||||||
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
|
// list rebuilt by refreshDiskSkills() and resolved via a separate alias map.
|
||||||
"",
|
let diskEntries: SkillEntry[] = [];
|
||||||
catalogSections.join("\n\n"),
|
const diskAliasMap = new Map<string, ResolvedSkill>();
|
||||||
].join("\n");
|
|
||||||
|
function loadDiskEntries(): SkillEntry[] {
|
||||||
|
return loadDiskSkills()
|
||||||
|
.filter((skill) => {
|
||||||
|
if (bundledIds.has(skill.id)) {
|
||||||
|
console.warn(`[disk-skills] Disk skill '${skill.id}' (${skill.dir}) shadows a built-in skill; keeping the built-in.`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((skill) => ({
|
||||||
|
id: skill.id,
|
||||||
|
title: skill.title,
|
||||||
|
summary: skill.summary,
|
||||||
|
content: skill.content,
|
||||||
|
// For disk skills the catalog/loadSkill reference is the absolute SKILL.md path.
|
||||||
|
catalogPath: skill.skillFile,
|
||||||
|
skillFile: skill.skillFile,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full, current skill set (bundled first so it wins on any collision).
|
||||||
|
function getSkillEntries(): SkillEntry[] {
|
||||||
|
return [...bundledEntries, ...diskEntries];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a skill catalog string, optionally excluding specific skills by ID.
|
* Build a skill catalog string, optionally excluding specific skills by ID.
|
||||||
|
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
|
||||||
*/
|
*/
|
||||||
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||||
const entries = options?.excludeIds
|
const entries = options?.excludeIds
|
||||||
? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
|
? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id))
|
||||||
: skillEntries;
|
: getSkillEntries();
|
||||||
const sections = entries.map((entry) => [
|
const sections = entries.map((entry) => [
|
||||||
`## ${entry.title}`,
|
`## ${entry.title}`,
|
||||||
`- **Skill file:** \`${entry.catalogPath}\``,
|
`- **Skill file:** \`${entry.catalogPath}\``,
|
||||||
|
|
@ -171,15 +196,17 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
|
||||||
const normalizeIdentifier = (value: string) =>
|
const normalizeIdentifier = (value: string) =>
|
||||||
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
|
||||||
|
|
||||||
|
// Bundled aliases are static; disk aliases live in diskAliasMap so they can be
|
||||||
|
// rebuilt wholesale on refresh without disturbing the bundled entries.
|
||||||
const aliasMap = new Map<string, ResolvedSkill>();
|
const aliasMap = new Map<string, ResolvedSkill>();
|
||||||
|
|
||||||
const registerAlias = (alias: string, entry: ResolvedSkill) => {
|
const registerAlias = (alias: string, entry: ResolvedSkill, target: Map<string, ResolvedSkill> = aliasMap) => {
|
||||||
const normalized = normalizeIdentifier(alias);
|
const normalized = normalizeIdentifier(alias);
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
aliasMap.set(normalized, entry);
|
target.set(normalized, entry);
|
||||||
};
|
};
|
||||||
|
|
||||||
const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
|
const registerAliasVariants = (alias: string, entry: ResolvedSkill, target: Map<string, ResolvedSkill> = aliasMap) => {
|
||||||
const normalized = normalizeIdentifier(alias);
|
const normalized = normalizeIdentifier(alias);
|
||||||
if (!normalized) return;
|
if (!normalized) return;
|
||||||
|
|
||||||
|
|
@ -196,11 +223,11 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const variant of variants) {
|
for (const variant of variants) {
|
||||||
registerAlias(variant, entry);
|
registerAlias(variant, entry, target);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const entry of skillEntries) {
|
for (const entry of bundledEntries) {
|
||||||
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
|
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
|
||||||
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
|
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
|
||||||
const resolvedEntry: ResolvedSkill = {
|
const resolvedEntry: ResolvedSkill = {
|
||||||
|
|
@ -227,11 +254,52 @@ for (const entry of skillEntries) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const availableSkills = skillEntries.map((entry) => entry.id);
|
// Disk skills resolve by their id and by their absolute on-disk SKILL.md path,
|
||||||
|
// so the existing loadSkill tool can load them unchanged. Rebuilt on refresh.
|
||||||
|
function rebuildDiskAliases(): void {
|
||||||
|
diskAliasMap.clear();
|
||||||
|
for (const entry of diskEntries) {
|
||||||
|
const resolvedEntry: ResolvedSkill = {
|
||||||
|
id: entry.id,
|
||||||
|
catalogPath: entry.catalogPath,
|
||||||
|
content: entry.content,
|
||||||
|
};
|
||||||
|
registerAlias(entry.id, resolvedEntry, diskAliasMap);
|
||||||
|
if (entry.skillFile) {
|
||||||
|
registerAlias(entry.skillFile, resolvedEntry, diskAliasMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live-updated in place (same array binding) so importers see current ids.
|
||||||
|
export const availableSkills: string[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-scan disk skills and rebuild the disk catalog/alias entries. Bundled
|
||||||
|
* skills are untouched. Callers (the disk-skills watcher) should follow this
|
||||||
|
* with invalidateCopilotInstructionsCache() so the next agent run picks it up.
|
||||||
|
* Returns the number of disk skills currently loaded.
|
||||||
|
*/
|
||||||
|
export function refreshDiskSkills(): number {
|
||||||
|
diskEntries = loadDiskEntries();
|
||||||
|
rebuildDiskAliases();
|
||||||
|
availableSkills.length = 0;
|
||||||
|
for (const entry of getSkillEntries()) availableSkills.push(entry.id);
|
||||||
|
return diskEntries.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial disk load at module init.
|
||||||
|
refreshDiskSkills();
|
||||||
|
|
||||||
|
// Snapshot of the catalog at module load, kept for backward-compatible
|
||||||
|
// consumers (e.g. the legacy CopilotInstructions export). Runtime consumers
|
||||||
|
// should call buildSkillCatalog() to get the live set.
|
||||||
|
export const skillCatalog = buildSkillCatalog();
|
||||||
|
|
||||||
export function resolveSkill(identifier: string): ResolvedSkill | null {
|
export function resolveSkill(identifier: string): ResolvedSkill | null {
|
||||||
const normalized = normalizeIdentifier(identifier);
|
const normalized = normalizeIdentifier(identifier);
|
||||||
if (!normalized) return null;
|
if (!normalized) return null;
|
||||||
|
|
||||||
return aliasMap.get(normalized) ?? null;
|
// Bundled wins over disk on any alias collision.
|
||||||
|
return aliasMap.get(normalized) ?? diskAliasMap.get(normalized) ?? null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import chokidar, { type FSWatcher } from "chokidar";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import { SKILL_ROOTS } from "./disk-loader.js";
|
||||||
|
import { refreshDiskSkills } from "./index.js";
|
||||||
|
import { invalidateCopilotInstructionsCache } from "../instructions.js";
|
||||||
|
|
||||||
|
// The skills CLI writes many files per install (~13 paths for one skill), so we
|
||||||
|
// coalesce a burst of events into a single refresh.
|
||||||
|
const DEBOUNCE_MS = 400;
|
||||||
|
|
||||||
|
let watcher: FSWatcher | null = null;
|
||||||
|
let debounceTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
function scheduleReload(): void {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null;
|
||||||
|
try {
|
||||||
|
const count = refreshDiskSkills();
|
||||||
|
invalidateCopilotInstructionsCache();
|
||||||
|
console.log(`[disk-skills] reloaded ${count} skills after change`);
|
||||||
|
} catch (err) {
|
||||||
|
// A bad SKILL.md (or any refresh error) must never crash the app.
|
||||||
|
console.error("[disk-skills] failed to reload after change:", err);
|
||||||
|
}
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start watching the disk skill roots (~/.rowboat/skills, ~/.agents/skills) for
|
||||||
|
* changes and live-reload the skill catalog. Idempotent. Missing directories
|
||||||
|
* are skipped so watching ~/.agents when it doesn't exist never errors.
|
||||||
|
*/
|
||||||
|
export function startSkillsWatcher(): void {
|
||||||
|
if (watcher) return;
|
||||||
|
|
||||||
|
const roots = SKILL_ROOTS.filter((root) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(root).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return false; // Directory absent (e.g. ~/.agents/skills) — nothing to watch.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (roots.length === 0) {
|
||||||
|
console.log("[disk-skills] no skill directories present; watcher idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
watcher = chokidar.watch(roots, {
|
||||||
|
ignoreInitial: true,
|
||||||
|
awaitWriteFinish: {
|
||||||
|
stabilityThreshold: 150,
|
||||||
|
pollInterval: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Any add/change/unlink (files or dirs) under a root triggers a debounced reload.
|
||||||
|
watcher
|
||||||
|
.on("add", scheduleReload)
|
||||||
|
.on("change", scheduleReload)
|
||||||
|
.on("unlink", scheduleReload)
|
||||||
|
.on("addDir", scheduleReload)
|
||||||
|
.on("unlinkDir", scheduleReload)
|
||||||
|
.on("error", (error: unknown) => {
|
||||||
|
console.error("[disk-skills] watcher error:", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[disk-skills] watching ${roots.length} skill director${roots.length === 1 ? "y" : "ies"} for changes`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[disk-skills] failed to start watcher:", err);
|
||||||
|
watcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stop the skills watcher and clear any pending reload. Idempotent. */
|
||||||
|
export function stopSkillsWatcher(): void {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = null;
|
||||||
|
}
|
||||||
|
if (watcher) {
|
||||||
|
watcher.close().catch(() => { /* ignore close errors on shutdown */ });
|
||||||
|
watcher = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ function ensureDirs() {
|
||||||
ensure(path.join(WorkDir, "agents"));
|
ensure(path.join(WorkDir, "agents"));
|
||||||
ensure(path.join(WorkDir, "config"));
|
ensure(path.join(WorkDir, "config"));
|
||||||
ensure(path.join(WorkDir, "knowledge"));
|
ensure(path.join(WorkDir, "knowledge"));
|
||||||
|
ensure(path.join(WorkDir, "skills"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureDefaultConfigs() {
|
function ensureDefaultConfigs() {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue