@ggui-ai/negotiator 0.1.0-rc.1

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 (91) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +49 -0
  3. package/dist/contract-hash.d.ts +54 -0
  4. package/dist/contract-hash.d.ts.map +1 -0
  5. package/dist/contract-hash.js +96 -0
  6. package/dist/contract-validators.d.ts +171 -0
  7. package/dist/contract-validators.d.ts.map +1 -0
  8. package/dist/contract-validators.js +478 -0
  9. package/dist/decision-input.d.ts +48 -0
  10. package/dist/decision-input.d.ts.map +1 -0
  11. package/dist/decision-input.js +14 -0
  12. package/dist/decision.d.ts +54 -0
  13. package/dist/decision.d.ts.map +1 -0
  14. package/dist/decision.js +500 -0
  15. package/dist/index.d.ts +36 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +25 -0
  18. package/dist/intent.d.ts +22 -0
  19. package/dist/intent.d.ts.map +1 -0
  20. package/dist/intent.js +28 -0
  21. package/dist/llm-caller.d.ts +70 -0
  22. package/dist/llm-caller.d.ts.map +1 -0
  23. package/dist/llm-caller.js +38 -0
  24. package/dist/llm-rerank.d.ts +101 -0
  25. package/dist/llm-rerank.d.ts.map +1 -0
  26. package/dist/llm-rerank.js +178 -0
  27. package/dist/negotiate.d.ts +141 -0
  28. package/dist/negotiate.d.ts.map +1 -0
  29. package/dist/negotiate.js +161 -0
  30. package/dist/normalize-schema.d.ts +22 -0
  31. package/dist/normalize-schema.d.ts.map +1 -0
  32. package/dist/normalize-schema.js +191 -0
  33. package/dist/pure.d.ts +30 -0
  34. package/dist/pure.d.ts.map +1 -0
  35. package/dist/pure.js +43 -0
  36. package/dist/rag-search.d.ts +73 -0
  37. package/dist/rag-search.d.ts.map +1 -0
  38. package/dist/rag-search.js +192 -0
  39. package/dist/rerank-eval/pairs.d.ts +28 -0
  40. package/dist/rerank-eval/pairs.d.ts.map +1 -0
  41. package/dist/rerank-eval/pairs.js +531 -0
  42. package/dist/rerank-eval/run-probe-cli.d.ts +3 -0
  43. package/dist/rerank-eval/run-probe-cli.d.ts.map +1 -0
  44. package/dist/rerank-eval/run-probe-cli.js +146 -0
  45. package/dist/rerank-eval/run-probe.d.ts +68 -0
  46. package/dist/rerank-eval/run-probe.d.ts.map +1 -0
  47. package/dist/rerank-eval/run-probe.js +113 -0
  48. package/dist/session.d.ts +42 -0
  49. package/dist/session.d.ts.map +1 -0
  50. package/dist/session.js +21 -0
  51. package/dist/suggestion.d.ts +38 -0
  52. package/dist/suggestion.d.ts.map +1 -0
  53. package/dist/suggestion.js +47 -0
  54. package/dist/synth-bench/corpus.d.ts +106 -0
  55. package/dist/synth-bench/corpus.d.ts.map +1 -0
  56. package/dist/synth-bench/corpus.js +994 -0
  57. package/dist/synth-bench/run-bench-cli.d.ts +3 -0
  58. package/dist/synth-bench/run-bench-cli.d.ts.map +1 -0
  59. package/dist/synth-bench/run-bench-cli.js +181 -0
  60. package/dist/synth-bench/run-bench.d.ts +101 -0
  61. package/dist/synth-bench/run-bench.d.ts.map +1 -0
  62. package/dist/synth-bench/run-bench.js +374 -0
  63. package/dist/synthesize-contract.d.ts +131 -0
  64. package/dist/synthesize-contract.d.ts.map +1 -0
  65. package/dist/synthesize-contract.js +948 -0
  66. package/dist/types.d.ts +30 -0
  67. package/dist/types.d.ts.map +1 -0
  68. package/dist/types.js +13 -0
  69. package/package.json +74 -0
  70. package/src/contract-hash.ts +102 -0
  71. package/src/contract-validators.ts +604 -0
  72. package/src/decision-input.ts +49 -0
  73. package/src/decision.ts +581 -0
  74. package/src/index.ts +63 -0
  75. package/src/intent.ts +37 -0
  76. package/src/llm-caller.ts +82 -0
  77. package/src/llm-rerank.ts +280 -0
  78. package/src/negotiate.ts +312 -0
  79. package/src/normalize-schema.ts +193 -0
  80. package/src/pure.ts +46 -0
  81. package/src/rag-search.ts +274 -0
  82. package/src/rerank-eval/pairs.ts +624 -0
  83. package/src/rerank-eval/run-probe-cli.ts +197 -0
  84. package/src/rerank-eval/run-probe.ts +198 -0
  85. package/src/session.ts +41 -0
  86. package/src/suggestion.ts +73 -0
  87. package/src/synth-bench/corpus.ts +1126 -0
  88. package/src/synth-bench/run-bench-cli.ts +237 -0
  89. package/src/synth-bench/run-bench.ts +525 -0
  90. package/src/synthesize-contract.ts +1161 -0
  91. package/src/types.ts +31 -0
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Deterministic repair of the invalid JSON Schema `type` spellings an
3
+ * LLM occasionally emits in a synthesized contract. Runs inside
4
+ * `buildContract` (before the `dataContractSchema` validation gate) so
5
+ * the dominant synth-decline class — a `type` value outside the seven
6
+ * JSON Schema primitives — is structurally eliminated rather than left
7
+ * to a repair retry.
8
+ *
9
+ * Pure, recursive, total: a schema that is already valid passes
10
+ * through unchanged — every mapping fires only on a value NOT in the
11
+ * valid set. Recursion descends only into sub-schema positions, so
12
+ * data-value positions (`default`, `const`, `enum`, `examples`) are
13
+ * left verbatim — a `default` of `{type: "list"}` is user data, not a
14
+ * schema, and must not be rewritten.
15
+ */
16
+
17
+ /** The seven JSON Schema primitive type names. */
18
+ const VALID_TYPES = new Set([
19
+ 'string',
20
+ 'number',
21
+ 'integer',
22
+ 'boolean',
23
+ 'array',
24
+ 'object',
25
+ 'null',
26
+ ]);
27
+
28
+ /** Unambiguous non-canonical type spellings → canonical primitive. */
29
+ const TYPE_ALIASES: Record<string, string> = {
30
+ list: 'array',
31
+ tuple: 'array',
32
+ sequence: 'array',
33
+ dict: 'object',
34
+ map: 'object',
35
+ record: 'object',
36
+ hash: 'object',
37
+ str: 'string',
38
+ text: 'string',
39
+ char: 'string',
40
+ int: 'integer',
41
+ long: 'integer',
42
+ short: 'integer',
43
+ float: 'number',
44
+ double: 'number',
45
+ decimal: 'number',
46
+ num: 'number',
47
+ numeric: 'number',
48
+ bool: 'boolean',
49
+ };
50
+
51
+ /** Type words that mean "no constraint" — the `type` key is dropped. */
52
+ const DROP_TYPES = new Set(['any', 'unknown', 'mixed', 'void']);
53
+
54
+ /** Object keys whose value is itself a single schema. */
55
+ const SCHEMA_VALUED_KEYS = new Set([
56
+ 'items',
57
+ 'additionalProperties',
58
+ 'not',
59
+ 'contains',
60
+ 'propertyNames',
61
+ ]);
62
+
63
+ /** Object keys whose value is a map of name → schema. */
64
+ const SCHEMA_MAP_KEYS = new Set([
65
+ 'properties',
66
+ 'patternProperties',
67
+ '$defs',
68
+ 'definitions',
69
+ ]);
70
+
71
+ /** Object keys whose value is an array of schemas. */
72
+ const SCHEMA_ARRAY_KEYS = new Set([
73
+ 'allOf',
74
+ 'anyOf',
75
+ 'oneOf',
76
+ 'prefixItems',
77
+ ]);
78
+
79
+ /**
80
+ * Infer the base type of an `enum`-constrained field from its value
81
+ * list. Mixed / empty / non-array → `string` (the safe default, and
82
+ * the overwhelmingly common enum shape).
83
+ */
84
+ function inferEnumBaseType(enumSibling: unknown): string {
85
+ if (!Array.isArray(enumSibling) || enumSibling.length === 0) {
86
+ return 'string';
87
+ }
88
+ if (enumSibling.every((v) => typeof v === 'string')) return 'string';
89
+ if (enumSibling.every((v) => typeof v === 'number')) return 'number';
90
+ if (enumSibling.every((v) => typeof v === 'boolean')) return 'boolean';
91
+ return 'string';
92
+ }
93
+
94
+ /**
95
+ * Map one `type` value to a canonical primitive, or `undefined` when
96
+ * the `type` key should be dropped entirely (an absent `type` is valid
97
+ * JSON Schema — it simply imposes no constraint).
98
+ *
99
+ * `enumSibling` is the schema's `enum` array, used to pick the base
100
+ * type when the LLM wrote the (invalid) `type: "enum"`.
101
+ */
102
+ function normalizeTypeValue(
103
+ value: unknown,
104
+ enumSibling: unknown,
105
+ ): string | undefined {
106
+ if (typeof value === 'string') {
107
+ const lower = value.toLowerCase();
108
+ if (VALID_TYPES.has(lower)) return lower;
109
+ if (lower === 'enum') return inferEnumBaseType(enumSibling);
110
+ if (DROP_TYPES.has(lower)) return undefined;
111
+ const alias = TYPE_ALIASES[lower];
112
+ if (alias !== undefined) return alias;
113
+ // Unrecognized garbage — drop the constraint rather than guess.
114
+ return undefined;
115
+ }
116
+ if (Array.isArray(value)) {
117
+ // Union type (`["string", "null"]`). The protocol contract schema
118
+ // accepts only a single string; recover the first valid non-null
119
+ // member, dropping the nullable arm.
120
+ for (const member of value) {
121
+ if (typeof member === 'string') {
122
+ const norm = normalizeTypeValue(member, enumSibling);
123
+ if (norm !== undefined && norm !== 'null') return norm;
124
+ }
125
+ }
126
+ return 'string';
127
+ }
128
+ // `type` was a number / object / boolean — meaningless; drop it.
129
+ return undefined;
130
+ }
131
+
132
+ /** Normalize a map of name → schema (e.g. `properties`). */
133
+ function normalizeSchemaMap(value: unknown): unknown {
134
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
135
+ return value;
136
+ }
137
+ const out: Record<string, unknown> = {};
138
+ for (const [name, sub] of Object.entries(value as Record<string, unknown>)) {
139
+ out[name] = coerceToSchema(sub);
140
+ }
141
+ return out;
142
+ }
143
+
144
+ /**
145
+ * Coerce a value found in a schema-expecting position into a schema
146
+ * object. An LLM sometimes writes the shorthand `items: "number"`
147
+ * where JSON Schema requires `items: {type: "number"}` — expand it
148
+ * (`"number"` → `{type: "number"}`, an unrecognized string → `{}`,
149
+ * the unconstrained schema). A boolean (`additionalProperties: false`)
150
+ * is a valid schema-position value and passes through; anything else
151
+ * recurses through {@link normalizeSchema}.
152
+ */
153
+ function coerceToSchema(value: unknown): unknown {
154
+ if (typeof value === 'string') {
155
+ const t = normalizeTypeValue(value, undefined);
156
+ return t === undefined ? {} : { type: t };
157
+ }
158
+ return normalizeSchema(value);
159
+ }
160
+
161
+ /**
162
+ * Recursively normalize a JSON Schema, repairing invalid `type`
163
+ * values. Non-object input (booleans, primitives) passes through —
164
+ * `additionalProperties: false` and the like are valid as-is.
165
+ */
166
+ export function normalizeSchema(schema: unknown): unknown {
167
+ if (typeof schema !== 'object' || schema === null || Array.isArray(schema)) {
168
+ return schema;
169
+ }
170
+ const obj = schema as Record<string, unknown>;
171
+ const out: Record<string, unknown> = {};
172
+ for (const [key, value] of Object.entries(obj)) {
173
+ if (key === 'type') {
174
+ const fixed = normalizeTypeValue(value, obj['enum']);
175
+ if (fixed !== undefined) out[key] = fixed;
176
+ continue;
177
+ }
178
+ if (SCHEMA_VALUED_KEYS.has(key)) {
179
+ out[key] = coerceToSchema(value);
180
+ } else if (SCHEMA_MAP_KEYS.has(key)) {
181
+ out[key] = normalizeSchemaMap(value);
182
+ } else if (SCHEMA_ARRAY_KEYS.has(key)) {
183
+ out[key] = Array.isArray(value)
184
+ ? value.map((s) => coerceToSchema(s))
185
+ : value;
186
+ } else {
187
+ // Data-value position (`default`, `const`, `enum`, `examples`,
188
+ // `required`, `description`, …) — leave verbatim.
189
+ out[key] = value;
190
+ }
191
+ }
192
+ return out;
193
+ }
package/src/pure.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pure helpers — zero I/O, zero AWS dependencies.
3
+ *
4
+ * - `inferInteractionMode(text)` — keyword-priority mapper from free
5
+ * text (category / pattern / prompt) to a categorical interaction
6
+ * label. Used for telemetry / RAG hinting only; NOT part of the
7
+ * `DataContract` wire surface. Deterministic; default `'display'`.
8
+ * - `inferJsonSchemaType(tsType)` — TypeScript-style type-string
9
+ * mapper to the JSON Schema type union. Deterministic.
10
+ */
11
+
12
+ /**
13
+ * Categorical interaction-mode label used by `inferInteractionMode` for
14
+ * telemetry / RAG hinting. NOT a `DataContract` field — the four typed
15
+ * specs (propsSpec / actionSpec / contextSpec / streamSpec) describe
16
+ * the wire surface exhaustively, so interaction mode is a derived
17
+ * heuristic, never part of the contract.
18
+ */
19
+ export type InteractionMode = 'display' | 'collect' | 'converse' | 'broadcast' | 'flow';
20
+
21
+ /**
22
+ * Infer interaction mode from any text (category, pattern, or prompt).
23
+ * Consolidated keyword matcher — all call sites use the same function.
24
+ *
25
+ * Used by rag-search and external benchmark runners for routing /
26
+ * telemetry. Deterministic; no I/O. Matches the first rule in priority
27
+ * order, defaulting to 'display'.
28
+ */
29
+ export function inferInteractionMode(text: string): InteractionMode {
30
+ const t = text.toLowerCase();
31
+ if (t.includes('form') || t.includes('survey') || t.includes('input') || t.includes('collect') || t.includes('sign up') || t.includes('contact')) return 'collect';
32
+ if (t.includes('chat') || t.includes('conversation') || t.includes('messaging') || t.includes('talk')) return 'converse';
33
+ if (t.includes('dashboard') || t.includes('monitor') || t.includes('live') || t.includes('feed') || t.includes('real-time')) return 'broadcast';
34
+ if (t.includes('wizard') || t.includes('onboarding') || t.includes('checkout') || t.includes('stepper') || t.includes('multi-step') || t.includes('flow') || t.includes('step')) return 'flow';
35
+ return 'display';
36
+ }
37
+
38
+ /** Map TypeScript-style type strings to JSON Schema types. */
39
+ export function inferJsonSchemaType(tsType: string): 'string' | 'number' | 'boolean' | 'array' | 'object' {
40
+ const t = tsType.toLowerCase();
41
+ if (t.includes('string')) return 'string';
42
+ if (t.includes('number') || t.includes('int') || t.includes('float')) return 'number';
43
+ if (t.includes('boolean') || t.includes('bool')) return 'boolean';
44
+ if (t.includes('array') || t.includes('[]')) return 'array';
45
+ return 'object';
46
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * RAG Search — embedding-similarity blueprint retrieval over the
3
+ * public `@ggui-ai/mcp-server-core` storage seam.
4
+ *
5
+ * Given agent `prompt` + a tenant `scope` (typically appId, or the
6
+ * literal `"shared"` for the global catalog), this helper:
7
+ *
8
+ * 1. Embeds the prompt via {@link EmbeddingProvider.embed}.
9
+ * 2. Queries the {@link VectorStore} for the top-K nearest neighbors
10
+ * (server-side cosine, scope-partitioned — no cross-tenant leak).
11
+ * 3. Filters by minimum score, biases private (per-app registered
12
+ * UIs) over shared (generated pool) at the same score band, and
13
+ * projects each hit into a {@link NegotiatorOption}.
14
+ *
15
+ * Two pipeline paths emerge from the confidence band:
16
+ * - **High confidence** (`score >= HIGH_CONFIDENCE_THRESHOLD`): the
17
+ * hit is marked `exact`. Upstream callers (V3 `negotiate()`) can
18
+ * short-circuit the decision LLM entirely.
19
+ * - **Medium confidence** (`score >= RETRIEVAL_MIN_SCORE` and below
20
+ * the exact threshold): marked `partial`. Upstream passes these to
21
+ * the decision LLM as candidates.
22
+ *
23
+ * ### Public-seam contract
24
+ *
25
+ * This function reads only the public `VectorStore` surface (scalar
26
+ * metadata). Consumers that still hold the rich `EmbeddingStorage`
27
+ * shape bridge via
28
+ * `@ggui-cloud/aws-adapters.embeddingStorageToVectorStore`, which
29
+ * encodes array-valued fields (`props`, `callbacks`, `sourceTools`)
30
+ * as JSON strings inside `metadata`. Any `VectorStore` implementation
31
+ * that stores fresh writes through `writeRagVector` (see
32
+ * `mcp-servers/ggui-protocol/src/adapters/vector-store.ts`) uses the
33
+ * same encoding — so existing caches, fresh writes, and community
34
+ * `VectorStore` backends all collide on the same retrieval key.
35
+ *
36
+ * ### Scope
37
+ *
38
+ * No AWS bindings, no LLM dependency. Pure composition over the two
39
+ * public seams. The one-LLM-call `makeDecision()` step is separate.
40
+ */
41
+
42
+ import type {
43
+ EmbeddingProvider,
44
+ VectorSearchResult,
45
+ VectorStore,
46
+ } from '@ggui-ai/mcp-server-core';
47
+ import type { DataContract, JsonValue } from '@ggui-ai/protocol';
48
+ import { inferJsonSchemaType } from './pure.js';
49
+ import type { NegotiatorOption } from './types.js';
50
+
51
+ /** RAG search result with per-stage timing. */
52
+ export interface RagSearchResult {
53
+ options: NegotiatorOption[];
54
+ embeddingLatencyMs: number;
55
+ searchLatencyMs: number;
56
+ }
57
+
58
+ /** Dependencies — both public seams from `@ggui-ai/mcp-server-core`. */
59
+ export interface RagSearchDeps {
60
+ embedding: EmbeddingProvider;
61
+ vectors: VectorStore;
62
+ }
63
+
64
+ export interface RagSearchInput {
65
+ /** Natural-language query text. Embedded as-is. */
66
+ prompt: string;
67
+ /**
68
+ * Scope / tenant partition. Typically `appId` for per-app indexes
69
+ * or the literal `"shared"` for the global catalog. Passed straight
70
+ * through to `VectorStore.query`; the scope is the tenant boundary,
71
+ * so a query never crosses into another tenant's index.
72
+ */
73
+ scope: string;
74
+ /** Override the k-NN cut-off (default 10). */
75
+ maxCandidates?: number;
76
+ }
77
+
78
+ /** Minimum cosine similarity to be considered a retrieval candidate. */
79
+ const RETRIEVAL_MIN_SCORE = 0.15;
80
+
81
+ /** Cosine similarity above this → exact match (skip LLM). */
82
+ const HIGH_CONFIDENCE_THRESHOLD = 0.45;
83
+
84
+ /** Default top-K for the k-NN query. */
85
+ const DEFAULT_MAX_CANDIDATES = 10;
86
+
87
+ /**
88
+ * Search the vector index for blueprints matching `prompt` within
89
+ * `scope`. See the module docstring for the confidence-band pipeline
90
+ * and encoding contract.
91
+ */
92
+ export async function ragSearch(
93
+ deps: RagSearchDeps,
94
+ input: RagSearchInput,
95
+ ): Promise<RagSearchResult> {
96
+ const topK = input.maxCandidates ?? DEFAULT_MAX_CANDIDATES;
97
+
98
+ // Stage 1: embed the query text
99
+ const embedStart = Date.now();
100
+ const queryEmbedding = await deps.embedding.embed(input.prompt);
101
+ const embeddingLatencyMs = Date.now() - embedStart;
102
+
103
+ // Stage 2: nearest-neighbor query
104
+ const searchStart = Date.now();
105
+ const results = await deps.vectors.query(input.scope, queryEmbedding, topK);
106
+ const searchLatencyMs = Date.now() - searchStart;
107
+
108
+ // Filter + sort: private (registered UIs) always beats shared (generated)
109
+ // at the same score level; then descending score.
110
+ const candidates = results
111
+ .filter((r) => r.score >= RETRIEVAL_MIN_SCORE)
112
+ .sort((a, b) => {
113
+ const aPrivate = readPoolSource(a.metadata) === 'private' ? 1 : 0;
114
+ const bPrivate = readPoolSource(b.metadata) === 'private' ? 1 : 0;
115
+ if (aPrivate !== bPrivate) return bPrivate - aPrivate;
116
+ return b.score - a.score;
117
+ });
118
+
119
+ if (candidates.length === 0) {
120
+ return { options: [], embeddingLatencyMs, searchLatencyMs };
121
+ }
122
+
123
+ const seenContracts = new Set<string>();
124
+ const options: NegotiatorOption[] = [];
125
+
126
+ for (const match of candidates) {
127
+ const option = buildOption(match);
128
+ if (!option) continue;
129
+ if (seenContracts.has(option.contractKey)) continue;
130
+ seenContracts.add(option.contractKey);
131
+ options.push(option.value);
132
+ }
133
+
134
+ return { options, embeddingLatencyMs, searchLatencyMs };
135
+ }
136
+
137
+ /**
138
+ * Project a public {@link VectorSearchResult} hit into a
139
+ * {@link NegotiatorOption}, together with the dedup key used to
140
+ * collapse hits that describe the same semantic contract.
141
+ */
142
+ function buildOption(
143
+ match: VectorSearchResult,
144
+ ): { value: NegotiatorOption; contractKey: string } | undefined {
145
+ const isExact = match.score >= HIGH_CONFIDENCE_THRESHOLD;
146
+ const verdict = isExact ? 'exact' : 'partial';
147
+
148
+ const blueprintHash = match.key;
149
+ // Registered (private) blueprints already carry a `p_` prefix in
150
+ // their hash; generated pool entries do not — prefix them as `c_`
151
+ // to match the legacy option ID format that downstream consumers
152
+ // (decision prompt, cache layer) have been reading since V2.
153
+ const blueprintId = blueprintHash.startsWith('p_')
154
+ ? blueprintHash
155
+ : `c_${blueprintHash}`;
156
+
157
+ const prompt = readString(match.metadata.prompt, '');
158
+ const intent = readString(match.metadata.intent, '');
159
+ const category = readString(match.metadata.category, '');
160
+ const contractHash = readString(match.metadata.contractHash, '');
161
+ const poolSource = readPoolSource(match.metadata);
162
+ const featured = Boolean(match.metadata.featured);
163
+ const props = readProps(match.metadata.props);
164
+
165
+ const contract = buildContract({ prompt, intent, category, props });
166
+
167
+ // Dedup: prefer matching on semantic intent; fall back to prop
168
+ // signature so two blueprints with identical intents but different
169
+ // prop shapes don't collapse together.
170
+ const contractKey =
171
+ intent !== ''
172
+ ? intent
173
+ : JSON.stringify(Object.keys(contract.propsSpec?.properties ?? {}).sort());
174
+
175
+ const option: NegotiatorOption = {
176
+ id: `rag_${blueprintId.slice(-8)}`,
177
+ type: 'blueprint',
178
+ blueprintId,
179
+ pattern: category,
180
+ description: `${prompt} (similarity: ${Math.round(match.score * 100)}%, ${verdict})`,
181
+ pros: [
182
+ isExact
183
+ ? 'Exact blueprint match — instant render'
184
+ : 'Semantically matched to your request',
185
+ ...(featured ? ['Featured blueprint — curated quality'] : []),
186
+ ],
187
+ cons: [
188
+ ...(isExact ? [] : ['Partial match — may need adjustments']),
189
+ ...(!isExact ? ['Fixed layout — limited customization'] : []),
190
+ ],
191
+ renderTime: 'instant',
192
+ contract,
193
+ ...(contractHash !== '' ? { contractHash } : {}),
194
+ ...(poolSource !== undefined ? { poolSource } : {}),
195
+ };
196
+
197
+ return { value: option, contractKey };
198
+ }
199
+
200
+ /** Prop schema shape as encoded by `embeddingStorageToVectorStore`. */
201
+ interface PropSpec {
202
+ name: string;
203
+ type: string;
204
+ required: boolean;
205
+ description: string;
206
+ example?: unknown;
207
+ }
208
+
209
+ function buildContract(args: {
210
+ prompt: string;
211
+ intent: string;
212
+ category: string;
213
+ props: PropSpec[];
214
+ }): DataContract {
215
+ // `intent` is not a contract field. The `prompt` and `intent` fields
216
+ // stay on the args because callers still use them for RAG keys +
217
+ // dedup; the returned contract carries structural shape only.
218
+ const { props } = args;
219
+ return {
220
+ propsSpec: {
221
+ properties: Object.fromEntries(
222
+ props.map((p) => [
223
+ p.name,
224
+ {
225
+ description: p.description,
226
+ schema: { type: inferJsonSchemaType(p.type) },
227
+ required: p.required,
228
+ ...(p.example !== undefined
229
+ ? { example: p.example as JsonValue }
230
+ : {}),
231
+ },
232
+ ]),
233
+ ),
234
+ },
235
+ };
236
+ }
237
+
238
+ function readString(
239
+ value: string | number | boolean | null | undefined,
240
+ fallback: string,
241
+ ): string {
242
+ return typeof value === 'string' ? value : fallback;
243
+ }
244
+
245
+ function readPoolSource(
246
+ metadata: Record<string, string | number | boolean | null>,
247
+ ): 'shared' | 'private' | undefined {
248
+ const v = metadata.poolSource;
249
+ return v === 'shared' || v === 'private' ? v : undefined;
250
+ }
251
+
252
+ function readProps(
253
+ value: string | number | boolean | null | undefined,
254
+ ): PropSpec[] {
255
+ if (typeof value !== 'string' || value.length === 0) return [];
256
+ try {
257
+ const parsed = JSON.parse(value) as unknown;
258
+ if (!Array.isArray(parsed)) return [];
259
+ return parsed.filter(isPropSpec);
260
+ } catch {
261
+ return [];
262
+ }
263
+ }
264
+
265
+ function isPropSpec(value: unknown): value is PropSpec {
266
+ if (value === null || typeof value !== 'object') return false;
267
+ const v = value as Record<string, unknown>;
268
+ return (
269
+ typeof v.name === 'string' &&
270
+ typeof v.type === 'string' &&
271
+ typeof v.required === 'boolean' &&
272
+ typeof v.description === 'string'
273
+ );
274
+ }