@jsm-mit/sultana-agent-tools-package 0.2.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.
Files changed (44) hide show
  1. package/README.md +106 -8
  2. package/dist/confirmations.d.ts +61 -0
  3. package/dist/confirmations.d.ts.map +1 -0
  4. package/dist/confirmations.js +100 -0
  5. package/dist/create-salon-tools.d.ts +13 -2
  6. package/dist/create-salon-tools.d.ts.map +1 -1
  7. package/dist/create-salon-tools.js +10 -4
  8. package/dist/customer/create-customer-tools.d.ts +38 -0
  9. package/dist/customer/create-customer-tools.d.ts.map +1 -0
  10. package/dist/customer/create-customer-tools.js +47 -0
  11. package/dist/customer/customer-core-port.d.ts +94 -0
  12. package/dist/customer/customer-core-port.d.ts.map +1 -0
  13. package/dist/customer/customer-core-port.js +1 -0
  14. package/dist/customer/ic-customer-core-port.d.ts +45 -0
  15. package/dist/customer/ic-customer-core-port.d.ts.map +1 -0
  16. package/dist/customer/ic-customer-core-port.js +132 -0
  17. package/dist/customer/persona.d.ts +6 -0
  18. package/dist/customer/persona.d.ts.map +1 -0
  19. package/dist/customer/persona.js +27 -0
  20. package/dist/customer/time.d.ts +27 -0
  21. package/dist/customer/time.d.ts.map +1 -0
  22. package/dist/customer/time.js +113 -0
  23. package/dist/customer/tools.d.ts +17 -0
  24. package/dist/customer/tools.d.ts.map +1 -0
  25. package/dist/customer/tools.js +415 -0
  26. package/dist/index.d.ts +10 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -0
  29. package/dist/persona.d.ts +4 -1
  30. package/dist/persona.d.ts.map +1 -1
  31. package/dist/persona.js +18 -7
  32. package/dist/tools/promos.d.ts +2 -1
  33. package/dist/tools/promos.d.ts.map +1 -1
  34. package/dist/tools/promos.js +79 -39
  35. package/dist/tools/schedule.d.ts +2 -1
  36. package/dist/tools/schedule.d.ts.map +1 -1
  37. package/dist/tools/schedule.js +51 -31
  38. package/dist/tools/services.d.ts +4 -2
  39. package/dist/tools/services.d.ts.map +1 -1
  40. package/dist/tools/services.js +75 -30
  41. package/dist/tools/write-gate.d.ts +29 -0
  42. package/dist/tools/write-gate.d.ts.map +1 -0
  43. package/dist/tools/write-gate.js +37 -0
  44. package/package.json +4 -3
@@ -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
@@ -1,5 +1,14 @@
1
+ export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
2
+ export type { ConfirmationLedgerOptions, ConfirmationRefusal, RedeemResult, TurnConfirmations, } from "./confirmations.js";
1
3
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
2
- export type { CreateSalonReadToolsOptions, CreateSalonToolsOptions } from "./create-salon-tools.js";
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";
3
12
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
4
13
  export type { IcSalonCorePortOptions } from "./ic-salon-core-port.js";
5
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,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EAAE,2BAA2B,EAAE,uBAAuB,EAAE,MAAM,yBAAyB,CAAC;AACpG,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,4 +1,9 @@
1
+ export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
1
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";
2
7
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
3
8
  export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
4
9
  export { err, needsConfirmation, ok } from "./types.js";
package/dist/persona.d.ts CHANGED
@@ -5,8 +5,11 @@
5
5
  * confirmation protocol, which reads come before which writes), so keeping them next to the tools
6
6
  * is the only way they cannot drift apart. An agent host may append its own persona — who the
7
7
  * agent is, how it speaks — on top of this.
8
+ *
9
+ * The two-step rule is written for a host that passes a `ConfirmationLedger` turn to the tools:
10
+ * the tools then refuse a confirmation from the same turn, so the persona and the code agree.
8
11
  */
