create-skybridge 0.0.0-dev.7914624 → 0.0.0-dev.793efe5

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 (53) hide show
  1. package/dist/index.js +107 -17
  2. package/package.json +2 -2
  3. package/templates/blank/README.md +2 -1
  4. package/templates/blank/node_modules/.bin/alpic +2 -2
  5. package/templates/blank/node_modules/.bin/tsx +2 -2
  6. package/templates/blank/node_modules/.bin/vite +2 -2
  7. package/templates/blank/package.json +8 -7
  8. package/templates/blank/src/index.ts +3 -0
  9. package/templates/blank/src/server.ts +7 -11
  10. package/templates/blank/vite.config.ts +7 -1
  11. package/templates/demo/README.md +3 -1
  12. package/templates/demo/evals/start.eval.ts +11 -0
  13. package/templates/demo/node_modules/.bin/alpic +2 -2
  14. package/templates/demo/node_modules/.bin/tsx +2 -2
  15. package/templates/demo/node_modules/.bin/vite +2 -2
  16. package/templates/demo/node_modules/.bin/vitest +21 -0
  17. package/templates/demo/package.json +19 -13
  18. package/templates/demo/src/index.ts +3 -0
  19. package/templates/demo/src/server.ts +86 -88
  20. package/templates/demo/src/views/onboarding.tsx +3 -3
  21. package/templates/demo/tsconfig.evals.json +11 -0
  22. package/templates/demo/vite.config.ts +9 -3
  23. package/templates/ecom/.env.template +5 -0
  24. package/templates/ecom/README.md +2 -1
  25. package/templates/ecom/node_modules/.bin/alpic +2 -2
  26. package/templates/ecom/node_modules/.bin/ladle +2 -2
  27. package/templates/ecom/node_modules/.bin/vite +2 -2
  28. package/templates/ecom/package.json +3 -2
  29. package/templates/ecom/src/catalog/index.ts +19 -0
  30. package/templates/ecom/src/catalog/mock.ts +134 -0
  31. package/templates/ecom/src/catalog/shopify.ts +264 -0
  32. package/templates/ecom/src/components/chip.css.ts +15 -8
  33. package/templates/ecom/src/components/chip.stories.tsx +2 -1
  34. package/templates/ecom/src/components/chip.tsx +16 -8
  35. package/templates/ecom/src/components/image-gallery.css.ts +2 -2
  36. package/templates/ecom/src/components/image-gallery.tsx +5 -4
  37. package/templates/ecom/src/components/product-card.css.ts +9 -5
  38. package/templates/ecom/src/components/product-card.tsx +5 -4
  39. package/templates/ecom/src/components/variant-picker.stories.tsx +9 -6
  40. package/templates/ecom/src/components/variant-picker.tsx +17 -29
  41. package/templates/ecom/src/components/view-frame.tsx +4 -4
  42. package/templates/ecom/src/i18n.ts +2 -1
  43. package/templates/ecom/src/index.ts +3 -0
  44. package/templates/ecom/src/lib/variants.ts +100 -29
  45. package/templates/ecom/src/server.ts +17 -21
  46. package/templates/ecom/src/tools/render-carousel.ts +11 -92
  47. package/templates/ecom/src/tools/search-products.ts +40 -26
  48. package/templates/ecom/src/types.ts +77 -3
  49. package/templates/ecom/src/views/carousel/detail/detail.css.ts +25 -10
  50. package/templates/ecom/src/views/carousel/detail/detail.stories.tsx +33 -14
  51. package/templates/ecom/src/views/carousel/detail/index.tsx +83 -44
  52. package/templates/ecom/src/views/carousel/index.tsx +3 -4
  53. package/templates/ecom/vite.config.ts +1 -1
@@ -1,79 +1,150 @@
1
- import type { Product, Variant } from "../tools/render-carousel.js";
1
+ import type { Product, Variant } from "../types.js";
2
2
 
3
3
  // Pure helpers that turn the client's option choices into a concrete variant.
4
4
  // The `variants` list is SPARSE: only combinations that exist are present, and
