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

Make The Framework modular: a capability-extension + skill SPI, discovered instead of hardcoded

The composition was pinned in `run.ts` (a fixed `vikeExtensionPersonas` list swapped in behind `--compose-extensions`), so no third party could publish a `framework-*` package and have it compose. This adds the extension SPI (#190), all agnostic (nothing is framework-gated):

- `defineFrameworkExtension` — a capability (auth, data, rbac, crud, shell, ...) that self-registers, matched by signal (a dependency is present) or opt-in, and frames the agent with its personas. An extension supersedes the neutral default persona of the same `capability` (e.g. `framework-data` replaces the default ORM modeler), so the agent never gets two conflicting personas for one concern.
- `defineSkill` — a doc pointer (an `llms.txt`), the shared unit with Open Loop (#204). A framework is a skill, not an adapter package: Vike now rides the same seam as `https://vike.dev/llms.txt`.
- `ExtensionRegistry` / `SkillRegistry`, `composePersonas`, `skillInstructions`, and `loadExtensionsFromModules` for discovering installed `framework-*` packages.

`run.ts` now composes matched extensions + skills through the registry instead of the hardcoded list, and the CLI reads the project's real signals and discovers installed `framework-*` capability packages (resolved from the user's workspace, failures reported not thrown). The built-in vike-* composers ship as extensions (`framework-auth`, `framework-data`, `framework-rbac`, `framework-crud`, `framework-shell`) and Vike ships as a skill, proving the seam. `--compose-extensions` still opts every built-in in; the publish-safe default (hand-rolled + Prisma) is unchanged. Closes #190.
48 changes: 48 additions & 0 deletions packages/ai-autopilot/src/extensions/compose.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Persona } from '../personas/types.js'
import type { FrameworkExtension, Skill } from './types.js'

/** A neutral default persona keyed by the capability an extension would supersede. */
export interface NeutralPersona {
capability: string
persona: Persona
}

/** Inputs to {@link composePersonas}. */
export interface ComposePersonasInput {
/** Always-on base personas (e.g. a preset's page builder). */
base?: readonly Persona[]
/** The active capability extensions. */
extensions: readonly FrameworkExtension[]
/** Neutral defaults; one is dropped when an active extension owns its capability. */
neutral?: readonly NeutralPersona[]
}

/**
* Compose the persona set for a run: the base personas, the active extensions'
* personas, then the neutral defaults for any capability no active extension
* covers. An extension supersedes a neutral default of the same capability (e.g.
* a `data` extension replaces the default ORM modeler), so the agent is never
* framed with two conflicting personas for one concern. Order is stable:
* base → extensions (registration order) → surviving neutral defaults.
*/
export function composePersonas(input: ComposePersonasInput): Persona[] {
const covered = new Set(input.extensions.map(e => e.capability))
const neutral = (input.neutral ?? []).filter(n => !covered.has(n.capability)).map(n => n.persona)
return [...(input.base ?? []), ...input.extensions.flatMap(e => e.personas), ...neutral]
}

/**
* 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
* of truth for that framework/domain instead of guessing.
*/
export function skillInstructions(skill: Skill): string {
return [
`# Skill: ${skill.title}`,
'',
skill.description,
'',
`Authoritative, LLM-optimized docs: ${skill.url}`,
`When you need ${skill.title} specifics, consult that document rather than relying on memory.`,
].join('\n')
}
65 changes: 65 additions & 0 deletions packages/ai-autopilot/src/extensions/define.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import type { ExtensionSignals, FrameworkExtension, FrameworkExtensionSpec, Skill, SkillSpec } from './types.js'

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

const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/

function frozenSignals(signals: ExtensionSignals | undefined): ExtensionSignals {
return Object.freeze({
dependencies: Object.freeze([...(signals?.dependencies ?? [])]),
files: Object.freeze([...(signals?.files ?? [])]),
})
}

/**
* Validate a {@link FrameworkExtensionSpec} and return a frozen
* {@link FrameworkExtension}. Optional fields default to empty so callers never
* null-check them. A third-party `framework-*` package's default export is the
* result of this call.
*/
export function defineFrameworkExtension(spec: FrameworkExtensionSpec): FrameworkExtension {
const name = spec.name?.trim()
if (!name) throw new ExtensionError('extension name is required')
if (!KEBAB.test(name)) throw new ExtensionError(`extension name must be kebab-case: ${JSON.stringify(spec.name)}`)
const capability = spec.capability?.trim()
if (!capability) throw new ExtensionError(`extension "${name}" needs a capability`)
if (!KEBAB.test(capability)) {
throw new ExtensionError(`extension "${name}" capability must be kebab-case: ${JSON.stringify(spec.capability)}`)
}

return Object.freeze({
name,
capability,
personas: Object.freeze([...(spec.personas ?? [])]),
skills: Object.freeze([...(spec.skills ?? [])]),
signals: frozenSignals(spec.signals),
})
}

