diff --git a/.changeset/open-loop-domain-preset.md b/.changeset/open-loop-domain-preset.md new file mode 100644 index 0000000..04aaad8 --- /dev/null +++ b/.changeset/open-loop-domain-preset.md @@ -0,0 +1,14 @@ +--- +'@gemstack/ai-autopilot': minor +--- + +Add the Open Loop bundle unit: a domain preset = {loops, prompts, skills} (#242). + +This is the keystone that ties the three data types the framework already ships +separately into one selectable, composable thing. Author one in code with +`defineDomainPreset`, or load one from a directory of `.md` files (`preset.md` + +`loops/`, `prompts/`, `skills/`) with `loadDomainPreset`. `composeDomainPresets` +merges several into one (loops concatenate; prompts and skills merge by id/name, +later wins), so presets-of-presets falls out; `selectPreset` picks the user's +domain by name. Kept distinct from the framework `Preset` detector in `presets/` +(skipped for the Open Loop MVP) by naming this `DomainPreset`. diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 157c772..eafde8d 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -94,6 +94,15 @@ * - {@link detectFramework} — score a project's deps/files against presets * - {@link vikePreset} / {@link nextPreset} — the built-ins * - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones + * + * Domain presets are the Open Loop bundle unit (#204): one selectable, composable + * bundle of {loops, prompts, skills}. Author in code or load a directory of `.md` + * files; compose several into one (presets-of-presets). Distinct from the + * framework `Preset` above — this is the user-picked domain, not a detector. + * + * - {@link defineDomainPreset} / {@link loadDomainPreset} — author, or load from a directory + * - {@link composeDomainPresets} — merge presets into one (later wins on prompt/skill id) + * - {@link selectPreset} — pick the user's chosen domain by name */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' @@ -319,6 +328,18 @@ export { type PresetScore, type FrameworkDetection, } from './presets/index.js' +export { + defineDomainPreset, + DomainPresetError, + composeDomainPresets, + selectPreset, + loadDomainPreset, + loadLoopsFrom, + loadSkillsFrom, + type DomainPreset, + type DomainPresetSpec, + type DomainPresetMeta, +} from './preset/index.js' export { defineFrameworkExtension, defineSkill, diff --git a/packages/ai-autopilot/src/preset/compose.test.ts b/packages/ai-autopilot/src/preset/compose.test.ts new file mode 100644 index 0000000..08f5225 --- /dev/null +++ b/packages/ai-autopilot/src/preset/compose.test.ts @@ -0,0 +1,57 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { composeDomainPresets, selectPreset } from './compose.js' +import { defineDomainPreset } from './define.js' +import { defineLoop } from '../loop/define.js' +import { defineSkill } from '../extensions/define.js' + +const prompt = (id: string, instructions = id) => ({ id, name: id, title: id, description: '', instructions, passes: 1, appliesTo: [] }) + +const base = defineDomainPreset({ + name: 'base', + loops: [defineLoop({ on: 'major-change', run: ['review'] })], + prompts: [prompt('review', 'base-review'), prompt('security')], + skills: [defineSkill({ name: 'vike', title: 'Vike', description: 'v', url: 'https://vike.dev/llms.txt' })], +}) + +const overlay = defineDomainPreset({ + name: 'overlay', + loops: [defineLoop({ on: 'ui-flow', run: ['qa'] })], + prompts: [prompt('review', 'overlay-review'), prompt('qa')], + skills: [defineSkill({ name: 'vike', title: 'Vike v2', description: 'v2', url: 'https://vike.dev/llms.txt' })], +}) + +describe('composeDomainPresets', () => { + it('concatenates loops and merges prompts/skills with later-wins', () => { + const merged = composeDomainPresets({ name: 'combined', title: 'Combined' }, base, overlay) + + assert.equal(merged.name, 'combined') + assert.equal(merged.title, 'Combined') + + // loops concatenate, in preset order + assert.equal(merged.loops.length, 2) + assert.deepEqual(merged.loops.map(l => [...l.on]), [['major-change'], ['ui-flow']]) + + // prompts merge by id, later preset wins, sorted by id + assert.deepEqual(merged.prompts.map(p => p.id), ['qa', 'review', 'security']) + assert.equal(merged.prompts.find(p => p.id === 'review')!.instructions, 'overlay-review') + + // skills merge by name, later preset wins + assert.equal(merged.skills.length, 1) + assert.equal(merged.skills[0]!.title, 'Vike v2') + }) + + it('composing is presets-of-presets: the result is itself a preset', () => { + const parent = composeDomainPresets({ name: 'parent' }, base, overlay) + const grand = composeDomainPresets({ name: 'grand' }, parent) + assert.equal(grand.prompts.length, parent.prompts.length) + assert.equal(grand.name, 'grand') + }) +}) + +describe('selectPreset', () => { + it('picks by name or returns undefined', () => { + assert.equal(selectPreset([base, overlay], 'overlay'), overlay) + assert.equal(selectPreset([base, overlay], 'missing'), undefined) + }) +}) diff --git a/packages/ai-autopilot/src/preset/compose.ts b/packages/ai-autopilot/src/preset/compose.ts new file mode 100644 index 0000000..60bc7c3 --- /dev/null +++ b/packages/ai-autopilot/src/preset/compose.ts @@ -0,0 +1,39 @@ +import { defineDomainPreset } from './define.js' +import type { DomainPreset, DomainPresetMeta } from './types.js' +import type { Prompt } from '../prompts/types.js' +import type { Skill } from '../extensions/types.js' + +/** + * Compose several {@link DomainPreset}s into one under a new label — this is what + * makes presets-of-presets fall out: a parent preset is just the composition of + * the children it selects. + * + * Merge rules: + * - **loops** concatenate in preset order (the loop engine already de-dupes the + * prompt ids a chain resolves to, so overlapping loops are harmless). + * - **prompts** merge by `id` and **skills** by `name`, later presets winning, so + * a preset later in the list overrides a shared body or doc pointer. Both come + * out sorted by their key for a stable result. + */ +export function composeDomainPresets(meta: DomainPresetMeta, ...presets: DomainPreset[]): DomainPreset { + const prompts = new Map() + const skills = new Map() + const loops = presets.flatMap(p => [...p.loops]) + + for (const preset of presets) { + for (const prompt of preset.prompts) prompts.set(prompt.id, prompt) + for (const skill of preset.skills) skills.set(skill.name, skill) + } + + return defineDomainPreset({ + ...meta, + loops, + prompts: [...prompts.values()].sort((a, b) => a.id.localeCompare(b.id)), + skills: [...skills.values()].sort((a, b) => a.name.localeCompare(b.name)), + }) +} + +/** Pick the preset with `name` from a set (e.g. the user's chosen domain), or `undefined`. */ +export function selectPreset(presets: readonly DomainPreset[], name: string): DomainPreset | undefined { + return presets.find(p => p.name === name) +} diff --git a/packages/ai-autopilot/src/preset/define.test.ts b/packages/ai-autopilot/src/preset/define.test.ts new file mode 100644 index 0000000..d32f6f0 --- /dev/null +++ b/packages/ai-autopilot/src/preset/define.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { defineDomainPreset, DomainPresetError } from './define.js' +import { defineLoop } from '../loop/define.js' +import { defineSkill } from '../extensions/define.js' + +const aLoop = defineLoop({ on: 'major-change', run: ['review'] }) +const aSkill = defineSkill({ name: 'vike', title: 'Vike', description: 'Vike knowledge', url: 'https://vike.dev/llms.txt' }) +const aPrompt = { id: 'review', name: 'review', title: 'Review', description: '', instructions: 'do', passes: 1, appliesTo: [] } + +describe('defineDomainPreset', () => { + it('freezes the bundle and defaults title/description/lists', () => { + const preset = defineDomainPreset({ name: 'software-development' }) + assert.equal(preset.name, 'software-development') + assert.equal(preset.title, 'software-development') // title defaults to name + assert.equal(preset.description, '') + assert.deepEqual(preset.loops, []) + assert.deepEqual(preset.prompts, []) + assert.deepEqual(preset.skills, []) + assert.ok(Object.isFrozen(preset)) + assert.throws(() => ((preset as { name: string }).name = 'x')) + }) + + it('carries the three content types', () => { + const preset = defineDomainPreset({ + name: 'sw-dev', + title: 'Software Development', + description: 'General engineering.', + loops: [aLoop], + prompts: [aPrompt], + skills: [aSkill], + }) + assert.equal(preset.title, 'Software Development') + assert.deepEqual(preset.loops, [aLoop]) + assert.deepEqual(preset.prompts, [aPrompt]) + assert.deepEqual(preset.skills, [aSkill]) + }) + + it('rejects a missing or non-kebab name', () => { + assert.throws(() => defineDomainPreset({ name: '' }), DomainPresetError) + assert.throws(() => defineDomainPreset({ name: 'Software Dev' }), DomainPresetError) + }) +}) diff --git a/packages/ai-autopilot/src/preset/define.ts b/packages/ai-autopilot/src/preset/define.ts new file mode 100644 index 0000000..812208b --- /dev/null +++ b/packages/ai-autopilot/src/preset/define.ts @@ -0,0 +1,31 @@ +import type { DomainPreset, DomainPresetSpec } from './types.js' + +/** Thrown when a domain preset spec is malformed. Fails fast at definition time. */ +export class DomainPresetError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'DomainPresetError' + } +} + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ + +/** + * Validate a {@link DomainPresetSpec} and return a frozen {@link DomainPreset}. + * `title` defaults to `name`, `description` to empty, and the three content lists + * to empty so callers never null-check them. + */ +export function defineDomainPreset(spec: DomainPresetSpec): DomainPreset { + const name = spec.name?.trim() + if (!name) throw new DomainPresetError('preset name is required') + if (!KEBAB.test(name)) throw new DomainPresetError(`preset name must be kebab-case: ${JSON.stringify(spec.name)}`) + + return Object.freeze({ + name, + title: spec.title?.trim() || name, + description: spec.description?.trim() ?? '', + loops: Object.freeze([...(spec.loops ?? [])]), + prompts: Object.freeze([...(spec.prompts ?? [])]), + skills: Object.freeze([...(spec.skills ?? [])]), + }) +} diff --git a/packages/ai-autopilot/src/preset/index.ts b/packages/ai-autopilot/src/preset/index.ts new file mode 100644 index 0000000..e07aa1f --- /dev/null +++ b/packages/ai-autopilot/src/preset/index.ts @@ -0,0 +1,13 @@ +/** + * The Open Loop bundle unit (#204, #242): a **domain preset** = {loops, prompts, + * skills}. Author one in code with {@link defineDomainPreset}, load one from a + * directory of `.md` files with {@link loadDomainPreset}, and merge several into + * one with {@link composeDomainPresets} (so presets-of-presets falls out). + * + * Distinct from the framework `Preset` in `presets/` (a project detector); this + * is the user-picked domain bundle. + */ +export { defineDomainPreset, DomainPresetError } from './define.js' +export { composeDomainPresets, selectPreset } from './compose.js' +export { loadDomainPreset, loadLoopsFrom, loadSkillsFrom } from './load.js' +export type { DomainPreset, DomainPresetSpec, DomainPresetMeta } from './types.js' diff --git a/packages/ai-autopilot/src/preset/load.test.ts b/packages/ai-autopilot/src/preset/load.test.ts new file mode 100644 index 0000000..08ba780 --- /dev/null +++ b/packages/ai-autopilot/src/preset/load.test.ts @@ -0,0 +1,76 @@ +import { describe, it, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { loadDomainPreset, loadLoopsFrom, loadSkillsFrom } from './load.js' +import { DomainPresetError } from './define.js' + +async function writeFixture(root: string) { + await writeFile( + join(root, 'preset.md'), + `---\nname: software-development\ndescription: General software engineering.\nmetadata:\n title: Software Development\n---\nA domain preset for building software.\n`, + ) + await mkdir(join(root, 'loops')) + await writeFile( + join(root, 'loops', 'major-change.md'), + `---\nname: major-change\ndescription: What fires on a major change.\nmetadata:\n on: major-change\n run: [review, security]\n---\nIf the change is substantial, review it then check security.\n`, + ) + await mkdir(join(root, 'prompts')) + await writeFile( + join(root, 'prompts', 'review.md'), + `---\nname: review\ndescription: Review a change.\nmetadata:\n loopId: review\n---\nReview the change for correctness.\n`, + ) + await mkdir(join(root, 'skills')) + await writeFile( + join(root, 'skills', 'vike.md'), + `---\nname: vike\ndescription: Vike page/routing knowledge.\nmetadata:\n title: Vike\n url: https://vike.dev/llms.txt\n---\n`, + ) +} + +describe('loadDomainPreset', () => { + let dir: string + before(async () => { + dir = await mkdtemp(join(tmpdir(), 'domain-preset-')) + await writeFixture(dir) + }) + after(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('loads {loops, prompts, skills} from a directory of .md files', async () => { + const preset = await loadDomainPreset(dir) + assert.equal(preset.name, 'software-development') + assert.equal(preset.title, 'Software Development') + assert.equal(preset.description, 'General software engineering.') + + assert.equal(preset.loops.length, 1) + assert.deepEqual([...preset.loops[0]!.on], ['major-change']) + assert.deepEqual([...preset.loops[0]!.run], ['review', 'security']) + + assert.equal(preset.prompts.length, 1) + assert.equal(preset.prompts[0]!.id, 'review') + assert.match(preset.prompts[0]!.instructions, /correctness/) + + assert.equal(preset.skills.length, 1) + assert.equal(preset.skills[0]!.name, 'vike') + assert.equal(preset.skills[0]!.url, 'https://vike.dev/llms.txt') + assert.equal(preset.skills[0]!.title, 'Vike') + }) + + it('throws when preset.md is missing', async () => { + const empty = await mkdtemp(join(tmpdir(), 'domain-preset-empty-')) + try { + await assert.rejects(loadDomainPreset(empty), DomainPresetError) + } finally { + await rm(empty, { recursive: true, force: true }) + } + }) +}) + +describe('loadLoopsFrom / loadSkillsFrom', () => { + it('return [] for a directory that does not exist', async () => { + assert.deepEqual(await loadLoopsFrom(join(tmpdir(), 'no-such-loops-dir-xyz')), []) + assert.deepEqual(await loadSkillsFrom(join(tmpdir(), 'no-such-skills-dir-xyz')), []) + }) +}) diff --git a/packages/ai-autopilot/src/preset/load.ts b/packages/ai-autopilot/src/preset/load.ts new file mode 100644 index 0000000..3e06ae7 --- /dev/null +++ b/packages/ai-autopilot/src/preset/load.ts @@ -0,0 +1,111 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { parseSkillManifest } from '@gemstack/ai-skills' +import { defineLoop } from '../loop/define.js' +import { defineSkill } from '../extensions/define.js' +import { loadPromptsFrom } from '../prompts/library.js' +import type { Loop } from '../loop/types.js' +import type { Skill } from '../extensions/types.js' +import { defineDomainPreset, DomainPresetError } from './define.js' +import type { DomainPreset } from './types.js' + +/** + * Load a {@link DomainPreset} from a directory of `.md` files — the no-code, + * marketplace-shippable form. Layout: + * + * ```text + * / + * preset.md # required: name + description (frontmatter), metadata.title + * loops/*.md # metadata.on (kind or kinds) + metadata.run (prompt ids) + * prompts/*.md # prompt bodies (the existing prompt bundle format) + * skills/*.md # metadata.url (llms.txt) + metadata.title; body ignored + * ``` + * + * The three content subdirectories are all optional — a missing one yields an + * empty list. The preset's identity comes from `preset.md`. + */ +export async function loadDomainPreset(dir: string): Promise { + const manifestPath = join(dir, 'preset.md') + let raw: string + try { + raw = await readFile(manifestPath, 'utf8') + } catch { + throw new DomainPresetError(`preset directory ${JSON.stringify(dir)} has no preset.md manifest`) + } + const { manifest } = parseSkillManifest(raw, manifestPath) + const title = str(manifest.metadata, 'title') + + const [loops, prompts, skills] = await Promise.all([ + loadLoopsFrom(join(dir, 'loops')), + loadPromptsIn(join(dir, 'prompts')), + loadSkillsFrom(join(dir, 'skills')), + ]) + + return defineDomainPreset({ + name: manifest.name, + ...(title ? { title } : {}), + description: manifest.description, + loops, + prompts, + skills, + }) +} + +/** Load every `*.md` loop file in a directory (a missing directory yields `[]`). */ +export async function loadLoopsFrom(dir: string): Promise { + const files = await mdFiles(dir) + return Promise.all( + files.map(async f => { + const path = join(dir, f) + const { manifest } = parseSkillManifest(await readFile(path, 'utf8'), path) + const meta = manifest.metadata ?? {} + const on = meta.on + const run = meta.run + if (on === undefined) throw new DomainPresetError(`loop ${JSON.stringify(path)} is missing metadata.on`) + if (!Array.isArray(run)) throw new DomainPresetError(`loop ${JSON.stringify(path)} needs metadata.run to be a list`) + return defineLoop({ on: on as string | string[], run: run as string[] }) + }), + ) +} + +/** Load every `*.md` skill pointer in a directory (a missing directory yields `[]`). */ +export async function loadSkillsFrom(dir: string): Promise { + const files = await mdFiles(dir) + return Promise.all( + files.map(async f => { + const path = join(dir, f) + const { manifest } = parseSkillManifest(await readFile(path, 'utf8'), path) + const meta = manifest.metadata ?? {} + const url = str(meta, 'url') + if (!url) throw new DomainPresetError(`skill ${JSON.stringify(path)} is missing metadata.url (its llms.txt pointer)`) + return defineSkill({ + name: manifest.name, + title: str(meta, 'title') ?? manifest.name, + description: manifest.description, + url, + }) + }), + ) +} + +// ─── Internals ─────────────────────────────────────────────────── + +/** `*.md` filenames in a directory, sorted; `[]` when the directory does not exist. */ +async function mdFiles(dir: string): Promise { + try { + return (await readdir(dir)).filter(f => f.endsWith('.md')).sort() + } catch { + return [] + } +} + +/** Prompts subdir via the existing loader; a missing/empty dir yields `[]` while real parse errors still surface. */ +async function loadPromptsIn(dir: string) { + const files = await mdFiles(dir) + return files.length ? loadPromptsFrom(dir) : [] +} + +function str(meta: Record | undefined, key: string): string | undefined { + const v = meta?.[key] + return typeof v === 'string' && v.trim() ? v.trim() : undefined +} diff --git a/packages/ai-autopilot/src/preset/types.ts b/packages/ai-autopilot/src/preset/types.ts new file mode 100644 index 0000000..cb3ba1e --- /dev/null +++ b/packages/ai-autopilot/src/preset/types.ts @@ -0,0 +1,55 @@ +import type { Loop } from '../loop/types.js' +import type { Prompt } from '../prompts/types.js' +import type { Skill } from '../extensions/types.js' + +/** + * The Open Loop bundle unit (#204, #242): a **domain preset** ties together the + * three data types the framework already ships separately into one selectable, + * composable thing. + * + * - **loops** ({@link Loop}) — the meta prompts: which prompt chains fire for + * which change kinds. + * - **prompts** ({@link Prompt}) — the prompt bodies (frontmatter + markdown) the + * loops dispatch by id. + * - **skills** ({@link Skill}) — the framing knowledge: an `llms.txt` pointer plus + * any curated personas. + * + * A preset is authored in code with {@link defineDomainPreset} or loaded from a + * directory of `.md` files with {@link loadDomainPreset}. Presets compose + * ({@link composeDomainPresets}), so presets-of-presets falls out for free. + * + * This is deliberately distinct from the framework `Preset` in `presets/` (a + * project *detector* that points at a framework skill) — that one is skipped for + * the Open Loop MVP; this is the user-picked domain bundle. + */ +export interface DomainPreset { + /** Stable kebab-case id (e.g. `software-development`). */ + readonly name: string + /** Human title for display (e.g. `Software Development`). */ + readonly title: string + /** One-line summary of the domain this preset covers. */ + readonly description: string + /** The meta prompts: event-kind to prompt-chain mappings. */ + readonly loops: readonly Loop[] + /** The prompt bodies the loops dispatch by id. */ + readonly prompts: readonly Prompt[] + /** The framing knowledge (llms.txt pointers + personas). */ + readonly skills: readonly Skill[] +} + +/** Author-facing shape for {@link defineDomainPreset}; every content field defaults to empty, `title` to `name`. */ +export interface DomainPresetSpec { + name: string + title?: string + description?: string + loops?: readonly Loop[] + prompts?: readonly Prompt[] + skills?: readonly Skill[] +} + +/** Identity fields for a composed preset — the merge of its parts is derived, only the label is new. */ +export interface DomainPresetMeta { + name: string + title?: string + description?: string +}