5
5
  // that is the whole contingency model. These helpers never encode rules; they
6
- // filter the list.
6
+ // filter the list. A variant may also skip an axis entirely (a one-size cap in
7
+ // a capacity x size catalog carries no `size`): that axis is simply "not
8
+ // applicable" to it, and the helpers treat the absence as its own value.
9
+ //
10
+ // Availability is computed TOP-DOWN (the Shopify convention): an axis is
11
+ // constrained only by the choices on the axes declared BEFORE it. So the first
12
+ // axis is never disabled by a later choice, and each later axis narrows under
13
+ // the ones above. Order the `options` array accordingly.
7
14
 
8
15
  // A selection is one chosen value per axis, keyed by Option.id -> OptionValue.id.
9
16
  export type Selection = Record<string, string>;
10
17
 
11
18
  /**
12
- * The variant matching a full selection (one value per axis), or undefined if
13
- * the selection is partial or the combination does not exist. A product with no
14
- * options resolves to its single variant on an empty selection.
19
+ * The variant that exactly matches a selection, or undefined if none does.
20
+ * Strict per-axis equality with "missing" normalized to null: a partial
21
+ * selection resolves nothing, and a variant that skips an axis matches only a
22
+ * selection that also leaves it unset. A product with no options resolves to
23
+ * its single variant on an empty selection.
15
24
  */
16
25
  export function resolveVariant(
17
26
  product: Product,
18
27
  selection: Selection,
19
28
  ): Variant | undefined {
20
- for (const option of product.options) {
21
- if (selection[option.id] == null) {
22
- return undefined; // partial selection: nothing resolved yet
23
- }
24
- }
25
29
  return product.variants.find((variant) =>
26
30
  product.options.every(
27
- (option) => variant.selection[option.id] === selection[option.id],
31
+ (option) =>
32
+ (variant.selection[option.id] ?? null) ===
33
+ (selection[option.id] ?? null),
28
34
  ),
29
35
  );
30
36
  }
31
37
 
32
38
  /**
33
- * The values of `axisId` still reachable given the choices already made on the
34
- * OTHER axes. A value is reachable if at least one variant carries it while
35
- * matching every other current choice. Values not in this set have no surviving
36
- * variant, so the picker disables them ("Red only in M").
39
+ * The state of every value of `axisId` given the choices on the axes ABOVE it:
40
+ * - "inStock": at least one matching variant is purchasable → normal chip.
41
+ * - "soldOut": matching variants exist, none in stock → struck, clickable.
42
+ * - absent: a hole in the variant matrix → hard-disabled chip.
43
+ * An empty map means the axis does not apply to the current configuration (a
44
+ * one-size colorway has no Size), and the picker hides its row.
37
45
  */