/**
* Validate a {@link SkillSpec} and return a frozen {@link Skill} — a doc pointer
* (an `llms.txt` URL) an agent consults for framework/domain knowledge.
*/
export function defineSkill(spec: SkillSpec): Skill {
const name = spec.name?.trim()
if (!name) throw new ExtensionError('skill name is required')
if (!KEBAB.test(name)) throw new ExtensionError(`skill name must be kebab-case: ${JSON.stringify(spec.name)}`)
if (!spec.title?.trim()) throw new ExtensionError(`skill "${name}" needs a title`)
if (!spec.description?.trim()) throw new ExtensionError(`skill "${name}" needs a description`)
const url = spec.url?.trim()
if (!url) throw new ExtensionError(`skill "${name}" needs a url (its llms.txt pointer)`)

return Object.freeze({
name,
title: spec.title.trim(),
description: spec.description.trim(),
url,
signals: frozenSignals(spec.signals),
})
}
140 changes: 140 additions & 0 deletions packages/ai-autopilot/src/extensions/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
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, skillInstructions } from './compose.js'
import { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js'
import {
builtinExtensionNames,
builtinExtensions,
frameworkAuth,
frameworkData,
neutralPersonas,
vikeSkill,
} from './library.js'
import { extensionPackageNames, isFrameworkExtension, loadExtensionsFromModules } from './load.js'
import { matchSignals, selectActive } from './match.js'
import { ExtensionRegistry, SkillRegistry } from './registry.js'

const persona = (name: string) => definePersona({ name, role: name, systemPrompt: `I am ${name}.` })

test('defineFrameworkExtension validates and freezes', () => {
const ext = defineFrameworkExtension({ name: 'framework-x', capability: 'x', personas: [persona('p')] })
assert.equal(ext.name, 'framework-x')
assert.equal(ext.capability, 'x')
assert.ok(Object.isFrozen(ext))
assert.deepEqual(ext.skills, [])
assert.throws(() => defineFrameworkExtension({ name: 'Bad Name', capability: 'x' }), ExtensionError)
assert.throws(() => defineFrameworkExtension({ name: 'ok', capability: '' }), ExtensionError)
})

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.throws(() => defineSkill({ name: 'vike', title: 'Vike', description: 'd', url: '' }), ExtensionError)
assert.throws(() => defineSkill({ name: 'Vike', title: 'Vike', description: 'd', url: 'u' }), ExtensionError)
})

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)
assert.equal(matchSignals(ext.signals, { dependencies: ['other'] }).score, 0)

const units = [ext]
// Not installed, not opted-in -> inactive.
assert.deepEqual(selectActive(units, { dependencies: [] }), [])
// Installed -> active by signal.
assert.deepEqual(selectActive(units, { dependencies: ['vike-auth'] }), [ext])
// Opted in by name even without the dep -> active.
assert.deepEqual(selectActive(units, { dependencies: [] }, ['framework-auth']), [ext])
})

test('ExtensionRegistry.match honors signals and include; addAll registers extras', () => {
const reg = new ExtensionRegistry()
assert.deepEqual(reg.match({ dependencies: [] }), []) // nothing installed, no opt-in
assert.deepEqual(
reg.match({ dependencies: ['vike-auth'] }).map(e => e.name),
['framework-auth'],
)
// Opt every built-in in by name (the --compose-extensions set).
assert.deepEqual(
reg.match({ dependencies: [] }, { include: builtinExtensionNames }).map(e => e.name),
[...builtinExtensionNames],
)
// A discovered third-party extension registers and auto-activates by its signal.
const third = defineFrameworkExtension({ name: 'framework-sentry', capability: 'tracking', signals: { dependencies: ['@sentry/node'] } })
reg.addAll([third])
assert.ok(reg.match({ dependencies: ['@sentry/node'] }).some(e => e.name === 'framework-sentry'))
})

test('SkillRegistry activates the Vike skill when Vike is detected', () => {
const reg = new SkillRegistry()
assert.deepEqual(reg.match({ dependencies: ['vike-react'] }).map(s => s.name), ['vike'])
assert.deepEqual(reg.match({ dependencies: ['react'] }), [])
assert.deepEqual(reg.match({ files: ['pages/+config.js'] }).map(s => s.name), ['vike'])
})

