From 603edf327d48a501b92bd447a4483fa613deab92 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Wed, 1 Jul 2026 00:19:34 +0300 Subject: [PATCH 1/2] ci: build docs on PRs to catch dead links vitepress build fails on dead internal links (the docs config ignores only localhost links), so a docs job on every PR doubles as a link check. Closes #59 --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a32f3e8..8bf2137 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,3 +19,17 @@ jobs: - run: pnpm build - run: pnpm typecheck - run: pnpm test + + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + # vitepress build fails on dead internal links (config ignores only + # localhost), so this doubles as a link check on every PR. + - run: pnpm --filter @gemstack/docs docs:build From 60ad4cf3f3c825942983f7133097da84dc18a323 Mon Sep 17 00:00:00 2001 From: Suleiman Shahbari Date: Thu, 2 Jul 2026 02:37:46 +0300 Subject: [PATCH 2/2] feat(ai-autopilot): personas + prompts library (stack-aware knowledge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the persona layer that makes autopilot opinionated about the GemStack stack (Vike + universal-orm) instead of generic: - definePersona() — a validated, frozen Persona (systemPrompt + skills + tools) - personaAgent()/personaWorkers() — materialize personas into Supervisor workers - personaRoster() — describe personas to a planner so plans route by role - Built-ins: vikePageBuilder, universalOrmModeler, uiIntentDesigner (the declare-intent/decouple-implementation UI guardrail) Skills compose over @gemstack/ai-skills. First child (#98) of epic #97. --- .changeset/ai-autopilot-personas.md | 5 + packages/ai-autopilot/README.md | 30 +++++ packages/ai-autopilot/package.json | 1 + packages/ai-autopilot/src/index.ts | 24 ++++ .../ai-autopilot/src/personas/compose.test.ts | 116 ++++++++++++++++++ packages/ai-autopilot/src/personas/compose.ts | 79 ++++++++++++ .../ai-autopilot/src/personas/define.test.ts | 43 +++++++ packages/ai-autopilot/src/personas/define.ts | 36 ++++++ packages/ai-autopilot/src/personas/index.ts | 25 ++++ packages/ai-autopilot/src/personas/library.ts | 96 +++++++++++++++ packages/ai-autopilot/src/personas/types.ts | 40 ++++++ pnpm-lock.yaml | 3 + 12 files changed, 498 insertions(+) create mode 100644 .changeset/ai-autopilot-personas.md create mode 100644 packages/ai-autopilot/src/personas/compose.test.ts create mode 100644 packages/ai-autopilot/src/personas/compose.ts create mode 100644 packages/ai-autopilot/src/personas/define.test.ts create mode 100644 packages/ai-autopilot/src/personas/define.ts create mode 100644 packages/ai-autopilot/src/personas/index.ts create mode 100644 packages/ai-autopilot/src/personas/library.ts create mode 100644 packages/ai-autopilot/src/personas/types.ts diff --git a/.changeset/ai-autopilot-personas.md b/.changeset/ai-autopilot-personas.md new file mode 100644 index 0000000..7ee7349 --- /dev/null +++ b/.changeset/ai-autopilot-personas.md @@ -0,0 +1,5 @@ +--- +"@gemstack/ai-autopilot": minor +--- + +Add the personas layer: stack-aware roles that make autopilot opinionated about the GemStack stack (Vike + universal-orm) instead of generic. `definePersona()` builds a role from a system prompt + skills (composed over `@gemstack/ai-skills`) + tools; `personaAgent()`/`personaWorkers()` materialize personas into Supervisor workers; `personaRoster()` describes them to a planner so plans route to the right role. Ships three built-ins: `vikePageBuilder`, `universalOrmModeler`, and `uiIntentDesigner` (the "declare intent, decouple implementation" UI guardrail). First child (#98) of the ai-autopilot end-to-end epic (#97). diff --git a/packages/ai-autopilot/README.md b/packages/ai-autopilot/README.md index 7bc193e..81ea8ae 100644 --- a/packages/ai-autopilot/README.md +++ b/packages/ai-autopilot/README.md @@ -40,6 +40,36 @@ Each stage is a plain function, so you mix LLM and deterministic logic freely: - **`workers`** — a single `Agent` (all subtasks), a `Record` (routed by `subtask.worker`), or a `WorkerRouter` function. - **`synthesize`** — a `Synthesizer`: `(task, results) => string`. Defaults to `defaultSynthesize` (concatenate successes, no LLM call); pass `agentSynthesizer(agent)` for an LLM pass. +## Personas — the stack-aware layer + +The Supervisor is stack-agnostic. **Personas** add the opinionated knowledge that +makes autopilot know the GemStack stack (Vike + universal-orm) instead of +guessing. A persona is *data*: a name, a one-line role, a system-prompt fragment, +and the skills/tools it brings (composed over [`@gemstack/ai-skills`](https://github.com/gemstack-land/gemstack/tree/main/packages/ai-skills)). + +Three are built in: `vikePageBuilder` (Vike `+` file conventions, renderer-agnostic), +`universalOrmModeler` (schema-first data, derived migrations), and +`uiIntentDesigner` — the "declare intent, decouple implementation" guardrail that +expresses UI as intent so an AI can't hardcode the wrong markup. + +```ts +import { Supervisor, agentPlanner } from '@gemstack/ai-autopilot' +import { stackPersonas, personaWorkers, personaRoster } from '@gemstack/ai-autopilot' + +const supervisor = new Supervisor({ + // Tell the planner which personas exist so it tags each subtask's `worker`. + plan: agentPlanner(agent(`Decompose the task.\n\n${personaRoster(stackPersonas)}`)), + // Materialize the personas into a worker pool keyed by name. + workers: personaWorkers(stackPersonas, { model: 'anthropic/claude-sonnet-4-5' }), +}) + +await supervisor.run('Add a paginated orders page backed by an orders table') +``` + +Define your own with `definePersona({ name, role, systemPrompt, skills?, tools? })`, +or materialize a single persona into an agent with `personaAgent(persona)`. Because +a persona is data, it can be inspected and listed without building an agent first. + ## Guardrails - **`concurrency`** (optional, default 4) — max workers in flight; positive integer. diff --git a/packages/ai-autopilot/package.json b/packages/ai-autopilot/package.json index 521902e..3fd262c 100644 --- a/packages/ai-autopilot/package.json +++ b/packages/ai-autopilot/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@gemstack/ai-sdk": "workspace:^", + "@gemstack/ai-skills": "workspace:^", "zod": "^4.0.0" }, "devDependencies": { diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 5d76f63..7bda472 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -11,10 +11,34 @@ * - {@link Supervisor} — the plan → dispatch → synthesize orchestrator * - {@link agentPlanner} — turn a planning agent into a {@link Planner} * - {@link agentSynthesizer} / {@link defaultSynthesize} — combine results + * + * Personas add the stack-aware knowledge layer: reusable roles that know the + * GemStack stack (Vike + universal-orm), materialized into worker agents. + * + * - {@link definePersona} — define a stack-aware role + * - {@link personaAgent} / {@link personaWorkers} — materialize personas for a run + * - {@link personaRoster} — describe personas to a planner + * - {@link stackPersonas} — the built-in Vike + universal-orm personas */ export { Supervisor } from './supervisor.js' export { agentPlanner, type AgentPlannerOptions } from './planner.js' export { agentSynthesizer, defaultSynthesize } from './synthesizer.js' +export { + definePersona, + PersonaError, + personaInstructions, + personaTools, + personaAgent, + personaWorkers, + personaRoster, + vikePageBuilder, + universalOrmModeler, + uiIntentDesigner, + stackPersonas, + type Persona, + type PersonaSpec, + type PersonaAgentOptions, +} from './personas/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/ai-autopilot/src/personas/compose.test.ts b/packages/ai-autopilot/src/personas/compose.test.ts new file mode 100644 index 0000000..78abc43 --- /dev/null +++ b/packages/ai-autopilot/src/personas/compose.test.ts @@ -0,0 +1,116 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { z } from 'zod' +import { dynamicTool, type AnyTool } from '@gemstack/ai-sdk' +import type { LoadedSkill } from '@gemstack/ai-skills' +import { definePersona } from './define.js' +import { + personaInstructions, + personaTools, + personaAgent, + personaWorkers, + personaRoster, +} from './compose.js' +import { stackPersonas } from './library.js' + +function fakeTool(name: string): AnyTool { + return dynamicTool({ name, description: name, inputSchema: z.object({}) }).server( + async () => 'ok', + ) as unknown as AnyTool +} + +function fakeSkill(name: string, instructions: string, tools: AnyTool[] = []): LoadedSkill { + return { + manifest: { name, description: name }, + instructions, + tools, + resources: [], + } +} + +describe('personaInstructions', () => { + it('puts the systemPrompt first, then skill bodies under headers', () => { + const p = definePersona({ + name: 'p', + role: 'r', + systemPrompt: 'BASE IDENTITY', + skills: [fakeSkill('refunds', 'HOW TO REFUND')], + }) + const out = personaInstructions(p) + assert.ok(out.startsWith('BASE IDENTITY'), 'systemPrompt leads') + assert.match(out, /# Skill: refunds/) + assert.match(out, /HOW TO REFUND/) + }) + + it('is just the systemPrompt when there are no skills', () => { + const p = definePersona({ name: 'p', role: 'r', systemPrompt: 'ONLY THIS' }) + assert.equal(personaInstructions(p), 'ONLY THIS') + }) +}) + +describe('personaTools', () => { + it('unions own tools with skill tools; own tools win a name collision', () => { + const p = definePersona({ + name: 'orders', + role: 'r', + systemPrompt: 's', + tools: [fakeTool('lookup')], + skills: [fakeSkill('refunds', '', [fakeTool('lookup'), fakeTool('refund')])], + }) + const names = personaTools(p).map(t => t.definition.name) + assert.deepEqual(names.slice(0, 1), ['lookup']) // own tool first, authoritative + assert.ok(names.includes('refund')) + // colliding skill tool is namespaced, not dropped + assert.ok(names.some(n => n.includes('lookup') && n !== 'lookup')) + }) +}) + +describe('personaAgent', () => { + it('materializes an agent carrying the composed instructions and tools', () => { + const p = definePersona({ + name: 'p', + role: 'r', + systemPrompt: 'BASE', + tools: [fakeTool('go')], + skills: [fakeSkill('s', 'SKILL BODY')], + }) + const a = personaAgent(p, { model: 'anthropic/claude-sonnet-4-5' }) as unknown as { + instructions(): string + tools(): AnyTool[] + model(): string | undefined + } + assert.match(a.instructions(), /BASE/) + assert.match(a.instructions(), /SKILL BODY/) + assert.deepEqual(a.tools().map(t => t.definition.name), ['go']) + assert.equal(a.model(), 'anthropic/claude-sonnet-4-5') + }) +}) + +describe('personaWorkers', () => { + it('keys agents by persona name', () => { + const workers = personaWorkers(stackPersonas) + assert.deepEqual( + Object.keys(workers).sort(), + ['ui-intent-designer', 'universal-orm-modeler', 'vike-page-builder'], + ) + }) + + it('throws on a duplicate persona name', () => { + const p = definePersona({ name: 'dup', role: 'r', systemPrompt: 's' }) + assert.throws(() => personaWorkers([p, p]), /duplicate persona name/) + }) +}) + +describe('personaRoster', () => { + it('lists each persona name + role for a planner to route on', () => { + const roster = personaRoster(stackPersonas) + assert.match(roster, /`vike-page-builder`/) + assert.match(roster, /`universal-orm-modeler`/) + assert.match(roster, /`ui-intent-designer`/) + assert.match(roster, /worker/) + }) + + it('handles an empty list', () => { + assert.match(personaRoster([]), /No personas/) + }) +}) diff --git a/packages/ai-autopilot/src/personas/compose.ts b/packages/ai-autopilot/src/personas/compose.ts new file mode 100644 index 0000000..1784343 --- /dev/null +++ b/packages/ai-autopilot/src/personas/compose.ts @@ -0,0 +1,79 @@ +import { agent } from '@gemstack/ai-sdk' +import type { Agent, AnyTool } from '@gemstack/ai-sdk' +import { composeInstructions, composeTools, composeMiddleware } from '@gemstack/ai-skills' +import type { Persona } from './types.js' + +/** + * The full instructions a persona contributes: its `systemPrompt` first + * (authoritative), then each skill's body under a `# Skill:` header. Reuses the + * `@gemstack/ai-skills` composition so a persona layers skills exactly like a + * `SkillfulAgent` does. + */ +export function personaInstructions(persona: Persona): string { + return composeInstructions(persona.systemPrompt, [...persona.skills]) +} + +/** + * The persona's own tools unioned with its skills' tools. The persona's own + * tools win a name collision; a colliding skill tool is namespaced, never + * dropped (see `composeTools`). + */ +export function personaTools(persona: Persona): AnyTool[] { + return composeTools([...persona.tools], [...persona.skills]) +} + +/** Options for materializing a persona into a runnable agent. */ +export interface PersonaAgentOptions { + /** Model string (e.g. `anthropic/claude-sonnet-4-5`). Omit to use the agent default. */ + model?: string +} + +/** + * Materialize a persona into a runnable `ai-sdk` {@link Agent}: its composed + * instructions, tools, and any skill middleware. Kept separate from the persona + * data so the same persona can be inspected or listed without building an agent. + */ +export function personaAgent(persona: Persona, opts: PersonaAgentOptions = {}): Agent { + return agent({ + instructions: personaInstructions(persona), + tools: personaTools(persona), + middleware: composeMiddleware([], [...persona.skills]), + model: opts.model, + }) +} + +/** + * Build a `Record` keyed by persona name, ready to drop into + * `Supervisor`'s `workers` option so a plan's `subtask.worker` routes to the + * right persona. + * + * @throws if two personas share a name (the key would collide silently). + */ +export function personaWorkers( + personas: readonly Persona[], + opts: PersonaAgentOptions = {}, +): Record { + const workers: Record = {} + for (const persona of personas) { + if (persona.name in workers) { + throw new Error(`[ai-autopilot] duplicate persona name in workers: "${persona.name}"`) + } + workers[persona.name] = personaAgent(persona, opts) + } + return workers +} + +/** + * A planner-facing prompt fragment listing the available personas by name and + * role. Inject it into a planning agent's instructions so it decomposes a task + * into subtasks tagged with the `worker` that should run each one — the bridge + * that makes the Supervisor's plan stack-aware. + */ +export function personaRoster(personas: readonly Persona[]): string { + if (personas.length === 0) return 'No personas are available.' + const lines = personas.map(p => `- \`${p.name}\` — ${p.role}`) + return [ + 'Available personas (route each subtask to one by setting its `worker` to the persona name):', + ...lines, + ].join('\n') +} diff --git a/packages/ai-autopilot/src/personas/define.test.ts b/packages/ai-autopilot/src/personas/define.test.ts new file mode 100644 index 0000000..c3b4432 --- /dev/null +++ b/packages/ai-autopilot/src/personas/define.test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' +import { definePersona, PersonaError } from './define.js' + +describe('definePersona', () => { + it('trims fields and defaults optional arrays to empty', () => { + const p = definePersona({ + name: 'my-persona', + role: ' builds things ', + systemPrompt: ' you build things ', + }) + assert.equal(p.name, 'my-persona') + assert.equal(p.role, 'builds things') + assert.equal(p.systemPrompt, 'you build things') + assert.deepEqual([...p.skills], []) + assert.deepEqual([...p.tools], []) + assert.deepEqual([...p.appliesTo], []) + }) + + it('freezes the persona so it cannot be mutated after definition', () => { + const p = definePersona({ name: 'p', role: 'r', systemPrompt: 's' }) + assert.throws(() => { + ;(p as { name: string }).name = 'other' + }) + assert.throws(() => { + ;(p.appliesTo as string[]).push('x') + }) + }) + + it('rejects a missing name', () => { + assert.throws(() => definePersona({ name: ' ', role: 'r', systemPrompt: 's' }), PersonaError) + }) + + it('rejects a non-kebab-case name', () => { + assert.throws(() => definePersona({ name: 'My Persona', role: 'r', systemPrompt: 's' }), PersonaError) + assert.throws(() => definePersona({ name: 'my_persona', role: 'r', systemPrompt: 's' }), PersonaError) + }) + + it('rejects a missing role or systemPrompt', () => { + assert.throws(() => definePersona({ name: 'p', role: '', systemPrompt: 's' }), PersonaError) + assert.throws(() => definePersona({ name: 'p', role: 'r', systemPrompt: ' ' }), PersonaError) + }) +}) diff --git a/packages/ai-autopilot/src/personas/define.ts b/packages/ai-autopilot/src/personas/define.ts new file mode 100644 index 0000000..abd6f7a --- /dev/null +++ b/packages/ai-autopilot/src/personas/define.ts @@ -0,0 +1,36 @@ +import type { Persona, PersonaSpec } from './types.js' + +/** Thrown when a `PersonaSpec` is malformed. Fails fast at definition time. */ +export class PersonaError extends Error { + constructor(message: string) { + super(`[ai-autopilot] ${message}`) + this.name = 'PersonaError' + } +} + +/** + * Validate a {@link PersonaSpec} and return a frozen {@link Persona}. + * + * Optional fields default to empty arrays so the rest of the library never has + * to null-check them. The result is deep-frozen at the top level: a persona is + * a shared, reusable identity, so mutating one after definition is a bug. + */ +export function definePersona(spec: PersonaSpec): Persona { + const name = spec.name?.trim() + if (!name) throw new PersonaError('persona name is required') + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) { + throw new PersonaError(`persona name must be kebab-case: ${JSON.stringify(spec.name)}`) + } + if (!spec.role?.trim()) throw new PersonaError(`persona "${name}" needs a role`) + if (!spec.systemPrompt?.trim()) throw new PersonaError(`persona "${name}" needs a systemPrompt`) + + const persona: Persona = { + name, + role: spec.role.trim(), + systemPrompt: spec.systemPrompt.trim(), + skills: Object.freeze([...(spec.skills ?? [])]), + tools: Object.freeze([...(spec.tools ?? [])]), + appliesTo: Object.freeze([...(spec.appliesTo ?? [])]), + } + return Object.freeze(persona) +} diff --git a/packages/ai-autopilot/src/personas/index.ts b/packages/ai-autopilot/src/personas/index.ts new file mode 100644 index 0000000..8c6e310 --- /dev/null +++ b/packages/ai-autopilot/src/personas/index.ts @@ -0,0 +1,25 @@ +/** + * Personas — the stack-aware knowledge layer of `@gemstack/ai-autopilot`. + * + * A {@link Persona} is a reusable role (identity + skills + tools) that knows + * the GemStack stack. Define one with {@link definePersona}, materialize it into + * an agent with {@link personaAgent}, wire a set as Supervisor workers with + * {@link personaWorkers}, and describe them to a planner with + * {@link personaRoster}. + */ +export { definePersona, PersonaError } from './define.js' +export { + personaInstructions, + personaTools, + personaAgent, + personaWorkers, + personaRoster, + type PersonaAgentOptions, +} from './compose.js' +export { + vikePageBuilder, + universalOrmModeler, + uiIntentDesigner, + stackPersonas, +} from './library.js' +export type { Persona, PersonaSpec } from './types.js' diff --git a/packages/ai-autopilot/src/personas/library.ts b/packages/ai-autopilot/src/personas/library.ts new file mode 100644 index 0000000..1d49558 --- /dev/null +++ b/packages/ai-autopilot/src/personas/library.ts @@ -0,0 +1,96 @@ +import { definePersona } from './define.js' +import type { Persona } from './types.js' + +/** + * The built-in, stack-aware personas — the opinionated knowledge that makes + * autopilot know the GemStack stack (Vike + universal-orm) instead of guessing. + * Each carries conventions-level guidance; detailed how-to arrives via skills + * attached to a persona at use time. + */ + +/** Builds pages, routes, and layouts with Vike's `+` file convention. */ +export const vikePageBuilder: Persona = definePersona({ + name: 'vike-page-builder', + role: 'Builds Vike pages, routes, and layouts using the `+` file convention', + appliesTo: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], + systemPrompt: `You build UI on Vike (Vite + SSR), which is renderer-agnostic — the same +conventions hold whether the project uses vike-react, vike-vue, or vike-solid. + +Conventions to follow: +- Routing is filesystem-based under \`pages/\`. A page is a folder with a + \`+Page\` file; the folder path is the URL. Use \`+route\` only for + parameterized or programmatic routes. +- Configure a page with sibling \`+\` files: \`+config\` (page config), \`+data\` + (server-side data loading), \`+Head\`, \`+Layout\`, \`+guard\` (access control), + \`+onBeforeRender\`. Put shared config in a parent folder's \`+config\` and let + it cascade. +- Load data in \`+data\` on the server; never fetch your own API from a page when + the data can be loaded server-side. Pass it to the page via the data hook. +- Prefer composing existing vike-* extensions (auth, data, notifications) over + hand-rolling their concerns. + +Keep pages thin: routing + layout + data wiring. Business logic and persistence +belong to the data layer, not the page.`, +}) + +/** Defines schema, derives migrations, and writes queries on universal-orm. */ +export const universalOrmModeler: Persona = definePersona({ + name: 'universal-orm-modeler', + role: 'Models data with universal-orm: schema first, migrations derived, typed queries', + appliesTo: ['@universal-orm/*', 'universal-orm'], + systemPrompt: `You own the data layer with universal-orm, which is schema-first and +adapter-agnostic (a native engine and Prisma/Drizzle adapters share one API). + +Conventions to follow: +- The schema is the source of truth. Define models/tables in the schema; derive + migrations from it rather than hand-writing DDL. Regenerate the typed registry + after a schema change so queries stay typed. +- Reads and writes go through the model query builder, not raw SQL. Reach for + raw SQL only when the builder genuinely cannot express the query, and prefer + bulk/cursor helpers (chunked iteration, RETURNING on writes) over N+1 loops. +- Keep the data layer separate from the framework: it does not import Vike or + know about pages. A page's \`+data\` hook calls into it; it never calls back. +- Migrations are forward-only and reviewed. Never edit an applied migration — + add a new one. + +When unsure whether a change belongs in the schema or the query, put durable +shape in the schema and per-request logic in the query.`, +}) + +/** + * Expresses UI as *intent*, keeping the implementation decoupled — the guardrail + * against an AI hardcoding the wrong markup. The app says "render this as a + * toaster"; what a toaster is stays swappable. + */ +export const uiIntentDesigner: Persona = definePersona({ + name: 'ui-intent-designer', + role: 'Expresses UI as decoupled intent, not hardcoded markup — the "declare intent" guardrail', + appliesTo: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], + systemPrompt: `You express UI as intent, not as concrete markup. The app declares what a +region *means* ("this is a notification", "render this record as a card"); the +implementation of that meaning is decoupled and owned elsewhere. + +Why: an AI that hardcodes markup gets the details wrong and locks the app into +one look. Declaring intent keeps a hard boundary — the implementation can change +(theme, framework, component library) without touching the app's stated intent. + +Conventions to follow: +- Reach for a named semantic component/slot that carries the intent before + writing raw elements. Only drop to primitive markup when no intent-level + component fits, and when you do, keep it small and local. +- Pass data and meaning, not layout decisions. Let the implementation choose + spacing, color, and element structure. +- Never inline styling or copy that belongs to a shared design decision. +- When you cannot express something as intent, say so explicitly and propose the + intent-level primitive that is missing rather than hardcoding around it. + +Your output should read as a declaration of what the UI is, leaving how it looks +to the decoupled implementation.`, +}) + +/** All built-in stack personas, in a stable order. */ +export const stackPersonas: readonly Persona[] = Object.freeze([ + vikePageBuilder, + universalOrmModeler, + uiIntentDesigner, +]) diff --git a/packages/ai-autopilot/src/personas/types.ts b/packages/ai-autopilot/src/personas/types.ts new file mode 100644 index 0000000..044969b --- /dev/null +++ b/packages/ai-autopilot/src/personas/types.ts @@ -0,0 +1,40 @@ +import type { AnyTool } from '@gemstack/ai-sdk' +import type { LoadedSkill } from '@gemstack/ai-skills' + +/** + * A reusable, stack-aware role an agent can take on. A persona is *data*: a + * name, a one-line role, a system-prompt fragment, and the skills/tools it + * brings. It carries opinionated knowledge of the GemStack stack (Vike + + * universal-orm) so an autopilot run is not generic — it knows where pages + * live, how the schema drives migrations, and to express UI as intent. + * + * A persona is materialized into an `Agent` on demand (see `personaAgent`), + * composing its `systemPrompt` with the instructions/tools of its `skills` via + * `@gemstack/ai-skills`. Keeping it as data means it can be inspected, listed + * in a planner roster, and routed to as a Supervisor worker — without building + * an agent first. + */ +export interface Persona { + /** Unique id, kebab-case by convention (e.g. `vike-page-builder`). */ + readonly name: string + /** One-line human description — what this persona is for. */ + readonly role: string + /** The persona's identity/instructions fragment. Skill bodies append after it. */ + readonly systemPrompt: string + /** Skills the persona brings, composed over `@gemstack/ai-skills`. */ + readonly skills: readonly LoadedSkill[] + /** Extra tools beyond those its skills contribute; authoritative on name collision. */ + readonly tools: readonly AnyTool[] + /** Stack hints (package names / globs) the persona applies to; documents intent. */ + readonly appliesTo: readonly string[] +} + +/** The author-facing shape passed to `definePersona`; optional fields default to empty. */ +export interface PersonaSpec { + name: string + role: string + systemPrompt: string + skills?: LoadedSkill[] + tools?: AnyTool[] + appliesTo?: string[] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60de86e..39f1e17 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,9 @@ importers: '@gemstack/ai-sdk': specifier: workspace:^ version: link:../ai-sdk + '@gemstack/ai-skills': + specifier: workspace:^ + version: link:../ai-skills zod: specifier: ^4.0.0 version: 4.4.3