@curless/shopify-storefront 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/__tests__/card-contract.test.d.ts +2 -0
  2. package/dist/__tests__/card-contract.test.d.ts.map +1 -0
  3. package/dist/__tests__/card-contract.test.js +22 -0
  4. package/dist/__tests__/card-contract.test.js.map +1 -0
  5. package/dist/__tests__/catalog-registry.test.d.ts +2 -0
  6. package/dist/__tests__/catalog-registry.test.d.ts.map +1 -0
  7. package/dist/__tests__/catalog-registry.test.js +109 -0
  8. package/dist/__tests__/catalog-registry.test.js.map +1 -0
  9. package/dist/__tests__/challenge.test.d.ts +2 -0
  10. package/dist/__tests__/challenge.test.d.ts.map +1 -0
  11. package/dist/__tests__/challenge.test.js +46 -0
  12. package/dist/__tests__/challenge.test.js.map +1 -0
  13. package/dist/__tests__/config.test.d.ts +2 -0
  14. package/dist/__tests__/config.test.d.ts.map +1 -0
  15. package/dist/__tests__/config.test.js +123 -0
  16. package/dist/__tests__/config.test.js.map +1 -0
  17. package/dist/__tests__/connection-state.test.d.ts +2 -0
  18. package/dist/__tests__/connection-state.test.d.ts.map +1 -0
  19. package/dist/__tests__/connection-state.test.js +40 -0
  20. package/dist/__tests__/connection-state.test.js.map +1 -0
  21. package/dist/__tests__/multi-token-store.test.d.ts +2 -0
  22. package/dist/__tests__/multi-token-store.test.d.ts.map +1 -0
  23. package/dist/__tests__/multi-token-store.test.js +89 -0
  24. package/dist/__tests__/multi-token-store.test.js.map +1 -0
  25. package/dist/__tests__/oauth.test.d.ts +2 -0
  26. package/dist/__tests__/oauth.test.d.ts.map +1 -0
  27. package/dist/__tests__/oauth.test.js +112 -0
  28. package/dist/__tests__/oauth.test.js.map +1 -0
  29. package/dist/__tests__/output-schema.test.d.ts +2 -0
  30. package/dist/__tests__/output-schema.test.d.ts.map +1 -0
  31. package/dist/__tests__/output-schema.test.js +112 -0
  32. package/dist/__tests__/output-schema.test.js.map +1 -0
  33. package/dist/__tests__/ping.test.d.ts +2 -0
  34. package/dist/__tests__/ping.test.d.ts.map +1 -0
  35. package/dist/__tests__/ping.test.js +22 -0
  36. package/dist/__tests__/ping.test.js.map +1 -0
  37. package/dist/__tests__/rate-limit.test.d.ts +2 -0
  38. package/dist/__tests__/rate-limit.test.d.ts.map +1 -0
  39. package/dist/__tests__/rate-limit.test.js +46 -0
  40. package/dist/__tests__/rate-limit.test.js.map +1 -0
  41. package/dist/__tests__/stock.test.d.ts +2 -0
  42. package/dist/__tests__/stock.test.d.ts.map +1 -0
  43. package/dist/__tests__/stock.test.js +109 -0
  44. package/dist/__tests__/stock.test.js.map +1 -0
  45. package/dist/__tests__/tenant.test.d.ts +2 -0
  46. package/dist/__tests__/tenant.test.d.ts.map +1 -0
  47. package/dist/__tests__/tenant.test.js +105 -0
  48. package/dist/__tests__/tenant.test.js.map +1 -0
  49. package/dist/__tests__/tools.test.d.ts +2 -0
  50. package/dist/__tests__/tools.test.d.ts.map +1 -0
  51. package/dist/__tests__/tools.test.js +543 -0
  52. package/dist/__tests__/tools.test.js.map +1 -0
  53. package/dist/__tests__/upstream-cap.test.d.ts +2 -0
  54. package/dist/__tests__/upstream-cap.test.d.ts.map +1 -0
  55. package/dist/__tests__/upstream-cap.test.js +78 -0
  56. package/dist/__tests__/upstream-cap.test.js.map +1 -0
  57. package/dist/__tests__/webhooks.test.d.ts +2 -0
  58. package/dist/__tests__/webhooks.test.d.ts.map +1 -0
  59. package/dist/__tests__/webhooks.test.js +88 -0
  60. package/dist/__tests__/webhooks.test.js.map +1 -0
  61. package/dist/card.generated.d.ts +2 -0
  62. package/dist/card.generated.d.ts.map +1 -0
  63. package/dist/card.generated.js +6 -0
  64. package/dist/card.generated.js.map +1 -0
  65. package/dist/catalog-registry.d.ts +44 -0
  66. package/dist/catalog-registry.d.ts.map +1 -0
  67. package/dist/catalog-registry.js +105 -0
  68. package/dist/catalog-registry.js.map +1 -0
  69. package/dist/catalog.d.ts +51 -0
  70. package/dist/catalog.d.ts.map +1 -0
  71. package/dist/catalog.js +189 -0
  72. package/dist/catalog.js.map +1 -0
  73. package/dist/challenge.d.ts +11 -0
  74. package/dist/challenge.d.ts.map +1 -0
  75. package/dist/challenge.js +36 -0
  76. package/dist/challenge.js.map +1 -0
  77. package/dist/config.d.ts +141 -0
  78. package/dist/config.d.ts.map +1 -0
  79. package/dist/config.js +65 -0
  80. package/dist/config.js.map +1 -0
  81. package/dist/connection.d.ts +10 -0
  82. package/dist/connection.d.ts.map +1 -0
  83. package/dist/connection.js +21 -0
  84. package/dist/connection.js.map +1 -0
  85. package/dist/index.d.ts +3 -0
  86. package/dist/index.d.ts.map +1 -0
  87. package/dist/index.js +974 -0
  88. package/dist/index.js.map +1 -0
  89. package/dist/legal.d.ts +52 -0
  90. package/dist/legal.d.ts.map +1 -0
  91. package/dist/legal.js +139 -0
  92. package/dist/legal.js.map +1 -0
  93. package/dist/load-config.d.ts +11 -0
  94. package/dist/load-config.d.ts.map +1 -0
  95. package/dist/load-config.js +23 -0
  96. package/dist/load-config.js.map +1 -0
  97. package/dist/multi-token-store.d.ts +18 -0
  98. package/dist/multi-token-store.d.ts.map +1 -0
  99. package/dist/multi-token-store.js +95 -0
  100. package/dist/multi-token-store.js.map +1 -0
  101. package/dist/oauth.d.ts +20 -0
  102. package/dist/oauth.d.ts.map +1 -0
  103. package/dist/oauth.js +123 -0
  104. package/dist/oauth.js.map +1 -0
  105. package/dist/rate-limit.d.ts +15 -0
  106. package/dist/rate-limit.d.ts.map +1 -0
  107. package/dist/rate-limit.js +56 -0
  108. package/dist/rate-limit.js.map +1 -0
  109. package/dist/stock.d.ts +39 -0
  110. package/dist/stock.d.ts.map +1 -0
  111. package/dist/stock.js +77 -0
  112. package/dist/stock.js.map +1 -0
  113. package/dist/tenant.d.ts +69 -0
  114. package/dist/tenant.d.ts.map +1 -0
  115. package/dist/tenant.js +68 -0
  116. package/dist/tenant.js.map +1 -0
  117. package/dist/token-store.d.ts +16 -0
  118. package/dist/token-store.d.ts.map +1 -0
  119. package/dist/token-store.js +47 -0
  120. package/dist/token-store.js.map +1 -0
  121. package/dist/tools.d.ts +201 -0
  122. package/dist/tools.d.ts.map +1 -0
  123. package/dist/tools.js +779 -0
  124. package/dist/tools.js.map +1 -0
  125. package/dist/webhooks.d.ts +30 -0
  126. package/dist/webhooks.d.ts.map +1 -0
  127. package/dist/webhooks.js +69 -0
  128. package/dist/webhooks.js.map +1 -0
  129. package/package.json +53 -0
