diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 15:04:26 +0900 |
| commit | adc8bd185b54935e7a31aae04da3175b7989927a (patch) | |
| tree | 031fa53009a4708ce0cc15c0e3dcb2f2a73cf2d9 /packages/core/src | |
| parent | 52af4ca33b1f83b8f863a6267d447944f55e729d (diff) | |
| download | dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.tar.gz dispatch-adc8bd185b54935e7a31aae04da3175b7989927a.zip | |
feat: phase 3 — config, skills, model groups, task list, and sidebar UI
- Config system: TOML-based dispatch.toml with hot-reload via chokidar
- Model/key resolution: tag-based model selection, key fallback chains
- Skills system: directory loader with TOML frontmatter, agent mappings
- Task list tool: add/update/list/get operations with WebSocket events
- API routes: GET /config, /skills, /skills/:name, /models, /models/resolve
- Frontend: sidebar with model status, task list, config viewer, skills browser, permission log
- Sliding sidebar animation using CSS transitions (not Svelte transitions)
Diffstat (limited to 'packages/core/src')
| -rw-r--r-- | packages/core/src/config/index.ts | 3 | ||||
| -rw-r--r-- | packages/core/src/config/loader.ts | 119 | ||||
| -rw-r--r-- | packages/core/src/config/schema.ts | 235 | ||||
| -rw-r--r-- | packages/core/src/config/watcher.ts | 53 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 17 | ||||
| -rw-r--r-- | packages/core/src/models/index.ts | 2 | ||||
| -rw-r--r-- | packages/core/src/models/registry.ts | 134 | ||||
| -rw-r--r-- | packages/core/src/models/resolver.ts | 88 | ||||
| -rw-r--r-- | packages/core/src/skills/index.ts | 7 | ||||
| -rw-r--r-- | packages/core/src/skills/loader.ts | 310 | ||||
| -rw-r--r-- | packages/core/src/skills/parser.ts | 62 | ||||
| -rw-r--r-- | packages/core/src/tools/task-list.ts | 148 | ||||
| -rw-r--r-- | packages/core/src/types/index.ts | 108 |
13 files changed, 1179 insertions, 107 deletions
diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts new file mode 100644 index 0000000..5b128c4 --- /dev/null +++ b/packages/core/src/config/index.ts @@ -0,0 +1,3 @@ +export { configToRuleset, loadConfig } from "./loader.js"; +export { validateConfig } from "./schema.js"; +export { createConfigWatcher } from "./watcher.js"; diff --git a/packages/core/src/config/loader.ts b/packages/core/src/config/loader.ts index 0e58ad2..3b4d733 100644 --- a/packages/core/src/config/loader.ts +++ b/packages/core/src/config/loader.ts @@ -1,25 +1,12 @@ import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; +import { parse } from "smol-toml"; import type { PermissionRule, Ruleset } from "../permission/index.js"; +import type { DispatchConfig } from "../types/index.js"; +import { validateConfig } from "./schema.js"; -// Strip inline comments that appear outside of quoted strings. -// Handles both single- and double-quoted values. -function stripInlineComment(line: string): string { - // Walk character by character; track whether we're inside quotes. - let inQuote: string | null = null; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (inQuote) { - if (ch === inQuote) inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === "#") { - return line.slice(0, i).trimEnd(); - } - } - return line; -} +const DEFAULT_CONFIG: DispatchConfig = { permissions: {} }; const VALID_ACTIONS = new Set(["allow", "deny", "ask"]); @@ -29,89 +16,27 @@ function validateAction(raw: string): "allow" | "deny" | "ask" { return "ask"; } -export interface DispatchConfig { - permissions: Record<string, string | Record<string, string>>; -} - -// Load dispatch.yaml from the given directory +// Load dispatch.toml from the given directory export function loadConfig(dir: string): DispatchConfig { - const yamlPath = join(dir, "dispatch.yaml"); + const tomlPath = join(dir, "dispatch.toml"); + let raw: unknown; try { - const content = readFileSync(yamlPath, "utf-8"); - return parseYaml(content); - } catch { - return { permissions: {} }; - } -} - -function expandHome(value: string): string { - const home = homedir(); - return value.replace(/^\$HOME(?=[\/\\]|$)/, home).replace(/^~(?=[\/\\]|$)/, home); -} - -function stripQuotes(s: string): string { - if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) { - return s.slice(1, -1); - } - return s; -} - -// Parse simple YAML for the permissions structure -function parseYaml(content: string): DispatchConfig { - const permissions: Record<string, string | Record<string, string>> = {}; - const lines = content.split("\n"); - - let inPermissions = false; - let currentKey: string | null = null; - - for (const raw of lines) { - // Skip comments and blank lines - const trimmed = raw.trimEnd(); - const stripped = trimmed.trimStart(); - if (stripped === "" || stripped.startsWith("#")) continue; - - const indent = trimmed.length - stripped.length; - - if (indent === 0) { - // Top-level key - inPermissions = stripped.startsWith("permissions:"); - currentKey = null; - continue; - } - - if (!inPermissions) continue; - - if (indent === 2) { - // permission key line: " key: value" or " key:" - const colonIdx = stripped.indexOf(":"); - if (colonIdx === -1) continue; - const key = stripQuotes(stripped.slice(0, colonIdx).trim()); - const valueRaw = stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim(); - if (valueRaw === "" || valueRaw === null) { - // nested map follows - currentKey = key; - permissions[currentKey] = {}; - } else { - // inline value - const value = stripQuotes(valueRaw); - permissions[key] = value; - currentKey = null; - } - continue; - } - - if (indent >= 4 && currentKey !== null) { - // sub-key line: " "pattern": action" - const colonIdx = stripped.indexOf(":"); - if (colonIdx === -1) continue; - const pattern = expandHome(stripQuotes(stripped.slice(0, colonIdx).trim())); - const actionRaw = stripQuotes(stripInlineComment(stripped.slice(colonIdx + 1).trim()).trim()); - const action = validateAction(actionRaw); - (permissions[currentKey] as Record<string, string>)[pattern] = action; + const content = readFileSync(tomlPath, "utf-8"); + raw = parse(content); + } catch (err: unknown) { + if (err instanceof Error && (err as NodeJS.ErrnoException).code === "ENOENT") { + // File doesn't exist — return empty default + return DEFAULT_CONFIG; } + console.warn(`dispatch: failed to parse dispatch.toml: ${err instanceof Error ? err.message : String(err)}`); + throw err; } - return { permissions }; + const { config, errors } = validateConfig(raw); + for (const e of errors) { + console.warn(`dispatch: config warning at ${e.path}: ${e.message}`); + } + return config; } // Convert the config's permission block to a Ruleset @@ -126,8 +51,8 @@ export function configToRuleset(config: DispatchConfig): Ruleset { } else { for (const [rawPattern, rawAction] of Object.entries(value)) { const pattern = rawPattern - .replace(/^\$HOME(?=[\/\\]|$)/, home) - .replace(/^~(?=[\/\\]|$)/, home); + .replace(/^\$HOME(?=[/\\]|$)/, home) + .replace(/^~(?=[/\\]|$)/, home); const action = validateAction(rawAction); rules.push({ permission, pattern, action }); } diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts new file mode 100644 index 0000000..d3eea98 --- /dev/null +++ b/packages/core/src/config/schema.ts @@ -0,0 +1,235 @@ +import type { + AgentTemplate, + ConfigError, + DispatchConfig, + KeyDefinition, + ModelDefinition, +} from "../types/index.js"; + +function isRecord(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isStringRecord(value: unknown): value is Record<string, string> { + if (!isRecord(value)) return false; + return Object.values(value).every((v) => typeof v === "string"); +} + +function isValidAction(value: string): boolean { + return value === "allow" || value === "deny" || value === "ask"; +} + +function isPermissionsValue(value: unknown): value is string | Record<string, string> { + return typeof value === "string" || isStringRecord(value); +} + +function validatePermissions( + raw: unknown, + path: string, + errors: ConfigError[], +): Record<string, string | Record<string, string>> { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return {}; + } + const result: Record<string, string | Record<string, string>> = {}; + for (const [key, value] of Object.entries(raw)) { + if (!isPermissionsValue(value)) { + errors.push({ path: `${path}.${key}`, message: "must be a string or a flat string-keyed object" }); + continue; + } + if (typeof value === "string") { + if (!isValidAction(value)) { + errors.push({ path: `${path}.${key}`, message: `invalid action "${value}"; must be "allow", "deny", or "ask"` }); + continue; + } + } else { + let hasError = false; + for (const [pattern, action] of Object.entries(value)) { + if (!isValidAction(action)) { + errors.push({ path: `${path}.${key}.${pattern}`, message: `invalid action "${action}"; must be "allow", "deny", or "ask"` }); + hasError = true; + } + } + if (hasError) continue; + } + result[key] = value; + } + return result; +} + +function validateAgentTemplate( + raw: unknown, + path: string, + errors: ConfigError[], +): AgentTemplate | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + + const requiredStrings = ["name", "description", "system_prompt", "model_tag"] as const; + for (const field of requiredStrings) { + if (typeof raw[field] !== "string") { + errors.push({ path: `${path}.${field}`, message: "must be a string" }); + return null; + } + } + + if (!Array.isArray(raw["tools"]) || !raw["tools"].every((t) => typeof t === "string")) { + errors.push({ path: `${path}.tools`, message: "must be an array of strings" }); + return null; + } + + const perms = validatePermissions(raw["permissions"] ?? {}, `${path}.permissions`, errors); + + return { + name: raw["name"] as string, + description: raw["description"] as string, + system_prompt: raw["system_prompt"] as string, + tools: raw["tools"] as string[], + model_tag: raw["model_tag"] as string, + permissions: perms, + }; +} + +function validateModel(raw: unknown, path: string, errors: ConfigError[]): ModelDefinition | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + if (typeof raw["id"] !== "string") { + errors.push({ path: `${path}.id`, message: "must be a string" }); + return null; + } + if (typeof raw["provider"] !== "string") { + errors.push({ path: `${path}.provider`, message: "must be a string" }); + return null; + } + if ( + !Array.isArray(raw["tags"]) || + raw["tags"].length === 0 || + !raw["tags"].every((t) => typeof t === "string") + ) { + errors.push({ path: `${path}.tags`, message: "must be a non-empty array of strings" }); + return null; + } + return { + id: raw["id"] as string, + provider: raw["provider"] as string, + tags: raw["tags"] as string[], + }; +} + +function validateKey(raw: unknown, path: string, errors: ConfigError[]): KeyDefinition | null { + if (!isRecord(raw)) { + errors.push({ path, message: "must be an object" }); + return null; + } + for (const field of ["id", "provider", "env", "base_url"] as const) { + if (typeof raw[field] !== "string") { + errors.push({ path: `${path}.${field}`, message: "must be a string" }); + return null; + } + } + return { + id: raw["id"] as string, + provider: raw["provider"] as string, + env: raw["env"] as string, + base_url: raw["base_url"] as string, + }; +} + +export function validateConfig(raw: unknown): { config: DispatchConfig; errors: ConfigError[] } { + const errors: ConfigError[] = []; + + if (!isRecord(raw)) { + errors.push({ path: "", message: "config must be an object" }); + return { config: { permissions: {} }, errors }; + } + + // permissions (required, but can be empty) + const permissions = validatePermissions(raw["permissions"] ?? {}, "permissions", errors); + + // agents (optional) + let agents: Record<string, AgentTemplate> | undefined; + if (raw["agents"] !== undefined) { + if (!isRecord(raw["agents"])) { + errors.push({ path: "agents", message: "must be an object" }); + } else { + agents = {}; + for (const [key, value] of Object.entries(raw["agents"])) { + const agent = validateAgentTemplate(value, `agents.${key}`, errors); + if (agent) agents[key] = agent; + } + } + } + + // models (optional) + let models: ModelDefinition[] | undefined; + if (raw["models"] !== undefined) { + if (!Array.isArray(raw["models"])) { + errors.push({ path: "models", message: "must be an array" }); + } else { + models = []; + for (let i = 0; i < raw["models"].length; i++) { + const model = validateModel(raw["models"][i], `models[${i}]`, errors); + if (model) models.push(model); + } + } + } + + // keys (optional) + let keys: KeyDefinition[] | undefined; + if (raw["keys"] !== undefined) { + if (!Array.isArray(raw["keys"])) { + errors.push({ path: "keys", message: "must be an array" }); + } else { + keys = []; + for (let i = 0; i < raw["keys"].length; i++) { + const key = validateKey(raw["keys"][i], `keys[${i}]`, errors); + if (key) keys.push(key); + } + } + } + + // fallback (optional) + let fallback: string[] | undefined; + if (raw["fallback"] !== undefined) { + if (!Array.isArray(raw["fallback"]) || !raw["fallback"].every((f) => typeof f === "string")) { + errors.push({ path: "fallback", message: "must be an array of strings" }); + } else { + fallback = raw["fallback"] as string[]; + // Validate that referenced key IDs exist + const keyIds = new Set((keys ?? []).map((k) => k.id)); + for (const id of fallback) { + if (!keyIds.has(id)) { + errors.push({ path: "fallback", message: `key id "${id}" not found in keys` }); + } + } + } + } + + // Warn if agent model_tags don't match any model + if (agents && models) { + const allTags = new Set(models.flatMap((m) => m.tags)); + for (const [name, agent] of Object.entries(agents)) { + if (!allTags.has(agent.model_tag)) { + errors.push({ + path: `agents.${name}.model_tag`, + message: `no model found with tag "${agent.model_tag}"`, + }); + } + } + } + + const config: DispatchConfig = { + permissions, + ...(agents !== undefined && { agents }), + ...(models !== undefined && { models }), + ...(keys !== undefined && { keys }), + ...(fallback !== undefined && { fallback }), + }; + + return { config, errors }; +} diff --git a/packages/core/src/config/watcher.ts b/packages/core/src/config/watcher.ts new file mode 100644 index 0000000..42b2f87 --- /dev/null +++ b/packages/core/src/config/watcher.ts @@ -0,0 +1,53 @@ +import { watch } from "chokidar"; +import { join } from "node:path"; +import type { DispatchConfig } from "../types/index.js"; +import { loadConfig } from "./loader.js"; + +export function createConfigWatcher( + dir: string, + onChange: (config: DispatchConfig) => void, +): { close(): void } { + const tomlPath = join(dir, "dispatch.toml"); + let debounceTimer: ReturnType<typeof setTimeout> | null = null; + + const watcher = watch(tomlPath, { + ignoreInitial: true, + persistent: false, + }); + + const handleChange = () => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + console.log(`dispatch: reloading config from ${tomlPath}`); + try { + const config = loadConfig(dir); + onChange(config); + } catch (err) { + console.warn(`dispatch: retaining last known config due to parse error: ${err instanceof Error ? err.message : String(err)}`); + } + }, 300); + }; + + watcher.on("change", handleChange); + watcher.on("add", handleChange); + watcher.on("unlink", handleChange); + + watcher.on("error", (err) => { + console.warn(`dispatch: config watcher error: ${err instanceof Error ? err.message : String(err)}`); + }); + + return { + close() { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + watcher.close().catch((err) => { + console.warn(`dispatch: error closing config watcher: ${err instanceof Error ? err.message : String(err)}`); + }); + }, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 233cb10..3009a0b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,7 +1,10 @@ // @dispatch/core — Agent runtime, LLM integration, tools +// Agent & LLM export { Agent } from "./agent/agent.js"; export { createProvider } from "./llm/provider.js"; + +// Tools export { createListFilesTool } from "./tools/list-files.js"; export { createRunShellTool } from "./tools/run-shell.js"; export { analyzeCommand } from "./tools/shell-analyze.js"; @@ -9,7 +12,17 @@ export { prefix as bashArityPrefix } from "./tools/bash-arity.js"; export { createReadFileTool } from "./tools/read-file.js"; export { createToolRegistry } from "./tools/registry.js"; export { createWriteFileTool } from "./tools/write-file.js"; +export { TaskList, createTaskListTool } from "./tools/task-list.js"; + +// Types & Permissions export * from "./types/index.js"; export * from "./permission/index.js"; -export { loadConfig, configToRuleset } from "./config/loader.js"; -export type { DispatchConfig } from "./config/loader.js"; + +// Config +export { loadConfig, configToRuleset, validateConfig, createConfigWatcher } from "./config/index.js"; + +// Skills +export { parseSkillFile, loadSkills, resolveSkillsForAgent, getSkillByName, createSkillsWatcher } from "./skills/index.js"; + +// Models +export { ModelRegistry, ModelResolver } from "./models/index.js"; diff --git a/packages/core/src/models/index.ts b/packages/core/src/models/index.ts new file mode 100644 index 0000000..e5efcc8 --- /dev/null +++ b/packages/core/src/models/index.ts @@ -0,0 +1,2 @@ +export { ModelRegistry } from "./registry.js"; +export { ModelResolver } from "./resolver.js"; diff --git a/packages/core/src/models/registry.ts b/packages/core/src/models/registry.ts new file mode 100644 index 0000000..f78fe0e --- /dev/null +++ b/packages/core/src/models/registry.ts @@ -0,0 +1,134 @@ +import type { KeyDefinition, KeyState, ModelDefinition } from "../types/index.js"; + +export class ModelRegistry { + private models: ModelDefinition[]; + private keyStates: Map<string, KeyState>; + private fallbackOrder: string[]; + + constructor(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]) { + this.models = []; + this.keyStates = new Map(); + this.fallbackOrder = []; + this._initConfig(models, keys, fallbackOrder, new Map()); + } + + private _initConfig( + models: ModelDefinition[], + keys: KeyDefinition[], + fallbackOrder: string[], + existingStates: Map<string, KeyState>, + ): void { + this.models = [...models]; + this.fallbackOrder = this._buildFallbackOrder(keys, fallbackOrder); + + const newStates = new Map<string, KeyState>(); + for (const key of keys) { + const existing = existingStates.get(key.id); + if (existing) { + // Preserve existing state but update definition + newStates.set(key.id, { ...existing, definition: key }); + } else { + newStates.set(key.id, { definition: key, status: "active" }); + } + } + this.keyStates = newStates; + } + + private _buildFallbackOrder(keys: KeyDefinition[], fallbackOrder: string[]): string[] { + const ordered: string[] = []; + const keyIds = new Set(keys.map((k) => k.id)); + + // Add keys from fallbackOrder first (if they exist) + for (const id of fallbackOrder) { + if (keyIds.has(id) && !ordered.includes(id)) { + ordered.push(id); + } + } + + // Append remaining keys not in fallbackOrder + for (const key of keys) { + if (!ordered.includes(key.id)) { + ordered.push(key.id); + } + } + + return ordered; + } + + getModels(): ModelDefinition[] { + return [...this.models]; + } + + getKeys(): KeyState[] { + return this.fallbackOrder + .map((id) => this.keyStates.get(id)) + .filter((state): state is KeyState => state !== undefined); + } + + getModelsByTag(tag: string): ModelDefinition[] { + return this.models.filter((m) => m.tags.includes(tag)); + } + + getAllTags(): string[] { + const tags = new Set<string>(); + for (const model of this.models) { + for (const tag of model.tags) { + tags.add(tag); + } + } + return [...tags]; + } + + markKeyExhausted(keyId: string, error?: string): void { + const state = this.keyStates.get(keyId); + if (!state) return; + this.keyStates.set(keyId, { + ...state, + status: "exhausted", + lastError: error, + exhaustedAt: Date.now(), + }); + } + + markKeyActive(keyId: string): void { + const state = this.keyStates.get(keyId); + if (!state) return; + const updated: KeyState = { + definition: state.definition, + status: "active", + }; + this.keyStates.set(keyId, updated); + } + + hasAvailableKey(provider: string): boolean { + for (const state of this.keyStates.values()) { + if (state.definition.provider === provider && state.status === "active") { + return true; + } + } + return false; + } + + allKeysExhausted(): boolean { + for (const state of this.keyStates.values()) { + if (state.status === "active") { + return false; + } + } + return true; + } + + updateConfig(models: ModelDefinition[], keys: KeyDefinition[], fallbackOrder: string[]): void { + this._initConfig(models, keys, fallbackOrder, this.keyStates); + } + + // Internal: get ordered key states for a specific provider + getOrderedKeysForProvider(provider: string): KeyState[] { + return this.fallbackOrder + .map((id) => this.keyStates.get(id)) + .filter( + (state): state is KeyState => + state !== undefined && state.definition.provider === provider, + ); + } +} diff --git a/packages/core/src/models/resolver.ts b/packages/core/src/models/resolver.ts new file mode 100644 index 0000000..3ada713 --- /dev/null +++ b/packages/core/src/models/resolver.ts @@ -0,0 +1,88 @@ +import type { ResolvedModel } from "../types/index.js"; +import type { ModelRegistry } from "./registry.js"; + +export class ModelResolver { + private registry: ModelRegistry; + + constructor(registry: ModelRegistry) { + this.registry = registry; + } + + resolve(tag: string): ResolvedModel | null { + const models = this.registry.getModelsByTag(tag); + const keys = this.registry.getKeys(); + + for (const keyState of keys) { + if (keyState.status !== "active") continue; + const model = models.find((m) => m.provider === keyState.definition.provider); + if (model) { + return { model, key: keyState.definition }; + } + } + + return null; + } + + async waitForKey( + tag: string, + options?: { + pollIntervalMs?: number; + signal?: AbortSignal; + onWaiting?: () => void; + onResume?: () => void; + }, + ): Promise<ResolvedModel | null> { + const pollIntervalMs = options?.pollIntervalMs ?? 60000; + const signal = options?.signal; + + // Try immediately first + const immediate = this.resolve(tag); + if (immediate) return immediate; + + // Check if aborted before entering wait state + if (signal?.aborted) return null; + + options?.onWaiting?.(); + + return new Promise<ResolvedModel | null>((resolve) => { + let timer: ReturnType<typeof setTimeout> | null = null; + + const cleanup = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + const onAbort = () => { + cleanup(); + resolve(null); + }; + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + + const poll = () => { + if (signal?.aborted) { + resolve(null); + return; + } + + const result = this.resolve(tag); + if (result) { + if (signal) { + signal.removeEventListener("abort", onAbort); + } + options?.onResume?.(); + resolve(result); + return; + } + + timer = setTimeout(poll, pollIntervalMs); + }; + + timer = setTimeout(poll, pollIntervalMs); + }); + } +} diff --git a/packages/core/src/skills/index.ts b/packages/core/src/skills/index.ts new file mode 100644 index 0000000..5f958b3 --- /dev/null +++ b/packages/core/src/skills/index.ts @@ -0,0 +1,7 @@ +export { parseSkillFile } from "./parser.js"; +export { + loadSkills, + resolveSkillsForAgent, + getSkillByName, + createSkillsWatcher, +} from "./loader.js"; diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts new file mode 100644 index 0000000..1dcd39e --- /dev/null +++ b/packages/core/src/skills/loader.ts @@ -0,0 +1,310 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import chokidar from "chokidar"; +import type { SkillDefinition, AgentSkillMapping, SkillScope } from "../types/index.js"; +import { parseSkillFile } from "./parser.js"; + +// ─── Internal Helpers ──────────────────────────────────────────── + +function loadSkillsFromDir( + dir: string, + scope: SkillScope, +): SkillDefinition[] { + if (!fs.existsSync(dir)) { + return []; + } + + const results: SkillDefinition[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) { + continue; + } + const filePath = path.join(dir, entry.name); + try { + const content = fs.readFileSync(filePath, "utf-8"); + const skill = parseSkillFile( + filePath, + content, + scope, + // We'll set directory based on the parent dir segment — caller sets it + "default", + ); + results.push(skill); + } catch { + // Skip unreadable files + } + } + + return results; +} + +function loadSkillsFromDirWithDirectory( + dir: string, + scope: SkillScope, + directory: SkillDefinition["directory"], +): SkillDefinition[] { + if (!fs.existsSync(dir)) { + return []; + } + + const results: SkillDefinition[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".md")) { + continue; + } + const filePath = path.join(dir, entry.name); + try { + const content = fs.readFileSync(filePath, "utf-8"); + const skill = parseSkillFile(filePath, content, scope, directory); + results.push(skill); + } catch { + // Skip unreadable files + } + } + + return results; +} + +function loadAgentMappings( + agentsDir: string, + scope: SkillScope, +): AgentSkillMapping[] { + if (!fs.existsSync(agentsDir)) { + return []; + } + + const results: AgentSkillMapping[] = []; + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(agentsDir, { withFileTypes: true }); + } catch { + return []; + } + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".txt")) { + continue; + } + + const fileName = entry.name; + let isOrchestrator = false; + let agentType: string; + + if (fileName.endsWith(".o.txt")) { + isOrchestrator = true; + agentType = fileName.slice(0, -6); // remove ".o.txt" + } else { + agentType = fileName.slice(0, -4); // remove ".txt" + } + + const filePath = path.join(agentsDir, fileName); + try { + const content = fs.readFileSync(filePath, "utf-8"); + const skills = content + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith("#")); + + results.push({ agentType, isOrchestrator, skills, scope }); + } catch { + // Skip unreadable mapping files + } + } + + return results; +} + +// ─── Public API ────────────────────────────────────────────────── + +export function loadSkills(projectDir: string): { + skills: SkillDefinition[]; + mappings: AgentSkillMapping[]; +} { + const globalBase = path.join(os.homedir(), ".skills"); + const projectBase = path.join(projectDir, ".skills"); + + const skills: SkillDefinition[] = []; + const mappings: AgentSkillMapping[] = []; + + // 1. Global default/ + skills.push( + ...loadSkillsFromDirWithDirectory( + path.join(globalBase, "default"), + "global", + "default", + ), + ); + + // 2. Project default/ + skills.push( + ...loadSkillsFromDirWithDirectory( + path.join(projectBase, "default"), + "project", + "default", + ), + ); + + // 3. Agent mappings — global then project + mappings.push(...loadAgentMappings(path.join(globalBase, "agents"), "global")); + mappings.push(...loadAgentMappings(path.join(projectBase, "agents"), "project")); + + // 4. Project/ skills (manually activated) + skills.push( + ...loadSkillsFromDirWithDirectory( + path.join(globalBase, "project"), + "global", + "project", + ), + ); + skills.push( + ...loadSkillsFromDirWithDirectory( + path.join(projectBase, "project"), + "project", + "project", + ), + ); + + return { skills, mappings }; +} + +export function resolveSkillsForAgent( + agentType: string, + isOrchestrator: boolean, + skills: SkillDefinition[], + mappings: AgentSkillMapping[], +): SkillDefinition[] { + // Helper: project overrides global for same-named skills + const dedupeByName = (list: SkillDefinition[]): SkillDefinition[] => { + const seen = new Map<string, SkillDefinition>(); + for (const skill of list) { + const existing = seen.get(skill.name); + if (!existing || skill.scope === "project") { + seen.set(skill.name, skill); + } + } + return Array.from(seen.values()); + }; + + // All default-directory skills (global first, then project — dedupe preserves project) + const defaultSkills = skills.filter((s) => s.directory === "default"); + + // Skills mapped to this agent type + const relevantMappings = mappings.filter( + (m) => m.agentType === agentType && m.isOrchestrator === isOrchestrator, + ); + + // Gather agent-specific skills in order: global mappings first, then project + const globalMappings = relevantMappings.filter((m) => m.scope === "global"); + const projectMappings = relevantMappings.filter((m) => m.scope === "project"); + + const agentSkillNames: string[] = []; + for (const mapping of [...globalMappings, ...projectMappings]) { + for (const skillFile of mapping.skills) { + const skillName = path.basename(skillFile, path.extname(skillFile)); + agentSkillNames.push(skillName); + } + } + + const agentSpecificSkills = agentSkillNames + .map((name) => getSkillByName(name, skills, "project")) + .filter((s): s is SkillDefinition => s !== undefined); + + const combined = [...defaultSkills, ...agentSpecificSkills]; + return dedupeByName(combined); +} + +export function getSkillByName( + name: string, + skills: SkillDefinition[], + preferScope?: SkillScope, +): SkillDefinition | undefined { + const matches = skills.filter((s) => s.name === name); + if (matches.length === 0) { + return undefined; + } + + if (preferScope) { + const preferred = matches.find((s) => s.scope === preferScope); + if (preferred) { + return preferred; + } + } + + // Default: project takes precedence + const projectMatch = matches.find((s) => s.scope === "project"); + return projectMatch ?? matches[0]; +} + +export function createSkillsWatcher( + projectDir: string, + onChange: (result: { skills: SkillDefinition[]; mappings: AgentSkillMapping[] }) => void, +): { close(): void } { + const globalBase = path.join(os.homedir(), ".skills"); + const projectBase = path.join(projectDir, ".skills"); + + let debounceTimer: ReturnType<typeof setTimeout> | null = null; + + const reload = () => { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + } + debounceTimer = setTimeout(() => { + debounceTimer = null; + const result = loadSkills(projectDir); + onChange(result); + }, 300); + }; + + const watchPaths = [globalBase, projectBase]; + + const watcher = chokidar.watch(watchPaths, { + ignoreInitial: true, + persistent: true, + }); + + watcher.on("add", (filePath: string) => { + if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { + reload(); + } + }); + + watcher.on("change", (filePath: string) => { + if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { + reload(); + } + }); + + watcher.on("unlink", (filePath: string) => { + if (filePath.endsWith(".md") || filePath.endsWith(".txt")) { + reload(); + } + }); + + return { + close() { + if (debounceTimer !== null) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + watcher.close(); + }, + }; +} + +// Keep loadSkillsFromDir exported for potential testing use +export { loadSkillsFromDir }; diff --git a/packages/core/src/skills/parser.ts b/packages/core/src/skills/parser.ts new file mode 100644 index 0000000..4df5450 --- /dev/null +++ b/packages/core/src/skills/parser.ts @@ -0,0 +1,62 @@ +import { parse } from "smol-toml"; +import * as path from "node:path"; +import type { SkillDefinition, SkillScope, SkillDirectory } from "../types/index.js"; + +const FRONTMATTER_DELIMITER = "+++"; + +export function parseSkillFile( + filePath: string, + content: string, + scope: SkillScope, + directory: SkillDirectory, +): SkillDefinition { + const defaultName = path.basename(filePath, path.extname(filePath)); + + let name = defaultName; + let description = ""; + let tags: string[] = []; + let body = content; + + // Check for TOML frontmatter + const trimmed = content.trimStart(); + if (trimmed.startsWith(FRONTMATTER_DELIMITER)) { + const afterOpen = trimmed.slice(FRONTMATTER_DELIMITER.length); + // Find the closing +++ + const closeIndex = afterOpen.indexOf(FRONTMATTER_DELIMITER); + if (closeIndex !== -1) { + const tomlSource = afterOpen.slice(0, closeIndex); + body = afterOpen.slice(closeIndex + FRONTMATTER_DELIMITER.length); + + try { + const frontmatter = parse(tomlSource); + + if (typeof frontmatter["name"] === "string") { + name = frontmatter["name"]; + } + if (typeof frontmatter["description"] === "string") { + description = frontmatter["description"]; + } + if (Array.isArray(frontmatter["tags"])) { + tags = (frontmatter["tags"] as unknown[]).filter( + (t): t is string => typeof t === "string", + ); + } + } catch { + // Malformed TOML — fall through with defaults + } + } + } + + // Trim leading newline from body (handles both LF and CRLF) + body = body.replace(/^\r?\n/, ""); + + return { + name, + description, + tags, + content: body, + scope, + source: filePath, + directory, + }; +} diff --git a/packages/core/src/tools/task-list.ts b/packages/core/src/tools/task-list.ts new file mode 100644 index 0000000..0bacdb4 --- /dev/null +++ b/packages/core/src/tools/task-list.ts @@ -0,0 +1,148 @@ +import { z } from "zod"; +import type { TaskItem, TaskStatus, ToolDefinition } from "../types/index.js"; + +export class TaskList { + private tasks: TaskItem[] = []; + private counter = 0; + private listeners: Array<(tasks: TaskItem[]) => void> = []; + + private notify(): void { + const snapshot = this.getTasks(); + for (const listener of this.listeners) { + listener(snapshot); + } + } + + getTasks(): TaskItem[] { + return [...this.tasks]; + } + + getTask(id: string): TaskItem | undefined { + return this.tasks.find((t) => t.id === id); + } + + addTask(title: string, description: string): TaskItem { + this.counter++; + const task: TaskItem = { + id: `task-${this.counter}`, + title, + description, + status: "pending", + }; + this.tasks.push(task); + this.notify(); + return task; + } + + updateTask(id: string, status: TaskStatus): TaskItem | undefined { + const task = this.tasks.find((t) => t.id === id); + if (!task) return undefined; + task.status = status; + this.notify(); + return { ...task }; + } + + removeTask(id: string): boolean { + const index = this.tasks.findIndex((t) => t.id === id); + if (index === -1) return false; + this.tasks.splice(index, 1); + this.notify(); + return true; + } + + onChange(callback: (tasks: TaskItem[]) => void): () => void { + this.listeners.push(callback); + return () => { + this.listeners = this.listeners.filter((l) => l !== callback); + }; + } +} + +export function createTaskListTool(taskList: TaskList): ToolDefinition { + return { + name: "task_list", + description: + "Manages a task list for tracking work items. The agent can add tasks, update their status, list all tasks, or get details on a specific task.", + parameters: z.object({ + action: z + .enum(["add", "update", "list", "get", "remove"]) + .describe("The action to perform"), + title: z.string().optional().describe("Task title (required for 'add')"), + description: z + .string() + .optional() + .describe("Task description (for 'add', defaults to empty)"), + task_id: z + .string() + .optional() + .describe("Task ID (required for 'update', 'get', 'remove')"), + status: z + .enum(["pending", "in_progress", "done", "blocked"]) + .optional() + .describe("New status (required for 'update')"), + }), + execute: async (args: Record<string, unknown>): Promise<string> => { + const action = args.action as string; + + if (action === "add") { + const title = args.title as string | undefined; + if (!title) { + return "Error: 'title' is required for the 'add' action."; + } + const description = (args.description as string | undefined) ?? ""; + const task = taskList.addTask(title, description); + return JSON.stringify(task); + } + + if (action === "update") { + const task_id = args.task_id as string | undefined; + const status = args.status as TaskStatus | undefined; + if (!task_id) { + return "Error: 'task_id' is required for the 'update' action."; + } + if (!status) { + return "Error: 'status' is required for the 'update' action."; + } + const updated = taskList.updateTask(task_id, status); + if (!updated) { + return `Error: Task with ID '${task_id}' not found.`; + } + return JSON.stringify(updated); + } + + if (action === "get") { + const task_id = args.task_id as string | undefined; + if (!task_id) { + return "Error: 'task_id' is required for the 'get' action."; + } + const task = taskList.getTask(task_id); + if (!task) { + return `Error: Task with ID '${task_id}' not found.`; + } + return JSON.stringify(task); + } + + if (action === "list") { + const tasks = taskList.getTasks(); + if (tasks.length === 0) { + return "No tasks."; + } + return JSON.stringify(tasks); + } + + if (action === "remove") { + const task_id = args.task_id as string | undefined; + if (!task_id) { + return "Error: 'task_id' is required for the 'remove' action."; + } + const removed = taskList.removeTask(task_id); + if (!removed) { + return `Error: Task with ID '${task_id}' not found.`; + } + return `Task '${task_id}' removed successfully.`; + } + + return `Error: Unknown action '${action}'.`; + }, + }; +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 6262d2b..5a761b5 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -1,7 +1,8 @@ import type { ZodType } from "zod"; import type { PermissionChecker, Ruleset } from "../permission/index.js"; -// Message types for the agent conversation +// ─── Message Types ─────────────────────────────────────────────── + export type MessageRole = "user" | "assistant" | "tool"; export interface ChatMessage { @@ -24,10 +25,10 @@ export interface ToolResult { isError: boolean; } -// Agent status -export type AgentStatus = "idle" | "running" | "error"; +// ─── Agent Status & Events ─────────────────────────────────────── + +export type AgentStatus = "idle" | "running" | "error" | "waiting_for_key"; -// Agent events emitted during execution (for WebSocket streaming) export type AgentEvent = | { type: "status"; status: AgentStatus } | { type: "text-delta"; delta: string } @@ -36,14 +37,16 @@ export type AgentEvent = | { type: "tool-result"; toolResult: ToolResult } | { type: "shell-output"; data: string; stream: "stdout" | "stderr" } | { type: "error"; error: string } - | { type: "done"; message: ChatMessage }; + | { type: "done"; message: ChatMessage } + | { type: "task-list-update"; tasks: TaskItem[] } + | { type: "config-reload" }; + +// ─── Tool Types ────────────────────────────────────────────────── -// Context passed to tool execute functions export interface ToolExecuteContext { onOutput?: (data: string, stream: "stdout" | "stderr") => void; } -// Tool definition interface export interface ToolDefinition { name: string; description: string; @@ -51,7 +54,8 @@ export interface ToolDefinition { execute: (args: Record<string, unknown>, context?: ToolExecuteContext) => Promise<string>; } -// Agent configuration +// ─── Agent Configuration ───────────────────────────────────────── + export interface AgentConfig { model: string; apiKey: string; @@ -62,3 +66,91 @@ export interface AgentConfig { permissionChecker?: PermissionChecker; ruleset?: Ruleset; } + +// ─── Config Types (dispatch.toml) ──────────────────────────────── + +export interface DispatchConfig { + agents?: Record<string, AgentTemplate>; + models?: ModelDefinition[]; + keys?: KeyDefinition[]; + fallback?: string[]; + permissions: Record<string, string | Record<string, string>>; +} + +export interface AgentTemplate { + name: string; + description: string; + system_prompt: string; + tools: string[]; + permissions: Record<string, string | Record<string, string>>; + model_tag: string; +} + +export interface ModelDefinition { + id: string; + provider: string; + tags: string[]; +} + +export interface KeyDefinition { + id: string; + provider: string; + env: string; + base_url: string; +} + +// ─── Model Resolution ──────────────────────────────────────────── + +export interface ResolvedModel { + model: ModelDefinition; + key: KeyDefinition; +} + +export type KeyStatus = "active" | "exhausted"; + +export interface KeyState { + definition: KeyDefinition; + status: KeyStatus; + lastError?: string; + exhaustedAt?: number; +} + +// ─── Skills Types ──────────────────────────────────────────────── + +export type SkillScope = "global" | "project"; +export type SkillDirectory = "default" | "agents" | "project"; + +export interface SkillDefinition { + name: string; + description: string; + tags: string[]; + content: string; + scope: SkillScope; + source: string; + directory: SkillDirectory; +} + +export interface AgentSkillMapping { + agentType: string; + isOrchestrator: boolean; + skills: string[]; + scope: SkillScope; +} + +// ─── Task List Types ───────────────────────────────────────────── + +export type TaskStatus = "pending" | "in_progress" | "done" | "blocked"; + +export interface TaskItem { + id: string; + title: string; + description: string; + status: TaskStatus; +} + +// ─── Config Validation ─────────────────────────────────────────── + +export interface ConfigError { + path: string; + message: string; +} |
