Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/page-builder-rides-skill.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@gemstack/ai-autopilot': minor
'@gemstack/framework': minor
---

Move the framework page builder off the preset onto its skill (fully skill-driven framing)

A framework was represented twice: its page-builder persona rode the preset seam (`preset.personas`, framed as the run's base) while its docs rode the skill seam (`vike.dev/llms.txt`). This finishes the "framework = skill, adapter axis gone" design: a `Skill` now carries its own curated framing personas alongside the doc pointer, so all of a framework's knowledge lives in one unit. `vikeSkill` carries `vikePageBuilder`; the new `nextSkill` carries `nextPageBuilder`. A preset is now a pure detector that points at its framework `skill`, and `run.ts` frames the page builder through the skill set (the detected preset's skill is always framed, even on an empty from-scratch project where nothing signal-matched, since preset selection is the fallback). New exports: `nextSkill`, `skillPersonas`. `presetPersonas` and the framing narration are unchanged. Part of #190.
20 changes: 19 additions & 1 deletion packages/ai-autopilot/src/extensions/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface NeutralPersona {

/** Inputs to {@link composePersonas}. */
export interface ComposePersonasInput {
/** Always-on base personas (e.g. a preset's page builder). */
/** Always-on base personas (e.g. the framework skill's page builder). */
base?: readonly Persona[]
/** The active capability extensions. */
extensions: readonly FrameworkExtension[]
Expand Down Expand Up @@ -49,6 +49,24 @@ export function composeSkills(input: { matched?: readonly Skill[]; extensions: r
return out
}

/**
* The framing personas an active skill set brings — every skill's curated
* {@link Skill.personas} in order, deduped by name (first occurrence wins).
* These are the base personas for a run (e.g. the detected framework's page
* builder), symmetric with {@link composeSkills}: the same skill carries both
* its doc pointer and the personas that knowledge always frames the agent with.
*/
export function skillPersonas(skills: readonly Skill[]): Persona[] {
const out: Persona[] = []
const seen = new Set<string>()
for (const persona of skills.flatMap(s => s.personas)) {
if (seen.has(persona.name)) continue
seen.add(persona.name)
out.push(persona)
}
return out
}

/**
* Render a doc-pointer {@link Skill} as a system-prompt fragment: what the
* knowledge is and where its `llms.txt` lives, so the agent consults the source
Expand Down
1 change: 1 addition & 0 deletions packages/ai-autopilot/src/extensions/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export function defineSkill(spec: SkillSpec): Skill {
title: spec.title.trim(),
description: spec.description.trim(),
url,
personas: Object.freeze([...(spec.personas ?? [])]),
signals: frozenSignals(spec.signals),
})
}
26 changes: 25 additions & 1 deletion packages/ai-autopilot/src/extensions/extensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { definePersona } from '../personas/define.js'
import { dataModeler, uiIntentDesigner } from '../personas/library.js'
import { composePersonas, composeSkills, skillInstructions } from './compose.js'
import { composePersonas, composeSkills, skillPersonas, skillInstructions } from './compose.js'
import { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js'
import {
builtinExtensionNames,
Expand Down Expand Up @@ -31,10 +31,34 @@ test('defineFrameworkExtension validates and freezes', () => {
test('defineSkill validates the required fields', () => {
const s = defineSkill({ name: 'vike', title: 'Vike', description: 'd', url: 'https://x/llms.txt' })
assert.equal(s.url, 'https://x/llms.txt')
assert.deepEqual(s.personas, []) // a pure doc pointer frames no personas
assert.throws(() => defineSkill({ name: 'vike', title: 'Vike', description: 'd', url: '' }), ExtensionError)
assert.throws(() => defineSkill({ name: 'Vike', title: 'Vike', description: 'd', url: 'u' }), ExtensionError)
})

test('a skill carries its curated framing personas (page builder rides the skill, not a preset)', () => {
const pb = definePersona({ name: 'astro-page-builder', role: 'builds Astro pages', systemPrompt: 'astro' })
const s = defineSkill({
name: 'astro',
title: 'Astro',
description: 'd',
url: 'https://astro.build/llms.txt',
personas: [pb],
})
assert.deepEqual(s.personas.map(p => p.name), ['astro-page-builder'])
// The built-in framework skills each ship their page builder.
assert.equal(vikeSkill.personas[0]?.name, 'vike-page-builder')
})

test('skillPersonas flattens the framing personas of a skill set, deduped by name', () => {
const pb = definePersona({ name: 'astro-page-builder', role: 'builds', systemPrompt: 'a' })
const astro = defineSkill({ name: 'astro', title: 'Astro', description: 'd', url: 'https://x/llms.txt', personas: [pb] })
const docOnly = defineSkill({ name: 'domain', title: 'Domain', description: 'd', url: 'https://x/d.txt' })
// vikeSkill re-listed twice contributes its page builder once.
const names = skillPersonas([vikeSkill, astro, docOnly, vikeSkill]).map(p => p.name)
assert.deepEqual(names, ['vike-page-builder', 'astro-page-builder'])
})

test('matchSignals scores deps over files; selectActive unions signal-match and opt-in', () => {
const ext = defineFrameworkExtension({ name: 'framework-auth', capability: 'auth', signals: { dependencies: ['vike-auth'] } })
assert.equal(matchSignals(ext.signals, { dependencies: ['vike-auth'] }).score, 2)
Expand Down
10 changes: 9 additions & 1 deletion packages/ai-autopilot/src/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,14 @@ export {
builtinSkillRegistry,
type MatchOptions,
} from './registry.js'
export { composePersonas, composeSkills, skillInstructions, type ComposePersonasInput, type NeutralPersona } from './compose.js'
export {
composePersonas,
composeSkills,
skillPersonas,
skillInstructions,
type ComposePersonasInput,
type NeutralPersona,
} from './compose.js'
export {
frameworkAuth,
frameworkData,
Expand All @@ -31,6 +38,7 @@ export {
builtinExtensions,
builtinExtensionNames,
vikeSkill,
nextSkill,
builtinSkills,
neutralPersonas,
} from './library.js'
Expand Down
32 changes: 27 additions & 5 deletions packages/ai-autopilot/src/extensions/library.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import {
dataModeler,
nextPageBuilder,
uiIntentDesigner,
vikeAuthComposer,
vikeCrudComposer,
vikeDataModeler,
vikePageBuilder,
vikeRbacComposer,
vikeShellComposer,
} from '../personas/library.js'
Expand Down Expand Up @@ -72,25 +74,45 @@ export function builtinExtensions(): FrameworkExtension[] {
export const builtinExtensionNames: readonly string[] = Object.freeze(builtinExtensions().map(e => e.name))

/**
* Vike as a {@link Skill}: framework knowledge arrives as a doc pointer to
* `vike.dev/llms.txt`, not a special adapter package. Auto-activates whenever
* Vike is detected. Proof that a framework rides the skill seam.
* Vike as a {@link Skill}: the whole framework rides the skill seam — its page
* builder ({@link vikePageBuilder}) plus a doc pointer to `vike.dev/llms.txt`,
* not a special adapter package or a preset-supplied persona. Auto-activates
* whenever Vike is detected.
*/
export const vikeSkill: Skill = defineSkill({
name: 'vike',
title: 'Vike',
description:
'Vike is the Vite-based, renderer-agnostic meta-framework the app is built on (filesystem routing under `pages/`, `+` config files, server-side `+data` loading).',
url: 'https://vike.dev/llms.txt',
personas: [vikePageBuilder],
signals: {
dependencies: ['vike', 'vike-react', 'vike-vue', 'vike-solid'],
files: [/(^|\/)\+Page(\.[\w-]+)?\.[jt]sx?$/, /(^|\/)\+config\.[jt]s$/],
},
})

/** The built-in skills (doc pointers). */
/**
* Next.js as a {@link Skill}: same seam as {@link vikeSkill} — its App Router
* page builder plus a doc pointer to `nextjs.org/llms.txt`. The second framework
* is another skill, not a runtime fork.
*/
export const nextSkill: Skill = defineSkill({
name: 'next',
title: 'Next.js',
description:
'Next.js with the App Router (React Server Components): filesystem routing under `app/`, `page.tsx`/`layout.tsx`, server components by default, server actions for mutations.',
url: 'https://nextjs.org/llms.txt',
personas: [nextPageBuilder],
signals: {
dependencies: ['next'],
files: [/(^|\/)next\.config\.[cm]?[jt]s$/, /(^|\/)app\/.*\/page\.[jt]sx?$/, /(^|\/)app\/layout\.[jt]sx?$/],
},
})

/** The built-in framework skills (page builder + doc pointer), flagship first. */
export function builtinSkills(): Skill[] {
return [vikeSkill]
return [vikeSkill, nextSkill]
}

/**
Expand Down
27 changes: 17 additions & 10 deletions packages/ai-autopilot/src/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import type { FrameworkSignals, PresetSignals } from '../presets/types.js'
*
* - {@link FrameworkExtension} — a capability (auth, data, ...) that frames the
* agent with personas when it matches a project.
* - {@link Skill} — a doc pointer (framework/domain knowledge = an `llms.txt`),
* the shared unit with Open Loop (#204).
* - {@link Skill} — framework/domain knowledge: a doc pointer (an `llms.txt`)
* plus the curated personas it frames the agent with, the shared unit with
* Open Loop (#204).
*
* A framework is not a special package: it is a {@link Skill} pointing at its
* `llms.txt`. There is no adapter axis.
* A framework is not a special package: it is a {@link Skill} carrying its page
* builder and pointing at its `llms.txt`. There is no adapter axis, and no
* separate seam supplies the page builder.
*/

/** How a unit is recognized in a project — the same deps/files shape presets use. */
Expand All @@ -22,11 +24,13 @@ export type ExtensionSignals = PresetSignals
export type { FrameworkSignals } from '../presets/types.js'

/**
* A doc-pointer skill: framework or domain knowledge an agent pulls in by
* reading an `llms.txt`. Distinct from `@gemstack/ai-skills`' on-disk
* `SKILL.md`/`LoadedSkill` (instructions + tools) — a {@link Skill} is just a
* pointer, the lightweight unit shared with Open Loop (#204). Vike is a skill
* (https://vike.dev/llms.txt), not an adapter package.
* A skill: framework or domain knowledge an agent pulls in. It carries two
* things — a doc pointer (an `llms.txt` the agent consults) and, optionally, the
* curated framing {@link Persona}s that knowledge always brings (e.g. Vike's page
* builder). Distinct from `@gemstack/ai-skills`' on-disk `SKILL.md`/`LoadedSkill`
* (instructions + tools) — a {@link Skill} is the lightweight unit shared with
* Open Loop (#204). A framework is a skill (Vike -> https://vike.dev/llms.txt +
* its page builder), not an adapter package; there is no adapter axis.
*/
export interface Skill {
/** Stable kebab-case id (e.g. `vike`). */
Expand All @@ -37,16 +41,19 @@ export interface Skill {
readonly description: string
/** The `llms.txt` (or other LLM-optimized doc) URL the agent should consult. */
readonly url: string
/** Curated personas this knowledge always frames the agent with (e.g. a page builder). Empty for a pure doc pointer. */
readonly personas: readonly Persona[]
/** When to auto-activate it; empty means opt-in only. */
readonly signals: ExtensionSignals
}

/** The author-facing shape for {@link defineSkill}; `signals` defaults to empty. */
/** The author-facing shape for {@link defineSkill}; `personas`/`signals` default to empty. */
export interface SkillSpec {
name: string
title: string
description: string
url: string
personas?: readonly Persona[]
signals?: ExtensionSignals
}

Expand Down
9 changes: 6 additions & 3 deletions packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,14 @@
* - {@link agentOverview} / {@link overviewLoopPrompt} — regenerate with an agent,
* and wire the maintainer into the loop
*
* Presets are the web-app layer: framework-specific personas selected by detecting
* the app's framework (Vike flagship, Next.js second), on top of the agnostic core.
* Presets are the web-app layer: detect the app's framework (Vike flagship,
* Next.js second) and point at its {@link Skill} (page builder + `llms.txt`), on
* top of the agnostic core.
*
* - {@link PresetRegistry} — register presets, {@link PresetRegistry.select} one
* - {@link detectFramework} — score a project's deps/files against presets
* - {@link vikePreset} / {@link nextPreset} — the built-ins
* - {@link presetPersonas} — a preset's personas + the shared neutral ones
* - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones
*/
export { Supervisor } from './supervisor.js'
export { agentPlanner, type AgentPlannerOptions } from './planner.js'
Expand Down Expand Up @@ -323,6 +324,7 @@ export {
builtinSkillRegistry,
composePersonas,
composeSkills,
skillPersonas,
skillInstructions,
frameworkAuth,
frameworkData,
Expand All @@ -332,6 +334,7 @@ export {
builtinExtensions,
builtinExtensionNames,
vikeSkill,
nextSkill,
builtinSkills,
neutralPersonas,
EXTENSION_NAME_RE,
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-autopilot/src/personas/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,7 @@ const neutralGuardrails: readonly Persona[] = Object.freeze([uiIntentDesigner])
/**
* The framework-neutral personas shared by every preset — the data layer and the
* intent-based UI guardrail apply the same whether the app is on Vike or Next.
* A preset adds its framework-specific page builder on top (see the presets seam).
* The framework's own skill adds its page builder on top (see the skill seam).
*/
export const sharedPersonas: readonly Persona[] = Object.freeze([
dataModeler,
Expand Down
8 changes: 8 additions & 0 deletions packages/ai-autopilot/src/presets/define.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { definePreset, PresetError } from './define.js'
import { defineSkill } from '../extensions/define.js'

describe('definePreset', () => {
it('validates and freezes a preset, defaulting personas/signals', () => {
Expand All @@ -9,11 +10,18 @@ describe('definePreset', () => {
assert.equal(preset.framework, 'Astro')
assert.deepEqual(preset.personas, [])
assert.deepEqual(preset.signals.dependencies, [])
assert.equal(preset.skill, undefined) // no framework skill unless supplied
assert.throws(() => {
;(preset as { name: string }).name = 'x'
})
})

it('carries the framework skill it points at', () => {
const skill = defineSkill({ name: 'astro', title: 'Astro', description: 'd', url: 'https://astro.build/llms.txt' })
const preset = definePreset({ name: 'astro', framework: 'Astro', skill })
assert.equal(preset.skill, skill)
})

it('rejects a missing/non-kebab name and a missing framework', () => {
assert.throws(() => definePreset({ name: '', framework: 'X' }), PresetError)
assert.throws(() => definePreset({ name: 'Not Kebab', framework: 'X' }), /kebab-case/)
Expand Down
1 change: 1 addition & 0 deletions packages/ai-autopilot/src/presets/define.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function definePreset(spec: PresetSpec): Preset {
return Object.freeze({
name,
framework: spec.framework.trim(),
...(spec.skill ? { skill: spec.skill } : {}),
personas: Object.freeze([...(spec.personas ?? [])]),
signals: Object.freeze({
dependencies: Object.freeze([...(spec.signals?.dependencies ?? [])]),
Expand Down
2 changes: 1 addition & 1 deletion packages/ai-autopilot/src/presets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
* - {@link vikePreset} / {@link nextPreset} — the built-ins
* - {@link detectFramework} — score a project's deps/files against presets
* - {@link PresetRegistry} — register presets and {@link PresetRegistry.select} one
* - {@link presetPersonas} — a preset's personas + the shared neutral ones
* - {@link presetPersonas} — its framework skill's page builder + the shared neutral ones
*/
export { definePreset, PresetError } from './define.js'
export { detectFramework } from './detect.js'
Expand Down
11 changes: 8 additions & 3 deletions packages/ai-autopilot/src/presets/library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ import {
} from './library.js'

describe('built-in presets', () => {
it('ship Vike (flagship) and Next, each with its page builder', () => {
it('ship Vike (flagship) and Next, each pointing at its framework skill', () => {
assert.deepEqual(builtinPresets().map(p => p.name), ['vike', 'next'])
assert.equal(vikePreset.personas[0]?.name, 'vike-page-builder')
assert.equal(nextPreset.personas[0]?.name, 'next-page-builder')
// The page builder rides the framework skill, not the preset itself.
assert.deepEqual(vikePreset.personas, [])
assert.deepEqual(nextPreset.personas, [])
assert.equal(vikePreset.skill?.name, 'vike')
assert.equal(nextPreset.skill?.name, 'next')
assert.equal(vikePreset.skill?.personas[0]?.name, 'vike-page-builder')
assert.equal(nextPreset.skill?.personas[0]?.name, 'next-page-builder')
})
})

Expand Down
Loading
Loading