import type { ChiBase, ChiConfigChange, ChiModuleDefinition, ChiModuleStatus, JsonObject, PromptContribution, PromptContributionInput, Scope, } from "../contract"; import { ConfigStore, type LoadedConfig } from "./config"; import { inspectSchema, type EditableField, type SchemaInfo } from "./schema"; interface ModuleRecord { definition: ChiModuleDefinition; status: ChiModuleStatus; schema: SchemaInfo; config?: LoadedConfig; } export interface RegistryOptions { config: ConfigStore; } export interface RegistryConfigView { id: string; fields: readonly EditableField[]; parsed: JsonObject; globalData: JsonObject; projectData: JsonObject; } export const MAX_PROMPT_CONTRIBUTION_CHARS = 4000; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } function configChanges( previous: JsonObject, current: JsonObject, scope: Scope, ): readonly ChiConfigChange[] { const keys = new Set([...Object.keys(previous), ...Object.keys(current)]); return [...keys].flatMap((key) => { if (Object.is(previous[key], current[key])) return []; return [{ scope, key, previous: previous[key], current: current[key] }]; }); } export class ChiRegistry implements ChiBase { private readonly config: ConfigStore; private readonly records: ModuleRecord[] = []; private readonly byId = new Map(); private readonly duplicateIds = new Set(); private readonly promptContributions = new Map(); private readonly resolving = new Set(); private configUpdates: Promise = Promise.resolve(); private collecting = true; private initialized = false; constructor(options: RegistryOptions) { this.config = options.config; } register( definition: ChiModuleDefinition, ): void { if (!this.collecting) throw new Error("Chi Base is no longer collecting registrations"); const status: ChiModuleStatus = { id: definition.id, version: definition.version, dependencies: [...(definition.dependencies ?? [])], state: "registered", }; const record: ModuleRecord = { definition, status, schema: { empty: true, fields: [] }, }; this.records.push(record); if (!definition.id.trim()) { status.state = "failed"; status.diagnostic = "module ID must not be empty"; return; } const duplicate = this.byId.get(definition.id); if (duplicate) { const diagnostic = "duplicate module ID " + definition.id + "; conflicting registration rejected"; duplicate.status.state = "failed"; duplicate.status.diagnostic = diagnostic; status.state = "failed"; status.diagnostic = diagnostic; this.duplicateIds.add(definition.id); return; } this.byId.set(definition.id, record); try { record.schema = inspectSchema(definition.config.schema); } catch (error) { status.state = "failed"; status.diagnostic = errorMessage(error); throw error; } try { record.config = this.config.load(definition.id, definition.config); } catch (error) { status.state = "failed"; status.diagnostic = errorMessage(error); } } async initializeAll(): Promise { if (this.initialized) return; this.initialized = true; this.collecting = false; for (const record of this.records) await this.resolve(record, []); } get(id: string): TApi | undefined { const record = this.byId.get(id); if (!record || record.status.state !== "ready") return undefined; return record.definition.api as TApi | undefined; } require(id: string): TApi { const record = this.byId.get(id); if (!record) throw new Error("Chi module " + id + " is not registered"); if (record.status.state !== "ready") { throw new Error( "Chi module " + id + " is " + record.status.state + ": " + (record.status.diagnostic ?? "not ready"), ); } return record.definition.api as TApi; } list(): readonly ChiModuleStatus[] { return this.records.map(({ status }) => ({ ...status, dependencies: [...status.dependencies], })); } getDuplicateModuleIds(): readonly string[] { return [...this.duplicateIds]; } getConfig(id: string): TConfig | undefined { return this.byId.get(id)?.config?.parsed as TConfig | undefined; } async setConfigValue( id: string, scope: Scope, key: string, value: unknown | undefined, ): Promise { const update = this.configUpdates.then(async () => { const record = this.byId.get(id); if (!record?.config) throw new Error("Chi module " + id + " has no loaded configuration"); const previous = record.config.parsed; const current = await this.config.setValue(id, record.definition.config, scope, key, value); await record.definition.onConfigChange?.( this, current, configChanges(previous, current, scope), ); return current; }); this.configUpdates = update.then(() => undefined, () => undefined); return update; } setPromptContribution( moduleId: string, contribution: PromptContributionInput | undefined, ): void { if (!this.byId.has(moduleId)) { throw new Error("Chi module " + moduleId + " is not registered"); } if (contribution === undefined) { this.promptContributions.delete(moduleId); return; } const { priority, maxChars, content } = contribution; if (!Number.isFinite(priority)) { throw new Error("prompt contribution from " + moduleId + " has a non-finite priority"); } if (!Number.isInteger(maxChars) || maxChars <= 0 || maxChars > MAX_PROMPT_CONTRIBUTION_CHARS) { throw new Error( "prompt contribution from " + moduleId + " must declare maxChars between 1 and " + MAX_PROMPT_CONTRIBUTION_CHARS, ); } if (content.length > maxChars) { throw new Error( "prompt contribution from " + moduleId + " is " + content.length + " chars, over its declared maxChars " + maxChars, ); } this.promptContributions.set(moduleId, { moduleId, priority, maxChars, content, updatedAt: new Date().toISOString(), }); } getPromptContributions(): readonly PromptContribution[] { return [...this.promptContributions.values()].sort( (a, b) => a.priority - b.priority || a.moduleId.localeCompare(b.moduleId), ); } renderPromptSections(): string | undefined { const contributions = this.getPromptContributions().filter(({ content }) => content.trim()); if (contributions.length === 0) return undefined; return contributions .map(({ moduleId, content }) => "## Chi module: " + moduleId + "\n\n" + content.trim()) .join("\n\n"); } getConfigViews(): readonly RegistryConfigView[] { return this.records.flatMap((record) => { if (!record.config) return []; return [{ id: record.status.id, fields: record.schema.fields, parsed: record.config.parsed, globalData: record.config.globalData, projectData: record.config.projectData, }]; }); } private async resolve(record: ModuleRecord, stack: readonly string[]): Promise { if (record.status.state === "ready") return true; if (record.status.state === "failed" || record.status.state === "blocked") return false; if (this.resolving.has(record.status.id)) { const cycleStart = stack.indexOf(record.status.id); const cycle = cycleStart < 0 ? [record.status.id] : stack.slice(cycleStart); const diagnostic = "dependency cycle: " + [...cycle, record.status.id].join(" -> "); for (const id of cycle) { const member = this.byId.get(id); if (member && member.status.state !== "failed") { member.status.state = "blocked"; member.status.diagnostic = diagnostic; } } return false; } this.resolving.add(record.status.id); record.status.state = "initializing"; try { for (const dependencyId of record.status.dependencies) { const dependency = this.byId.get(dependencyId); if (!dependency) { this.block(record, "missing dependency " + dependencyId); return false; } if (!(await this.resolve(dependency, [...stack, record.status.id]))) { if (record.status.state === "initializing") { this.block(record, "dependency " + dependencyId + " is not ready"); } return false; } } if (!record.config) { this.fail(record, "configuration was not loaded"); return false; } try { await record.definition.initialize(this, record.config.parsed); } catch (error) { this.fail(record, errorMessage(error)); return false; } record.status.state = "ready"; return true; } finally { this.resolving.delete(record.status.id); } } private block(record: ModuleRecord, cause: string): void { record.status.state = "blocked"; record.status.diagnostic = "module " + record.status.id + " blocked: " + cause; } private fail(record: ModuleRecord, cause: string): void { record.status.state = "failed"; record.status.diagnostic = "module " + record.status.id + " failed: " + cause; } }