entangled-worlds-orbiters-shared 2.17.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,52 +0,0 @@
1
- /**
2
- * Tiny JSON-Schema-subset validator (shared machinery, TypeScript).
3
- *
4
- * The ONE validator both registries use (supersedes the duplicated
5
- * `studio/capabilities/schema.js` + `core/edit/capabilities/schema.ts`). Deliberately NOT a
6
- * dependency (no zod/ajv) — the app is bundle-weight sensitive and the house style is
7
- * lightweight guard clauses. Each capability carries an `inputSchema` literal in this subset;
8
- * the SAME literal is what the future agent layer hands the model as a tool definition, so
9
- * there is no second schema language to keep in sync.
10
- *
11
- * Supported keywords (a pragmatic subset of JSON Schema draft-07):
12
- * type 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
13
- * (or an array of those — value matches if it satisfies any)
14
- * properties per-key subschemas (objects)
15
- * required array of keys that must be present and not undefined (objects)
16
- * items subschema applied to every array element
17
- * enum value must be one of the listed values
18
- * const value must deep-equal the given value
19
- * nullable when true, null always passes regardless of type
20
- * minimum / maximum numbers
21
- * minLength / maxLength strings
22
- * minItems / maxItems arrays
23
- * pattern string regex source (tested with RegExp)
24
- *
25
- * A schema with no `type` accepts any value (still honouring enum/const/properties if set).
26
- * Unknown keywords are ignored — forward-compatible, never throws on shape it doesn't know.
27
- */
28
- import { type SchemaIssue } from './errors.js';
29
- export type JsonType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';
30
- export interface JsonSchema {
31
- type?: JsonType | JsonType[];
32
- properties?: Record<string, JsonSchema>;
33
- required?: string[];
34
- items?: JsonSchema;
35
- enum?: unknown[];
36
- const?: unknown;
37
- nullable?: boolean;
38
- minimum?: number;
39
- maximum?: number;
40
- minLength?: number;
41
- maxLength?: number;
42
- minItems?: number;
43
- maxItems?: number;
44
- pattern?: string;
45
- }
46
- /** Returns { ok, issues }. Never throws. */
47
- export declare function validate(schema: JsonSchema, value: unknown): {
48
- ok: boolean;
49
- issues: SchemaIssue[];
50
- };
51
- /** Throws ValidationError when invalid; returns the value otherwise. Used by the registry. */
52
- export declare function assertValid<T>(schema: JsonSchema, value: T, capability?: string | null): T;
@@ -1,139 +0,0 @@
1
- /**
2
- * Tiny JSON-Schema-subset validator (shared machinery, TypeScript).
3
- *
4
- * The ONE validator both registries use (supersedes the duplicated
5
- * `studio/capabilities/schema.js` + `core/edit/capabilities/schema.ts`). Deliberately NOT a
6
- * dependency (no zod/ajv) — the app is bundle-weight sensitive and the house style is
7
- * lightweight guard clauses. Each capability carries an `inputSchema` literal in this subset;
8
- * the SAME literal is what the future agent layer hands the model as a tool definition, so
9
- * there is no second schema language to keep in sync.
10
- *
11
- * Supported keywords (a pragmatic subset of JSON Schema draft-07):
12
- * type 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
13
- * (or an array of those — value matches if it satisfies any)
14
- * properties per-key subschemas (objects)
15
- * required array of keys that must be present and not undefined (objects)
16
- * items subschema applied to every array element
17
- * enum value must be one of the listed values
18
- * const value must deep-equal the given value
19
- * nullable when true, null always passes regardless of type
20
- * minimum / maximum numbers
21
- * minLength / maxLength strings
22
- * minItems / maxItems arrays
23
- * pattern string regex source (tested with RegExp)
24
- *
25
- * A schema with no `type` accepts any value (still honouring enum/const/properties if set).
26
- * Unknown keywords are ignored — forward-compatible, never throws on shape it doesn't know.
27
- */
28
- import { ValidationError } from './errors.js';
29
- const TYPE_CHECKS = {
30
- object: (v) => v !== null && typeof v === 'object' && !Array.isArray(v),
31
- array: (v) => Array.isArray(v),
32
- string: (v) => typeof v === 'string',
33
- number: (v) => typeof v === 'number' && Number.isFinite(v),
34
- integer: (v) => typeof v === 'number' && Number.isInteger(v),
35
- boolean: (v) => typeof v === 'boolean',
36
- null: (v) => v === null,
37
- };
38
- function deepEqual(a, b) {
39
- if (a === b)
40
- return true;
41
- if (typeof a !== typeof b || a === null || b === null || typeof a !== 'object')
42
- return false;
43
- const ka = Object.keys(a);
44
- const kb = Object.keys(b);
45
- if (ka.length !== kb.length)
46
- return false;
47
- return ka.every((k) => deepEqual(a[k], b[k]));
48
- }
49
- function checkType(schema, value) {
50
- if (schema.type == null)
51
- return true;
52
- const types = Array.isArray(schema.type) ? schema.type : [schema.type];
53
- return types.some((t) => (TYPE_CHECKS[t] ? TYPE_CHECKS[t](value) : true));
54
- }
55
- function describeType(value) {
56
- if (value === null)
57
- return 'null';
58
- if (Array.isArray(value))
59
- return 'array';
60
- return typeof value;
61
- }
62
- function validateNode(schema, value, path, issues) {
63
- if (!schema || typeof schema !== 'object')
64
- return;
65
- const at = path || '(root)';
66
- if (schema.nullable && value === null)
67
- return;
68
- if (!checkType(schema, value)) {
69
- const want = Array.isArray(schema.type) ? schema.type.join('|') : schema.type;
70
- issues.push({ path: at, message: `expected type ${want}, got ${describeType(value)}` });
71
- return; // type is the gate; downstream checks would be noise
72
- }
73
- if ('const' in schema && !deepEqual(value, schema.const)) {
74
- issues.push({ path: at, message: `must equal ${JSON.stringify(schema.const)}` });
75
- }
76
- if (Array.isArray(schema.enum) && !schema.enum.some((e) => deepEqual(e, value))) {
77
- issues.push({ path: at, message: `must be one of ${JSON.stringify(schema.enum)}` });
78
- }
79
- if (typeof value === 'number') {
80
- if (typeof schema.minimum === 'number' && value < schema.minimum) {
81
- issues.push({ path: at, message: `must be >= ${schema.minimum}` });
82
- }
83
- if (typeof schema.maximum === 'number' && value > schema.maximum) {
84
- issues.push({ path: at, message: `must be <= ${schema.maximum}` });
85
- }
86
- }
87
- if (typeof value === 'string') {
88
- if (typeof schema.minLength === 'number' && value.length < schema.minLength) {
89
- issues.push({ path: at, message: `must be at least ${schema.minLength} chars` });
90
- }
91
- if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) {
92
- issues.push({ path: at, message: `must be at most ${schema.maxLength} chars` });
93
- }
94
- if (typeof schema.pattern === 'string' && !new RegExp(schema.pattern).test(value)) {
95
- issues.push({ path: at, message: `must match /${schema.pattern}/` });
96
- }
97
- }
98
- if (Array.isArray(value)) {
99
- if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
100
- issues.push({ path: at, message: `must have at least ${schema.minItems} items` });
101
- }
102
- if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
103
- issues.push({ path: at, message: `must have at most ${schema.maxItems} items` });
104
- }
105
- if (schema.items) {
106
- value.forEach((el, i) => validateNode(schema.items, el, `${path}[${i}]`, issues));
107
- }
108
- }
109
- if (TYPE_CHECKS.object(value)) {
110
- const obj = value;
111
- if (Array.isArray(schema.required)) {
112
- for (const key of schema.required) {
113
- if (obj[key] === undefined)
114
- issues.push({ path: path ? `${path}.${key}` : key, message: 'is required' });
115
- }
116
- }
117
- if (schema.properties) {
118
- for (const [key, sub] of Object.entries(schema.properties)) {
119
- if (obj[key] !== undefined)
120
- validateNode(sub, obj[key], path ? `${path}.${key}` : key, issues);
121
- }
122
- }
123
- }
124
- }
125
- /** Returns { ok, issues }. Never throws. */
126
- export function validate(schema, value) {
127
- const issues = [];
128
- validateNode(schema ?? {}, value, '', issues);
129
- return { ok: issues.length === 0, issues };
130
- }
131
- /** Throws ValidationError when invalid; returns the value otherwise. Used by the registry. */
132
- export function assertValid(schema, value, capability = null) {
133
- const { ok, issues } = validate(schema, value);
134
- if (!ok) {
135
- const summary = issues.map((i) => `${i.path}: ${i.message}`).join('; ');
136
- throw new ValidationError(`Invalid input${capability ? ` for "${capability}"` : ''}: ${summary}`, { capability, issues });
137
- }
138
- return value;
139
- }
@@ -1,115 +0,0 @@
1
- /**
2
- * Capability layer error taxonomy (shared machinery).
3
- *
4
- * This is the ONE generic capability machinery, app-agnostic and layer-neutral
5
- * (`src/capabilities/` imports nothing app-level — see `.agents/decisions/`). Both the studio
6
- * registry (`src/studio/capabilities/`, the credit-metered superset caller) and the edit-mode
7
- * registry (`src/core/edit/capabilities/`, the lean preview caller) build on it. It supersedes
8
- * the previously duplicated `studio/capabilities/errors.js` + `core/edit/capabilities/errors.ts`.
9
- *
10
- * One base class so callers — today the UI, tomorrow the agent loop — branch on `err.code`
11
- * instead of string-matching messages. Every error carries the offending capability name when
12
- * known, so failures are traceable without a stack dive.
13
- */
14
-
15
- export type CapabilityErrorCode =
16
- | 'CAPABILITY_ERROR'
17
- | 'VALIDATION'
18
- | 'UNAVAILABLE'
19
- | 'PRECHECK_FAILED'
20
- | 'NOT_IMPLEMENTED'
21
- | 'INSUFFICIENT_CREDITS'
22
- | 'UNKNOWN_CAPABILITY'
23
-
24
- export interface SchemaIssue {
25
- path: string
26
- message: string
27
- }
28
-
29
- export class CapabilityError extends Error {
30
- code: CapabilityErrorCode
31
- capability: string | null
32
- cause?: unknown
33
- constructor(
34
- message: string,
35
- { code = 'CAPABILITY_ERROR', capability = null, cause = null }:
36
- { code?: CapabilityErrorCode; capability?: string | null; cause?: unknown } = {},
37
- ) {
38
- super(message)
39
- this.name = 'CapabilityError'
40
- this.code = code
41
- this.capability = capability
42
- if (cause) this.cause = cause
43
- }
44
- }
45
-
46
- /** Input args failed the capability's inputSchema. Thrown before any write. */
47
- export class ValidationError extends CapabilityError {
48
- issues: SchemaIssue[]
49
- constructor(message: string, { capability = null, issues = [] }: { capability?: string | null; issues?: SchemaIssue[] } = {}) {
50
- super(message, { code: 'VALIDATION', capability })
51
- this.name = 'ValidationError'
52
- this.issues = issues
53
- }
54
- }
55
-
56
- /** Capability is declared but not invokable yet (status: 'planned' / available()===false). */
57
- export class UnavailableError extends CapabilityError {
58
- reason: string | null
59
- constructor(message: string, { capability = null, reason = null }: { capability?: string | null; reason?: string | null } = {}) {
60
- super(message, { code: 'UNAVAILABLE', capability })
61
- this.name = 'UnavailableError'
62
- this.reason = reason
63
- }
64
- }
65
-
66
- /**
67
- * Capability's precheck rejected the args against live state (no-op target, missing element,
68
- * guard condition). Thrown before pushHistory / any write.
69
- */
70
- export class PrecheckError extends CapabilityError {
71
- reason: string | null
72
- constructor(message: string, { capability = null, reason = null }: { capability?: string | null; reason?: string | null } = {}) {
73
- super(message, { code: 'PRECHECK_FAILED', capability })
74
- this.name = 'PrecheckError'
75
- this.reason = reason
76
- }
77
- }
78
-
79
- /**
80
- * Wired capability whose execute() is not implemented (generative stubs). Distinct from
81
- * UnavailableError: "active but unfinished", reachable only if status was flipped to 'active'
82
- * before the implementation landed.
83
- */
84
- export class NotImplementedError extends CapabilityError {
85
- constructor(capability: string, message: string | null = null) {
86
- super(message || `Capability "${capability}" is not implemented yet`, { code: 'NOT_IMPLEMENTED', capability })
87
- this.name = 'NotImplementedError'
88
- }
89
- }
90
-
91
- /**
92
- * Reserved for the future generative path: the backend credit check failed (402-style).
93
- * Declared now so the agent loop can catch it without a follow-up refactor.
94
- */
95
- export class InsufficientCreditsError extends CapabilityError {
96
- required: number | null
97
- available: number | null
98
- constructor(
99
- message: string,
100
- { capability = null, required = null, available = null }:
101
- { capability?: string | null; required?: number | null; available?: number | null } = {},
102
- ) {
103
- super(message, { code: 'INSUFFICIENT_CREDITS', capability })
104
- this.name = 'InsufficientCreditsError'
105
- this.required = required
106
- this.available = available
107
- }
108
- }
109
-
110
- export class UnknownCapabilityError extends CapabilityError {
111
- constructor(name: string) {
112
- super(`Unknown capability "${name}"`, { code: 'UNKNOWN_CAPABILITY', capability: name })
113
- this.name = 'UnknownCapabilityError'
114
- }
115
- }
@@ -1,22 +0,0 @@
1
- /**
2
- * Shared capability machinery — app-agnostic, layer-neutral registry + validator +
3
- * error taxonomy. Both `src/studio/capabilities/` and `src/core/edit/capabilities/` build their
4
- * own registry instances and descriptors on top of this. Imports nothing app-level (see
5
- * `.agents/decisions/`). This is the generic core of the AI-ready capability seam.
6
- */
7
-
8
- export {
9
- createCapabilityRegistry,
10
- type CapabilityRegistry,
11
- type CapabilityDescriptor,
12
- type CapabilityInfo,
13
- type CapabilityKind,
14
- type CapabilityStatus,
15
- type Reactivity,
16
- type InvokeMeta,
17
- type InvokeMode,
18
- type PrecheckResult,
19
- type RegistryCtx,
20
- } from './registry.js'
21
- export { validate, assertValid, type JsonSchema, type JsonType } from './schema.js'
22
- export * from './errors.js'
@@ -1,307 +0,0 @@
1
- /**
2
- * Capability registry — the single, named, validated chokepoint for edits (shared machinery).
3
- *
4
- * The ONE registry both callers build on (supersedes the duplicated
5
- * `studio/capabilities/registry.js` + `core/edit/capabilities/registry.ts`). It is the SUPERSET:
6
- * the studio uses the full pipeline (precheck → reversible history → credit cost → generative),
7
- * the edit-mode caller uses the lean subset (validation + named dispatch + the preview/commit
8
- * `mode`) and simply leaves the extra features unset. App-agnostic: `src/capabilities/` imports
9
- * nothing app-level (see `.agents/decisions/`); all state/functions arrive via the injected ctx.
10
- *
11
- * Today the UI calls invoke()/invokeSync() with source:'ui'; tomorrow the agent loop calls the
12
- * SAME registry with source:'agent'. Every mutation flows through one pipeline so history,
13
- * validation, and availability are enforced in exactly one place.
14
- *
15
- * invoke() pipeline:
16
- * 1. resolve unknown name -> UnknownCapabilityError
17
- * 2. assertAvailable status:'planned' / available(ctx)===false -> UnavailableError (no writes)
18
- * 3. validate args vs inputSchema -> ValidationError (no writes)
19
- * 4. precheck PURE, no writes. Returns { ok, reason, willMutate }. !ok -> PrecheckError
20
- * 5. commit only now: if reversible && willMutate !== false -> ctx.pushHistory()
21
- * 6. execute the actual write
22
- *
23
- * `mode` is the edit-mode preview/commit distinction (ignored by studio capabilities):
24
- * 'preview' → live side effect only, no host broadcast 'commit' → side effect + emit.
25
- */
26
-
27
- import {
28
- PrecheckError,
29
- UnavailableError,
30
- UnknownCapabilityError,
31
- ValidationError,
32
- } from './errors.js'
33
- import { assertValid, type JsonSchema } from './schema.js'
34
-
35
- export type CapabilityKind = 'mutation' | 'query' | 'generative'
36
- export type Reactivity = 'live' | 'structural'
37
- export type CapabilityStatus = 'active' | 'planned'
38
- export type InvokeMode = 'preview' | 'commit'
39
-
40
- export interface InvokeMeta {
41
- source: string
42
- mode: InvokeMode
43
- }
44
-
45
- export interface PrecheckResult {
46
- ok: boolean
47
- reason?: string | null
48
- /** false suppresses the no-op history snapshot at the commit boundary. */
49
- willMutate?: boolean
50
- }
51
-
52
- /**
53
- * The optional history hook the registry calls at the commit boundary for reversible mutations.
54
- * A caller's ctx may include it (the studio does) or not (edit-mode has no history). It is NOT a
55
- * generic constraint — the ctx type is free-form per caller — the registry just reads pushHistory
56
- * off it when present.
57
- */
58
- export interface RegistryCtx {
59
- pushHistory?: () => void
60
- }
61
-
62
- export interface CapabilityDescriptor<Ctx = Record<string, unknown>, Args = Record<string, unknown>> {
63
- name: string
64
- kind: CapabilityKind
65
- description?: string
66
- inputSchema?: JsonSchema
67
- status?: CapabilityStatus
68
- reversible?: boolean
69
- /** Cost class of applying the edit: 'live' (cheap in-place) vs 'structural' (rebuild). */
70
- reactivity?: Reactivity
71
- available?: (ctx: Ctx) => boolean
72
- cost?: (args: Args, ctx: Ctx) => number
73
- precheck?: (args: Args, ctx: Ctx, meta: InvokeMeta) => PrecheckResult | Promise<PrecheckResult>
74
- execute: (args: Args, ctx: Ctx, meta: InvokeMeta) => unknown
75
- }
76
-
77
- /** Public, function-free view of a capability — what the agent tool-catalog builder consumes. */
78
- export interface CapabilityInfo {
79
- name: string
80
- kind: CapabilityKind
81
- status: CapabilityStatus
82
- description: string
83
- inputSchema: JsonSchema
84
- reversible: boolean
85
- reactivity: Reactivity | null
86
- }
87
-
88
- interface NormalizedCapability<Ctx> {
89
- name: string
90
- kind: CapabilityKind
91
- status: CapabilityStatus
92
- description: string
93
- inputSchema: JsonSchema
94
- reversible: boolean
95
- reactivity: Reactivity | null
96
- available: (ctx: Ctx) => boolean
97
- cost: (args: never, ctx: Ctx) => number
98
- precheck: (args: never, ctx: Ctx, meta: InvokeMeta) => PrecheckResult | Promise<PrecheckResult>
99
- execute: (args: never, ctx: Ctx, meta: InvokeMeta) => unknown
100
- }
101
-
102
- const VALID_KINDS = new Set<CapabilityKind>(['mutation', 'query', 'generative'])
103
-
104
- // Fill descriptor defaults once at registration so invoke() stays branch-light.
105
- function normalize<Ctx>(descriptor: CapabilityDescriptor<Ctx>): NormalizedCapability<Ctx> {
106
- if (!descriptor || typeof descriptor !== 'object') throw new Error('[capabilities] descriptor must be an object')
107
- const { name, kind } = descriptor
108
- if (typeof name !== 'string' || !name) throw new Error('[capabilities] descriptor.name is required')
109
- if (!VALID_KINDS.has(kind)) {
110
- throw new Error(`[capabilities] "${name}" has invalid kind "${kind}" (expected mutation|query|generative)`)
111
- }
112
- const status: CapabilityStatus = descriptor.status ?? 'active'
113
- if (status !== 'active' && status !== 'planned') {
114
- throw new Error(`[capabilities] "${name}" has invalid status "${status}" (expected active|planned)`)
115
- }
116
- if (typeof descriptor.execute !== 'function') throw new Error(`[capabilities] "${name}" must define an execute() function`)
117
- return {
118
- name,
119
- kind,
120
- status,
121
- description: descriptor.description ?? '',
122
- inputSchema: descriptor.inputSchema ?? { type: 'object' },
123
- reversible: descriptor.reversible === true,
124
- reactivity: descriptor.reactivity ?? null,
125
- available: typeof descriptor.available === 'function' ? descriptor.available : () => status === 'active',
126
- cost: (typeof descriptor.cost === 'function' ? descriptor.cost : () => 0) as NormalizedCapability<Ctx>['cost'],
127
- precheck: (typeof descriptor.precheck === 'function' ? descriptor.precheck : () => ({ ok: true })) as NormalizedCapability<Ctx>['precheck'],
128
- execute: descriptor.execute as NormalizedCapability<Ctx>['execute'],
129
- }
130
- }
131
-
132
- export interface CapabilityRegistry<Ctx = Record<string, unknown>> {
133
- register: (descriptor: CapabilityDescriptor<Ctx>) => NormalizedCapability<Ctx>
134
- registerAll: (descriptors: CapabilityDescriptor<Ctx>[]) => CapabilityRegistry<Ctx>
135
- has: (name: string) => boolean
136
- get: (name: string) => NormalizedCapability<Ctx> | null
137
- /** Public catalog view of one capability by name (null if unknown). */
138
- describe: (name: string) => CapabilityInfo | null
139
- list: (opts?: { includePlanned?: boolean; kind?: CapabilityKind | null }) => CapabilityInfo[]
140
- isAvailable: (name: string) => boolean
141
- cost: (name: string, args?: Record<string, unknown>) => number
142
- invoke: (name: string, args?: Record<string, unknown>, opts?: Partial<InvokeMeta>) => Promise<unknown>
143
- invokeSync: (name: string, args?: Record<string, unknown>, opts?: Partial<InvokeMeta>) => unknown
144
- }
145
-
146
- export function createCapabilityRegistry<Ctx = Record<string, unknown>>(
147
- { ctx = {} as Ctx }: { ctx?: Ctx } = {},
148
- ): CapabilityRegistry<Ctx> {
149
- const caps = new Map<string, NormalizedCapability<Ctx>>()
150
-
151
- function register(descriptor: CapabilityDescriptor<Ctx>): NormalizedCapability<Ctx> {
152
- const cap = normalize(descriptor)
153
- if (caps.has(cap.name)) throw new Error(`[capabilities] "${cap.name}" is already registered`)
154
- caps.set(cap.name, cap)
155
- return cap
156
- }
157
-
158
- const has = (name: string) => caps.has(name)
159
- const get = (name: string) => caps.get(name) || null
160
-
161
- // Function-free catalog view of a capability — never leaks execute/precheck to catalog-only
162
- // callers (the future tool-schema builder).
163
- function info(cap: NormalizedCapability<Ctx>): CapabilityInfo {
164
- return {
165
- name: cap.name,
166
- kind: cap.kind,
167
- status: cap.status,
168
- description: cap.description,
169
- inputSchema: cap.inputSchema,
170
- reversible: cap.reversible,
171
- reactivity: cap.reactivity,
172
- }
173
- }
174
-
175
- function describe(name: string): CapabilityInfo | null {
176
- const cap = get(name)
177
- return cap ? info(cap) : null
178
- }
179
-
180
- function list({ includePlanned = false, kind = null }: { includePlanned?: boolean; kind?: CapabilityKind | null } = {}): CapabilityInfo[] {
181
- const out: CapabilityInfo[] = []
182
- for (const cap of caps.values()) {
183
- if (!includePlanned && cap.status !== 'active') continue
184
- if (kind && cap.kind !== kind) continue
185
- out.push(info(cap))
186
- }
187
- return out
188
- }
189
-
190
- function isAvailable(name: string): boolean {
191
- const cap = get(name)
192
- if (!cap || cap.status !== 'active') return false
193
- try {
194
- return cap.available(ctx) !== false
195
- } catch {
196
- return false
197
- }
198
- }
199
-
200
- function cost(name: string, args: Record<string, unknown> = {}): number {
201
- const cap = get(name)
202
- if (!cap) return 0
203
- try {
204
- return Number(cap.cost(args as never, ctx)) || 0
205
- } catch {
206
- return 0
207
- }
208
- }
209
-
210
- // Steps 1–3 (resolve → availability → validate): pure, no side effects. Shared by both paths.
211
- function prepareInvocation(name: string, args: Record<string, unknown>, meta: InvokeMeta): NormalizedCapability<Ctx> {
212
- const cap = get(name)
213
- if (!cap) throw new UnknownCapabilityError(name)
214
- if (cap.status !== 'active') {
215
- throw new UnavailableError(`Capability "${name}" is declared but not available yet`, { capability: name, reason: `status:${cap.status}` })
216
- }
217
- let availResult: boolean
218
- try {
219
- availResult = cap.available(ctx)
220
- } catch (err) {
221
- throw new UnavailableError(`Capability "${name}" availability check threw`, { capability: name, reason: (err as Error)?.message || String(err) })
222
- }
223
- if (availResult === false) {
224
- throw new UnavailableError(`Capability "${name}" is not currently available`, { capability: name, reason: 'available(ctx) returned false' })
225
- }
226
- assertValid(cap.inputSchema, args, name)
227
- void meta
228
- return cap
229
- }
230
-
231
- function wrapPrecheckError(name: string, err: unknown): Error {
232
- if (err instanceof ValidationError || err instanceof PrecheckError) return err
233
- return new PrecheckError(`Precheck for "${name}" threw: ${(err as Error)?.message || err}`, { capability: name, reason: (err as Error)?.message || String(err) })
234
- }
235
-
236
- function resolvePrecheck(name: string, pre: PrecheckResult | undefined): PrecheckResult {
237
- const r = pre || { ok: true }
238
- if (r.ok === false) {
239
- throw new PrecheckError(`Precheck for "${name}" rejected: ${r.reason || 'no reason given'}`, { capability: name, reason: r.reason ?? null })
240
- }
241
- return r
242
- }
243
-
244
- // commit boundary — snapshot only a change that will actually mutate.
245
- function commitHistory(cap: NormalizedCapability<Ctx>, pre: PrecheckResult): void {
246
- const pushHistory = (ctx as unknown as RegistryCtx)?.pushHistory
247
- if (cap.reversible && pre.willMutate !== false && typeof pushHistory === 'function') {
248
- pushHistory()
249
- }
250
- }
251
-
252
- const fullMeta = (opts?: Partial<InvokeMeta>): InvokeMeta => ({ source: opts?.source ?? 'ui', mode: opts?.mode ?? 'commit' })
253
-
254
- // Async pipeline — supports async precheck/execute (the generative/agent path).
255
- async function invoke(name: string, args: Record<string, unknown> = {}, opts?: Partial<InvokeMeta>): Promise<unknown> {
256
- const meta = fullMeta(opts)
257
- const cap = prepareInvocation(name, args, meta)
258
- let pre: PrecheckResult
259
- try {
260
- pre = resolvePrecheck(name, await cap.precheck(args as never, ctx, meta))
261
- } catch (err) {
262
- throw wrapPrecheckError(name, err)
263
- }
264
- commitHistory(cap, pre)
265
- return cap.execute(args as never, ctx, meta)
266
- }
267
-
268
- // Synchronous pipeline for UI mutation/query callers — preserves handler ordering (the write
269
- // lands before the caller's following side effects). Rejects async precheck/execute.
270
- function invokeSync(name: string, args: Record<string, unknown> = {}, opts?: Partial<InvokeMeta>): unknown {
271
- const meta = fullMeta(opts)
272
- const cap = prepareInvocation(name, args, meta)
273
- let preResult: PrecheckResult | Promise<PrecheckResult>
274
- try {
275
- preResult = cap.precheck(args as never, ctx, meta)
276
- } catch (err) {
277
- throw wrapPrecheckError(name, err)
278
- }
279
- if (preResult && typeof (preResult as Promise<PrecheckResult>).then === 'function') {
280
- throw new Error(`Capability "${name}" has an async precheck — use invoke()`)
281
- }
282
- const pre = resolvePrecheck(name, preResult as PrecheckResult)
283
- commitHistory(cap, pre)
284
- const result = cap.execute(args as never, ctx, meta)
285
- if (result && typeof (result as Promise<unknown>).then === 'function') {
286
- throw new Error(`Capability "${name}" execute() is async — use invoke() (e.g. generative)`)
287
- }
288
- return result
289
- }
290
-
291
- const api: CapabilityRegistry<Ctx> = {
292
- register,
293
- registerAll(descriptors) {
294
- descriptors.forEach(register)
295
- return api
296
- },
297
- has,
298
- get,
299
- describe,
300
- list,
301
- isAvailable,
302
- cost,
303
- invoke,
304
- invokeSync,
305
- }
306
- return api
307
- }