@kuralle-agents/commerce 0.8.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +83 -0
- package/dist/cart.d.ts +16 -0
- package/dist/cart.js +120 -0
- package/dist/catalog.d.ts +3 -0
- package/dist/catalog.js +25 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/order.d.ts +47 -0
- package/dist/order.js +110 -0
- package/dist/types.d.ts +61 -0
- package/dist/types.js +24 -0
- package/dist/whatsapp.d.ts +29 -0
- package/dist/whatsapp.js +17 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
package/README.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# @kuralle-agents/commerce
|
|
2
|
+
|
|
3
|
+
Conversational-commerce primitives for Kuralle agents: typed carts, an
|
|
4
|
+
idempotent `create_order` tool, and channel mapping helpers. Tools return
|
|
5
|
+
data only — flows own the conversation; the messaging layer owns rendering.
|
|
6
|
+
|
|
7
|
+
## What's inside
|
|
8
|
+
|
|
9
|
+
- **Money / Product / Cart / Order types** — integer minor units, never floats.
|
|
10
|
+
- **`ProductCatalog`** — the host-implemented product source (your backend,
|
|
11
|
+
MCP server, or API). `createInMemoryCatalog(products)` for dev/tests.
|
|
12
|
+
- **`createCartTools({ catalog })`** — durable `product_search`, `cart_add`,
|
|
13
|
+
`cart_remove`, `cart_view` tools. The cart lives in flow state
|
|
14
|
+
(`runState.state.__cart`), so it persists with the conversation and is
|
|
15
|
+
visible to flow nodes and validators.
|
|
16
|
+
- **`createOrderTool({ submit, ledger? })`** — idempotent order placement:
|
|
17
|
+
- a **content key** (hash of session + cart lines) dedupes identical
|
|
18
|
+
resubmissions across turns — "place my order" twice returns the same
|
|
19
|
+
order instead of charging twice;
|
|
20
|
+
- **in-flight coalescing** collapses concurrent submissions;
|
|
21
|
+
- the durable effect log still covers replay of the same call.
|
|
22
|
+
Provide a durable `OrderLedger` (Redis/Postgres/DO) in production; the
|
|
23
|
+
default ledger is in-memory.
|
|
24
|
+
- **`toWhatsAppProductList(productsOrCart, opts)`** — renders products as a
|
|
25
|
+
WhatsApp multi-product message payload, structurally compatible with
|
|
26
|
+
`@kuralle-agents/messaging-meta`'s `client.sendProductList`.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { defineAgent, defineFlow } from '@kuralle-agents/core';
|
|
32
|
+
import {
|
|
33
|
+
createCartTools,
|
|
34
|
+
createOrderTool,
|
|
35
|
+
createInMemoryCatalog,
|
|
36
|
+
} from '@kuralle-agents/commerce';
|
|
37
|
+
|
|
38
|
+
const catalog = createInMemoryCatalog(products); // or your ProductCatalog impl
|
|
39
|
+
const cartTools = createCartTools({ catalog });
|
|
40
|
+
const createOrder = createOrderTool({
|
|
41
|
+
submit: async ({ items, total, contentKey }) => {
|
|
42
|
+
const order = await myBackend.createOrder({ items, total, idempotencyKey: contentKey });
|
|
43
|
+
return { orderId: order.id };
|
|
44
|
+
},
|
|
45
|
+
ledger: myDurableLedger, // Redis/Postgres in production
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const agent = defineAgent({
|
|
49
|
+
id: 'shop',
|
|
50
|
+
instructions: 'You help customers order from our store.',
|
|
51
|
+
globalTools: { product_search: cartTools.product_search, cart_view: cartTools.cart_view },
|
|
52
|
+
tools: { cart_add: cartTools.cart_add, cart_remove: cartTools.cart_remove, create_order: createOrder },
|
|
53
|
+
flows: [checkoutFlow], // gate create_order behind an explicit confirm step
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Showing products natively on WhatsApp (requires a Meta Commerce Manager
|
|
58
|
+
catalog and `retailerId` on your products):
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { toWhatsAppProductList } from '@kuralle-agents/commerce';
|
|
62
|
+
|
|
63
|
+
const results = await catalog.search('chocolate cake');
|
|
64
|
+
await whatsapp.sendProductList(
|
|
65
|
+
to,
|
|
66
|
+
toWhatsAppProductList(results, {
|
|
67
|
+
catalogId: META_CATALOG_ID,
|
|
68
|
+
header: 'Our cakes',
|
|
69
|
+
body: 'Tap to view and add to your order.',
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Inbound WhatsApp orders (user taps "Add to cart" in the native catalog UI)
|
|
75
|
+
arrive via `parseInboundOrder` from `@kuralle-agents/messaging-meta/whatsapp`.
|
|
76
|
+
|
|
77
|
+
## Design rules honored
|
|
78
|
+
|
|
79
|
+
- Tools return data only; confirmation wording comes from flow nodes.
|
|
80
|
+
- Consequential tools (`cart_add`, `create_order`) stay flow-gated — never in
|
|
81
|
+
`globalTools`.
|
|
82
|
+
- `create_order` pairs with `needsApproval` or a flow confirm gate for
|
|
83
|
+
human-in-the-loop checkout.
|
package/dist/cart.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AnyTool, ToolContext } from '@kuralle-agents/core';
|
|
2
|
+
import type { Cart, CartItem, ProductCatalog } from './types.js';
|
|
3
|
+
/** Durable home of the cart: flow state, persisted by the runtime at turn boundaries. */
|
|
4
|
+
export declare const CART_STATE_KEY = "__cart";
|
|
5
|
+
export declare function readCart(ctx: ToolContext): Cart;
|
|
6
|
+
export declare function writeCart(ctx: ToolContext, items: CartItem[]): Cart;
|
|
7
|
+
export declare function clearCart(ctx: ToolContext): void;
|
|
8
|
+
export interface CartToolsOptions {
|
|
9
|
+
catalog: ProductCatalog;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Durable cart + catalog tools. Data-only returns (rendering is the channel
|
|
13
|
+
* layer's job). `product_search` and `cart_view` are safe for `globalTools`;
|
|
14
|
+
* keep `cart_add`/`cart_remove` flow-gated or model-visible per your design.
|
|
15
|
+
*/
|
|
16
|
+
export declare function createCartTools(options: CartToolsOptions): Record<string, AnyTool>;
|
package/dist/cart.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineTool } from '@kuralle-agents/core';
|
|
3
|
+
import { cartTotal, formatMoney } from './types.js';
|
|
4
|
+
/** Durable home of the cart: flow state, persisted by the runtime at turn boundaries. */
|
|
5
|
+
export const CART_STATE_KEY = '__cart';
|
|
6
|
+
function requireCtx(ctx) {
|
|
7
|
+
if (!ctx?.runState) {
|
|
8
|
+
throw new Error('cart tools require a run context (runState)');
|
|
9
|
+
}
|
|
10
|
+
return ctx;
|
|
11
|
+
}
|
|
12
|
+
export function readCart(ctx) {
|
|
13
|
+
const raw = ctx.runState.state[CART_STATE_KEY];
|
|
14
|
+
if (raw && typeof raw === 'object' && Array.isArray(raw.items)) {
|
|
15
|
+
return raw;
|
|
16
|
+
}
|
|
17
|
+
return { items: [], updatedAt: new Date(0).toISOString() };
|
|
18
|
+
}
|
|
19
|
+
export function writeCart(ctx, items) {
|
|
20
|
+
const cart = { items, updatedAt: new Date().toISOString() };
|
|
21
|
+
ctx.runState.state[CART_STATE_KEY] = cart;
|
|
22
|
+
return cart;
|
|
23
|
+
}
|
|
24
|
+
export function clearCart(ctx) {
|
|
25
|
+
delete ctx.runState.state[CART_STATE_KEY];
|
|
26
|
+
}
|
|
27
|
+
function cartSummary(cart) {
|
|
28
|
+
return {
|
|
29
|
+
items: cart.items,
|
|
30
|
+
itemCount: cart.items.reduce((sum, item) => sum + item.quantity, 0),
|
|
31
|
+
total: cart.items.length > 0 ? cartTotal(cart.items) : null,
|
|
32
|
+
totalFormatted: cart.items.length > 0 ? formatMoney(cartTotal(cart.items)) : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const addInput = z.object({
|
|
36
|
+
productId: z.string().describe('Product id from the catalog (use product_search first)'),
|
|
37
|
+
quantity: z.number().int().min(1).default(1),
|
|
38
|
+
});
|
|
39
|
+
const removeInput = z.object({
|
|
40
|
+
productId: z.string(),
|
|
41
|
+
quantity: z
|
|
42
|
+
.union([z.number().int().min(1), z.null()])
|
|
43
|
+
.describe('How many to remove; null removes the line entirely'),
|
|
44
|
+
});
|
|
45
|
+
const searchInput = z.object({
|
|
46
|
+
query: z.string().describe('What the user is looking for, e.g. "chocolate cake"'),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Durable cart + catalog tools. Data-only returns (rendering is the channel
|
|
50
|
+
* layer's job). `product_search` and `cart_view` are safe for `globalTools`;
|
|
51
|
+
* keep `cart_add`/`cart_remove` flow-gated or model-visible per your design.
|
|
52
|
+
*/
|
|
53
|
+
export function createCartTools(options) {
|
|
54
|
+
const { catalog } = options;
|
|
55
|
+
const product_search = defineTool({
|
|
56
|
+
name: 'product_search',
|
|
57
|
+
description: 'Search the product catalog. Returns matching products with ids and prices.',
|
|
58
|
+
input: searchInput,
|
|
59
|
+
execute: async (args) => {
|
|
60
|
+
const products = await catalog.search(args.query);
|
|
61
|
+
return { products, count: products.length };
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const cart_add = defineTool({
|
|
65
|
+
name: 'cart_add',
|
|
66
|
+
description: 'Add a product to the cart by product id.',
|
|
67
|
+
input: addInput,
|
|
68
|
+
execute: async (args, ctx) => {
|
|
69
|
+
const toolCtx = requireCtx(ctx);
|
|
70
|
+
const product = await catalog.get(args.productId);
|
|
71
|
+
if (!product) {
|
|
72
|
+
return { error: 'product_not_found', productId: args.productId };
|
|
73
|
+
}
|
|
74
|
+
if (product.stock !== undefined && product.stock < args.quantity) {
|
|
75
|
+
return { error: 'insufficient_stock', productId: args.productId, available: product.stock };
|
|
76
|
+
}
|
|
77
|
+
const cart = readCart(toolCtx);
|
|
78
|
+
const existing = cart.items.find((item) => item.productId === args.productId);
|
|
79
|
+
const items = existing
|
|
80
|
+
? cart.items.map((item) => item.productId === args.productId
|
|
81
|
+
? { ...item, quantity: item.quantity + args.quantity }
|
|
82
|
+
: item)
|
|
83
|
+
: [
|
|
84
|
+
...cart.items,
|
|
85
|
+
{
|
|
86
|
+
productId: product.id,
|
|
87
|
+
title: product.title,
|
|
88
|
+
unitPrice: product.price,
|
|
89
|
+
quantity: args.quantity,
|
|
90
|
+
retailerId: product.retailerId,
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
return cartSummary(writeCart(toolCtx, items));
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
const cart_remove = defineTool({
|
|
97
|
+
name: 'cart_remove',
|
|
98
|
+
description: 'Remove a product (or reduce its quantity) from the cart.',
|
|
99
|
+
input: removeInput,
|
|
100
|
+
execute: async (args, ctx) => {
|
|
101
|
+
const toolCtx = requireCtx(ctx);
|
|
102
|
+
const cart = readCart(toolCtx);
|
|
103
|
+
const items = cart.items.flatMap((item) => {
|
|
104
|
+
if (item.productId !== args.productId)
|
|
105
|
+
return [item];
|
|
106
|
+
if (args.quantity == null || args.quantity >= item.quantity)
|
|
107
|
+
return [];
|
|
108
|
+
return [{ ...item, quantity: item.quantity - args.quantity }];
|
|
109
|
+
});
|
|
110
|
+
return cartSummary(writeCart(toolCtx, items));
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const cart_view = defineTool({
|
|
114
|
+
name: 'cart_view',
|
|
115
|
+
description: 'View the current cart contents and total.',
|
|
116
|
+
input: z.object({}),
|
|
117
|
+
execute: async (_args, ctx) => cartSummary(readCart(requireCtx(ctx))),
|
|
118
|
+
});
|
|
119
|
+
return { product_search, cart_add, cart_remove, cart_view };
|
|
120
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** In-memory catalog for dev/tests. Production: implement `ProductCatalog` over your backend. */
|
|
2
|
+
export function createInMemoryCatalog(products) {
|
|
3
|
+
const byId = new Map(products.map((product) => [product.id, product]));
|
|
4
|
+
return {
|
|
5
|
+
async get(productId) {
|
|
6
|
+
return byId.get(productId) ?? null;
|
|
7
|
+
},
|
|
8
|
+
async search(query, opts) {
|
|
9
|
+
const limit = opts?.limit ?? 10;
|
|
10
|
+
const tokens = query
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.split(/[^a-z0-9]+/)
|
|
13
|
+
.filter((token) => token.length > 1);
|
|
14
|
+
const scored = products
|
|
15
|
+
.map((product) => {
|
|
16
|
+
const haystack = `${product.title} ${product.description ?? ''}`.toLowerCase();
|
|
17
|
+
const hits = tokens.filter((token) => haystack.includes(token)).length;
|
|
18
|
+
return { product, hits };
|
|
19
|
+
})
|
|
20
|
+
.filter((entry) => entry.hits > 0)
|
|
21
|
+
.sort((a, b) => b.hits - a.hits);
|
|
22
|
+
return scored.slice(0, limit).map((entry) => entry.product);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export type { Money, Product, CartItem, Cart, Order, OrderStatus, ProductCatalog, } from './types.js';
|
|
2
|
+
export { cartTotal, formatMoney } from './types.js';
|
|
3
|
+
export { createInMemoryCatalog } from './catalog.js';
|
|
4
|
+
export { createCartTools, readCart, writeCart, clearCart, CART_STATE_KEY, type CartToolsOptions, } from './cart.js';
|
|
5
|
+
export { createOrderTool, createInMemoryOrderLedger, orderContentKey, type OrderLedger, type SubmitOrderArgs, type CreateOrderToolOptions, } from './order.js';
|
|
6
|
+
export { toWhatsAppProductList, type WhatsAppProductListOptions } from './whatsapp.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { cartTotal, formatMoney } from './types.js';
|
|
2
|
+
export { createInMemoryCatalog } from './catalog.js';
|
|
3
|
+
export { createCartTools, readCart, writeCart, clearCart, CART_STATE_KEY, } from './cart.js';
|
|
4
|
+
export { createOrderTool, createInMemoryOrderLedger, orderContentKey, } from './order.js';
|
|
5
|
+
export { toWhatsAppProductList } from './whatsapp.js';
|
package/dist/order.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { AnyTool } from '@kuralle-agents/core';
|
|
2
|
+
import { cartTotal } from './types.js';
|
|
3
|
+
import type { CartItem, Order } from './types.js';
|
|
4
|
+
/**
|
|
5
|
+
* Idempotent order creation.
|
|
6
|
+
*
|
|
7
|
+
* Two dedupe layers, because the durable effect log only covers replays of
|
|
8
|
+
* the SAME tool call — it cannot stop a fresh "place my order" turn from
|
|
9
|
+
* submitting the identical cart twice:
|
|
10
|
+
*
|
|
11
|
+
* 1. Content key: stable hash of (sessionId + sorted cart lines). The
|
|
12
|
+
* ledger maps content key → order; an identical resubmission returns
|
|
13
|
+
* the existing order instead of re-submitting.
|
|
14
|
+
* 2. In-flight coalescing: concurrent submissions of the same content key
|
|
15
|
+
* share one `submit` promise.
|
|
16
|
+
*/
|
|
17
|
+
export interface OrderLedger {
|
|
18
|
+
get(contentKey: string): Promise<Order | null>;
|
|
19
|
+
/** Store the order for its content key; first write wins. Returns the stored order. */
|
|
20
|
+
putIfAbsent(contentKey: string, order: Order): Promise<Order>;
|
|
21
|
+
}
|
|
22
|
+
export declare function createInMemoryOrderLedger(): OrderLedger;
|
|
23
|
+
export declare function orderContentKey(sessionId: string, items: CartItem[]): string;
|
|
24
|
+
export interface SubmitOrderArgs {
|
|
25
|
+
sessionId: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
items: CartItem[];
|
|
28
|
+
total: ReturnType<typeof cartTotal>;
|
|
29
|
+
contentKey: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CreateOrderToolOptions {
|
|
33
|
+
/** Host order submission (your backend / payment flow). Must return the order id. */
|
|
34
|
+
submit: (args: SubmitOrderArgs) => Promise<{
|
|
35
|
+
orderId: string;
|
|
36
|
+
metadata?: Record<string, unknown>;
|
|
37
|
+
}>;
|
|
38
|
+
/** Cross-turn dedupe ledger. Default: in-memory (single process — use a durable ledger in production). */
|
|
39
|
+
ledger?: OrderLedger;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Durable, idempotent `create_order` tool. Reads the cart from flow state,
|
|
43
|
+
* refuses empty carts, dedupes identical resubmissions via the content-key
|
|
44
|
+
* ledger, coalesces concurrent submissions, and clears the cart on success.
|
|
45
|
+
* Returns order data only — confirmation wording is the flow's job.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createOrderTool(options: CreateOrderToolOptions): AnyTool;
|
package/dist/order.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool } from '@kuralle-agents/core';
|
|
4
|
+
import { cartTotal } from './types.js';
|
|
5
|
+
import { clearCart, readCart } from './cart.js';
|
|
6
|
+
export function createInMemoryOrderLedger() {
|
|
7
|
+
const orders = new Map();
|
|
8
|
+
return {
|
|
9
|
+
async get(contentKey) {
|
|
10
|
+
return orders.get(contentKey) ?? null;
|
|
11
|
+
},
|
|
12
|
+
async putIfAbsent(contentKey, order) {
|
|
13
|
+
const existing = orders.get(contentKey);
|
|
14
|
+
if (existing)
|
|
15
|
+
return existing;
|
|
16
|
+
orders.set(contentKey, order);
|
|
17
|
+
return order;
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function orderContentKey(sessionId, items) {
|
|
22
|
+
const lines = items
|
|
23
|
+
.map((item) => `${item.productId}x${item.quantity}@${item.unitPrice.amount}${item.unitPrice.currency}`)
|
|
24
|
+
.sort()
|
|
25
|
+
.join('|');
|
|
26
|
+
return createHash('sha256').update(`${sessionId}::${lines}`).digest('hex').slice(0, 32);
|
|
27
|
+
}
|
|
28
|
+
const createOrderInput = z.object({
|
|
29
|
+
note: z.union([z.string(), z.null()]).describe('Optional order note from the user'),
|
|
30
|
+
});
|
|
31
|
+
/**
|
|
32
|
+
* Durable, idempotent `create_order` tool. Reads the cart from flow state,
|
|
33
|
+
* refuses empty carts, dedupes identical resubmissions via the content-key
|
|
34
|
+
* ledger, coalesces concurrent submissions, and clears the cart on success.
|
|
35
|
+
* Returns order data only — confirmation wording is the flow's job.
|
|
36
|
+
*/
|
|
37
|
+
export function createOrderTool(options) {
|
|
38
|
+
const ledger = options.ledger ?? createInMemoryOrderLedger();
|
|
39
|
+
const inFlight = new Map();
|
|
40
|
+
async function submitOnce(ctx, note) {
|
|
41
|
+
const cart = readCart(ctx);
|
|
42
|
+
if (cart.items.length === 0) {
|
|
43
|
+
throw new Error('cart_empty');
|
|
44
|
+
}
|
|
45
|
+
const sessionId = ctx.session.id;
|
|
46
|
+
const contentKey = orderContentKey(sessionId, cart.items);
|
|
47
|
+
const existing = await ledger.get(contentKey);
|
|
48
|
+
if (existing) {
|
|
49
|
+
return existing;
|
|
50
|
+
}
|
|
51
|
+
const pending = inFlight.get(contentKey);
|
|
52
|
+
if (pending) {
|
|
53
|
+
return pending;
|
|
54
|
+
}
|
|
55
|
+
const submission = (async () => {
|
|
56
|
+
const total = cartTotal(cart.items);
|
|
57
|
+
const result = await options.submit({
|
|
58
|
+
sessionId,
|
|
59
|
+
userId: ctx.session.userId,
|
|
60
|
+
items: cart.items,
|
|
61
|
+
total,
|
|
62
|
+
contentKey,
|
|
63
|
+
note,
|
|
64
|
+
});
|
|
65
|
+
const order = {
|
|
66
|
+
id: result.orderId,
|
|
67
|
+
sessionId,
|
|
68
|
+
userId: ctx.session.userId,
|
|
69
|
+
items: cart.items,
|
|
70
|
+
total,
|
|
71
|
+
status: 'created',
|
|
72
|
+
contentKey,
|
|
73
|
+
createdAt: new Date().toISOString(),
|
|
74
|
+
metadata: result.metadata,
|
|
75
|
+
};
|
|
76
|
+
const stored = await ledger.putIfAbsent(contentKey, order);
|
|
77
|
+
if (stored.id === order.id) {
|
|
78
|
+
clearCart(ctx);
|
|
79
|
+
}
|
|
80
|
+
return stored;
|
|
81
|
+
})();
|
|
82
|
+
inFlight.set(contentKey, submission);
|
|
83
|
+
try {
|
|
84
|
+
return await submission;
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
inFlight.delete(contentKey);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return defineTool({
|
|
91
|
+
name: 'create_order',
|
|
92
|
+
description: 'Place the order for the current cart. Idempotent: resubmitting the same cart returns the existing order instead of ordering twice.',
|
|
93
|
+
input: createOrderInput,
|
|
94
|
+
execute: async (args, ctx) => {
|
|
95
|
+
if (!ctx?.runState) {
|
|
96
|
+
throw new Error('create_order requires a run context (runState)');
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const order = await submitOnce(ctx, args.note ?? undefined);
|
|
100
|
+
return { order };
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
if (error instanceof Error && error.message === 'cart_empty') {
|
|
104
|
+
return { error: 'cart_empty' };
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic conversational-commerce primitives.
|
|
3
|
+
*
|
|
4
|
+
* Money is integer minor units (cents) — never floats. Tools return data
|
|
5
|
+
* only (Kuralle design rule); rendering carts/products into channel messages
|
|
6
|
+
* is the messaging layer's job (see `toWhatsAppProductList`).
|
|
7
|
+
*/
|
|
8
|
+
export interface Money {
|
|
9
|
+
/** Integer amount in minor units (e.g. cents). */
|
|
10
|
+
amount: number;
|
|
11
|
+
/** ISO 4217 currency code, e.g. 'USD', 'LKR'. */
|
|
12
|
+
currency: string;
|
|
13
|
+
}
|
|
14
|
+
export interface Product {
|
|
15
|
+
id: string;
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
price: Money;
|
|
19
|
+
/** Channel catalog id (e.g. Meta Commerce Manager `product_retailer_id`). */
|
|
20
|
+
retailerId?: string;
|
|
21
|
+
imageUrl?: string;
|
|
22
|
+
/** Available stock; undefined = not tracked. */
|
|
23
|
+
stock?: number;
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
}
|
|
26
|
+
export interface CartItem {
|
|
27
|
+
productId: string;
|
|
28
|
+
title: string;
|
|
29
|
+
unitPrice: Money;
|
|
30
|
+
quantity: number;
|
|
31
|
+
retailerId?: string;
|
|
32
|
+
}
|
|
33
|
+
export interface Cart {
|
|
34
|
+
items: CartItem[];
|
|
35
|
+
updatedAt: string;
|
|
36
|
+
}
|
|
37
|
+
export type OrderStatus = 'created' | 'confirmed' | 'fulfilled' | 'cancelled';
|
|
38
|
+
export interface Order {
|
|
39
|
+
id: string;
|
|
40
|
+
sessionId: string;
|
|
41
|
+
userId?: string;
|
|
42
|
+
items: CartItem[];
|
|
43
|
+
total: Money;
|
|
44
|
+
status: OrderStatus;
|
|
45
|
+
/** Stable content key the order was deduplicated on. */
|
|
46
|
+
contentKey: string;
|
|
47
|
+
createdAt: string;
|
|
48
|
+
metadata?: Record<string, unknown>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Host-implemented product source (your commerce backend / MCP server /
|
|
52
|
+
* product API). Tools never invent products — they read this.
|
|
53
|
+
*/
|
|
54
|
+
export interface ProductCatalog {
|
|
55
|
+
get(productId: string): Promise<Product | null>;
|
|
56
|
+
search(query: string, opts?: {
|
|
57
|
+
limit?: number;
|
|
58
|
+
}): Promise<Product[]>;
|
|
59
|
+
}
|
|
60
|
+
export declare function cartTotal(items: CartItem[]): Money;
|
|
61
|
+
export declare function formatMoney(money: Money): string;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel-agnostic conversational-commerce primitives.
|
|
3
|
+
*
|
|
4
|
+
* Money is integer minor units (cents) — never floats. Tools return data
|
|
5
|
+
* only (Kuralle design rule); rendering carts/products into channel messages
|
|
6
|
+
* is the messaging layer's job (see `toWhatsAppProductList`).
|
|
7
|
+
*/
|
|
8
|
+
export function cartTotal(items) {
|
|
9
|
+
if (items.length === 0) {
|
|
10
|
+
return { amount: 0, currency: 'USD' };
|
|
11
|
+
}
|
|
12
|
+
const currency = items[0].unitPrice.currency;
|
|
13
|
+
let amount = 0;
|
|
14
|
+
for (const item of items) {
|
|
15
|
+
if (item.unitPrice.currency !== currency) {
|
|
16
|
+
throw new Error(`Mixed currencies in cart: ${currency} and ${item.unitPrice.currency}`);
|
|
17
|
+
}
|
|
18
|
+
amount += item.unitPrice.amount * item.quantity;
|
|
19
|
+
}
|
|
20
|
+
return { amount, currency };
|
|
21
|
+
}
|
|
22
|
+
export function formatMoney(money) {
|
|
23
|
+
return `${(money.amount / 100).toFixed(2)} ${money.currency}`;
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Cart, Product } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Structural match for `@kuralle-agents/messaging-meta`'s `ProductListMessage`
|
|
4
|
+
* send options — defined here (not imported) so the commerce package stays
|
|
5
|
+
* channel-agnostic. `client.sendProductList(to, toWhatsAppProductList(...))`
|
|
6
|
+
* type-checks structurally.
|
|
7
|
+
*/
|
|
8
|
+
export interface WhatsAppProductListOptions {
|
|
9
|
+
catalogId: string;
|
|
10
|
+
header: string;
|
|
11
|
+
body: string;
|
|
12
|
+
footer?: string;
|
|
13
|
+
sections: Array<{
|
|
14
|
+
title?: string;
|
|
15
|
+
productRetailerIds: string[];
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Render products (or a cart) as a WhatsApp multi-product message payload.
|
|
20
|
+
* Products without a `retailerId` are skipped — they don't exist in the Meta
|
|
21
|
+
* catalog and the API would reject them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function toWhatsAppProductList(source: Product[] | Cart, opts: {
|
|
24
|
+
catalogId: string;
|
|
25
|
+
header: string;
|
|
26
|
+
body: string;
|
|
27
|
+
footer?: string;
|
|
28
|
+
sectionTitle?: string;
|
|
29
|
+
}): WhatsAppProductListOptions;
|
package/dist/whatsapp.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render products (or a cart) as a WhatsApp multi-product message payload.
|
|
3
|
+
* Products without a `retailerId` are skipped — they don't exist in the Meta
|
|
4
|
+
* catalog and the API would reject them.
|
|
5
|
+
*/
|
|
6
|
+
export function toWhatsAppProductList(source, opts) {
|
|
7
|
+
const retailerIds = (Array.isArray(source) ? source : source.items)
|
|
8
|
+
.map((entry) => entry.retailerId)
|
|
9
|
+
.filter((id) => typeof id === 'string' && id.length > 0);
|
|
10
|
+
return {
|
|
11
|
+
catalogId: opts.catalogId,
|
|
12
|
+
header: opts.header,
|
|
13
|
+
body: opts.body,
|
|
14
|
+
footer: opts.footer,
|
|
15
|
+
sections: [{ title: opts.sectionTitle, productRetailerIds: retailerIds }],
|
|
16
|
+
};
|
|
17
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kuralle-agents/commerce",
|
|
3
|
+
"license": "Apache-2.0",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/kuralle/kuralle-agents.git",
|
|
7
|
+
"directory": "packages/kuralle-commerce"
|
|
8
|
+
},
|
|
9
|
+
"version": "0.8.5",
|
|
10
|
+
"description": "Conversational commerce primitives for Kuralle agents: carts, idempotent orders, catalog contracts",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"main": "dist/index.js",
|
|
13
|
+
"types": "dist/index.d.ts",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@kuralle-agents/core": "0.8.5"
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"zod": "^4.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^20.11.0",
|
|
28
|
+
"bun-types": "^1.3.0",
|
|
29
|
+
"typescript": "^5.3.0",
|
|
30
|
+
"zod": "^4.0.0",
|
|
31
|
+
"@ai-sdk/openai": "^3.0.0",
|
|
32
|
+
"dotenv": "^16.4.0"
|
|
33
|
+
},
|
|
34
|
+
"publishConfig": {
|
|
35
|
+
"access": "public"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"prebuild": "rm -rf dist",
|
|
43
|
+
"build": "tsc -p tsconfig.json",
|
|
44
|
+
"clean": "rm -rf dist",
|
|
45
|
+
"test": "bun test ./test"
|
|
46
|
+
}
|
|
47
|
+
}
|