38
- export function selectableValues(
46
+ export function axisStates(
39
47
  product: Product,
40
48
  axisId: string,
41
49
  selection: Selection,
42
- ): Set<string> {
43
- const reachable = new Set<string>();
50
+ ): Map<string, "inStock" | "soldOut"> {
51
+ const states = new Map<string, "inStock" | "soldOut">();
44
52
  for (const variant of product.variants) {
45
- let matchesOthers = true;
53
+ let matchesEarlier = true;
46
54
  for (const option of product.options) {
47
55
  if (option.id === axisId) {
48
- continue;
56
+ break; // only the axes above constrain this one
49
57
  }
50
58
  const chosen = selection[option.id];
51
- if (chosen != null && variant.selection[option.id] !== chosen) {
52
- matchesOthers = false;
59
+ const carried = variant.selection[option.id];
60
+ if (chosen != null && carried !== undefined && carried !== chosen) {
61
+ matchesEarlier = false;
62
+ break;
63
+ }
64
+ }
65
+ if (!matchesEarlier) {
66
+ continue;
67
+ }
68
+ // A variant to which this axis does not apply carries no value to offer.
69
+ const value = variant.selection[axisId];
70
+ if (value !== undefined && states.get(value) !== "inStock") {
71
+ states.set(value, variant.outOfStock ? "soldOut" : "inStock");
72
+ }
73
+ }
74
+ return states;
75
+ }
76
+
77
+ /**
78
+ * The selection after picking `valueId` on `axisId`: the one state transition
79
+ * of the picker, rebuilt top-down.
80
+ * - An axis with no reachable value does not apply: its choice is dropped.
81
+ * - A prior choice that still EXISTS under the axes above is KEPT, even sold
82
+ * out: switching color never silently changes the size the user asked for.
83
+ * - Anything else fills with the first value in display order, preferring one
84
+ * that is in stock.
85
+ * The result resolves to a concrete variant, with one exception: a variant
86
+ * skipping an EARLIER axis keeps offering its later values under any choice on
87
+ * that axis, so picking one can compose a selection no variant matches. The
88
+ * detail renders that as "Combination unavailable".
89
+ */
90
+ export function applyChoice(
91
+ product: Product,
92
+ selection: Selection,
93
+ axisId: string,
94
+ valueId: string,
95
+ ): Selection {
96
+ const next: Selection = {};
97
+ for (const option of product.options) {
98
+ const candidate = option.id === axisId ? valueId : selection[option.id];
99
+ const states = axisStates(product, option.id, next);
100
+ if (states.size === 0) {
101
+ continue; // axis does not apply to the configuration above it
102
+ }
103
+ if (candidate != null && states.has(candidate)) {
104
+ next[option.id] = candidate;
105
+ continue;
106
+ }
107
+ let fill: string | undefined;
108
+ for (const value of option.values) {
109
+ if (states.get(value.id) === "inStock") {
110
+ fill = value.id;
53
111
  break;
54
112
  }
55
113
  }
56
- if (matchesOthers) {
57
- reachable.add(variant.selection[axisId]);
114
+ if (fill === undefined) {
115
+ for (const value of option.values) {
116
+ if (states.has(value.id)) {
117
+ fill = value.id;
118
+ break;
119
+ }
120
+ }
121
+ }
122
+ if (fill !== undefined) {
123
+ next[option.id] = fill;
58
124
  }
59
125
  }
60
- return reachable;
126
+ return next;
61
127
  }
62
128
 
63
129
  /**
64
130
  * The selection to preselect when a product opens: the variant the client
65
- * tapped (its id equals the opened product id), else the first variant. A
66
- * variant is always preselected when the product has any, so the buy CTA is
67
- * live on open rather than starting disabled.
131
+ * tapped (its id equals the opened product id), else the first IN-STOCK
132
+ * variant, else the first variant. A variant is always preselected when the
133
+ * product has any, so the buy CTA is live on open rather than starting
134
+ * disabled.
68
135
  */
69
136
  export function initialSelection(product: Product): Selection {
70
137
  let base: Variant | undefined;
138
+ let firstInStock: Variant | undefined;
71
139
  for (const variant of product.variants) {
72
140
  if (variant.id === product.id) {
73
141
  base = variant;
74
142
  break;
75
143
  }
144
+ if (firstInStock === undefined && !variant.outOfStock) {
145
+ firstInStock = variant;
146
+ }
76
147
  }
77
- base ??= product.variants[0];
148
+ base ??= firstInStock ?? product.variants[0];
78
149
  return base ? { ...base.selection } : {};
79
150
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
- import { McpServer } from "skybridge/server";
2
+ import { Skybridge } from "skybridge/server";
3
3
  import { CAROUSEL_RANGE, MIN_SEARCH_ITERATIONS } from "./config.js";
4
4
  import {
5
5
  renderCarouselDefinition,
@@ -15,29 +15,25 @@ if (existsSync(".env")) {
15
15
  process.loadEnvFile();
16
16
  }
17
17
 
18
- const server = new McpServer(
19
- {
20
- // @todo: name and version your app.
21
- name: "skybridge-ecom",
22
- version: "0.0.1",
23
- },
24
- {
25
- // @todo: adapt this server-wide prompt to your catalog.
26
- instructions: `\
18
+ export const app = new Skybridge({
19
+ // @todo: name and version your app.
20
+ name: "skybridge-ecom",
21
+ version: "0.0.1",
22
+ // @todo: adapt this server-wide prompt to your catalog.
23
+ instructions: `\
27
24
  Two phases:
28
25
 
29
- SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting—never off one call. \
30
- Vary the keyword, apply filters from a prior response, or page deeper. \
26
+ SEARCH: Call search-products ${MIN_SEARCH_ITERATIONS}+ times before presenting, never off one call. \
27
+ Vary the keywords or page deeper. \
31
28
  Stay silent while searching: emit NO text between calls. Speak only \
32
- once the carousel renders. Never call a category unavailable before searching.
29
+ once the carousel renders.
33
30
 
34
31
  RENDER: After curating, call render-carousel with the chosen product IDs (aim for ${CAROUSEL_RANGE}). \
35
- Speak only once it renders, then recommend in carousel order.`,
36
- },
37
- )
38
- .registerTool(searchProductsDefinition, searchProductsHandler)
39
- .registerTool(renderCarouselDefinition, renderCarouselHandler);
32
+ Speak once it renders, then recommend products in carousel order.`,
33
+ handler: (server) =>
34
+ server
35
+ .registerTool(searchProductsDefinition, searchProductsHandler)
36
+ .registerTool(renderCarouselDefinition, renderCarouselHandler),
37
+ });
40
38
 
41
- export default await server.run();
42
-
43
- export type AppType = typeof server;
39
+ export type AppType = typeof app;
@@ -1,73 +1,11 @@
1
1
  import { z } from "zod";
2
+ import { getProducts } from "../catalog/index.js";
2
3
  import { CAROUSEL_MAX_SIZE, CAROUSEL_RANGE } from "../config.js";
3
- import {
4
- type Attribute,
5
- AttributeSchema,
6
- type Price,
7
- PriceSchema,
8
- } from "../types.js";
4
+ import { PriceSchema, type Product, SpecSchema } from "../types.js";
9
5
 
10
6
  // The `render-carousel` tool: takes the IDs the model curated and returns the
11
- // matching products for the carousel view to render.
12
- // Everything this tool needs lives in this file.
13
-
14
- // ---------------------------------------------------------------------------
15
- // Product model
16
- // ---------------------------------------------------------------------------
17
- // Model: variant-as-full-product. Each `Variant` is a complete, buyable product
18
- // (its own title, price, media). A `Product` ties sibling variants together and
19
- // declares the axes (`Option`s) they vary on. A product with no variations is
20
- // just a product with a single variant and no options.
21
-
22
- // One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
23
- type OptionValue = {
24
- id: string; // stable key referenced by Variant.selection, e.g. "black"
25
- label: string; // shown to the user, e.g. "Black"
26
- media?: string; // optional swatch / image representing this value
27
- };
28
-
29
- // A variation axis the variants differ on, e.g. Color or Size.
30
- type Option = {
31
- id: string; // stable key, used as a key in Variant.selection, e.g. "color"
32
- label: string; // shown to the user, e.g. "Color"
33
- values: OptionValue[]; // in display order
34
- };
35
-
36
- // Display fields shared by a Variant and by a product's `card`.
37
- type Meta = {
38
- title: string;
39
- description?: string;
40
- price?: Price;
41
- media: string[]; // images for this item; media[0] is the primary/cover
42
- url?: string; // link to this item's external product page
43
- outOfStock?: boolean; // true = not purchasable
44
- // Facts to display: spec rows, promo labels, etc.
45
- attributes: Attribute[];
46
- };
47
-
48
- // One buyable product: full display Meta plus which value it takes on each axis.
49
- export type Variant = Meta & {
50
- id: string; // SKU / article number; unique within the catalog
51
- // The chosen value per axis: keys are Option.id, values are OptionValue.id.
52
- // e.g. { color: "black", size: "40" }
53
- selection: Record<string, string>;
54
- };
55
-
56
- // A product: one carousel card backed by one or more variants and the axes they
57
- // vary on (none for a single-variant product).
58
- export type Product = {
59
- id: string; // stable product key
60
- options: Option[]; // the axes the variants vary on
61
- // Only the variants that actually exist. A missing combination (e.g. no
62
- // { color: "black", size: "40" }) is simply absent from this list — that is how
63
- // contingent variations are expressed. Derive the selectable values for an axis
64
- // by filtering this list on the choices already made.
65
- variants: Variant[];
66
- // The product's carousel card. Surfaced both in the carousel (the view
67
- // renders it) and to the model (structuredContent is projected from it).
68
- // How you build it depends on your mapping strategy: see getProducts.
69
- card: Meta;
70
- };
7
+ // matching products for the carousel view to render. Data access lives in
8
+ // `src/catalog/`; everything else this tool needs lives in this file.
71
9
 
72
10
  // ---------------------------------------------------------------------------
73
11
  // Input
@@ -101,37 +39,18 @@ const outputSchema = {
101
39
  description: z.string().optional(),
102
40
  price: PriceSchema.optional(),
103
41
  outOfStock: z.boolean(),
104
- attributes: z
105
- .array(AttributeSchema)
106
- .describe("Product facts (specs, promo labels…)."),
42
+ specs: z
43
+ .array(SpecSchema)
44
+ .describe("Product-specific facts (material, dimensions, care…)."),
107
45
  }),
108
46
  )
109
47
  .describe(
110
- "The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent attributes.",
48
+ "The products shown in the carousel, in display order. For your reference only — to curate, compare, and answer follow-ups. Ground every claim in this data; never invent facts.",
111
49
  ),
112
50
  };
113
51
 
114
52
  type RenderOutput = z.infer<z.ZodObject<typeof outputSchema>>;
115
53
 
116
- // ---------------------------------------------------------------------------
117
- // Data access
118
- // ---------------------------------------------------------------------------
119
-
120
- async function getProducts(_ids: string[]): Promise<Product[]> {
121
- // @todo: fetch each id from your product API / DB and map the results into
122
- // `Product`s (rename `_ids` -> `ids` once you use it).
123
- //
124
- // First decide your mapping strategy — it depends on your catalog:
125
- // - no variants (simple products): one `Product`, a single variant, card = that variant, options: [],
126
- // - grouped: one `Product` per product; `card` = union of its variants, one picture per requested variant
127
- // - one card per requested variant: `card` = that variant
128
- // Either way, set `variants` to ALL variants the source returns for the product;
129
- // the detail view reads them so the client can switch variant.
130
- //
131
- // Returns [] for now, so the carousel is empty.
132
- return [];
133
- }
134
-
135
54
  // ---------------------------------------------------------------------------
136
55
  // Mapping: trim each product's `card` and `options` into the model-facing
137
56
  // grounding (outputSchema), dropping presentational fields (media, url). The
@@ -162,7 +81,7 @@ function toStructuredContent(products: Product[]): RenderOutput {
162
81
  price: card.price,
163
82
  outOfStock: card.outOfStock ?? false,
164
83
  options,
165
- attributes: card.attributes,
84
+ specs: card.specs,
166
85
  });
167
86
  }
