@jsm-mit/sultana-agent-tools-package 0.3.0 → 0.4.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.
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # sultana-agent-tools-package
2
2
 
3
- `@jsm-mit/sultana-agent-tools-package` — the tool layer that lets an LLM agent edit a Sultana
4
- salon by conversation: services, promos and working hours.
3
+ `@jsm-mit/sultana-agent-tools-package` — the tool layer that lets an LLM agent work in Sultana by
4
+ conversation: a salon owner's set (services, promos and working hours) and a customer's set
5
+ (search, free times, booking on the customer's behalf).
5
6
 
6
7
  It is **framework-agnostic**. A tool is `{ name, description, parameters, progress, execute }`, which is what
7
8
  OpenAI, Anthropic and `@jsm-mit/chat-agent-package` all want from a function tool, so wiring this
@@ -128,6 +129,45 @@ never sent to the model. A write tool previewing with `confirmed: false` shows t
128
129
  `createSalonTools` returns all 21; `createSalonReadTools` returns `list_services`, `list_promos`
129
130
  and `find_service_type`.
130
131
 
132
+ ## Customer tools
133
+
134
+ The second set works for ONE customer instead of one salon: it searches across all salons and
135
+ books, lists and cancels visits **on the customer's behalf**. The host passes the customer's lent
136
+ identity (an Internet Identity delegation) and the position the app sent; neither is ever a tool
137
+ argument, so a model holding these tools can act for nobody else and search around nowhere else.
138
+
139
+ ```ts
140
+ import { createCustomerTools, createCustomerReadTools, CUSTOMER_AGENT_PERSONA_PL } from "@jsm-mit/sultana-agent-tools-package";
141
+
142
+ const tools = session
143
+ ? createCustomerTools({ canisterId, identity: session.identity, location: session.location, confirmations: ledger.forTurn() })
144
+ : createCustomerReadTools({ canisterId, location }); // no identity: search only, nothing books
145
+ ```
146
+
147
+ | tool | writes | needs identity | progress |
148
+ |---|---|---|---|
149
+ | `find_service_type` | | | Szukam typu usługi… |
150
+ | `search_slots` | | | Szukam wolnych terminów w okolicy… |
151
+ | `get_salon` | | | Sprawdzam salon… |
152
+ | `get_free_times` | | | Sprawdzam wolne godziny… |
153
+ | `book_appointment` | yes | yes | Rezerwuję wizytę… |
154
+ | `list_my_appointments` | | yes | Sprawdzam Twoje wizyty… |
155
+ | `cancel_appointment` | yes | yes | Odwołuję wizytę… |
156
+
157
+ - `search_slots` without `date`/`time` is the package's "as soon as possible" search (the next
158
+ hour, else up to 24 h ahead); with them it probes from 30 min before to 60 min after the moment.
159
+ Results: at most 6 salons, soonest first, each with its services and up to 6 free moments.
160
+ - A model reads and sends local time: `date` `"YYYY-MM-DD"`, `time` `"HH:mm"`.
161
+ `get_free_times` folds the 5-minute start grid into ranges.
162
+ - `book_appointment` picks the first free worker, like the app, and checks the slot on the preview
163
+ AND on the confirmed call — a booking the canister refuses still spends the customer's daily quota.
164
+ - The public reads go out anonymously even with an identity; only booking, the visits and
165
+ cancelling are signed.
166
+ - **The host process must run in the salons' time zone** (`TZ=Europe/Warsaw`): the canister wrapper
167
+ takes every offset from the process clock. While it does not, every time-dependent tool answers
168
+ `unavailable` instead of booking hours off (`timeZone: null` skips the guard; `hostTimeZoneProblem`
169
+ lets a host check at start-up).
170
+
131
171
  ## What the tools guarantee
132
172
 
133
173
  - **`execute` never throws.** Every call returns `{status: "ok" | "confirmation_required" | "error"}`
@@ -160,6 +200,7 @@ and `find_service_type`.
160
200
  | `npm run sandbox -- --fake` | talk to the tools against an in-memory salon |
161
201
  | `npm run sandbox` | the same conversation against a real canister |
162
202
  | `npm run test-tools` | the round trip against a live canister (creates its own salon) |
203
+ | `npm run test-customer-tools` | the customer's round trip against a live, seeded canister: search, book, list, cancel (a fresh identity per run) |
163
204
  | `npm run publish-public` | publishes to npm from master — see below |
164
205
 
165
206
  ## Testing by talking
