entangled-worlds-orbiters-shared 2.16.0 → 3.0.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.
package/README.md CHANGED
@@ -81,10 +81,8 @@ import * as EntangledWorlds from 'entangled-worlds-orbiters-shared';
81
81
  - Real binaries (close pair from component masses, a distant companion for wide pairs) and an optional debris disc; `mainSequenceTemperature` / `Radius` / `Luminosity` helpers
82
82
  - Perf + look harness: `npm run bench:star` (see `bench/star/results/REPORT.md`)
83
83
 
84
- ### Capabilities (`entangled-worlds-orbiters-shared/capabilities`)
85
- - `createCapabilityRegistry` - Validated, named registry of studio actions (query / mutation / generative)
86
- - `validate`, `assertValid` - JSON-Schema-subset validator used for capability inputs
87
- - Error taxonomy - `ValidationError`, `UnavailableError`, `PrecheckError`, …
84
+ ### Capabilities → moved
85
+ The capability registry (`createCapabilityRegistry`, `validate` / `assertValid`, the error taxonomy) moved to its own package, **`plantasia.space-orchidea/capabilities`** (removed here in 3.0.0).
88
86
 
89
87
  ### Studio session (`entangled-worlds-orbiters-shared/studio`)
90
88
  - `createHistory` - Snapshot undo/redo with injected snapshot/restore
@@ -70,20 +70,24 @@ export function readHeadShape(value, sources) {
70
70
  if (typeof file !== 'string' || !Object.hasOwn(sources, file))
71
71
  throw new Error(`module ${index}: no such source`);
72
72
  const last = index === value.length - 1;
73
- if (last) {
74
- if (notes?.kind !== 'instrument' || !whole(notes.polyphony, MAX_NOTE_LANES))
75
- throw new Error(`module ${index}: a head ends with an instrument of 1 to ${MAX_NOTE_LANES} voices`);
76
- const constants = notes.constants ?? {};
73
+ // Program constants, an instrument's or a note module's: a few safe names, each a small whole number.
74
+ const constantsOf = (given) => {
75
+ const constants = given ?? {};
77
76
  if (typeof constants !== 'object' || Array.isArray(constants))
78
77
  throw new Error(`module ${index}: constants`);
79
78
  const entries = Object.entries(constants);
80
79
  if (entries.length > 8 || entries.some(([name, n]) => !/^[A-Z][A-Z0-9_]{0,23}$/.test(name) || !whole(n, MAX_CONSTANT)))
81
80
  throw new Error(`module ${index}: constants`);
82
- return { dspFileName: file, notes: { kind: 'instrument', polyphony: notes.polyphony, ...(entries.length ? { constants: Object.fromEntries(entries) } : {}) } };
81
+ return entries.length ? { constants: Object.fromEntries(entries) } : {};
82
+ };
83
+ if (last) {
84
+ if (notes?.kind !== 'instrument' || !whole(notes.polyphony, MAX_NOTE_LANES))
85
+ throw new Error(`module ${index}: a head ends with an instrument of 1 to ${MAX_NOTE_LANES} voices`);
86
+ return { dspFileName: file, notes: { kind: 'instrument', polyphony: notes.polyphony, ...constantsOf(notes.constants) } };
83
87
  }
84
88
  if ((notes?.kind !== 'generator' && notes?.kind !== 'midi') || !whole(notes.lanesPerLane, MAX_NOTE_LANES))
85
89
  throw new Error(`module ${index}: a note module with 1 to ${MAX_NOTE_LANES} lanes per lane`);
86
- return { dspFileName: file, notes: { kind: notes.kind, lanesPerLane: notes.lanesPerLane } };
90
+ return { dspFileName: file, notes: { kind: notes.kind, lanesPerLane: notes.lanesPerLane, ...constantsOf(notes.constants) } };
87
91
  });
88
92
  }