168
87
 
@@ -182,7 +101,7 @@ export const renderCarouselDefinition = {
182
101
  Display the products you curated as an inline carousel for the client.
183
102
 
184
103
  ## When to call
185
- Call this AFTER searching and curating, and BEFORE writing your recommendation. Do not describe the products in text first; the carousel shows them. Stay silent until it has rendered, then speak.
104
+ Call this AFTER searching and curating, and BEFORE writing your recommendation. Avoid describing the products in text first since the carousel shows them.
186
105
 
187
106
  ## What to pass
188
107
  Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most relevant first). Order is significant: the carousel shows them in this exact order and your recommendation must follow the same sequence. Pass distinct products, not several variants of the same one; the detail view lets the client explore a product's variants (colors, sizes, and so on).
@@ -191,7 +110,7 @@ Pass the IDs of the ${CAROUSEL_RANGE} products you chose, in display order (most
191
110
  Recommend in carousel order so the client can follow along. The cards already show image, title, price, and key facts, so do not repeat them: add useful analysis tied to the client's need. Suggest a refinement the client has not addressed yet (from the available filters), never one they already used.
192
111
 
193
112
  ## Accuracy
194
- Use only the data returned for each product. Never invent attributes, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`,
113
+ Use only the data returned for each product. Never invent facts, materials, or availability. If the client asks about something not present, open that product's detail or search again before answering.`,
195
114
  annotations: {
196
115
  readOnlyHint: true,
197
116
  openWorldHint: false,
@@ -1,15 +1,17 @@
1
1
  import { z } from "zod";
2
+ import { search } from "../catalog/index.js";
2
3
  import {
3
4
  CAROUSEL_MAX_SIZE,
4
5
  CAROUSEL_RANGE,
5
6
  MIN_SEARCH_ITERATIONS,
6
7
  } from "../config.js";
7
- import { AttributeSchema, PriceSchema } from "../types.js";
8
+ import { PriceSchema, type SearchResult, SpecSchema } from "../types.js";
8
9
 
9
10
  // The `search-products` tool: keyword + filters in, matching products out as
10
11
  // structured output for the model. It has NO view — include only what the model
11
12
  // needs to curate (ids + properties), never presentational data (images, media);
12
- // render-carousel handles that. Everything this tool needs lives in this file.
13
+ // render-carousel handles that. Data access lives in `src/catalog/`; everything
14
+ // else this tool needs lives in this file.
13
15
 
14
16
  // ---------------------------------------------------------------------------
15
17
  // Input
@@ -21,10 +23,8 @@ const inputSchema = {
21
23
  keyword: z.string().describe(
22
24
  `\
23
25
  Short noun phrases extracted from conversational input. Never pass full sentences. \
24
- ALWAYS use the singular form, not the plural. \
25
26
  Include color, material, and style descriptors for accuracy. \
26
- For vague gift or occasion queries without a clear product type, use broad category terms. \
27
- Preserve the existing keyword across filter refinements; only replace when the user makes a completely different request.`,
27
+ For vague gift or occasion queries without a clear product type, use broad category terms.`,
28
28
  ),
29
29
 
30
30
  // @todo: set the sort options your backend supports (or remove).
@@ -41,7 +41,7 @@ Preserve the existing keyword across filter refinements; only replace when the u
41
41
  ),
42
42
  };
43
43
 
44
- type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
44
+ export type SearchInput = z.infer<z.ZodObject<typeof inputSchema>>;
45
45
 
46
46
  // ---------------------------------------------------------------------------
47
47
  // Output — model-facing grounding, returned in structuredContent.
@@ -56,9 +56,12 @@ const productSchema = z.object({
56
56
  .boolean()
57
57
  .optional()
58
58
  .describe("True when the product is not purchasable."),
59
- attributes: z
60
- .array(AttributeSchema)
61
- .describe("Facts to curate on (specs, materials, promo labels…)."),
59
+ specs: z
60
+ .array(SpecSchema)
61
+ .describe("Product-specific facts to curate on (material, dimensions…)."),
62
+
63
+ // @todo: Add whatever custom fields the model should curate on as real types,
64
+ // not `specs` strings (e.g. `rating`, `discountPct`, `badges`).
62
65
  });
63
66
 
64
67
  const outputSchema = {
@@ -79,17 +82,29 @@ const outputSchema = {
79
82
  type SearchOutput = z.infer<z.ZodObject<typeof outputSchema>>;
80
83
 
81
84
  // ---------------------------------------------------------------------------
82
- // Data access
85
+ // Mapping: project each product's `card` into the model-facing grounding
86
+ // (outputSchema), dropping presentational fields (media, url).
87
+ // @todo: choose what the model sees per product. Grounding only: the model
88
+ // curates on facts, so presentational data never belongs here.
83
89
  // ---------------------------------------------------------------------------
84
90
 
85
- function search(_input: SearchInput): SearchOutput {
86
- // @todo: plug in your product API / DB. Query it with the input params and map
87
- // each result into `products` below. `pages` and `totalHits` are optional.
88
- return {
89
- products: [], // { id: string; properties: { name: PropertyName; value: string[] }[] }[]
90
- pages: { current: 1, total: 1 },
91
- totalHits: 0,
92
- };
91
+ function toStructuredContent({
92
+ products,
93
+ pages,
94
+ totalHits,
95
+ }: SearchResult): SearchOutput {
96
+ const results: SearchOutput["products"] = [];
97
+ for (const { id, card } of products) {
98
+ results.push({
99
+ id,
100
+ title: card.title,
101
+ description: card.description,
102
+ price: card.price,
103
+ outOfStock: card.outOfStock,
104
+ specs: card.specs,
105
+ });
106
+ }
107
+ return { products: results, pages, totalHits };
93
108
  }
94
109
 
95
110
  // ---------------------------------------------------------------------------
@@ -97,7 +112,7 @@ function search(_input: SearchInput): SearchOutput {
97
112
  // themselves ride in structuredContent; this text carries NO result data.
98
113
  // @todo: customize the NEXT STEPS guidance below for your flow (keep searching
99
114
  // vs. curate and render). Remind the model to ground claims in the structured
100
- // results and never invent attributes.
115
+ // results and never invent facts.
101
116
  // ---------------------------------------------------------------------------
102
117
 
103
118
  function narrate({ products }: SearchOutput): string {
@@ -142,15 +157,14 @@ export const searchProductsDefinition = {
142
157
  Search the product catalog. Handles any query: specific products, broad categories, gifts, occasions, or open discovery.
143
158
  Never assume a category is unavailable: always search before responding.
144
159
 
145
- The response is data only: a list of matching products (name, ID, price, description, and any product-specific attributes) plus pagination and the available filters. The raw results are for your eyes only: the client never sees them.
160
+ The response is data only: a list of matching products (name, ID, price, description, and any product-specific facts) plus pagination and the available filters. The raw results are for your eyes only: the client never sees them.
146
161
 
147
- NEVER describe or characterize the raw results to the client: do not mention how many there are, what categories they fall into, or that they look off-topic. While searching, stay silent: do not narrate what you are doing, announce searches, or report progress between tool calls. Speak only once the carousel is rendered.
162
+ NEVER describe or characterize the raw results to the client: do not mention how many there are, what categories they fall into, or that they look off-topic.
148
163
 
149
164
  Act on the response as follows:
150
165
 
151
- - FIRST SEARCH: use the keyword only (plus sort if needed). Read the available filters in the response; these are the only valid filter options for follow-ups.
152
- - REFINEMENT: if the user's intent matches an available filter, apply it. If it doesn't match any filter, start a new keyword search.
153
- - ITERATE: a single search is never enough. Run several searches (${MIN_SEARCH_ITERATIONS} minimum) before rendering: vary the keyword, apply a filter, or page deeper, and compare results across them. Quality comes from this multi-call exploration; one call then render gives shallow results.
166
+ - FIRST SEARCH: use keywords only (plus sort if needed).
167
+ - ITERATE: a single search is not always enough. Run several searches (${MIN_SEARCH_ITERATIONS} minimum) before rendering: vary the keyword, apply a filter, or page deeper, and compare results across them. Quality comes from this multi-call exploration; one call then render gives shallow results.
154
168
  - CURATION: read results and pick the best matches for the client's intent, grounding your choice in each product's description. If zero results, broaden the keyword or relax filters and search again: do NOT call render-carousel. If fewer than 3 results and the user didn't ask for a specific product, search again with broader terms. Only after exploring across several searches, call render-carousel with the selected IDs.
155
169
  - PRESENT: Once you have ${CAROUSEL_RANGE} distinct, relevant products, call render-carousel with their IDs. Recommend ONLY AFTER the carousel displays, in carousel order.
156
170
 
@@ -172,8 +186,8 @@ The sweet spot is ${CAROUSEL_RANGE} products.
172
186
  outputSchema,
173
187
  };
174
188
 
175
- export function searchProductsHandler(input: SearchInput) {
176
- const results = search(input);
189
+ export async function searchProductsHandler(input: SearchInput) {
190
+ const results = toStructuredContent(await search(input));
177
191
  return {
178
192
  structuredContent: results,
179
193
  content: [{ type: "text" as const, text: narrate(results) }],
@@ -6,8 +6,82 @@ export const PriceSchema = z.object({
6
6
  });
7
7
  export type Price = z.infer<typeof PriceSchema>;
8
8
 
9
- export const AttributeSchema = z.object({
10
- name: z.string(),
9
+ // A product-specific fact (an objective spec: material, dimensions, capacity,
10
+ // care…). `label` is optional so a fact can be a bare value (e.g. "Waterproof").
11
+ export const SpecSchema = z.object({
12
+ label: z.string().optional(),
11
13
  value: z.string(),
12
14
  });
13
- export type Attribute = z.infer<typeof AttributeSchema>;
15
+ export type Spec = z.infer<typeof SpecSchema>;
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Product model — what the catalog providers in `src/catalog/` return and the
19
+ // views render. Model: variant-as-full-product. Each `Variant` is a complete,
20
+ // buyable product (its own title, price, media). A `Product` ties sibling
21
+ // variants together and declares the axes (`Option`s) they vary on. A product
22
+ // with no variations is just a product with a single variant and no options.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ // One selectable value on an axis, e.g. the "Black" choice on the "Color" axis.
26
+ type OptionValue = {
27
+ id: string; // stable key referenced by Variant.selection, e.g. "black"
28
+ label: string; // shown to the user, e.g. "Black"
29
+ media?: string; // optional swatch / image representing this value
30
+ };
31
+
32
+ // A variation axis the variants differ on, e.g. Color or Size.
33
+ export type Option = {
34
+ id: string; // stable key, used as a key in Variant.selection, e.g. "color"
35
+ label: string; // shown to the user, e.g. "Color"
36
+ values: OptionValue[]; // in display order
37
+ };
38
+
39
+ // Display fields shared by a Variant and by a product's `card`.
40
+ type Meta = {
41
+ title: string;
42
+ description?: string;
43
+ price?: Price;
44
+ media: string[]; // images for this item; media[0] is the primary/cover
45
+ url?: string; // link to this item's external product page
46
+ outOfStock?: boolean; // true = not purchasable
47
+ // Objective, product-specific facts (material, dimensions, capacity, care…),
48
+ // rendered as-is. Each fact's label is optional.
49
+ specs: Spec[];
50
+
51
+ // @todo: Add whatever custom fields the carousel should render as real types
52
+ // (e.g. `rating` → stars, `discountPct` → badge, `badges` → chips).
53
+ };
54
+
55
+ // One buyable product: full display Meta plus which value it takes on each axis.
56
+ export type Variant = Meta & {
57
+ id: string; // SKU / article number; unique within the catalog
58
+ // The chosen value per axis: keys are Option.id, values are OptionValue.id.
59
+ // e.g. { color: "black", size: "40" }
60
+ selection: Record<string, string>;
61
+ };
62
+
63
+ // A product: one carousel card backed by one or more variants and the axes they
64
+ // vary on (none for a single-variant product).
65
+ export type Product = {
66
+ id: string; // stable product key
67
+ // The axes the variants vary on, in display order. Order is semantic: the
68
+ // detail picker narrows availability top-down (each axis constrained by the
69
+ // ones before it), so put the imagery-driving axis (usually color) first.
70
+ options: Option[];
71
+ // Only the variants that actually exist. A missing combination (e.g. no
72
+ // { color: "black", size: "40" }) is simply absent from this list — that is how
73
+ // contingent variations are expressed. Derive the selectable values for an axis
74
+ // by filtering this list on the choices already made.
75
+ variants: Variant[];
76
+ // The product's carousel card. Surfaced both in the carousel (the view
77
+ // renders it) and to the model (structuredContent is projected from it).
78
+ card: Meta;
79
+ };
80
+
81
+ // What a catalog provider's `search` returns. `pages` and `totalHits` are
82
+ // optional: not every backend reports them.
83
+ export type SearchResult = {
84
+ products: Product[];
85
+ pages?: { current: number; total: number };
86
+ totalHits?: number;
87
+ };