diff --git a/.changeset/framework-extension-spi.md b/.changeset/framework-extension-spi.md new file mode 100644 index 0000000..e4cf661 --- /dev/null +++ b/.changeset/framework-extension-spi.md @@ -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. diff --git a/packages/ai-autopilot/src/extensions/compose.ts b/packages/ai-autopilot/src/extensions/compose.ts new file mode 100644 index 0000000..c0e49cf --- /dev/null +++ b/packages/ai-autopilot/src/extensions/compose.ts @@ -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') +} diff --git a/packages/ai-autopilot/src/extensions/define.ts b/packages/ai-autopilot/src/extensions/define.ts new file mode 100644 index 0000000..3209f38 --- /dev/null +++ b/packages/ai-autopilot/src/extensions/define.ts @@ -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), + }) +} diff --git a/packages/ai-autopilot/src/extensions/extensions.test.ts b/packages/ai-autopilot/src/extensions/extensions.test.ts new file mode 100644 index 0000000..f5d154f --- /dev/null +++ b/packages/ai-autopilot/src/extensions/extensions.test.ts @@ -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 = { + '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/) +}) diff --git a/packages/ai-autopilot/src/extensions/index.ts b/packages/ai-autopilot/src/extensions/index.ts new file mode 100644 index 0000000..f594cb5 --- /dev/null +++ b/packages/ai-autopilot/src/extensions/index.ts @@ -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' diff --git a/packages/ai-autopilot/src/extensions/library.ts b/packages/ai-autopilot/src/extensions/library.ts new file mode 100644 index 0000000..c62ff5d --- /dev/null +++ b/packages/ai-autopilot/src/extensions/library.ts @@ -0,0 +1,104 @@ +import { + dataModeler, + uiIntentDesigner, + vikeAuthComposer, + vikeCrudComposer, + vikeDataModeler, + vikeRbacComposer, + vikeShellComposer, +} from '../personas/library.js' +import { defineFrameworkExtension, defineSkill } from './define.js' +import type { NeutralPersona } from './compose.js' +import type { FrameworkExtension, Skill } from './types.js' + +/** + * The built-in capability extensions — the vike-* composers, each packaged as a + * self-describing {@link FrameworkExtension} so `run.ts` composes them through + * the registry instead of a hardcoded, Vike-gated list. Each auto-activates when + * its package is present in the project, and can be opted in by name for a + * from-scratch build (where nothing is installed yet). Agnostic: a third-party + * `framework-*` package registers the same way. + */ + +/** Composes vike-auth for authentication instead of hand-rolling sessions/cookies/login. */ +export const frameworkAuth: FrameworkExtension = defineFrameworkExtension({ + name: 'framework-auth', + capability: 'auth', + personas: [vikeAuthComposer], + signals: { dependencies: ['vike-auth'] }, +}) + +/** Models domain data on the universal-orm data layer (one registered adapter, no ORM install). */ +export const frameworkData: FrameworkExtension = defineFrameworkExtension({ + name: 'framework-data', + capability: 'data', + personas: [vikeDataModeler], + signals: { dependencies: ['@universal-orm/core', '@vike-data/vike-schema', '@vike-data/universal-schema'] }, +}) + +/** Composes vike-rbac for roles/permissions (`can()`/`hasRole()`) instead of hand-rolled authz. */ +export const frameworkRbac: FrameworkExtension = defineFrameworkExtension({ + name: 'framework-rbac', + capability: 'rbac', + personas: [vikeRbacComposer], + signals: { dependencies: ['vike-rbac'] }, +}) + +/** Composes vike-crud / vike-admin to derive CRUD + admin UI from the schema. */ +export const frameworkCrud: FrameworkExtension = defineFrameworkExtension({ + name: 'framework-crud', + capability: 'crud', + personas: [vikeCrudComposer], + signals: { dependencies: ['vike-crud', 'vike-admin'] }, +}) + +/** Composes vike-themes + vike-layouts for styling and the app shell. */ +export const frameworkShell: FrameworkExtension = defineFrameworkExtension({ + name: 'framework-shell', + capability: 'shell', + personas: [vikeShellComposer], + signals: { dependencies: ['vike-themes', 'vike-layouts', 'vike-toolbar'] }, +}) + +/** + * The built-in capability extensions, in composition order (data leads so its + * modeler frames the agent before the composers that ride on it). + */ +export function builtinExtensions(): FrameworkExtension[] { + return [frameworkData, frameworkAuth, frameworkRbac, frameworkCrud, frameworkShell] +} + +/** The names of the built-in extensions — the opt-in set behind `--compose-extensions`. */ +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. + */ +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', + signals: { + dependencies: ['vike', 'vike-react', 'vike-vue', 'vike-solid'], + files: [/(^|\/)\+Page(\.[\w-]+)?\.[jt]sx?$/, /(^|\/)\+config\.[jt]s$/], + }, +}) + +/** The built-in skills (doc pointers). */ +export function builtinSkills(): Skill[] { + return [vikeSkill] +} + +/** + * The neutral default personas, keyed by the capability an active extension + * supersedes. `data` is the default Prisma modeler (replaced by `framework-data`); + * `ui` is the intent-based UI guardrail (no extension owns it, so it is always on). + */ +export const neutralPersonas: readonly NeutralPersona[] = Object.freeze([ + { capability: 'data', persona: dataModeler }, + { capability: 'ui', persona: uiIntentDesigner }, +]) diff --git a/packages/ai-autopilot/src/extensions/load.ts b/packages/ai-autopilot/src/extensions/load.ts new file mode 100644 index 0000000..ac744ae --- /dev/null +++ b/packages/ai-autopilot/src/extensions/load.ts @@ -0,0 +1,78 @@ +import type { FrameworkExtension } from './types.js' + +/** + * The `framework-*` package-name convention: a bare `framework-` or a + * scoped `@scope/framework-`. Matching packages in a project's + * dependencies are candidate {@link FrameworkExtension}s to discover. + */ +export const EXTENSION_NAME_RE = /^(?:@[a-z0-9-]+\/)?framework-[a-z0-9-]+$/ + +/** + * Filter a dependency list down to `framework-*` extension package names. + * Deduped and sorted for determinism; `exclude` drops packages that match the + * convention but are not extensions (e.g. the framework core `@gemstack/framework`). + */ +export function extensionPackageNames(deps: Iterable, opts: { exclude?: readonly string[] } = {}): string[] { + const exclude = new Set(opts.exclude ?? []) + return [...new Set(deps)].filter(name => EXTENSION_NAME_RE.test(name) && !exclude.has(name)).sort() +} + +/** A duck-typed check that a loaded module's export is a {@link FrameworkExtension}. */ +export function isFrameworkExtension(value: unknown): value is FrameworkExtension { + if (!value || typeof value !== 'object') return false + const v = value as Record + return ( + typeof v.name === 'string' && + typeof v.capability === 'string' && + Array.isArray(v.personas) && + Array.isArray(v.skills) && + typeof v.signals === 'object' && + v.signals !== null + ) +} + +/** A successfully discovered extension and the package it came from. */ +export interface LoadedExtension { + package: string + extension: FrameworkExtension +} + +/** A package that matched the convention but could not be loaded as an extension. */ +export interface FailedExtension { + package: string + error: string +} + +/** The outcome of {@link loadExtensionsFromModules}: what loaded and what didn't. */ +export interface DiscoverResult { + loaded: LoadedExtension[] + failed: FailedExtension[] +} + +/** + * Load {@link FrameworkExtension}s from `framework-*` package names using a + * caller-supplied `load` function (an `import`-like). Pure with respect to the + * filesystem: the CLI passes a loader that resolves from the user's workspace, + * tests pass a fake map — so the SPI stays testable without disk or real + * packages. An extension export is taken from `default`, then `extension`, then + * the module itself. A package that fails to load or exports no extension is + * collected in `failed`, never thrown, so one bad package cannot abort a run. + */ +export async function loadExtensionsFromModules( + packageNames: readonly string[], + load: (name: string) => Promise, +): Promise { + const loaded: LoadedExtension[] = [] + const failed: FailedExtension[] = [] + for (const name of packageNames) { + try { + const mod = (await load(name)) as Record | undefined + const candidate = mod?.['default'] ?? mod?.['extension'] ?? mod + if (isFrameworkExtension(candidate)) loaded.push({ package: name, extension: candidate }) + else failed.push({ package: name, error: 'no FrameworkExtension export (default/extension)' }) + } catch (err) { + failed.push({ package: name, error: err instanceof Error ? err.message : String(err) }) + } + } + return { loaded, failed } +} diff --git a/packages/ai-autopilot/src/extensions/match.ts b/packages/ai-autopilot/src/extensions/match.ts new file mode 100644 index 0000000..ecdc010 --- /dev/null +++ b/packages/ai-autopilot/src/extensions/match.ts @@ -0,0 +1,53 @@ +import type { ExtensionSignals, FrameworkSignals, SignalMatch } 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 { + if (!deps) return new Set() + return new Set(Array.isArray(deps) ? deps : Object.keys(deps)) +} + +/** + * Score a unit's {@link ExtensionSignals} against a project's + * {@link FrameworkSignals}. Deterministic and mirrors preset detection: deps + * weigh more than files. A `score > 0` means the unit is present in the project + * and should auto-activate. Unlike preset selection (exactly one wins), many + * extensions can match at once. + */ +export function matchSignals(signals: ExtensionSignals, project: FrameworkSignals): SignalMatch { + const deps = depNames(project.dependencies) + const files = project.files ?? [] + const reasons: string[] = [] + let score = 0 + + for (const dep of signals.dependencies ?? []) { + if (deps.has(dep)) { + score += DEP_WEIGHT + reasons.push(`dependency "${dep}"`) + } + } + for (const pattern of signals.files ?? []) { + if (files.some(f => pattern.test(f))) { + score += FILE_WEIGHT + reasons.push(`file matching ${pattern}`) + } + } + return { score, reasons } +} + +/** + * Select the units active for a project: those whose signals matched, unioned + * with any explicitly included by name (opt-in, regardless of signals — how a + * from-scratch build activates a capability whose package is not installed yet). + * Registration order is preserved. + */ +export function selectActive( + units: readonly T[], + project: FrameworkSignals, + include: Iterable = [], +): T[] { + const forced = new Set(include) + return units.filter(u => forced.has(u.name) || matchSignals(u.signals, project).score > 0) +} diff --git a/packages/ai-autopilot/src/extensions/registry.ts b/packages/ai-autopilot/src/extensions/registry.ts new file mode 100644 index 0000000..0a50b1f --- /dev/null +++ b/packages/ai-autopilot/src/extensions/registry.ts @@ -0,0 +1,96 @@ +import { selectActive } from './match.js' +import { builtinExtensions, builtinSkills } from './library.js' +import type { FrameworkExtension, FrameworkSignals, Skill } from './types.js' + +/** Options for {@link ExtensionRegistry.match} / {@link SkillRegistry.match}. */ +export interface MatchOptions { + /** Force these units active by name regardless of signals (explicit opt-in). */ + include?: readonly string[] +} + +/** + * A set of {@link FrameworkExtension}s with signal + opt-in matching. Register + * the built-ins (or discovered `framework-*` packages), then {@link match} the + * ones active for a project. The registry only decides *which* capabilities to + * compose; composition itself is `composePersonas`. + */ +export class ExtensionRegistry { + private readonly byName = new Map() + + constructor(extensions: readonly FrameworkExtension[] = builtinExtensions()) { + for (const e of extensions) this.byName.set(e.name, e) + } + + /** The extension with this name, or `undefined`. */ + get(name: string): FrameworkExtension | undefined { + return this.byName.get(name) + } + + /** All extensions, in registration order. */ + all(): FrameworkExtension[] { + return [...this.byName.values()] + } + + /** Add or replace an extension (e.g. a discovered third-party one). Returns `this`. */ + add(extension: FrameworkExtension): this { + this.byName.set(extension.name, extension) + return this + } + + /** Add many extensions. Returns `this`. */ + addAll(extensions: Iterable): this { + for (const e of extensions) this.add(e) + return this + } + + /** The extensions active for a project: signal-matched ∪ explicitly included. */ + match(project: FrameworkSignals, opts: MatchOptions = {}): FrameworkExtension[] { + return selectActive(this.all(), project, opts.include ?? []) + } +} + +/** A set of {@link Skill}s (doc pointers) with the same signal + opt-in matching. */ +export class SkillRegistry { + private readonly byName = new Map() + + constructor(skills: readonly Skill[] = builtinSkills()) { + for (const s of skills) this.byName.set(s.name, s) + } + + /** The skill with this name, or `undefined`. */ + get(name: string): Skill | undefined { + return this.byName.get(name) + } + + /** All skills, in registration order. */ + all(): Skill[] { + return [...this.byName.values()] + } + + /** Add or replace a skill. Returns `this`. */ + add(skill: Skill): this { + this.byName.set(skill.name, skill) + return this + } + + /** Add many skills. Returns `this`. */ + addAll(skills: Iterable): this { + for (const s of skills) this.add(s) + return this + } + + /** The skills active for a project: signal-matched ∪ explicitly included. */ + match(project: FrameworkSignals, opts: MatchOptions = {}): Skill[] { + return selectActive(this.all(), project, opts.include ?? []) + } +} + +/** The built-in extensions as a ready-to-use {@link ExtensionRegistry}. */ +export function builtinExtensionRegistry(): ExtensionRegistry { + return new ExtensionRegistry(builtinExtensions()) +} + +/** The built-in skills as a ready-to-use {@link SkillRegistry}. */ +export function builtinSkillRegistry(): SkillRegistry { + return new SkillRegistry(builtinSkills()) +} diff --git a/packages/ai-autopilot/src/extensions/types.ts b/packages/ai-autopilot/src/extensions/types.ts new file mode 100644 index 0000000..cdb766f --- /dev/null +++ b/packages/ai-autopilot/src/extensions/types.ts @@ -0,0 +1,89 @@ +import type { Persona } from '../personas/types.js' +import type { FrameworkSignals, PresetSignals } from '../presets/types.js' + +/** + * The framework extension SPI (#190). The Framework is modular: installed + * capability packages self-register instead of the CLI hardcoding the list. The + * model is two units, both agnostic (nothing is Vike-gated): + * + * - {@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). + * + * A framework is not a special package: it is a {@link Skill} pointing at its + * `llms.txt`. There is no adapter axis. + */ + +/** How a unit is recognized in a project — the same deps/files shape presets use. */ +export type ExtensionSignals = PresetSignals + +/** Re-export the project-side signal shape (a project's deps + file list) for callers. */ +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. + */ +export interface Skill { + /** Stable kebab-case id (e.g. `vike`). */ + readonly name: string + /** Human title (e.g. `Vike`). */ + readonly title: string + /** One-line summary of the knowledge this skill points at. */ + readonly description: string + /** The `llms.txt` (or other LLM-optimized doc) URL the agent should consult. */ + readonly url: string + /** When to auto-activate it; empty means opt-in only. */ + readonly signals: ExtensionSignals +} + +/** The author-facing shape for {@link defineSkill}; `signals` defaults to empty. */ +export interface SkillSpec { + name: string + title: string + description: string + url: string + signals?: ExtensionSignals +} + +/** + * A framework capability extension: a cross-cutting concern that self-registers + * and composes into an autopilot run. Agnostic — a matched extension frames the + * agent with its personas and pulls in its skills. The `capability` it owns lets + * it supersede the neutral default persona for that concern (e.g. a `data` + * extension replaces the default ORM modeler) so the agent never gets two + * conflicting personas for one concern. + */ +export interface FrameworkExtension { + /** Package/id, kebab-case, `framework-*` by convention (e.g. `framework-auth`). */ + readonly name: string + /** The concern it owns (e.g. `auth`, `data`) — the supersession key. */ + readonly capability: string + /** Personas it frames the agent with when active. */ + readonly personas: readonly Persona[] + /** Doc-pointer skills it pulls in when active. */ + readonly skills: readonly Skill[] + /** Deps/files that auto-activate it in a project. */ + readonly signals: ExtensionSignals +} + +/** The author-facing shape for {@link defineFrameworkExtension}; optional fields default to empty. */ +export interface FrameworkExtensionSpec { + name: string + capability: string + personas?: readonly Persona[] + skills?: readonly Skill[] + signals?: ExtensionSignals +} + +/** One unit's match against a project's {@link FrameworkSignals}. */ +export interface SignalMatch { + /** 0 when nothing matched; deps weigh more than files. */ + score: number + /** The concrete signals that matched, for narration. */ + reasons: string[] +} diff --git a/packages/ai-autopilot/src/index.ts b/packages/ai-autopilot/src/index.ts index 72ddff2..6da8b09 100644 --- a/packages/ai-autopilot/src/index.ts +++ b/packages/ai-autopilot/src/index.ts @@ -311,6 +311,45 @@ export { type PresetScore, type FrameworkDetection, } from './presets/index.js' +export { + defineFrameworkExtension, + defineSkill, + ExtensionError, + matchSignals, + selectActive, + ExtensionRegistry, + SkillRegistry, + builtinExtensionRegistry, + builtinSkillRegistry, + composePersonas, + skillInstructions, + frameworkAuth, + frameworkData, + frameworkRbac, + frameworkCrud, + frameworkShell, + builtinExtensions, + builtinExtensionNames, + vikeSkill, + builtinSkills, + neutralPersonas, + EXTENSION_NAME_RE, + extensionPackageNames, + isFrameworkExtension, + loadExtensionsFromModules, + type MatchOptions, + type ComposePersonasInput, + type NeutralPersona, + type LoadedExtension, + type FailedExtension, + type DiscoverResult, + type FrameworkExtension, + type FrameworkExtensionSpec, + type Skill, + type SkillSpec, + type ExtensionSignals, + type SignalMatch, +} from './extensions/index.js' export type { Subtask, PlannedSubtask, diff --git a/packages/framework/README.md b/packages/framework/README.md index 544acc1..ba4bd90 100644 --- a/packages/framework/README.md +++ b/packages/framework/README.md @@ -66,7 +66,7 @@ framework --fake Offline demo (no CLI, no model, deterministic). --cwd Workspace the agent builds in (default: cwd). --model Model to pass through to the wrapped agent. --scope How much app to build (default: full). - --compose-extensions Compose the vike-* extensions instead of hand-rolling them (Vike-only, opt-in). + --compose-extensions Opt the built-in capability extensions in (Vike-only; see below). --deploy Narrate a deploy decision (e.g. cloudflare, dokploy). --port Dashboard port (default: 4477). --no-dashboard Run headless. @@ -76,17 +76,62 @@ framework --fake Offline demo (no CLI, no model, deterministic). The live path needs the Claude Code CLI installed (`claude` on `PATH`). The `--fake` path needs neither a CLI nor a model, so it is what CI runs. -## Composing vike-* extensions - -By default the agent hand-rolls auth and models data with Prisma, a fully -publish-safe stack. Pass `--compose-extensions` (Vike-only) to instead frame the -agent with the **composer personas**: compose vike-auth for identity, the -universal-orm data layer for domain data, vike-rbac for roles/permissions, -vike-crud/vike-admin for schema-derived CRUD + admin UI, and vike-themes/vike-layouts -for styling and the app shell, rather than reinventing each. It is opt-in because -those vike-* packages currently resolve only inside the vike-data workspace, so the -default path is the one that stays publishable. See `@gemstack/ai-autopilot`'s -`vikeExtensionPersonas`. +## Extensions (#190) + +The Framework is modular: it composes **capability extensions** and **skills** +into the agent frame instead of hardcoding a fixed list. Nothing is framework-gated. + +- A **capability extension** (`framework-auth`, `framework-data`, ...) owns a + cross-cutting concern. When it matches a project it frames the agent with its + personas. An extension with the same `capability` as a built-in default + supersedes it (e.g. `framework-data` replaces the default ORM modeler), so the + agent never gets two conflicting personas for one concern. +- A **skill** is a doc pointer — an `llms.txt` the agent consults for + framework/domain knowledge. A framework is a skill, not an adapter package: + Vike is `https://vike.dev/llms.txt`. + +An extension activates two ways: by **signal** (one of its dependencies is in the +project's `package.json`) or by **opt-in** (`--compose-extensions` turns the +built-ins on, for a from-scratch build where nothing is installed yet). + +The built-ins are the vike-* composers: `framework-auth` (vike-auth for identity), +`framework-data` (the universal-orm data layer for domain data), `framework-rbac` +(vike-rbac for roles/permissions), `framework-crud` (vike-crud/vike-admin for +schema-derived CRUD + admin UI), and `framework-shell` (vike-themes/vike-layouts +for styling and the app shell). They resolve only inside the vike-data workspace, +so `--compose-extensions` is Vike-only and ignored on any other preset; the default +path (hand-rolled auth + Prisma) is the one that stays publishable. + +### Authoring a `framework-*` extension + +Publish a package named `framework-` (or `@scope/framework-`) whose +default export is a `FrameworkExtension`: + +```ts +// framework-sentry/src/index.ts +import { defineFrameworkExtension, definePersona, defineSkill } from '@gemstack/ai-autopilot' + +export default defineFrameworkExtension({ + name: 'framework-sentry', + capability: 'tracking', + // deps/files that auto-activate it in a project: + signals: { dependencies: ['@sentry/node', '@sentry/react'] }, + // personas frame the agent; skills point it at authoritative docs: + personas: [definePersona({ + name: 'error-tracker', + role: 'Wires Sentry for error tracking instead of hand-rolling logging', + systemPrompt: 'Install @sentry/node and wrap the server; never hand-roll error capture. ...', + })], + skills: [defineSkill({ + name: 'sentry', title: 'Sentry', description: 'Error tracking + performance.', + url: 'https://docs.sentry.io/llms.txt', + })], +}) +``` + +When a user's project depends on both `@gemstack/framework` and +`framework-sentry`, the CLI discovers the package, registers it, and composes it +whenever its signal matches — no change to the framework core. ## Status diff --git a/packages/framework/src/cli.ts b/packages/framework/src/cli.ts index e294e69..ad61dcf 100644 --- a/packages/framework/src/cli.ts +++ b/packages/framework/src/cli.ts @@ -7,6 +7,7 @@ import { startDashboard, type Dashboard } from './dashboard/index.js' import { formatFrameworkEvent, type FrameworkEvent } from './events.js' import { runFramework, type DeployDecision, type RunFrameworkOptions, type ServeConfig } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' +import { discoverExtensions, readProjectSignals } from './extensions.js' import { preflight } from './preflight.js' /** Where the CLI writes. Injectable so tests capture output. */ @@ -34,10 +35,10 @@ Options: --cwd Workspace the agent builds in (default: current directory). --model Model to pass through to the wrapped agent. --scope How much app to build (default: full). - --compose-extensions Compose the vike-* extensions (auth, data, rbac, crud, - themes/layouts) instead of hand-rolling them. Vike-only; - extensions resolve in the vike-data workspace - (default: off, hand-rolled + Prisma). + --compose-extensions Opt the built-in capability extensions in (auth, data, + rbac, crud, shell) so the agent composes them instead of + hand-rolling. Vike-only; installed framework-* extensions + auto-activate either way (default: off, hand-rolled + Prisma). --max-passes Full-fledged loop pass budget (default: 5). --permission-mode Claude Code permission mode: default | acceptEdits | bypassPermissions | plan (default: acceptEdits). @@ -318,18 +319,31 @@ export async function runCli(argv: string[], io: CliIO = defaultIO): Promise { + const dir = workspace({ dependencies: { 'vike-auth': '1.0.0' }, devDependencies: { vike: '0.4.0' } }) + const signals = readProjectSignals(dir) + const deps = signals.dependencies as Record + assert.ok('vike-auth' in deps) + assert.ok('vike' in deps) +}) + +test('readProjectSignals returns empty signals when there is no package.json', () => { + const dir = workspace(undefined) + assert.deepEqual(readProjectSignals(dir), {}) +}) + +test('discoverExtensions finds nothing when no framework-* packages are declared', async () => { + const dir = workspace({ dependencies: { vike: '0.4.0', react: '18.0.0' } }) + const result = await discoverExtensions(dir) + assert.deepEqual(result.extensions, []) + assert.deepEqual(result.failed, []) +}) + +test('discoverExtensions reports a declared-but-uninstalled framework-* package instead of throwing', async () => { + const dir = workspace({ dependencies: { 'framework-ghost': '1.0.0' } }) + const result = await discoverExtensions(dir) + assert.deepEqual(result.extensions, []) + assert.equal(result.failed.length, 1) + assert.equal(result.failed[0]!.package, 'framework-ghost') +}) diff --git a/packages/framework/src/extensions.ts b/packages/framework/src/extensions.ts new file mode 100644 index 0000000..f67ae8b --- /dev/null +++ b/packages/framework/src/extensions.ts @@ -0,0 +1,59 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { + extensionPackageNames, + loadExtensionsFromModules, + type FailedExtension, + type FrameworkExtension, + type FrameworkSignals, +} from '@gemstack/ai-autopilot' + +/** The framework core package — matches the `framework-*` convention loosely but is not an extension. */ +const FRAMEWORK_CORE = '@gemstack/framework' + +/** + * Read a project's detection signals from its `package.json`: the union of + * `dependencies` + `devDeps` names. Returns empty signals when there is no + * `package.json` (a from-scratch build in an empty workspace) so detection and + * extension activation simply find nothing rather than throwing. + */ +export function readProjectSignals(cwd: string): FrameworkSignals { + let pkg: { dependencies?: Record; devDependencies?: Record } + try { + pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')) + } catch { + return {} + } + const dependencies = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) } + return { dependencies } +} + +/** The result of {@link discoverExtensions}: what resolved and what didn't. */ +export interface DiscoverExtensionsResult { + extensions: FrameworkExtension[] + failed: FailedExtension[] +} + +/** + * Discover installed `framework-*` capability packages in `cwd` and load their + * {@link FrameworkExtension} exports. Resolves each package from the user's + * workspace (not the CLI's own tree), so a project that installs + * `framework-sentry` gets it composed without the CLI knowing about it. Bad or + * missing packages are reported in `failed`, never thrown — one broken extension + * cannot abort a run. + */ +export async function discoverExtensions(cwd: string, signals?: FrameworkSignals): Promise { + const deps = signals?.dependencies ?? readProjectSignals(cwd).dependencies ?? {} + const depNames = Array.isArray(deps) ? deps : Object.keys(deps) + const names = extensionPackageNames(depNames, { exclude: [FRAMEWORK_CORE] }) + if (names.length === 0) return { extensions: [], failed: [] } + + // Resolve extension packages from the user's workspace, not the CLI's tree. + const require = createRequire(pathToFileURL(join(cwd, 'package.json')).href) + const load = async (name: string): Promise => import(pathToFileURL(require.resolve(name)).href) + + const { loaded, failed } = await loadExtensionsFromModules(names, load) + return { extensions: loaded.map(l => l.extension), failed } +} diff --git a/packages/framework/src/run.test.ts b/packages/framework/src/run.test.ts index 4ecf90c..e6f33cc 100644 --- a/packages/framework/src/run.test.ts +++ b/packages/framework/src/run.test.ts @@ -1,5 +1,6 @@ import { strict as assert } from 'node:assert' import { test } from 'node:test' +import { defineFrameworkExtension, definePersona } from '@gemstack/ai-autopilot' import { DEFAULT_MAX_PASSES, runFramework } from './run.js' import { FAKE_DEPLOY, FAKE_INTENT, FAKE_SIGNALS, fakeDriver } from './fake-script.js' import type { Driver } from './driver/index.js' @@ -161,6 +162,37 @@ test('without --compose-extensions the default framing has no vike-auth (publish assert.doesNotMatch(system(), /vike-auth/) }) +test('Vike arrives as a skill (llms.txt pointer) in the framing (#190)', async () => { + const { driver, system } = recordingDriver() + await runFramework({ + intent: FAKE_INTENT, + driver, + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, // has vike-react -> the Vike skill activates + onEvent: () => {}, + }) + assert.match(system(), /https:\/\/vike\.dev\/llms\.txt/) +}) + +test('a registered extension auto-activates by its signal, no opt-in needed (#190)', async () => { + const { driver, system } = recordingDriver() + const audit = defineFrameworkExtension({ + name: 'framework-audit', + capability: 'audit', + personas: [definePersona({ name: 'auditor', role: 'audits', systemPrompt: 'AUDIT-LOG-EVERYTHING sentinel.' })], + signals: { dependencies: ['@prisma/client'] }, // present in FAKE_SIGNALS + }) + await runFramework({ + intent: FAKE_INTENT, + driver, + cwd: '/tmp/ws', + signals: FAKE_SIGNALS, + extensions: [audit], + onEvent: () => {}, + }) + assert.match(system(), /AUDIT-LOG-EVERYTHING sentinel/) +}) + test('the default pass budget is raised for from-scratch builds (#182)', () => { // 3 was too low: the first passes go to bootstrapping an empty workspace. assert.equal(DEFAULT_MAX_PASSES, 5) diff --git a/packages/framework/src/run.ts b/packages/framework/src/run.ts index 933984b..3980ea9 100644 --- a/packages/framework/src/run.ts +++ b/packages/framework/src/run.ts @@ -1,18 +1,23 @@ import { Bootstrap, DecisionLedger, + ExtensionRegistry, LocalRunner, + SkillRegistry, + builtinExtensionNames, builtinPresetRegistry, + composePersonas, mergeChecklists, + neutralPersonas, personaInstructions, - presetPersonas, serveCheck, - vikeExtensionPersonas, + skillInstructions, type BootstrapEvent, type BootstrapResult, type BootstrapScope, type DeployTarget, type FrameworkDetection, + type FrameworkExtension, type FrameworkSignals, type LocalRunnerSession, } from '@gemstack/ai-autopilot' @@ -77,15 +82,20 @@ export interface RunFrameworkOptions { /** Signals for preset detection (deps/files). Default: none, so the flagship preset wins. */ signals?: FrameworkSignals /** - * Compose the vike-* extensions instead of hand-rolling them: vike-auth for - * auth, the universal-orm data layer for domain data, vike-rbac for - * roles/permissions, vike-crud/vike-admin for the CRUD+admin UI, and - * vike-themes/vike-layouts for styling and the app shell. Frames the agent - * with the extension personas. Opt-in and Vike-only: the extensions resolve - * inside the vike-data workspace, so the default (hand-rolled + Prisma) path - * stays publish-safe. + * Opt the built-in capability extensions in (auth, data, rbac, crud, shell) so + * a from-scratch build is framed to compose them instead of hand-rolling + * auth/data/UI. Vike-only: the built-in composers resolve inside the vike-data + * workspace, so the opt-in is ignored on a non-Vike preset. Off by default: the + * publish-safe path (hand-rolled + Prisma) still stands, and installed + * extensions auto-activate by signal either way (see #190). */ composeExtensions?: boolean + /** + * Extra {@link FrameworkExtension}s to register on top of the built-ins — + * discovered `framework-*` packages from the project, or explicit ones. Each + * still activates by signal or opt-in; registering does not force it on. + */ + extensions?: readonly FrameworkExtension[] /** Max full-fledged passes. Default {@link DEFAULT_MAX_PASSES} (5). */ maxPasses?: number /** A deploy decision to narrate at the end. Omit to skip the deploy phase. */ @@ -162,25 +172,30 @@ export async function runFramework(opts: RunFrameworkOptions): Promise vike.dev/llms.txt) that ride the same seam. + const signals = opts.signals ?? {} + const { preset, detection } = builtinPresetRegistry().select(signals) + const optInBuiltins = opts.composeExtensions === true && preset.name === 'vike' + if (opts.composeExtensions && !optInBuiltins) { emit({ kind: 'log', - message: `--compose-extensions ignored: the vike-* extensions are Vike-only, but the detected preset is "${preset.name}". Using the hand-rolled + Prisma path.`, + message: `--compose-extensions ignored: the built-in extensions are Vike-only, but the detected preset is "${preset.name}". Using the hand-rolled + Prisma path.`, }) } - const personas = composeExtensions - ? presetPersonas(preset, vikeExtensionPersonas) - : presetPersonas(preset) - const system = personas.map(personaInstructions).join('\n\n') + const extensionRegistry = new ExtensionRegistry().addAll(opts.extensions ?? []) + const activeExtensions = extensionRegistry.match(signals, { + include: optInBuiltins ? builtinExtensionNames : [], + }) + const personas = composePersonas({ base: preset.personas, extensions: activeExtensions, neutral: neutralPersonas }) + const skills = new SkillRegistry().match(signals) + const system = [...personas.map(personaInstructions), ...skills.map(skillInstructions)].join('\n\n') // The session id is not known until the first driver turn returns, so a // templated link (`.../{sessionId}`) can only resolve later. A literal link is @@ -195,9 +210,11 @@ export async function runFramework(opts: RunFrameworkOptions): Promise e.name).join(' + ')}` : '' + const skillNote = skills.length ? `, ${skills.length} skill(s)` : '' emit({ kind: 'log', - message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)`, + message: `Detected ${detection.framework ?? preset.framework} (confidence ${detection.confidence}); framing with ${personas.length} persona(s)${extensionNote}${skillNote}`, }) // Watch the black box for its real session id (the {type:'result'} event) and