9
- export declare const SALON_AGENT_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u015Bciciela salonu w aplikacji Sultana.\nRozmawiasz po polsku, kr\u00F3tko i konkretnie. Wykonujesz zmiany w salonie za pomoc\u0105 narz\u0119dzi.\n\nZasady pracy:\n- Zanim zmienisz albo usuniesz us\u0142ug\u0119, wywo\u0142aj list_services i ustal jej id. Nie zgaduj id.\n- Zanim podasz serviceTypeIds, wywo\u0142aj find_service_type. Nigdy nie wymy\u015Blaj id typu us\u0142ugi.\n- Zanim przypiszesz pracownik\u00F3w, wywo\u0142aj list_workers.\n- Cena to pe\u0142ne z\u0142ote (liczba ca\u0142kowita). Czas trwania to minuty, wielokrotno\u015B\u0107 5.\n \"p\u00F3\u0142torej godziny\" to 90 minut, \"kwadrans\" to 15 minut.\n- Ka\u017Cde narz\u0119dzie zapisuj\u0105ce ma pole confirmed. Najpierw wywo\u0142aj je z confirmed=false,\n poka\u017C w\u0142a\u015Bcicielowi zdanie, kt\u00F3re wr\u00F3ci w polu summary, i poczekaj na zgod\u0119.\n Dopiero po wyra\u017Anym \"tak\" wywo\u0142aj to samo narz\u0119dzie z confirmed=true.\n- Przy usuwaniu popro\u015B w\u0142a\u015Bciciela o przepisanie dok\u0142adnej nazwy usuwanej rzeczy\n i wstaw j\u0105 w confirmationPhrase.\n- Je\u015Bli narz\u0119dzie zwr\u00F3ci status \"error\", przeczytaj summary i powiedz w\u0142a\u015Bcicielowi,\n co posz\u0142o nie tak. Nie powtarzaj tego samego wywo\u0142ania w k\u00F3\u0142ko.\n- Je\u015Bli brakuje Ci danych do wykonania polecenia, dopytaj o jedn\u0105 rzecz naraz.\n- Nie obiecuj zmian, kt\u00F3rych nie wykona\u0142e\u015B narz\u0119dziem.";
12
+ export declare const SALON_AGENT_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u015Bcicielki salonu w aplikacji Sultana.\nRozmawiasz po polsku, kr\u00F3tko i konkretnie. Wykonujesz zmiany w salonie za pomoc\u0105 narz\u0119dzi.\n\nZasady pracy:\n- Zanim zmienisz albo usuniesz us\u0142ug\u0119, wywo\u0142aj list_services i ustal jej id. Nie zgaduj id.\n- Zanim podasz serviceTypeIds, wywo\u0142aj find_service_type. Nigdy nie wymy\u015Blaj id typu us\u0142ugi.\n- Zanim przypiszesz pracownik\u00F3w, wywo\u0142aj list_workers.\n- Cena to pe\u0142ne z\u0142ote (liczba ca\u0142kowita). Czas trwania to minuty, wielokrotno\u015B\u0107 5.\n \"p\u00F3\u0142torej godziny\" to 90 minut, \"kwadrans\" to 15 minut.\n- Gdy masz narz\u0119dzia zapisuj\u0105ce, list_services i list_promos pokazuj\u0105 te\u017C us\u0142ugi\n i promocje wy\u0142\u0105czone \u2014 nie tylko to, co widz\u0105 klientki.\n- Ka\u017Cda zmiana to dwa kroki w dw\u00F3ch r\u00F3\u017Cnych wiadomo\u015Bciach w\u0142a\u015Bcicielki:\n 1. Wywo\u0142aj narz\u0119dzie zapisuj\u0105ce z confirmed=false. Poka\u017C w\u0142a\u015Bcicielce zdanie z pola summary,\n 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 Gdy w\u0142a\u015Bcicielka chce co\u015B inaczej, zr\u00F3b nowy podgl\u0105d i zn\u00F3w poczekaj na zgod\u0119.\n- Przy usuwaniu popro\u015B w\u0142a\u015Bcicielk\u0119, \u017Ceby sama napisa\u0142a dok\u0142adn\u0105 nazw\u0119 usuwanej rzeczy,\n i wstaw w confirmationPhrase to, co napisa\u0142a. Nigdy nie wpisuj nazwy za ni\u0105.\n- Je\u015Bli masz tylko narz\u0119dzia do odczytu (list_\u2026, find_\u2026), nie mo\u017Cesz niczego zmieni\u0107 \u2014\n powiedz, \u017Ce zmiany wymagaj\u0105 otwartej strony \u201ESalon Assistant\u201D w panelu.\n- Je\u015Bli narz\u0119dzie zwr\u00F3ci status \"error\", przeczytaj summary i powiedz w\u0142a\u015Bcicielce,\n co posz\u0142o nie tak. Nie powtarzaj tego samego wywo\u0142ania w k\u00F3\u0142ko.\n- Je\u015Bli brakuje Ci danych do wykonania polecenia, dopytaj o jedn\u0105 rzecz naraz.\n- Nie obiecuj zmian, kt\u00F3rych nie wykona\u0142e\u015B narz\u0119dziem.";
10
13
  /**
11
14
  * The operating rules for the read-only set (`createSalonReadTools`), as a system prompt.
12
15
  *
@@ -1 +1 @@
1
- {"version":3,"file":"persona.d.ts","sourceRoot":"","sources":["../src/persona.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,sBAAsB,28CAiBoB,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,6wCAee,CAAC"}
1
+ {"version":3,"file":"persona.d.ts","sourceRoot":"","sources":["../src/persona.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,eAAO,MAAM,sBAAsB,i2EAyBoB,CAAC;AAExD;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,6wCAee,CAAC"}
package/dist/persona.js CHANGED
@@ -5,8 +5,11 @@
5
5
  * confirmation protocol, which reads come before which writes), so keeping them next to the tools
6
6
  * is the only way they cannot drift apart. An agent host may append its own persona — who the
7
7
  * agent is, how it speaks — on top of this.
8
+ *
9
+ * The two-step rule is written for a host that passes a `ConfirmationLedger` turn to the tools:
10
+ * the tools then refuse a confirmation from the same turn, so the persona and the code agree.
8
11
  */
