diff --git a/CLAUDE.md b/CLAUDE.md index 2089a31..9cfadc5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Manual workflow dispatch via GitHub Actions (`release.yml`). **Critical rules:** ## Commands ```bash -bun test # run tests (258+) +bun test # run tests (354+) bun run check # biome lint + format bun run packages/cli/bin/kib.ts # run CLI locally ``` @@ -49,3 +49,29 @@ bun run packages/cli/bin/kib.ts # run CLI locally - CLI lazy-imports from core to keep cold starts fast - LLM providers: Anthropic, OpenAI, Ollama (auto-detected from env vars) - Credentials stored at `~/.config/kib/credentials`, loaded on CLI startup + +## Skill Ecosystem (v0.8.0) + +### Built-in Skills (10) +`summarize`, `flashcards`, `connections`, `find-contradictions`, `weekly-digest`, `export-slides`, `timeline`, `compare`, `explain`, `suggest-tags` + +### Skill Management CLI +```bash +kib skill list # list all available skills +kib skill run # run a skill +kib skill install github:user/repo # install from GitHub +kib skill install # install from npm +kib skill uninstall # remove an installed skill +kib skill create # scaffold a new skill +kib skill publish # validate for publishing +kib skill installed # list installed skills +``` + +### Skill Architecture +- Skills defined as `SkillDefinition` objects with `run(ctx: SkillContext)` method +- `SkillContext` provides: vault read/write, LLM (complete/stream), search, logger, args, `invoke()` for skill-to-skill calls +- Skills live in: `packages/core/src/skills/builtins.ts` (built-in) or `.kb/skills/` (installed) +- Directory-based skills use `skill.json` for metadata + entry point +- Dependency resolution with circular dependency detection +- Hooks system: skills auto-run after compile/ingest/lint via `hooks` field or `[skills.hooks]` in config.toml +- Config: `[skills]` section in `config.toml` for hooks and per-skill settings diff --git a/ROADMAP.md b/ROADMAP.md index b3b544a..e0de0d8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,26 +134,26 @@ What's built, what's next, and what's deferred. ## v0.8.0 — Skill Ecosystem ### Remote Skill Registry -- [ ] `kib skill install github:user/skill-name` — install from GitHub repo -- [ ] `kib skill install ` — install from npm -- [ ] Skill dependency resolution (skills can depend on other skills) -- [ ] `kib skill create ` — scaffold a new skill from template -- [ ] `kib skill publish` — publish to registry +- [x] `kib skill install github:user/skill-name` — install from GitHub repo +- [x] `kib skill install ` — install from npm +- [x] Skill dependency resolution (skills can depend on other skills) +- [x] `kib skill create ` — scaffold a new skill from template +- [x] `kib skill publish` — publish to registry ### Additional Built-in Skills -- [ ] `find-contradictions` — detect contradictory claims across articles -- [ ] `weekly-digest` — generate a weekly summary of new additions -- [ ] `export-slides` — generate Marp slide deck from articles -- [ ] `timeline` — generate chronological timeline from articles -- [ ] `compare` — compare two articles/topics side by side -- [ ] `explain` — explain a topic at a specified reading level -- [ ] `suggest-tags` — auto-tag articles based on content analysis +- [x] `find-contradictions` — detect contradictory claims across articles +- [x] `weekly-digest` — generate a weekly summary of new additions +- [x] `export-slides` — generate Marp slide deck from articles +- [x] `timeline` — generate chronological timeline from articles +- [x] `compare` — compare two articles/topics side by side +- [x] `explain` — explain a topic at a specified reading level +- [x] `suggest-tags` — auto-tag articles based on content analysis ### Skill API Enhancements -- [ ] Skill-to-skill invocation (one skill can call another) -- [ ] Skill hooks: run automatically after compile, ingest, or lint -- [ ] Skill configuration in `config.toml` -- [ ] Skill output to specific wiki category +- [x] Skill-to-skill invocation (one skill can call another) +- [x] Skill hooks: run automatically after compile, ingest, or lint +- [x] Skill configuration in `config.toml` +- [x] Skill output to specific wiki category --- diff --git a/packages/cli/src/commands/skill.ts b/packages/cli/src/commands/skill.ts index 993595a..7c31556 100644 --- a/packages/cli/src/commands/skill.ts +++ b/packages/cli/src/commands/skill.ts @@ -26,7 +26,16 @@ export async function skill(subcommand: string, name?: string, opts?: SkillOpts) throw err; } - const { loadSkills, findSkill, runSkill } = await import("@kibhq/core"); + const { + loadSkills, + findSkill, + runSkill, + installSkill, + uninstallSkill, + createSkill, + publishSkill, + listInstalledSkills, + } = await import("@kibhq/core"); switch (subcommand) { case "list": { @@ -113,9 +122,146 @@ export async function skill(subcommand: string, name?: string, opts?: SkillOpts) break; } + case "install": { + if (!name) { + log.error( + "Source required. Usage: kib skill install github:user/repo or kib skill install ", + ); + process.exit(1); + } + + const spinner = opts?.json ? null : createSpinner(`Installing skill from ${name}...`); + spinner?.start(); + + try { + const result = await installSkill(root, name); + + if (opts?.json) { + console.log(JSON.stringify(result, null, 2)); + break; + } + + spinner?.succeed(`Installed "${result.name}" v${result.version} from ${result.source}`); + log.dim(` Path: ${result.path}`); + log.blank(); + } catch (err) { + spinner?.fail("Install failed"); + log.error((err as Error).message); + process.exit(1); + } + break; + } + + case "uninstall": + case "remove": { + if (!name) { + log.error("Skill name required. Usage: kib skill uninstall "); + process.exit(1); + } + + const spinner = opts?.json ? null : createSpinner(`Uninstalling "${name}"...`); + spinner?.start(); + + try { + await uninstallSkill(root, name); + + if (opts?.json) { + console.log(JSON.stringify({ uninstalled: name }, null, 2)); + break; + } + + spinner?.succeed(`Uninstalled "${name}"`); + log.blank(); + } catch (err) { + spinner?.fail("Uninstall failed"); + log.error((err as Error).message); + process.exit(1); + } + break; + } + + case "create": { + if (!name) { + log.error("Skill name required. Usage: kib skill create "); + process.exit(1); + } + + const spinner = opts?.json ? null : createSpinner(`Creating skill "${name}"...`); + spinner?.start(); + + try { + const path = await createSkill(root, name); + + if (opts?.json) { + console.log(JSON.stringify({ name, path }, null, 2)); + break; + } + + spinner?.succeed(`Created skill "${name}"`); + log.dim(` Path: ${path}`); + log.dim(" Edit index.ts to implement your skill logic."); + log.blank(); + } catch (err) { + spinner?.fail("Create failed"); + log.error((err as Error).message); + process.exit(1); + } + break; + } + + case "publish": { + if (!name) { + log.error("Skill name required. Usage: kib skill publish "); + process.exit(1); + } + + const spinner = opts?.json ? null : createSpinner(`Validating skill "${name}"...`); + spinner?.start(); + + try { + const path = await publishSkill(root, name); + + if (opts?.json) { + console.log(JSON.stringify({ name, path, valid: true }, null, 2)); + break; + } + + spinner?.succeed(`Skill "${name}" is valid and ready to publish`); + log.dim(` Path: ${path}`); + log.dim(" To publish to npm: cd && npm publish"); + log.blank(); + } catch (err) { + spinner?.fail("Publish validation failed"); + log.error((err as Error).message); + process.exit(1); + } + break; + } + + case "installed": { + const installed = await listInstalledSkills(root); + + if (opts?.json) { + console.log(JSON.stringify(installed, null, 2)); + break; + } + + if (installed.length === 0) { + log.info("No installed skills. Use kib skill install to add some."); + break; + } + + log.header("installed skills"); + for (const s of installed) { + console.log(` ${s.name.padEnd(20)} ${s.path}`); + } + log.blank(); + break; + } + default: log.error(`Unknown subcommand: ${subcommand}`); - log.dim("Available: list, run"); + log.dim("Available: list, run, install, uninstall, create, publish, installed"); process.exit(1); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 86b1480..da7a995 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -19,8 +19,23 @@ export * from "./schemas.js"; export { highlightSnippet, parseQuery, SearchIndex } from "./search/engine.js"; export { HybridSearch } from "./search/hybrid.js"; export { VectorIndex } from "./search/vector.js"; +export { getBuiltinSkills } from "./skills/builtins.js"; +export { getHookedSkills, runSkillHooks } from "./skills/hooks.js"; export { findSkill, loadSkills } from "./skills/loader.js"; +export { + createSkill, + installSkill, + listInstalledSkills, + publishSkill, + resolveSkillDependencies, + uninstallSkill, +} from "./skills/registry.js"; export { runSkill } from "./skills/runner.js"; -export { SkillDefinitionSchema } from "./skills/schema.js"; +export { + SkillConfigSchema, + SkillDefinitionSchema, + SkillHookSchema, + SkillPackageSchema, +} from "./skills/schema.js"; export * from "./types.js"; export * from "./vault.js"; diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts index f62a851..4ea4e95 100644 --- a/packages/core/src/schemas.ts +++ b/packages/core/src/schemas.ts @@ -111,6 +111,18 @@ export const VaultConfigSchema = z.object({ ttl_hours: z.number().int().positive().default(DEFAULTS.cacheTtlHours), max_size_mb: z.number().positive().default(DEFAULTS.cacheMaxSizeMb), }), + skills: z + .object({ + hooks: z + .object({ + "post-compile": z.array(z.string()).default([]), + "post-ingest": z.array(z.string()).default([]), + "post-lint": z.array(z.string()).default([]), + }) + .default({}), + config: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), + }) + .default({}), }); // ─── Article Frontmatter ───────────────────────────────────────── diff --git a/packages/core/src/skills/builtins.ts b/packages/core/src/skills/builtins.ts new file mode 100644 index 0000000..de79763 --- /dev/null +++ b/packages/core/src/skills/builtins.ts @@ -0,0 +1,523 @@ +import type { SkillDefinition } from "../types.js"; + +/** + * All built-in skills that ship with kib. + */ +export function getBuiltinSkills(): SkillDefinition[] { + return [ + summarize, + flashcards, + connections, + findContradictions, + weeklyDigest, + exportSlides, + timeline, + compare, + explain, + suggestTags, + ]; +} + +// ─── Original skills ──────────────────────────────────────────── + +const summarize: SkillDefinition = { + name: "summarize", + version: "1.0.0", + description: "Summarize a wiki article or raw source", + input: "selection", + output: "stdout", + llm: { + required: true, + model: "fast", + systemPrompt: + "Summarize the following content concisely. Highlight key points, main arguments, and conclusions. Output markdown.", + maxTokens: 1024, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to summarize."); + return {}; + } + const content = articles.map((a) => `# ${a.title}\n\n${a.content}`).join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const flashcards: SkillDefinition = { + name: "flashcards", + version: "1.0.0", + description: "Generate flashcards from wiki articles", + input: "wiki", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `Generate flashcards from the following knowledge base articles. +Output format: +Q: [question] +A: [answer] + +Create 5-10 flashcards per article. Focus on key concepts, definitions, and relationships.`, + maxTokens: 4096, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to generate flashcards from."); + return {}; + } + const content = articles + .slice(0, 5) + .map((a) => `# ${a.title}\n\n${a.content}`) + .join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const connections: SkillDefinition = { + name: "connections", + version: "1.0.0", + description: "Suggest new connections between existing articles", + input: "index", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `Analyze the following wiki index and suggest connections between articles that aren't currently linked. +For each suggestion, explain why the connection is relevant. +Output as a markdown list.`, + maxTokens: 2048, + temperature: 0.3, + }, + async run(ctx) { + const index = await ctx.vault.readIndex(); + const graph = await ctx.vault.readGraph(); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [ + { + role: "user", + content: `CURRENT INDEX:\n${index}\n\nCURRENT GRAPH:\n${graph}`, + }, + ], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +// ─── New v0.8.0 skills ───────────────────────────────────────── + +const findContradictions: SkillDefinition = { + name: "find-contradictions", + version: "1.0.0", + description: "Detect contradictory claims across articles", + input: "wiki", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `You are an expert fact-checker. Analyze the following knowledge base articles and identify any contradictory claims, inconsistencies, or conflicting information between articles. + +For each contradiction found, output: + +## Contradiction N +- **Article A**: [title] — "[claim]" +- **Article B**: [title] — "[conflicting claim]" +- **Analysis**: [explain the contradiction and suggest how to resolve it] + +If no contradictions are found, say "No contradictions detected."`, + maxTokens: 4096, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length < 2) { + ctx.logger.warn("Need at least 2 articles to check for contradictions."); + return { content: "No contradictions detected — fewer than 2 articles in vault." }; + } + const content = articles + .slice(0, 15) + .map((a) => `# ${a.title}\n\n${a.content}`) + .join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const weeklyDigest: SkillDefinition = { + name: "weekly-digest", + version: "1.0.0", + description: "Generate a weekly summary of new additions", + input: "vault", + output: "report", + llm: { + required: true, + model: "fast", + systemPrompt: `Generate a concise weekly digest of knowledge base activity. Summarize: +1. New articles added this week +2. Key themes and topics covered +3. Notable connections between new and existing content +4. Suggested areas to explore next + +Format as a clean markdown newsletter with sections.`, + maxTokens: 2048, + temperature: 0.2, + }, + async run(ctx) { + const manifest = ctx.vault.manifest; + const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(); + + // Find recently added/updated articles + const recentArticles: string[] = []; + for (const [slug, entry] of Object.entries(manifest.articles)) { + if (entry.lastUpdated >= oneWeekAgo || entry.createdAt >= oneWeekAgo) { + recentArticles.push(slug); + } + } + + // Find recently ingested sources + const recentSources: string[] = []; + for (const [id, entry] of Object.entries(manifest.sources)) { + if (entry.ingestedAt >= oneWeekAgo) { + recentSources.push(entry.metadata.title ?? id); + } + } + + const articles = await ctx.vault.readWiki(); + const recentContent = articles + .filter((a) => recentArticles.includes(a.slug)) + .map((a) => `# ${a.title}\n\n${a.content}`) + .join("\n\n---\n\n"); + + const prompt = `VAULT STATS: +- Total sources: ${manifest.stats.totalSources} +- Total articles: ${manifest.stats.totalArticles} +- Recent sources (last 7 days): ${recentSources.length} +- Recent articles (last 7 days): ${recentArticles.length} + +RECENT SOURCES INGESTED: +${recentSources.length > 0 ? recentSources.map((s) => `- ${s}`).join("\n") : "None"} + +RECENT ARTICLES: +${recentContent || "No new articles this week."}`; + + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content: prompt }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const exportSlides: SkillDefinition = { + name: "export-slides", + version: "1.0.0", + description: "Generate a Marp slide deck from articles", + input: "wiki", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `Convert the following knowledge base articles into a Marp-compatible markdown slide deck. + +Rules: +- Use "---" to separate slides +- First slide should be a title slide +- Each major concept gets its own slide +- Use bullet points, keep text concise +- Add speaker notes with "" where helpful +- Include a summary/conclusion slide at the end +- Start the document with the Marp frontmatter: + +--- +marp: true +theme: default +paginate: true +---`, + maxTokens: 8192, + temperature: 0.1, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to export as slides."); + return {}; + } + const content = articles + .slice(0, 8) + .map((a) => `# ${a.title}\n\n${a.content}`) + .join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const timeline: SkillDefinition = { + name: "timeline", + version: "1.0.0", + description: "Generate a chronological timeline from articles", + input: "wiki", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `Analyze the following knowledge base articles and extract a chronological timeline of events, milestones, and developments. + +Output format: +## Timeline + +| Date/Period | Event | Source Article | +|---|---|---| +| [date] | [what happened] | [article title] | + +After the table, add a "Key Observations" section noting trends and patterns. +If no temporal information is found, explain that and suggest articles that would benefit from dates.`, + maxTokens: 4096, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to build timeline from."); + return {}; + } + const content = articles + .slice(0, 15) + .map((a) => `# ${a.title}\n\n${a.content}`) + .join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const compare: SkillDefinition = { + name: "compare", + version: "1.0.0", + description: "Compare two articles or topics side by side", + input: "selection", + output: "report", + llm: { + required: true, + model: "default", + systemPrompt: `Compare the following articles/topics side by side. Create a structured comparison: + +## Comparison: [Topic A] vs [Topic B] + +### Similarities +- [shared aspects] + +### Differences +| Aspect | [Topic A] | [Topic B] | +|---|---|---| +| [aspect] | [detail] | [detail] | + +### Key Takeaways +- [insights from the comparison] + +### When to Use Which +- [practical guidance]`, + maxTokens: 4096, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length < 2) { + ctx.logger.warn("Need at least 2 articles to compare."); + return { content: "Cannot compare — need at least 2 articles." }; + } + // Compare first two articles (CLI can pass specific articles via args) + const toCompare = ctx.args.articles + ? articles.filter((a) => + (ctx.args.articles as string[]).some( + (name) => + a.title.toLowerCase().includes(name.toLowerCase()) || + a.slug.includes(name.toLowerCase()), + ), + ) + : articles.slice(0, 2); + + if (toCompare.length < 2) { + ctx.logger.warn("Could not find 2 matching articles to compare."); + return { content: "Could not find 2 matching articles." }; + } + + const content = toCompare.map((a) => `# ${a.title}\n\n${a.content}`).join("\n\n---\n\n"); + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const explain: SkillDefinition = { + name: "explain", + version: "1.0.0", + description: "Explain a topic at a specified reading level", + input: "selection", + output: "stdout", + llm: { + required: true, + model: "default", + systemPrompt: `Explain the following topic from the knowledge base at the specified reading level. + +Reading levels: +- "beginner": ELI5 — simple analogies, no jargon, short sentences +- "intermediate": Assume basic familiarity, explain technical terms on first use +- "expert": Dense, precise, assume domain expertise +- "child": Use fun examples, metaphors, and simple language suitable for ages 8-12 + +Default to "intermediate" if no level specified. + +Structure your explanation with: +1. One-sentence summary +2. Core explanation +3. Key takeaways (2-3 bullet points) +4. "Learn more" — suggest related topics from the knowledge base`, + maxTokens: 2048, + temperature: 0.2, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to explain."); + return {}; + } + + const level = (ctx.args.level as string) ?? "intermediate"; + const target = ctx.args.topic + ? articles.find( + (a) => + a.title.toLowerCase().includes((ctx.args.topic as string).toLowerCase()) || + a.slug.includes((ctx.args.topic as string).toLowerCase()), + ) + : articles[0]; + + if (!target) { + ctx.logger.warn("Could not find the specified topic."); + return { content: "Topic not found in the knowledge base." }; + } + + const content = `# ${target.title}\n\n${target.content}`; + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [ + { + role: "user", + content: `Reading level: ${level}\n\n${content}`, + }, + ], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; + +const suggestTags: SkillDefinition = { + name: "suggest-tags", + version: "1.0.0", + description: "Auto-tag articles based on content analysis", + input: "wiki", + output: "report", + llm: { + required: true, + model: "fast", + systemPrompt: `Analyze the following knowledge base articles and suggest tags for each one. + +Rules: +- Suggest 3-7 tags per article +- Use lowercase, hyphenated tags (e.g., "machine-learning", "web-development") +- Reuse existing tags where appropriate for consistency +- Tags should capture: topic, domain, type (tutorial, concept, reference), and key technologies + +Output format: +## Tag Suggestions + +### [Article Title] +Current tags: [existing tags or "none"] +Suggested tags: \`tag-1\`, \`tag-2\`, \`tag-3\` +Reason: [brief explanation]`, + maxTokens: 4096, + temperature: 0, + }, + async run(ctx) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles to tag."); + return {}; + } + + // Gather existing tags for consistency + const existingTags = new Set(); + for (const entry of Object.values(ctx.vault.manifest.articles)) { + for (const tag of entry.tags) { + existingTags.add(tag); + } + } + + const content = articles + .slice(0, 20) + .map((a) => { + const entry = Object.values(ctx.vault.manifest.articles).find( + (e) => + a.slug === + Object.keys(ctx.vault.manifest.articles).find( + (k) => ctx.vault.manifest.articles[k] === e, + ), + ); + const tags = entry?.tags ?? []; + return `# ${a.title}\nCurrent tags: [${tags.join(", ")}]\n\n${a.content}`; + }) + .join("\n\n---\n\n"); + + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [ + { + role: "user", + content: `EXISTING TAGS IN VAULT: ${[...existingTags].join(", ") || "none"}\n\n${content}`, + }, + ], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + return { content: result.content }; + }, +}; diff --git a/packages/core/src/skills/hooks.ts b/packages/core/src/skills/hooks.ts new file mode 100644 index 0000000..b3e65b8 --- /dev/null +++ b/packages/core/src/skills/hooks.ts @@ -0,0 +1,93 @@ +import type { LLMProvider, VaultConfig } from "../types.js"; +import { loadSkills } from "./loader.js"; +import { runSkill } from "./runner.js"; +import type { SkillHook } from "./schema.js"; + +export interface HookResult { + skill: string; + content?: string; + error?: string; +} + +/** + * Run all skills registered for a given hook event. + * + * Skills are discovered from two sources: + * 1. Skills with `hooks` field in their definition (built-in or installed) + * 2. Skills listed in `config.toml` under `[skills.hooks]` + */ +export async function runSkillHooks( + root: string, + hook: SkillHook, + opts?: { provider?: LLMProvider; config?: VaultConfig }, +): Promise { + const allSkills = await loadSkills(root); + const results: HookResult[] = []; + + // Collect skill names to run from both sources + const skillNames = new Set(); + + // 1. Skills with hooks declared in their definition + for (const skill of allSkills) { + if (skill.hooks?.includes(hook)) { + skillNames.add(skill.name); + } + } + + // 2. Skills listed in config.toml [skills.hooks] + if (opts?.config?.skills?.hooks) { + const configHooks = opts.config.skills.hooks[hook]; + if (configHooks) { + for (const name of configHooks) { + skillNames.add(name); + } + } + } + + // Run each hook skill + for (const name of skillNames) { + const skill = allSkills.find((s) => s.name === name); + if (!skill) { + results.push({ skill: name, error: `Skill "${name}" not found` }); + continue; + } + + try { + const result = await runSkill(root, skill, { provider: opts?.provider }); + results.push({ skill: name, content: result.content }); + } catch (err) { + results.push({ skill: name, error: (err as Error).message }); + } + } + + return results; +} + +/** + * Get all skills registered for a given hook. + */ +export async function getHookedSkills( + root: string, + hook: SkillHook, + config?: VaultConfig, +): Promise { + const allSkills = await loadSkills(root); + const names = new Set(); + + for (const skill of allSkills) { + if (skill.hooks?.includes(hook)) { + names.add(skill.name); + } + } + + if (config?.skills?.hooks) { + const configHooks = config.skills.hooks[hook]; + if (configHooks) { + for (const name of configHooks) { + names.add(name); + } + } + } + + return [...names]; +} diff --git a/packages/core/src/skills/loader.ts b/packages/core/src/skills/loader.ts index 29fadc4..bd738e0 100644 --- a/packages/core/src/skills/loader.ts +++ b/packages/core/src/skills/loader.ts @@ -1,9 +1,10 @@ import { existsSync } from "node:fs"; -import { readdir } from "node:fs/promises"; +import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { SKILLS_DIR, VAULT_DIR } from "../constants.js"; import type { SkillDefinition } from "../types.js"; -import { SkillDefinitionSchema } from "./schema.js"; +import { getBuiltinSkills } from "./builtins.js"; +import { SkillDefinitionSchema, SkillPackageSchema } from "./schema.js"; /** * Load all available skills (built-in + installed). @@ -24,28 +25,25 @@ export async function findSkill(root: string, name: string): Promise { const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); if (!existsSync(skillsDir)) return []; - const files = await readdir(skillsDir); - const tsFiles = files.filter((f) => f.endsWith(".ts") || f.endsWith(".js")); - + const entries = await readdir(skillsDir, { withFileTypes: true }); const skills: SkillDefinition[] = []; - for (const file of tsFiles) { + for (const entry of entries) { try { - const mod = await import(join(skillsDir, file)); - const definition = mod.default ?? mod; - - // Validate the skill definition - const parsed = SkillDefinitionSchema.safeParse(definition); - if (parsed.success) { - skills.push({ - ...definition, - run: definition.run, - }); + if (entry.isDirectory()) { + // Directory-based skill — look for skill.json or index.ts/index.js + const skill = await loadDirectorySkill(join(skillsDir, entry.name)); + if (skill) skills.push(skill); + } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + // Single-file skill + const skill = await loadSingleFileSkill(join(skillsDir, entry.name)); + if (skill) skills.push(skill); } } catch { // Skip malformed skills @@ -55,109 +53,40 @@ async function loadInstalledSkills(root: string): Promise { return skills; } -/** - * Built-in skills that ship with kib. - */ -function getBuiltinSkills(): SkillDefinition[] { - return [ - { - name: "summarize", - version: "1.0.0", - description: "Summarize a wiki article or raw source", - input: "selection", - output: "stdout", - llm: { - required: true, - model: "fast", - systemPrompt: - "Summarize the following content concisely. Highlight key points, main arguments, and conclusions. Output markdown.", - maxTokens: 1024, - temperature: 0, - }, - async run(ctx) { - const articles = await ctx.vault.readWiki(); - if (articles.length === 0) { - ctx.logger.warn("No articles to summarize."); - return {}; - } - const content = articles.map((a) => `# ${a.title}\n\n${a.content}`).join("\n\n---\n\n"); - const result = await ctx.llm.complete({ - system: this.llm!.systemPrompt, - messages: [{ role: "user", content }], - maxTokens: this.llm!.maxTokens, - temperature: this.llm!.temperature, - }); - return { content: result.content }; - }, - }, - { - name: "flashcards", - version: "1.0.0", - description: "Generate flashcards from wiki articles", - input: "wiki", - output: "report", - llm: { - required: true, - model: "default", - systemPrompt: `Generate flashcards from the following knowledge base articles. -Output format: -Q: [question] -A: [answer] +async function loadDirectorySkill(dir: string): Promise { + // Check for skill.json to find entry point + const skillJsonPath = join(dir, "skill.json"); + let entryPoint = "index.ts"; + + if (existsSync(skillJsonPath)) { + const raw = await readFile(skillJsonPath, "utf-8"); + const pkg = SkillPackageSchema.safeParse(JSON.parse(raw)); + if (pkg.success && pkg.data.main) { + entryPoint = pkg.data.main; + } + } + + // Try the entry point, fallback to common alternatives + const candidates = [join(dir, entryPoint), join(dir, "index.ts"), join(dir, "index.js")]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + return loadSingleFileSkill(candidate); + } + } + + return null; +} + +async function loadSingleFileSkill(filePath: string): Promise { + const mod = await import(filePath); + const definition = mod.default ?? mod; + + const parsed = SkillDefinitionSchema.safeParse(definition); + if (!parsed.success) return null; -Create 5-10 flashcards per article. Focus on key concepts, definitions, and relationships.`, - maxTokens: 4096, - temperature: 0, - }, - async run(ctx) { - const articles = await ctx.vault.readWiki(); - if (articles.length === 0) { - ctx.logger.warn("No articles to generate flashcards from."); - return {}; - } - const content = articles - .slice(0, 5) - .map((a) => `# ${a.title}\n\n${a.content}`) - .join("\n\n---\n\n"); - const result = await ctx.llm.complete({ - system: this.llm!.systemPrompt, - messages: [{ role: "user", content }], - maxTokens: this.llm!.maxTokens, - temperature: this.llm!.temperature, - }); - return { content: result.content }; - }, - }, - { - name: "connections", - version: "1.0.0", - description: "Suggest new connections between existing articles", - input: "index", - output: "report", - llm: { - required: true, - model: "default", - systemPrompt: `Analyze the following wiki index and suggest connections between articles that aren't currently linked. -For each suggestion, explain why the connection is relevant. -Output as a markdown list.`, - maxTokens: 2048, - temperature: 0.3, - }, - async run(ctx) { - const index = await ctx.vault.readIndex(); - const graph = await ctx.vault.readGraph(); - const result = await ctx.llm.complete({ - system: this.llm!.systemPrompt, - messages: [ - { - role: "user", - content: `CURRENT INDEX:\n${index}\n\nCURRENT GRAPH:\n${graph}`, - }, - ], - maxTokens: this.llm!.maxTokens, - temperature: this.llm!.temperature, - }); - return { content: result.content }; - }, - }, - ]; + return { + ...definition, + run: definition.run, + }; } diff --git a/packages/core/src/skills/registry.ts b/packages/core/src/skills/registry.ts new file mode 100644 index 0000000..3cae0e4 --- /dev/null +++ b/packages/core/src/skills/registry.ts @@ -0,0 +1,344 @@ +import { existsSync } from "node:fs"; +import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { SKILLS_DIR, VAULT_DIR } from "../constants.js"; +import type { SkillDefinition } from "../types.js"; +import { SkillDefinitionSchema, SkillPackageSchema } from "./schema.js"; + +export interface InstallResult { + name: string; + version: string; + source: "github" | "npm" | "local"; + path: string; +} + +/** + * Install a skill from GitHub or npm. + * + * Supported formats: + * - `github:user/repo` — clone from GitHub + * - `` — install from npm + */ +export async function installSkill(root: string, source: string): Promise { + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + await mkdir(skillsDir, { recursive: true }); + + if (source.startsWith("github:")) { + return installFromGitHub(root, source.slice(7), skillsDir); + } + + return installFromNpm(root, source, skillsDir); +} + +/** + * Uninstall a skill by name. + */ +export async function uninstallSkill(root: string, name: string): Promise { + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + const skillDir = join(skillsDir, name); + + if (!existsSync(skillDir)) { + // Try single-file skills + const singleFile = join(skillsDir, `${name}.ts`); + const singleFileJs = join(skillsDir, `${name}.js`); + if (existsSync(singleFile)) { + await rm(singleFile); + return; + } + if (existsSync(singleFileJs)) { + await rm(singleFileJs); + return; + } + throw new Error(`Skill "${name}" is not installed`); + } + + await rm(skillDir, { recursive: true, force: true }); +} + +/** + * Scaffold a new skill from a template. + */ +export async function createSkill( + root: string, + name: string, + opts?: { author?: string }, +): Promise { + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + await mkdir(skillsDir, { recursive: true }); + + const skillDir = join(skillsDir, name); + if (existsSync(skillDir)) { + throw new Error(`Skill "${name}" already exists at ${skillDir}`); + } + + await mkdir(skillDir, { recursive: true }); + + const packageJson = { + name, + version: "1.0.0", + description: `Custom skill: ${name}`, + author: opts?.author ?? "", + main: "index.ts", + }; + + await writeFile(join(skillDir, "skill.json"), JSON.stringify(packageJson, null, 2)); + + const template = `import type { SkillContext } from "@kibhq/core"; + +export default { + name: "${name}", + version: "1.0.0", + description: "TODO: describe what this skill does", + author: "${opts?.author ?? ""}", + + input: "wiki" as const, + output: "report" as const, + + llm: { + required: true, + model: "default" as const, + systemPrompt: "You are a helpful assistant. Analyze the provided content and produce a report.", + maxTokens: 4096, + temperature: 0, + }, + + async run(ctx: SkillContext) { + const articles = await ctx.vault.readWiki(); + if (articles.length === 0) { + ctx.logger.warn("No articles found."); + return {}; + } + + const content = articles + .map((a) => \`# \${a.title}\\n\\n\${a.content}\`) + .join("\\n\\n---\\n\\n"); + + const result = await ctx.llm.complete({ + system: this.llm!.systemPrompt, + messages: [{ role: "user", content }], + maxTokens: this.llm!.maxTokens, + temperature: this.llm!.temperature, + }); + + return { content: result.content }; + }, +}; +`; + + await writeFile(join(skillDir, "index.ts"), template); + + return skillDir; +} + +/** + * Generate a publishable skill package from the skill directory. + * Returns the path to the package tarball directory. + */ +export async function publishSkill(root: string, name: string): Promise { + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + const skillDir = join(skillsDir, name); + + if (!existsSync(skillDir)) { + throw new Error(`Skill "${name}" not found at ${skillDir}`); + } + + const packagePath = join(skillDir, "skill.json"); + if (!existsSync(packagePath)) { + throw new Error(`No skill.json found in ${skillDir}. Run "kib skill create" first.`); + } + + const raw = await readFile(packagePath, "utf-8"); + const pkg = SkillPackageSchema.parse(JSON.parse(raw)); + + // Validate the skill loads correctly + const mainPath = join(skillDir, pkg.main); + if (!existsSync(mainPath)) { + throw new Error(`Skill entry point "${pkg.main}" not found`); + } + + const mod = await import(mainPath); + const definition = mod.default ?? mod; + const parsed = SkillDefinitionSchema.safeParse(definition); + if (!parsed.success) { + throw new Error(`Skill validation failed: ${parsed.error.message}`); + } + + // Return the directory — actual publishing to npm/registry is handled by the CLI + return skillDir; +} + +/** + * List installed skills (metadata only, no loading). + */ +export async function listInstalledSkills(root: string): Promise<{ name: string; path: string }[]> { + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + if (!existsSync(skillsDir)) return []; + + const entries = await readdir(skillsDir, { withFileTypes: true }); + const results: { name: string; path: string }[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + results.push({ name: entry.name, path: join(skillsDir, entry.name) }); + } else if (entry.isFile() && (entry.name.endsWith(".ts") || entry.name.endsWith(".js"))) { + results.push({ + name: entry.name.replace(/\.(ts|js)$/, ""), + path: join(skillsDir, entry.name), + }); + } + } + + return results; +} + +/** + * Resolve skill dependencies — returns skills in execution order. + * Throws on circular dependencies. + */ +export function resolveSkillDependencies( + skill: SkillDefinition, + allSkills: SkillDefinition[], +): SkillDefinition[] { + const resolved: SkillDefinition[] = []; + const seen = new Set(); + + function visit(s: SkillDefinition, chain: Set) { + if (seen.has(s.name)) return; + if (chain.has(s.name)) { + throw new Error(`Circular skill dependency: ${[...chain, s.name].join(" → ")}`); + } + + chain.add(s.name); + + for (const dep of s.dependencies ?? []) { + const depSkill = allSkills.find((sk) => sk.name === dep); + if (!depSkill) { + throw new Error(`Skill "${s.name}" depends on "${dep}", which was not found`); + } + visit(depSkill, new Set(chain)); + } + + chain.delete(s.name); + seen.add(s.name); + resolved.push(s); + } + + visit(skill, new Set()); + return resolved; +} + +// ─── Private helpers ──────────────────────────────────────────── + +async function installFromGitHub( + _root: string, + repo: string, + skillsDir: string, +): Promise { + // repo format: "user/repo" or "user/repo#branch" + const [repoPath, branch] = repo.split("#"); + const parts = repoPath.split("/"); + if (parts.length !== 2) { + throw new Error(`Invalid GitHub repo format: "${repo}". Expected "user/repo"`); + } + + const repoName = parts[1]; + const destDir = join(skillsDir, repoName); + + if (existsSync(destDir)) { + throw new Error(`Skill "${repoName}" is already installed. Uninstall first.`); + } + + const url = `https://github.com/${repoPath}.git`; + const args = ["git", "clone", "--depth", "1"]; + if (branch) args.push("--branch", branch); + args.push(url, destDir); + + const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Failed to clone ${url}: ${stderr.trim()}`); + } + + // Clean up .git directory — we don't need it + const gitDir = join(destDir, ".git"); + if (existsSync(gitDir)) { + await rm(gitDir, { recursive: true, force: true }); + } + + // Read skill metadata + const pkgPath = join(destDir, "skill.json"); + let name = repoName; + let version = "1.0.0"; + + if (existsSync(pkgPath)) { + const raw = await readFile(pkgPath, "utf-8"); + const pkg = SkillPackageSchema.safeParse(JSON.parse(raw)); + if (pkg.success) { + name = pkg.data.name; + version = pkg.data.version; + } + } + + return { name, version, source: "github", path: destDir }; +} + +async function installFromNpm( + _root: string, + packageName: string, + skillsDir: string, +): Promise { + // Use bun to install the package into the skills directory + const destDir = join(skillsDir, packageName.replace(/^@/, "").replace("/", "-")); + + if (existsSync(destDir)) { + throw new Error(`Skill "${packageName}" is already installed. Uninstall first.`); + } + + await mkdir(destDir, { recursive: true }); + + // Initialize a minimal package.json so bun can install into it + await writeFile( + join(destDir, "package.json"), + JSON.stringify({ name: "kib-skill-wrapper", private: true, dependencies: {} }), + ); + + const proc = Bun.spawn(["bun", "add", packageName], { + cwd: destDir, + stdout: "pipe", + stderr: "pipe", + }); + const exitCode = await proc.exited; + + if (exitCode !== 0) { + await rm(destDir, { recursive: true, force: true }); + const stderr = await new Response(proc.stderr).text(); + throw new Error(`Failed to install ${packageName}: ${stderr.trim()}`); + } + + // Find the installed package + const nodeModulesDir = join(destDir, "node_modules", packageName); + let name = packageName; + let version = "1.0.0"; + + // Check for skill.json or package.json for metadata + const skillJsonPath = join(nodeModulesDir, "skill.json"); + const pkgJsonPath = join(nodeModulesDir, "package.json"); + + if (existsSync(skillJsonPath)) { + const raw = await readFile(skillJsonPath, "utf-8"); + const pkg = SkillPackageSchema.safeParse(JSON.parse(raw)); + if (pkg.success) { + name = pkg.data.name; + version = pkg.data.version; + } + } else if (existsSync(pkgJsonPath)) { + const raw = await readFile(pkgJsonPath, "utf-8"); + const pkg = JSON.parse(raw); + name = pkg.name ?? packageName; + version = pkg.version ?? "1.0.0"; + } + + return { name, version, source: "npm", path: destDir }; +} diff --git a/packages/core/src/skills/runner.ts b/packages/core/src/skills/runner.ts index 90d7e93..e401718 100644 --- a/packages/core/src/skills/runner.ts +++ b/packages/core/src/skills/runner.ts @@ -11,12 +11,16 @@ import type { VaultConfig, } from "../types.js"; import { listRaw, listWiki, loadConfig, loadManifest, readIndex, writeWiki } from "../vault.js"; +import { findSkill } from "./loader.js"; +import { resolveSkillDependencies } from "./registry.js"; export interface RunSkillOptions { /** Additional CLI args */ args?: Record; /** LLM provider (required if skill.llm.required is true) */ provider?: LLMProvider; + /** Max depth for skill-to-skill invocation (prevents infinite recursion) */ + maxDepth?: number; } export interface RunSkillResult { @@ -25,6 +29,7 @@ export interface RunSkillResult { /** * Execute a skill against a vault. + * Resolves dependencies and runs them first if needed. */ export async function runSkill( root: string, @@ -38,6 +43,17 @@ export async function runSkill( throw new Error(`Skill "${skill.name}" requires an LLM provider`); } + // Resolve and run dependencies first + if (skill.dependencies?.length) { + const { loadSkills } = await import("./loader.js"); + const allSkills = await loadSkills(root); + const deps = resolveSkillDependencies(skill, allSkills); + // Run dependencies (all except the skill itself, which is last) + for (const dep of deps.slice(0, -1)) { + await runSkill(root, dep, options); + } + } + const ctx = buildContext(root, manifest, config, options, skill); return skill.run(ctx); } @@ -48,7 +64,10 @@ function buildContext( config: VaultConfig, options: RunSkillOptions, skill: SkillDefinition, + depth = 0, ): SkillContext { + const maxDepth = options.maxDepth ?? 5; + return { vault: { async readIndex() { @@ -121,6 +140,25 @@ function buildContext( }, }, + async invoke(skillName: string, args?: Record) { + if (depth >= maxDepth) { + throw new Error( + `Skill invocation depth limit (${maxDepth}) reached. Possible circular invocation.`, + ); + } + + const targetSkill = await findSkill(root, skillName); + if (!targetSkill) { + throw new Error(`Skill "${skillName}" not found`); + } + + return runSkill(root, targetSkill, { + ...options, + args: { ...options.args, ...args }, + maxDepth: maxDepth - depth, + }); + }, + logger: { info: (msg) => console.log(` [${skill.name}] ${msg}`), warn: (msg) => console.warn(` [${skill.name}] ⚠ ${msg}`), diff --git a/packages/core/src/skills/schema.ts b/packages/core/src/skills/schema.ts index 5e7cd05..07887c7 100644 --- a/packages/core/src/skills/schema.ts +++ b/packages/core/src/skills/schema.ts @@ -4,6 +4,8 @@ export const SkillInputSchema = z.enum(["wiki", "raw", "vault", "selection", "in export const SkillOutputSchema = z.enum(["articles", "report", "mutations", "stdout", "none"]); +export const SkillHookSchema = z.enum(["post-compile", "post-ingest", "post-lint"]); + export const SkillDefinitionSchema = z.object({ name: z.string().min(1), version: z.string().default("1.0.0"), @@ -13,6 +15,10 @@ export const SkillDefinitionSchema = z.object({ input: SkillInputSchema, output: SkillOutputSchema, + dependencies: z.array(z.string()).optional(), + hooks: z.array(SkillHookSchema).optional(), + category: z.string().optional(), + llm: z .object({ required: z.boolean().default(true), @@ -24,5 +30,30 @@ export const SkillDefinitionSchema = z.object({ .optional(), }); +/** Schema for skill.json manifest in installed skill packages */ +export const SkillPackageSchema = z.object({ + name: z.string().min(1), + version: z.string().default("1.0.0"), + description: z.string().min(1), + author: z.string().optional(), + main: z.string().default("index.ts"), + dependencies: z.array(z.string()).optional(), +}); + +/** Schema for skills section in vault config.toml */ +export const SkillConfigSchema = z.object({ + hooks: z + .object({ + "post-compile": z.array(z.string()).default([]), + "post-ingest": z.array(z.string()).default([]), + "post-lint": z.array(z.string()).default([]), + }) + .default({}), + config: z.record(z.string(), z.record(z.string(), z.unknown())).default({}), +}); + export type SkillInput = z.infer; export type SkillOutput = z.infer; +export type SkillHook = z.infer; +export type SkillPackage = z.infer; +export type SkillConfig = z.infer; diff --git a/packages/core/src/skills/skills.test.ts b/packages/core/src/skills/skills.test.ts index 2f824be..d5d61ef 100644 --- a/packages/core/src/skills/skills.test.ts +++ b/packages/core/src/skills/skills.test.ts @@ -1,7 +1,9 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { SKILLS_DIR, VAULT_DIR } from "../constants.js"; import type { CompletionResult, LLMProvider, @@ -10,7 +12,10 @@ import type { StreamChunk, } from "../types.js"; import { initVault, writeWiki } from "../vault.js"; +import { getBuiltinSkills } from "./builtins.js"; +import { getHookedSkills, runSkillHooks } from "./hooks.js"; import { findSkill, loadSkills } from "./loader.js"; +import { createSkill, resolveSkillDependencies, uninstallSkill } from "./registry.js"; import { runSkill } from "./runner.js"; let tempDir: string; @@ -45,17 +50,41 @@ function articleMd(title: string, slug: string, content: string): string { return `---\ntitle: ${title}\nslug: ${slug}\ncategory: concept\ntags: []\nsummary: ""\n---\n\n# ${title}\n\n${content}`; } +// ─── Loader tests ─────────────────────────────────────────────── + describe("skill loader", () => { test("loads built-in skills", async () => { const root = await makeTempVault(); const skills = await loadSkills(root); - expect(skills.length).toBeGreaterThanOrEqual(3); + expect(skills.length).toBeGreaterThanOrEqual(10); expect(skills.some((s) => s.name === "summarize")).toBe(true); expect(skills.some((s) => s.name === "flashcards")).toBe(true); expect(skills.some((s) => s.name === "connections")).toBe(true); }); + test("loads all v0.8.0 built-in skills", async () => { + const root = await makeTempVault(); + const skills = await loadSkills(root); + + const expected = [ + "summarize", + "flashcards", + "connections", + "find-contradictions", + "weekly-digest", + "export-slides", + "timeline", + "compare", + "explain", + "suggest-tags", + ]; + + for (const name of expected) { + expect(skills.some((s) => s.name === name)).toBe(true); + } + }); + test("finds skill by name", async () => { const root = await makeTempVault(); const skill = await findSkill(root, "summarize"); @@ -70,8 +99,75 @@ describe("skill loader", () => { const skill = await findSkill(root, "nonexistent"); expect(skill).toBeNull(); }); + + test("loads directory-based installed skills", async () => { + const root = await makeTempVault(); + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + const mySkillDir = join(skillsDir, "my-custom-skill"); + await mkdir(mySkillDir, { recursive: true }); + + await writeFile( + join(mySkillDir, "index.ts"), + `export default { + name: "my-custom-skill", + version: "1.0.0", + description: "A test custom skill", + input: "none", + output: "stdout", + async run() { return { content: "hello" }; }, + };`, + ); + + const skills = await loadSkills(root); + expect(skills.some((s) => s.name === "my-custom-skill")).toBe(true); + }); + + test("loads directory skill with skill.json entry point", async () => { + const root = await makeTempVault(); + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + const mySkillDir = join(skillsDir, "custom-entry"); + await mkdir(mySkillDir, { recursive: true }); + + await writeFile( + join(mySkillDir, "skill.json"), + JSON.stringify({ + name: "custom-entry", + version: "2.0.0", + description: "test", + main: "main.ts", + }), + ); + await writeFile( + join(mySkillDir, "main.ts"), + `export default { + name: "custom-entry", + version: "2.0.0", + description: "A skill with custom entry", + input: "none", + output: "stdout", + async run() { return { content: "custom" }; }, + };`, + ); + + const skills = await loadSkills(root); + expect(skills.some((s) => s.name === "custom-entry")).toBe(true); + }); + + test("skips malformed installed skills", async () => { + const root = await makeTempVault(); + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + + await writeFile(join(skillsDir, "bad-skill.ts"), `export default { broken: true };`); + + const skills = await loadSkills(root); + expect(skills.some((s) => s.name === "bad-skill")).toBe(false); + // Should still load built-ins + expect(skills.some((s) => s.name === "summarize")).toBe(true); + }); }); +// ─── Runner tests ─────────────────────────────────────────────── + describe("skill runner", () => { test("runs summarize skill", async () => { const root = await makeTempVault(); @@ -137,4 +233,592 @@ describe("skill runner", () => { const result = await runSkill(root, testSkill); expect(result.content).toContain("Found 2 articles"); }); + + test("runs find-contradictions skill", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/a.md", articleMd("Topic A", "a", "The sky is blue.")); + await writeWiki(root, "concepts/b.md", articleMd("Topic B", "b", "The sky is green.")); + + const skill = await findSkill(root, "find-contradictions"); + expect(skill).not.toBeNull(); + + const provider = mockProvider( + "## Contradiction 1\n- Article A: sky is blue\n- Article B: sky is green", + ); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Contradiction"); + }); + + test("find-contradictions returns early with < 2 articles", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/only.md", articleMd("Only", "only", "Solo article.")); + + const skill = await findSkill(root, "find-contradictions"); + const provider = mockProvider("should not be called"); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("fewer than 2 articles"); + }); + + test("runs weekly-digest skill", async () => { + const root = await makeTempVault(); + await writeWiki( + root, + "concepts/new.md", + articleMd("New Thing", "new-thing", "Recently added."), + ); + + const skill = await findSkill(root, "weekly-digest"); + const provider = mockProvider("# Weekly Digest\n\n## New This Week\n- New Thing"); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Weekly Digest"); + }); + + test("runs export-slides skill", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/topic.md", articleMd("Topic", "topic", "Some content here.")); + + const skill = await findSkill(root, "export-slides"); + const provider = mockProvider("---\nmarp: true\n---\n# Slide 1\n\n- Point A"); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("marp: true"); + }); + + test("runs timeline skill", async () => { + const root = await makeTempVault(); + await writeWiki( + root, + "concepts/history.md", + articleMd("History", "history", "In 2020, X happened."), + ); + + const skill = await findSkill(root, "timeline"); + const provider = mockProvider( + "## Timeline\n\n| Date | Event |\n|---|---|\n| 2020 | X happened |", + ); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Timeline"); + }); + + test("runs compare skill", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/a.md", articleMd("React", "react", "React is a UI library.")); + await writeWiki( + root, + "concepts/b.md", + articleMd("Vue", "vue", "Vue is a progressive framework."), + ); + + const skill = await findSkill(root, "compare"); + const provider = mockProvider( + "## Comparison: React vs Vue\n\n### Similarities\n- Both are JS frameworks", + ); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Comparison"); + }); + + test("compare returns early with < 2 articles", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/only.md", articleMd("Only", "only", "Solo article.")); + + const skill = await findSkill(root, "compare"); + const provider = mockProvider("should not be called"); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Cannot compare"); + }); + + test("runs explain skill", async () => { + const root = await makeTempVault(); + await writeWiki( + root, + "concepts/ml.md", + articleMd("Machine Learning", "ml", "ML uses neural networks."), + ); + + const skill = await findSkill(root, "explain"); + const provider = mockProvider( + "**Summary**: ML is computers learning from data.\n\n## Core Explanation...", + ); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("Summary"); + }); + + test("runs suggest-tags skill", async () => { + const root = await makeTempVault(); + await writeWiki( + root, + "concepts/ml.md", + articleMd("Machine Learning", "ml", "ML is a subset of AI."), + ); + + const skill = await findSkill(root, "suggest-tags"); + const provider = mockProvider("### Machine Learning\nSuggested tags: `machine-learning`, `ai`"); + const result = await runSkill(root, skill!, { provider }); + + expect(result.content).toContain("machine-learning"); + }); +}); + +// ─── Skill-to-skill invocation ────────────────────────────────── + +describe("skill-to-skill invocation", () => { + test("skill can invoke another skill", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/a.md", articleMd("Article A", "a", "Content A.")); + + const callerSkill: SkillDefinition = { + name: "caller", + version: "1.0.0", + description: "Calls another skill", + input: "none", + output: "report", + async run(ctx) { + const result = await ctx.invoke("summarize"); + return { content: `Invoked summarize: ${result.content}` }; + }, + }; + + const provider = mockProvider("Summary result"); + const result = await runSkill(root, callerSkill, { provider }); + + expect(result.content).toContain("Invoked summarize: Summary result"); + }); + + test("invoke throws on unknown skill", async () => { + const root = await makeTempVault(); + + const callerSkill: SkillDefinition = { + name: "caller", + version: "1.0.0", + description: "Calls unknown skill", + input: "none", + output: "report", + async run(ctx) { + return ctx.invoke("nonexistent"); + }, + }; + + expect(runSkill(root, callerSkill)).rejects.toThrow('Skill "nonexistent" not found'); + }); + + test("invoke respects depth limit", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/a.md", articleMd("A", "a", "Content.")); + + // A skill that invokes summarize, which in turn calls the LLM + const wrapperSkill: SkillDefinition = { + name: "wrapper", + version: "1.0.0", + description: "Wraps summarize", + input: "none", + output: "report", + async run(ctx) { + const result = await ctx.invoke("summarize"); + return { content: `wrapped: ${result.content}` }; + }, + }; + + const provider = mockProvider("inner result"); + const result = await runSkill(root, wrapperSkill, { provider, maxDepth: 3 }); + expect(result.content).toContain("wrapped: inner result"); + }); +}); + +// ─── Dependency resolution ────────────────────────────────────── + +describe("skill dependency resolution", () => { + test("resolves skills with no dependencies", () => { + const skill: SkillDefinition = { + name: "standalone", + version: "1.0.0", + description: "No deps", + input: "none", + output: "none", + async run() { + return {}; + }, + }; + + const resolved = resolveSkillDependencies(skill, [skill]); + expect(resolved).toHaveLength(1); + expect(resolved[0].name).toBe("standalone"); + }); + + test("resolves skills in dependency order", () => { + const a: SkillDefinition = { + name: "a", + version: "1.0.0", + description: "Base skill", + input: "none", + output: "none", + async run() { + return {}; + }, + }; + + const b: SkillDefinition = { + name: "b", + version: "1.0.0", + description: "Depends on a", + input: "none", + output: "none", + dependencies: ["a"], + async run() { + return {}; + }, + }; + + const resolved = resolveSkillDependencies(b, [a, b]); + expect(resolved).toHaveLength(2); + expect(resolved[0].name).toBe("a"); + expect(resolved[1].name).toBe("b"); + }); + + test("throws on circular dependencies", () => { + const a: SkillDefinition = { + name: "a", + version: "1.0.0", + description: "Depends on b", + input: "none", + output: "none", + dependencies: ["b"], + async run() { + return {}; + }, + }; + + const b: SkillDefinition = { + name: "b", + version: "1.0.0", + description: "Depends on a", + input: "none", + output: "none", + dependencies: ["a"], + async run() { + return {}; + }, + }; + + expect(() => resolveSkillDependencies(a, [a, b])).toThrow("Circular skill dependency"); + }); + + test("throws on missing dependency", () => { + const skill: SkillDefinition = { + name: "needs-missing", + version: "1.0.0", + description: "Depends on nonexistent", + input: "none", + output: "none", + dependencies: ["nonexistent"], + async run() { + return {}; + }, + }; + + expect(() => resolveSkillDependencies(skill, [skill])).toThrow( + 'depends on "nonexistent", which was not found', + ); + }); + + test("handles deep dependency chains", () => { + const a: SkillDefinition = { + name: "a", + version: "1.0.0", + description: "Base", + input: "none", + output: "none", + async run() { + return {}; + }, + }; + const b: SkillDefinition = { + name: "b", + version: "1.0.0", + description: "Dep on a", + input: "none", + output: "none", + dependencies: ["a"], + async run() { + return {}; + }, + }; + const c: SkillDefinition = { + name: "c", + version: "1.0.0", + description: "Dep on b", + input: "none", + output: "none", + dependencies: ["b"], + async run() { + return {}; + }, + }; + + const resolved = resolveSkillDependencies(c, [a, b, c]); + expect(resolved).toHaveLength(3); + expect(resolved[0].name).toBe("a"); + expect(resolved[1].name).toBe("b"); + expect(resolved[2].name).toBe("c"); + }); +}); + +// ─── Registry tests ───────────────────────────────────────────── + +describe("skill registry", () => { + test("createSkill scaffolds a new skill", async () => { + const root = await makeTempVault(); + const path = await createSkill(root, "my-new-skill", { author: "testuser" }); + + expect(existsSync(path)).toBe(true); + expect(existsSync(join(path, "index.ts"))).toBe(true); + expect(existsSync(join(path, "skill.json"))).toBe(true); + + const pkg = JSON.parse(await readFile(join(path, "skill.json"), "utf-8")); + expect(pkg.name).toBe("my-new-skill"); + expect(pkg.author).toBe("testuser"); + + const indexContent = await readFile(join(path, "index.ts"), "utf-8"); + expect(indexContent).toContain("my-new-skill"); + expect(indexContent).toContain("testuser"); + }); + + test("createSkill throws if skill already exists", async () => { + const root = await makeTempVault(); + await createSkill(root, "existing-skill"); + + expect(createSkill(root, "existing-skill")).rejects.toThrow("already exists"); + }); + + test("created skill can be loaded", async () => { + const root = await makeTempVault(); + await createSkill(root, "loadable-skill"); + + const skills = await loadSkills(root); + const found = skills.find((s) => s.name === "loadable-skill"); + expect(found).toBeDefined(); + expect(found?.description).toContain("TODO"); + }); + + test("uninstallSkill removes a directory-based skill", async () => { + const root = await makeTempVault(); + const path = await createSkill(root, "removable-skill"); + + expect(existsSync(path)).toBe(true); + await uninstallSkill(root, "removable-skill"); + expect(existsSync(path)).toBe(false); + }); + + test("uninstallSkill removes a single-file skill", async () => { + const root = await makeTempVault(); + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + const filePath = join(skillsDir, "single.ts"); + await writeFile( + filePath, + `export default { name: "single", version: "1.0.0", description: "test", input: "none", output: "none", async run() { return {}; } };`, + ); + + expect(existsSync(filePath)).toBe(true); + await uninstallSkill(root, "single"); + expect(existsSync(filePath)).toBe(false); + }); + + test("uninstallSkill throws for non-existent skill", async () => { + const root = await makeTempVault(); + expect(uninstallSkill(root, "ghost")).rejects.toThrow('Skill "ghost" is not installed'); + }); +}); + +// ─── Hooks tests ──────────────────────────────────────────────── + +describe("skill hooks", () => { + test("getHookedSkills returns skills registered for a hook", async () => { + const root = await makeTempVault(); + + // Install a skill with hooks + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + await writeFile( + join(skillsDir, "auto-tag.ts"), + `export default { + name: "auto-tag", + version: "1.0.0", + description: "Auto-tag after compile", + input: "wiki", + output: "report", + hooks: ["post-compile"], + async run() { return { content: "tagged" }; }, + };`, + ); + + const hooked = await getHookedSkills(root, "post-compile"); + expect(hooked).toContain("auto-tag"); + }); + + test("getHookedSkills includes config-based hooks", async () => { + const root = await makeTempVault(); + const { loadConfig } = await import("../vault.js"); + const config = await loadConfig(root); + config.skills = { + hooks: { + "post-compile": ["summarize"], + "post-ingest": [], + "post-lint": [], + }, + config: {}, + }; + + const hooked = await getHookedSkills(root, "post-compile", config); + expect(hooked).toContain("summarize"); + }); + + test("runSkillHooks runs all hooks and collects results", async () => { + const root = await makeTempVault(); + await writeWiki(root, "concepts/a.md", articleMd("A", "a", "Content")); + + const skillsDir = join(root, VAULT_DIR, SKILLS_DIR); + await writeFile( + join(skillsDir, "hook-skill.ts"), + `export default { + name: "hook-skill", + version: "1.0.0", + description: "Runs after compile", + input: "none", + output: "stdout", + hooks: ["post-compile"], + async run() { return { content: "hook ran!" }; }, + };`, + ); + + const results = await runSkillHooks(root, "post-compile"); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some((r) => r.skill === "hook-skill" && r.content === "hook ran!")).toBe(true); + }); + + test("runSkillHooks reports errors without throwing", async () => { + const root = await makeTempVault(); + + const { loadConfig } = await import("../vault.js"); + const config = await loadConfig(root); + config.skills = { + hooks: { + "post-compile": ["nonexistent-skill"], + "post-ingest": [], + "post-lint": [], + }, + config: {}, + }; + + const results = await runSkillHooks(root, "post-compile", { config }); + expect(results.some((r) => r.skill === "nonexistent-skill" && r.error)).toBe(true); + }); +}); + +// ─── Builtins tests ───────────────────────────────────────────── + +describe("built-in skills", () => { + test("all built-in skills have valid definitions", () => { + const builtins = getBuiltinSkills(); + + for (const skill of builtins) { + expect(skill.name).toBeTruthy(); + expect(skill.version).toBeTruthy(); + expect(skill.description).toBeTruthy(); + expect(typeof skill.run).toBe("function"); + } + }); + + test("built-in skills have unique names", () => { + const builtins = getBuiltinSkills(); + const names = builtins.map((s) => s.name); + const unique = new Set(names); + expect(unique.size).toBe(names.length); + }); + + test("all LLM-requiring skills have systemPrompt", () => { + const builtins = getBuiltinSkills(); + + for (const skill of builtins) { + if (skill.llm?.required) { + expect(skill.llm.systemPrompt).toBeTruthy(); + expect(skill.llm.systemPrompt.length).toBeGreaterThan(10); + } + } + }); + + test("returns 10 built-in skills", () => { + const builtins = getBuiltinSkills(); + expect(builtins).toHaveLength(10); + }); +}); + +// ─── Schema tests ─────────────────────────────────────────────── + +describe("skill schemas", () => { + test("SkillDefinitionSchema validates new fields", async () => { + const { SkillDefinitionSchema } = await import("./schema.js"); + + const result = SkillDefinitionSchema.safeParse({ + name: "test-skill", + description: "A test", + input: "wiki", + output: "report", + dependencies: ["summarize"], + hooks: ["post-compile"], + category: "outputs", + }); + + expect(result.success).toBe(true); + }); + + test("SkillDefinitionSchema rejects invalid hooks", async () => { + const { SkillDefinitionSchema } = await import("./schema.js"); + + const result = SkillDefinitionSchema.safeParse({ + name: "test", + description: "test", + input: "none", + output: "none", + hooks: ["invalid-hook"], + }); + + expect(result.success).toBe(false); + }); + + test("SkillPackageSchema validates skill.json", async () => { + const { SkillPackageSchema } = await import("./schema.js"); + + const result = SkillPackageSchema.safeParse({ + name: "my-skill", + version: "1.0.0", + description: "A cool skill", + author: "test", + main: "index.ts", + dependencies: ["other-skill"], + }); + + expect(result.success).toBe(true); + }); + + test("SkillConfigSchema validates config section", async () => { + const { SkillConfigSchema } = await import("./schema.js"); + + const result = SkillConfigSchema.safeParse({ + hooks: { + "post-compile": ["suggest-tags"], + "post-ingest": [], + "post-lint": [], + }, + config: { + "suggest-tags": { maxTags: 5 }, + }, + }); + + expect(result.success).toBe(true); + }); }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4d0aad1..2a5ea80 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -104,6 +104,9 @@ export interface SkillContext { error(msg: string): void; }; + /** Invoke another skill by name (skill-to-skill invocation) */ + invoke(skillName: string, args?: Record): Promise<{ content?: string }>; + args: Record; } @@ -116,6 +119,15 @@ export interface SkillDefinition { input: "wiki" | "raw" | "vault" | "selection" | "index" | "none"; output: "articles" | "report" | "mutations" | "stdout" | "none"; + /** Skill names this skill depends on (resolved before run) */ + dependencies?: string[]; + + /** Lifecycle hooks — run this skill automatically after these events */ + hooks?: ("post-compile" | "post-ingest" | "post-lint")[]; + + /** Target wiki category for skill output (e.g. "outputs") */ + category?: string; + llm?: { required: boolean; model: "default" | "fast";