test('composePersonas: extensions supersede the neutral default of their capability', () => {
const base = [persona('page-builder')]
// No extensions: base + both neutral defaults.
const dflt = composePersonas({ base, extensions: [], neutral: neutralPersonas })
assert.deepEqual(dflt.map(p => p.name), ['page-builder', dataModeler.name, uiIntentDesigner.name])

// framework-data (capability 'data') drops the default data modeler; ui stays.
const withData = composePersonas({ base, extensions: [frameworkData], neutral: neutralPersonas })
const names = withData.map(p => p.name)
assert.ok(names.includes('vike-data-modeler'))
assert.ok(!names.includes(dataModeler.name)) // superseded
assert.ok(names.includes(uiIntentDesigner.name)) // no extension owns 'ui'
})

test('skillInstructions renders the doc pointer', () => {
const text = skillInstructions(vikeSkill)
assert.match(text, /Vike/)
assert.match(text, /https:\/\/vike\.dev\/llms\.txt/)
})

test('the built-in auth extension composes vike-auth and the data extension owns data', () => {
assert.equal(frameworkAuth.capability, 'auth')
assert.equal(frameworkAuth.personas[0]!.name, 'vike-auth-composer')
assert.equal(frameworkData.capability, 'data')
assert.deepEqual([...builtinExtensionNames], builtinExtensions().map(e => e.name))
})

test('extensionPackageNames filters to the framework-* convention and excludes the core', () => {
const names = extensionPackageNames(
['react', 'framework-auth', '@gemstack/framework', '@acme/framework-sentry', 'vike', 'framework-auth'],
{ exclude: ['@gemstack/framework'] },
)
assert.deepEqual(names, ['@acme/framework-sentry', 'framework-auth']) // deduped + sorted, core excluded
})

test('isFrameworkExtension duck-types a loaded export', () => {
assert.ok(isFrameworkExtension(frameworkAuth))
assert.ok(!isFrameworkExtension({ name: 'x' }))
assert.ok(!isFrameworkExtension(null))
})

test('loadExtensionsFromModules loads good exports and reports bad ones without throwing', async () => {
const good = defineFrameworkExtension({ name: 'framework-good', capability: 'g' })
const modules: Record<string, unknown> = {
'framework-good': { default: good },
'framework-named': { extension: defineFrameworkExtension({ name: 'framework-named', capability: 'n' }) },
'framework-empty': { something: 1 },
'framework-throws': undefined, // triggers the throw branch below
}
const load = async (name: string) => {
if (name === 'framework-throws') throw new Error('boom')
return modules[name]
}
const { loaded, failed } = await loadExtensionsFromModules(
['framework-good', 'framework-named', 'framework-empty', 'framework-throws'],
load,
)
assert.deepEqual(loaded.map(l => l.package), ['framework-good', 'framework-named'])
assert.deepEqual(
failed.map(f => f.package),
['framework-empty', 'framework-throws'],
)
assert.match(failed.find(f => f.package === 'framework-throws')!.error, /boom/)
})
54 changes: 54 additions & 0 deletions packages/ai-autopilot/src/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* The framework extension SPI (#190) — The Framework made modular. Installed
* capability packages self-register instead of the CLI hardcoding the list. Two
* agnostic units:
*
* - {@link FrameworkExtension} — a capability (auth, data, ...) matched by
* signals or opt-in, defined with {@link defineFrameworkExtension}.
* - {@link Skill} — a doc pointer (an `llms.txt`), defined with {@link defineSkill};
* a framework (Vike) is a skill, not an adapter. Shared unit with Open Loop (#204).
*
* Match with {@link ExtensionRegistry} / {@link SkillRegistry}, compose personas
* with {@link composePersonas}, frame a skill with {@link skillInstructions}, and
* discover third-party `framework-*` packages with {@link loadExtensionsFromModules}.
*/
export { defineFrameworkExtension, defineSkill, ExtensionError } from './define.js'
export { matchSignals, selectActive } from './match.js'
export {
ExtensionRegistry,
SkillRegistry,
builtinExtensionRegistry,
builtinSkillRegistry,
type MatchOptions,
} from './registry.js'
export { composePersonas, skillInstructions, type ComposePersonasInput, type NeutralPersona } from './compose.js'
export {
frameworkAuth,
frameworkData,
frameworkRbac,
frameworkCrud,
frameworkShell,
builtinExtensions,
builtinExtensionNames,
vikeSkill,
builtinSkills,
neutralPersonas,
} from './library.js'
export {
EXTENSION_NAME_RE,
extensionPackageNames,
isFrameworkExtension,
loadExtensionsFromModules,
type LoadedExtension,
type FailedExtension,
type DiscoverResult,
} from './load.js'
export type {
FrameworkExtension,
FrameworkExtensionSpec,
Skill,
SkillSpec,
ExtensionSignals,
FrameworkSignals,
SignalMatch,
} from './types.js'
Loading
Loading