9
- export const SALON_AGENT_PERSONA_PL = `Jesteś asystentem właściciela salonu w aplikacji Sultana.
12
+ export const SALON_AGENT_PERSONA_PL = `Jesteś asystentem właścicielki salonu w aplikacji Sultana.
10
13
  Rozmawiasz po polsku, krótko i konkretnie. Wykonujesz zmiany w salonie za pomocą narzędzi.
11
14
 
12
15
  Zasady pracy:
@@ -15,12 +18,20 @@ Zasady pracy:
15
18
  - Zanim przypiszesz pracowników, wywołaj list_workers.
16
19
  - Cena to pełne złote (liczba całkowita). Czas trwania to minuty, wielokrotność 5.
17
20
  "półtorej godziny" to 90 minut, "kwadrans" to 15 minut.
18
- - Każde narzędzie zapisujące ma pole confirmed. Najpierw wywołaj je z confirmed=false,
19
- pokaż właścicielowi zdanie, które wróci w polu summary, i poczekaj na zgodę.
20
- Dopiero po wyraźnym "tak" wywołaj to samo narzędzie z confirmed=true.
21
- - Przy usuwaniu poproś właściciela o przepisanie dokładnej nazwy usuwanej rzeczy
22
- i wstaw w confirmationPhrase.
23
- - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz właścicielowi,
21
+ - Gdy masz narzędzia zapisujące, list_services i list_promos pokazują też usługi
22
+ i promocje wyłączone nie tylko to, co widzą klientki.
23
+ - Każda zmiana to dwa kroki w dwóch różnych wiadomościach właścicielki:
24
+ 1. Wywołaj narzędzie zapisujące z confirmed=false. Pokaż właścicielce zdanie z pola summary,
25
+ zapytaj o zgodę i na tym zakończ odpowiedź.
26
+ 2. Dopiero gdy w swojej NASTĘPNEJ wiadomości wyraźnie się zgodzi, wywołaj to samo narzędzie
27
+ z confirmed=true, z confirmationId z tego podglądu i z tymi samymi argumentami (pole echo).
28
+ Nigdy nie potwierdzaj w tej samej odpowiedzi, w której pokazałeś podgląd.
29
+ Gdy właścicielka chce coś inaczej, zrób nowy podgląd i znów poczekaj na zgodę.
30
+ - Przy usuwaniu poproś właścicielkę, żeby sama napisała dokładną nazwę usuwanej rzeczy,
31
+ i wstaw w confirmationPhrase to, co napisała. Nigdy nie wpisuj nazwy za nią.
32
+ - Jeśli masz tylko narzędzia do odczytu (list_…, find_…), nie możesz niczego zmienić —
33
+ powiedz, że zmiany wymagają otwartej strony „Salon Assistant” w panelu.
34
+ - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz właścicielce,
24
35
  co poszło nie tak. Nie powtarzaj tego samego wywołania w kółko.
25
36
  - Jeśli brakuje Ci danych do wykonania polecenia, dopytaj o jedną rzecz naraz.
26
37
  - Nie obiecuj zmian, których nie wykonałeś narzędziem.`;
@@ -1,6 +1,7 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
1
2
  import type { SalonAccess, SalonCorePort } from "../salon-core-port.js";
2
3
  import { type AgentTool } from "../types.js";
3
- export declare function createPromoTools(port: SalonCorePort): AgentTool[];
4
+ export declare function createPromoTools(port: SalonCorePort, confirmations?: TurnConfirmations): AgentTool[];
4
5
  /** `access` must match the port: `"public"` only for a port read anonymously, because the canister
5
6
  * gives the salon's owner the whole library whatever the port calls. */
6
7
  export declare function listPromosTool(port: SalonCorePort, access?: SalonAccess): AgentTool;
@@ -1 +1 @@
1
- {"version":3,"file":"promos.d.ts","sourceRoot":"","sources":["../../src/tools/promos.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAA8B,WAAW,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAA8B,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAqBzE,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CAYjE;AA2CD;wEACwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB5F"}
1
+ {"version":3,"file":"promos.d.ts","sourceRoot":"","sources":["../../src/tools/promos.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAA8B,WAAW,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAC;AACpG,OAAO,EAAW,KAAK,SAAS,EAAE,MAAM,aAAa,CAAC;AAsBtD,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,aAAa,EAAE,aAAa,CAAC,EAAE,iBAAiB,GAAG,SAAS,EAAE,CAYpG;AA2CD;wEACwE;AACxE,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,EAAE,MAAM,GAAE,WAAqB,GAAG,SAAS,CAmB5F"}