package/dist/tools.js ADDED
@@ -0,0 +1,779 @@
1
+ import { cartPermalink, lookupShopifyOrder, resolveVariant, snapChoices, } from '@curless/shopify-adapter';
2
+ // OpenAI's Apps review requires all three hints on every tool. All four read
3
+ // the merchant's own store and change nothing, in it or anywhere else.
4
+ //
5
+ // Anthropic's checklist additionally requires a `title` on every tool, and it is
6
+ // not decoration: the hints decide auto-permissions, and the title is the name a
7
+ // buyer reads in the permission line. `${deps.tools.checkout}` is the reason to
8
+ // care — a tool whose NAME says "create" is asked about in the buyer's language,
9
+ // and "Get a Moody checkout link" is what it actually does.
10
+ const READ_ONLY = { readOnlyHint: true, destructiveHint: false, openWorldHint: false };
11
+ const reads = (title) => ({ ...READ_ONLY, title });
12
+ /**
13
+ * The tool table, built for THIS storefront's names.
14
+ *
15
+ * A constant until there was a second merchant. A model choosing among
16
+ * several storefront connectors picks by tool name, so the names carry the
17
+ * merchant — and that means they cannot be literals here.
18
+ */
19
+ export const buildTools = (n, v) => [
20
+ {
21
+ name: n.list,
22
+ // The first clause says what this READS, not what to use it instead of.
23
+ //
24
+ // It said "use this INSTEAD of web search" for a day, written after watching
25
+ // an assistant answer "Moody brown dailies" from the scraped storefront —
26
+ // £14.84 in one paragraph and "$19.99–$24.99 USD" two below it, nothing
27
+ // about stock, not one orderable prescription. But Anthropic's criteria
28
+ // reject descriptions that "interfere with Claude calling other tools", and
29
+ // that sentence is exactly the shape they name.
30
+ //
31
+ // Nothing informative was lost. What could ever have moved the decision is
32
+ // the capability — live catalogue, real stock, prescriptions that can be
33
+ // ordered — and that is a fact about this tool. The imperative was the part
34
+ // arguing with the other tools, and arguing was never what worked.
35
+ description: `Open the ${v.merchant} storefront — the live catalogue of the shop itself: the price it charges today, what it can actually ship, and the combinations it will accept, rendered as a card the buyer can buy from. Call this FIRST, with NO arguments, as soon as the buyer wants ${v.nounPlural} — it renders a card with pictures, filter chips and a Buy button on every ${v.noun}, so they can browse and choose without being asked anything. Do not interview them first and do not recommend on top of it. Pass \`query\` only once they have said what they want.${v.hints.browse ? ` ${v.hints.browse}` : ''}`,
36
+ annotations: reads(`Browse ${v.merchant} ${v.nounPlural}`),
37
+ inputSchema: {
38
+ type: 'object',
39
+ properties: {
40
+ query: {
41
+ type: 'string',
42
+ description: 'Free text over the name, tags and description — this is how colour is searched ("green", "brown", "matcha")',
43
+ },
44
+ maxPrice: { type: 'number', description: 'Upper bound in whole currency units' },
45
+ limit: { type: 'number', description: 'Default 20' },
46
+ },
47
+ },
48
+ },
49
+ {
50
+ name: n.get,
51
+ description: `One ${v.noun} in full: its options, every value offered, prices and images. Check this BEFORE checkout — how many choices the buyer must make is set by the product, not fixed, so read them here rather than assuming.${v.hints.product ? ` ${v.hints.product}` : ''}`,
52
+ annotations: reads(`View one ${v.merchant} ${v.noun}`),
53
+ inputSchema: {
54
+ type: 'object',
55
+ properties: {
56
+ product: {
57
+ type: 'string',
58
+ description: `The ${v.noun} name or handle${v.hints.example ? `, e.g. "${v.hints.example}"` : ''}`,
59
+ },
60
+ },
61
+ required: ['product'],
62
+ },
63
+ },
64
+ {
65
+ name: n.checkout,
66
+ description: `Get the checkout link for ONE specific ${v.noun} variant. Fill EVERY option the product has — if it offers two, both are required, and they are usually different. Never assume one answer carries to another option, and never round to a nearby value: ask. If the choices do not name exactly one variant this returns the options to ask about instead of guessing.${v.hints.checkout ? ` ${v.hints.checkout}` : ''}`,
67
+ annotations: reads(`Get a ${v.merchant} checkout link`),
68
+ inputSchema: {
69
+ type: 'object',
70
+ properties: {
71
+ product: { type: 'string', description: `The ${v.noun} name or handle` },
72
+ options: {
73
+ type: 'object',
74
+ // The SHAPE is generic; the example is not, so it comes from the
75
+ // merchant. A pet shop reading {"Left":"-2.00"} learns nothing except
76
+ // that this tool was written for someone else.
77
+ description: `One value per option, keyed by the store's own option names.${v.hints.checkout ? ` ${v.hints.checkout}` : ''}`,
78
+ additionalProperties: { type: 'string' },
79
+ },
80
+ quantity: { type: 'number', description: 'Default 1' },
81
+ },
82
+ required: ['product', 'options'],
83
+ },
84
+ },
85
+ {
86
+ name: n.lookupOrder,
87
+ description: 'Find an order the buyer already placed. BOTH the order number and the email it was placed with are required — that pair is what proves the order is theirs.',
88
+ annotations: reads(`Look up a ${v.merchant} order`),
89
+ inputSchema: {
90
+ type: 'object',
91
+ properties: {
92
+ orderNumber: { type: 'string', description: 'From the confirmation email, e.g. "#1052"' },
93
+ email: { type: 'string', description: 'The email the order was placed with' },
94
+ },
95
+ required: ['orderNumber', 'email'],
96
+ },
97
+ },
98
+ ];
99
+ // The shape of each tool's `structuredContent`, so the model knows what it is
100
+ // holding without inferring it from one example.
101
+ //
102
+ // EVERY tool can return more than one shape — `${deps.tools.checkout}` answers
103
+ // `checkout`, `needs-options`, `out-of-stock` or `error` depending on what the
104
+ // buyer gave it — so `view` is the discriminator and is always present. That is
105
+ // the part a reader must be able to rely on, so it is the part declared exactly.
106
+ //
107
+ // Item internals are left open on purpose. A schema that promises fields the
108
+ // payload sometimes omits is worse than no schema: it is a claim that is
109
+ // sometimes false, and the reader trusting it is the model deciding what to say
110
+ // to a buyer.
111
+ const OBJ = { type: 'object' };
112
+ const ARR = { type: 'array', items: { type: 'object' } };
113
+ const STR = { type: 'string' };
114
+ export const buildOutputSchemas = (n) => ({
115
+ [n.list]: {
116
+ type: 'object',
117
+ properties: {
118
+ view: { const: 'products' },
119
+ merchant: STR,
120
+ logoUrl: STR,
121
+ toolNames: OBJ,
122
+ vocab: OBJ,
123
+ products: ARR,
124
+ total: { type: 'number' },
125
+ facets: ARR,
126
+ promotions: ARR,
127
+ // Set when the answer is broader than what was asked — the assistant
128
+ // should not read a relaxed result as an exact match.
129
+ relaxedFrom: STR,
130
+ },
131
+ required: ['view', 'products'],
132
+ },
133
+ [n.get]: {
134
+ type: 'object',
135
+ properties: {
136
+ view: { enum: ['product', 'error'] },
137
+ merchant: STR,
138
+ logoUrl: STR,
139
+ toolNames: OBJ,
140
+ vocab: OBJ,
141
+ // `options` is what the buyer must still answer; `inStock` is which
142
+ // combinations of those can actually be bought.
143
+ product: OBJ,
144
+ error: STR,
145
+ },
146
+ required: ['view'],
147
+ },
148
+ [n.checkout]: {
149
+ type: 'object',
150
+ properties: {
151
+ view: { enum: ['checkout', 'needs-options', 'out-of-stock', 'error'] },
152
+ merchant: STR,
153
+ logoUrl: STR,
154
+ toolNames: OBJ,
155
+ vocab: OBJ,
156
+ product: OBJ,
157
+ // view=checkout
158
+ variant: OBJ,
159
+ quantity: { type: 'number' },
160
+ total: STR,
161
+ checkoutUrl: STR,
162
+ // view=needs-options — what is still missing, and what was understood so
163
+ // far, already snapped to the store's own values.
164
+ options: ARR,
165
+ given: OBJ,
166
+ eyes: { enum: ['pair', 'single', 'unknown'] },
167
+ inStock: ARR,
168
+ error: STR,
169
+ },
170
+ required: ['view'],
171
+ },
172
+ [n.lookupOrder]: {
173
+ type: 'object',
174
+ properties: {
175
+ // `order-not-found` is returned both for no such order AND for a wrong
176
+ // email — deliberately indistinguishable.
177
+ view: { enum: ['order', 'order-not-found', 'rate-limited'] },
178
+ merchant: STR,
179
+ logoUrl: STR,
180
+ toolNames: OBJ,
181
+ vocab: OBJ,
182
+ order: OBJ,
183
+ },
184
+ required: ['view'],
185
+ },
186
+ });
187
+ // Colour words → what this store actually calls its lenses. The colour IS the
188
+ // product name here ("Matcha Green", "Oolong Brown"), so a buyer asking for a
189
+ // family — in either language — matches nothing without this. Not a translation
190
+ // table: only the words a lens shopper reaches for.
191
+ //
192
+ // Keyed on the BARE colour. A picker offering "灰/蓝色系" sends 蓝色系, and
193
+ // enumerating every suffix by hand is how 绿色系 and 棕色系 ended up mapped while
194
+ // 蓝色系 and 紫色系 silently fell through to the whole 382-product shelf.
195
+ const COLOUR_WORDS = {
196
+ 棕: ['brown', 'choco', 'coffee', 'oolong', 'walnut', 'caramel', 'hazel', 'barley'],
197
+ 咖啡: ['brown', 'coffee', 'choco'],
198
+ brown: ['choco', 'coffee', 'oolong', 'walnut', 'caramel', 'hazel'],
199
+ 绿: ['green', 'matcha', 'olive', 'jade', 'mint', 'sage'],
200
+ green: ['matcha', 'olive', 'jade', 'mint', 'sage'],
201
+ 灰: ['grey', 'gray', 'smoke', 'ash', 'silver', 'granite'],
202
+ grey: ['gray', 'smoke', 'ash', 'silver'],
203
+ gray: ['grey', 'smoke', 'ash', 'silver'],
204
+ 蓝: ['blue', 'ocean', 'sky', 'aqua', 'denim'],
205
+ blue: ['ocean', 'sky', 'aqua', 'denim'],
206
+ 紫: ['purple', 'violet', 'lilac', 'lavender', 'taro'],
207
+ purple: ['violet', 'lilac', 'lavender', 'taro'],
208
+ 粉: ['pink', 'rose', 'peach', 'berry', 'sakura', 'coral'],
209
+ pink: ['rose', 'peach', 'berry', 'sakura', 'coral'],
210
+ 黑: ['black', 'noir', 'onyx', 'halo'],
211
+ black: ['noir', 'onyx'],
212
+ 日抛: ['1 day'],
213
+ 月抛: ['1 month'],
214
+ 半年抛: ['6 months'],
215
+ 自然: ['natural', 'nude', 'daily'],
216
+ };
217
+ // "蓝色系" / "蓝色" / "蓝" are one key. Trimmed rather than enumerated, so a
218
+ // phrasing nobody thought of still lands on the colour.
219
+ const colourKey = (w) => w.replace(/(色系|色调|系|色)$/u, '') || w;
220
+ // The filter chips, taken from the merchant's OWN taxonomy.
221
+ //
222
+ // Moody's site files a lens under By Style / By Color / By G.Diameter /
223
+ // Replacement cycle, and their tags land on exactly those axes: Brown(26),
224
+ // Grey(7); With Limbal ring(49), Glowy Highlight(28); 1 Day(36), 6 Months(3).
225
+ // A flat row of the six commonest tags — which is what this was — mixes a colour
226
+ // with a product line and a lens finish, and asks the buyer to spot the
227
+ // difference. Grouped, each row answers one question.
228
+ //
229
+ // Two corrections their own site made obvious:
230
+ //
231
+ // • DIAMETER IS A FILTER, and I had been throwing it away. The tags are bare
232
+ // numbers (14.2, 13.5), which the old rule called noise along with base
233
+ // curve and water content — but Moody offers G.Diameter as a top-level
234
+ // choice, bucketed. Bucketed here too, at their thresholds.
235
+ // • Base curve (8.6) and water content (55%) really are specs, not filters.
236
+ // They appear on their spec sheets and in none of their filter menus.
237
+ const DIAMETER_BUCKETS = [
238
+ { label: 'Large ≥13.7mm', min: 13.7, max: 99 },
239
+ { label: 'Medium 13.4–13.6mm', min: 13.4, max: 13.69 },
240
+ { label: 'Small ≤13.3mm', min: 0, max: 13.39 },
241
+ ];
242
+ // A lens diameter, or null. Base curve (8.x) and water content (n%) are excluded
243
+ // by range and by shape — 8.6 is not a small lens, it is a curve.
244
+ const asDiameter = (tag) => {
245
+ if (!/^\d{2}(\.\d)?$/.test(tag))
246
+ return null;
247
+ const n = Number(tag);
248
+ return n >= 12 && n <= 16 ? n : null;
249
+ };
250
+ const AXES = [
251
+ { axis: 'Colour', match: /^(brown|green|blue|black|gr[ae]y|pink|red|purple|gold[\s\w]*)$/i },
252
+ {
253
+ axis: 'Style',
254
+ match: /^(with limbal ring|without limbal ring|glowy highlight|iris pattern|bold|neutral)$/i,
255
+ },
256
+ { axis: 'Cycle', match: /^(1 day|1 month|6 months|1 year|trials?)$/i },
257
+ ];
258
+ // An axis is worth offering when its choices tell results apart. Judged on the
259
+ // AXIS, never on the individual value — dropping a value because everything has
260
+ // it deletes a true option from a row of alternatives, and the buyer reads the
261
+ // absence as an answer.
262
+ //
263
+ // That is not hypothetical: a lens is tagged with every diameter it comes in, so
264
+ // all 40 results offered a large size, and the value-level rule silently removed
265
+ // "Large ≥13.7mm" while keeping Medium and Small. The row then said this store
266
+ // has no large lenses — about the diameters it stocks most.
267
+ const worthOffering = (values, total) => values.length > 1 && values.some((v) => v.count < total);
268
+ const facetsOf = (products) => {
269
+ if (products.length < 3)
270
+ return [];
271
+ const covers = (tags) => products.filter((p) => (p.tags ?? []).some((t) => tags.includes(t))).length;
272
+ const out = [];
273
+ for (const { axis, match } of AXES) {
274
+ const tags = new Set();
275
+ for (const p of products)
276
+ for (const t of p.tags ?? [])
277
+ if (match.test(t))
278
+ tags.add(t);
279
+ const values = [...tags]
280
+ .map((t) => ({ label: t, tags: [t], count: covers([t]) }))
281
+ .filter((v) => v.count > 0)
282
+ .sort((a, b) => b.count - a.count);
283
+ if (worthOffering(values, products.length))
284
+ out.push({ axis, values });
285
+ }
286
+ // Diameter, bucketed at Moody's own thresholds rather than offering twelve
287
+ // bare numbers.
288
+ const diameters = new Set();
289
+ for (const p of products)
290
+ for (const t of p.tags ?? [])
291
+ if (asDiameter(t) !== null)
292
+ diameters.add(t);
293
+ if (diameters.size > 1) {
294
+ const values = DIAMETER_BUCKETS.map(({ label, min, max }) => {
295
+ const tags = [...diameters].filter((t) => {
296
+ const n = asDiameter(t);
297
+ return n >= min && n <= max;
298
+ });
299
+ return { label, tags, count: covers(tags) };
300
+ }).filter((v) => v.count > 0);
301
+ if (worthOffering(values, products.length))
302
+ out.push({ axis: 'Diameter', values });
303
+ }
304
+ return out;
305
+ };
306
+ // The combinations a buyer can actually buy, as option maps — the one thing the
307
+ // option lists cannot express. "Left -5.75 is offered" and "Right -5.50 is
308
+ // offered" says nothing about whether that PAIR exists, and at this store 674 of
309
+ // the 676 pairs do not.
310
+ const inStockCombos = (variants) => {
311
+ const stocked = variants.filter((v) => v.available);
312
+ return {
313
+ inStock: stocked
314
+ .slice(0, IN_STOCK_CAP)
315
+ .map((v) => Object.fromEntries(v.options.map((o) => [o.name, o.value]))),
316
+ // Tells the picker to stop filtering rather than filter on a truncated list,
317
+ // which would hide buyable prescriptions.
318
+ inStockTruncated: stocked.length > IN_STOCK_CAP,
319
+ };
320
+ };
321
+ // How many in-stock combinations a product payload carries. Generous, because
322
+ // the list is what stops the picker offering prescriptions nobody can buy, and
323
+ // stingy enough that a fully stocked pair (676) cannot flood a tool result the
324
+ // model also has to read.
325
+ const IN_STOCK_CAP = 300;
326
+ // The shop's offers, as the card and the assistant both need them: the
327
+ // merchant's own wording, which is usually the whole story — they write "Buy 2
328
+ // get 5% OFF | Buy 3 get 10% OFF" precisely because that is what a shopper reads.
329
+ //
330
+ // `promotionsBlocked` says WHY there are none when the reason is not "there are
331
+ // none". Those need a scope granted at install, so the honest answer to a
332
+ // merchant asking why their sale is invisible is "reinstall the app", and that
333
+ // answer only exists if we keep the distinction.
334
+ const promoPayload = (deps) => {
335
+ const snap = deps.catalog.current;
336
+ const promotions = snap.discounts
337
+ .map((d) => ({
338
+ title: d.title,
339
+ ...(d.percentage !== undefined ? { percentOff: Math.round(d.percentage * 100) } : {}),
340
+ ...(d.minQuantity !== undefined ? { minQuantity: d.minQuantity } : {}),
341
+ ...(d.endsAt ? { endsAt: d.endsAt } : {}),
342
+ }))
343
+ // Only offers a shopper can act on.
344
+ //
345
+ // These are AUTOMATIC discounts — they apply at checkout with no code to
346
+ // type — so the title is all a buyer gets. Some merchants write that title
347
+ // for shoppers ("Buy 2 get 5% OFF"); this one named them for the back
348
+ // office: Student, ELSPETBF2024, DL230. Thirteen of those stacked above
349
+ // the products, saying nothing and offering nothing to do.
350
+ //
351
+ // A percentage, a minimum quantity or an end date makes it an offer. A
352
+ // bare internal label does not, whatever it is called.
353
+ .filter((d) => d.percentOff !== undefined || d.minQuantity !== undefined || d.endsAt);
354
+ return {
355
+ ...(promotions.length ? { promotions } : {}),
356
+ ...(snap.discountsBlocked ? { promotionsBlocked: snap.discountsBlocked } : {}),
357
+ };
358
+ };
359
+ const money = (minor, currency) => {
360
+ const d = currency === 'JPY' || currency === 'KRW' ? 0 : 2;
361
+ return `${(minor / 10 ** d).toFixed(d)} ${currency}`;
362
+ };
363
+ // What a lens looks like in one line — the shape the demo conversation used:
364
+ // name | replacement cycle | price | colours | rating.
365
+ const optionValues = (p, nameLike) => p.options.find((o) => nameLike.test(o.name))?.values ?? [];
366
+ // Prescription-ish option names, matched loosely: this store calls them `Left`,
367
+ // `Right` and `Diopter`, and the next one will call them something else again.
368
+ const POWER = /度数|prescription|power|diopter|sph|left|right/i;
369
+ // Whether a product is a PAIR or a single lens, which decides how many
370
+ // prescriptions the buyer has to give. Moody encodes it two ways at once — a
371
+ // `-L`/`-R` suffix on the name, and one option instead of two — so read the
372
+ // options (the structural fact) and use the name only to explain it.
373
+ const eyeShape = (p) => {
374
+ const power = p.options.filter((o) => POWER.test(o.name));
375
+ if (power.length >= 2)
376
+ return 'pair';
377
+ if (power.length === 1)
378
+ return 'single';
379
+ return 'unknown';
380
+ };
381
+ /**
382
+ * An accessory names the thing it belongs to: "Rake FOR ELS PET Spaceship",
383
+ * "Liners for litter tray", "Replaceable Accessories". A shopper opening a
384
+ * storefront wants the litter box before the sponge strip that fits it.
385
+ *
386
+ * A heuristic on the TITLE, because the structured fields cannot answer it —
387
+ * this shop files all 30 products as "pet supplies". Checked against the real
388
+ * catalogue before it went in: 11 main products, 19 accessories, split where a
389
+ * person would split them.
390
+ */
391
+ const ACCESSORY = /\bfor\b|\baccessor|\bliner|\brefill|\bspare\b|\breplacement\b|\breplaceable\b/i;
392
+ export const isAccessory = (title) => ACCESSORY.test(title);
393
+ const productSummary = (p, imageProxy) => ({
394
+ name: p.title,
395
+ handle: p.handle,
396
+ // The lens's page on Moody's own site — reviews, the size guide, the photos
397
+ // we do not carry. A buyer who wants to read more should land there, not on a
398
+ // *.myshopify.com address they have never heard of.
399
+ url: p.url,
400
+ price: p.fromPriceMinor !== undefined ? money(p.fromPriceMinor, p.currency) : undefined,
401
+ // What the buyer must still choose, in the store's own words. Not "colours":
402
+ // at Moody the colour is the product name, and inventing a colour axis that
403
+ // does not exist would have the assistant asking an unanswerable question.
404
+ choices: p.options.map((o) => ({ name: o.name, count: o.values.length })),
405
+ // Present ONLY when the store actually has a prescription axis.
406
+ //
407
+ // These are derived from the option names, so a shop that sells litter boxes
408
+ // got `eyes: 'unknown'` and `prescriptions: []` on every product — which does
409
+ // not read as "not applicable", it reads as "this store has prescriptions and
410
+ // I could not find them", and invites the assistant to ask a buyer for a
411
+ // diopter they do not have. Absent says what empty could not.
412
+ ...(() => {
413
+ const power = p.options.filter((o) => POWER.test(o.name));
414
+ return power.length ? { eyes: eyeShape(p), prescriptions: power[0]?.values ?? [] } : {};
415
+ })(),
416
+ type: p.productType || undefined,
417
+ tags: p.tags,
418
+ image: p.imageUrl && imageProxy ? `${imageProxy}${encodeURIComponent(p.imageUrl)}` : p.imageUrl,
419
+ // The direct CDN url, for a host whose CSP allows it (and for a reader that
420
+ // wants the real address rather than our proxy's).
421
+ imageCdn: p.imageUrl,
422
+ });
423
+ const notLoaded = (deps) => {
424
+ if (deps.catalog.list().length > 0)
425
+ return null;
426
+ const err = deps.catalog.current.lastError;
427
+ return {
428
+ structured: { view: 'error', error: 'catalog-unavailable', detail: err ?? null },
429
+ text: `The ${deps.merchantName} catalog is not loaded yet${err ? ` (${err})` : ''}. Try again in a moment.`,
430
+ };
431
+ };
432
+ export const callTool = async (deps, name, args) => {
433
+ if (name === deps.tools.list) {
434
+ const blocked = notLoaded(deps);
435
+ if (blocked)
436
+ return blocked;
437
+ const q = String(args.query ?? '')
438
+ .trim()
439
+ .toLowerCase();
440
+ const maxPrice = typeof args.maxPrice === 'number' ? args.maxPrice : undefined;
441
+ const limit = typeof args.limit === 'number' && args.limit > 0 ? Math.min(args.limit, 50) : 20;
442
+ let items = deps.catalog.list();
443
+ // Widened, and never empty when the shelf is not.
444
+ //
445
+ // The naive substring match answered "brown lenses" and "棕色系" with
446
+ // nothing, so the assistant tried again, and again, each attempt drawing its
447
+ // own card — the buyer watched three storefronts stack up in the transcript
448
+ // for one question. The store is 382 products; a search over it should
449
+ // return the near misses, not a wall.
450
+ let relaxedFrom;
451
+ if (q) {
452
+ const hay = (p) => [p.title, p.handle, p.productType, p.description, ...p.tags].join(' ').toLowerCase();
453
+ // WHERE the match landed decides the order, on BOTH paths below.
454
+ //
455
+ // The ranking used to exist only on the relaxed path — which is the one a
456
+ // single word almost never takes. "grey" IS a substring of several brown
457
+ // lenses' descriptions, so it matched exactly, skipped the ranking
458
+ // entirely, and led a search for grey with Oolong Brown and Barley Tea.
459
+ // The comment further down predicted this exact failure and the fix was
460
+ // applied to the wrong branch.
461
+ const nameFirst = (list, terms) => {
462
+ const inName = (p) => terms.some((t) => `${p.title} ${p.handle} ${p.tags.join(' ')}`.toLowerCase().includes(t));
463
+ return [...list.filter(inName), ...list.filter((p) => !inName(p))];
464
+ };
465
+ const exact = items.filter((p) => hay(p).includes(q));
466
+ if (exact.length > 0) {
467
+ items = nameFirst(exact, [q]);
468
+ }
469
+ else {
470
+ // Any word, then any colour word we can map. "Brown lenses" keeps
471
+ // "brown"; a Chinese colour term becomes the English one this store
472
+ // actually names its products with.
473
+ //
474
+ // Ranked by WHERE the match landed, because the colour is the product
475
+ // NAME here and the descriptions are full of other colours: "Matcha
476
+ // Green" says "hojicha-brown iris details", so an unranked search for
477
+ // brown puts a green lens in the buyer's first row.
478
+ // A single CJK character IS a word (灰 = grey), so the one-letter filter
479
+ // only applies to alphabetic ones, where it drops "a"/"of".
480
+ // Split on every separator a picker or a person actually types, the
481
+ // full-width ones included: "棕色系(自然)" arrives whole, and one
482
+ // unhandled bracket is the difference between a colour family and all
483
+ // 382 products.
484
+ const words = q
485
+ .split(/[\s,,、;;::/|·()()[\]【】{}"'“”‘’]+/)
486
+ .filter((w) => w.length > 1 || /[一-鿿]/.test(w));
487
+ const terms = [
488
+ ...new Set([...words, ...words.flatMap((w) => COLOUR_WORDS[colourKey(w)] ?? [])]),
489
+ ];
490
+ const loose = terms.length
491
+ ? items.filter((p) => terms.some((t) => hay(p).includes(t)))
492
+ : [];
493
+ if (loose.length > 0) {
494
+ items = nameFirst(loose, terms);
495
+ relaxedFrom = q;
496
+ }
497
+ else {
498
+ // Still nothing. Show the shelf rather than an empty card: the next
499
+ // move is the buyer narrowing it, and they cannot narrow nothing.
500
+ relaxedFrom = q;
501
+ }
502
+ }
503
+ }
504
+ if (maxPrice !== undefined) {
505
+ items = items.filter((p) => {
506
+ if (p.fromPriceMinor === undefined)
507
+ return false;
508
+ const d = p.currency === 'JPY' || p.currency === 'KRW' ? 0 : 2;
509
+ return p.fromPriceMinor / 10 ** d <= maxPrice;
510
+ });
511
+ }
512
+ // Accessories after the things they attach to, and only when the buyer did
513
+ // not ask for something specific — a search for "liner" should still put
514
+ // liners first. Stable, so within each group the shop's own order survives.
515
+ const ranked = q
516
+ ? items
517
+ : [
518
+ ...items.filter((p) => !isAccessory(p.title)),
519
+ ...items.filter((p) => isAccessory(p.title)),
520
+ ];
521
+ const products = ranked.slice(0, limit).map((p) => productSummary(p, deps.imageProxy));
522
+ const lines = products.map((p) => `- **${p.name}**${p.price ? ` · ${p.price}` : ''}` +
523
+ `${p.eyes === 'pair' ? ' · pair (needs both eyes)' : p.eyes === 'single' ? ' · single lens' : ''}`);
524
+ return {
525
+ structured: {
526
+ view: 'products',
527
+ merchant: deps.merchantName,
528
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
529
+ toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
530
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
531
+ products,
532
+ total: items.length,
533
+ // The shop's live offers. They are attached to no product, so without
534
+ // this the card quotes a price the buyer beats at checkout and never
535
+ // mentions the deal that would have made them buy two.
536
+ ...promoPayload(deps),
537
+ // What the card offers as filter chips, computed from THESE results
538
+ // rather than a list we invented. The buyer narrows inside the card, so
539
+ // narrowing costs no tool call — and a tool call is what put three
540
+ // storefronts in the transcript for one question.
541
+ facets: facetsOf(products),
542
+ // Say when the answer is broader than what was asked, so the card can
543
+ // label it and the assistant does not read it as an exact match.
544
+ ...(relaxedFrom ? { relaxedFrom } : {}),
545
+ },
546
+ text: [
547
+ products.length
548
+ ? `${products.length} of ${items.length} ${deps.vocab.nounPlural}:\n${lines.join('\n')}`
549
+ : 'This store has no products loaded right now.',
550
+ // The offers, in one line. Shopify applies them at its own checkout
551
+ // whether or not anyone mentions them — but a buyer who does not know
552
+ // about "buy 2 get 5% off" buys one.
553
+ deps.catalog.current.discounts.length
554
+ ? `Store offers right now: ${deps.catalog.current.discounts.map((d) => d.title).join(' · ')} (applied automatically at checkout).`
555
+ : '',
556
+ // Tell the assistant it already has the near misses. Otherwise it reads
557
+ // a narrow result as a failure and searches again, and every retry draws
558
+ // another storefront card in front of the buyer.
559
+ relaxedFrom
560
+ ? `(Nothing matched "${relaxedFrom}" exactly — these are the closest. Do NOT search again; ask the buyer to narrow it, or show these.)`
561
+ : '',
562
+ ]
563
+ .filter(Boolean)
564
+ .join('\n'),
565
+ };
566
+ }
567
+ if (name === deps.tools.get) {
568
+ const blocked = notLoaded(deps);
569
+ if (blocked)
570
+ return blocked;
571
+ const p = deps.catalog.byHandleOrTitle(String(args.product ?? ''));
572
+ if (!p) {
573
+ return {
574
+ structured: { view: 'error', error: 'no-such-product' },
575
+ text: `No ${deps.vocab.noun} called "${String(args.product ?? '')}". Try ${deps.tools.list}.`,
576
+ };
577
+ }
578
+ // Variants are loaded HERE, for this one product — the catalogue holds none.
579
+ const variants = await deps.catalog.variantsFor(p.handle);
580
+ const inStock = variants.filter((v) => v.available);
581
+ return {
582
+ structured: {
583
+ view: 'product',
584
+ merchant: deps.merchantName,
585
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
586
+ toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
587
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
588
+ product: {
589
+ ...productSummary(p, deps.imageProxy),
590
+ description: p.description,
591
+ images: p.images,
592
+ currency: p.currency,
593
+ options: p.options,
594
+ // Capped in the payload: 676 rows is not a thing to put in a tool
595
+ // result, and the options above are what the buyer chooses from.
596
+ variantCount: variants.length,
597
+ inStockCount: inStock.length,
598
+ // WHICH combinations can actually be bought, as option maps:
599
+ // [{Left:'-5.75', Right:'-5.50'}]. This is what the picker needs and
600
+ // the option lists above cannot express — "Left -5.75 is offered" and
601
+ // "Right -5.50 is offered" says nothing about whether that PAIR exists,
602
+ // and at this store 674 of the 676 pairs do not. Without it the buyer
603
+ // picks from 26×26 chips and is told "sold out" almost every time.
604
+ //
605
+ // Small in practice BECAUSE it is in-stock only: 2 rows for Matcha
606
+ // Green, 26 for a fully stocked single. Capped anyway — a fully stocked
607
+ // pair would be 676 rows in a tool result the model also reads — and
608
+ // the flag tells the picker to stop filtering rather than filter on a
609
+ // truncated list, which would hide buyable prescriptions.
610
+ ...inStockCombos(variants),
611
+ // In-stock FIRST. A pair is Left × Right in the merchant's own order,
612
+ // so the first 30 rows are the whole 0.00 row and a slice of -1.00 —
613
+ // and at this store those are sold out while the stocked ones sit deep
614
+ // in the tail. Showing the first 30 answers "what can I buy?" with
615
+ // thirty things you cannot, which is worse than showing none.
616
+ variants: [...inStock, ...variants.filter((v) => !v.available)].slice(0, 30).map((v) => ({
617
+ variantId: v.variantId,
618
+ title: v.title,
619
+ price: money(v.priceMinor, p.currency),
620
+ wasPrice: v.compareAtMinor ? money(v.compareAtMinor, p.currency) : undefined,
621
+ available: v.available,
622
+ options: v.options,
623
+ })),
624
+ },
625
+ },
626
+ text: [
627
+ `**${p.title}** — ${p.fromPriceMinor !== undefined ? money(p.fromPriceMinor, p.currency) : ''}`,
628
+ ...p.options.map((o) => `${o.name}: ${o.values.join(' / ')}`),
629
+ // Say it plainly. Every value above is offered; far fewer are stocked,
630
+ // and an assistant reading only the option lists will confidently name a
631
+ // prescription that cannot be bought.
632
+ inStock.length === 0
633
+ ? `OUT OF STOCK: none of the ${variants.length} combinations is in stock right now.`
634
+ : inStock.length < variants.length
635
+ ? `In stock: ${inStock.length} of ${variants.length} combinations — the values listed above are what the store OFFERS, not what it has. Check with ${deps.tools.checkout} before promising one.`
636
+ : '',
637
+ p.description,
638
+ ]
639
+ .filter(Boolean)
640
+ .join('\n'),
641
+ };
642
+ }
643
+ if (name === deps.tools.checkout) {
644
+ const blocked = notLoaded(deps);
645
+ if (blocked)
646
+ return blocked;
647
+ const p = deps.catalog.byHandleOrTitle(String(args.product ?? ''));
648
+ if (!p) {
649
+ return {
650
+ structured: { view: 'error', error: 'no-such-product' },
651
+ text: `No ${deps.vocab.noun} called "${String(args.product ?? '')}".`,
652
+ };
653
+ }
654
+ const choices = {};
655
+ for (const [k, v] of Object.entries((args.options ?? {}))) {
656
+ if (typeof v === 'string' || typeof v === 'number')
657
+ choices[k] = String(v);
658
+ }
659
+ const variants = await deps.catalog.variantsFor(p.handle);
660
+ const variant = resolveVariant(variants, choices);
661
+ if (!variant) {
662
+ // Deliberately NOT a best guess. The options on a lens are the colour and
663
+ // the prescription; getting the second one wrong sends someone a lens they
664
+ // cannot wear, and a plausible guess is exactly how that happens.
665
+ //
666
+ // In the store's own words, and only what the store actually offers. Both
667
+ // readers below need that: the card pre-selects from it, and the prose has
668
+ // to call `-2.0` against a `-2.00` option ANSWERED — telling an assistant
669
+ // a question is still open when the buyer already answered it is how the
670
+ // buyer gets asked their prescription twice.
671
+ const given = snapChoices(p.options, choices);
672
+ return {
673
+ structured: {
674
+ view: 'needs-options',
675
+ merchant: deps.merchantName,
676
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
677
+ toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
678
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
679
+ product: productSummary(p, deps.imageProxy),
680
+ options: p.options,
681
+ eyes: eyeShape(p),
682
+ given,
683
+ // The picker opens on THIS payload when an assistant calls checkout
684
+ // straight from the conversation, so it needs the same stock list the
685
+ // product view gets — otherwise the buyer is offered 26x26 chips and
686
+ // told "sold out" on almost every one of them.
687
+ ...inStockCombos(variants),
688
+ },
689
+ text: `**${p.title}** needs ${p.options.length === 1 ? 'one choice' : `${p.options.length} choices`}${eyeShape(p) === 'pair' ? ' — it is a PAIR, so both eyes' : ''}:\n${p.options
690
+ .map((o) => `- ${o.name}${given[o.name] ? ` (given: ${given[o.name]})` : ' ← still needed'}: ${o.values.join(' / ')}`)
691
+ .join('\n')}\nAsk the buyer for what is missing. Never assume one answer carries to another option, and never round to a nearby value — the wrong one is something they cannot use.`,
692
+ };
693
+ }
694
+ if (!variant.available) {
695
+ return {
696
+ structured: {
697
+ view: 'out-of-stock',
698
+ product: productSummary(p, deps.imageProxy),
699
+ variant: variant.title,
700
+ },
701
+ text: `**${p.title} — ${variant.title}** is out of stock right now.`,
702
+ };
703
+ }
704
+ // Bounded: a cart link is a public URL, and an unbounded quantity in one is
705
+ // a link that looks like this store sells 10,000 of something.
706
+ const quantity = typeof args.quantity === 'number' && args.quantity > 0
707
+ ? Math.min(Math.floor(args.quantity), 20)
708
+ : 1;
709
+ // Send them to the checkout on the SAME host as the product page they were
710
+ // just looking at, not to `<handle>.myshopify.com`. Taken from the product
711
+ // rather than from config because that is where the shop's own domain
712
+ // already is, and because the two links must not disagree: a card offering
713
+ // moodycosmetics.com/products/… and a Buy leading somewhere else reads as a
714
+ // swapped link, which is precisely what buyers are taught to distrust.
715
+ // Falls back to the myshopify host, which still works, when the storefront
716
+ // domain is unknown.
717
+ const storefrontDomain = p.url ? new URL(p.url).host : undefined;
718
+ const url = cartPermalink({ ...deps.shopify, ...(storefrontDomain ? { storefrontDomain } : {}) }, [{ variantId: variant.variantId, quantity }], {
719
+ attributes: {
720
+ Source: 'Curless',
721
+ ...(deps.assistant ? { Assistant: deps.assistant } : {}),
722
+ },
723
+ });
724
+ return {
725
+ structured: {
726
+ view: 'checkout',
727
+ merchant: deps.merchantName,
728
+ ...(deps.logoUrl ? { logoUrl: deps.logoUrl } : {}),
729
+ toolNames: { get: deps.tools.get, checkout: deps.tools.checkout },
730
+ vocab: { noun: deps.vocab.noun, nounPlural: deps.vocab.nounPlural },
731
+ product: productSummary(p, deps.imageProxy),
732
+ variant: {
733
+ title: variant.title,
734
+ options: variant.options,
735
+ price: money(variant.priceMinor, p.currency),
736
+ },
737
+ quantity,
738
+ total: money(variant.priceMinor * quantity, p.currency),
739
+ checkoutUrl: url,
740
+ },
741
+ text: `**${p.title} — ${variant.title}** ×${quantity} · ${money(variant.priceMinor * quantity, p.currency)}\n\nPay on ${deps.merchantName}'s checkout: ${url}\n\n(Payment and the order itself happen on Moody's own Shopify checkout.)`,
742
+ };
743
+ }
744
+ if (name === deps.tools.lookupOrder) {
745
+ const res = await lookupShopifyOrder(deps.shopify, {
746
+ orderNumber: String(args.orderNumber ?? ''),
747
+ email: String(args.email ?? ''),
748
+ });
749
+ if (!res.found) {
750
+ // One answer for "no such order" and for "wrong email" — see the adapter.
751
+ return {
752
+ structured: { view: 'order-not-found' },
753
+ text: 'No order matches that order number and email. Check both against the confirmation email.',
754
+ };
755
+ }
756
+ const lines = res.lines
757
+ .map((l) => `- ${l.quantity}× ${l.title}${l.variantTitle ? ` (${l.variantTitle})` : ''}`)
758
+ .join('\n');
759
+ return {
760
+ structured: { view: 'order', merchant: deps.merchantName, order: res },
761
+ text: [
762
+ `**${res.name}** · ${money(res.totalMinor, res.currency)} · ${res.financialStatus}${res.fulfillmentStatus ? ` · ${res.fulfillmentStatus}` : ''}`,
763
+ lines,
764
+ res.trackingUrls.length ? `Tracking: ${res.trackingUrls.join(' ')}` : '',
765
+ res.statusUrl ? `Order status page: ${res.statusUrl}` : '',
766
+ ]
767
+ .filter(Boolean)
768
+ .join('\n'),
769
+ };
770
+ }
771
+ return { structured: { view: 'error', error: 'unknown-tool' }, text: `Unknown tool: ${name}` };
772
+ };
773
+ /**
774
+ * `productSummary` under test. Exported rather than reached through a live
775
+ * catalogue because the thing worth pinning is the SHAPE — which fields exist
776
+ * for which store — and a fetch mock would bury that behind plumbing.
777
+ */
778
+ export const productSummaryForTest = (p, imageProxy) => productSummary(p, imageProxy);
779
+ //# sourceMappingURL=tools.js.map