89
93
  const isHead = (head) => {
@@ -41,6 +41,7 @@
41
41
  * lanes so far and multiplies them by its `lanesPerLane`, inside its note-domain bypass; the
42
42
  * instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
43
43
  * a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
44
+ * and a note module with program constants (an optional section compiled in or out) gets them too,
44
45
  * since `notes(n)` is told only n.
45
46
  *
46
47
  * Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
@@ -62,6 +63,7 @@ export type SlotNotes = {
62
63
  } | {
63
64
  kind: 'generator' | 'midi';
64
65
  lanesPerLane: number;
66
+ constants?: Readonly<Record<string, number>>;
65
67
  };
66
68
  /** One slot of a composed voice: the module's `.dsp`, by file name. */
67
69
  export interface VoiceSlot {
@@ -41,6 +41,7 @@
41
41
  * lanes so far and multiplies them by its `lanesPerLane`, inside its note-domain bypass; the
42
42
  * instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
43
43
  * a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
44
+ * and a note module with program constants (an optional section compiled in or out) gets them too,
44
45
  * since `notes(n)` is told only n.
45
46
  *
46
47
  * Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
@@ -117,7 +118,7 @@ function noteModules(slots, head) {
117
118
  }
118
119
  const k = wholeAtLeastOne(notes.lanesPerLane, `slot ${index}: lanesPerLane`);
119
120
  // A module that adds lanes is told how many it puts out per lane in (an echo's repeats + 1).
120
- const env = k === 1 ? `SLOT = ${index};` : `SLOT = ${index}; LANES_OUT = ${k};`;
121
+ const env = (k === 1 ? `SLOT = ${index};` : `SLOT = ${index}; LANES_OUT = ${k};`) + constantsEnv(notes.constants, index);
121
122
  const fx = `library("${slot.dspFileName}")[${env}].notes(${lanes})`;
122
123
  parts.push(`hgroup("${slotGroupId(index)}", nt.bypass(${lanes}, ${k}, ${fx}))`);
123
124
  lanes *= k;
@@ -134,13 +135,16 @@ function noteModules(slots, head) {
134
135
  function instrumentPart(slots, head, lanes) {
135
136
  // The instrument's place salts its random draws too (an LFO's Random), as every note module's does;
136
137
  // its program constants (unison's `U`) shape it, as its polyphony does.
137
- const constants = Object.entries(slots[head].notes.constants ?? {})
138
- .map(([name, value]) => {
138
+ const constants = constantsEnv(slots[head].notes.constants, head);
139
+ return `hgroup("${slotGroupId(head)}", library("${slots[head].dspFileName}")[SLOT = ${head};${constants}].play(${lanes}))`;
140
+ }
141
+ /** A slot's program constants as library environment entries (` U = 2;`), each a safe name and a whole number. */
142
+ function constantsEnv(constants, index) {
143
+ return Object.entries(constants ?? {}).map(([name, value]) => {
139
144
  if (!/^[A-Z][A-Z0-9_]*$/.test(name))
140
- throw new Error(`[voiceProgram] slot ${head}: unsafe constant ${name}`);
141
- return ` ${name} = ${wholeAtLeastOne(value, `slot ${head}: ${name}`)};`;
145
+ throw new Error(`[voiceProgram] slot ${index}: unsafe constant ${name}`);
146
+ return ` ${name} = ${wholeAtLeastOne(value, `slot ${index}: ${name}`)};`;
142
147
  }).join('');
143
- return `hgroup("${slotGroupId(head)}", library("${slots[head].dspFileName}")[SLOT = ${head};${constants}].play(${lanes}))`;
144
148
  }
145
149
  /** The entry, the note modules and the instrument, up to `head` (the instrument): lanes all the way. */
146
150
  function noteChain(slots, head) {
@@ -167,8 +171,8 @@ export function voiceCacheKey(slots) {
167
171
  // A note module's lane count is structural too: it shapes the program.
168
172
  const shape = (slots ?? []).map((slot) => {
169
173
  const notes = slot?.notes;
170
- const constants = notes?.kind === 'instrument' ? constantsKey(notes.constants) : '';
171
- const lanes = !notes ? '' : notes.kind === 'instrument' ? `#p${notes.polyphony}${constants}` : `#${notes.kind}${notes.lanesPerLane}`;
174
+ const constants = notes ? constantsKey(notes.constants) : '';
175
+ const lanes = !notes ? '' : notes.kind === 'instrument' ? `#p${notes.polyphony}${constants}` : `#${notes.kind}${notes.lanesPerLane}${constants}`;
172
176
  return String(slot?.dspFileName ?? '') + lanes;
173
177
  }).join('>');
174
178
  return `v${fnv(shape)}`;
@@ -238,7 +242,10 @@ export function buildHeadPrograms(slots) {
238
242
  // FAUST roots a program's controls in a group named after it only when it has several groups at the
239
243
  // top: named here, both programs' addresses start as the one program's do, whatever each holds.
240
244
  const root = (chain) => `vgroup("${HEAD_PROGRAM_NAME}", ${chain})`;
241
- const shape = slots.slice(0, head).map((slot) => `${slot.dspFileName}#${slot.notes.kind}${slot.notes.lanesPerLane}`).join('>');
245
+ const shape = slots.slice(0, head).map((slot) => {
246
+ const n = slot.notes;
247
+ return `${slot.dspFileName}#${n.kind}${n.lanesPerLane}${constantsKey(n.constants)}`;
248
+ }).join('>');
242
249
  return {
243
250
  notes: {
244
251
  key: `n${fileKey(`${shape}>#p${notes.polyphony}`)}`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "entangled-worlds-orbiters-shared",
3
- "version": "2.16.0",
3
+ "version": "3.0.0",
4
4
  "description": "Shared library for entangled worlds orbiters - contains core world rendering, moons system, and UI components",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -38,10 +38,6 @@
38
38
  "types": "./dist/clock/curves.d.ts",
39
39
  "import": "./dist/clock/curves.js"
40
40
  },
41
- "./capabilities": {
42
- "types": "./dist/capabilities/index.d.ts",
43
- "import": "./dist/capabilities/index.js"
44
- },
45
41
  "./faust": {
46
42
  "types": "./dist/faust/index.d.ts",
47
43
  "import": "./dist/faust/index.js"
@@ -129,14 +125,13 @@
129
125
  "prepare": "npm run build:ts",
130
126
  "typecheck": "tsc -p tsconfig.json --noEmit",
131
127
  "test:clock": "node --test --import tsx src/clock/*.test.ts",
132
- "test:capabilities": "node --test --import tsx src/capabilities/*.test.js",
133
128
  "test:notes": "node --test --import tsx src/notes/*.test.ts",
134
129
  "test:faust": "node --test --import tsx src/faust/*.test.ts",
135
130
  "test:studio": "node --test --import tsx src/studio/*.test.js",
136
131
  "test:uploads": "node --test --import tsx src/uploads/*.test.js",
137
132
  "test:audio": "node --test --import tsx src/audio/*.test.ts",
138
133
  "test:render": "node --test src/render/*.test.js",
139
- "test": "npm run test:clock && npm run test:capabilities && npm run test:notes && npm run test:faust && npm run test:studio && npm run test:uploads && npm run test:audio && npm run test:render && npm run test:moons && npm run test:star",
134
+ "test": "npm run test:clock && npm run test:notes && npm run test:faust && npm run test:studio && npm run test:uploads && npm run test:audio && npm run test:render && npm run test:moons && npm run test:star",
140
135
  "test:moons": "node --test src/moons/*.test.js",
141
136
  "test:star": "node --test --import tsx src/star/*.test.ts",
142
137
  "bench:star:build": "esbuild bench/star/main.ts --bundle --format=esm --target=es2022 --outfile=bench/star/build/main.js --log-level=warning",
@@ -90,16 +90,20 @@ export function readHeadShape(value: unknown, sources: Readonly<Record<string, s
90
90
  const notes = (slot as VoiceSlot | null)?.notes as SlotNotes | undefined;
91
91
  if (typeof file !== 'string' || !Object.hasOwn(sources, file)) throw new Error(`module ${index}: no such source`);
92
92
  const last = index === value.length - 1;
93
- if (last) {
94
- if (notes?.kind !== 'instrument' || !whole(notes.polyphony, MAX_NOTE_LANES)) throw new Error(`module ${index}: a head ends with an instrument of 1 to ${MAX_NOTE_LANES} voices`);
95
- const constants = notes.constants ?? {};
93
+ // Program constants, an instrument's or a note module's: a few safe names, each a small whole number.
94
+ const constantsOf = (given: unknown): { constants?: Record<string, number> } => {
95
+ const constants = given ?? {};
96
96
  if (typeof constants !== 'object' || Array.isArray(constants)) throw new Error(`module ${index}: constants`);
97
97
  const entries = Object.entries(constants);
98
98
  if (entries.length > 8 || entries.some(([name, n]) => !/^[A-Z][A-Z0-9_]{0,23}$/.test(name) || !whole(n, MAX_CONSTANT))) throw new Error(`module ${index}: constants`);
99
- return { dspFileName: file, notes: { kind: 'instrument', polyphony: notes.polyphony, ...(entries.length ? { constants: Object.fromEntries(entries) } : {}) } };
99
+ return entries.length ? { constants: Object.fromEntries(entries) } : {};
100
+ };
101
+ if (last) {
102
+ if (notes?.kind !== 'instrument' || !whole(notes.polyphony, MAX_NOTE_LANES)) throw new Error(`module ${index}: a head ends with an instrument of 1 to ${MAX_NOTE_LANES} voices`);
103
+ return { dspFileName: file, notes: { kind: 'instrument', polyphony: notes.polyphony, ...constantsOf(notes.constants) } };
100
104
  }
101
105
  if ((notes?.kind !== 'generator' && notes?.kind !== 'midi') || !whole(notes.lanesPerLane, MAX_NOTE_LANES)) throw new Error(`module ${index}: a note module with 1 to ${MAX_NOTE_LANES} lanes per lane`);
102
- return { dspFileName: file, notes: { kind: notes.kind, lanesPerLane: notes.lanesPerLane } };
106
+ return { dspFileName: file, notes: { kind: notes.kind, lanesPerLane: notes.lanesPerLane, ...constantsOf(notes.constants) } };
103
107
  });
104
108
  }
105
109
 
@@ -41,6 +41,7 @@
41
41
  * lanes so far and multiplies them by its `lanesPerLane`, inside its note-domain bypass; the
42
42
  * instrument plays every lane that reaches it. `SLOT` is the module's place, its part of the seed;
43
43
  * a module that adds lanes (a structural `lanesOut`) also gets `LANES_OUT`, its lanes out per lane in,
44
+ * and a note module with program constants (an optional section compiled in or out) gets them too,
44
45
  * since `notes(n)` is told only n.
45
46
  *
46
47
  * Between the note modules a lane carries a fourth signal, the beat its note started on (what Random
@@ -55,7 +56,7 @@ export const MAX_NOTE_LANES = 16;
55
56
  * What a slot does with notes: a notes instrument's polyphony (and its program constants, when it has
56
57
  * any), or a generator's or MIDI effect's lanes out per lane in.
57
58
  */
58
- export type SlotNotes = { kind: 'instrument'; polyphony: number; constants?: Readonly<Record<string, number>>; copies?: number } | { kind: 'generator' | 'midi'; lanesPerLane: number };
59
+ export type SlotNotes = { kind: 'instrument'; polyphony: number; constants?: Readonly<Record<string, number>>; copies?: number } | { kind: 'generator' | 'midi'; lanesPerLane: number; constants?: Readonly<Record<string, number>> };
59
60
 
60
61
  /** Faust identifiers: the group name goes into generated source, so keep it tame. */
61
62
  const SAFE_SEGMENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -143,7 +144,7 @@ function noteModules(slots: readonly VoiceSlot[], head: number): { parts: string
143
144
  }
144
145
  const k = wholeAtLeastOne(notes.lanesPerLane, `slot ${index}: lanesPerLane`);
145
146
  // A module that adds lanes is told how many it puts out per lane in (an echo's repeats + 1).
146
- const env = k === 1 ? `SLOT = ${index};` : `SLOT = ${index}; LANES_OUT = ${k};`;
147
+ const env = (k === 1 ? `SLOT = ${index};` : `SLOT = ${index}; LANES_OUT = ${k};`) + constantsEnv(notes.constants, index);
147
148
  const fx = `library("${slot.dspFileName}")[${env}].notes(${lanes})`;
148
149
  parts.push(`hgroup("${slotGroupId(index)}", nt.bypass(${lanes}, ${k}, ${fx}))`);
149
150
  lanes *= k;
@@ -160,14 +161,18 @@ function noteModules(slots: readonly VoiceSlot[], head: number): { parts: string
160
161
  function instrumentPart(slots: readonly VoiceSlot[], head: number, lanes: number): string {
161
162
  // The instrument's place salts its random draws too (an LFO's Random), as every note module's does;
162
163
  // its program constants (unison's `U`) shape it, as its polyphony does.
163
- const constants = Object.entries((slots[head].notes as { constants?: Readonly<Record<string, number>> }).constants ?? {})
164
- .map(([name, value]) => {
165
- if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`[voiceProgram] slot ${head}: unsafe constant ${name}`);
166
- return ` ${name} = ${wholeAtLeastOne(value, `slot ${head}: ${name}`)};`;
167
- }).join('');
164
+ const constants = constantsEnv((slots[head].notes as { constants?: Readonly<Record<string, number>> }).constants, head);
168
165
  return `hgroup("${slotGroupId(head)}", library("${slots[head].dspFileName}")[SLOT = ${head};${constants}].play(${lanes}))`;
169
166
  }
170
167
 
168
+ /** A slot's program constants as library environment entries (` U = 2;`), each a safe name and a whole number. */
169
+ function constantsEnv(constants: Readonly<Record<string, number>> | undefined, index: number): string {
170
+ return Object.entries(constants ?? {}).map(([name, value]) => {
171
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`[voiceProgram] slot ${index}: unsafe constant ${name}`);
172
+ return ` ${name} = ${wholeAtLeastOne(value, `slot ${index}: ${name}`)};`;
173
+ }).join('');
174
+ }
175
+
171
176
  /** The entry, the note modules and the instrument, up to `head` (the instrument): lanes all the way. */
172
177
  function noteChain(slots: readonly VoiceSlot[], head: number): string[] {
173
178
  const { parts, lanes } = noteModules(slots, head);
@@ -194,8 +199,8 @@ export function voiceCacheKey(slots: readonly VoiceSlot[] | null | undefined): s
194
199
  // A note module's lane count is structural too: it shapes the program.
195
200
  const shape = (slots ?? []).map((slot) => {
196
201
  const notes = slot?.notes;
197
- const constants = notes?.kind === 'instrument' ? constantsKey(notes.constants) : '';
198
- const lanes = !notes ? '' : notes.kind === 'instrument' ? `#p${notes.polyphony}${constants}` : `#${notes.kind}${notes.lanesPerLane}`;
202
+ const constants = notes ? constantsKey(notes.constants) : '';
203
+ const lanes = !notes ? '' : notes.kind === 'instrument' ? `#p${notes.polyphony}${constants}` : `#${notes.kind}${notes.lanesPerLane}${constants}`;
199
204
  return String(slot?.dspFileName ?? '') + lanes;
200
205
  }).join('>');
201
206
  return `v${fnv(shape)}`;
@@ -287,7 +292,10 @@ export function buildHeadPrograms(slots: readonly VoiceSlot[]): HeadPrograms {
287
292
  // FAUST roots a program's controls in a group named after it only when it has several groups at the
288
293
  // top: named here, both programs' addresses start as the one program's do, whatever each holds.
289
294
  const root = (chain: string) => `vgroup("${HEAD_PROGRAM_NAME}", ${chain})`;
290
- const shape = slots.slice(0, head).map((slot) => `${slot.dspFileName}#${slot.notes!.kind}${(slot.notes as { lanesPerLane: number }).lanesPerLane}`).join('>');
295
+ const shape = slots.slice(0, head).map((slot) => {
296
+ const n = slot.notes as Extract<SlotNotes, { kind: 'generator' | 'midi' }>;
297
+ return `${slot.dspFileName}#${n.kind}${n.lanesPerLane}${constantsKey(n.constants)}`;
298
+ }).join('>');
291
299
  return {
292
300
  notes: {
293
301
  key: `n${fileKey(`${shape}>#p${notes.polyphony}`)}`,
@@ -1,79 +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
- export type CapabilityErrorCode = 'CAPABILITY_ERROR' | 'VALIDATION' | 'UNAVAILABLE' | 'PRECHECK_FAILED' | 'NOT_IMPLEMENTED' | 'INSUFFICIENT_CREDITS' | 'UNKNOWN_CAPABILITY';
15
- export interface SchemaIssue {
16
- path: string;
17
- message: string;
18
- }
19
- export declare class CapabilityError extends Error {
20
- code: CapabilityErrorCode;
21
- capability: string | null;
22
- cause?: unknown;
23
- constructor(message: string, { code, capability, cause }?: {
24
- code?: CapabilityErrorCode;
25
- capability?: string | null;
26
- cause?: unknown;
27
- });
28
- }
29
- /** Input args failed the capability's inputSchema. Thrown before any write. */
30
- export declare class ValidationError extends CapabilityError {
31
- issues: SchemaIssue[];
32
- constructor(message: string, { capability, issues }?: {
33
- capability?: string | null;
34
- issues?: SchemaIssue[];
35
- });
36
- }
37
- /** Capability is declared but not invokable yet (status: 'planned' / available()===false). */
38
- export declare class UnavailableError extends CapabilityError {
39
- reason: string | null;
40
- constructor(message: string, { capability, reason }?: {
41
- capability?: string | null;
42
- reason?: string | null;
43
- });
44
- }
45
- /**
46
- * Capability's precheck rejected the args against live state (no-op target, missing element,
47
- * guard condition). Thrown before pushHistory / any write.
48
- */
49
- export declare class PrecheckError extends CapabilityError {
50
- reason: string | null;
51
- constructor(message: string, { capability, reason }?: {
52
- capability?: string | null;
53
- reason?: string | null;
54
- });
55
- }
56
- /**
57
- * Wired capability whose execute() is not implemented (generative stubs). Distinct from
58
- * UnavailableError: "active but unfinished", reachable only if status was flipped to 'active'
59
- * before the implementation landed.
60
- */
61
- export declare class NotImplementedError extends CapabilityError {
62
- constructor(capability: string, message?: string | null);
63
- }
64
- /**
65
- * Reserved for the future generative path: the backend credit check failed (402-style).
66
- * Declared now so the agent loop can catch it without a follow-up refactor.
67
- */
68
- export declare class InsufficientCreditsError extends CapabilityError {
69
- required: number | null;
70
- available: number | null;
71
- constructor(message: string, { capability, required, available }?: {
72
- capability?: string | null;
73
- required?: number | null;
74
- available?: number | null;
75
- });
76
- }
77
- export declare class UnknownCapabilityError extends CapabilityError {
78
- constructor(name: string);
79
- }
@@ -1,87 +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
- export class CapabilityError extends Error {
15
- code;
16
- capability;
17
- cause;
18
- constructor(message, { code = 'CAPABILITY_ERROR', capability = null, cause = null } = {}) {
19
- super(message);
20
- this.name = 'CapabilityError';
21
- this.code = code;
22
- this.capability = capability;
23
- if (cause)
24
- this.cause = cause;
25
- }
26
- }
27
- /** Input args failed the capability's inputSchema. Thrown before any write. */
28
- export class ValidationError extends CapabilityError {
29
- issues;
30
- constructor(message, { capability = null, issues = [] } = {}) {
31
- super(message, { code: 'VALIDATION', capability });
32
- this.name = 'ValidationError';
33
- this.issues = issues;
34
- }
35
- }
36
- /** Capability is declared but not invokable yet (status: 'planned' / available()===false). */
37
- export class UnavailableError extends CapabilityError {
38
- reason;
39
- constructor(message, { capability = null, reason = null } = {}) {
40
- super(message, { code: 'UNAVAILABLE', capability });
41
- this.name = 'UnavailableError';
42
- this.reason = reason;
43
- }
44
- }
45
- /**
46
- * Capability's precheck rejected the args against live state (no-op target, missing element,
47
- * guard condition). Thrown before pushHistory / any write.
48
- */
49
- export class PrecheckError extends CapabilityError {
50
- reason;
51
- constructor(message, { capability = null, reason = null } = {}) {
52
- super(message, { code: 'PRECHECK_FAILED', capability });
53
- this.name = 'PrecheckError';
54
- this.reason = reason;
55
- }
56
- }
57
- /**
58
- * Wired capability whose execute() is not implemented (generative stubs). Distinct from
59
- * UnavailableError: "active but unfinished", reachable only if status was flipped to 'active'
60
- * before the implementation landed.
61
- */
62
- export class NotImplementedError extends CapabilityError {
63
- constructor(capability, message = null) {
64
- super(message || `Capability "${capability}" is not implemented yet`, { code: 'NOT_IMPLEMENTED', capability });
65
- this.name = 'NotImplementedError';
66
- }
67
- }
68
- /**
69
- * Reserved for the future generative path: the backend credit check failed (402-style).
70
- * Declared now so the agent loop can catch it without a follow-up refactor.
71
- */
72
- export class InsufficientCreditsError extends CapabilityError {
73
- required;
74
- available;
75
- constructor(message, { capability = null, required = null, available = null } = {}) {
76
- super(message, { code: 'INSUFFICIENT_CREDITS', capability });
77
- this.name = 'InsufficientCreditsError';
78
- this.required = required;
79
- this.available = available;
80
- }
81
- }
82
- export class UnknownCapabilityError extends CapabilityError {
83
- constructor(name) {
84
- super(`Unknown capability "${name}"`, { code: 'UNKNOWN_CAPABILITY', capability: name });
85
- this.name = 'UnknownCapabilityError';
86
- }
87
- }
@@ -1,9 +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
- export { createCapabilityRegistry, type CapabilityRegistry, type CapabilityDescriptor, type CapabilityInfo, type CapabilityKind, type CapabilityStatus, type Reactivity, type InvokeMeta, type InvokeMode, type PrecheckResult, type RegistryCtx, } from './registry.js';
8
- export { validate, assertValid, type JsonSchema, type JsonType } from './schema.js';
9
- export * from './errors.js';
@@ -1,9 +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
- export { createCapabilityRegistry, } from './registry.js';
8
- export { validate, assertValid } from './schema.js';
9
- export * from './errors.js';
@@ -1,106 +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
- import { type JsonSchema } from './schema.js';
27
- export type CapabilityKind = 'mutation' | 'query' | 'generative';
28
- export type Reactivity = 'live' | 'structural';
29
- export type CapabilityStatus = 'active' | 'planned';
30
- export type InvokeMode = 'preview' | 'commit';
31
- export interface InvokeMeta {
32
- source: string;
33
- mode: InvokeMode;
34
- }
35
- export interface PrecheckResult {
36
- ok: boolean;
37
- reason?: string | null;
38
- /** false suppresses the no-op history snapshot at the commit boundary. */
39
- willMutate?: boolean;
40
- }
41
- /**
42
- * The optional history hook the registry calls at the commit boundary for reversible mutations.
43
- * A caller's ctx may include it (the studio does) or not (edit-mode has no history). It is NOT a
44
- * generic constraint — the ctx type is free-form per caller — the registry just reads pushHistory
45
- * off it when present.
46
- */
47
- export interface RegistryCtx {
48
- pushHistory?: () => void;
49
- }
50
- export interface CapabilityDescriptor<Ctx = Record<string, unknown>, Args = Record<string, unknown>> {
51
- name: string;
52
- kind: CapabilityKind;
53
- description?: string;
54
- inputSchema?: JsonSchema;
55
- status?: CapabilityStatus;
56
- reversible?: boolean;
57
- /** Cost class of applying the edit: 'live' (cheap in-place) vs 'structural' (rebuild). */
58
- reactivity?: Reactivity;
59
- available?: (ctx: Ctx) => boolean;
60
- cost?: (args: Args, ctx: Ctx) => number;
61
- precheck?: (args: Args, ctx: Ctx, meta: InvokeMeta) => PrecheckResult | Promise<PrecheckResult>;
62
- execute: (args: Args, ctx: Ctx, meta: InvokeMeta) => unknown;
63
- }
64
- /** Public, function-free view of a capability — what the agent tool-catalog builder consumes. */
65
- export interface CapabilityInfo {
66
- name: string;
67
- kind: CapabilityKind;
68
- status: CapabilityStatus;
69
- description: string;
70
- inputSchema: JsonSchema;
71
- reversible: boolean;
72
- reactivity: Reactivity | null;
73
- }
74
- interface NormalizedCapability<Ctx> {
75
- name: string;
76
- kind: CapabilityKind;
77
- status: CapabilityStatus;
78
- description: string;
79
- inputSchema: JsonSchema;
80
- reversible: boolean;
81
- reactivity: Reactivity | null;
82
- available: (ctx: Ctx) => boolean;
83
- cost: (args: never, ctx: Ctx) => number;
84
- precheck: (args: never, ctx: Ctx, meta: InvokeMeta) => PrecheckResult | Promise<PrecheckResult>;
85
- execute: (args: never, ctx: Ctx, meta: InvokeMeta) => unknown;
86
- }
87
- export interface CapabilityRegistry<Ctx = Record<string, unknown>> {
88
- register: (descriptor: CapabilityDescriptor<Ctx>) => NormalizedCapability<Ctx>;
89
- registerAll: (descriptors: CapabilityDescriptor<Ctx>[]) => CapabilityRegistry<Ctx>;
90
- has: (name: string) => boolean;
91
- get: (name: string) => NormalizedCapability<Ctx> | null;
92
- /** Public catalog view of one capability by name (null if unknown). */
93
- describe: (name: string) => CapabilityInfo | null;
94
- list: (opts?: {
95
- includePlanned?: boolean;
96
- kind?: CapabilityKind | null;
97
- }) => CapabilityInfo[];
98
- isAvailable: (name: string) => boolean;
99
- cost: (name: string, args?: Record<string, unknown>) => number;
100
- invoke: (name: string, args?: Record<string, unknown>, opts?: Partial<InvokeMeta>) => Promise<unknown>;
101
- invokeSync: (name: string, args?: Record<string, unknown>, opts?: Partial<InvokeMeta>) => unknown;
102
- }
103
- export declare function createCapabilityRegistry<Ctx = Record<string, unknown>>({ ctx }?: {
104
- ctx?: Ctx;
105
- }): CapabilityRegistry<Ctx>;
106
- export {};