capdag 0.88.20458

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.
Files changed (5) hide show
  1. package/README.md +131 -0
  2. package/RULES.md +111 -0
  3. package/capdag.js +4322 -0
  4. package/capdag.test.js +2874 -0
  5. package/package.json +36 -0
package/capdag.js ADDED
@@ -0,0 +1,4322 @@
1
+ // Cap URN JavaScript Implementation
2
+ // Follows the exact same rules as Rust, Go, and Objective-C implementations
3
+
4
+ // Import TaggedUrn from the tagged-urn package
5
+ const { TaggedUrn } = require('tagged-urn');
6
+
7
+ /**
8
+ * Error types for Cap URN operations
9
+ */
10
+ class CapUrnError extends Error {
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = 'CapUrnError';
14
+ this.code = code;
15
+ }
16
+ }
17
+
18
+ // Error codes
19
+ const ErrorCodes = {
20
+ INVALID_FORMAT: 1,
21
+ EMPTY_TAG: 2,
22
+ INVALID_CHARACTER: 3,
23
+ INVALID_TAG_FORMAT: 4,
24
+ MISSING_CAP_PREFIX: 5,
25
+ DUPLICATE_KEY: 6,
26
+ NUMERIC_KEY: 7,
27
+ UNTERMINATED_QUOTE: 8,
28
+ INVALID_ESCAPE_SEQUENCE: 9,
29
+ MISSING_IN_SPEC: 10,
30
+ MISSING_OUT_SPEC: 11
31
+ };
32
+
33
+ // Note: All parsing is delegated to TaggedUrn from tagged-urn-js
34
+ // No duplicate state machine or parsing helpers needed here
35
+
36
+ /**
37
+ * Cap URN implementation with required direction (in/out) and optional tags
38
+ *
39
+ * Direction is now a REQUIRED first-class field:
40
+ * - inSpec: The input media URN (required, must start with "media:")
41
+ * - outSpec: The output media URN (required, must start with "media:")
42
+ * - tags: Other optional tags (no longer contains in/out)
43
+ */
44
+ /**
45
+ * Check if a value is a valid media URN or wildcard
46
+ * @param {string} value - The value to check
47
+ * @returns {boolean} True if valid media URN or wildcard
48
+ */
49
+ function isValidMediaUrnOrWildcard(value) {
50
+ return value === '*' || (value && value.startsWith('media:'));
51
+ }
52
+
53
+ class CapUrn {
54
+ /**
55
+ * Create a new CapUrn with required direction specs
56
+ * @param {string} inSpec - Required input media URN (e.g., "media:void") or wildcard "*"
57
+ * @param {string} outSpec - Required output media URN (e.g., "media:object") or wildcard "*"
58
+ * @param {Object} tags - Other tags (must NOT contain 'in' or 'out')
59
+ */
60
+ constructor(inSpec, outSpec, tags = {}) {
61
+ // Validate in/out are media URNs or wildcards
62
+ if (!isValidMediaUrnOrWildcard(inSpec)) {
63
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
64
+ }
65
+ if (!isValidMediaUrnOrWildcard(outSpec)) {
66
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
67
+ }
68
+
69
+ this.inSpec = inSpec;
70
+ this.outSpec = outSpec;
71
+ this.tags = {};
72
+ // Copy tags, filtering out any 'in' or 'out' that might have slipped through
73
+ for (const [key, value] of Object.entries(tags)) {
74
+ const keyLower = key.toLowerCase();
75
+ if (keyLower !== 'in' && keyLower !== 'out') {
76
+ this.tags[keyLower] = value;
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Get the input media URN
83
+ * @returns {string} The input media URN
84
+ */
85
+ getInSpec() {
86
+ return this.inSpec;
87
+ }
88
+
89
+ /**
90
+ * Get the output media URN
91
+ * @returns {string} The output media URN
92
+ */
93
+ getOutSpec() {
94
+ return this.outSpec;
95
+ }
96
+
97
+ /**
98
+ * Create a Cap URN from string representation
99
+ * Format: cap:in="<media-urn>";out="<media-urn>";key1=value1;key2=value2;...
100
+ *
101
+ * IMPORTANT: 'in' and 'out' tags are REQUIRED and must be valid media URNs.
102
+ *
103
+ * Uses TaggedUrn for parsing to ensure consistent behavior across implementations.
104
+ *
105
+ * @param {string} s - The Cap URN string
106
+ * @returns {CapUrn} The parsed Cap URN
107
+ * @throws {CapUrnError} If parsing fails or in/out are missing/invalid
108
+ */
109
+ static fromString(s) {
110
+ if (!s || typeof s !== 'string') {
111
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, 'Cap URN cannot be empty');
112
+ }
113
+
114
+ // Check for 'cap:' prefix early to give better error messages
115
+ if (!s.startsWith('cap:')) {
116
+ throw new CapUrnError(ErrorCodes.MISSING_CAP_PREFIX, "Cap URN must start with 'cap:' prefix");
117
+ }
118
+
119
+ // Use TaggedUrn for parsing
120
+ let taggedUrn;
121
+ try {
122
+ taggedUrn = TaggedUrn.fromString(s);
123
+ } catch (e) {
124
+ // Convert TaggedUrnError to CapUrnError with appropriate error code
125
+ const msg = e.message || '';
126
+ const msgLower = msg.toLowerCase();
127
+ if (msgLower.includes('invalid character')) {
128
+ throw new CapUrnError(ErrorCodes.INVALID_CHARACTER, msg);
129
+ }
130
+ if (msgLower.includes('duplicate')) {
131
+ throw new CapUrnError(ErrorCodes.DUPLICATE_KEY, msg);
132
+ }
133
+ if (msgLower.includes('unterminated') || msgLower.includes('unclosed')) {
134
+ throw new CapUrnError(ErrorCodes.UNTERMINATED_QUOTE, msg);
135
+ }
136
+ if (msgLower.includes('numeric') || msgLower.includes('purely numeric')) {
137
+ throw new CapUrnError(ErrorCodes.NUMERIC_KEY, msg);
138
+ }
139
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, msg);
140
+ }
141
+
142
+ // Double-check prefix (should always be 'cap' after the early check above)
143
+ if (taggedUrn.getPrefix() !== 'cap') {
144
+ throw new CapUrnError(ErrorCodes.MISSING_CAP_PREFIX, `Expected 'cap:' prefix, got '${taggedUrn.getPrefix()}:'`);
145
+ }
146
+
147
+ // Extract required 'in' and 'out' tags
148
+ const inSpec = taggedUrn.getTag('in');
149
+ const outSpec = taggedUrn.getTag('out');
150
+
151
+ if (!inSpec) {
152
+ throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' tag for input media URN");
153
+ }
154
+ if (!outSpec) {
155
+ throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
156
+ }
157
+
158
+ // Validate in/out are media URNs or wildcards
159
+ if (!isValidMediaUrnOrWildcard(inSpec)) {
160
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
161
+ }
162
+ if (!isValidMediaUrnOrWildcard(outSpec)) {
163
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
164
+ }
165
+
166
+ // Build remaining tags (excluding in/out)
167
+ const remainingTags = {};
168
+ for (const [key, value] of Object.entries(taggedUrn.tags)) {
169
+ if (key !== 'in' && key !== 'out') {
170
+ remainingTags[key] = value;
171
+ }
172
+ }
173
+
174
+ return new CapUrn(inSpec, outSpec, remainingTags);
175
+ }
176
+
177
+ /**
178
+ * Create a Cap URN from a tags object
179
+ * Extracts 'in' and 'out' from tags (required), stores rest as regular tags
180
+ *
181
+ * @param {Object} tags - Object containing all tags including 'in' and 'out'
182
+ * @returns {CapUrn} The parsed Cap URN
183
+ * @throws {CapUrnError} If 'in' or 'out' tags are missing or invalid
184
+ */
185
+ static fromTags(tags) {
186
+ const inSpec = tags['in'] || tags['IN'];
187
+ const outSpec = tags['out'] || tags['OUT'];
188
+
189
+ if (!inSpec) {
190
+ throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' tag for input media URN");
191
+ }
192
+ if (!outSpec) {
193
+ throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' tag for output media URN");
194
+ }
195
+
196
+ // Validate in/out are media URNs or wildcards
197
+ if (!isValidMediaUrnOrWildcard(inSpec)) {
198
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'in' media URN: ${inSpec}. Must start with 'media:' or be '*'`);
199
+ }
200
+ if (!isValidMediaUrnOrWildcard(outSpec)) {
201
+ throw new CapUrnError(ErrorCodes.INVALID_FORMAT, `Invalid 'out' media URN: ${outSpec}. Must start with 'media:' or be '*'`);
202
+ }
203
+
204
+ // Build remaining tags (excluding in/out)
205
+ const remainingTags = {};
206
+ for (const [key, value] of Object.entries(tags)) {
207
+ const keyLower = key.toLowerCase();
208
+ if (keyLower !== 'in' && keyLower !== 'out') {
209
+ remainingTags[keyLower] = value;
210
+ }
211
+ }
212
+
213
+ return new CapUrn(inSpec, outSpec, remainingTags);
214
+ }
215
+
216
+ /**
217
+ * Get the canonical string representation of this cap URN
218
+ * Always includes "cap:" prefix
219
+ * Tags are sorted alphabetically for consistent representation (in/out included)
220
+ * Uses TaggedUrn for serialization to ensure consistent quoting
221
+ *
222
+ * @returns {string} The canonical string representation
223
+ */
224
+ toString() {
225
+ // Build complete tags map including in and out
226
+ const allTags = { ...this.tags, 'in': this.inSpec, 'out': this.outSpec };
227
+
228
+ // Use TaggedUrn for canonical serialization
229
+ const taggedUrn = new TaggedUrn('cap', allTags, true);
230
+ return taggedUrn.toString();
231
+ }
232
+
233
+ /**
234
+ * Get the value of a specific tag
235
+ * Key is normalized to lowercase for lookup
236
+ * Returns inSpec for "in" key, outSpec for "out" key
237
+ *
238
+ * @param {string} key - The tag key
239
+ * @returns {string|undefined} The tag value or undefined if not found
240
+ */
241
+ getTag(key) {
242
+ const keyLower = key.toLowerCase();
243
+ if (keyLower === 'in') {
244
+ return this.inSpec;
245
+ }
246
+ if (keyLower === 'out') {
247
+ return this.outSpec;
248
+ }
249
+ return this.tags[keyLower];
250
+ }
251
+
252
+ /**
253
+ * Check if this cap has a specific tag with a specific value
254
+ * Key is normalized to lowercase; value comparison is case-sensitive
255
+ * Checks inSpec for "in" key, outSpec for "out" key
256
+ *
257
+ * @param {string} key - The tag key
258
+ * @param {string} value - The tag value to check
259
+ * @returns {boolean} Whether the tag exists with the specified value
260
+ */
261
+ hasTag(key, value) {
262
+ const keyLower = key.toLowerCase();
263
+ if (keyLower === 'in') {
264
+ return this.inSpec === value;
265
+ }
266
+ if (keyLower === 'out') {
267
+ return this.outSpec === value;
268
+ }
269
+ const tagValue = this.tags[keyLower];
270
+ return tagValue !== undefined && tagValue === value;
271
+ }
272
+
273
+ /**
274
+ * Create a new cap URN with an added or updated tag
275
+ * Key is normalized to lowercase; value is preserved as-is
276
+ * SILENTLY IGNORES attempts to set "in" or "out" - use withInSpec/withOutSpec instead
277
+ *
278
+ * @param {string} key - The tag key
279
+ * @param {string} value - The tag value
280
+ * @returns {CapUrn} A new CapUrn instance with the tag added/updated
281
+ */
282
+ withTag(key, value) {
283
+ const keyLower = key.toLowerCase();
284
+ // Silently ignore attempts to set in/out via withTag
285
+ if (keyLower === 'in' || keyLower === 'out') {
286
+ return this;
287
+ }
288
+ const newTags = { ...this.tags };
289
+ newTags[keyLower] = value;
290
+ return new CapUrn(this.inSpec, this.outSpec, newTags);
291
+ }
292
+
293
+ /**
294
+ * Create a new cap URN with a different input spec
295
+ *
296
+ * @param {string} inSpec - The new input spec ID
297
+ * @returns {CapUrn} A new CapUrn instance with the updated inSpec
298
+ */
299
+ withInSpec(inSpec) {
300
+ return new CapUrn(inSpec, this.outSpec, this.tags);
301
+ }
302
+
303
+ /**
304
+ * Create a new cap URN with a different output spec
305
+ *
306
+ * @param {string} outSpec - The new output spec ID
307
+ * @returns {CapUrn} A new CapUrn instance with the updated outSpec
308
+ */
309
+ withOutSpec(outSpec) {
310
+ return new CapUrn(this.inSpec, outSpec, this.tags);
311
+ }
312
+
313
+ /**
314
+ * Create a new cap URN with a tag removed
315
+ * Key is normalized to lowercase for case-insensitive removal
316
+ * SILENTLY IGNORES attempts to remove "in" or "out" - they are required
317
+ *
318
+ * @param {string} key - The tag key to remove
319
+ * @returns {CapUrn} A new CapUrn instance with the tag removed
320
+ */
321
+ withoutTag(key) {
322
+ const keyLower = key.toLowerCase();
323
+ // Silently ignore attempts to remove in/out - they are required
324
+ if (keyLower === 'in' || keyLower === 'out') {
325
+ return this;
326
+ }
327
+ const newTags = { ...this.tags };
328
+ delete newTags[keyLower];
329
+ return new CapUrn(this.inSpec, this.outSpec, newTags);
330
+ }
331
+
332
+ /**
333
+ * Check if this cap (pattern/handler) accepts a request (instance).
334
+ *
335
+ * Direction (in/out) uses TaggedUrn.accepts()/conformsTo() (via MediaUrn matching):
336
+ * - Input: capIn.accepts(requestIn) — cap's input spec is pattern, request's input is instance
337
+ * - Output: capOut.conformsTo(requestOut) — cap's output is instance, request's output is pattern
338
+ * For other tags:
339
+ * - For each tag in the request: cap has same value, wildcard (*), or missing tag
340
+ * - For each tag in the cap: if request is missing that tag, that's fine (cap is more specific)
341
+ * Missing tags (except in/out) are treated as wildcards (less specific, can handle any value).
342
+ *
343
+ * @param {CapUrn} request - The request cap to check
344
+ * @returns {boolean} Whether this cap accepts the request
345
+ */
346
+ accepts(request) {
347
+ if (!request) {
348
+ return true;
349
+ }
350
+
351
+ // Direction specs: TaggedUrn semantic matching via MediaUrn
352
+ // Check in_urn: cap's input spec (pattern) accepts request's input (instance).
353
+ // "media:" on the PATTERN side (this.inSpec) means "I accept any input" — skip check.
354
+ // "*" is also treated as wildcard. "media:" on the instance side still participates.
355
+ if (this.inSpec !== '*' && this.inSpec !== 'media:' && request.inSpec !== '*') {
356
+ const capIn = TaggedUrn.fromString(this.inSpec);
357
+ const requestIn = TaggedUrn.fromString(request.inSpec);
358
+ if (!capIn.accepts(requestIn)) {
359
+ return false;
360
+ }
361
+ }
362
+
363
+ // Check out_urn: cap's output (instance) conforms to request's output (pattern).
364
+ // "media:" on the PATTERN side (this.outSpec) means "I accept any output" — skip check.
365
+ // "*" is also treated as wildcard. "media:" on the instance side still participates.
366
+ if (this.outSpec !== '*' && this.outSpec !== 'media:' && request.outSpec !== '*') {
367
+ const capOut = TaggedUrn.fromString(this.outSpec);
368
+ const requestOut = TaggedUrn.fromString(request.outSpec);
369
+ if (!capOut.conformsTo(requestOut)) {
370
+ return false;
371
+ }
372
+ }
373
+
374
+ // Check all other tags that the request specifies
375
+ for (const [requestKey, requestValue] of Object.entries(request.tags)) {
376
+ const capValue = this.tags[requestKey];
377
+
378
+ if (capValue === undefined) {
379
+ // Missing tag in cap is treated as wildcard - can handle any value
380
+ continue;
381
+ }
382
+
383
+ if (capValue === '*') {
384
+ // Cap has wildcard - can handle any value
385
+ continue;
386
+ }
387
+
388
+ if (requestValue === '*') {
389
+ // Request accepts any value - cap's specific value matches
390
+ continue;
391
+ }
392
+
393
+ if (capValue !== requestValue) {
394
+ // Cap has specific value that doesn't match request's specific value
395
+ return false;
396
+ }
397
+ }
398
+
399
+ // If cap has additional specific tags that request doesn't specify, that's fine
400
+ // The cap is just more specific than needed
401
+ return true;
402
+ }
403
+
404
+ /**
405
+ * Check if this cap (instance) conforms to another cap (pattern).
406
+ * Equivalent to cap.accepts(this).
407
+ *
408
+ * @param {CapUrn} cap - The cap to check conformance against
409
+ * @returns {boolean} Whether this cap conforms to the given cap
410
+ */
411
+ conformsTo(cap) {
412
+ return cap.accepts(this);
413
+ }
414
+
415
+ /**
416
+ * Calculate specificity score for cap matching
417
+ *
418
+ * More specific caps have higher scores and are preferred.
419
+ * Direction specs contribute their MediaUrn tag count (more tags = more specific).
420
+ * Other tags contribute 1 per non-wildcard value.
421
+ *
422
+ * @returns {number} The specificity score
423
+ */
424
+ specificity() {
425
+ let count = 0;
426
+ // Direction specs contribute their MediaUrn tag count
427
+ if (this.inSpec !== '*') {
428
+ const inMedia = TaggedUrn.fromString(this.inSpec);
429
+ count += Object.keys(inMedia.tags).length;
430
+ }
431
+ if (this.outSpec !== '*') {
432
+ const outMedia = TaggedUrn.fromString(this.outSpec);
433
+ count += Object.keys(outMedia.tags).length;
434
+ }
435
+ // Count non-wildcard tags
436
+ count += Object.values(this.tags).filter(value => value !== '*').length;
437
+ return count;
438
+ }
439
+
440
+ /**
441
+ * Check if this cap is more specific than another
442
+ *
443
+ * @param {CapUrn} other - The other cap to compare with
444
+ * @returns {boolean} Whether this cap is more specific
445
+ */
446
+ isMoreSpecificThan(other) {
447
+ if (!other) {
448
+ return true;
449
+ }
450
+
451
+ return this.specificity() > other.specificity();
452
+ }
453
+
454
+ /**
455
+ * Create a new cap with a specific tag set to wildcard
456
+ * Handles "in" and "out" specially
457
+ *
458
+ * @param {string} key - The tag key to set to wildcard
459
+ * @returns {CapUrn} A new CapUrn instance with the tag set to wildcard
460
+ */
461
+ withWildcardTag(key) {
462
+ const keyLower = key.toLowerCase();
463
+ if (keyLower === 'in') {
464
+ return this.withInSpec('*');
465
+ }
466
+ if (keyLower === 'out') {
467
+ return this.withOutSpec('*');
468
+ }
469
+ if (this.tags.hasOwnProperty(keyLower)) {
470
+ return this.withTag(key, '*');
471
+ }
472
+ return this;
473
+ }
474
+
475
+ /**
476
+ * Create a new cap with only specified tags
477
+ * Always preserves inSpec and outSpec (they are required)
478
+ *
479
+ * @param {string[]} keys - Array of tag keys to include
480
+ * @returns {CapUrn} A new CapUrn instance with only the specified tags (plus in/out)
481
+ */
482
+ subset(keys) {
483
+ const newTags = {};
484
+ for (const key of keys) {
485
+ const normalizedKey = key.toLowerCase();
486
+ // Skip in/out - they are always preserved via constructor
487
+ if (normalizedKey !== 'in' && normalizedKey !== 'out') {
488
+ if (this.tags.hasOwnProperty(normalizedKey)) {
489
+ newTags[normalizedKey] = this.tags[normalizedKey];
490
+ }
491
+ }
492
+ }
493
+ return new CapUrn(this.inSpec, this.outSpec, newTags);
494
+ }
495
+
496
+ /**
497
+ * Merge with another cap (other takes precedence for conflicts)
498
+ * Direction specs (in/out) are taken from other
499
+ *
500
+ * @param {CapUrn} other - The cap to merge with
501
+ * @returns {CapUrn} A new CapUrn instance with merged tags
502
+ */
503
+ merge(other) {
504
+ if (!other) {
505
+ return new CapUrn(this.inSpec, this.outSpec, this.tags);
506
+ }
507
+ const newTags = { ...this.tags, ...other.tags };
508
+ return new CapUrn(other.inSpec, other.outSpec, newTags);
509
+ }
510
+
511
+ /**
512
+ * Check if this cap URN is equal to another
513
+ * Compares direction specs (in/out) and tags
514
+ *
515
+ * @param {CapUrn} other - The other cap URN to compare with
516
+ * @returns {boolean} Whether the cap URNs are equal
517
+ */
518
+ equals(other) {
519
+ if (!other || !(other instanceof CapUrn)) {
520
+ return false;
521
+ }
522
+
523
+ // Compare direction specs
524
+ if (this.inSpec !== other.inSpec || this.outSpec !== other.outSpec) {
525
+ return false;
526
+ }
527
+
528
+ // Compare tags
529
+ const thisKeys = Object.keys(this.tags).sort();
530
+ const otherKeys = Object.keys(other.tags).sort();
531
+
532
+ if (thisKeys.length !== otherKeys.length) {
533
+ return false;
534
+ }
535
+
536
+ for (let i = 0; i < thisKeys.length; i++) {
537
+ if (thisKeys[i] !== otherKeys[i]) {
538
+ return false;
539
+ }
540
+ if (this.tags[thisKeys[i]] !== other.tags[otherKeys[i]]) {
541
+ return false;
542
+ }
543
+ }
544
+
545
+ return true;
546
+ }
547
+
548
+ /**
549
+ * Get a hash string for this cap URN
550
+ * Two equivalent cap URNs will have the same hash
551
+ *
552
+ * @returns {string} A hash of the canonical string representation
553
+ */
554
+ hash() {
555
+ // Simple hash function for the canonical string
556
+ const canonical = this.toString();
557
+ let hash = 0;
558
+ for (let i = 0; i < canonical.length; i++) {
559
+ const char = canonical.charCodeAt(i);
560
+ hash = ((hash << 5) - hash) + char;
561
+ hash = hash & hash; // Convert to 32-bit integer
562
+ }
563
+ return hash.toString(16);
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Cap URN Builder for fluent construction
569
+ */
570
+ class CapUrnBuilder {
571
+ constructor() {
572
+ this._inSpec = null;
573
+ this._outSpec = null;
574
+ this._tags = {};
575
+ }
576
+
577
+ /**
578
+ * Set the input spec ID
579
+ *
580
+ * @param {string} spec - The input spec ID
581
+ * @returns {CapUrnBuilder} This builder instance for chaining
582
+ */
583
+ inSpec(spec) {
584
+ this._inSpec = spec;
585
+ return this;
586
+ }
587
+
588
+ /**
589
+ * Set the output spec ID
590
+ *
591
+ * @param {string} spec - The output spec ID
592
+ * @returns {CapUrnBuilder} This builder instance for chaining
593
+ */
594
+ outSpec(spec) {
595
+ this._outSpec = spec;
596
+ return this;
597
+ }
598
+
599
+ /**
600
+ * Add or update a tag
601
+ * Key is normalized to lowercase; value is preserved as-is
602
+ * SILENTLY IGNORES attempts to set "in" or "out" - use inSpec/outSpec methods
603
+ *
604
+ * @param {string} key - The tag key
605
+ * @param {string} value - The tag value
606
+ * @returns {CapUrnBuilder} This builder instance for chaining
607
+ */
608
+ tag(key, value) {
609
+ const keyLower = key.toLowerCase();
610
+ // Silently ignore in/out - use inSpec/outSpec methods
611
+ if (keyLower !== 'in' && keyLower !== 'out') {
612
+ this._tags[keyLower] = value;
613
+ }
614
+ return this;
615
+ }
616
+
617
+ /**
618
+ * Build the final CapUrn
619
+ *
620
+ * @returns {CapUrn} A new CapUrn instance
621
+ * @throws {CapUrnError} If inSpec or outSpec are not set
622
+ */
623
+ build() {
624
+ if (!this._inSpec) {
625
+ throw new CapUrnError(ErrorCodes.MISSING_IN_SPEC, "Cap URN requires 'in' spec - call inSpec() before build()");
626
+ }
627
+ if (!this._outSpec) {
628
+ throw new CapUrnError(ErrorCodes.MISSING_OUT_SPEC, "Cap URN requires 'out' spec - call outSpec() before build()");
629
+ }
630
+ return new CapUrn(this._inSpec, this._outSpec, this._tags);
631
+ }
632
+ }
633
+
634
+ /**
635
+ * Cap Matcher utility class
636
+ */
637
+ class CapMatcher {
638
+ /**
639
+ * Find the most specific cap that accepts a request
640
+ *
641
+ * @param {CapUrn[]} caps - Array of available caps
642
+ * @param {CapUrn} request - The request to match
643
+ * @returns {CapUrn|null} The best matching cap or null if no match
644
+ */
645
+ static findBestMatch(caps, request) {
646
+ let best = null;
647
+ let bestSpecificity = -1;
648
+
649
+ for (const cap of caps) {
650
+ if (cap.accepts(request)) {
651
+ const specificity = cap.specificity();
652
+ if (specificity > bestSpecificity) {
653
+ best = cap;
654
+ bestSpecificity = specificity;
655
+ }
656
+ }
657
+ }
658
+
659
+ return best;
660
+ }
661
+
662
+ /**
663
+ * Find all caps that accept a request, sorted by specificity
664
+ *
665
+ * @param {CapUrn[]} caps - Array of available caps
666
+ * @param {CapUrn} request - The request to match
667
+ * @returns {CapUrn[]} Array of matching caps sorted by specificity (most specific first)
668
+ */
669
+ static findAllMatches(caps, request) {
670
+ const matches = caps.filter(cap => cap.accepts(request));
671
+
672
+ // Sort by specificity (most specific first)
673
+ matches.sort((a, b) => b.specificity() - a.specificity());
674
+
675
+ return matches;
676
+ }
677
+
678
+ /**
679
+ * Check if two cap sets are compatible
680
+ *
681
+ * @param {CapUrn[]} caps1 - First set of caps
682
+ * @param {CapUrn[]} caps2 - Second set of caps
683
+ * @returns {boolean} Whether any caps from the two sets are compatible
684
+ */
685
+ static areCompatible(caps1, caps2) {
686
+ for (const c1 of caps1) {
687
+ for (const c2 of caps2) {
688
+ if (c1.accepts(c2) || c2.accepts(c1)) {
689
+ return true;
690
+ }
691
+ }
692
+ }
693
+ return false;
694
+ }
695
+ }
696
+
697
+ // ============================================================================
698
+ // MEDIA SPEC PARSING
699
+ // ============================================================================
700
+
701
+ /**
702
+ * MediaSpec error types
703
+ */
704
+ class MediaSpecError extends Error {
705
+ constructor(code, message) {
706
+ super(message);
707
+ this.name = 'MediaSpecError';
708
+ this.code = code;
709
+ }
710
+ }
711
+
712
+ const MediaSpecErrorCodes = {
713
+ UNRESOLVABLE_MEDIA_URN: 1,
714
+ DUPLICATE_MEDIA_URN: 2
715
+ };
716
+
717
+ // ============================================================================
718
+ // BUILT-IN SPEC IDS AND DEFINITIONS
719
+ // ============================================================================
720
+
721
+ /**
722
+ * Well-known built-in media URN constants
723
+ * These media URNs are implicitly available and do not need to be declared in mediaSpecs
724
+ *
725
+ * Cardinality and Structure use orthogonal marker tags:
726
+ * - `list` marker: presence = list/array, absence = scalar (default)
727
+ * - `record` marker: presence = has internal fields, absence = opaque (default)
728
+ *
729
+ * Examples:
730
+ * - `media:pdf` → scalar, opaque (no markers)
731
+ * - `media:textable;list` → list, opaque (has list marker)
732
+ * - `media:json;textable;record` → scalar, record (has record marker)
733
+ * - `media:json;list;record;textable` → list of records (has both markers)
734
+ */
735
+
736
+ // Primitive types - URNs must match base.toml definitions
737
+ // Media URN for void (no input/output) - no coercion tags
738
+ const MEDIA_VOID = 'media:void';
739
+ // Media URN for string type - textable (can become text), scalar by default (no list marker)
740
+ const MEDIA_STRING = 'media:textable';
741
+ // Media URN for integer type - textable, numeric (math ops valid), scalar by default
742
+ const MEDIA_INTEGER = 'media:integer;textable;numeric';
743
+ // Media URN for number type - textable, numeric, scalar by default
744
+ const MEDIA_NUMBER = 'media:textable;numeric';
745
+ // Media URN for boolean type - uses "bool" not "boolean" per base.toml
746
+ const MEDIA_BOOLEAN = 'media:bool;textable';
747
+ // Media URN for a generic record/object type - has internal key-value structure but NOT textable
748
+ // Use MEDIA_JSON for textable JSON objects.
749
+ const MEDIA_OBJECT = 'media:record';
750
+ // Media URN for binary data - the most general media type (no constraints)
751
+ const MEDIA_BINARY = 'media:';
752
+
753
+ // Array types - URNs must match base.toml definitions
754
+ // Media URN for string array type - textable with list marker
755
+ const MEDIA_STRING_ARRAY = 'media:list;textable';
756
+ // Media URN for integer array type - textable, numeric with list marker
757
+ const MEDIA_INTEGER_ARRAY = 'media:integer;list;textable;numeric';
758
+ // Media URN for number array type - textable, numeric with list marker
759
+ const MEDIA_NUMBER_ARRAY = 'media:list;textable;numeric';
760
+ // Media URN for boolean array type - uses "bool" with list marker
761
+ const MEDIA_BOOLEAN_ARRAY = 'media:bool;list;textable';
762
+ // Media URN for object array type - list of records (NOT textable)
763
+ // Use a specific format like JSON array for textable object arrays.
764
+ const MEDIA_OBJECT_ARRAY = 'media:list;record';
765
+
766
+ // Semantic media types for specialized content
767
+ // Media URN for PNG image data
768
+ const MEDIA_PNG = 'media:image;png';
769
+ // Media URN for audio data (wav, mp3, flac, etc.)
770
+ const MEDIA_AUDIO = 'media:wav;audio';
771
+ // Media URN for video data (mp4, webm, mov, etc.)
772
+ const MEDIA_VIDEO = 'media:video';
773
+
774
+ // Semantic AI input types - distinguished by their purpose/context
775
+ // Media URN for audio input containing speech for transcription (Whisper)
776
+ const MEDIA_AUDIO_SPEECH = 'media:audio;wav;speech';
777
+ // Media URN for thumbnail image output
778
+ const MEDIA_IMAGE_THUMBNAIL = 'media:image;png;thumbnail';
779
+
780
+ // Document types (PRIMARY naming - type IS the format)
781
+ // Media URN for PDF documents
782
+ const MEDIA_PDF = 'media:pdf';
783
+ // Media URN for EPUB documents
784
+ const MEDIA_EPUB = 'media:epub';
785
+
786
+ // Text format types (PRIMARY naming - type IS the format)
787
+ // Media URN for Markdown text
788
+ const MEDIA_MD = 'media:md;textable';
789
+ // Media URN for plain text
790
+ const MEDIA_TXT = 'media:txt;textable';
791
+ // Media URN for reStructuredText
792
+ const MEDIA_RST = 'media:rst;textable';
793
+ // Media URN for log files
794
+ const MEDIA_LOG = 'media:log;textable';
795
+ // Media URN for HTML documents
796
+ const MEDIA_HTML = 'media:html;textable';
797
+ // Media URN for XML documents
798
+ const MEDIA_XML = 'media:xml;textable';
799
+ // Media URN for JSON data - has record marker (structured key-value)
800
+ const MEDIA_JSON = 'media:json;record;textable';
801
+ // Media URN for JSON with schema constraint (input for structured queries)
802
+ const MEDIA_JSON_SCHEMA = 'media:json;json-schema;record;textable';
803
+ // Media URN for YAML data - has record marker (structured key-value)
804
+ const MEDIA_YAML = 'media:record;textable;yaml';
805
+
806
+ // File path types - for arguments that represent filesystem paths
807
+ // Media URN for a single file path - textable, scalar by default (no list marker)
808
+ const MEDIA_FILE_PATH = 'media:file-path;textable';
809
+ // Media URN for an array of file paths - textable with list marker
810
+ const MEDIA_FILE_PATH_ARRAY = 'media:file-path;list;textable';
811
+
812
+ // Semantic text input types - distinguished by their purpose/context
813
+ // Media URN for frontmatter text (book metadata) - scalar by default
814
+ const MEDIA_FRONTMATTER_TEXT = 'media:frontmatter;textable';
815
+ // Media URN for model spec (provider:model format, HuggingFace name, etc.) - scalar by default
816
+ const MEDIA_MODEL_SPEC = 'media:model-spec;textable';
817
+ // Media URN for MLX model path - scalar by default
818
+ const MEDIA_MLX_MODEL_PATH = 'media:mlx-model-path;textable';
819
+ // Media URN for model repository (input for list-models) - has record marker
820
+ const MEDIA_MODEL_REPO = 'media:model-repo;record;textable';
821
+
822
+ // CAPDAG output types - record marker for structured JSON objects, list marker for arrays
823
+ // Media URN for model dimension output - scalar by default (no list marker)
824
+ const MEDIA_MODEL_DIM = 'media:integer;model-dim;numeric;textable';
825
+ // Media URN for model download output - has record marker
826
+ const MEDIA_DOWNLOAD_OUTPUT = 'media:download-result;record;textable';
827
+ // Media URN for model list output - has record marker
828
+ const MEDIA_LIST_OUTPUT = 'media:model-list;record;textable';
829
+ // Media URN for model status output - has record marker
830
+ const MEDIA_STATUS_OUTPUT = 'media:model-status;record;textable';
831
+ // Media URN for model contents output - has record marker
832
+ const MEDIA_CONTENTS_OUTPUT = 'media:model-contents;record;textable';
833
+ // Media URN for model availability output - has record marker
834
+ const MEDIA_AVAILABILITY_OUTPUT = 'media:model-availability;record;textable';
835
+ // Media URN for model path output - has record marker
836
+ const MEDIA_PATH_OUTPUT = 'media:model-path;record;textable';
837
+ // Media URN for embedding vector output - has record marker
838
+ const MEDIA_EMBEDDING_VECTOR = 'media:embedding-vector;record;textable';
839
+ // Media URN for LLM inference output - has record marker
840
+ const MEDIA_LLM_INFERENCE_OUTPUT = 'media:generated-text;record;textable';
841
+ // Media URN for extracted metadata - has record marker
842
+ const MEDIA_FILE_METADATA = 'media:file-metadata;record;textable';
843
+ // Media URN for extracted outline - has record marker
844
+ const MEDIA_DOCUMENT_OUTLINE = 'media:document-outline;record;textable';
845
+ // Media URN for disbound page - has list marker (array of page objects)
846
+ const MEDIA_DISBOUND_PAGE = 'media:disbound-page;list;textable';
847
+ // Media URN for vision inference output - textable, scalar by default
848
+ const MEDIA_IMAGE_DESCRIPTION = 'media:image-description;textable';
849
+ // Media URN for transcription output - has record marker
850
+ const MEDIA_TRANSCRIPTION_OUTPUT = 'media:record;textable;transcription';
851
+ // Media URN for decision output (bit choice) - scalar by default
852
+ const MEDIA_DECISION = 'media:bool;decision;textable';
853
+ // Media URN for decision array output (bit choices) - has list marker
854
+ const MEDIA_DECISION_ARRAY = 'media:bool;decision;list;textable';
855
+
856
+ // =============================================================================
857
+ // STANDARD CAP URN CONSTANTS
858
+ // =============================================================================
859
+
860
+ // Standard echo capability URN
861
+ // Accepts any media type as input and outputs any media type
862
+ const CAP_IDENTITY = 'cap:in=media:;out=media:';
863
+
864
+ // =============================================================================
865
+ // MEDIA URN CLASS
866
+ // =============================================================================
867
+
868
+ /**
869
+ * Error types for MediaUrn operations
870
+ */
871
+ class MediaUrnError extends Error {
872
+ constructor(code, message) {
873
+ super(message);
874
+ this.name = 'MediaUrnError';
875
+ this.code = code;
876
+ }
877
+ }
878
+
879
+ const MediaUrnErrorCodes = {
880
+ INVALID_PREFIX: 'INVALID_PREFIX',
881
+ };
882
+
883
+ /**
884
+ * MediaUrn wraps a TaggedUrn with prefix validation and media-specific convenience methods.
885
+ * Mirrors the Rust MediaUrn type.
886
+ */
887
+ class MediaUrn {
888
+ /**
889
+ * @param {TaggedUrn} taggedUrn - A parsed TaggedUrn with prefix 'media'
890
+ */
891
+ constructor(taggedUrn) {
892
+ if (taggedUrn.getPrefix() !== 'media') {
893
+ throw new MediaUrnError(
894
+ MediaUrnErrorCodes.INVALID_PREFIX,
895
+ `Expected prefix 'media', got '${taggedUrn.getPrefix()}'`
896
+ );
897
+ }
898
+ this._urn = taggedUrn;
899
+ }
900
+
901
+ /**
902
+ * Parse a media URN string. Validates the prefix is 'media'.
903
+ * @param {string} str - The media URN string (e.g., "media:pdf")
904
+ * @returns {MediaUrn}
905
+ * @throws {MediaUrnError} If prefix is not 'media'
906
+ */
907
+ static fromString(str) {
908
+ const urn = TaggedUrn.fromString(str);
909
+ return new MediaUrn(urn);
910
+ }
911
+
912
+ /** @returns {boolean} True if the "textable" marker tag is NOT present (binary = not textable) */
913
+ isBinary() { return this._urn.getTag('textable') === undefined; }
914
+
915
+ // =========================================================================
916
+ // CARDINALITY (list marker)
917
+ // =========================================================================
918
+
919
+ /**
920
+ * Returns true if this media is a list (has `list` marker tag).
921
+ * Returns false if scalar (no `list` marker = default).
922
+ * @returns {boolean}
923
+ */
924
+ isList() { return this._hasMarkerTag('list'); }
925
+
926
+ /**
927
+ * Returns true if this media is a scalar (no `list` marker).
928
+ * Scalar is the default cardinality.
929
+ * @returns {boolean}
930
+ */
931
+ isScalar() { return !this._hasMarkerTag('list'); }
932
+
933
+ // =========================================================================
934
+ // STRUCTURE (record marker)
935
+ // =========================================================================
936
+
937
+ /**
938
+ * Returns true if this media is a record (has `record` marker tag).
939
+ * A record has internal key-value structure (e.g., JSON object).
940
+ * @returns {boolean}
941
+ */
942
+ isRecord() { return this._hasMarkerTag('record'); }
943
+
944
+ /**
945
+ * Returns true if this media is opaque (no `record` marker).
946
+ * Opaque is the default structure - no internal fields recognized.
947
+ * @returns {boolean}
948
+ */
949
+ isOpaque() { return !this._hasMarkerTag('record'); }
950
+
951
+ // =========================================================================
952
+ // HELPER: Check for marker tag presence
953
+ // =========================================================================
954
+
955
+ /**
956
+ * Check if a marker tag (tag with wildcard/no value) is present.
957
+ * A marker tag is stored as key="*" in the tagged URN.
958
+ * @param {string} tagName
959
+ * @returns {boolean}
960
+ * @private
961
+ */
962
+ _hasMarkerTag(tagName) {
963
+ const value = this._urn.getTag(tagName);
964
+ return value === '*';
965
+ }
966
+
967
+ /** @returns {boolean} True if the "json" marker tag is present */
968
+ isJson() { return this._urn.getTag('json') !== undefined; }
969
+
970
+ /** @returns {boolean} True if the "textable" marker tag is present */
971
+ isText() { return this._urn.getTag('textable') !== undefined; }
972
+
973
+ /** @returns {boolean} True if the "void" marker tag is present */
974
+ isVoid() { return this._urn.getTag('void') !== undefined; }
975
+
976
+ /** @returns {boolean} True if the "image" marker tag is present */
977
+ isImage() { return this._urn.getTag('image') !== undefined; }
978
+
979
+ /** @returns {boolean} True if the "audio" marker tag is present */
980
+ isAudio() { return this._urn.getTag('audio') !== undefined; }
981
+
982
+ /** @returns {boolean} True if the "video" marker tag is present */
983
+ isVideo() { return this._urn.getTag('video') !== undefined; }
984
+
985
+ /** @returns {boolean} True if the "numeric" marker tag is present */
986
+ isNumeric() { return this._urn.getTag('numeric') !== undefined; }
987
+
988
+ /** @returns {boolean} True if the "bool" marker tag is present */
989
+ isBool() { return this._urn.getTag('bool') !== undefined; }
990
+
991
+ /**
992
+ * Check if this represents a single file path type (not array).
993
+ * Returns true if the "file-path" marker tag is present AND no list marker.
994
+ * @returns {boolean}
995
+ */
996
+ isFilePath() { return this._hasMarkerTag('file-path') && !this.isList(); }
997
+
998
+ /**
999
+ * Check if this represents a file path array type.
1000
+ * Returns true if the "file-path" marker tag is present AND has list marker.
1001
+ * @returns {boolean}
1002
+ */
1003
+ isFilePathArray() { return this._hasMarkerTag('file-path') && this.isList(); }
1004
+
1005
+ /**
1006
+ * Check if this represents any file path type (single or array).
1007
+ * Returns true if "file-path" marker tag is present.
1008
+ * @returns {boolean}
1009
+ */
1010
+ isAnyFilePath() { return this._hasMarkerTag('file-path'); }
1011
+
1012
+ /**
1013
+ * Check if this media URN conforms to another (pattern).
1014
+ * @param {MediaUrn} pattern
1015
+ * @returns {boolean}
1016
+ */
1017
+ conformsTo(pattern) { return this._urn.conformsTo(pattern._urn); }
1018
+
1019
+ /**
1020
+ * Check if this media URN (as pattern) accepts an instance.
1021
+ * @param {MediaUrn} instance
1022
+ * @returns {boolean}
1023
+ */
1024
+ accepts(instance) { return this._urn.accepts(instance._urn); }
1025
+
1026
+ /** @returns {number} Specificity score (tag count based) */
1027
+ specificity() { return this._urn.specificity(); }
1028
+
1029
+ /**
1030
+ * Get the file extension from the ext tag, if present.
1031
+ * @returns {string|null}
1032
+ */
1033
+ extension() {
1034
+ const ext = this._urn.getTag('ext');
1035
+ return ext !== undefined ? ext : null;
1036
+ }
1037
+
1038
+ /**
1039
+ * @param {string} key
1040
+ * @param {string} [value]
1041
+ * @returns {boolean}
1042
+ */
1043
+ hasTag(key, value) {
1044
+ if (value !== undefined) {
1045
+ return this._urn.hasTag(key, value);
1046
+ }
1047
+ return this._urn.getTag(key) !== undefined;
1048
+ }
1049
+
1050
+ /**
1051
+ * @param {string} key
1052
+ * @returns {string|undefined}
1053
+ */
1054
+ getTag(key) { return this._urn.getTag(key); }
1055
+
1056
+ /** @returns {string} Canonical string representation */
1057
+ toString() { return this._urn.toString(); }
1058
+
1059
+ /**
1060
+ * @param {MediaUrn} other
1061
+ * @returns {boolean}
1062
+ */
1063
+ equals(other) { return this._urn.equals(other._urn); }
1064
+ }
1065
+
1066
+ // =============================================================================
1067
+ // STANDARD CAP URN BUILDERS
1068
+ // =============================================================================
1069
+
1070
+ /**
1071
+ * Build URN for LLM conversation capability
1072
+ * @param {string} langCode - Language code (e.g., "en", "fr")
1073
+ * @returns {CapUrn}
1074
+ */
1075
+ function llmConversationUrn(langCode) {
1076
+ return new CapUrnBuilder()
1077
+ .tag('op', 'conversation')
1078
+ .tag('unconstrained', '*')
1079
+ .tag('language', langCode)
1080
+ .inSpec(MEDIA_STRING)
1081
+ .outSpec(MEDIA_LLM_INFERENCE_OUTPUT)
1082
+ .build();
1083
+ }
1084
+
1085
+ /**
1086
+ * Build URN for model-availability capability
1087
+ * @returns {CapUrn}
1088
+ */
1089
+ function modelAvailabilityUrn() {
1090
+ return new CapUrnBuilder()
1091
+ .tag('op', 'model-availability')
1092
+ .inSpec(MEDIA_MODEL_SPEC)
1093
+ .outSpec(MEDIA_AVAILABILITY_OUTPUT)
1094
+ .build();
1095
+ }
1096
+
1097
+ /**
1098
+ * Build URN for model-path capability
1099
+ * @returns {CapUrn}
1100
+ */
1101
+ function modelPathUrn() {
1102
+ return new CapUrnBuilder()
1103
+ .tag('op', 'model-path')
1104
+ .inSpec(MEDIA_MODEL_SPEC)
1105
+ .outSpec(MEDIA_PATH_OUTPUT)
1106
+ .build();
1107
+ }
1108
+
1109
+ // =============================================================================
1110
+ // SCHEMA URL CONFIGURATION
1111
+ // =============================================================================
1112
+
1113
+ const DEFAULT_SCHEMA_BASE = 'https://capdag.com/schema';
1114
+
1115
+ /**
1116
+ * Get the schema base URL from environment variables or default
1117
+ *
1118
+ * Checks in order:
1119
+ * 1. CAPDAG_SCHEMA_BASE_URL environment variable
1120
+ * 2. CAPDAG_REGISTRY_URL environment variable + "/schema"
1121
+ * 3. Default: "https://capdag.com/schema"
1122
+ *
1123
+ * @returns {string} The schema base URL
1124
+ */
1125
+ function getSchemaBaseURL() {
1126
+ if (typeof process !== 'undefined' && process.env) {
1127
+ if (process.env.CAPDAG_SCHEMA_BASE_URL) {
1128
+ return process.env.CAPDAG_SCHEMA_BASE_URL;
1129
+ }
1130
+ if (process.env.CAPDAG_REGISTRY_URL) {
1131
+ return process.env.CAPDAG_REGISTRY_URL + '/schema';
1132
+ }
1133
+ }
1134
+ return DEFAULT_SCHEMA_BASE;
1135
+ }
1136
+
1137
+ /**
1138
+ * Get a profile URL for the given profile name
1139
+ *
1140
+ * @param {string} profileName - The profile name (e.g., 'str', 'int')
1141
+ * @returns {string} The full profile URL
1142
+ */
1143
+ function getProfileURL(profileName) {
1144
+ return `${getSchemaBaseURL()}/${profileName}`;
1145
+ }
1146
+
1147
+ // =============================================================================
1148
+ // MEDIA URN TAG UTILITIES
1149
+ // =============================================================================
1150
+ // NOTE: The MEDIA_X constants above are convenience values for referencing
1151
+ // common media URNs in code. Resolution must go through mediaSpecs tables -
1152
+ // there is NO built-in resolution.
1153
+
1154
+ /**
1155
+ * Resolved MediaSpec structure
1156
+ *
1157
+ * A MediaSpec is a resolved media specification containing information about
1158
+ * a value type in the CAPDAG system. MediaSpecs are identified by unique media URNs
1159
+ * and contain fields like media_type, profile_uri, schema, etc.
1160
+ *
1161
+ * MediaSpecs are defined in JSON files in the registry or inline in cap definitions.
1162
+ */
1163
+ class MediaSpec {
1164
+ /**
1165
+ * Create a new MediaSpec
1166
+ * @param {string} contentType - The MIME content type
1167
+ * @param {string|null} profile - Optional profile URL
1168
+ * @param {Object|null} schema - Optional JSON Schema for local validation
1169
+ * @param {string|null} title - Optional display-friendly title
1170
+ * @param {string|null} description - Optional description
1171
+ * @param {string|null} mediaUrn - Source media URN for tag-based checks
1172
+ * @param {Object|null} validation - Optional validation rules (min, max, min_length, max_length, pattern, allowed_values)
1173
+ * @param {Object|null} metadata - Optional metadata (arbitrary key-value pairs for display/categorization)
1174
+ * @param {string[]} extensions - File extensions for storing this media type (e.g., ['pdf'], ['jpg', 'jpeg'])
1175
+ */
1176
+ constructor(contentType, profile = null, schema = null, title = null, description = null, mediaUrn = null, validation = null, metadata = null, extensions = []) {
1177
+ this.contentType = contentType;
1178
+ this.profile = profile;
1179
+ this.schema = schema;
1180
+ this.title = title;
1181
+ this.description = description;
1182
+ this.mediaUrn = mediaUrn;
1183
+ this.validation = validation;
1184
+ this.metadata = metadata;
1185
+ this.extensions = extensions;
1186
+ }
1187
+
1188
+ /**
1189
+ * Get the parsed MediaUrn object for this spec. Lazily created and cached.
1190
+ * @returns {MediaUrn|null} The parsed MediaUrn, or null if no mediaUrn string
1191
+ */
1192
+ parsedMediaUrn() {
1193
+ if (!this.mediaUrn) return null;
1194
+ if (!this._parsedMediaUrn) {
1195
+ this._parsedMediaUrn = MediaUrn.fromString(this.mediaUrn);
1196
+ }
1197
+ return this._parsedMediaUrn;
1198
+ }
1199
+
1200
+ /** @returns {boolean} True if binary (textable marker tag absent) */
1201
+ isBinary() {
1202
+ const mu = this.parsedMediaUrn();
1203
+ return mu ? mu.isBinary() : false;
1204
+ }
1205
+
1206
+ /** @returns {boolean} True if record structure (has record marker) */
1207
+ isRecord() {
1208
+ const mu = this.parsedMediaUrn();
1209
+ return mu ? mu.isRecord() : false;
1210
+ }
1211
+
1212
+ /** @returns {boolean} True if opaque structure (no record marker) */
1213
+ isOpaque() {
1214
+ const mu = this.parsedMediaUrn();
1215
+ return mu ? mu.isOpaque() : false;
1216
+ }
1217
+
1218
+ /** @returns {boolean} True if scalar value (no list marker) */
1219
+ isScalar() {
1220
+ const mu = this.parsedMediaUrn();
1221
+ return mu ? mu.isScalar() : false;
1222
+ }
1223
+
1224
+ /** @returns {boolean} True if list/array (has list marker) */
1225
+ isList() {
1226
+ const mu = this.parsedMediaUrn();
1227
+ return mu ? mu.isList() : false;
1228
+ }
1229
+
1230
+ /** @returns {boolean} True if JSON representation (json tag present) */
1231
+ isJSON() {
1232
+ const mu = this.parsedMediaUrn();
1233
+ return mu ? mu.isJson() : false;
1234
+ }
1235
+
1236
+ /** @returns {boolean} True if text (textable tag present) */
1237
+ isText() {
1238
+ const mu = this.parsedMediaUrn();
1239
+ return mu ? mu.isText() : false;
1240
+ }
1241
+
1242
+ /** @returns {boolean} True if image (image tag present) */
1243
+ isImage() {
1244
+ const mu = this.parsedMediaUrn();
1245
+ return mu ? mu.hasTag('image') : false;
1246
+ }
1247
+
1248
+ /** @returns {boolean} True if audio (audio tag present) */
1249
+ isAudio() {
1250
+ const mu = this.parsedMediaUrn();
1251
+ return mu ? mu.hasTag('audio') : false;
1252
+ }
1253
+
1254
+ /** @returns {boolean} True if video (video tag present) */
1255
+ isVideo() {
1256
+ const mu = this.parsedMediaUrn();
1257
+ return mu ? mu.hasTag('video') : false;
1258
+ }
1259
+
1260
+ /** @returns {boolean} True if numeric (numeric tag present) */
1261
+ isNumeric() {
1262
+ const mu = this.parsedMediaUrn();
1263
+ return mu ? mu.hasTag('numeric') : false;
1264
+ }
1265
+
1266
+ /** @returns {boolean} True if boolean (bool tag present) */
1267
+ isBool() {
1268
+ const mu = this.parsedMediaUrn();
1269
+ return mu ? mu.hasTag('bool') : false;
1270
+ }
1271
+
1272
+ /**
1273
+ * Get the primary type (e.g., "image" from "image/png")
1274
+ * @returns {string} The primary type
1275
+ */
1276
+ primaryType() {
1277
+ return this.contentType.split('/')[0];
1278
+ }
1279
+
1280
+ /**
1281
+ * Get the subtype (e.g., "png" from "image/png")
1282
+ * @returns {string|undefined} The subtype
1283
+ */
1284
+ subtype() {
1285
+ const parts = this.contentType.split('/');
1286
+ return parts.length > 1 ? parts[1] : undefined;
1287
+ }
1288
+
1289
+ /**
1290
+ * Get the canonical string representation
1291
+ * Format: <media-type>; profile="<url>" (no content-type: prefix)
1292
+ * @returns {string} The media_spec as a string
1293
+ */
1294
+ toString() {
1295
+ if (this.profile) {
1296
+ return `${this.contentType}; profile="${this.profile}"`;
1297
+ }
1298
+ return this.contentType;
1299
+ }
1300
+
1301
+ /**
1302
+ * Get MediaSpec from a CapUrn using the output media URN
1303
+ * NOTE: outSpec is now a required first-class field on CapUrn
1304
+ * @param {CapUrn} capUrn - The cap URN
1305
+ * @param {Object} mediaSpecs - Optional mediaSpecs lookup table for resolution
1306
+ * @returns {MediaSpec} The resolved MediaSpec
1307
+ * @throws {MediaSpecError} If media URN cannot be resolved
1308
+ */
1309
+ static fromCapUrn(capUrn, mediaSpecs = []) {
1310
+ // outSpec is now a required field, so it's always present
1311
+ const mediaUrn = capUrn.getOutSpec();
1312
+
1313
+ // Resolve the media URN to a MediaSpec - no fallbacks, fail hard
1314
+ return resolveMediaUrn(mediaUrn, mediaSpecs);
1315
+ }
1316
+ }
1317
+
1318
+ /**
1319
+ * Resolve a media URN to a MediaSpec
1320
+ *
1321
+ * Resolution: Look up mediaUrn in mediaSpecs array (by urn field), FAIL HARD if not found.
1322
+ * There is no built-in resolution - all media URNs must be in mediaSpecs.
1323
+ *
1324
+ * @param {string} mediaUrn - The media URN (e.g., "media:textable")
1325
+ * @param {Array} mediaSpecs - The mediaSpecs array (each item has urn, media_type, title, etc.)
1326
+ * @returns {MediaSpec} The resolved MediaSpec
1327
+ * @throws {MediaSpecError} If media URN cannot be resolved
1328
+ */
1329
+ function resolveMediaUrn(mediaUrn, mediaSpecs = []) {
1330
+ // Look up in mediaSpecs array by urn field
1331
+ if (mediaSpecs && Array.isArray(mediaSpecs)) {
1332
+ const def = mediaSpecs.find(spec => spec.urn === mediaUrn);
1333
+
1334
+ if (def) {
1335
+ // Object form: { urn, media_type, title, profile_uri?, schema?, description?, validation?, metadata?, extensions? }
1336
+ const mediaType = def.media_type || def.mediaType;
1337
+ const profileUri = def.profile_uri || def.profileUri || null;
1338
+ const schema = def.schema || null;
1339
+ const title = def.title || null;
1340
+ const description = def.description || null;
1341
+ const validation = def.validation || null;
1342
+ const metadata = def.metadata || null;
1343
+ const extensions = Array.isArray(def.extensions) ? def.extensions : [];
1344
+
1345
+ if (!mediaType) {
1346
+ throw new MediaSpecError(
1347
+ MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
1348
+ `Media URN '${mediaUrn}' has invalid definition: missing media_type`
1349
+ );
1350
+ }
1351
+
1352
+ return new MediaSpec(mediaType, profileUri, schema, title, description, mediaUrn, validation, metadata, extensions);
1353
+ }
1354
+ }
1355
+
1356
+ // FAIL HARD - media URN must be in mediaSpecs array
1357
+ throw new MediaSpecError(
1358
+ MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
1359
+ `Cannot resolve media URN: '${mediaUrn}'. Not found in mediaSpecs array.`
1360
+ );
1361
+ }
1362
+
1363
+ /**
1364
+ * Build an extension index from a mediaSpecs array.
1365
+ * Maps lowercase extension strings to arrays of media URNs that use that extension.
1366
+ *
1367
+ * @param {Array} mediaSpecs - The mediaSpecs array
1368
+ * @returns {Map<string, string[]>} Map from extension to list of URNs
1369
+ */
1370
+ function buildExtensionIndex(mediaSpecs) {
1371
+ const index = new Map();
1372
+ if (!mediaSpecs || !Array.isArray(mediaSpecs)) {
1373
+ return index;
1374
+ }
1375
+
1376
+ for (const spec of mediaSpecs) {
1377
+ if (!spec.urn || !Array.isArray(spec.extensions)) continue;
1378
+ for (const ext of spec.extensions) {
1379
+ const extLower = ext.toLowerCase();
1380
+ if (!index.has(extLower)) {
1381
+ index.set(extLower, []);
1382
+ }
1383
+ const urns = index.get(extLower);
1384
+ if (!urns.includes(spec.urn)) {
1385
+ urns.push(spec.urn);
1386
+ }
1387
+ }
1388
+ }
1389
+ return index;
1390
+ }
1391
+
1392
+ /**
1393
+ * Look up all media URNs that match a file extension (synchronous, no network).
1394
+ *
1395
+ * Returns all media URNs registered for the given file extension.
1396
+ * Multiple URNs may match the same extension (e.g., with different form= parameters).
1397
+ *
1398
+ * The extension should NOT include the leading dot (e.g., "pdf" not ".pdf").
1399
+ * Lookup is case-insensitive.
1400
+ *
1401
+ * @param {string} extension - The file extension to look up (without leading dot)
1402
+ * @param {Array} mediaSpecs - The mediaSpecs array
1403
+ * @returns {string[]} Array of media URNs for the extension
1404
+ * @throws {MediaSpecError} If no media spec is registered for the given extension
1405
+ *
1406
+ * @example
1407
+ * const urns = mediaUrnsForExtension('pdf', mediaSpecs);
1408
+ * // May return ['media:pdf']
1409
+ */
1410
+ function mediaUrnsForExtension(extension, mediaSpecs) {
1411
+ const index = buildExtensionIndex(mediaSpecs);
1412
+ const extLower = extension.toLowerCase();
1413
+ const urns = index.get(extLower);
1414
+
1415
+ if (!urns || urns.length === 0) {
1416
+ throw new MediaSpecError(
1417
+ MediaSpecErrorCodes.UNRESOLVABLE_MEDIA_URN,
1418
+ `No media spec registered for extension '${extension}'. ` +
1419
+ `Ensure the media spec is defined with an 'extensions' array containing '${extension}'.`
1420
+ );
1421
+ }
1422
+
1423
+ return urns;
1424
+ }
1425
+
1426
+ /**
1427
+ * Get all registered extensions and their corresponding media URNs.
1428
+ *
1429
+ * Returns an array of [extension, urns] pairs for debugging and introspection.
1430
+ *
1431
+ * @param {Array} mediaSpecs - The mediaSpecs array
1432
+ * @returns {Array<[string, string[]]>} Array of [extension, urns] pairs
1433
+ */
1434
+ function getExtensionMappings(mediaSpecs) {
1435
+ const index = buildExtensionIndex(mediaSpecs);
1436
+ return Array.from(index.entries());
1437
+ }
1438
+
1439
+ /**
1440
+ * Validate that media_specs array has no duplicate URNs.
1441
+ *
1442
+ * @param {Array} mediaSpecs - The mediaSpecs array to validate
1443
+ * @returns {{valid: boolean, error?: string, duplicates?: string[]}}
1444
+ */
1445
+ function validateNoMediaSpecDuplicates(mediaSpecs) {
1446
+ if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
1447
+ return { valid: true };
1448
+ }
1449
+
1450
+ const seen = new Set();
1451
+ const duplicates = [];
1452
+
1453
+ for (const spec of mediaSpecs) {
1454
+ if (!spec.urn) continue;
1455
+ if (seen.has(spec.urn)) {
1456
+ duplicates.push(spec.urn);
1457
+ } else {
1458
+ seen.add(spec.urn);
1459
+ }
1460
+ }
1461
+
1462
+ if (duplicates.length > 0) {
1463
+ return {
1464
+ valid: false,
1465
+ error: `Duplicate media URNs in media_specs: ${duplicates.join(', ')}`,
1466
+ duplicates
1467
+ };
1468
+ }
1469
+
1470
+ return { valid: true };
1471
+ }
1472
+
1473
+ /**
1474
+ * XV5: Validate that inline media_specs don't redefine existing registry specs.
1475
+ *
1476
+ * Validation requires a registryLookup function to check if media URNs exist.
1477
+ * If no registryLookup is provided, validation passes (graceful degradation).
1478
+ *
1479
+ * @param {Array} mediaSpecs - The inline media_specs array from a capability
1480
+ * @param {Object} [options] - Validation options
1481
+ * @param {Function} [options.registryLookup] - Function to check if media URN exists in registry
1482
+ * Returns true if exists, false otherwise
1483
+ * Should handle errors gracefully (return false)
1484
+ * @returns {Promise<{valid: boolean, error?: string, redefines?: string[]}>}
1485
+ */
1486
+ async function validateNoMediaSpecRedefinition(mediaSpecs, options = {}) {
1487
+ if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
1488
+ return { valid: true };
1489
+ }
1490
+
1491
+ const { registryLookup } = options;
1492
+
1493
+ // If no registry lookup provided, degrade gracefully and allow
1494
+ if (!registryLookup || typeof registryLookup !== 'function') {
1495
+ return { valid: true };
1496
+ }
1497
+
1498
+ const redefines = [];
1499
+
1500
+ for (const spec of mediaSpecs) {
1501
+ const mediaUrn = spec.urn;
1502
+ if (!mediaUrn) continue;
1503
+ try {
1504
+ const existsInRegistry = await registryLookup(mediaUrn);
1505
+ if (existsInRegistry) {
1506
+ redefines.push(mediaUrn);
1507
+ }
1508
+ } catch (err) {
1509
+ // Registry lookup failed - log warning and allow (graceful degradation)
1510
+ console.warn(`[WARN] XV5: Could not verify inline spec '${mediaUrn}' against registry: ${err.message}. Allowing operation in offline mode.`);
1511
+ }
1512
+ }
1513
+
1514
+ if (redefines.length > 0) {
1515
+ return {
1516
+ valid: false,
1517
+ error: `XV5: Inline media specs redefine existing registry specs: ${redefines.join(', ')}`,
1518
+ redefines
1519
+ };
1520
+ }
1521
+
1522
+ return { valid: true };
1523
+ }
1524
+
1525
+ /**
1526
+ * XV5: Synchronous version that checks against a provided lookup function.
1527
+ * If no registryLookup is provided, validation passes (graceful degradation).
1528
+ *
1529
+ * @param {Array} mediaSpecs - The inline media_specs array from a capability
1530
+ * @param {Function} [registryLookup] - Synchronous function to check if media URN exists
1531
+ * Returns true if exists, false otherwise
1532
+ * @returns {{valid: boolean, error?: string, redefines?: string[]}}
1533
+ */
1534
+ function validateNoMediaSpecRedefinitionSync(mediaSpecs, registryLookup = null) {
1535
+ if (!mediaSpecs || !Array.isArray(mediaSpecs) || mediaSpecs.length === 0) {
1536
+ return { valid: true };
1537
+ }
1538
+
1539
+ // If no registry lookup provided, degrade gracefully and allow
1540
+ if (!registryLookup || typeof registryLookup !== 'function') {
1541
+ return { valid: true };
1542
+ }
1543
+
1544
+ const redefines = [];
1545
+
1546
+ for (const spec of mediaSpecs) {
1547
+ const mediaUrn = spec.urn;
1548
+ if (!mediaUrn) continue;
1549
+ if (registryLookup(mediaUrn)) {
1550
+ redefines.push(mediaUrn);
1551
+ }
1552
+ }
1553
+
1554
+ if (redefines.length > 0) {
1555
+ return {
1556
+ valid: false,
1557
+ error: `XV5: Inline media specs redefine existing registry specs: ${redefines.join(', ')}`,
1558
+ redefines
1559
+ };
1560
+ }
1561
+
1562
+ return { valid: true };
1563
+ }
1564
+
1565
+ /**
1566
+ * Check if a CapUrn represents binary output.
1567
+ * Throws error if the output spec cannot be resolved - no fallbacks.
1568
+ * @param {CapUrn} capUrn - The cap URN
1569
+ * @param {Array} mediaSpecs - Optional mediaSpecs array
1570
+ * @returns {boolean} True if binary
1571
+ * @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
1572
+ */
1573
+ function isBinaryCapUrn(capUrn, mediaSpecs = []) {
1574
+ const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
1575
+ return mediaSpec.isBinary();
1576
+ }
1577
+
1578
+ /**
1579
+ * Check if a CapUrn represents JSON output.
1580
+ * Note: This checks for explicit JSON format marker only.
1581
+ * Throws error if the output spec cannot be resolved - no fallbacks.
1582
+ * @param {CapUrn} capUrn - The cap URN
1583
+ * @param {Array} mediaSpecs - Optional mediaSpecs array
1584
+ * @returns {boolean} True if explicit JSON tag present
1585
+ * @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
1586
+ */
1587
+ function isJSONCapUrn(capUrn, mediaSpecs = []) {
1588
+ const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
1589
+ return mediaSpec.isJSON();
1590
+ }
1591
+
1592
+ /**
1593
+ * Check if a CapUrn represents structured output (map or list).
1594
+ * Structured data can be serialized as JSON when transmitted as text.
1595
+ * Throws error if the output spec cannot be resolved - no fallbacks.
1596
+ * @param {CapUrn} capUrn - The cap URN
1597
+ * @param {Array} mediaSpecs - Optional mediaSpecs array
1598
+ * @returns {boolean} True if structured (map or list)
1599
+ * @throws {MediaSpecError} If 'out' tag is missing or spec ID cannot be resolved
1600
+ */
1601
+ function isStructuredCapUrn(capUrn, mediaSpecs = []) {
1602
+ const mediaSpec = MediaSpec.fromCapUrn(capUrn, mediaSpecs);
1603
+ return mediaSpec.isStructured();
1604
+ }
1605
+
1606
+ /**
1607
+ * Registration attribution - who registered a capability and when
1608
+ */
1609
+ class RegisteredBy {
1610
+ /**
1611
+ * Create a new registration attribution
1612
+ * @param {string} username - Username of the user who registered this capability
1613
+ * @param {string} registeredAt - ISO 8601 timestamp of when the capability was registered
1614
+ */
1615
+ constructor(username, registeredAt) {
1616
+ if (!username || typeof username !== 'string') {
1617
+ throw new Error('Username is required and must be a string');
1618
+ }
1619
+ if (!registeredAt || typeof registeredAt !== 'string') {
1620
+ throw new Error('RegisteredAt is required and must be a string');
1621
+ }
1622
+ this.username = username;
1623
+ this.registered_at = registeredAt;
1624
+ }
1625
+
1626
+ /**
1627
+ * Create from JSON representation
1628
+ * @param {Object} json - The JSON data
1629
+ * @returns {RegisteredBy} The registration attribution instance
1630
+ */
1631
+ static fromJSON(json) {
1632
+ return new RegisteredBy(json.username, json.registered_at);
1633
+ }
1634
+
1635
+ /**
1636
+ * Convert to JSON representation
1637
+ * @returns {Object} The JSON representation
1638
+ */
1639
+ toJSON() {
1640
+ return {
1641
+ username: this.username,
1642
+ registered_at: this.registered_at
1643
+ };
1644
+ }
1645
+ }
1646
+
1647
+ // ============================================================================
1648
+ // CAP ARGUMENT SYSTEM
1649
+ // ============================================================================
1650
+
1651
+ /**
1652
+ * Known source keys for argument sources
1653
+ */
1654
+ const KNOWN_SOURCE_KEYS = ['stdin', 'position', 'cli_flag'];
1655
+
1656
+ /**
1657
+ * Reserved CLI flags that cannot be used
1658
+ */
1659
+ const RESERVED_CLI_FLAGS = ['manifest', '--help', '--version', '-v', '-h'];
1660
+
1661
+ /**
1662
+ * Argument source - specifies how an argument can be provided
1663
+ */
1664
+ class ArgSource {
1665
+ constructor() {
1666
+ this.stdin = null; // string (media URN) or null
1667
+ this.position = null; // number or null
1668
+ this.cli_flag = null; // string or null
1669
+ }
1670
+
1671
+ /**
1672
+ * Create an ArgSource from a JSON object
1673
+ * @param {Object} obj - The source object with one of: stdin, position, cli_flag
1674
+ * @returns {ArgSource} The ArgSource instance
1675
+ * @throws {Error} If unknown keys are present (RULE8)
1676
+ */
1677
+ static fromJSON(obj) {
1678
+ // RULE8: Reject unknown keys
1679
+ for (const key of Object.keys(obj)) {
1680
+ if (!KNOWN_SOURCE_KEYS.includes(key)) {
1681
+ throw new ValidationError('InvalidCapSchema', 'unknown',
1682
+ { issue: `Unknown source key: ${key}` });
1683
+ }
1684
+ }
1685
+ const source = new ArgSource();
1686
+ if (obj.stdin !== undefined) source.stdin = obj.stdin;
1687
+ if (obj.position !== undefined) source.position = obj.position;
1688
+ if (obj.cli_flag !== undefined) source.cli_flag = obj.cli_flag;
1689
+ return source;
1690
+ }
1691
+
1692
+ /**
1693
+ * Get the type of this source
1694
+ * @returns {string|null} The source type: 'stdin', 'position', 'cli_flag', or null
1695
+ */
1696
+ getType() {
1697
+ if (this.stdin !== null) return 'stdin';
1698
+ if (this.position !== null) return 'position';
1699
+ if (this.cli_flag !== null) return 'cli_flag';
1700
+ return null;
1701
+ }
1702
+
1703
+ /**
1704
+ * Convert to JSON representation
1705
+ * @returns {Object} The JSON representation
1706
+ */
1707
+ toJSON() {
1708
+ if (this.stdin !== null) return { stdin: this.stdin };
1709
+ if (this.position !== null) return { position: this.position };
1710
+ if (this.cli_flag !== null) return { cli_flag: this.cli_flag };
1711
+ return {};
1712
+ }
1713
+ }
1714
+
1715
+ /**
1716
+ * Cap argument definition - media_urn is the unique identifier
1717
+ */
1718
+ class CapArg {
1719
+ /**
1720
+ * Create a new CapArg
1721
+ * @param {string} mediaUrn - The unique media URN for this argument
1722
+ * @param {boolean} required - Whether this argument is required
1723
+ * @param {Array<ArgSource>} sources - How this argument can be provided
1724
+ * @param {Object} options - Optional fields: arg_description, default_value, metadata
1725
+ */
1726
+ constructor(mediaUrn, required, sources, options = {}) {
1727
+ this.media_urn = mediaUrn;
1728
+ this.required = required;
1729
+ this.sources = sources; // Array of ArgSource
1730
+ this.arg_description = options.arg_description || null;
1731
+ this.default_value = options.default_value !== undefined ? options.default_value : null;
1732
+ this.metadata = options.metadata || null;
1733
+ }
1734
+
1735
+ /**
1736
+ * Create a CapArg from JSON
1737
+ * @param {Object} json - The JSON representation
1738
+ * @returns {CapArg} The CapArg instance
1739
+ */
1740
+ static fromJSON(json) {
1741
+ const sources = (json.sources || []).map(s => ArgSource.fromJSON(s));
1742
+ return new CapArg(
1743
+ json.media_urn,
1744
+ json.required,
1745
+ sources,
1746
+ {
1747
+ arg_description: json.arg_description,
1748
+ default_value: json.default_value,
1749
+ metadata: json.metadata
1750
+ }
1751
+ );
1752
+ }
1753
+
1754
+ /**
1755
+ * Convert to JSON representation
1756
+ * @returns {Object} The JSON representation
1757
+ */
1758
+ toJSON() {
1759
+ const result = {
1760
+ media_urn: this.media_urn,
1761
+ required: this.required,
1762
+ sources: this.sources.map(s => s.toJSON())
1763
+ };
1764
+ if (this.arg_description) result.arg_description = this.arg_description;
1765
+ if (this.default_value !== null && this.default_value !== undefined) {
1766
+ result.default_value = this.default_value;
1767
+ }
1768
+ if (this.metadata) result.metadata = this.metadata;
1769
+ return result;
1770
+ }
1771
+
1772
+ /**
1773
+ * Check if this argument has a stdin source
1774
+ * @returns {boolean} True if has stdin source
1775
+ */
1776
+ hasStdinSource() {
1777
+ return this.sources.some(s => s.stdin !== null);
1778
+ }
1779
+
1780
+ /**
1781
+ * Get the stdin media URN if present
1782
+ * @returns {string|null} The stdin media URN or null
1783
+ */
1784
+ getStdinMediaUrn() {
1785
+ const stdinSource = this.sources.find(s => s.stdin !== null);
1786
+ return stdinSource ? stdinSource.stdin : null;
1787
+ }
1788
+
1789
+ /**
1790
+ * Check if this argument has a position source
1791
+ * @returns {boolean} True if has position source
1792
+ */
1793
+ hasPositionSource() {
1794
+ return this.sources.some(s => s.position !== null);
1795
+ }
1796
+
1797
+ /**
1798
+ * Get the position if present
1799
+ * @returns {number|null} The position or null
1800
+ */
1801
+ getPosition() {
1802
+ const posSource = this.sources.find(s => s.position !== null);
1803
+ return posSource ? posSource.position : null;
1804
+ }
1805
+
1806
+ /**
1807
+ * Check if this argument has a cli_flag source
1808
+ * @returns {boolean} True if has cli_flag source
1809
+ */
1810
+ hasCliFlagSource() {
1811
+ return this.sources.some(s => s.cli_flag !== null);
1812
+ }
1813
+
1814
+ /**
1815
+ * Get the cli_flag if present
1816
+ * @returns {string|null} The cli_flag or null
1817
+ */
1818
+ getCliFlag() {
1819
+ const flagSource = this.sources.find(s => s.cli_flag !== null);
1820
+ return flagSource ? flagSource.cli_flag : null;
1821
+ }
1822
+ }
1823
+
1824
+ /**
1825
+ * Capability definition class
1826
+ */
1827
+ class Cap {
1828
+ /**
1829
+ * Create a new capability
1830
+ * @param {CapUrn} urn - The capability URN
1831
+ * @param {string} title - The human-readable title (required)
1832
+ * @param {string} command - The command string
1833
+ * @param {string|null} capDescription - Optional description
1834
+ * @param {Object} metadata - Optional metadata object
1835
+ * @param {Object|null} metadataJson - Optional arbitrary metadata as JSON object
1836
+ */
1837
+ constructor(urn, title, command, capDescription = null, metadata = {}, metadataJson = null) {
1838
+ if (!(urn instanceof CapUrn)) {
1839
+ throw new Error('URN must be a CapUrn instance');
1840
+ }
1841
+ if (!title || typeof title !== 'string') {
1842
+ throw new Error('Title is required and must be a string');
1843
+ }
1844
+ if (!command || typeof command !== 'string') {
1845
+ throw new Error('Command is required and must be a string');
1846
+ }
1847
+
1848
+ this.urn = urn;
1849
+ this.title = title;
1850
+ this.command = command;
1851
+ this.cap_description = capDescription;
1852
+ this.metadata = metadata || {};
1853
+ this.mediaSpecs = []; // Media spec definitions array
1854
+ this.args = []; // Array of CapArg - unified argument format
1855
+ this.output = null;
1856
+ this.metadata_json = metadataJson;
1857
+ this.registered_by = null; // Registration attribution
1858
+ }
1859
+
1860
+ /**
1861
+ * Get the media type expected for stdin (derived from args with stdin source)
1862
+ * @returns {string|null} The media URN for stdin, or null if cap doesn't accept stdin
1863
+ */
1864
+ stdinMediaType() {
1865
+ return this.getStdinMediaUrn();
1866
+ }
1867
+
1868
+ /**
1869
+ * Get the stdin media URN from args
1870
+ * @returns {string|null} The stdin media URN or null if no arg accepts stdin
1871
+ */
1872
+ getStdinMediaUrn() {
1873
+ for (const arg of this.args) {
1874
+ const stdinUrn = arg.getStdinMediaUrn();
1875
+ if (stdinUrn) return stdinUrn;
1876
+ }
1877
+ return null;
1878
+ }
1879
+
1880
+ /**
1881
+ * Check if this cap accepts stdin input
1882
+ * @returns {boolean} True if any arg has a stdin source
1883
+ */
1884
+ acceptsStdin() {
1885
+ return this.getStdinMediaUrn() !== null;
1886
+ }
1887
+
1888
+ /**
1889
+ * Resolve a media URN to a MediaSpec using this cap's mediaSpecs table
1890
+ * @param {string} mediaUrn - The media URN (e.g., "media:string")
1891
+ * @returns {MediaSpec} The resolved MediaSpec
1892
+ * @throws {MediaSpecError} If media URN cannot be resolved
1893
+ */
1894
+ resolveMediaUrn(mediaUrn) {
1895
+ return resolveMediaUrn(mediaUrn, this.mediaSpecs);
1896
+ }
1897
+
1898
+ /**
1899
+ * Get the URN as a string
1900
+ * @returns {string} The URN string representation
1901
+ */
1902
+ urnString() {
1903
+ return this.urn.toString();
1904
+ }
1905
+
1906
+ /**
1907
+ * Check if this capability accepts a request string
1908
+ * @param {string} request - The request string
1909
+ * @returns {boolean} Whether this capability accepts the request
1910
+ */
1911
+ acceptsRequest(request) {
1912
+ const requestUrn = CapUrn.fromString(request);
1913
+ return this.urn.accepts(requestUrn);
1914
+ }
1915
+
1916
+ /**
1917
+ * Check if this capability is more specific than another
1918
+ * @param {Cap} other - The other capability
1919
+ * @returns {boolean} Whether this capability is more specific
1920
+ */
1921
+ isMoreSpecificThan(other) {
1922
+ if (!other) return true;
1923
+ return this.urn.isMoreSpecificThan(other.urn);
1924
+ }
1925
+
1926
+ /**
1927
+ * Get metadata value by key
1928
+ * @param {string} key - The metadata key
1929
+ * @returns {string|undefined} The metadata value
1930
+ */
1931
+ getMetadata(key) {
1932
+ return this.metadata[key];
1933
+ }
1934
+
1935
+ /**
1936
+ * Set metadata value
1937
+ * @param {string} key - The metadata key
1938
+ * @param {string} value - The metadata value
1939
+ */
1940
+ setMetadata(key, value) {
1941
+ this.metadata[key] = value;
1942
+ }
1943
+
1944
+ /**
1945
+ * Remove metadata value
1946
+ * @param {string} key - The metadata key to remove
1947
+ * @returns {boolean} Whether the key existed
1948
+ */
1949
+ removeMetadata(key) {
1950
+ const existed = this.metadata.hasOwnProperty(key);
1951
+ delete this.metadata[key];
1952
+ return existed;
1953
+ }
1954
+
1955
+ /**
1956
+ * Check if this capability has specific metadata
1957
+ * @param {string} key - The metadata key
1958
+ * @returns {boolean} Whether the metadata exists
1959
+ */
1960
+ hasMetadata(key) {
1961
+ return this.metadata.hasOwnProperty(key);
1962
+ }
1963
+
1964
+ /**
1965
+ * Add an argument
1966
+ * @param {CapArg} arg - The argument to add
1967
+ */
1968
+ addArg(arg) {
1969
+ this.args.push(arg);
1970
+ }
1971
+
1972
+ /**
1973
+ * Get all required arguments
1974
+ * @returns {Array<CapArg>} Required arguments
1975
+ */
1976
+ getRequiredArgs() {
1977
+ return this.args.filter(arg => arg.required);
1978
+ }
1979
+
1980
+ /**
1981
+ * Get all optional arguments
1982
+ * @returns {Array<CapArg>} Optional arguments
1983
+ */
1984
+ getOptionalArgs() {
1985
+ return this.args.filter(arg => !arg.required);
1986
+ }
1987
+
1988
+ /**
1989
+ * Find argument by media_urn
1990
+ * @param {string} mediaUrn - The media URN to search for
1991
+ * @returns {CapArg|null} The argument or null
1992
+ */
1993
+ findArgByMediaUrn(mediaUrn) {
1994
+ return this.args.find(arg => arg.media_urn === mediaUrn) || null;
1995
+ }
1996
+
1997
+ /**
1998
+ * Set the output definition
1999
+ * @param {Object} output - The output definition
2000
+ */
2001
+ setOutput(output) {
2002
+ this.output = output;
2003
+ }
2004
+
2005
+ /**
2006
+ * Get metadata JSON
2007
+ * @returns {Object|null} The metadata JSON
2008
+ */
2009
+ getMetadataJSON() {
2010
+ return this.metadata_json;
2011
+ }
2012
+
2013
+ /**
2014
+ * Set metadata JSON
2015
+ * @param {Object} metadata - The metadata JSON object
2016
+ */
2017
+ setMetadataJSON(metadata) {
2018
+ this.metadata_json = metadata;
2019
+ }
2020
+
2021
+ /**
2022
+ * Clear metadata JSON
2023
+ */
2024
+ clearMetadataJSON() {
2025
+ this.metadata_json = null;
2026
+ }
2027
+
2028
+ /**
2029
+ * Check if this capability equals another
2030
+ * Compares all fields to match Rust reference implementation
2031
+ * @param {Cap} other - The other capability
2032
+ * @returns {boolean} Whether the capabilities are equal
2033
+ */
2034
+ equals(other) {
2035
+ if (!other || !(other instanceof Cap)) {
2036
+ return false;
2037
+ }
2038
+
2039
+ return this.urn.equals(other.urn) &&
2040
+ this.title === other.title &&
2041
+ this.command === other.command &&
2042
+ this.cap_description === other.cap_description &&
2043
+ JSON.stringify(this.metadata) === JSON.stringify(other.metadata) &&
2044
+ JSON.stringify(this.mediaSpecs) === JSON.stringify(other.mediaSpecs) &&
2045
+ JSON.stringify(this.args.map(a => a.toJSON())) === JSON.stringify(other.args.map(a => a.toJSON())) &&
2046
+ JSON.stringify(this.output) === JSON.stringify(other.output) &&
2047
+ JSON.stringify(this.metadata_json) === JSON.stringify(other.metadata_json) &&
2048
+ JSON.stringify(this.registered_by) === JSON.stringify(other.registered_by);
2049
+ }
2050
+
2051
+ /**
2052
+ * Convert to JSON representation
2053
+ * @returns {Object} JSON representation
2054
+ */
2055
+ toJSON() {
2056
+ const result = {
2057
+ urn: this.urn.toString(),
2058
+ title: this.title,
2059
+ command: this.command,
2060
+ cap_description: this.cap_description,
2061
+ metadata: this.metadata,
2062
+ media_specs: this.mediaSpecs,
2063
+ args: this.args.map(a => a.toJSON()),
2064
+ output: this.output
2065
+ };
2066
+
2067
+ if (this.metadata_json !== null && this.metadata_json !== undefined) {
2068
+ result.metadata_json = this.metadata_json;
2069
+ }
2070
+
2071
+ return result;
2072
+ }
2073
+
2074
+ /**
2075
+ * Create a capability from JSON representation
2076
+ * @param {Object} json - The JSON data
2077
+ * @returns {Cap} The capability instance
2078
+ */
2079
+ static fromJSON(json) {
2080
+ // URN must be a string in canonical format
2081
+ if (typeof json.urn !== 'string') {
2082
+ throw new Error("URN must be a string in canonical format (e.g., 'cap:in=\"media:...\";op=...;out=\"media:...\"')");
2083
+ }
2084
+ const urn = CapUrn.fromString(json.urn);
2085
+
2086
+ const cap = new Cap(urn, json.title, json.command, json.cap_description, json.metadata, json.metadata_json);
2087
+ cap.mediaSpecs = json.media_specs || json.mediaSpecs || [];
2088
+ // Parse args (new format)
2089
+ if (json.args && Array.isArray(json.args)) {
2090
+ cap.args = json.args.map(a => CapArg.fromJSON(a));
2091
+ } else {
2092
+ cap.args = [];
2093
+ }
2094
+ cap.output = json.output;
2095
+ cap.registered_by = json.registered_by ? RegisteredBy.fromJSON(json.registered_by) : null;
2096
+ return cap;
2097
+ }
2098
+
2099
+ /**
2100
+ * Get the registration attribution
2101
+ * @returns {RegisteredBy|null} The registration attribution or null
2102
+ */
2103
+ getRegisteredBy() {
2104
+ return this.registered_by;
2105
+ }
2106
+
2107
+ /**
2108
+ * Set the registration attribution
2109
+ * @param {RegisteredBy} registeredBy - The registration attribution
2110
+ */
2111
+ setRegisteredBy(registeredBy) {
2112
+ this.registered_by = registeredBy;
2113
+ }
2114
+
2115
+ /**
2116
+ * Clear the registration attribution
2117
+ */
2118
+ clearRegisteredBy() {
2119
+ this.registered_by = null;
2120
+ }
2121
+ }
2122
+
2123
+ /**
2124
+ * Helper functions for creating capabilities
2125
+ */
2126
+ function createCap(urn, title, command) {
2127
+ return new Cap(urn, title, command);
2128
+ }
2129
+
2130
+ function createCapWithDescription(urn, title, command, description) {
2131
+ return new Cap(urn, title, command, description);
2132
+ }
2133
+
2134
+ function createCapWithMetadata(urn, title, command, metadata) {
2135
+ return new Cap(urn, title, command, null, metadata);
2136
+ }
2137
+
2138
+ function createCapWithDescriptionAndMetadata(urn, title, command, description, metadata) {
2139
+ return new Cap(urn, title, command, description, metadata);
2140
+ }
2141
+
2142
+ // ============================================================================
2143
+ // VALIDATION SYSTEM
2144
+ // ============================================================================
2145
+
2146
+ /**
2147
+ * Validation error types with descriptive failure information
2148
+ */
2149
+ class ValidationError extends Error {
2150
+ constructor(type, capUrn, details = {}) {
2151
+ const message = ValidationError.formatMessage(type, capUrn, details);
2152
+ super(message);
2153
+ this.name = 'ValidationError';
2154
+ this.type = type;
2155
+ this.capUrn = capUrn;
2156
+ this.details = details;
2157
+ }
2158
+
2159
+ static formatMessage(type, capUrn, details) {
2160
+ switch (type) {
2161
+ case 'UnknownCap':
2162
+ return `Unknown cap '${capUrn}' - cap not registered or advertised`;
2163
+ case 'MissingRequiredArgument':
2164
+ return `Cap '${capUrn}' requires argument '${details.argumentName}' but it was not provided`;
2165
+ case 'UnknownArgument':
2166
+ return `Cap '${capUrn}' does not accept argument '${details.argumentName}' - check capability definition for valid arguments`;
2167
+ case 'InvalidArgumentType':
2168
+ if (details.expectedMediaSpec) {
2169
+ const errors = details.schemaErrors ? details.schemaErrors.join(', ') : 'validation failed';
2170
+ return `Cap '${capUrn}' argument '${details.argumentName}' expects media_spec '${details.expectedMediaSpec}' but ${errors} for value: ${JSON.stringify(details.actualValue)}`;
2171
+ }
2172
+ return `Cap '${capUrn}' argument '${details.argumentName}' expects type '${details.expectedType}' but received '${details.actualType}' with value: ${JSON.stringify(details.actualValue)}`;
2173
+ case 'MediaValidationFailed':
2174
+ return `Cap '${capUrn}' argument '${details.argumentName}' failed validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
2175
+ case 'MediaSpecValidationFailed':
2176
+ return `Cap '${capUrn}' argument '${details.argumentName}' failed media spec '${details.mediaUrn}' validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
2177
+ case 'InvalidOutputType':
2178
+ if (details.expectedMediaSpec) {
2179
+ const errors = details.schemaErrors ? details.schemaErrors.join(', ') : 'validation failed';
2180
+ return `Cap '${capUrn}' output expects media_spec '${details.expectedMediaSpec}' but ${errors} for value: ${JSON.stringify(details.actualValue)}`;
2181
+ }
2182
+ return `Cap '${capUrn}' output expects type '${details.expectedType}' but received '${details.actualType}' with value: ${JSON.stringify(details.actualValue)}`;
2183
+ case 'OutputValidationFailed':
2184
+ return `Cap '${capUrn}' output failed validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
2185
+ case 'OutputMediaSpecValidationFailed':
2186
+ return `Cap '${capUrn}' output failed media spec '${details.mediaUrn}' validation rule '${details.validationRule}' with value: ${JSON.stringify(details.actualValue)}`;
2187
+ case 'InvalidCapSchema':
2188
+ return `Cap '${capUrn}' has invalid schema: ${details.issue}`;
2189
+ case 'TooManyArguments':
2190
+ return `Cap '${capUrn}' expects at most ${details.maxExpected} arguments but received ${details.actualCount}`;
2191
+ case 'JsonParseError':
2192
+ return `Cap '${capUrn}' JSON parsing failed: ${details.error}`;
2193
+ case 'SchemaValidationFailed':
2194
+ return `Cap '${capUrn}' schema validation failed for '${details.fieldName}': ${details.schemaErrors}`;
2195
+ default:
2196
+ return `Cap validation error: ${type}`;
2197
+ }
2198
+ }
2199
+ }
2200
+
2201
+ /**
2202
+ * Validate cap args against the 12 validation rules
2203
+ * @param {Cap} cap - The capability to validate
2204
+ * @throws {ValidationError} If any validation rule is violated
2205
+ */
2206
+ function validateCapArgs(cap) {
2207
+ const capUrn = cap.urnString();
2208
+ const args = cap.args;
2209
+
2210
+ // RULE1: No duplicate media_urns (using string comparison for now)
2211
+ const mediaUrns = new Set();
2212
+ for (const arg of args) {
2213
+ if (mediaUrns.has(arg.media_urn)) {
2214
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2215
+ issue: `RULE1: Duplicate media_urn '${arg.media_urn}'`
2216
+ });
2217
+ }
2218
+ mediaUrns.add(arg.media_urn);
2219
+ }
2220
+
2221
+ // RULE2: sources must not be null or empty
2222
+ for (const arg of args) {
2223
+ if (!arg.sources || arg.sources.length === 0) {
2224
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2225
+ issue: `RULE2: Argument '${arg.media_urn}' has empty sources`
2226
+ });
2227
+ }
2228
+ }
2229
+
2230
+ // Collect stdin URNs, positions, and cli_flags for cross-arg validation
2231
+ const stdinUrns = [];
2232
+ const positions = [];
2233
+ const cliFlags = [];
2234
+
2235
+ for (const arg of args) {
2236
+ const sourceTypes = new Set();
2237
+ let hasPosition = false;
2238
+ let hasCliFlag = false;
2239
+
2240
+ for (const source of arg.sources) {
2241
+ const sourceType = source.getType();
2242
+
2243
+ // RULE4: No arg may specify same source type more than once
2244
+ if (sourceTypes.has(sourceType)) {
2245
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2246
+ issue: `RULE4: Argument '${arg.media_urn}' has duplicate source type '${sourceType}'`
2247
+ });
2248
+ }
2249
+ sourceTypes.add(sourceType);
2250
+
2251
+ if (source.stdin !== null) {
2252
+ stdinUrns.push(source.stdin);
2253
+ }
2254
+ if (source.position !== null) {
2255
+ hasPosition = true;
2256
+ positions.push({ position: source.position, mediaUrn: arg.media_urn });
2257
+ }
2258
+ if (source.cli_flag !== null) {
2259
+ hasCliFlag = true;
2260
+ cliFlags.push({ flag: source.cli_flag, mediaUrn: arg.media_urn });
2261
+
2262
+ // RULE10: Reserved cli_flags
2263
+ if (RESERVED_CLI_FLAGS.includes(source.cli_flag)) {
2264
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2265
+ issue: `RULE10: Argument '${arg.media_urn}' uses reserved cli_flag '${source.cli_flag}'`
2266
+ });
2267
+ }
2268
+ }
2269
+ }
2270
+
2271
+ // RULE7: No arg may have both position and cli_flag
2272
+ if (hasPosition && hasCliFlag) {
2273
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2274
+ issue: `RULE7: Argument '${arg.media_urn}' has both position and cli_flag sources`
2275
+ });
2276
+ }
2277
+ }
2278
+
2279
+ // RULE3: If multiple args have stdin source, stdin media_urns must be identical
2280
+ if (stdinUrns.length > 1) {
2281
+ const firstStdin = stdinUrns[0];
2282
+ for (let i = 1; i < stdinUrns.length; i++) {
2283
+ if (stdinUrns[i] !== firstStdin) {
2284
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2285
+ issue: `RULE3: Multiple args have different stdin media_urns: '${firstStdin}' vs '${stdinUrns[i]}'`
2286
+ });
2287
+ }
2288
+ }
2289
+ }
2290
+
2291
+ // RULE5: No two args may have same position
2292
+ const positionSet = new Set();
2293
+ for (const { position, mediaUrn } of positions) {
2294
+ if (positionSet.has(position)) {
2295
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2296
+ issue: `RULE5: Duplicate position ${position} in argument '${mediaUrn}'`
2297
+ });
2298
+ }
2299
+ positionSet.add(position);
2300
+ }
2301
+
2302
+ // RULE6: Positions must be sequential (0-based, no gaps when aggregated)
2303
+ if (positions.length > 0) {
2304
+ const sortedPositions = [...positions].sort((a, b) => a.position - b.position);
2305
+ for (let i = 0; i < sortedPositions.length; i++) {
2306
+ if (sortedPositions[i].position !== i) {
2307
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2308
+ issue: `RULE6: Position gap - expected ${i} but found ${sortedPositions[i].position}`
2309
+ });
2310
+ }
2311
+ }
2312
+ }
2313
+
2314
+ // RULE9: No two args may have same cli_flag
2315
+ const flagSet = new Set();
2316
+ for (const { flag, mediaUrn } of cliFlags) {
2317
+ if (flagSet.has(flag)) {
2318
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2319
+ issue: `RULE9: Duplicate cli_flag '${flag}' in argument '${mediaUrn}'`
2320
+ });
2321
+ }
2322
+ flagSet.add(flag);
2323
+ }
2324
+
2325
+ // RULE8: No unknown keys in source objects - this is handled in ArgSource.fromJSON()
2326
+ // RULE11: cli_flag used verbatim as specified - enforced by design
2327
+ // RULE12: media_urn is the key, no name field - enforced by CapArg structure
2328
+ }
2329
+
2330
+ /**
2331
+ * Input argument validator
2332
+ */
2333
+ class InputValidator {
2334
+ /**
2335
+ * Validate positional arguments against cap input schema
2336
+ */
2337
+ static validatePositionalArguments(cap, argValues) {
2338
+ const capUrn = cap.urnString();
2339
+ const args = cap.arguments;
2340
+
2341
+ // Check if too many arguments provided
2342
+ const maxArgs = args.required.length + args.optional.length;
2343
+ if (argValues.length > maxArgs) {
2344
+ throw new ValidationError('TooManyArguments', capUrn, {
2345
+ maxExpected: maxArgs,
2346
+ actualCount: argValues.length
2347
+ });
2348
+ }
2349
+
2350
+ // Validate required arguments
2351
+ for (let i = 0; i < args.required.length; i++) {
2352
+ if (i >= argValues.length) {
2353
+ throw new ValidationError('MissingRequiredArgument', capUrn, {
2354
+ argumentName: args.required[i].name
2355
+ });
2356
+ }
2357
+
2358
+ InputValidator.validateSingleArgument(cap, args.required[i], argValues[i]);
2359
+ }
2360
+
2361
+ // Validate optional arguments if provided
2362
+ const requiredCount = args.required.length;
2363
+ for (let i = 0; i < args.optional.length; i++) {
2364
+ const argIndex = requiredCount + i;
2365
+ if (argIndex < argValues.length) {
2366
+ InputValidator.validateSingleArgument(cap, args.optional[i], argValues[argIndex]);
2367
+ }
2368
+ }
2369
+ }
2370
+
2371
+ /**
2372
+ * Validate named arguments against cap input schema
2373
+ */
2374
+ static validateNamedArguments(cap, namedArgs) {
2375
+ const capUrn = cap.urnString();
2376
+ const args = cap.arguments;
2377
+
2378
+ // Extract named argument values into a map
2379
+ const providedArgs = new Map();
2380
+ for (const arg of namedArgs) {
2381
+ if (typeof arg === 'object' && arg.name && arg.hasOwnProperty('value')) {
2382
+ providedArgs.set(arg.name, arg.value);
2383
+ }
2384
+ }
2385
+
2386
+ // Check that all required arguments are provided as named arguments
2387
+ for (const reqArg of args.required) {
2388
+ if (!providedArgs.has(reqArg.name)) {
2389
+ throw new ValidationError('MissingRequiredArgument', capUrn, {
2390
+ argumentName: `${reqArg.name} (expected as named argument)`
2391
+ });
2392
+ }
2393
+
2394
+ // Validate the provided argument value
2395
+ const providedValue = providedArgs.get(reqArg.name);
2396
+ InputValidator.validateSingleArgument(cap, reqArg, providedValue);
2397
+ }
2398
+
2399
+ // Validate optional arguments if provided
2400
+ for (const optArg of args.optional) {
2401
+ if (providedArgs.has(optArg.name)) {
2402
+ const providedValue = providedArgs.get(optArg.name);
2403
+ InputValidator.validateSingleArgument(cap, optArg, providedValue);
2404
+ }
2405
+ }
2406
+
2407
+ // Check for unknown arguments
2408
+ const knownArgNames = new Set([
2409
+ ...args.required.map(arg => arg.name),
2410
+ ...args.optional.map(arg => arg.name)
2411
+ ]);
2412
+
2413
+ for (const providedName of providedArgs.keys()) {
2414
+ if (!knownArgNames.has(providedName)) {
2415
+ throw new ValidationError('UnknownArgument', capUrn, {
2416
+ argumentName: providedName
2417
+ });
2418
+ }
2419
+ }
2420
+ }
2421
+
2422
+ /**
2423
+ * Validate a single argument against its definition
2424
+ * Two-pass validation:
2425
+ * 1. Type validation + media spec validation rules (inherent to semantic type)
2426
+ */
2427
+ static validateSingleArgument(cap, argDef, value) {
2428
+ // Type validation - returns the resolved MediaSpec
2429
+ const mediaSpec = InputValidator.validateArgumentType(cap, argDef, value);
2430
+
2431
+ // Media spec validation rules (inherent to the semantic type)
2432
+ if (mediaSpec && mediaSpec.validation) {
2433
+ InputValidator.validateMediaSpecRules(cap, argDef, mediaSpec, value);
2434
+ }
2435
+ }
2436
+
2437
+ /**
2438
+ * Validate argument type using MediaSpec
2439
+ * Resolves spec ID to MediaSpec before validation
2440
+ * @returns {MediaSpec|null} The resolved MediaSpec
2441
+ */
2442
+ static validateArgumentType(cap, argDef, value) {
2443
+ const capUrn = cap.urnString();
2444
+
2445
+ // Get mediaUrn field (now contains a media URN)
2446
+ const mediaUrn = argDef.mediaUrn || argDef.media_urn;
2447
+ if (!mediaUrn) {
2448
+ // No media_urn - skip validation
2449
+ return null;
2450
+ }
2451
+
2452
+ // Resolve media URN to MediaSpec - FAIL HARD if unresolvable
2453
+ let mediaSpec;
2454
+ try {
2455
+ mediaSpec = cap.resolveMediaUrn(mediaUrn);
2456
+ } catch (e) {
2457
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2458
+ issue: `Cannot resolve media URN '${mediaUrn}' for argument '${argDef.name}': ${e.message}`
2459
+ });
2460
+ }
2461
+
2462
+ // For binary media types, expect base64-encoded string
2463
+ if (mediaSpec.isBinary()) {
2464
+ if (typeof value !== 'string') {
2465
+ throw new ValidationError('InvalidArgumentType', capUrn, {
2466
+ argumentName: argDef.name,
2467
+ expectedMediaSpec: mediaUrn,
2468
+ actualValue: value,
2469
+ schemaErrors: ['Expected base64-encoded string for binary type']
2470
+ });
2471
+ }
2472
+ return mediaSpec;
2473
+ }
2474
+
2475
+ // If the resolved media spec has a local schema, validate against it
2476
+ if (mediaSpec.schema) {
2477
+ // TODO: Full JSON Schema validation would require a JSON Schema library
2478
+ // For now, skip local schema validation
2479
+ }
2480
+
2481
+ // For types with profile, validate against profile
2482
+ if (mediaSpec.profile) {
2483
+ const valid = InputValidator.validateAgainstProfile(mediaSpec.profile, value);
2484
+ if (!valid) {
2485
+ throw new ValidationError('InvalidArgumentType', capUrn, {
2486
+ argumentName: argDef.name,
2487
+ expectedMediaSpec: mediaUrn,
2488
+ actualValue: value,
2489
+ schemaErrors: [`Value does not match profile schema`]
2490
+ });
2491
+ }
2492
+ }
2493
+
2494
+ return mediaSpec;
2495
+ }
2496
+
2497
+ /**
2498
+ * Validate value against media spec's inherent validation rules (first pass)
2499
+ * @param {Cap} cap - The capability
2500
+ * @param {Object} argDef - The argument definition
2501
+ * @param {MediaSpec} mediaSpec - The resolved media spec
2502
+ * @param {*} value - The value to validate
2503
+ */
2504
+ static validateMediaSpecRules(cap, argDef, mediaSpec, value) {
2505
+ const capUrn = cap.urnString();
2506
+ const validation = mediaSpec.validation;
2507
+ const mediaUrn = mediaSpec.mediaUrn;
2508
+
2509
+ // Min/max validation for numbers
2510
+ if (typeof value === 'number') {
2511
+ if (validation.min !== undefined && value < validation.min) {
2512
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2513
+ argumentName: argDef.name,
2514
+ mediaUrn: mediaUrn,
2515
+ validationRule: `min value ${validation.min}`,
2516
+ actualValue: value
2517
+ });
2518
+ }
2519
+ if (validation.max !== undefined && value > validation.max) {
2520
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2521
+ argumentName: argDef.name,
2522
+ mediaUrn: mediaUrn,
2523
+ validationRule: `max value ${validation.max}`,
2524
+ actualValue: value
2525
+ });
2526
+ }
2527
+ }
2528
+
2529
+ // Length validation for strings and arrays
2530
+ if (typeof value === 'string' || Array.isArray(value)) {
2531
+ const length = value.length;
2532
+ if (validation.min_length !== undefined && length < validation.min_length) {
2533
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2534
+ argumentName: argDef.name,
2535
+ mediaUrn: mediaUrn,
2536
+ validationRule: `min length ${validation.min_length}`,
2537
+ actualValue: value
2538
+ });
2539
+ }
2540
+ if (validation.max_length !== undefined && length > validation.max_length) {
2541
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2542
+ argumentName: argDef.name,
2543
+ mediaUrn: mediaUrn,
2544
+ validationRule: `max length ${validation.max_length}`,
2545
+ actualValue: value
2546
+ });
2547
+ }
2548
+ }
2549
+
2550
+ // Pattern validation for strings
2551
+ if (typeof value === 'string' && validation.pattern) {
2552
+ const regex = new RegExp(validation.pattern);
2553
+ if (!regex.test(value)) {
2554
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2555
+ argumentName: argDef.name,
2556
+ mediaUrn: mediaUrn,
2557
+ validationRule: `pattern ${validation.pattern}`,
2558
+ actualValue: value
2559
+ });
2560
+ }
2561
+ }
2562
+
2563
+ // Allowed values validation
2564
+ if (validation.allowed_values && Array.isArray(validation.allowed_values)) {
2565
+ if (!validation.allowed_values.includes(value)) {
2566
+ throw new ValidationError('MediaSpecValidationFailed', capUrn, {
2567
+ argumentName: argDef.name,
2568
+ mediaUrn: mediaUrn,
2569
+ validationRule: `allowed values [${validation.allowed_values.join(', ')}]`,
2570
+ actualValue: value
2571
+ });
2572
+ }
2573
+ }
2574
+ }
2575
+
2576
+ /**
2577
+ * Basic validation against common profile schemas
2578
+ * @param {string} profile - Profile URL
2579
+ * @param {*} value - Value to validate
2580
+ * @returns {boolean} True if valid
2581
+ */
2582
+ static validateAgainstProfile(profile, value) {
2583
+ // Match against standard capdag.com schemas (both /schema/ and /schemas/ for compatibility)
2584
+ if (profile.includes('/schema/str') || profile.includes('/schemas/str')) {
2585
+ return typeof value === 'string';
2586
+ }
2587
+ if (profile.includes('/schema/int') || profile.includes('/schemas/int')) {
2588
+ return Number.isInteger(value);
2589
+ }
2590
+ if (profile.includes('/schema/num') || profile.includes('/schemas/num')) {
2591
+ return typeof value === 'number' && !isNaN(value);
2592
+ }
2593
+ if (profile.includes('/schema/bool') || profile.includes('/schemas/bool')) {
2594
+ return typeof value === 'boolean';
2595
+ }
2596
+ if (profile.includes('/schema/obj') || profile.includes('/schemas/obj')) {
2597
+ // Check obj before obj-array
2598
+ if (profile.includes('-array')) {
2599
+ return Array.isArray(value) && value.every(v => typeof v === 'object' && v !== null && !Array.isArray(v));
2600
+ }
2601
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
2602
+ }
2603
+ if (profile.includes('/schema/str-array') || profile.includes('/schemas/str-array')) {
2604
+ return Array.isArray(value) && value.every(v => typeof v === 'string');
2605
+ }
2606
+ if (profile.includes('/schema/int-array') || profile.includes('/schemas/int-array')) {
2607
+ return Array.isArray(value) && value.every(v => Number.isInteger(v));
2608
+ }
2609
+ if (profile.includes('/schema/num-array') || profile.includes('/schemas/num-array')) {
2610
+ return Array.isArray(value) && value.every(v => typeof v === 'number');
2611
+ }
2612
+ if (profile.includes('/schema/bool-array') || profile.includes('/schemas/bool-array')) {
2613
+ return Array.isArray(value) && value.every(v => typeof v === 'boolean');
2614
+ }
2615
+
2616
+ // Unknown profile - allow any JSON value
2617
+ return true;
2618
+ }
2619
+
2620
+ /**
2621
+ * Get JSON type name for a value
2622
+ */
2623
+ static getJsonTypeName(value) {
2624
+ if (value === null) return 'null';
2625
+ if (Array.isArray(value)) return 'array';
2626
+ if (typeof value === 'object') return 'object';
2627
+ if (Number.isInteger(value)) return 'integer';
2628
+ return typeof value;
2629
+ }
2630
+ }
2631
+
2632
+ /**
2633
+ * Output validator
2634
+ */
2635
+ class OutputValidator {
2636
+ /**
2637
+ * Validate output against cap output schema using MediaSpec
2638
+ * Resolves spec ID to MediaSpec before validation
2639
+ */
2640
+ static validateOutput(cap, output) {
2641
+ const capUrn = cap.urnString();
2642
+ const outputDef = cap.output;
2643
+
2644
+ if (!outputDef) return; // No output definition to validate against
2645
+
2646
+ // Type validation - returns the resolved MediaSpec
2647
+ const mediaSpec = OutputValidator.validateOutputType(cap, outputDef, output);
2648
+
2649
+ // Media spec validation rules (inherent to the semantic type)
2650
+ if (mediaSpec && mediaSpec.validation) {
2651
+ OutputValidator.validateOutputMediaSpecRules(cap, mediaSpec, output);
2652
+ }
2653
+ }
2654
+
2655
+ /**
2656
+ * Validate output type using MediaSpec
2657
+ * @returns {MediaSpec|null} The resolved MediaSpec
2658
+ */
2659
+ static validateOutputType(cap, outputDef, value) {
2660
+ const capUrn = cap.urnString();
2661
+
2662
+ // Get mediaUrn field (now contains a media URN)
2663
+ const mediaUrn = outputDef.mediaUrn || outputDef.media_urn;
2664
+ if (!mediaUrn) {
2665
+ // No media_urn - skip validation
2666
+ return null;
2667
+ }
2668
+
2669
+ // Resolve media URN to MediaSpec - FAIL HARD if unresolvable
2670
+ let mediaSpec;
2671
+ try {
2672
+ mediaSpec = cap.resolveMediaUrn(mediaUrn);
2673
+ } catch (e) {
2674
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2675
+ issue: `Cannot resolve media URN '${mediaUrn}' for output: ${e.message}`
2676
+ });
2677
+ }
2678
+
2679
+ // For binary media types, expect base64-encoded string
2680
+ if (mediaSpec.isBinary()) {
2681
+ if (typeof value !== 'string') {
2682
+ throw new ValidationError('InvalidOutputType', capUrn, {
2683
+ expectedMediaSpec: mediaUrn,
2684
+ actualValue: value,
2685
+ schemaErrors: ['Expected base64-encoded string for binary type']
2686
+ });
2687
+ }
2688
+ return mediaSpec;
2689
+ }
2690
+
2691
+ // If the resolved media spec has a local schema, validate against it
2692
+ if (mediaSpec.schema) {
2693
+ // TODO: Full JSON Schema validation would require a JSON Schema library
2694
+ // For now, skip local schema validation
2695
+ }
2696
+
2697
+ // For types with profile, validate against profile
2698
+ if (mediaSpec.profile) {
2699
+ const valid = InputValidator.validateAgainstProfile(mediaSpec.profile, value);
2700
+ if (!valid) {
2701
+ throw new ValidationError('InvalidOutputType', capUrn, {
2702
+ expectedMediaSpec: mediaUrn,
2703
+ actualValue: value,
2704
+ schemaErrors: [`Value does not match profile schema`]
2705
+ });
2706
+ }
2707
+ }
2708
+
2709
+ return mediaSpec;
2710
+ }
2711
+
2712
+ /**
2713
+ * Validate output against media spec's inherent validation rules (first pass)
2714
+ */
2715
+ static validateOutputMediaSpecRules(cap, mediaSpec, value) {
2716
+ const capUrn = cap.urnString();
2717
+ const validation = mediaSpec.validation;
2718
+ const mediaUrn = mediaSpec.mediaUrn;
2719
+
2720
+ // Min/max validation for numbers
2721
+ if (typeof value === 'number') {
2722
+ if (validation.min !== undefined && value < validation.min) {
2723
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2724
+ mediaUrn: mediaUrn,
2725
+ validationRule: `min value ${validation.min}`,
2726
+ actualValue: value
2727
+ });
2728
+ }
2729
+ if (validation.max !== undefined && value > validation.max) {
2730
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2731
+ mediaUrn: mediaUrn,
2732
+ validationRule: `max value ${validation.max}`,
2733
+ actualValue: value
2734
+ });
2735
+ }
2736
+ }
2737
+
2738
+ // Length validation for strings
2739
+ if (typeof value === 'string') {
2740
+ if (validation.min_length !== undefined && value.length < validation.min_length) {
2741
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2742
+ mediaUrn: mediaUrn,
2743
+ validationRule: `min length ${validation.min_length}`,
2744
+ actualValue: value
2745
+ });
2746
+ }
2747
+ if (validation.max_length !== undefined && value.length > validation.max_length) {
2748
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2749
+ mediaUrn: mediaUrn,
2750
+ validationRule: `max length ${validation.max_length}`,
2751
+ actualValue: value
2752
+ });
2753
+ }
2754
+ }
2755
+
2756
+ // Pattern validation for strings
2757
+ if (typeof value === 'string' && validation.pattern) {
2758
+ const regex = new RegExp(validation.pattern);
2759
+ if (!regex.test(value)) {
2760
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2761
+ mediaUrn: mediaUrn,
2762
+ validationRule: `pattern ${validation.pattern}`,
2763
+ actualValue: value
2764
+ });
2765
+ }
2766
+ }
2767
+
2768
+ // Allowed values validation
2769
+ if (validation.allowed_values && Array.isArray(validation.allowed_values)) {
2770
+ if (!validation.allowed_values.includes(value)) {
2771
+ throw new ValidationError('OutputMediaSpecValidationFailed', capUrn, {
2772
+ mediaUrn: mediaUrn,
2773
+ validationRule: `allowed values [${validation.allowed_values.join(', ')}]`,
2774
+ actualValue: value
2775
+ });
2776
+ }
2777
+ }
2778
+ }
2779
+ }
2780
+
2781
+ /**
2782
+ * Cap validator
2783
+ */
2784
+ class CapValidator {
2785
+ /**
2786
+ * Validate cap schema
2787
+ */
2788
+ static validateCap(cap) {
2789
+ const capUrn = cap.urnString();
2790
+
2791
+ // Validate basic cap structure
2792
+ if (!cap.title || typeof cap.title !== 'string') {
2793
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2794
+ issue: 'Cap must have a valid title'
2795
+ });
2796
+ }
2797
+
2798
+ if (!cap.command || typeof cap.command !== 'string') {
2799
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2800
+ issue: 'Cap must have a valid command'
2801
+ });
2802
+ }
2803
+
2804
+ // Validate arguments structure
2805
+ if (cap.arguments) {
2806
+ if (cap.arguments.required && !Array.isArray(cap.arguments.required)) {
2807
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2808
+ issue: 'Required arguments must be an array'
2809
+ });
2810
+ }
2811
+
2812
+ if (cap.arguments.optional && !Array.isArray(cap.arguments.optional)) {
2813
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2814
+ issue: 'Optional arguments must be an array'
2815
+ });
2816
+ }
2817
+ }
2818
+
2819
+ // Validate output structure
2820
+ if (cap.output && typeof cap.output !== 'object') {
2821
+ throw new ValidationError('InvalidCapSchema', capUrn, {
2822
+ issue: 'Cap output must be an object'
2823
+ });
2824
+ }
2825
+ }
2826
+ }
2827
+
2828
+ // ============================================================================
2829
+ // CAP ARGUMENT VALUE - Unified argument type
2830
+ // ============================================================================
2831
+
2832
+ /**
2833
+ * Unified argument type - arguments are identified by media_urn.
2834
+ * The cap definition's sources specify how to extract values (stdin, position, cli_flag).
2835
+ */
2836
+ class CapArgumentValue {
2837
+ /**
2838
+ * Create a new CapArgumentValue
2839
+ * @param {string} mediaUrn - Semantic identifier, e.g., "media:model-spec;textable"
2840
+ * @param {Uint8Array|Buffer} value - Value bytes (UTF-8 for text, raw for binary)
2841
+ */
2842
+ constructor(mediaUrn, value) {
2843
+ this.mediaUrn = mediaUrn;
2844
+ this.value = value instanceof Uint8Array ? value : new Uint8Array(value || []);
2845
+ }
2846
+
2847
+ /**
2848
+ * Create a new CapArgumentValue from a string value
2849
+ * @param {string} mediaUrn - Semantic identifier
2850
+ * @param {string} value - String value (will be converted to UTF-8 bytes)
2851
+ * @returns {CapArgumentValue}
2852
+ */
2853
+ static fromStr(mediaUrn, value) {
2854
+ const encoder = new TextEncoder();
2855
+ return new CapArgumentValue(mediaUrn, encoder.encode(value));
2856
+ }
2857
+
2858
+ /**
2859
+ * Get the value as a UTF-8 string
2860
+ * @returns {string} The value decoded as UTF-8
2861
+ */
2862
+ valueAsStr() {
2863
+ const decoder = new TextDecoder('utf-8', { fatal: true });
2864
+ return decoder.decode(this.value);
2865
+ }
2866
+ }
2867
+
2868
+ // ============================================================================
2869
+ // CAP MATRIX - Registry for Capability Hosts
2870
+ // ============================================================================
2871
+
2872
+ /**
2873
+ * Error types for capability host registry operations
2874
+ */
2875
+ class CapMatrixError extends Error {
2876
+ constructor(type, message) {
2877
+ super(message);
2878
+ this.name = 'CapMatrixError';
2879
+ this.type = type;
2880
+ }
2881
+
2882
+ static noSetsFound(capability) {
2883
+ return new CapMatrixError('NoSetsFound', `No cap sets found for capability: ${capability}`);
2884
+ }
2885
+
2886
+ static invalidUrn(urn, reason) {
2887
+ return new CapMatrixError('InvalidUrn', `Invalid capability URN: ${urn}: ${reason}`);
2888
+ }
2889
+
2890
+ static registryError(message) {
2891
+ return new CapMatrixError('RegistryError', message);
2892
+ }
2893
+ }
2894
+
2895
+ /**
2896
+ * Internal entry for a registered capability host
2897
+ */
2898
+ class CapSetEntry {
2899
+ constructor(name, host, capabilities) {
2900
+ this.name = name;
2901
+ this.host = host; // Object implementing executeCap(capUrn, arguments) -> Promise
2902
+ this.capabilities = capabilities; // Array<Cap>
2903
+ }
2904
+ }
2905
+
2906
+ /**
2907
+ * Unified registry for cap sets (providers and plugins)
2908
+ * Provides capability host discovery using subset matching.
2909
+ */
2910
+ class CapMatrix {
2911
+ constructor() {
2912
+ this.sets = new Map(); // Map<string, CapSetEntry>
2913
+ }
2914
+
2915
+ /**
2916
+ * Register a capability host with its supported capabilities
2917
+ * @param {string} name - Unique name for the capability host
2918
+ * @param {object} host - Object with executeCap method
2919
+ * @param {Cap[]} capabilities - Array of capabilities this host supports
2920
+ */
2921
+ registerCapSet(name, host, capabilities) {
2922
+ const entry = new CapSetEntry(name, host, capabilities);
2923
+ this.sets.set(name, entry);
2924
+ }
2925
+
2926
+ /**
2927
+ * Find cap sets that can handle the requested capability
2928
+ * Uses subset matching: host capabilities must be a subset of or match the request
2929
+ * @param {string} requestUrn - The capability URN to find sets for
2930
+ * @returns {object[]} Array of hosts that can handle the request
2931
+ * @throws {CapMatrixError} If URN is invalid or no sets found
2932
+ */
2933
+ findCapSets(requestUrn) {
2934
+ let request;
2935
+ try {
2936
+ request = CapUrn.fromString(requestUrn);
2937
+ } catch (e) {
2938
+ throw CapMatrixError.invalidUrn(requestUrn, e.message);
2939
+ }
2940
+
2941
+ const matchingHosts = [];
2942
+
2943
+ for (const entry of this.sets.values()) {
2944
+ for (const cap of entry.capabilities) {
2945
+ if (cap.urn.accepts(request)) {
2946
+ matchingHosts.push(entry.host);
2947
+ break; // Found a matching capability for this host
2948
+ }
2949
+ }
2950
+ }
2951
+
2952
+ if (matchingHosts.length === 0) {
2953
+ throw CapMatrixError.noSetsFound(requestUrn);
2954
+ }
2955
+
2956
+ return matchingHosts;
2957
+ }
2958
+
2959
+ /**
2960
+ * Find the best capability host for the request using specificity ranking
2961
+ * @param {string} requestUrn - The capability URN to find the best host for
2962
+ * @returns {{host: object, cap: Cap}} The best host and matching cap definition
2963
+ * @throws {CapMatrixError} If URN is invalid or no sets found
2964
+ */
2965
+ findBestCapSet(requestUrn) {
2966
+ let request;
2967
+ try {
2968
+ request = CapUrn.fromString(requestUrn);
2969
+ } catch (e) {
2970
+ throw CapMatrixError.invalidUrn(requestUrn, e.message);
2971
+ }
2972
+
2973
+ let bestHost = null;
2974
+ let bestCap = null;
2975
+ let bestSpecificity = -1;
2976
+
2977
+ for (const entry of this.sets.values()) {
2978
+ for (const cap of entry.capabilities) {
2979
+ if (cap.urn.accepts(request)) {
2980
+ const specificity = cap.urn.specificity();
2981
+ if (bestSpecificity === -1 || specificity > bestSpecificity) {
2982
+ bestHost = entry.host;
2983
+ bestCap = cap;
2984
+ bestSpecificity = specificity;
2985
+ }
2986
+ break; // Found match for this entry, check next
2987
+ }
2988
+ }
2989
+ }
2990
+
2991
+ if (bestHost === null) {
2992
+ throw CapMatrixError.noSetsFound(requestUrn);
2993
+ }
2994
+
2995
+ return { host: bestHost, cap: bestCap };
2996
+ }
2997
+
2998
+ /**
2999
+ * Get all registered capability host names
3000
+ * @returns {string[]} Array of host names
3001
+ */
3002
+ getHostNames() {
3003
+ return Array.from(this.sets.keys());
3004
+ }
3005
+
3006
+ /**
3007
+ * Get all capabilities from all registered sets
3008
+ * @returns {Cap[]} Array of all capabilities
3009
+ */
3010
+ getAllCapabilities() {
3011
+ const capabilities = [];
3012
+ for (const entry of this.sets.values()) {
3013
+ capabilities.push(...entry.capabilities);
3014
+ }
3015
+ return capabilities;
3016
+ }
3017
+
3018
+ /**
3019
+ * Check if any host accepts the specified capability request
3020
+ * @param {string} requestUrn - The capability URN to check
3021
+ * @returns {boolean} Whether the capability request is accepted
3022
+ */
3023
+ acceptsRequest(requestUrn) {
3024
+ try {
3025
+ this.findCapSets(requestUrn);
3026
+ return true;
3027
+ } catch (e) {
3028
+ if (e instanceof CapMatrixError) {
3029
+ return false;
3030
+ }
3031
+ throw e;
3032
+ }
3033
+ }
3034
+
3035
+ /**
3036
+ * Unregister a capability host
3037
+ * @param {string} name - The name of the host to unregister
3038
+ * @returns {boolean} Whether the host was found and removed
3039
+ */
3040
+ unregisterCapSet(name) {
3041
+ return this.sets.delete(name);
3042
+ }
3043
+
3044
+ /**
3045
+ * Clear all registered sets
3046
+ */
3047
+ clear() {
3048
+ this.sets.clear();
3049
+ }
3050
+ }
3051
+
3052
+ // ============================================================================
3053
+ // CAP BLOCK - Composite Registry
3054
+ // ============================================================================
3055
+
3056
+ /**
3057
+ * Result of finding the best match across registries
3058
+ */
3059
+ class BestCapSetMatch {
3060
+ /**
3061
+ * @param {Cap} cap - The Cap definition that matched
3062
+ * @param {number} specificity - The specificity score of the match
3063
+ * @param {string} registryName - The name of the registry that provided this match
3064
+ */
3065
+ constructor(cap, specificity, registryName) {
3066
+ this.cap = cap;
3067
+ this.specificity = specificity;
3068
+ this.registryName = registryName;
3069
+ }
3070
+ }
3071
+
3072
+ /**
3073
+ * Composite CapSet that wraps multiple registries
3074
+ * and delegates execution to the best matching one.
3075
+ */
3076
+ class CompositeCapSet {
3077
+ /**
3078
+ * @param {Array<{name: string, registry: CapMatrix}>} registries
3079
+ */
3080
+ constructor(registries) {
3081
+ this.registries = registries;
3082
+ }
3083
+
3084
+ /**
3085
+ * Execute a capability by finding the best match and delegating
3086
+ * @param {string} capUrn - The capability URN to execute
3087
+ * @param {CapArgumentValue[]} args - Arguments identified by media_urn
3088
+ * @returns {Promise<{binaryOutput: Uint8Array|null, textOutput: string|null}>}
3089
+ */
3090
+ async executeCap(capUrn, args) {
3091
+ let request;
3092
+ try {
3093
+ request = CapUrn.fromString(capUrn);
3094
+ } catch (e) {
3095
+ throw new Error(`Invalid cap URN '${capUrn}': ${e.message}`);
3096
+ }
3097
+
3098
+ // Find the best matching host across all registries
3099
+ let bestHost = null;
3100
+ let bestSpecificity = -1;
3101
+
3102
+ for (const { registry } of this.registries) {
3103
+ for (const entry of registry.sets.values()) {
3104
+ for (const cap of entry.capabilities) {
3105
+ if (cap.urn.accepts(request)) {
3106
+ const specificity = cap.urn.specificity();
3107
+ if (bestSpecificity === -1 || specificity > bestSpecificity) {
3108
+ bestHost = entry.host;
3109
+ bestSpecificity = specificity;
3110
+ }
3111
+ break; // Found match for this entry
3112
+ }
3113
+ }
3114
+ }
3115
+ }
3116
+
3117
+ if (bestHost === null) {
3118
+ throw new Error(`No capability host found for '${capUrn}'`);
3119
+ }
3120
+
3121
+ // Delegate execution to the best matching host
3122
+ return bestHost.executeCap(capUrn, args);
3123
+ }
3124
+
3125
+ /**
3126
+ * Build a directed graph from all capabilities in the registries.
3127
+ * @returns {CapGraph}
3128
+ */
3129
+ graph() {
3130
+ return CapGraph.buildFromRegistries(this.registries);
3131
+ }
3132
+ }
3133
+
3134
+ /**
3135
+ * Composite registry that wraps multiple CapMatrix instances
3136
+ * and finds the best match across all of them by specificity.
3137
+ *
3138
+ * When multiple registries can handle a request, this registry compares
3139
+ * specificity scores and returns the most specific match.
3140
+ * On tie, defaults to the first registry that was added (priority order).
3141
+ */
3142
+ class CapBlock {
3143
+ constructor() {
3144
+ this.registries = []; // Array of {name: string, registry: CapMatrix}
3145
+ }
3146
+
3147
+ /**
3148
+ * Add a child registry with a name.
3149
+ * Registries are checked in order of addition for tie-breaking.
3150
+ * @param {string} name - Unique name for this registry
3151
+ * @param {CapMatrix} registry - The CapMatrix to add
3152
+ */
3153
+ addRegistry(name, registry) {
3154
+ this.registries.push({ name, registry });
3155
+ }
3156
+
3157
+ /**
3158
+ * Remove a child registry by name
3159
+ * @param {string} name - The name of the registry to remove
3160
+ * @returns {CapMatrix|null} The removed registry, or null if not found
3161
+ */
3162
+ removeRegistry(name) {
3163
+ const index = this.registries.findIndex(entry => entry.name === name);
3164
+ if (index !== -1) {
3165
+ const removed = this.registries[index].registry;
3166
+ this.registries.splice(index, 1);
3167
+ return removed;
3168
+ }
3169
+ return null;
3170
+ }
3171
+
3172
+ /**
3173
+ * Get a child registry by name
3174
+ * @param {string} name - The name of the registry
3175
+ * @returns {CapMatrix|null} The registry, or null if not found
3176
+ */
3177
+ getRegistry(name) {
3178
+ const entry = this.registries.find(e => e.name === name);
3179
+ return entry ? entry.registry : null;
3180
+ }
3181
+
3182
+ /**
3183
+ * Get names of all child registries
3184
+ * @returns {string[]} Array of registry names in priority order
3185
+ */
3186
+ getRegistryNames() {
3187
+ return this.registries.map(entry => entry.name);
3188
+ }
3189
+
3190
+ /**
3191
+ * Check if a capability is available and return execution info.
3192
+ * This is the main entry point for capability lookup.
3193
+ * @param {string} capUrn - The capability URN to look up
3194
+ * @returns {{cap: Cap, compositeHost: CompositeCapSet}} The cap and composite host for execution
3195
+ * @throws {CapMatrixError} If URN is invalid or no match found
3196
+ */
3197
+ can(capUrn) {
3198
+ // Find the best match to get the cap definition
3199
+ const bestMatch = this.findBestCapSet(capUrn);
3200
+
3201
+ // Create a CompositeCapSet that will delegate execution
3202
+ const compositeHost = new CompositeCapSet([...this.registries]);
3203
+
3204
+ return {
3205
+ cap: bestMatch.cap,
3206
+ compositeHost: compositeHost
3207
+ };
3208
+ }
3209
+
3210
+ /**
3211
+ * Find the best capability host across ALL child registries.
3212
+ * Polls all registries and compares their best matches by specificity.
3213
+ * On specificity tie, returns the match from the first registry.
3214
+ * @param {string} requestUrn - The capability URN to find the best host for
3215
+ * @returns {BestCapSetMatch} The best match
3216
+ * @throws {CapMatrixError} If URN is invalid or no match found
3217
+ */
3218
+ findBestCapSet(requestUrn) {
3219
+ let request;
3220
+ try {
3221
+ request = CapUrn.fromString(requestUrn);
3222
+ } catch (e) {
3223
+ throw CapMatrixError.invalidUrn(requestUrn, e.message);
3224
+ }
3225
+
3226
+ let bestOverall = null;
3227
+
3228
+ for (const { name, registry } of this.registries) {
3229
+ // Find the best match within this registry
3230
+ const result = this._findBestInRegistry(registry, request);
3231
+ if (result) {
3232
+ const { cap, specificity } = result;
3233
+ const candidate = new BestCapSetMatch(cap, specificity, name);
3234
+
3235
+ if (bestOverall === null) {
3236
+ bestOverall = candidate;
3237
+ } else if (specificity > bestOverall.specificity) {
3238
+ // Only replace if strictly more specific
3239
+ // On tie, keep the first one (priority order)
3240
+ bestOverall = candidate;
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ if (bestOverall === null) {
3246
+ throw CapMatrixError.noSetsFound(requestUrn);
3247
+ }
3248
+
3249
+ return bestOverall;
3250
+ }
3251
+
3252
+ /**
3253
+ * Check if any registry accepts the specified capability request
3254
+ * @param {string} requestUrn - The capability URN to check
3255
+ * @returns {boolean} Whether the capability request is accepted
3256
+ */
3257
+ acceptsRequest(requestUrn) {
3258
+ try {
3259
+ this.findBestCapSet(requestUrn);
3260
+ return true;
3261
+ } catch (e) {
3262
+ if (e instanceof CapMatrixError) {
3263
+ return false;
3264
+ }
3265
+ throw e;
3266
+ }
3267
+ }
3268
+
3269
+ /**
3270
+ * Find the best match within a single registry
3271
+ * @private
3272
+ * @param {CapMatrix} registry - The registry to search
3273
+ * @param {CapUrn} request - The parsed request URN
3274
+ * @returns {{cap: Cap, specificity: number}|null} The best match or null
3275
+ */
3276
+ _findBestInRegistry(registry, request) {
3277
+ let bestCap = null;
3278
+ let bestSpecificity = -1;
3279
+
3280
+ for (const entry of registry.sets.values()) {
3281
+ for (const cap of entry.capabilities) {
3282
+ if (cap.urn.accepts(request)) {
3283
+ const specificity = cap.urn.specificity();
3284
+ if (bestSpecificity === -1 || specificity > bestSpecificity) {
3285
+ bestCap = cap;
3286
+ bestSpecificity = specificity;
3287
+ }
3288
+ break; // Found match for this entry
3289
+ }
3290
+ }
3291
+ }
3292
+
3293
+ if (bestCap === null) {
3294
+ return null;
3295
+ }
3296
+ return { cap: bestCap, specificity: bestSpecificity };
3297
+ }
3298
+
3299
+ /**
3300
+ * Build a directed graph from all capabilities across all registries.
3301
+ * The graph represents all possible conversions where:
3302
+ * - Nodes are media URNs (e.g., "media:string", "media:binary")
3303
+ * - Edges are capabilities that convert from one media URN to another
3304
+ * @returns {CapGraph} The capability graph
3305
+ */
3306
+ graph() {
3307
+ return CapGraph.buildFromRegistries(this.registries);
3308
+ }
3309
+ }
3310
+
3311
+ // ============================================================================
3312
+ // CAP GRAPH - Directed graph of capability conversions
3313
+ // ============================================================================
3314
+
3315
+ /**
3316
+ * An edge in the capability graph representing a conversion from one media URN to another.
3317
+ */
3318
+ class CapGraphEdge {
3319
+ /**
3320
+ * @param {string} fromUrn - The input media URN
3321
+ * @param {string} toUrn - The output media URN
3322
+ * @param {Cap} cap - The capability that performs this conversion
3323
+ * @param {string} registryName - The registry that provided this capability
3324
+ * @param {number} specificity - Specificity score for ranking
3325
+ */
3326
+ constructor(fromUrn, toUrn, cap, registryName, specificity) {
3327
+ this.fromUrn = fromUrn;
3328
+ this.toUrn = toUrn;
3329
+ this.cap = cap;
3330
+ this.registryName = registryName;
3331
+ this.specificity = specificity;
3332
+ }
3333
+ }
3334
+
3335
+ /**
3336
+ * Statistics about a capability graph.
3337
+ */
3338
+ class CapGraphStats {
3339
+ /**
3340
+ * @param {number} nodeCount - Number of unique media URN nodes
3341
+ * @param {number} edgeCount - Number of edges (capabilities)
3342
+ * @param {number} inputUrnCount - Number of URNs that serve as inputs
3343
+ * @param {number} outputUrnCount - Number of URNs that serve as outputs
3344
+ */
3345
+ constructor(nodeCount, edgeCount, inputUrnCount, outputUrnCount) {
3346
+ this.nodeCount = nodeCount;
3347
+ this.edgeCount = edgeCount;
3348
+ this.inputUrnCount = inputUrnCount;
3349
+ this.outputUrnCount = outputUrnCount;
3350
+ }
3351
+ }
3352
+
3353
+ /**
3354
+ * A directed graph where nodes are media URNs and edges are capabilities.
3355
+ * This graph enables discovering conversion paths between different media formats.
3356
+ */
3357
+ class CapGraph {
3358
+ constructor() {
3359
+ this.edges = [];
3360
+ this.outgoing = new Map(); // fromUrn -> edge indices
3361
+ this.incoming = new Map(); // toUrn -> edge indices
3362
+ this.nodes = new Set();
3363
+ }
3364
+
3365
+ /**
3366
+ * Add a capability as an edge in the graph.
3367
+ * @param {Cap} cap - The capability to add
3368
+ * @param {string} registryName - The registry that provided this capability
3369
+ */
3370
+ addCap(cap, registryName) {
3371
+ const fromUrn = cap.urn.getInSpec();
3372
+ const toUrn = cap.urn.getOutSpec();
3373
+ const specificity = cap.urn.specificity();
3374
+
3375
+ // Add nodes
3376
+ this.nodes.add(fromUrn);
3377
+ this.nodes.add(toUrn);
3378
+
3379
+ // Create edge
3380
+ const edgeIndex = this.edges.length;
3381
+ const edge = new CapGraphEdge(fromUrn, toUrn, cap, registryName, specificity);
3382
+ this.edges.push(edge);
3383
+
3384
+ // Update outgoing index
3385
+ if (!this.outgoing.has(fromUrn)) {
3386
+ this.outgoing.set(fromUrn, []);
3387
+ }
3388
+ this.outgoing.get(fromUrn).push(edgeIndex);
3389
+
3390
+ // Update incoming index
3391
+ if (!this.incoming.has(toUrn)) {
3392
+ this.incoming.set(toUrn, []);
3393
+ }
3394
+ this.incoming.get(toUrn).push(edgeIndex);
3395
+ }
3396
+
3397
+ /**
3398
+ * Build a graph from multiple registries.
3399
+ * @param {Array<{name: string, registry: CapMatrix}>} registries
3400
+ * @returns {CapGraph}
3401
+ */
3402
+ static buildFromRegistries(registries) {
3403
+ const graph = new CapGraph();
3404
+
3405
+ for (const { name, registry } of registries) {
3406
+ for (const entry of registry.sets.values()) {
3407
+ for (const cap of entry.capabilities) {
3408
+ graph.addCap(cap, name);
3409
+ }
3410
+ }
3411
+ }
3412
+
3413
+ return graph;
3414
+ }
3415
+
3416
+ /**
3417
+ * Get all nodes (media URNs) in the graph.
3418
+ * @returns {Set<string>}
3419
+ */
3420
+ getNodes() {
3421
+ return new Set(this.nodes);
3422
+ }
3423
+
3424
+ /**
3425
+ * Get all edges in the graph.
3426
+ * @returns {CapGraphEdge[]}
3427
+ */
3428
+ getEdges() {
3429
+ return [...this.edges];
3430
+ }
3431
+
3432
+ /**
3433
+ * Get all edges where the provided URN satisfies the edge's input requirement.
3434
+ * Uses conformsTo-based matching instead of exact string matching.
3435
+ * @param {string} urn - The media URN
3436
+ * @returns {CapGraphEdge[]}
3437
+ */
3438
+ getOutgoing(urn) {
3439
+ // Use TaggedUrn matching: find all edges where the provided URN (instance)
3440
+ // conforms to the edge's input requirement (pattern/fromUrn)
3441
+ const providedUrn = TaggedUrn.fromString(urn);
3442
+
3443
+ const edges = this.edges.filter(edge => {
3444
+ const requirementUrn = TaggedUrn.fromString(edge.fromUrn);
3445
+ return providedUrn.conformsTo(requirementUrn);
3446
+ });
3447
+
3448
+ // Sort by specificity (highest first) for consistent ordering
3449
+ edges.sort((a, b) => b.specificity - a.specificity);
3450
+
3451
+ return edges;
3452
+ }
3453
+
3454
+ /**
3455
+ * Get all edges targeting a media URN.
3456
+ * @param {string} urn - The media URN
3457
+ * @returns {CapGraphEdge[]}
3458
+ */
3459
+ getIncoming(urn) {
3460
+ const indices = this.incoming.get(urn) || [];
3461
+ return indices.map(i => this.edges[i]);
3462
+ }
3463
+
3464
+ /**
3465
+ * Check if there's any direct edge from one URN to another.
3466
+ * @param {string} fromUrn - The source media URN
3467
+ * @param {string} toUrn - The target media URN
3468
+ * @returns {boolean}
3469
+ */
3470
+ hasDirectEdge(fromUrn, toUrn) {
3471
+ return this.getOutgoing(fromUrn).some(edge => edge.toUrn === toUrn);
3472
+ }
3473
+
3474
+ /**
3475
+ * Get all direct edges from one URN to another, sorted by specificity (highest first).
3476
+ * @param {string} fromUrn - The source media URN
3477
+ * @param {string} toUrn - The target media URN
3478
+ * @returns {CapGraphEdge[]}
3479
+ */
3480
+ getDirectEdges(fromUrn, toUrn) {
3481
+ const edges = this.getOutgoing(fromUrn).filter(edge => edge.toUrn === toUrn);
3482
+ edges.sort((a, b) => b.specificity - a.specificity);
3483
+ return edges;
3484
+ }
3485
+
3486
+ /**
3487
+ * Check if a conversion path exists from one URN to another.
3488
+ * Uses BFS to find if there's any path (direct or through intermediates).
3489
+ * @param {string} fromUrn - The source media URN
3490
+ * @param {string} toUrn - The target media URN
3491
+ * @returns {boolean}
3492
+ */
3493
+ canConvert(fromUrn, toUrn) {
3494
+ if (fromUrn === toUrn) {
3495
+ return true;
3496
+ }
3497
+
3498
+ if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
3499
+ return false;
3500
+ }
3501
+
3502
+ const visited = new Set();
3503
+ const queue = [fromUrn];
3504
+ visited.add(fromUrn);
3505
+
3506
+ while (queue.length > 0) {
3507
+ const current = queue.shift();
3508
+
3509
+ for (const edge of this.getOutgoing(current)) {
3510
+ if (edge.toUrn === toUrn) {
3511
+ return true;
3512
+ }
3513
+ if (!visited.has(edge.toUrn)) {
3514
+ visited.add(edge.toUrn);
3515
+ queue.push(edge.toUrn);
3516
+ }
3517
+ }
3518
+ }
3519
+
3520
+ return false;
3521
+ }
3522
+
3523
+ /**
3524
+ * Find the shortest conversion path from one URN to another.
3525
+ * @param {string} fromUrn - The source media URN
3526
+ * @param {string} toUrn - The target media URN
3527
+ * @returns {CapGraphEdge[]|null} Array of edges representing the path, or null if no path exists
3528
+ */
3529
+ findPath(fromUrn, toUrn) {
3530
+ if (fromUrn === toUrn) {
3531
+ return [];
3532
+ }
3533
+
3534
+ if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
3535
+ return null;
3536
+ }
3537
+
3538
+ // BFS to find shortest path
3539
+ // visited maps urn -> {prevUrn, edgeIdx} or null for start node
3540
+ const visited = new Map();
3541
+ const queue = [fromUrn];
3542
+ visited.set(fromUrn, null);
3543
+
3544
+ while (queue.length > 0) {
3545
+ const current = queue.shift();
3546
+
3547
+ const indices = this.outgoing.get(current) || [];
3548
+ for (const edgeIdx of indices) {
3549
+ const edge = this.edges[edgeIdx];
3550
+
3551
+ if (edge.toUrn === toUrn) {
3552
+ // Found the target - reconstruct path
3553
+ const path = [this.edges[edgeIdx]];
3554
+
3555
+ let backtrack = current;
3556
+ let backtrackInfo = visited.get(backtrack);
3557
+ while (backtrackInfo !== null && backtrackInfo !== undefined) {
3558
+ path.push(this.edges[backtrackInfo.edgeIdx]);
3559
+ backtrack = backtrackInfo.prevUrn;
3560
+ backtrackInfo = visited.get(backtrack);
3561
+ }
3562
+
3563
+ path.reverse();
3564
+ return path;
3565
+ }
3566
+
3567
+ if (!visited.has(edge.toUrn)) {
3568
+ visited.set(edge.toUrn, { prevUrn: current, edgeIdx });
3569
+ queue.push(edge.toUrn);
3570
+ }
3571
+ }
3572
+ }
3573
+
3574
+ return null;
3575
+ }
3576
+
3577
+ /**
3578
+ * Find all conversion paths from one URN to another (up to a maximum depth).
3579
+ * @param {string} fromUrn - The source media URN
3580
+ * @param {string} toUrn - The target media URN
3581
+ * @param {number} maxDepth - Maximum path length to search
3582
+ * @returns {CapGraphEdge[][]} Array of paths (each path is an array of edges)
3583
+ */
3584
+ findAllPaths(fromUrn, toUrn, maxDepth) {
3585
+ if (!this.nodes.has(fromUrn) || !this.nodes.has(toUrn)) {
3586
+ return [];
3587
+ }
3588
+
3589
+ const allPaths = [];
3590
+ const currentPath = [];
3591
+ const visited = new Set();
3592
+
3593
+ this._dfsFindPaths(fromUrn, toUrn, maxDepth, currentPath, visited, allPaths);
3594
+
3595
+ // Sort by path length (shortest first)
3596
+ allPaths.sort((a, b) => a.length - b.length);
3597
+
3598
+ // Convert indices to edge references
3599
+ return allPaths.map(indices => indices.map(i => this.edges[i]));
3600
+ }
3601
+
3602
+ /**
3603
+ * DFS helper for finding all paths
3604
+ * @private
3605
+ */
3606
+ _dfsFindPaths(current, target, remainingDepth, currentPath, visited, allPaths) {
3607
+ if (remainingDepth === 0) {
3608
+ return;
3609
+ }
3610
+
3611
+ const indices = this.outgoing.get(current) || [];
3612
+ for (const edgeIdx of indices) {
3613
+ const edge = this.edges[edgeIdx];
3614
+
3615
+ if (edge.toUrn === target) {
3616
+ // Found a path
3617
+ allPaths.push([...currentPath, edgeIdx]);
3618
+ } else if (!visited.has(edge.toUrn)) {
3619
+ // Continue searching
3620
+ visited.add(edge.toUrn);
3621
+ currentPath.push(edgeIdx);
3622
+
3623
+ this._dfsFindPaths(edge.toUrn, target, remainingDepth - 1, currentPath, visited, allPaths);
3624
+
3625
+ currentPath.pop();
3626
+ visited.delete(edge.toUrn);
3627
+ }
3628
+ }
3629
+ }
3630
+
3631
+ /**
3632
+ * Find the best (highest specificity) conversion path from one URN to another.
3633
+ * @param {string} fromUrn - The source media URN
3634
+ * @param {string} toUrn - The target media URN
3635
+ * @param {number} maxDepth - Maximum path length to search
3636
+ * @returns {CapGraphEdge[]|null} Array of edges representing the best path, or null if no path exists
3637
+ */
3638
+ findBestPath(fromUrn, toUrn, maxDepth) {
3639
+ const allPaths = this.findAllPaths(fromUrn, toUrn, maxDepth);
3640
+
3641
+ if (allPaths.length === 0) {
3642
+ return null;
3643
+ }
3644
+
3645
+ let bestPath = null;
3646
+ let bestScore = -1;
3647
+
3648
+ for (const path of allPaths) {
3649
+ const score = path.reduce((sum, edge) => sum + edge.specificity, 0);
3650
+ if (score > bestScore) {
3651
+ bestScore = score;
3652
+ bestPath = path;
3653
+ }
3654
+ }
3655
+
3656
+ return bestPath;
3657
+ }
3658
+
3659
+ /**
3660
+ * Get all URNs that have at least one outgoing edge.
3661
+ * @returns {string[]}
3662
+ */
3663
+ getInputUrns() {
3664
+ return Array.from(this.outgoing.keys());
3665
+ }
3666
+
3667
+ /**
3668
+ * Get all URNs that have at least one incoming edge.
3669
+ * @returns {string[]}
3670
+ */
3671
+ getOutputUrns() {
3672
+ return Array.from(this.incoming.keys());
3673
+ }
3674
+
3675
+ /**
3676
+ * Get statistics about the graph.
3677
+ * @returns {CapGraphStats}
3678
+ */
3679
+ stats() {
3680
+ return new CapGraphStats(
3681
+ this.nodes.size,
3682
+ this.edges.length,
3683
+ this.outgoing.size,
3684
+ this.incoming.size
3685
+ );
3686
+ }
3687
+ }
3688
+
3689
+ // ============================================================================
3690
+ // StdinSource - Represents stdin input source (data or file reference)
3691
+ // ============================================================================
3692
+
3693
+ /**
3694
+ * Stdin source kinds
3695
+ */
3696
+ const StdinSourceKind = {
3697
+ DATA: 'data',
3698
+ FILE_REFERENCE: 'file_reference'
3699
+ };
3700
+
3701
+ /**
3702
+ * Represents the source for stdin data.
3703
+ * For plugins (via gRPC/XPC), using FileReference avoids size limits
3704
+ * by letting the receiving side read the file locally.
3705
+ */
3706
+ class StdinSource {
3707
+ /**
3708
+ * Create a StdinSource (use static factory methods instead)
3709
+ * @param {string} kind - StdinSourceKind.DATA or StdinSourceKind.FILE_REFERENCE
3710
+ * @param {Object} options - Options for the source
3711
+ * @private
3712
+ */
3713
+ constructor(kind, options = {}) {
3714
+ this.kind = kind;
3715
+
3716
+ if (kind === StdinSourceKind.DATA) {
3717
+ this.data = options.data || null;
3718
+ } else if (kind === StdinSourceKind.FILE_REFERENCE) {
3719
+ this.trackedFileId = options.trackedFileId || '';
3720
+ this.originalPath = options.originalPath || '';
3721
+ this.securityBookmark = options.securityBookmark || null;
3722
+ this.mediaUrn = options.mediaUrn || '';
3723
+ }
3724
+ }
3725
+
3726
+ /**
3727
+ * Create a StdinSource from raw data bytes
3728
+ * @param {Uint8Array|Buffer|null} data - The raw bytes for stdin
3729
+ * @returns {StdinSource}
3730
+ */
3731
+ static fromData(data) {
3732
+ return new StdinSource(StdinSourceKind.DATA, { data });
3733
+ }
3734
+
3735
+ /**
3736
+ * Create a StdinSource from a file reference
3737
+ * Used for plugins to read files locally instead of sending bytes over the wire.
3738
+ * @param {string} trackedFileId - ID for lifecycle management
3739
+ * @param {string} originalPath - Original file path (for logging/debugging)
3740
+ * @param {Uint8Array|Buffer|null} securityBookmark - Security bookmark data
3741
+ * @param {string} mediaUrn - Media URN so receiver knows expected type
3742
+ * @returns {StdinSource}
3743
+ */
3744
+ static fromFileReference(trackedFileId, originalPath, securityBookmark, mediaUrn) {
3745
+ return new StdinSource(StdinSourceKind.FILE_REFERENCE, {
3746
+ trackedFileId,
3747
+ originalPath,
3748
+ securityBookmark,
3749
+ mediaUrn
3750
+ });
3751
+ }
3752
+
3753
+ /**
3754
+ * Check if this is a data source
3755
+ * @returns {boolean}
3756
+ */
3757
+ isData() {
3758
+ return this.kind === StdinSourceKind.DATA;
3759
+ }
3760
+
3761
+ /**
3762
+ * Check if this is a file reference source
3763
+ * @returns {boolean}
3764
+ */
3765
+ isFileReference() {
3766
+ return this.kind === StdinSourceKind.FILE_REFERENCE;
3767
+ }
3768
+ }
3769
+
3770
+ // =============================================================================
3771
+ // Plugin Repository System
3772
+ // =============================================================================
3773
+
3774
+ /**
3775
+ * Plugin capability summary from registry
3776
+ */
3777
+ class PluginCapSummary {
3778
+ constructor(urn, title, description = '') {
3779
+ this.urn = urn;
3780
+ this.title = title;
3781
+ this.description = description;
3782
+ }
3783
+ }
3784
+
3785
+ /**
3786
+ * Plugin information from registry
3787
+ */
3788
+ class PluginInfo {
3789
+ constructor(data) {
3790
+ this.id = data.id;
3791
+ this.name = data.name;
3792
+ this.version = data.version || '';
3793
+ this.description = data.description || '';
3794
+ this.author = data.author || '';
3795
+ this.pageUrl = data.pageUrl || '';
3796
+ this.teamId = data.teamId || '';
3797
+ this.signedAt = data.signedAt || '';
3798
+ this.minAppVersion = data.minAppVersion || '';
3799
+ this.caps = (data.caps || []).map(c => new PluginCapSummary(c.urn, c.title, c.description || ''));
3800
+ this.categories = data.categories || [];
3801
+ this.tags = data.tags || [];
3802
+ this.changelog = data.changelog || {};
3803
+ // Distribution fields
3804
+ this.platform = data.platform || '';
3805
+ this.packageName = data.packageName || '';
3806
+ this.packageSha256 = data.packageSha256 || '';
3807
+ this.packageSize = data.packageSize || 0;
3808
+ this.binaryName = data.binaryName || '';
3809
+ this.binarySha256 = data.binarySha256 || '';
3810
+ this.binarySize = data.binarySize || 0;
3811
+ this.availableVersions = data.availableVersions || [];
3812
+ }
3813
+
3814
+ /**
3815
+ * Check if plugin is signed (has team_id and signed_at)
3816
+ */
3817
+ isSigned() {
3818
+ return this.teamId.length > 0 && this.signedAt.length > 0;
3819
+ }
3820
+
3821
+ /**
3822
+ * Check if binary download info is available
3823
+ */
3824
+ hasBinary() {
3825
+ return this.binaryName.length > 0 && this.binarySha256.length > 0;
3826
+ }
3827
+ }
3828
+
3829
+ /**
3830
+ * Plugin suggestion for a missing cap
3831
+ */
3832
+ class PluginSuggestion {
3833
+ constructor(data) {
3834
+ this.pluginId = data.pluginId;
3835
+ this.pluginName = data.pluginName;
3836
+ this.pluginDescription = data.pluginDescription;
3837
+ this.capUrn = data.capUrn;
3838
+ this.capTitle = data.capTitle;
3839
+ this.latestVersion = data.latestVersion;
3840
+ this.repoUrl = data.repoUrl;
3841
+ this.pageUrl = data.pageUrl;
3842
+ }
3843
+ }
3844
+
3845
+ /**
3846
+ * Plugin registry cache entry
3847
+ */
3848
+ class PluginRepoCache {
3849
+ constructor(repoUrl) {
3850
+ this.plugins = new Map(); // plugin_id -> PluginInfo
3851
+ this.capToPlugins = new Map(); // cap_urn -> [plugin_ids]
3852
+ this.lastUpdated = Date.now();
3853
+ this.repoUrl = repoUrl;
3854
+ }
3855
+ }
3856
+
3857
+ /**
3858
+ * Plugin repository client - fetches and caches plugin registry
3859
+ */
3860
+ class PluginRepoClient {
3861
+ constructor(cacheTtlSeconds = 3600) {
3862
+ this.caches = new Map(); // repo_url -> PluginRepoCache
3863
+ this.cacheTtl = cacheTtlSeconds * 1000; // Convert to milliseconds
3864
+ }
3865
+
3866
+ /**
3867
+ * Fetch registry from a URL
3868
+ */
3869
+ async fetchRegistry(repoUrl) {
3870
+ const response = await fetch(repoUrl);
3871
+
3872
+ if (!response.ok) {
3873
+ throw new Error(`Plugin registry request failed: HTTP ${response.status} from ${repoUrl}`);
3874
+ }
3875
+
3876
+ const data = await response.json();
3877
+
3878
+ if (!data.plugins || !Array.isArray(data.plugins)) {
3879
+ throw new Error(`Invalid plugin registry response from ${repoUrl}: missing plugins array`);
3880
+ }
3881
+
3882
+ return data.plugins.map(p => new PluginInfo(p));
3883
+ }
3884
+
3885
+ /**
3886
+ * Update cache from registry data
3887
+ */
3888
+ updateCache(repoUrl, plugins) {
3889
+ const cache = new PluginRepoCache(repoUrl);
3890
+
3891
+ for (const plugin of plugins) {
3892
+ cache.plugins.set(plugin.id, plugin);
3893
+
3894
+ for (const cap of plugin.caps) {
3895
+ if (!cache.capToPlugins.has(cap.urn)) {
3896
+ cache.capToPlugins.set(cap.urn, []);
3897
+ }
3898
+ cache.capToPlugins.get(cap.urn).push(plugin.id);
3899
+ }
3900
+ }
3901
+
3902
+ this.caches.set(repoUrl, cache);
3903
+ }
3904
+
3905
+ /**
3906
+ * Check if cache is stale
3907
+ */
3908
+ isCacheStale(cache) {
3909
+ return (Date.now() - cache.lastUpdated) > this.cacheTtl;
3910
+ }
3911
+
3912
+ /**
3913
+ * Sync plugin data from repository URLs
3914
+ */
3915
+ async syncRepos(repoUrls) {
3916
+ for (const repoUrl of repoUrls) {
3917
+ try {
3918
+ const plugins = await this.fetchRegistry(repoUrl);
3919
+ this.updateCache(repoUrl, plugins);
3920
+ } catch (e) {
3921
+ console.warn(`Failed to sync plugin repo ${repoUrl}: ${e.message}`);
3922
+ // Continue with other repos
3923
+ }
3924
+ }
3925
+ }
3926
+
3927
+ /**
3928
+ * Check if any repo needs syncing
3929
+ */
3930
+ needsSync(repoUrls) {
3931
+ for (const repoUrl of repoUrls) {
3932
+ const cache = this.caches.get(repoUrl);
3933
+ if (!cache || this.isCacheStale(cache)) {
3934
+ return true;
3935
+ }
3936
+ }
3937
+ return false;
3938
+ }
3939
+
3940
+ /**
3941
+ * Get plugin suggestions for a cap URN
3942
+ */
3943
+ getSuggestionsForCap(capUrn) {
3944
+ const suggestions = [];
3945
+
3946
+ for (const cache of this.caches.values()) {
3947
+ const pluginIds = cache.capToPlugins.get(capUrn);
3948
+ if (!pluginIds) continue;
3949
+
3950
+ for (const pluginId of pluginIds) {
3951
+ const plugin = cache.plugins.get(pluginId);
3952
+ if (!plugin) continue;
3953
+
3954
+ const capInfo = plugin.caps.find(c => c.urn === capUrn);
3955
+ if (!capInfo) continue;
3956
+
3957
+ const pageUrl = plugin.pageUrl || cache.repoUrl;
3958
+
3959
+ suggestions.push(new PluginSuggestion({
3960
+ pluginId: plugin.id,
3961
+ pluginName: plugin.name,
3962
+ pluginDescription: plugin.description,
3963
+ capUrn: capUrn,
3964
+ capTitle: capInfo.title,
3965
+ latestVersion: plugin.version,
3966
+ repoUrl: cache.repoUrl,
3967
+ pageUrl: pageUrl
3968
+ }));
3969
+ }
3970
+ }
3971
+
3972
+ return suggestions;
3973
+ }
3974
+
3975
+ /**
3976
+ * Get all available plugins from all repos
3977
+ */
3978
+ getAllPlugins() {
3979
+ const plugins = [];
3980
+ for (const cache of this.caches.values()) {
3981
+ for (const [pluginId, pluginInfo] of cache.plugins) {
3982
+ plugins.push([pluginId, pluginInfo]);
3983
+ }
3984
+ }
3985
+ return plugins;
3986
+ }
3987
+
3988
+ /**
3989
+ * Get all available cap URNs from plugins
3990
+ */
3991
+ getAllAvailableCaps() {
3992
+ const caps = new Set();
3993
+ for (const cache of this.caches.values()) {
3994
+ for (const capUrn of cache.capToPlugins.keys()) {
3995
+ caps.add(capUrn);
3996
+ }
3997
+ }
3998
+ return Array.from(caps).sort();
3999
+ }
4000
+
4001
+ /**
4002
+ * Get plugin info by ID
4003
+ */
4004
+ getPlugin(pluginId) {
4005
+ for (const cache of this.caches.values()) {
4006
+ const plugin = cache.plugins.get(pluginId);
4007
+ if (plugin) {
4008
+ return plugin;
4009
+ }
4010
+ }
4011
+ return null;
4012
+ }
4013
+
4014
+ /**
4015
+ * Get suggestions for missing caps
4016
+ */
4017
+ getSuggestionsForMissingCaps(availableCaps, requestedCaps) {
4018
+ const availableSet = new Set(availableCaps);
4019
+ const suggestions = [];
4020
+
4021
+ for (const capUrn of requestedCaps) {
4022
+ if (!availableSet.has(capUrn)) {
4023
+ suggestions.push(...this.getSuggestionsForCap(capUrn));
4024
+ }
4025
+ }
4026
+
4027
+ return suggestions;
4028
+ }
4029
+ }
4030
+
4031
+ /**
4032
+ * Plugin repository server - serves registry data with queries
4033
+ */
4034
+ class PluginRepoServer {
4035
+ constructor(registry) {
4036
+ this.registry = registry;
4037
+ this.validateRegistry();
4038
+ }
4039
+
4040
+ /**
4041
+ * Validate registry schema
4042
+ */
4043
+ validateRegistry() {
4044
+ if (!this.registry) {
4045
+ throw new Error('Registry is required');
4046
+ }
4047
+ if (this.registry.schemaVersion !== '3.0') {
4048
+ throw new Error(`Unsupported registry schema version: ${this.registry.schemaVersion}. Required: 3.0`);
4049
+ }
4050
+ if (!this.registry.plugins || typeof this.registry.plugins !== 'object') {
4051
+ throw new Error('Registry must have plugins object');
4052
+ }
4053
+ }
4054
+
4055
+ /**
4056
+ * Validate version data has all required fields
4057
+ */
4058
+ validateVersionData(id, version, versionData) {
4059
+ if (!versionData.platform) {
4060
+ throw new Error(`Plugin ${id} v${version}: missing required field 'platform'`);
4061
+ }
4062
+ if (!versionData.package || !versionData.package.name) {
4063
+ throw new Error(`Plugin ${id} v${version}: missing required field 'package'`);
4064
+ }
4065
+ if (!versionData.binary || !versionData.binary.name) {
4066
+ throw new Error(`Plugin ${id} v${version}: missing required field 'binary'`);
4067
+ }
4068
+ }
4069
+
4070
+ /**
4071
+ * Compare version strings
4072
+ */
4073
+ compareVersions(a, b) {
4074
+ const partsA = a.split('.').map(x => parseInt(x) || 0);
4075
+ const partsB = b.split('.').map(x => parseInt(x) || 0);
4076
+
4077
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
4078
+ const numA = partsA[i] || 0;
4079
+ const numB = partsB[i] || 0;
4080
+ if (numA !== numB) {
4081
+ return numA - numB;
4082
+ }
4083
+ }
4084
+ return 0;
4085
+ }
4086
+
4087
+ /**
4088
+ * Build changelog map from versions
4089
+ */
4090
+ buildChangelogMap(versions) {
4091
+ const changelog = {};
4092
+ for (const [version, versionData] of Object.entries(versions)) {
4093
+ if (versionData.changelog && Array.isArray(versionData.changelog)) {
4094
+ changelog[version] = versionData.changelog;
4095
+ }
4096
+ }
4097
+ return changelog;
4098
+ }
4099
+
4100
+ /**
4101
+ * Transform registry to flat plugin array
4102
+ */
4103
+ transformToPluginArray() {
4104
+ const pluginsObject = this.registry.plugins || {};
4105
+ const plugins = [];
4106
+
4107
+ for (const [id, plugin] of Object.entries(pluginsObject)) {
4108
+ const latestVersion = plugin.latestVersion;
4109
+ const versionData = plugin.versions[latestVersion];
4110
+
4111
+ if (!versionData) {
4112
+ throw new Error(`Plugin ${id}: latest version ${latestVersion} not found in versions`);
4113
+ }
4114
+
4115
+ // Validate required fields - fail hard
4116
+ this.validateVersionData(id, latestVersion, versionData);
4117
+
4118
+ // Get all version numbers sorted descending
4119
+ const availableVersions = Object.keys(plugin.versions).sort((a, b) => {
4120
+ return this.compareVersions(b, a);
4121
+ });
4122
+
4123
+ // Build flat plugin object with latest version data
4124
+ const packageUrl = `https://machinefabric.com/plugins/packages/${versionData.package.name}`;
4125
+ plugins.push({
4126
+ id,
4127
+ name: plugin.name,
4128
+ version: latestVersion,
4129
+ description: plugin.description,
4130
+ author: plugin.author,
4131
+ pageUrl: plugin.pageUrl || packageUrl,
4132
+ teamId: plugin.teamId,
4133
+ signedAt: versionData.releaseDate,
4134
+ minAppVersion: versionData.minAppVersion || plugin.minAppVersion,
4135
+ caps: plugin.caps || [],
4136
+ categories: plugin.categories,
4137
+ tags: plugin.tags,
4138
+ changelog: this.buildChangelogMap(plugin.versions),
4139
+ // Distribution fields - ALL REQUIRED
4140
+ platform: versionData.platform,
4141
+ packageName: versionData.package.name,
4142
+ packageSha256: versionData.package.sha256,
4143
+ packageSize: versionData.package.size,
4144
+ binaryName: versionData.binary.name,
4145
+ binarySha256: versionData.binary.sha256,
4146
+ binarySize: versionData.binary.size,
4147
+ // All available versions
4148
+ availableVersions
4149
+ });
4150
+ }
4151
+
4152
+ return plugins;
4153
+ }
4154
+
4155
+ /**
4156
+ * Get all plugins (API response format)
4157
+ */
4158
+ getPlugins() {
4159
+ return {
4160
+ plugins: this.transformToPluginArray()
4161
+ };
4162
+ }
4163
+
4164
+ /**
4165
+ * Get plugin by ID
4166
+ */
4167
+ getPluginById(id) {
4168
+ const plugins = this.transformToPluginArray();
4169
+ return plugins.find(p => p.id === id);
4170
+ }
4171
+
4172
+ /**
4173
+ * Search plugins by query
4174
+ */
4175
+ searchPlugins(query) {
4176
+ const plugins = this.transformToPluginArray();
4177
+ const lowerQuery = query.toLowerCase();
4178
+
4179
+ return plugins.filter(p =>
4180
+ p.name.toLowerCase().includes(lowerQuery) ||
4181
+ p.description.toLowerCase().includes(lowerQuery) ||
4182
+ p.tags.some(t => t.toLowerCase().includes(lowerQuery)) ||
4183
+ p.caps.some(c => c.urn.toLowerCase().includes(lowerQuery) || c.title.toLowerCase().includes(lowerQuery))
4184
+ );
4185
+ }
4186
+
4187
+ /**
4188
+ * Get plugins by category
4189
+ */
4190
+ getPluginsByCategory(category) {
4191
+ const plugins = this.transformToPluginArray();
4192
+ return plugins.filter(p => p.categories.includes(category));
4193
+ }
4194
+
4195
+ /**
4196
+ * Get plugins that provide a specific cap
4197
+ */
4198
+ getPluginsByCap(capUrn) {
4199
+ const plugins = this.transformToPluginArray();
4200
+ return plugins.filter(p => p.caps.some(c => c.urn === capUrn));
4201
+ }
4202
+ }
4203
+
4204
+ // Export for CommonJS
4205
+ module.exports = {
4206
+ CapUrn,
4207
+ CapUrnBuilder,
4208
+ CapMatcher,
4209
+ CapUrnError,
4210
+ ErrorCodes,
4211
+ MediaUrn,
4212
+ MediaUrnError,
4213
+ MediaUrnErrorCodes,
4214
+ Cap,
4215
+ CapArg,
4216
+ ArgSource,
4217
+ RegisteredBy,
4218
+ createCap,
4219
+ createCapWithDescription,
4220
+ createCapWithMetadata,
4221
+ createCapWithDescriptionAndMetadata,
4222
+ ValidationError,
4223
+ InputValidator,
4224
+ OutputValidator,
4225
+ CapValidator,
4226
+ validateCapArgs,
4227
+ RESERVED_CLI_FLAGS,
4228
+ MediaSpec,
4229
+ MediaSpecError,
4230
+ MediaSpecErrorCodes,
4231
+ isBinaryCapUrn,
4232
+ isJSONCapUrn,
4233
+ isStructuredCapUrn,
4234
+ resolveMediaUrn,
4235
+ buildExtensionIndex,
4236
+ mediaUrnsForExtension,
4237
+ getExtensionMappings,
4238
+ validateNoMediaSpecRedefinition,
4239
+ validateNoMediaSpecRedefinitionSync,
4240
+ validateNoMediaSpecDuplicates,
4241
+ getSchemaBaseURL,
4242
+ getProfileURL,
4243
+ MEDIA_STRING,
4244
+ MEDIA_INTEGER,
4245
+ MEDIA_NUMBER,
4246
+ MEDIA_BOOLEAN,
4247
+ MEDIA_OBJECT,
4248
+ MEDIA_STRING_ARRAY,
4249
+ MEDIA_INTEGER_ARRAY,
4250
+ MEDIA_NUMBER_ARRAY,
4251
+ MEDIA_BOOLEAN_ARRAY,
4252
+ MEDIA_OBJECT_ARRAY,
4253
+ MEDIA_BINARY,
4254
+ MEDIA_VOID,
4255
+ MEDIA_PNG,
4256
+ MEDIA_AUDIO,
4257
+ MEDIA_VIDEO,
4258
+ MEDIA_AUDIO_SPEECH,
4259
+ MEDIA_IMAGE_THUMBNAIL,
4260
+ // Document types (PRIMARY naming)
4261
+ MEDIA_PDF,
4262
+ MEDIA_EPUB,
4263
+ // Text format types (PRIMARY naming)
4264
+ MEDIA_MD,
4265
+ MEDIA_TXT,
4266
+ MEDIA_RST,
4267
+ MEDIA_LOG,
4268
+ MEDIA_HTML,
4269
+ MEDIA_XML,
4270
+ MEDIA_JSON,
4271
+ MEDIA_JSON_SCHEMA,
4272
+ MEDIA_YAML,
4273
+ MEDIA_MODEL_SPEC,
4274
+ MEDIA_MODEL_REPO,
4275
+ MEDIA_MODEL_DIM,
4276
+ MEDIA_DECISION,
4277
+ MEDIA_DECISION_ARRAY,
4278
+ // Semantic output types - model management
4279
+ MEDIA_DOWNLOAD_OUTPUT,
4280
+ MEDIA_LIST_OUTPUT,
4281
+ MEDIA_STATUS_OUTPUT,
4282
+ MEDIA_CONTENTS_OUTPUT,
4283
+ MEDIA_AVAILABILITY_OUTPUT,
4284
+ MEDIA_PATH_OUTPUT,
4285
+ // Semantic output types - inference
4286
+ MEDIA_EMBEDDING_VECTOR,
4287
+ MEDIA_LLM_INFERENCE_OUTPUT,
4288
+ MEDIA_FILE_METADATA,
4289
+ MEDIA_DOCUMENT_OUTLINE,
4290
+ MEDIA_DISBOUND_PAGE,
4291
+ MEDIA_IMAGE_DESCRIPTION,
4292
+ MEDIA_TRANSCRIPTION_OUTPUT,
4293
+ // File path types
4294
+ MEDIA_FILE_PATH,
4295
+ MEDIA_FILE_PATH_ARRAY,
4296
+ // Semantic text input types
4297
+ MEDIA_FRONTMATTER_TEXT,
4298
+ MEDIA_MLX_MODEL_PATH,
4299
+ // Unified argument type
4300
+ CapArgumentValue,
4301
+ // Standard cap URN builders
4302
+ llmConversationUrn,
4303
+ modelAvailabilityUrn,
4304
+ modelPathUrn,
4305
+ CapMatrixError,
4306
+ CapMatrix,
4307
+ BestCapSetMatch,
4308
+ CompositeCapSet,
4309
+ CapBlock,
4310
+ CapGraphEdge,
4311
+ CapGraphStats,
4312
+ CapGraph,
4313
+ StdinSource,
4314
+ StdinSourceKind,
4315
+ // Plugin Repository
4316
+ PluginCapSummary,
4317
+ PluginInfo,
4318
+ PluginSuggestion,
4319
+ PluginRepoCache,
4320
+ PluginRepoClient,
4321
+ PluginRepoServer
4322
+ };