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
5 changes: 5 additions & 0 deletions .changeset/ai-autopilot-framework-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@gemstack/ai-autopilot": minor
---

Add the web-app preset seam: framework-specific knowledge selected by detecting the app's framework, on top of the agnostic core. A `Preset` bundles a framework's personas with the signals that identify it; `detectFramework` scores a project's dependencies + files (deps weigh more than files) and `PresetRegistry.select` picks the preset (falling back to the flagship when nothing matches). Ships two built-ins — `vikePreset` (flagship) and `nextPreset` — plus a new `nextPageBuilder` persona (App Router + React Server Components). `presetPersonas(preset)` returns the framework page builder followed by the shared, framework-neutral personas (`sharedPersonas`: the universal-orm modeler + intent-UI designer), so only the page builder changes between frameworks while the rest of the stack stays put and prompts stay neutral. One shared core; a new framework is a new `Preset`, not a runtime fork. Closes #115.
30 changes: 29 additions & 1 deletion packages/ai-autopilot/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
* - {@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.
* GemStack stack (Vike/Next + 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
* - {@link sharedPersonas} — the framework-neutral core (data layer + intent UI)
*
* The runner is the pluggable execution seam: a workspace (filesystem + shell +
* optional preview) where autopilot builds and runs an app. Shaped after Flue's
Expand Down Expand Up @@ -82,6 +83,14 @@
* - {@link detectMaterialChange} — the deterministic refresh trigger
* - {@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.
*
* - {@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
*/
export { Supervisor } from './supervisor.js'
export { agentPlanner, type AgentPlannerOptions } from './planner.js'
Expand All @@ -95,8 +104,10 @@ export {
personaWorkers,
personaRoster,
vikePageBuilder,
nextPageBuilder,
universalOrmModeler,
uiIntentDesigner,
sharedPersonas,
stackPersonas,
type Persona,
type PersonaSpec,
Expand Down Expand Up @@ -260,6 +271,23 @@ export {
type OverviewRefresh,
type OverviewEvent,
} from './overview/index.js'
export {
definePreset,
PresetError,
detectFramework,
vikePreset,
nextPreset,
builtinPresets,
presetPersonas,
PresetRegistry,
builtinPresetRegistry,
type Preset,
type PresetSpec,
type PresetSignals,
type FrameworkSignals,
type PresetScore,
type FrameworkDetection,
} from './presets/index.js'
export type {
Subtask,
PlannedSubtask,
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-autopilot/src/personas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ export {
} from './compose.js'
export {
vikePageBuilder,
nextPageBuilder,
universalOrmModeler,
uiIntentDesigner,
sharedPersonas,
stackPersonas,
} from './library.js'
export type { Persona, PersonaSpec } from './types.js'
38 changes: 35 additions & 3 deletions packages/ai-autopilot/src/personas/library.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,29 @@ Keep pages thin: routing + layout + data wiring. Business logic and persistence
belong to the data layer, not the page.`,
})

/** Builds pages, routes, and layouts with Next.js' App Router + React Server Components. */
export const nextPageBuilder: Persona = definePersona({
name: 'next-page-builder',
role: 'Builds Next.js pages, routes, and layouts using the App Router and Server Components',
appliesTo: ['next'],
systemPrompt: `You build UI on Next.js with the App Router (React Server Components by default).

Conventions to follow:
- Routing is filesystem-based under \`app/\`. A route is a folder with a
\`page.tsx\`; the folder path is the URL. Use \`layout.tsx\` for shared shells,
\`loading.tsx\` / \`error.tsx\` for states, and \`route.ts\` for API/route handlers.
- Components are Server Components by default: fetch data directly in an async
server component, and never ship server-only code or secrets to the client.
Add \`'use client'\` only for the leaf that genuinely needs interactivity.
- Prefer server actions for mutations over hand-rolled API routes when the caller
is your own UI. Validate input at the edge either way.
- Keep pages thin: routing + layout + data wiring. Business logic and persistence
belong to the data layer, not the page.

Do not reach for \`getServerSideProps\`/\`getStaticProps\` (that is the older Pages
Router); use the App Router data model.`,
})

/** Defines schema, derives migrations, and writes queries on universal-orm. */
export const universalOrmModeler: Persona = definePersona({
name: 'universal-orm-modeler',
Expand Down Expand Up @@ -88,9 +111,18 @@ 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,
/**
* 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).
*/
export const sharedPersonas: readonly Persona[] = Object.freeze([
universalOrmModeler,
uiIntentDesigner,
])

/** All built-in stack personas (the Vike stack), in a stable order. */
export const stackPersonas: readonly Persona[] = Object.freeze([
vikePageBuilder,
...sharedPersonas,
])
22 changes: 22 additions & 0 deletions packages/ai-autopilot/src/presets/define.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { definePreset, PresetError } from './define.js'

describe('definePreset', () => {
it('validates and freezes a preset, defaulting personas/signals', () => {
const preset = definePreset({ name: 'astro', framework: 'Astro' })
assert.equal(preset.name, 'astro')
assert.equal(preset.framework, 'Astro')
assert.deepEqual(preset.personas, [])
assert.deepEqual(preset.signals.dependencies, [])
assert.throws(() => {
;(preset as { name: string }).name = 'x'
})
})

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/)
assert.throws(() => definePreset({ name: 'ok', framework: '' }), /needs a framework/)
})
})
32 changes: 32 additions & 0 deletions packages/ai-autopilot/src/presets/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Preset, PresetSpec } from './types.js'

/** Thrown when a `PresetSpec` is malformed. Fails fast at definition time. */
export class PresetError extends Error {
constructor(message: string) {
super(`[ai-autopilot] ${message}`)
this.name = 'PresetError'
}
}

/**
* Validate a {@link PresetSpec} and return a frozen {@link Preset}. Optional
* fields default to empty so callers never null-check them.
*/
export function definePreset(spec: PresetSpec): Preset {
const name = spec.name?.trim()
if (!name) throw new PresetError('preset name is required')
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) {
throw new PresetError(`preset name must be kebab-case: ${JSON.stringify(spec.name)}`)
}
if (!spec.framework?.trim()) throw new PresetError(`preset "${name}" needs a framework name`)

return Object.freeze({
name,
framework: spec.framework.trim(),
personas: Object.freeze([...(spec.personas ?? [])]),
signals: Object.freeze({
dependencies: Object.freeze([...(spec.signals?.dependencies ?? [])]),
files: Object.freeze([...(spec.signals?.files ?? [])]),
}),
})
}
57 changes: 57 additions & 0 deletions packages/ai-autopilot/src/presets/detect.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { detectFramework } from './detect.js'
import { vikePreset, nextPreset, builtinPresets } from './library.js'

const presets = builtinPresets()

describe('detectFramework', () => {
it('detects Vike from its dependency', () => {
const d = detectFramework(presets, { dependencies: { 'vike-react': '1.0.0', react: '18' } })
assert.equal(d.preset?.name, 'vike')
assert.equal(d.framework, 'Vike')
assert.ok(d.confidence >= 2)
assert.match(d.scores[0]!.reasons.join(), /vike-react/)
})

it('detects Next from a dep + a file, and outscores a bare dep match', () => {
const d = detectFramework(presets, {
dependencies: ['next'],
files: ['app/dashboard/page.tsx', 'next.config.mjs'],
})
assert.equal(d.preset?.name, 'next')
// dep (2) + two file patterns (1 each) = 4
assert.ok(d.confidence >= 3)
})

it('accepts dependencies as a bare list of names', () => {
const d = detectFramework(presets, { dependencies: ['vike'] })
assert.equal(d.preset?.name, 'vike')
})

it('returns no preset and confidence 0 when nothing matches', () => {
const d = detectFramework(presets, { dependencies: ['express'], files: ['server.js'] })
assert.equal(d.preset, undefined)
assert.equal(d.framework, undefined)
assert.equal(d.confidence, 0)
assert.equal(d.scores.length, presets.length) // every preset still scored (at 0)
})

it('picks the higher-scoring framework when signals overlap', () => {
// a repo mid-migration: has next as a dep but many Vike files
const d = detectFramework(presets, {
dependencies: ['next', 'vike'],
files: ['pages/index/+Page.tsx', '+config.ts'],
})
// vike: dep(2) + 2 files(2) = 4 ; next: dep(2) = 2
assert.equal(d.preset?.name, 'vike')
assert.equal(d.scores[0]?.preset, 'vike')
assert.equal(d.scores[1]?.preset, 'next')
})

it('weights a dependency above a file match', () => {
const depOnly = detectFramework([nextPreset], { dependencies: ['next'] }).confidence
const fileOnly = detectFramework([vikePreset], { files: ['x/+config.ts'] }).confidence
assert.ok(depOnly > fileOnly)
})
})
54 changes: 54 additions & 0 deletions packages/ai-autopilot/src/presets/detect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { FrameworkDetection, FrameworkSignals, Preset, PresetScore } from './types.js'

/** Weight of a matched dependency vs a matched file — deps are the stronger signal. */
const DEP_WEIGHT = 2
const FILE_WEIGHT = 1

function depNames(deps: FrameworkSignals['dependencies']): Set<string> {
if (!deps) return new Set()
return new Set(Array.isArray(deps) ? deps : Object.keys(deps))
}

/**
* Score each preset against a project's {@link FrameworkSignals} and return the
* best match. Deterministic: dependencies weigh more than files, and every
* preset's score is returned (highest first) so ties are inspectable. When no
* preset matches, `preset`/`framework` are undefined and `confidence` is 0 — the
* caller decides the fallback (usually the flagship preset).
*/
export function detectFramework(
presets: readonly Preset[],
signals: FrameworkSignals,
): FrameworkDetection {
const deps = depNames(signals.dependencies)
const files = signals.files ?? []

const scores: PresetScore[] = presets.map(preset => {
const reasons: string[] = []
let score = 0

for (const dep of preset.signals.dependencies ?? []) {
if (deps.has(dep)) {
score += DEP_WEIGHT
reasons.push(`dependency "${dep}"`)
}
}
for (const pattern of preset.signals.files ?? []) {
if (files.some(f => pattern.test(f))) {
score += FILE_WEIGHT
reasons.push(`file matching ${pattern}`)
}
}
return { preset: preset.name, score, reasons }
})

scores.sort((a, b) => b.score - a.score)
const top = scores[0]
const winner = top && top.score > 0 ? presets.find(p => p.name === top.preset) : undefined

return {
...(winner ? { preset: winner, framework: winner.framework } : {}),
confidence: top?.score ?? 0,
scores,
}
}
30 changes: 30 additions & 0 deletions packages/ai-autopilot/src/presets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* The web-app preset seam (#115) — framework-specific knowledge (personas) picked
* by *detecting* the app's framework, on top of the agnostic core. Vike is the
* flagship; Next.js is the second. A new framework is a new {@link Preset}, not a
* runtime fork.
*
* - {@link definePreset} — define a framework preset
* - {@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
*/
export { definePreset, PresetError } from './define.js'
export { detectFramework } from './detect.js'
export {
vikePreset,
nextPreset,
builtinPresets,
presetPersonas,
PresetRegistry,
builtinPresetRegistry,
} from './library.js'
export type {
Preset,
PresetSpec,
PresetSignals,
FrameworkSignals,
PresetScore,
FrameworkDetection,
} from './types.js'
60 changes: 60 additions & 0 deletions packages/ai-autopilot/src/presets/library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
vikePreset,
nextPreset,
builtinPresets,
presetPersonas,
PresetRegistry,
builtinPresetRegistry,
} from './library.js'

describe('built-in presets', () => {
it('ship Vike (flagship) and Next, each with its page builder', () => {
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')
})
})

describe('presetPersonas', () => {
it('is the preset page builder followed by the shared neutral personas', () => {
const names = presetPersonas(vikePreset).map(p => p.name)
assert.deepEqual(names, ['vike-page-builder', 'universal-orm-modeler', 'ui-intent-designer'])
})

it('swaps only the page builder between frameworks — the rest of the stack is shared', () => {
const vike = presetPersonas(vikePreset).map(p => p.name)
const next = presetPersonas(nextPreset).map(p => p.name)
assert.equal(vike[0], 'vike-page-builder')
assert.equal(next[0], 'next-page-builder')
assert.deepEqual(vike.slice(1), next.slice(1)) // identical shared core
})
})

describe('PresetRegistry', () => {
it('selects the detected preset', () => {
const { preset, detection } = builtinPresetRegistry().select({ dependencies: ['next'] })
assert.equal(preset.name, 'next')
assert.equal(detection.framework, 'Next.js')
})

it('falls back to the flagship (first-registered) preset when nothing matches', () => {
const { preset, detection } = builtinPresetRegistry().select({ dependencies: ['express'] })
assert.equal(preset.name, 'vike') // flagship default
assert.equal(detection.preset, undefined) // ...but detection is honest that nothing matched
})

it('honors an explicit fallback', () => {
const { preset } = builtinPresetRegistry().select({ files: [] }, nextPreset)
assert.equal(preset.name, 'next')
})

it('get / all / add', () => {
const reg = new PresetRegistry([vikePreset])
assert.equal(reg.get('vike')?.name, 'vike')
assert.equal(reg.get('next'), undefined)
reg.add(nextPreset)
assert.deepEqual(reg.all().map(p => p.name), ['vike', 'next'])
})
})
Loading
Loading