@@ -0,0 +1,38 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
2
+ import type { AgentTool } from "../types.js";
3
+ import type { CustomerCorePort } from "./customer-core-port.js";
4
+ import { type IcCustomerCorePortOptions } from "./ic-customer-core-port.js";
5
+ /** The zone Sultana's salons live in. The canister keeps no zone per salon. */
6
+ export declare const DEFAULT_SALON_TIME_ZONE = "Europe/Warsaw";
7
+ export interface CreateCustomerToolsFromPortOptions {
8
+ /** This turn's confirmations — `ledger.forTurn()`, as for the salon tools. */
9
+ confirmations?: TurnConfirmations;
10
+ /** The clock; defaults to the system's. */
11
+ now?: () => Date;
12
+ /**
13
+ * The salons' time zone. The host process MUST run in it (`TZ=Europe/Warsaw`): the canister
14
+ * wrapper takes every offset from the process clock. While it does not, every tool that
15
+ * touches a time answers with an error instead of booking hours off. `null` skips the guard.
16
+ */
17
+ timeZone?: string | null;
18
+ }
19
+ export interface CreateCustomerToolsOptions extends Omit<IcCustomerCorePortOptions, "language">, CreateCustomerToolsFromPortOptions {
20
+ /** Language of service-type labels. Defaults to Polish. */
21
+ language?: string;
22
+ }
23
+ /**
24
+ * The tool set of ONE customer: search, free times, booking, the customer's visits, cancelling.
25
+ * The customer's identity and position are captured here and never appear as tool arguments, so a
26
+ * model holding these tools can act for nobody else and search around nowhere else.
27
+ */
28
+ export declare function createCustomerTools(options: CreateCustomerToolsOptions): AgentTool[];
29
+ /** The same set over any port — the seam tests use. */
30
+ export declare function createCustomerToolsFromPort(port: CustomerCorePort, options?: CreateCustomerToolsFromPortOptions): AgentTool[];
31
+ export type CreateCustomerReadToolsOptions = Omit<CreateCustomerToolsOptions, "identity" | "confirmations">;
32
+ /**
33
+ * The set for a customer whose delegation is missing or expired: everything that needs no
34
+ * identity — finding a service type, searching slots, a salon, its free times. Nothing books.
35
+ */
36
+ export declare function createCustomerReadTools(options: CreateCustomerReadToolsOptions): AgentTool[];
37
+ export declare function createCustomerReadToolsFromPort(port: CustomerCorePort, options?: Omit<CreateCustomerToolsFromPortOptions, "confirmations">): AgentTool[];
38
+ //# sourceMappingURL=create-customer-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"create-customer-tools.d.ts","sourceRoot":"","sources":["../../src/customer/create-customer-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAC7C,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AAChE,OAAO,EAAsB,KAAK,yBAAyB,EAAE,MAAM,4BAA4B,CAAC;AAWhG,+EAA+E;AAC/E,eAAO,MAAM,uBAAuB,kBAAkB,CAAC;AAEvD,MAAM,WAAW,kCAAkC;IAC/C,8EAA8E;IAC9E,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,2CAA2C;IAC3C,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,0BAA2B,SAAQ,IAAI,CAAC,yBAAyB,EAAE,UAAU,CAAC,EAAE,kCAAkC;IAC/H,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,0BAA0B,GAAG,SAAS,EAAE,CAKpF;AAED,uDAAuD;AACvD,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,gBAAgB,EAAE,OAAO,GAAE,kCAAuC,GAAG,SAAS,EAAE,CASjI;AAED,MAAM,MAAM,8BAA8B,GAAG,IAAI,CAAC,0BAA0B,EAAE,UAAU,GAAG,eAAe,CAAC,CAAC;AAE5G;;;GAGG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,EAAE,8BAA8B,GAAG,SAAS,EAAE,CAK5F;AAED,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,gBAAgB,EAAE,OAAO,GAAE,IAAI,CAAC,kCAAkC,EAAE,eAAe,CAAM,GAAG,SAAS,EAAE,CAE5J"}
@@ -0,0 +1,47 @@
1
+ import { findServiceTypeTool } from "../tools/services.js";
2
+ import { IcCustomerCorePort } from "./ic-customer-core-port.js";
3
+ import { bookAppointmentTool, cancelAppointmentTool, getFreeTimesTool, getSalonTool, listMyAppointmentsTool, searchSlotsTool, } from "./tools.js";
4
+ /** The zone Sultana's salons live in. The canister keeps no zone per salon. */
5
+ export const DEFAULT_SALON_TIME_ZONE = "Europe/Warsaw";
6
+ /**
7
+ * The tool set of ONE customer: search, free times, booking, the customer's visits, cancelling.
8
+ * The customer's identity and position are captured here and never appear as tool arguments, so a
9
+ * model holding these tools can act for nobody else and search around nowhere else.
10
+ */
11
+ export function createCustomerTools(options) {
12
+ const { confirmations, now, timeZone, language, ...portOptions } = options;
13
+ const port = new IcCustomerCorePort({ ...portOptions, language: language ?? "pl" });
14
+ return createCustomerToolsFromPort(port, { confirmations, now, timeZone });
15
+ }
16
+ /** The same set over any port — the seam tests use. */
17
+ export function createCustomerToolsFromPort(port, options = {}) {
18
+ const context = contextOf(port, options);
19
+ return [
20
+ ...readTools(context),
21
+ bookAppointmentTool(context, options.confirmations),
22
+ listMyAppointmentsTool(context),
23
+ cancelAppointmentTool(context, options.confirmations),
24
+ ];
25
+ }
26
+ /**
27
+ * The set for a customer whose delegation is missing or expired: everything that needs no
28
+ * identity — finding a service type, searching slots, a salon, its free times. Nothing books.
29
+ */
30
+ export function createCustomerReadTools(options) {
31
+ const { now, timeZone, language, ...portOptions } = options;
32
+ const port = new IcCustomerCorePort({ ...portOptions, language: language ?? "pl" });
33
+ return createCustomerReadToolsFromPort(port, { now, timeZone });
34
+ }
35
+ export function createCustomerReadToolsFromPort(port, options = {}) {
36
+ return readTools(contextOf(port, options));
37
+ }
38
+ function contextOf(port, options) {
39
+ return {
40
+ port,
41
+ now: options.now ?? (() => new Date()),
42
+ timeZone: options.timeZone === null ? undefined : options.timeZone ?? DEFAULT_SALON_TIME_ZONE,
43
+ };
44
+ }
45
+ function readTools(context) {
46
+ return [findServiceTypeTool(context.port), searchSlotsTool(context), getSalonTool(context), getFreeTimesTool(context)];
47
+ }
@@ -0,0 +1,94 @@
1
+ import type { ServiceTypeView, TimeRange } from "../salon-core-port.js";
2
+ /**
3
+ * Everything the customer tools may do on the core canister, in plain JSON. Unlike
4
+ * `SalonCorePort` it is bound to no salon — a customer looks across all of them — but to ONE
5
+ * customer: the identity behind the port (the customer's lent delegation) and the place the
6
+ * customer searches around. Neither ever appears as a tool argument.
7
+ *
8
+ * Moments are Unix milliseconds; a day is the LOCAL date `"YYYY-MM-DD"`.
9
+ */
10
+ export interface Coordinates {
11
+ lat: number;
12
+ lng: number;
13
+ }
14
+ /** A salon's service as a visitor sees it (active services only). */
15
+ export interface CustomerServiceView {
16
+ id: string;
17
+ salonId: string;
18
+ name: string;
19
+ pricePln: number;
20
+ durationMinutes: number;
21
+ serviceTypeIds: string[];
22
+ /** Workers who can perform it; the tools pick the first free one, like the app does. */
23
+ workerIds: string[];
24
+ }
25
+ export interface SalonView {
26
+ id: string;
27
+ name: string;
28
+ address: string;
29
+ }
30
+ export interface CustomerPromoView {
31
+ name: string;
32
+ textLines: string[];
33
+ discountCode?: string;
34
+ /** The service the promo leads to; absent = the salon itself. */
35
+ targetServiceId?: string;
36
+ }
37
+ /** One salon's answer to a slot search. */
38
+ export interface SalonSlotsView {
39
+ salonId: string;
40
+ distanceKm: number;
41
+ /** The salon's services of the searched type. */
42
+ services: CustomerServiceView[];
43
+ /** Free start moments, ascending. */
44
+ availableAt: number[];
45
+ /** Asap only: the soonest moment the customer can still reach from the search location. */
46
+ soonestAt: number | null;
47
+ }
48
+ export interface SlotSearchView {
49
+ /** Asap only. "extended": nothing reachable within the hour, so the scan went on up to 24 h. */
50
+ phase?: "soon" | "extended";
51
+ salons: SalonSlotsView[];
52
+ /** Probes whose canister call failed — an empty list with failures means "could not check". */
53
+ failedProbes: number;
54
+ }
55
+ export type AppointmentStatusView = "Pending" | "Confirmed" | "Cancelled" | "Completed" | "NoShow";
56
+ export interface AppointmentView {
57
+ id: string;
58
+ salonId: string;
59
+ serviceId: string;
60
+ startAt: number;
61
+ durationMinutes: number;
62
+ pricePln: number;
63
+ status: AppointmentStatusView;
64
+ discountCode?: string;
65
+ }
66
+ export interface BookingInput {
67
+ salonId: string;
68
+ serviceId: string;
69
+ workerId: string;
70
+ startAt: number;
71
+ notes: string;
72
+ discountCode?: string;
73
+ }
74
+ export interface CustomerCorePort {
75
+ /** Where the customer searches around; `null` when the app sent no position. */
76
+ readonly location: Coordinates | null;
77
+ findServiceTypes(query: string): Promise<ServiceTypeView[]>;
78
+ /** "As soon as possible" around `location`. */
79
+ searchAsap(serviceTypeId: string, radiusKm: number, now: Date): Promise<SlotSearchView>;
80
+ /** The given start moments around `location`. */
81
+ searchAtMoments(serviceTypeId: string, radiusKm: number, moments: number[]): Promise<SlotSearchView>;
82
+ getSalons(salonIds: string[]): Promise<SalonView[]>;
83
+ listSalonServices(salonId: string): Promise<CustomerServiceView[]>;
84
+ listSalonPromos(salonId: string): Promise<CustomerPromoView[]>;
85
+ /** One worker's free windows on a local day. A worker with no hours that day gives `[]`. */
86
+ freeWindows(salonId: string, workerId: string, day: string): Promise<TimeRange[]>;
87
+ /** Needs the customer's identity. Returns the appointment id. */
88
+ book(input: BookingInput): Promise<string>;
89
+ /** Needs the customer's identity. Every appointment of the customer, past and cancelled too. */
90
+ listMyAppointments(): Promise<AppointmentView[]>;
91
+ /** Needs the customer's identity. */
92
+ cancel(appointmentId: string): Promise<void>;
93
+ }
94
+ //# sourceMappingURL=customer-core-port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"customer-core-port.d.ts","sourceRoot":"","sources":["../../src/customer/customer-core-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAExE;;;;;;;GAOG;AAEH,MAAM,WAAW,WAAW;IACxB,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,EAAE,MAAM,CAAC;CACf;AAED,qEAAqE;AACrE,MAAM,WAAW,mBAAmB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,wFAAwF;IACxF,SAAS,EAAE,MAAM,EAAE,CAAC;CACvB;AAED,MAAM,WAAW,SAAS;IACtB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,iBAAiB;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,MAAM,EAAE,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,iEAAiE;IACjE,eAAe,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2CAA2C;AAC3C,MAAM,WAAW,cAAc;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,iDAAiD;IACjD,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,qCAAqC;IACrC,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,2FAA2F;IAC3F,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC5B;AAED,MAAM,WAAW,cAAc;IAC3B,gGAAgG;IAChG,KAAK,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAC5B,MAAM,EAAE,cAAc,EAAE,CAAC;IACzB,+FAA+F;IAC/F,YAAY,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,MAAM,qBAAqB,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GAAG,QAAQ,CAAC;AAEnG,MAAM,WAAW,eAAe;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,qBAAqB,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,YAAY;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,CAAC,EAAE,MAAM,CAAC;CACzB;AAED,MAAM,WAAW,gBAAgB;IAC7B,gFAAgF;IAChF,QAAQ,CAAC,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAC;IAEtC,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IAE5D,+CAA+C;IAC/C,UAAU,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACxF,iDAAiD;IACjD,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAErG,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IACpD,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAAC;IACnE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAAC;IAE/D,4FAA4F;IAC5F,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAAC;IAElF,iEAAiE;IACjE,IAAI,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3C,gGAAgG;IAChG,kBAAkB,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC;IACjD,qCAAqC;IACrC,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAChD"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,45 @@
1
+ import type { Identity } from "@icp-sdk/core/agent";
2
+ import type { ServiceTypeView, TimeRange } from "../salon-core-port.js";
3
+ import type { AppointmentView, BookingInput, Coordinates, CustomerCorePort, CustomerPromoView, CustomerServiceView, SalonView, SlotSearchView } from "./customer-core-port.js";
4
+ export interface IcCustomerCorePortOptions {
5
+ canisterId: string;
6
+ /** The customer's lent delegation. Omit it and every read still works (they are public
7
+ * queries), while booking, the customer's appointments and cancelling are refused. */
8
+ identity?: Identity;
9
+ /** Where the customer searches around — the position the app sent with the session. */
10
+ location?: Coordinates;
11
+ /** Language of service-type labels. */
12
+ language: string;
13
+ }
14
+ /**
15
+ * The customer port over the published canister wrapper. Conversions live here and nowhere
16
+ * else: the canister speaks `bigint`, `Principal` and nanoseconds, the tools speak numbers,
17
+ * strings and milliseconds.
18
+ *
19
+ * The public reads go out ANONYMOUSLY even when an identity is present: they answer the same
20
+ * either way, and a signed query would only spend the customer's delegation on nothing.
21
+ */
22
+ export declare class IcCustomerCorePort implements CustomerCorePort {
23
+ readonly location: Coordinates | null;
24
+ private readonly language;
25
+ private readonly search;
26
+ private readonly salons;
27
+ private readonly services;
28
+ private readonly serviceTypes;
29
+ private readonly promo;
30
+ private readonly availability;
31
+ private readonly appointments;
32
+ constructor(options: IcCustomerCorePortOptions);
33
+ findServiceTypes(query: string): Promise<ServiceTypeView[]>;
34
+ searchAsap(serviceTypeId: string, radiusKm: number, now: Date): Promise<SlotSearchView>;
35
+ searchAtMoments(serviceTypeId: string, radiusKm: number, moments: number[]): Promise<SlotSearchView>;
36
+ getSalons(salonIds: string[]): Promise<SalonView[]>;
37
+ listSalonServices(salonId: string): Promise<CustomerServiceView[]>;
38
+ listSalonPromos(salonId: string): Promise<CustomerPromoView[]>;
39
+ freeWindows(salonId: string, workerId: string, day: string): Promise<TimeRange[]>;
40
+ book(input: BookingInput): Promise<string>;
41
+ listMyAppointments(): Promise<AppointmentView[]>;
42
+ cancel(appointmentId: string): Promise<void>;
43
+ private requireLocation;
44
+ }
45
+ //# sourceMappingURL=ic-customer-core-port.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ic-customer-core-port.d.ts","sourceRoot":"","sources":["../../src/customer/ic-customer-core-port.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAepD,OAAO,KAAK,EAAE,eAAe,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,KAAK,EAER,eAAe,EACf,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EAEnB,SAAS,EACT,cAAc,EACjB,MAAM,yBAAyB,CAAC;AAEjC,MAAM,WAAW,yBAAyB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB;0FACsF;IACtF,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,WAAW,CAAC;IACvB,uCAAuC;IACvC,QAAQ,EAAE,MAAM,CAAC;CACpB;AAaD;;;;;;;GAOG;AACH,qBAAa,kBAAmB,YAAW,gBAAgB;IACvD,SAAgB,QAAQ,EAAE,WAAW,GAAG,IAAI,CAAC;IAE7C,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAc;IACrC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAqB;IAC9C,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoB;IACjD,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAa;IACnC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoB;IACjD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAoB;gBAErC,OAAO,EAAE,yBAAyB;IAajC,gBAAgB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAU3D,UAAU,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAAC,cAAc,CAAC;IAWvF,eAAe,CAAC,aAAa,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,cAAc,CAAC;IAWpG,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAQnD,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,EAAE,CAAC;IAKlE,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,EAAE,CAAC;IAa9D,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC;IAUjF,IAAI,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC;IAW1C,kBAAkB,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC;IAehD,MAAM,CAAC,aAAa,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIzD,OAAO,CAAC,eAAe;CAI1B"}
@@ -0,0 +1,132 @@
1
+ import { Principal } from "@icp-sdk/core/principal";
2
+ import { AppointmentsActor, AvailabilityActor, filterServiceTypes, PromoActor, SalonServicesActor, SalonsActor, searchAsapAsyncUnsafe, searchAtMomentsAsyncUnsafe, SearchActor, ServiceTypesActor, } from "@jsm-mit/sultana-core-motoko-package";
3
+ /**
4
+ * The customer port over the published canister wrapper. Conversions live here and nowhere
5
+ * else: the canister speaks `bigint`, `Principal` and nanoseconds, the tools speak numbers,
6
+ * strings and milliseconds.
7
+ *
8
+ * The public reads go out ANONYMOUSLY even when an identity is present: they answer the same
9
+ * either way, and a signed query would only spend the customer's delegation on nothing.
10
+ */
11
+ export class IcCustomerCorePort {
12
+ location;
13
+ language;
14
+ search;
15
+ salons;
16
+ services;
17
+ serviceTypes;
18
+ promo;
19
+ availability;
20
+ appointments;
21
+ constructor(options) {
22
+ this.location = options.location ?? null;
23
+ this.language = options.language;
24
+ this.search = new SearchActor(options.canisterId);
25
+ this.salons = new SalonsActor(options.canisterId);
26
+ this.services = new SalonServicesActor(options.canisterId);
27
+ this.serviceTypes = new ServiceTypesActor(options.canisterId);
28
+ this.promo = new PromoActor(options.canisterId);
29
+ this.availability = new AvailabilityActor(options.canisterId);
30
+ this.appointments = new AppointmentsActor(options.canisterId, options.identity);
31
+ }
32
+ async findServiceTypes(query) {
33
+ const catalog = await this.serviceTypes.getServiceTypesCatalogAsyncUnsafe(this.language);
34
+ return filterServiceTypes(catalog, query).map((entry) => ({
35
+ id: entry.id,
36
+ label: entry.text,
37
+ category: entry.category,
38
+ }));
39
+ }
40
+ async searchAsap(serviceTypeId, radiusKm, now) {
41
+ const state = await searchAsapAsyncUnsafe(this.search, {
42
+ location: this.requireLocation(),
43
+ serviceTypeId,
44
+ radiusKm,
45
+ now,
46
+ });
47
+ return { phase: state.phase, salons: state.results.map(toSalonSlots), failedProbes: state.failedProbes };
48
+ }
49
+ async searchAtMoments(serviceTypeId, radiusKm, moments) {
50
+ const state = await searchAtMomentsAsyncUnsafe(this.search, {
51
+ location: this.requireLocation(),
52
+ serviceTypeId,
53
+ radiusKm,
54
+ moments,
55
+ });
56
+ return { salons: state.results.map(toSalonSlots), failedProbes: state.failedProbes };
57
+ }
58
+ async getSalons(salonIds) {
59
+ if (salonIds.length === 0)
60
+ return [];
61
+ // The canister answers for at most 100 ids per call.
62
+ const salons = await this.salons.getSalonsPublicInfoAsyncUnsafe(salonIds.slice(0, 100));
63
+ return salons.map((salon) => ({ id: salon.id, name: salon.name, address: salon.address }));
64
+ }
65
+ async listSalonServices(salonId) {
66
+ const services = await this.services.getSalonServicesPublicAsyncUnsafe(salonId);
67
+ return services.map(toService);
68
+ }
69
+ async listSalonPromos(salonId) {
70
+ const promos = await this.promo.getSalonPromosAsyncUnsafe(salonId);
71
+ return promos
72
+ .filter((promo) => promo.active)
73
+ .map((promo) => ({
74
+ name: promo.name,
75
+ textLines: [...promo.textLines],
76
+ discountCode: promo.cta.discountCode[0],
77
+ targetServiceId: "SalonService" in promo.target ? promo.target.SalonService : undefined,
78
+ }));
79
+ }
80
+ async freeWindows(salonId, workerId, day) {
81
+ // `day` stays a "YYYY-MM-DD" string: the wrapper reads a date's UTC parts, so a Date of
82
+ // local midnight would land a day early. The offset is the one of that day's noon, so a
83
+ // day on the far side of a clock change is read with its own offset.
84
+ const [year, month, date] = day.split("-").map(Number);
85
+ const offsetSlots = BigInt(-new Date(year, month - 1, date, 12).getTimezoneOffset() / 5);
86
+ return this.availability.getEffectiveMaskForSalonAsyncUnsafe(day, salonId, Principal.fromText(workerId), offsetSlots);
87
+ }
88
+ async book(input) {
89
+ return this.appointments.bookAppointmentAsyncUnsafe(input.notes, input.salonId, input.serviceId, input.startAt, input.workerId, input.discountCode);
90
+ }
91
+ async listMyAppointments() {
92
+ const appointments = await this.appointments.getMyAppointmentsAsyncUnsafe();
93
+ return appointments.map((appointment) => ({
94
+ id: appointment.id,
95
+ salonId: appointment.salonId,
96
+ serviceId: appointment.salonServiceId,
97
+ startAt: Number(appointment.startTimeUTC / 1000000n),
98
+ durationMinutes: Number(appointment.durationMinutes),
99
+ pricePln: Number(appointment.priceSnapshot),
100
+ status: Object.keys(appointment.status)[0],
101
+ discountCode: appointment.discountCode[0],
102
+ }));
103
+ }
104
+ async cancel(appointmentId) {
105
+ await this.appointments.cancelAppointmentAsyncUnsafe(appointmentId);
106
+ }
107
+ requireLocation() {
108
+ if (!this.location)
109
+ throw new Error("IcCustomerCorePort: no location — the tools check it before they search");
110
+ return this.location;
111
+ }
112
+ }
113
+ function toService(service) {
114
+ return {
115
+ id: service.id,
116
+ salonId: service.salonId,
117
+ name: service.name,
118
+ pricePln: Number(service.price),
119
+ durationMinutes: Number(service.duration),
120
+ serviceTypeIds: [...service.serviceTypeIds],
121
+ workerIds: service.workerIds.map((worker) => worker.toText()),
122
+ };
123
+ }
124
+ function toSalonSlots(found) {
125
+ return {
126
+ salonId: found.salonId,
127
+ distanceKm: found.distanceKm,
128
+ services: found.services.map(toService),
129
+ availableAt: [...found.availableAt],
130
+ soonestAt: found.soonest ? found.soonest.at : null,
131
+ };
132
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The operating rules of the customer tools, as a system prompt — next to the tools for the same
3
+ * reason as the salon persona: they describe the tool contract. A host appends who the agent is.
4
+ */
5
+ export declare const CUSTOMER_AGENT_PERSONA_PL = "Jeste\u015B asystentem klienta w aplikacji Sultana: pomagasz znale\u017A\u0107 salon i wolny termin,\nrezerwujesz i odwo\u0142ujesz wizyty w imieniu klienta. Rozmawiasz po polsku, kr\u00F3tko i konkretnie.\n\nZasady pracy:\n- Szukasz zawsze wok\u00F3\u0142 pozycji klienta, kt\u00F3r\u0105 poda\u0142a aplikacja. Nie pytaj o adres.\n- Typ us\u0142ugi ustal przez find_service_type (np. \u201Estrzy\u017Cenie m\u0119skie\u201D). Nigdy nie wymy\u015Blaj id.\n Gdy pasuje kilka typ\u00F3w, dopytaj klienta, o kt\u00F3ry chodzi.\n- Terminy znajd\u017A przez search_slots: bez daty = jak najszybciej, z date i time = okolice tej godziny.\n Dzisiejsz\u0105 dat\u0119 we\u017A z get_current_date \u2014 \u201Ejutro o 10\u201D przelicz na RRRR-MM-DD i GG:MM.\n- Poka\u017C klientowi najwy\u017Cej kilka propozycji: salon, us\u0142uga, cena, odleg\u0142o\u015B\u0107, godzina. Nie pokazuj id.\n- Inne godziny w wybranym salonie sprawd\u017A przez get_free_times.\n- Rezerwacja i odwo\u0142anie to dwa kroki w dw\u00F3ch r\u00F3\u017Cnych wiadomo\u015Bciach klienta:\n 1. Wywo\u0142aj book_appointment albo cancel_appointment z confirmed=false. Poka\u017C klientowi zdanie\n z pola summary, zapytaj o zgod\u0119 i na tym zako\u0144cz odpowied\u017A.\n 2. Dopiero gdy w swojej NAST\u0118PNEJ wiadomo\u015Bci wyra\u017Anie si\u0119 zgodzi, wywo\u0142aj to samo narz\u0119dzie\n z confirmed=true, z confirmationId z tego podgl\u0105du i z tymi samymi argumentami (pole echo).\n Nigdy nie potwierdzaj w tej samej odpowiedzi, w kt\u00F3rej pokaza\u0142e\u015B podgl\u0105d.\n- Kod rabatowy podawaj tylko wtedy, gdy klient go wpisa\u0142 albo wybra\u0142 promocj\u0119 z get_salon.\n- Je\u015Bli masz tylko narz\u0119dzia do szukania (bez book_appointment), nie mo\u017Cesz rezerwowa\u0107 \u2014\n powiedz, \u017Ce klient musi si\u0119 zalogowa\u0107 ponownie w aplikacji i otworzy\u0107 czat jeszcze raz.\n- Je\u015Bli narz\u0119dzie zwr\u00F3ci status \"error\", przeczytaj summary i powiedz klientowi, co posz\u0142o nie tak.\n Nie powtarzaj tego samego wywo\u0142ania w k\u00F3\u0142ko: nieudana pr\u00F3ba rezerwacji zu\u017Cywa dzienny limit klienta.\n- Nie obiecuj rezerwacji, kt\u00F3rej nie wykona\u0142e\u015B narz\u0119dziem.";
6
+ //# sourceMappingURL=persona.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"persona.d.ts","sourceRoot":"","sources":["../../src/customer/persona.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,eAAO,MAAM,yBAAyB,isEAsBqB,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The operating rules of the customer tools, as a system prompt — next to the tools for the same
3
+ * reason as the salon persona: they describe the tool contract. A host appends who the agent is.
4
+ */
5
+ export const CUSTOMER_AGENT_PERSONA_PL = `Jesteś asystentem klienta w aplikacji Sultana: pomagasz znaleźć salon i wolny termin,
6
+ rezerwujesz i odwołujesz wizyty w imieniu klienta. Rozmawiasz po polsku, krótko i konkretnie.
7
+
8
+ Zasady pracy:
9
+ - Szukasz zawsze wokół pozycji klienta, którą podała aplikacja. Nie pytaj o adres.
10
+ - Typ usługi ustal przez find_service_type (np. „strzyżenie męskie”). Nigdy nie wymyślaj id.
11
+ Gdy pasuje kilka typów, dopytaj klienta, o który chodzi.
12
+ - Terminy znajdź przez search_slots: bez daty = jak najszybciej, z date i time = okolice tej godziny.
13
+ Dzisiejszą datę weź z get_current_date — „jutro o 10” przelicz na RRRR-MM-DD i GG:MM.
14
+ - Pokaż klientowi najwyżej kilka propozycji: salon, usługa, cena, odległość, godzina. Nie pokazuj id.
15
+ - Inne godziny w wybranym salonie sprawdź przez get_free_times.
16
+ - Rezerwacja i odwołanie to dwa kroki w dwóch różnych wiadomościach klienta:
17
+ 1. Wywołaj book_appointment albo cancel_appointment z confirmed=false. Pokaż klientowi zdanie
18
+ z pola summary, zapytaj o zgodę i na tym zakończ odpowiedź.
19
+ 2. Dopiero gdy w swojej NASTĘPNEJ wiadomości wyraźnie się zgodzi, wywołaj to samo narzędzie
20
+ z confirmed=true, z confirmationId z tego podglądu i z tymi samymi argumentami (pole echo).
21
+ Nigdy nie potwierdzaj w tej samej odpowiedzi, w której pokazałeś podgląd.
22
+ - Kod rabatowy podawaj tylko wtedy, gdy klient go wpisał albo wybrał promocję z get_salon.
23
+ - Jeśli masz tylko narzędzia do szukania (bez book_appointment), nie możesz rezerwować —
24
+ powiedz, że klient musi się zalogować ponownie w aplikacji i otworzyć czat jeszcze raz.
25
+ - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz klientowi, co poszło nie tak.
26
+ Nie powtarzaj tego samego wywołania w kółko: nieudana próba rezerwacji zużywa dzienny limit klienta.
27
+ - Nie obiecuj rezerwacji, której nie wykonałeś narzędziem.`;
@@ -0,0 +1,27 @@
1
+ import type { TimeRange } from "../salon-core-port.js";
2
+ /** The local day of a moment, `"YYYY-MM-DD"`. */
3
+ export declare function localDayOf(at: number | Date): string;
4
+ /** The local time of a moment, `"HH:mm"`. */
5
+ export declare function localTimeOf(at: number | Date): string;
6
+ /** `"YYYY-MM-DD HH:mm"` — what a model reads and sends back as `date` + `time`. */
7
+ export declare function localStampOf(at: number | Date): string;
8
+ export declare function isDay(text: string): boolean;
9
+ /** Minutes from midnight of `"HH:mm"` (`"9:30"` too), or `null` when it is no time of day. */
10
+ export declare function minutesOf(time: string): number | null;
11
+ export declare function timeOfMinutes(minutes: number): string;
12
+ /** The moment of a local day at minutes from its midnight. `day` must pass `isDay`. */
13
+ export declare function momentOf(day: string, minutes: number): number;
14
+ export declare function onSlotGrid(minutes: number): boolean;
15
+ /** Start minutes, on the 5-minute grid, where a service of this duration fits in one window. */
16
+ export declare function freeStartMinutes(windows: TimeRange[], durationMinutes: number): number[];
17
+ /** Consecutive grid minutes folded into ranges — dozens of start times are noise to a model. */
18
+ export declare function foldStartRanges(starts: number[]): {
19
+ from: string;
20
+ to: string;
21
+ }[];
22
+ /**
23
+ * Why this process must not book for salons in `expectedTimeZone`, or `null` when it may: the
24
+ * process clock has to run in that zone, because the canister wrapper takes every offset from it.
25
+ */
26
+ export declare function hostTimeZoneProblem(expectedTimeZone: string, at?: Date): string | null;
27
+ //# sourceMappingURL=time.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"time.d.ts","sourceRoot":"","sources":["../../src/customer/time.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAevD,iDAAiD;AACjD,wBAAgB,UAAU,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAGpD;AAED,6CAA6C;AAC7C,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAGrD;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,GAAG,MAAM,CAEtD;AAED,wBAAgB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAO3C;AAED,8FAA8F;AAC9F,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAQrD;AAED,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD;AAED,uFAAuF;AACvF,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAG7D;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,gGAAgG;AAChG,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,EAAE,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,EAAE,CAcxF;AAED,gGAAgG;AAChG,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,EAAE,CAUhF;AAqBD;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,gBAAgB,EAAE,MAAM,EAAE,EAAE,GAAE,IAAiB,GAAG,MAAM,GAAG,IAAI,CAalG"}
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Local-time arithmetic of the customer tools. "Local" is the zone of the HOST PROCESS: the
3
+ * canister wrapper derives every `timeOffset` from the process zone too, so the two cannot
4
+ * disagree — but the process zone must then be the salons' zone. `hostTimeZoneProblem` is the
5
+ * guard: a host in UTC would book every visit two hours off, and refusing is better than that.
6
+ */
7
+ const SLOT_MINUTES = 5;
8
+ const DAY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
9
+ const TIME_PATTERN = /^(\d{1,2}):(\d{2})$/;
10
+ const pad = (value) => String(value).padStart(2, "0");
11
+ /** The local day of a moment, `"YYYY-MM-DD"`. */
12
+ export function localDayOf(at) {
13
+ const date = new Date(at);
14
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
15
+ }
16
+ /** The local time of a moment, `"HH:mm"`. */
17
+ export function localTimeOf(at) {
18
+ const date = new Date(at);
19
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
20
+ }
21
+ /** `"YYYY-MM-DD HH:mm"` — what a model reads and sends back as `date` + `time`. */
22
+ export function localStampOf(at) {
23
+ return `${localDayOf(at)} ${localTimeOf(at)}`;
24
+ }
25
+ export function isDay(text) {
26
+ const match = DAY_PATTERN.exec(text);
27
+ if (!match)
28
+ return false;
29
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
30
+ const date = new Date(year, month - 1, day);
31
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
32
+ }
33
+ /** Minutes from midnight of `"HH:mm"` (`"9:30"` too), or `null` when it is no time of day. */
34
+ export function minutesOf(time) {
35
+ const match = TIME_PATTERN.exec(time.trim());
36
+ if (!match)
37
+ return null;
38
+ const [hours, minutes] = [Number(match[1]), Number(match[2])];
39
+ if (hours > 23 || minutes > 59)
40
+ return null;
41
+ return hours * 60 + minutes;
42
+ }
43
+ export function timeOfMinutes(minutes) {
44
+ return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`;
45
+ }
46
+ /** The moment of a local day at minutes from its midnight. `day` must pass `isDay`. */
47
+ export function momentOf(day, minutes) {
48
+ const [year, month, date] = day.split("-").map(Number);
49
+ return new Date(year, month - 1, date, Math.floor(minutes / 60), minutes % 60, 0, 0).getTime();
50
+ }
51
+ export function onSlotGrid(minutes) {
52
+ return minutes % SLOT_MINUTES === 0;
53
+ }
54
+ /** Start minutes, on the 5-minute grid, where a service of this duration fits in one window. */
55
+ export function freeStartMinutes(windows, durationMinutes) {
56
+ const starts = new Set();
57
+ for (const window of windows) {
58
+ const from = minutesOf(window.startTime);
59
+ // The wrapper writes the end of the day as "24:00" or "00:00".
60
+ const to = window.endTime === "24:00" || window.endTime === "00:00" ? 24 * 60 : minutesOf(window.endTime);
61
+ if (from === null || to === null)
62
+ continue;
63
+ const first = Math.ceil(from / SLOT_MINUTES) * SLOT_MINUTES;
64
+ for (let start = first; start + durationMinutes <= to; start += SLOT_MINUTES)
65
+ starts.add(start);
66
+ }
67
+ return [...starts].sort((a, b) => a - b);
68
+ }
69
+ /** Consecutive grid minutes folded into ranges — dozens of start times are noise to a model. */
70
+ export function foldStartRanges(starts) {
71
+ const ranges = [];
72
+ for (const start of starts) {
73
+ const last = ranges[ranges.length - 1];
74
+ if (last && start === last.to + SLOT_MINUTES)
75
+ last.to = start;
76
+ else
77
+ ranges.push({ from: start, to: start });
78
+ }
79
+ return ranges.map((range) => ({ from: timeOfMinutes(range.from), to: timeOfMinutes(range.to) }));
80
+ }
81
+ /** Minutes east of UTC of a named zone at a moment. */
82
+ function zoneOffsetMinutes(timeZone, at) {
83
+ const parts = new Intl.DateTimeFormat("en-US", {
84
+ timeZone,
85
+ hourCycle: "h23",
86
+ year: "numeric",
87
+ month: "numeric",
88
+ day: "numeric",
89
+ hour: "numeric",
90
+ minute: "numeric",
91
+ second: "numeric",
92
+ }).formatToParts(at);
93
+ const read = (type) => Number(parts.find((part) => part.type === type)?.value);
94
+ const asUtc = Date.UTC(read("year"), read("month") - 1, read("day"), read("hour"), read("minute"), read("second"));
95
+ return Math.round((asUtc - at.getTime()) / 60_000);
96
+ }
97
+ /**
98
+ * Why this process must not book for salons in `expectedTimeZone`, or `null` when it may: the
99
+ * process clock has to run in that zone, because the canister wrapper takes every offset from it.
100
+ */
101
+ export function hostTimeZoneProblem(expectedTimeZone, at = new Date()) {
102
+ let expected;
103
+ try {
104
+ expected = zoneOffsetMinutes(expectedTimeZone, at);
105
+ }
106
+ catch {
107
+ return `nieznana strefa czasowa „${expectedTimeZone}”`;
108
+ }
109
+ const actual = -at.getTimezoneOffset();
110
+ if (actual === expected)
111
+ return null;
112
+ return `proces działa w innej strefie czasowej niż salony (${expectedTimeZone}): różnica ${Math.abs(expected - actual)} min — ustaw TZ=${expectedTimeZone}`;
113
+ }
@@ -0,0 +1,17 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
2
+ import { type AgentTool } from "../types.js";
3
+ import type { CustomerCorePort } from "./customer-core-port.js";
4
+ export interface CustomerToolsContext {
5
+ port: CustomerCorePort;
6
+ /** The clock — a test sets it. */
7
+ now: () => Date;
8
+ /** The salons' time zone the host process must run in; `undefined` skips the guard. */
9
+ timeZone?: string;
10
+ }
11
+ export declare function searchSlotsTool(context: CustomerToolsContext): AgentTool;
12
+ export declare function getSalonTool(context: CustomerToolsContext): AgentTool;
13
+ export declare function getFreeTimesTool(context: CustomerToolsContext): AgentTool;
14
+ export declare function bookAppointmentTool(context: CustomerToolsContext, confirmations: TurnConfirmations | undefined): AgentTool;
15
+ export declare function listMyAppointmentsTool(context: CustomerToolsContext): AgentTool;
16
+ export declare function cancelAppointmentTool(context: CustomerToolsContext, confirmations: TurnConfirmations | undefined): AgentTool;
17
+ //# sourceMappingURL=tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../../src/customer/tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAI7D,OAAO,EAAW,KAAK,SAAS,EAAmB,MAAM,aAAa,CAAC;AACvE,OAAO,KAAK,EAAmB,gBAAgB,EAAkD,MAAM,yBAAyB,CAAC;AAejI,MAAM,WAAW,oBAAoB;IACjC,IAAI,EAAE,gBAAgB,CAAC;IACvB,kCAAkC;IAClC,GAAG,EAAE,MAAM,IAAI,CAAC;IAChB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,MAAM,CAAC;CACrB;AAwCD,wBAAgB,eAAe,CAAC,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAgFxE;AAUD,wBAAgB,YAAY,CAAC,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAkCrE;AAkCD,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,oBAAoB,GAAG,SAAS,CA2CzE;AAED,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,GAAG,SAAS,GAAG,SAAS,CA6E1H;AA6CD,wBAAgB,sBAAsB,CAAC,OAAO,EAAE,oBAAoB,GAAG,SAAS,CAkC/E;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,oBAAoB,EAAE,aAAa,EAAE,iBAAiB,GAAG,SAAS,GAAG,SAAS,CA4C5H"}
@@ -0,0 +1,415 @@
1
+ import { toToolError } from "../errors.js";
2
+ import { ArgumentError, readBoolean, readOptionalNumber, readOptionalString, readString } from "../tools/args.js";
3
+ import { writeGate } from "../tools/write-gate.js";
4
+ import { err, ok } from "../types.js";
5
+ import { foldStartRanges, freeStartMinutes, hostTimeZoneProblem, isDay, localDayOf, localStampOf, localTimeOf, minutesOf, momentOf, onSlotGrid, timeOfMinutes, } from "./time.js";
6
+ const DEFAULT_RADIUS_KM = 10;
7
+ const MAX_RADIUS_KM = 50;
8
+ const MAX_SALONS_SHOWN = 6;
9
+ const MAX_TIMES_SHOWN = 6;
10
+ /** "Pick a time": the asked moment, a little before it and the hour after it. */
11
+ const AROUND_TIME_MINUTES = [-30, -15, 0, 15, 30, 45, 60];
12
+ const CONFIRMED_FIELD = {
13
+ type: "boolean",
14
+ description: "false = pokaż podgląd bez zapisu; true = wykonaj, dopiero po zgodzie klienta w jego następnej wiadomości.",
15
+ };
16
+ const NO_LOCATION = "Aplikacja nie podała pozycji klienta, więc nie wiem, gdzie szukać. Poproś klienta, żeby włączył lokalizację w aplikacji i otworzył czat ponownie.";
17
+ /** Refuses every time-dependent tool while the host clock runs in the wrong zone. */
18
+ function zoneRefusal(context) {
19
+ if (!context.timeZone)
20
+ return null;
21
+ const problem = hostTimeZoneProblem(context.timeZone, context.now());
22
+ return problem ? err("unavailable", `Rezerwacje są chwilowo wyłączone: ${problem}.`) : null;
23
+ }
24
+ function readDay(args, key) {
25
+ const day = readString(args, key);
26
+ if (!isDay(day))
27
+ throw new ArgumentError(`Pole "${key}" musi być datą w formacie RRRR-MM-DD.`);
28
+ return day;
29
+ }
30
+ function readTimeMinutes(args, key) {
31
+ const minutes = minutesOf(readString(args, key));
32
+ if (minutes === null)
33
+ throw new ArgumentError(`Pole "${key}" musi być godziną w formacie GG:MM.`);
34
+ return minutes;
35
+ }
36
+ function serviceLine(service) {
37
+ return { serviceId: service.id, name: service.name, pricePln: service.pricePln, durationMinutes: service.durationMinutes };
38
+ }
39
+ export function searchSlotsTool(context) {
40
+ const { port } = context;
41
+ return {
42
+ name: "search_slots",
43
+ progress: "Szukam wolnych terminów w okolicy…",
44
+ description: "Szuka salonów w okolicy klienta, które mają wolny termin na usługę danego typu. Bez date i time szuka „jak najszybciej” (najbliższa godzina, a gdy nic nie ma — do 24 godzin naprzód). Z date i time sprawdza okolice tej godziny (pół godziny wcześniej do godziny później). Zwraca salony z usługami (serviceId, cena, czas) i wolnymi terminami. serviceTypeId weź z find_service_type.",
45
+ parameters: {
46
+ type: "object",
47
+ properties: {
48
+ serviceTypeId: { type: "string", description: "Id typu usługi z find_service_type." },
49
+ date: { type: "string", description: "Dzień RRRR-MM-DD. Pomiń razem z time, żeby szukać jak najszybciej." },
50
+ time: { type: "string", description: "Godzina GG:MM. Wymagana, gdy podajesz date." },
51
+ radiusKm: { type: "number", description: `Promień szukania w km, domyślnie ${DEFAULT_RADIUS_KM}, najwyżej ${MAX_RADIUS_KM}.` },
52
+ },
53
+ required: ["serviceTypeId"],
54
+ additionalProperties: false,
55
+ },
56
+ execute: async (args) => {
57
+ try {
58
+ const refusal = zoneRefusal(context);
59
+ if (refusal)
60
+ return refusal;
61
+ if (!port.location)
62
+ return err("invalid_arguments", NO_LOCATION);
63
+ const serviceTypeId = readString(args, "serviceTypeId");
64
+ const radiusKm = Math.min(Math.max(readOptionalNumber(args, "radiusKm") ?? DEFAULT_RADIUS_KM, 1), MAX_RADIUS_KM);
65
+ const dayText = readOptionalString(args, "date");
66
+ const timeText = readOptionalString(args, "time");
67
+ const now = context.now();
68
+ if ((dayText === undefined) !== (timeText === undefined)) {
69
+ throw new ArgumentError("Podaj date i time razem albo pomiń oba (jak najszybciej).");
70
+ }
71
+ let found;
72
+ let lead;
73
+ if (dayText === undefined) {
74
+ found = await port.searchAsap(serviceTypeId, radiusKm, now);
75
+ lead = found.phase === "extended" ? "W najbliższej godzinie nic wolnego, na które klient zdąży. Najbliższe terminy:" : "Najbliższe wolne terminy:";
76
+ }
77
+ else {
78
+ const day = readDay(args, "date");
79
+ const at = readTimeMinutes(args, "time");
80
+ const moments = AROUND_TIME_MINUTES.map((shift) => momentOf(day, at) + shift * 60_000)
81
+ // Only moments of the asked day's grid that are still ahead.
82
+ .filter((moment) => moment > now.getTime() && onSlotGrid(new Date(moment).getMinutes()));
83
+ if (moments.length === 0)
84
+ return err("invalid_arguments", "Ten termin już minął. Zapytaj klienta o późniejszy.");
85
+ found = await port.searchAtMoments(serviceTypeId, radiusKm, moments);
86
+ lead = `Wolne terminy w okolicach ${day} ${timeOfMinutes(at)}:`;
87
+ }
88
+ if (found.salons.length === 0) {
89
+ if (found.failedProbes > 0)
90
+ return err("unavailable", "Nie udało się sprawdzić terminów — kanister nie odpowiadał. Spróbuj ponownie za chwilę.");
91
+ return ok(`Nic wolnego w promieniu ${radiusKm} km. Zaproponuj inną godzinę, inny dzień albo większy promień.`, []);
92
+ }
93
+ const ranked = [...found.salons].sort(bySoonestThenNearest).slice(0, MAX_SALONS_SHOWN);
94
+ const names = new Map((await port.getSalons(ranked.map((salon) => salon.salonId))).map((salon) => [salon.id, salon]));
95
+ const data = ranked.map((salon) => ({
96
+ salonId: salon.salonId,
97
+ name: names.get(salon.salonId)?.name ?? "Salon",
98
+ address: names.get(salon.salonId)?.address ?? "",
99
+ distanceKm: Math.round(salon.distanceKm * 10) / 10,
100
+ services: salon.services.map(serviceLine),
101
+ freeAt: salon.availableAt.slice(0, MAX_TIMES_SHOWN).map(localStampOf),
102
+ }));
103
+ const more = found.salons.length > ranked.length ? ` Pokazuję ${ranked.length} z ${found.salons.length} salonów.` : "";
104
+ return ok(`${lead} ${ranked.length} salon(y).${more} Terminy to początek wizyty; inne godziny w danym salonie sprawdź przez get_free_times.`, data);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof ArgumentError)
108
+ return err("invalid_arguments", error.message);
109
+ return toToolError(error, "Nie udało się wyszukać terminów.");
110
+ }
111
+ },
112
+ };
113
+ }
114
+ function firstMoment(salon) {
115
+ return salon.soonestAt ?? salon.availableAt[0] ?? Number.MAX_SAFE_INTEGER;
116
+ }
117
+ function bySoonestThenNearest(a, b) {
118
+ return firstMoment(a) - firstMoment(b) || a.distanceKm - b.distanceKm;
119
+ }
120
+ export function getSalonTool(context) {
121
+ const { port } = context;
122
+ return {
123
+ name: "get_salon",
124
+ progress: "Sprawdzam salon…",
125
+ description: "Zwraca salon: nazwę, adres, AKTYWNE usługi z cenami (serviceId) i aktywne promocje z kodami rabatowymi. salonId weź z search_slots albo list_my_appointments.",
126
+ parameters: {
127
+ type: "object",
128
+ properties: { salonId: { type: "string", description: "Id salonu." } },
129
+ required: ["salonId"],
130
+ additionalProperties: false,
131
+ },
132
+ execute: async (args) => {
133
+ try {
134
+ const salonId = readString(args, "salonId");
135
+ const [salon] = await port.getSalons([salonId]);
136
+ if (!salon)
137
+ return err("not_found", "Nie ma takiego salonu. Id weź z search_slots.");
138
+ const [services, promos] = await Promise.all([port.listSalonServices(salonId), port.listSalonPromos(salonId)]);
139
+ return ok(`${salon.name}, ${salon.address}: ${services.length} usług(i), ${promos.length} promocji.`, {
140
+ salonId,
141
+ name: salon.name,
142
+ address: salon.address,
143
+ services: services.map(serviceLine),
144
+ promos,
145
+ });
146
+ }
147
+ catch (error) {
148
+ if (error instanceof ArgumentError)
149
+ return err("invalid_arguments", error.message);
150
+ return toToolError(error, "Nie udało się odczytać salonu.");
151
+ }
152
+ },
153
+ };
154
+ }
155
+ /** The salon's active service, or the tool error that says why not. */
156
+ async function findService(port, salonId, serviceId) {
157
+ const services = await port.listSalonServices(salonId);
158
+ const service = services.find((candidate) => candidate.id === serviceId);
159
+ if (!service)
160
+ return err("not_found", "Ten salon nie ma takiej aktywnej usługi. Id salonu i usługi weź z search_slots albo get_salon.");
161
+ if (service.workerIds.length === 0)
162
+ return err("not_found", `Usługi „${service.name}” nikt teraz nie wykonuje — nie da się jej zarezerwować.`);
163
+ return service;
164
+ }
165
+ /** Free start minutes of a service on a day → the first worker free then (the app's rule). */
166
+ async function freeStartsByWorker(port, service, day) {
167
+ const perWorker = await Promise.all(service.workerIds.map(async (workerId) => {
168
+ try {
169
+ return { workerId, starts: freeStartMinutes(await port.freeWindows(service.salonId, workerId, day), service.durationMinutes) };
170
+ }
171
+ catch {
172
+ // A worker with no hours that day is refused by the canister; the others still count.
173
+ return { workerId, starts: [] };
174
+ }
175
+ }));
176
+ const workerAt = new Map();
177
+ for (const { workerId, starts } of perWorker) {
178
+ for (const start of starts)
179
+ if (!workerAt.has(start))
180
+ workerAt.set(start, workerId);
181
+ }
182
+ return workerAt;
183
+ }
184
+ export function getFreeTimesTool(context) {
185
+ const { port } = context;
186
+ return {
187
+ name: "get_free_times",
188
+ progress: "Sprawdzam wolne godziny…",
189
+ description: "Zwraca wolne godziny rozpoczęcia jednej usługi w jednym salonie w danym dniu, jako przedziały (wizyta może zacząć się co 5 minut od from do to włącznie).",
190
+ parameters: {
191
+ type: "object",
192
+ properties: {
193
+ salonId: { type: "string", description: "Id salonu." },
194
+ serviceId: { type: "string", description: "Id usługi z search_slots albo get_salon." },
195
+ date: { type: "string", description: "Dzień RRRR-MM-DD." },
196
+ },
197
+ required: ["salonId", "serviceId", "date"],
198
+ additionalProperties: false,
199
+ },
200
+ execute: async (args) => {
201
+ try {
202
+ const refusal = zoneRefusal(context);
203
+ if (refusal)
204
+ return refusal;
205
+ const salonId = readString(args, "salonId");
206
+ const day = readDay(args, "date");
207
+ const now = context.now();
208
+ if (day < localDayOf(now))
209
+ return err("invalid_arguments", "Ten dzień już minął.");
210
+ const service = await findService(port, salonId, readString(args, "serviceId"));
211
+ if ("status" in service)
212
+ return service;
213
+ const nowMinutes = day === localDayOf(now) ? now.getHours() * 60 + now.getMinutes() : -1;
214
+ const starts = [...(await freeStartsByWorker(port, service, day)).keys()].filter((start) => start > nowMinutes).sort((a, b) => a - b);
215
+ if (starts.length === 0)
216
+ return ok(`„${service.name}” — ${day}: brak wolnych godzin.`, []);
217
+ return ok(`„${service.name}” — ${day}: wolne godziny rozpoczęcia (${service.durationMinutes} min, ${service.pricePln} zł).`, foldStartRanges(starts));
218
+ }
219
+ catch (error) {
220
+ if (error instanceof ArgumentError)
221
+ return err("invalid_arguments", error.message);
222
+ return toToolError(error, "Nie udało się sprawdzić wolnych godzin.");
223
+ }
224
+ },
225
+ };
226
+ }
227
+ export function bookAppointmentTool(context, confirmations) {
228
+ const { port } = context;
229
+ const gate = writeGate("book_appointment", confirmations);
230
+ return {
231
+ name: "book_appointment",
232
+ progress: "Rezerwuję wizytę…",
233
+ description: "Rezerwuje wizytę w imieniu klienta. Najpierw confirmed=false: dostaniesz podsumowanie do pokazania klientowi. Rezerwacja następuje dopiero przy confirmed=true po jego zgodzie. Pracownika dobiera system.",
234
+ parameters: gate.parameters({
235
+ type: "object",
236
+ properties: {
237
+ salonId: { type: "string", description: "Id salonu." },
238
+ serviceId: { type: "string", description: "Id usługi z search_slots albo get_salon." },
239
+ date: { type: "string", description: "Dzień wizyty RRRR-MM-DD." },
240
+ time: { type: "string", description: "Godzina rozpoczęcia GG:MM, wielokrotność 5 minut." },
241
+ discountCode: { type: "string", description: "Kod rabatowy, tylko gdy klient go podał albo pochodzi z promocji salonu." },
242
+ notes: { type: "string", description: "Uwagi klienta dla salonu." },
243
+ confirmed: CONFIRMED_FIELD,
244
+ },
245
+ required: ["salonId", "serviceId", "date", "time", "confirmed"],
246
+ additionalProperties: false,
247
+ }),
248
+ execute: async (args) => {
249
+ try {
250
+ const zone = zoneRefusal(context);
251
+ if (zone)
252
+ return zone;
253
+ const salonId = readString(args, "salonId");
254
+ const serviceId = readString(args, "serviceId");
255
+ const day = readDay(args, "date");
256
+ const startMinutes = readTimeMinutes(args, "time");
257
+ const discountCode = readOptionalString(args, "discountCode");
258
+ const notes = readOptionalString(args, "notes");
259
+ if (!onSlotGrid(startMinutes))
260
+ throw new ArgumentError("Wizyta może zacząć się tylko o pełnych 5 minutach (np. 10:00, 10:05).");
261
+ // The echo is the change in its normal form, whichever spelling the model used.
262
+ const echo = { salonId, serviceId, date: day, time: timeOfMinutes(startMinutes) };
263
+ if (discountCode !== undefined)
264
+ echo.discountCode = discountCode;
265
+ if (notes !== undefined)
266
+ echo.notes = notes;
267
+ const refusal = gate.refusal(args, echo);
268
+ if (refusal)
269
+ return refusal;
270
+ const startAt = momentOf(day, startMinutes);
271
+ if (startAt <= context.now().getTime())
272
+ return err("invalid_arguments", "Ten termin już minął. Zapytaj klienta o późniejszy.");
273
+ const service = await findService(port, salonId, serviceId);
274
+ if ("status" in service)
275
+ return service;
276
+ // Checked on BOTH calls: a refused booking still spends the customer's daily quota,
277
+ // and the slot may have gone between the preview and the "yes".
278
+ const workerId = (await freeStartsByWorker(port, service, day)).get(startMinutes);
279
+ if (workerId === undefined) {
280
+ return err("not_found", `Termin ${day} ${timeOfMinutes(startMinutes)} nie jest już wolny. Sprawdź inne godziny przez get_free_times.`);
281
+ }
282
+ const [salon] = await port.getSalons([salonId]);
283
+ const where = salon ? `${salon.name} (${salon.address})` : "salon";
284
+ const code = discountCode === undefined ? "" : `, z kodem rabatowym ${discountCode}`;
285
+ const what = `„${service.name}” w ${where}, ${day} o ${timeOfMinutes(startMinutes)}, ${service.durationMinutes} min, cena ${service.pricePln} zł${code}`;
286
+ if (!readBoolean(args, "confirmed"))
287
+ return gate.preview(`Zarezerwuję: ${what}.`, echo);
288
+ const refused = gate.redeem(args, echo);
289
+ if (refused)
290
+ return refused;
291
+ const appointmentId = await port.book({ salonId, serviceId, workerId, startAt, notes: notes ?? "", discountCode });
292
+ return ok(`Zarezerwowano: ${what}.${await pricePaidNote(port, appointmentId, service.pricePln)} Wizyta jest w zakładce „Wizyty”.`, { appointmentId });
293
+ }
294
+ catch (error) {
295
+ if (error instanceof ArgumentError)
296
+ return err("invalid_arguments", error.message);
297
+ return toToolError(error, "Nie udało się zarezerwować wizyty.");
298
+ }
299
+ },
300
+ };
301
+ }
302
+ /** What the customer really pays, when a discount code changed it. Never fails a booking that is
303
+ * already made — and a query right after an update may not see it yet. */
304
+ async function pricePaidNote(port, appointmentId, listPricePln) {
305
+ try {
306
+ const booked = (await port.listMyAppointments()).find((appointment) => appointment.id === appointmentId);
307
+ if (!booked || booked.pricePln === listPricePln)
308
+ return "";
309
+ return ` Cena po rabacie: ${booked.pricePln} zł.`;
310
+ }
311
+ catch {
312
+ return "";
313
+ }
314
+ }
315
+ /** The customer's visits still ahead, soonest first, with the names a person knows them by. */
316
+ async function upcomingAppointments(port, now) {
317
+ const upcoming = (await port.listMyAppointments())
318
+ .filter((appointment) => (appointment.status === "Confirmed" || appointment.status === "Pending") && appointment.startAt > now.getTime())
319
+ .sort((a, b) => a.startAt - b.startAt);
320
+ const salonIds = [...new Set(upcoming.map((appointment) => appointment.salonId))];
321
+ const salons = new Map((await port.getSalons(salonIds)).map((salon) => [salon.id, salon]));
322
+ const serviceNames = new Map();
323
+ await Promise.all(salonIds.map(async (salonId) => {
324
+ try {
325
+ for (const service of await port.listSalonServices(salonId))
326
+ serviceNames.set(service.id, service.name);
327
+ }
328
+ catch {
329
+ // A visit is still worth listing without its service's name.
330
+ }
331
+ }));
332
+ return upcoming.map((appointment) => ({ ...appointment, salon: salons.get(appointment.salonId), serviceName: serviceNames.get(appointment.serviceId) }));
333
+ }
334
+ function describeAppointment(appointment) {
335
+ const what = appointment.serviceName ? `„${appointment.serviceName}”` : "wizyta";
336
+ const where = appointment.salon ? ` w ${appointment.salon.name} (${appointment.salon.address})` : "";
337
+ return `${what}${where}, ${localDayOf(appointment.startAt)} o ${localTimeOf(appointment.startAt)}`;
338
+ }
339
+ export function listMyAppointmentsTool(context) {
340
+ const { port } = context;
341
+ return {
342
+ name: "list_my_appointments",
343
+ progress: "Sprawdzam Twoje wizyty…",
344
+ description: "Zwraca nadchodzące wizyty klienta (potwierdzone, jeszcze nie odbyte): id wizyty, salon, usługę, termin i cenę.",
345
+ parameters: { type: "object", properties: {}, additionalProperties: false },
346
+ execute: async () => {
347
+ try {
348
+ const zone = zoneRefusal(context);
349
+ if (zone)
350
+ return zone;
351
+ const upcoming = await upcomingAppointments(port, context.now());
352
+ if (upcoming.length === 0)
353
+ return ok("Klient nie ma nadchodzących wizyt.", []);
354
+ return ok(`Nadchodzące wizyty: ${upcoming.length}.`, upcoming.map((appointment) => ({
355
+ appointmentId: appointment.id,
356
+ salonId: appointment.salonId,
357
+ salonName: appointment.salon?.name ?? "Salon",
358
+ address: appointment.salon?.address ?? "",
359
+ serviceName: appointment.serviceName ?? "",
360
+ start: localStampOf(appointment.startAt),
361
+ durationMinutes: appointment.durationMinutes,
362
+ pricePln: appointment.pricePln,
363
+ })));
364
+ }
365
+ catch (error) {
366
+ return toToolError(error, "Nie udało się odczytać wizyt.");
367
+ }
368
+ },
369
+ };
370
+ }
371
+ export function cancelAppointmentTool(context, confirmations) {
372
+ const { port } = context;
373
+ const gate = writeGate("cancel_appointment", confirmations);
374
+ return {
375
+ name: "cancel_appointment",
376
+ progress: "Odwołuję wizytę…",
377
+ description: "Odwołuje nadchodzącą wizytę klienta. Najpierw confirmed=false (podsumowanie dla klienta), potem confirmed=true po jego zgodzie. appointmentId weź z list_my_appointments.",
378
+ parameters: gate.parameters({
379
+ type: "object",
380
+ properties: {
381
+ appointmentId: { type: "string", description: "Id wizyty z list_my_appointments." },
382
+ confirmed: CONFIRMED_FIELD,
383
+ },
384
+ required: ["appointmentId", "confirmed"],
385
+ additionalProperties: false,
386
+ }),
387
+ execute: async (args) => {
388
+ try {
389
+ const zone = zoneRefusal(context);
390
+ if (zone)
391
+ return zone;
392
+ const appointmentId = readString(args, "appointmentId");
393
+ const echo = { appointmentId };
394
+ const refusal = gate.refusal(args, echo);
395
+ if (refusal)
396
+ return refusal;
397
+ const appointment = (await upcomingAppointments(port, context.now())).find((candidate) => candidate.id === appointmentId);
398
+ if (!appointment)
399
+ return err("not_found", "Klient nie ma takiej nadchodzącej wizyty. Id weź z list_my_appointments.");
400
+ if (!readBoolean(args, "confirmed"))
401
+ return gate.preview(`Odwołam wizytę: ${describeAppointment(appointment)}.`, echo);
402
+ const refused = gate.redeem(args, echo);
403
+ if (refused)
404
+ return refused;
405
+ await port.cancel(appointmentId);
406
+ return ok(`Odwołano wizytę: ${describeAppointment(appointment)}.`, { appointmentId });
407
+ }
408
+ catch (error) {
409
+ if (error instanceof ArgumentError)
410
+ return err("invalid_arguments", error.message);
411
+ return toToolError(error, "Nie udało się odwołać wizyty.");
412
+ }
413
+ },
414
+ };
415
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,13 @@ export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations
2
2
  export type { ConfirmationLedgerOptions, ConfirmationRefusal, RedeemResult, TurnConfirmations, } from "./confirmations.js";
3
3
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
4
4
  export type { CreateSalonReadToolsOptions, CreateSalonToolsFromPortOptions, CreateSalonToolsOptions, } from "./create-salon-tools.js";
5
+ export { createCustomerReadTools, createCustomerReadToolsFromPort, createCustomerTools, createCustomerToolsFromPort, DEFAULT_SALON_TIME_ZONE, } from "./customer/create-customer-tools.js";
6
+ export type { CreateCustomerReadToolsOptions, CreateCustomerToolsFromPortOptions, CreateCustomerToolsOptions, } from "./customer/create-customer-tools.js";
7
+ export type { AppointmentStatusView, AppointmentView, BookingInput, Coordinates, CustomerCorePort, CustomerPromoView, CustomerServiceView, SalonSlotsView, SalonView, SlotSearchView, } from "./customer/customer-core-port.js";
8
+ export { IcCustomerCorePort } from "./customer/ic-customer-core-port.js";
9
+ export type { IcCustomerCorePortOptions } from "./customer/ic-customer-core-port.js";
10
+ export { CUSTOMER_AGENT_PERSONA_PL } from "./customer/persona.js";
11
+ export { hostTimeZoneProblem } from "./customer/time.js";
5
12
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
6
13
  export type { IcSalonCorePortOptions } from "./ic-salon-core-port.js";
7
14
  export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACrF,YAAY,EACR,yBAAyB,EACzB,mBAAmB,EACnB,YAAY,EACZ,iBAAiB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EACR,2BAA2B,EAC3B,+BAA+B,EAC/B,uBAAuB,GAC1B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EACR,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,iBAAiB,EACjB,SAAS,EACT,UAAU,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,GAAG,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACrF,YAAY,EACR,yBAAyB,EACzB,mBAAmB,EACnB,YAAY,EACZ,iBAAiB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EACR,2BAA2B,EAC3B,+BAA+B,EAC/B,uBAAuB,GAC1B,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACH,uBAAuB,EACvB,+BAA+B,EAC/B,mBAAmB,EACnB,2BAA2B,EAC3B,uBAAuB,GAC1B,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EACR,8BAA8B,EAC9B,kCAAkC,EAClC,0BAA0B,GAC7B,MAAM,qCAAqC,CAAC;AAC7C,YAAY,EACR,qBAAqB,EACrB,eAAe,EACf,YAAY,EACZ,WAAW,EACX,gBAAgB,EAChB,iBAAiB,EACjB,mBAAmB,EACnB,cAAc,EACd,SAAS,EACT,cAAc,GACjB,MAAM,kCAAkC,CAAC;AAC1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,qCAAqC,CAAC;AACzE,YAAY,EAAE,yBAAyB,EAAE,MAAM,qCAAqC,CAAC;AACrF,OAAO,EAAE,yBAAyB,EAAE,MAAM,uBAAuB,CAAC;AAClE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAC;AAC1D,YAAY,EAAE,sBAAsB,EAAE,MAAM,yBAAyB,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAC7E,YAAY,EACR,iBAAiB,EACjB,gBAAgB,EAChB,SAAS,EACT,SAAS,EACT,eAAe,EACf,WAAW,EACX,aAAa,EACb,cAAc,EACd,eAAe,EACf,WAAW,EACX,iBAAiB,EACjB,iBAAiB,EACjB,SAAS,EACT,UAAU,GACb,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAE,GAAG,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,YAAY,CAAC;AACxD,YAAY,EAAE,SAAS,EAAE,aAAa,EAAE,oBAAoB,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,5 +1,9 @@
1
1
  export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
2
2
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
3
+ export { createCustomerReadTools, createCustomerReadToolsFromPort, createCustomerTools, createCustomerToolsFromPort, DEFAULT_SALON_TIME_ZONE, } from "./customer/create-customer-tools.js";
4
+ export { IcCustomerCorePort } from "./customer/ic-customer-core-port.js";
5
+ export { CUSTOMER_AGENT_PERSONA_PL } from "./customer/persona.js";
6
+ export { hostTimeZoneProblem } from "./customer/time.js";
3
7
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
4
8
  export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
5
9
  export { err, needsConfirmation, ok } from "./types.js";
@@ -4,5 +4,6 @@ import { type AgentTool } from "../types.js";
4
4
  export declare function createServiceTools(port: SalonCorePort, confirmations?: TurnConfirmations): AgentTool[];
5
5
  /** `access` must match the port: `"public"` only for a port that reads the visitor's view. */
6
6
  export declare function listServicesTool(port: SalonCorePort, access?: SalonAccess): AgentTool;
7
- export declare function findServiceTypeTool(port: SalonCorePort): AgentTool;
7
+ /** Takes any port that can search the catalogue — the customer set shares this tool. */
8
+ export declare function findServiceTypeTool(port: Pick<SalonCorePort, "findServiceTypes">): AgentTool;
8
9
  //# sourceMappingURL=services.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/tools/services.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkC,MAAM,uBAAuB,CAAC;AACxG,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAwBtD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CAStG;AAoBD,8FAA8F;AAC9F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB9F;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,CAiClE"}
1
+ {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/tools/services.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkC,MAAM,uBAAuB,CAAC;AACxG,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAwBtD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CAStG;AAoBD,8FAA8F;AAC9F,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB9F;AAED,wFAAwF;AACxF,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,IAAI,CAAC,aAAa,EAAE,kBAAkB,CAAC,GAAG,SAAS,CAiC5F"}
@@ -54,6 +54,7 @@ export function listServicesTool(port, access = "owner") {
54
54
  },
55
55
  };
56
56
  }
57
+ /** Takes any port that can search the catalogue — the customer set shares this tool. */
57
58
  export function findServiceTypeTool(port) {
58
59
  return {
59
60
  name: "find_service_type",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jsm-mit/sultana-agent-tools-package",
3
- "version": "0.3.0",
4
- "description": "Agent tool layer for a Sultana salon: framework-agnostic tools (services, promos, schedule) over the Sultana core canister wrapper, plus a read-only set that needs no identity.",
3
+ "version": "0.4.0",
4
+ "description": "Agent tool layer for Sultana: framework-agnostic tools over the Sultana core canister wrapper — a salon owner's set (services, promos, schedule) and a customer's set (search, free times, booking), each with a read-only set that needs no identity.",
5
5
  "homepage": "https://github.com/JSM-Sultana/sultana-agent-tools-package#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/JSM-Sultana/sultana-agent-tools-package/issues"
@@ -31,12 +31,13 @@
31
31
  "sandbox": "npx tsx --env-file-if-exists=.env sandbox/main.ts",
32
32
  "prepare": "npm run build",
33
33
  "test-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-salon-tools.ts",
34
+ "test-customer-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-customer-tools.ts",
34
35
  "whoami": "npx tsx --env-file-if-exists=.env sandbox/whoami.ts",
35
36
  "publish-public": "bash scripts/publish-public.sh"
36
37
  },
37
38
  "dependencies": {
38
39
  "@icp-sdk/core": "^6.1.0",
39
- "@jsm-mit/sultana-core-motoko-package": "^0.17.0"
40
+ "@jsm-mit/sultana-core-motoko-package": "^0.21.1"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@jsm-mit/utils-package": "^0.5.0",