@jsm-mit/sultana-agent-tools-package 0.2.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.
@@ -0,0 +1,300 @@
1
+ import { formatMinutesToTime, parseTimeRangesToSlots, parseTimeToMinutes } from "@jsm-mit/sultana-core-motoko-package";
2
+ import { toToolError } from "../errors.js";
3
+ import { err, needsConfirmation, ok } from "../types.js";
4
+ import { ArgumentError, readBoolean, readOptionalString, readString } from "./args.js";
5
+ /** 0 = Monday, matching the canister's own day index. */
6
+ const DAY_NAMES_PL = ["poniedziałek", "wtorek", "środa", "czwartek", "piątek", "sobota", "niedziela"];
7
+ const CONFIRMED_FIELD = {
8
+ type: "boolean",
9
+ description: "false dla podglądu: narzędzie nic nie zapisze i odda opis zmiany do zatwierdzenia przez właściciela. true dopiero po potwierdzeniu.",
10
+ };
11
+ const WORKER_FIELD = { type: "string", description: "Id pracownika z list_workers." };
12
+ const DATE_FIELD = { type: "string", description: "Dzień w formacie YYYY-MM-DD." };
13
+ const RANGES_FIELD = {
14
+ type: "string",
15
+ description: 'Zakresy godzin oddzielone przecinkiem, np. "10-18" albo "9:30-13,14-18".',
16
+ };
17
+ export function createScheduleTools(port) {
18
+ return [
19
+ getWeeklyHoursTool(port),
20
+ setWeeklyHoursTool(port),
21
+ getDayScheduleTool(port),
22
+ setDayOffTool(port),
23
+ setBusyHoursTool(port),
24
+ clearDailyOverrideTool(port),
25
+ ];
26
+ }
27
+ function getWeeklyHoursTool(port) {
28
+ return {
29
+ name: "get_weekly_hours",
30
+ progress: "Sprawdzam grafik tygodniowy…",
31
+ description: "Zwraca tygodniowy grafik pracownika — godziny pracy dla każdego dnia tygodnia.",
32
+ parameters: {
33
+ type: "object",
34
+ properties: { workerId: WORKER_FIELD },
35
+ required: ["workerId"],
36
+ additionalProperties: false,
37
+ },
38
+ execute: async (args) => {
39
+ try {
40
+ const worker = await resolveWorker(port, readString(args, "workerId"));
41
+ const week = [];
42
+ for (let day = 0; day < DAY_NAMES_PL.length; day += 1) {
43
+ const ranges = await port.getWeeklyHours(worker.id, day);
44
+ week.push({ day, dayName: DAY_NAMES_PL[day], hours: formatRanges(ranges) });
45
+ }
46
+ return ok(`Grafik tygodniowy: ${worker.name}.`, week);
47
+ }
48
+ catch (error) {
49
+ if (error instanceof ArgumentError)
50
+ return err("invalid_arguments", error.message);
51
+ return toToolError(error, "Nie udało się odczytać grafiku.");
52
+ }
53
+ },
54
+ };
55
+ }
56
+ function setWeeklyHoursTool(port) {
57
+ return {
58
+ name: "set_weekly_hours",
59
+ progress: "Ustawiam grafik…",
60
+ description: "Ustawia godziny PRACY pracownika w wybranych dniach tygodnia. Zastępuje dotychczasowe godziny w tych dniach. Pusty zakres oznacza dzień niepracujący.",
61
+ parameters: {
62
+ type: "object",
63
+ properties: {
64
+ workerId: WORKER_FIELD,
65
+ days: {
66
+ type: "array",
67
+ items: { type: "integer" },
68
+ description: "Dni tygodnia: 0 = poniedziałek, 6 = niedziela.",
69
+ },
70
+ ranges: RANGES_FIELD,
71
+ confirmed: CONFIRMED_FIELD,
72
+ },
73
+ required: ["workerId", "days", "ranges", "confirmed"],
74
+ additionalProperties: false,
75
+ },
76
+ execute: async (args) => {
77
+ try {
78
+ const worker = await resolveWorker(port, readString(args, "workerId"));
79
+ const days = readDays(args);
80
+ const parsed = normalizeRanges(readOptionalString(args, "ranges") ?? "");
81
+ const dayNames = days.map((day) => DAY_NAMES_PL[day]).join(", ");
82
+ const hours = parsed.ranges.length === 0 ? "dzień wolny" : formatRanges(parsed.ranges).join(", ");
83
+ if (!readBoolean(args, "confirmed")) {
84
+ return needsConfirmation(`Ustawię ${worker.name}: ${dayNames} — ${hours}.${noteSuffix(parsed.notes)}`, {
85
+ ...args,
86
+ confirmed: true,
87
+ });
88
+ }
89
+ for (const day of days) {
90
+ await port.setWeeklyHours(worker.id, day, parsed.ranges);
91
+ }
92
+ return ok(`Ustawiono grafik: ${worker.name}, ${dayNames} — ${hours}.${noteSuffix(parsed.notes)}`);
93
+ }
94
+ catch (error) {
95
+ if (error instanceof ArgumentError)
96
+ return err("invalid_arguments", error.message);
97
+ return toToolError(error, "Nie udało się ustawić grafiku.");
98
+ }
99
+ },
100
+ };
101
+ }
102
+ function getDayScheduleTool(port) {
103
+ return {
104
+ name: "get_day_schedule",
105
+ progress: "Sprawdzam grafik na ten dzień…",
106
+ description: "Zwraca nadpisanie dla konkretnego dnia — godziny ZAJĘTE. Pusta lista oznacza, że tego dnia obowiązuje zwykły grafik tygodniowy.",
107
+ parameters: {
108
+ type: "object",
109
+ properties: { workerId: WORKER_FIELD, date: DATE_FIELD },
110
+ required: ["workerId", "date"],
111
+ additionalProperties: false,
112
+ },
113
+ execute: async (args) => {
114
+ try {
115
+ const worker = await resolveWorker(port, readString(args, "workerId"));
116
+ const date = readDate(args);
117
+ const busy = await port.getDailyBusy(worker.id, date);
118
+ if (busy.length === 0)
119
+ return ok(`${worker.name}, ${date}: brak nadpisania, obowiązuje grafik tygodniowy.`, []);
120
+ return ok(`${worker.name}, ${date}: godziny zajęte.`, formatRanges(busy));
121
+ }
122
+ catch (error) {
123
+ if (error instanceof ArgumentError)
124
+ return err("invalid_arguments", error.message);
125
+ return toToolError(error, "Nie udało się odczytać dnia.");
126
+ }
127
+ },
128
+ };
129
+ }
130
+ function setDayOffTool(port) {
131
+ return {
132
+ name: "set_day_off",
133
+ progress: "Ustawiam dzień wolny…",
134
+ description: "Oznacza cały wskazany dzień jako wolny dla pracownika.",
135
+ parameters: {
136
+ type: "object",
137
+ properties: { workerId: WORKER_FIELD, date: DATE_FIELD, confirmed: CONFIRMED_FIELD },
138
+ required: ["workerId", "date", "confirmed"],
139
+ additionalProperties: false,
140
+ },
141
+ execute: async (args) => {
142
+ try {
143
+ const worker = await resolveWorker(port, readString(args, "workerId"));
144
+ const date = readDate(args);
145
+ if (!readBoolean(args, "confirmed")) {
146
+ return needsConfirmation(`Ustawię ${worker.name} dzień wolny: ${date}.`, { ...args, confirmed: true });
147
+ }
148
+ await port.setDayOff(worker.id, date);
149
+ return ok(`${worker.name} ma wolne ${date}.`);
150
+ }
151
+ catch (error) {
152
+ if (error instanceof ArgumentError)
153
+ return err("invalid_arguments", error.message);
154
+ return toToolError(error, "Nie udało się ustawić dnia wolnego.");
155
+ }
156
+ },
157
+ };
158
+ }
159
+ function setBusyHoursTool(port) {
160
+ return {
161
+ name: "set_busy_hours",
162
+ progress: "Zapisuję godziny zajęte…",
163
+ description: "Ustawia godziny ZAJĘTE w konkretnym dniu — nadpisuje grafik tygodniowy tylko tego dnia. Używaj do jednorazowych nieobecności („we wtorek jestem zajęta od 12 do 15”).",
164
+ parameters: {
165
+ type: "object",
166
+ properties: { workerId: WORKER_FIELD, date: DATE_FIELD, ranges: RANGES_FIELD, confirmed: CONFIRMED_FIELD },
167
+ required: ["workerId", "date", "ranges", "confirmed"],
168
+ additionalProperties: false,
169
+ },
170
+ execute: async (args) => {
171
+ try {
172
+ const worker = await resolveWorker(port, readString(args, "workerId"));
173
+ const date = readDate(args);
174
+ const parsed = normalizeRanges(readString(args, "ranges"));
175
+ if (parsed.ranges.length === 0) {
176
+ throw new ArgumentError("Podaj godziny zajęte albo użyj clear_daily_override, żeby wrócić do grafiku tygodniowego.");
177
+ }
178
+ const hours = formatRanges(parsed.ranges).join(", ");
179
+ if (!readBoolean(args, "confirmed")) {
180
+ return needsConfirmation(`Zaznaczę ${worker.name} jako zajętą ${date}: ${hours}.${noteSuffix(parsed.notes)}`, {
181
+ ...args,
182
+ confirmed: true,
183
+ });
184
+ }
185
+ await port.setDailyBusy(worker.id, date, parsed.ranges);
186
+ return ok(`${worker.name}, ${date}: zajęte ${hours}.${noteSuffix(parsed.notes)}`);
187
+ }
188
+ catch (error) {
189
+ if (error instanceof ArgumentError)
190
+ return err("invalid_arguments", error.message);
191
+ return toToolError(error, "Nie udało się zapisać godzin zajętych.");
192
+ }
193
+ },
194
+ };
195
+ }
196
+ function clearDailyOverrideTool(port) {
197
+ return {
198
+ name: "clear_daily_override",
199
+ progress: "Przywracam grafik tygodniowy…",
200
+ description: "Usuwa nadpisanie dla wskazanego dnia — od tej chwili obowiązuje zwykły grafik tygodniowy.",
201
+ parameters: {
202
+ type: "object",
203
+ properties: { workerId: WORKER_FIELD, date: DATE_FIELD, confirmed: CONFIRMED_FIELD },
204
+ required: ["workerId", "date", "confirmed"],
205
+ additionalProperties: false,
206
+ },
207
+ execute: async (args) => {
208
+ try {
209
+ const worker = await resolveWorker(port, readString(args, "workerId"));
210
+ const date = readDate(args);
211
+ if (!readBoolean(args, "confirmed")) {
212
+ return needsConfirmation(`Usunę nadpisanie dnia ${date} dla ${worker.name} — wróci grafik tygodniowy.`, {
213
+ ...args,
214
+ confirmed: true,
215
+ });
216
+ }
217
+ await port.clearDailyOverride(worker.id, date);
218
+ return ok(`${worker.name}, ${date}: wrócił grafik tygodniowy.`);
219
+ }
220
+ catch (error) {
221
+ if (error instanceof ArgumentError)
222
+ return err("invalid_arguments", error.message);
223
+ return toToolError(error, "Nie udało się usunąć nadpisania.");
224
+ }
225
+ },
226
+ };
227
+ }
228
+ async function resolveWorker(port, workerId) {
229
+ const workers = await port.listWorkers();
230
+ const worker = workers.find((candidate) => candidate.id === workerId);
231
+ if (!worker)
232
+ throw new ArgumentError(`W tym salonie nie ma pracownika o id ${workerId}. Użyj list_workers.`);
233
+ return worker;
234
+ }
235
+ function readDays(args) {
236
+ const value = args.days;
237
+ const list = Array.isArray(value) ? value : [value];
238
+ const days = [];
239
+ for (const entry of list) {
240
+ const day = typeof entry === "number" ? entry : Number(entry);
241
+ if (!Number.isInteger(day) || day < 0 || day > 6) {
242
+ throw new ArgumentError("Dni tygodnia to liczby 0-6, gdzie 0 to poniedziałek.");
243
+ }
244
+ if (!days.includes(day))
245
+ days.push(day);
246
+ }
247
+ if (days.length === 0)
248
+ throw new ArgumentError('Brakuje pola "days".');
249
+ return days;
250
+ }
251
+ /** The wrapper keys a day by its UTC parts, so the date travels as a plain `YYYY-MM-DD` string all
252
+ * the way down — never as a `Date` built from local parts, which would land on the wrong day. */
253
+ function readDate(args) {
254
+ const date = readString(args, "date");
255
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date))
256
+ throw new ArgumentError(`Data musi mieć format YYYY-MM-DD, a jest "${date}".`);
257
+ const parsed = new Date(`${date}T00:00:00Z`);
258
+ if (Number.isNaN(parsed.getTime()))
259
+ throw new ArgumentError(`"${date}" nie jest istniejącą datą.`);
260
+ return date;
261
+ }
262
+ /** Availability is a grid of 5-minute slots, so every edge is snapped to the grid and the change is
263
+ * reported rather than applied quietly. */
264
+ function normalizeRanges(input) {
265
+ if (input.trim() === "")
266
+ return { ranges: [], notes: [] };
267
+ let slots;
268
+ try {
269
+ slots = parseTimeRangesToSlots(input);
270
+ }
271
+ catch (error) {
272
+ throw new ArgumentError(error instanceof Error ? error.message : `Nie rozumiem zakresu godzin "${input}".`);
273
+ }
274
+ const notes = [];
275
+ const ranges = slots.map((slot) => ({
276
+ startTime: snap(slot.startTime, notes),
277
+ endTime: snap(slot.endTime, notes),
278
+ }));
279
+ for (const range of ranges) {
280
+ if (parseTimeToMinutes(range.startTime) >= parseTimeToMinutes(range.endTime)) {
281
+ throw new ArgumentError(`Zakres ${range.startTime}-${range.endTime} nie ma sensu — początek musi być przed końcem.`);
282
+ }
283
+ }
284
+ return { ranges, notes };
285
+ }
286
+ function snap(time, notes) {
287
+ const minutes = parseTimeToMinutes(time);
288
+ const snapped = Math.round(minutes / 5) * 5;
289
+ if (snapped === minutes)
290
+ return time;
291
+ const formatted = formatMinutesToTime(snapped);
292
+ notes.push(`godzinę ${time} wyrównano do ${formatted}`);
293
+ return formatted;
294
+ }
295
+ function formatRanges(ranges) {
296
+ return ranges.map((range) => `${range.startTime}-${range.endTime}`);
297
+ }
298
+ function noteSuffix(notes) {
299
+ return notes.length === 0 ? "" : ` Uwaga: ${notes.join("; ")}.`;
300
+ }
@@ -0,0 +1,7 @@
1
+ import type { SalonAccess, SalonCorePort } from "../salon-core-port.js";
2
+ import { type AgentTool } from "../types.js";
3
+ export declare function createServiceTools(port: SalonCorePort): AgentTool[];
4
+ /** `access` must match the port: `"public"` only for a port that reads the visitor's view. */
5
+ export declare function listServicesTool(port: SalonCorePort, access?: SalonAccess): AgentTool;
6
+ export declare function findServiceTypeTool(port: SalonCorePort): AgentTool;
7
+ //# sourceMappingURL=services.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"services.d.ts","sourceRoot":"","sources":["../../src/tools/services.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAkC,MAAM,uBAAuB,CAAC;AACxG,OAAO,EAA8B,KAAK,SAAS,EAAmB,MAAM,aAAa,CAAC;AAoB1F,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,EAAE,CASnE;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"}
@@ -0,0 +1,309 @@
1
+ import { toToolError } from "../errors.js";
2
+ import { err, needsConfirmation, ok } from "../types.js";
3
+ import { ArgumentError, normalizeDuration, normalizePrice, phrasesMatch, readBoolean, readNumber, readOptionalNumber, readOptionalString, readOptionalStringArray, readString, } from "./args.js";
4
+ const CONFIRMED_FIELD = {
5
+ type: "boolean",
6
+ description: "false dla podglądu: narzędzie nic nie zapisze i odda opis zmiany do zatwierdzenia przez właściciela. true dopiero po tym, jak właściciel potwierdzi.",
7
+ };
8
+ export function createServiceTools(port) {
9
+ return [
10
+ listServicesTool(port),
11
+ findServiceTypeTool(port),
12
+ listWorkersTool(port),
13
+ addServiceTool(port),
14
+ updateServiceTool(port),
15
+ removeServiceTool(port),
16
+ ];
17
+ }
18
+ /** What the model reads about the list depends on whose view the port returns: a public read has
19
+ * no switched-off services in it, and a text that implied otherwise would have the model tell the
20
+ * owner a service is gone when it is only hidden. */
21
+ const LIST_SERVICES_TEXT = {
22
+ owner: {
23
+ description: "Zwraca katalog usług salonu: id, nazwę, cenę, czas trwania, status i przypisanych pracowników. Wywołaj je przed każdą zmianą usługi, żeby poznać id.",
24
+ empty: "Salon nie ma jeszcze żadnej usługi.",
25
+ count: (count) => `Salon ma ${count} usług(i).`,
26
+ },
27
+ public: {
28
+ description: "Zwraca AKTYWNE usługi salonu — te, które widzą klientki: id, nazwę, cenę w pełnych złotych, czas trwania w minutach i id typów usług. Usług wyłączonych tu nie ma.",
29
+ empty: "Salon nie ma żadnej aktywnej usługi.",
30
+ count: (count) => `Aktywne usługi salonu: ${count}.`,
31
+ },
32
+ };
33
+ /** `access` must match the port: `"public"` only for a port that reads the visitor's view. */
34
+ export function listServicesTool(port, access = "owner") {
35
+ const text = LIST_SERVICES_TEXT[access];
36
+ return {
37
+ name: "list_services",
38
+ progress: "Sprawdzam listę usług…",
39
+ description: text.description,
40
+ parameters: { type: "object", properties: {}, additionalProperties: false },
41
+ execute: async () => {
42
+ try {
43
+ const services = await port.listServices();
44
+ if (services.length === 0)
45
+ return ok(text.empty, []);
46
+ return ok(text.count(services.length), services);
47
+ }
48
+ catch (error) {
49
+ return toToolError(error, "Nie udało się odczytać listy usług.");
50
+ }
51
+ },
52
+ };
53
+ }
54
+ export function findServiceTypeTool(port) {
55
+ return {
56
+ name: "find_service_type",
57
+ progress: "Szukam typu usługi…",
58
+ description: "Szuka w katalogu typów usług (np. „strzyżenie”, „koloryzacja”) i zwraca ich id wraz z etykietami. Każdy serviceTypeId musi pochodzić stąd — nie wymyślaj id.",
59
+ parameters: {
60
+ type: "object",
61
+ properties: {
62
+ query: { type: "string", description: "Fragment nazwy typu usługi. Puste zapytanie zwraca cały katalog." },
63
+ },
64
+ required: [],
65
+ additionalProperties: false,
66
+ },
67
+ execute: async (args) => {
68
+ try {
69
+ const query = readOptionalString(args, "query") ?? "";
70
+ const matches = await port.findServiceTypes(query);
71
+ if (matches.length === 0)
72
+ return ok(`Nic nie pasuje do „${query}”.`, []);
73
+ // A long catalog is noise in a conversation; the model only needs enough to choose.
74
+ const shown = matches.slice(0, 25);
75
+ const summary = matches.length > shown.length
76
+ ? `Znaleziono ${matches.length} typów, pokazuję pierwsze ${shown.length}.`
77
+ : `Znaleziono ${matches.length} typ(ów).`;
78
+ return ok(summary, shown);
79
+ }
80
+ catch (error) {
81
+ return toToolError(error, "Nie udało się odczytać katalogu typów usług.");
82
+ }
83
+ },
84
+ };
85
+ }
86
+ function listWorkersTool(port) {
87
+ return {
88
+ name: "list_workers",
89
+ progress: "Sprawdzam zespół…",
90
+ description: "Zwraca zespół salonu: id pracownika i imię. Potrzebne, żeby przypisać usługę albo ustawić grafik.",
91
+ parameters: { type: "object", properties: {}, additionalProperties: false },
92
+ execute: async () => {
93
+ try {
94
+ const workers = await port.listWorkers();
95
+ if (workers.length === 0)
96
+ return ok("Salon nie ma jeszcze żadnego pracownika.", []);
97
+ return ok(`Zespół liczy ${workers.length} osob(y).`, workers);
98
+ }
99
+ catch (error) {
100
+ return toToolError(error, "Nie udało się odczytać zespołu.");
101
+ }
102
+ },
103
+ };
104
+ }
105
+ function addServiceTool(port) {
106
+ return {
107
+ name: "add_service",
108
+ progress: "Dodaję usługę…",
109
+ description: "Dodaje nową usługę do katalogu salonu. Najpierw ustal typ usługi przez find_service_type i pracowników przez list_workers.",
110
+ parameters: {
111
+ type: "object",
112
+ properties: {
113
+ name: { type: "string", description: "Nazwa usługi widoczna dla klientek." },
114
+ pricePln: { type: "number", description: "Cena w pełnych złotych." },
115
+ durationMinutes: { type: "number", description: "Czas trwania w minutach, wielokrotność 5." },
116
+ serviceTypeIds: {
117
+ type: "array",
118
+ items: { type: "string" },
119
+ description: "Id typów usług z find_service_type.",
120
+ },
121
+ workerIds: {
122
+ type: "array",
123
+ items: { type: "string" },
124
+ description: "Id pracowników z list_workers. Pusta lista oznacza usługę bez przypisanych osób.",
125
+ },
126
+ active: { type: "boolean", description: "Czy usługa ma być od razu widoczna. Domyślnie true." },
127
+ confirmed: CONFIRMED_FIELD,
128
+ },
129
+ required: ["name", "pricePln", "durationMinutes", "confirmed"],
130
+ additionalProperties: false,
131
+ },
132
+ execute: async (args) => {
133
+ try {
134
+ const draft = await buildDraft(port, args, null);
135
+ if (!readBoolean(args, "confirmed")) {
136
+ return needsConfirmation(`Dodam usługę: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, {
137
+ ...args,
138
+ ...draft.input,
139
+ confirmed: true,
140
+ });
141
+ }
142
+ const serviceId = await port.addService(draft.input);
143
+ return ok(`Dodano usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`, { serviceId });
144
+ }
145
+ catch (error) {
146
+ if (error instanceof ArgumentError)
147
+ return err("invalid_arguments", error.message);
148
+ return toToolError(error, "Nie udało się dodać usługi.");
149
+ }
150
+ },
151
+ };
152
+ }
153
+ function updateServiceTool(port) {
154
+ return {
155
+ name: "update_service",
156
+ progress: "Zmieniam usługę…",
157
+ description: "Zmienia istniejącą usługę. Podaj tylko te pola, które mają się zmienić — reszta zostanie zachowana. Id weź z list_services.",
158
+ parameters: {
159
+ type: "object",
160
+ properties: {
161
+ salonServiceId: { type: "string", description: "Id usługi z list_services." },
162
+ name: { type: "string", description: "Nowa nazwa." },
163
+ pricePln: { type: "number", description: "Nowa cena w pełnych złotych." },
164
+ durationMinutes: { type: "number", description: "Nowy czas trwania w minutach, wielokrotność 5." },
165
+ serviceTypeIds: { type: "array", items: { type: "string" }, description: "Nowa lista typów usług." },
166
+ workerIds: { type: "array", items: { type: "string" }, description: "Nowa lista pracowników." },
167
+ active: { type: "boolean", description: "Czy usługa ma być widoczna." },
168
+ confirmed: CONFIRMED_FIELD,
169
+ },
170
+ required: ["salonServiceId", "confirmed"],
171
+ additionalProperties: false,
172
+ },
173
+ execute: async (args) => {
174
+ try {
175
+ const serviceId = readString(args, "salonServiceId");
176
+ const current = await findService(port, serviceId);
177
+ if (!current)
178
+ return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
179
+ const draft = await buildDraft(port, args, current);
180
+ if (!readBoolean(args, "confirmed")) {
181
+ return needsConfirmation(`Zmienię usługę „${current.name}” na: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, { ...args, ...draft.input, confirmed: true });
182
+ }
183
+ await port.updateService(serviceId, draft.input);
184
+ return ok(`Zmieniono usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`);
185
+ }
186
+ catch (error) {
187
+ if (error instanceof ArgumentError)
188
+ return err("invalid_arguments", error.message);
189
+ return toToolError(error, "Nie udało się zmienić usługi.");
190
+ }
191
+ },
192
+ };
193
+ }
194
+ function removeServiceTool(port) {
195
+ return {
196
+ name: "remove_service",
197
+ progress: "Usuwam usługę…",
198
+ description: "Usuwa usługę z katalogu. Operacja jest nieodwracalna — właściciel musi przepisać dokładną nazwę usługi w polu confirmationPhrase.",
199
+ parameters: {
200
+ type: "object",
201
+ properties: {
202
+ salonServiceId: { type: "string", description: "Id usługi z list_services." },
203
+ confirmationPhrase: { type: "string", description: "Dokładna nazwa usuwanej usługi, przepisana przez właściciela." },
204
+ confirmed: CONFIRMED_FIELD,
205
+ },
206
+ required: ["salonServiceId", "confirmed"],
207
+ additionalProperties: false,
208
+ },
209
+ execute: async (args) => {
210
+ try {
211
+ const serviceId = readString(args, "salonServiceId");
212
+ const current = await findService(port, serviceId);
213
+ if (!current)
214
+ return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
215
+ if (!readBoolean(args, "confirmed")) {
216
+ return needsConfirmation(`Usunę usługę „${current.name}” (${current.pricePln} zł, ${current.durationMinutes} min). Tego nie da się cofnąć — poproś właściciela o przepisanie nazwy usługi.`, { salonServiceId: serviceId, confirmationPhrase: current.name, confirmed: true });
217
+ }
218
+ const phrase = readOptionalString(args, "confirmationPhrase");
219
+ if (!phrase || !phrasesMatch(phrase, current.name)) {
220
+ return err("invalid_arguments", `Aby usunąć usługę, właściciel musi przepisać jej nazwę: „${current.name}”.`);
221
+ }
222
+ await port.removeService(serviceId);
223
+ return ok(`Usunięto usługę „${current.name}”.`);
224
+ }
225
+ catch (error) {
226
+ if (error instanceof ArgumentError)
227
+ return err("invalid_arguments", error.message);
228
+ return toToolError(error, "Nie udało się usunąć usługi.");
229
+ }
230
+ },
231
+ };
232
+ }
233
+ /**
234
+ * Builds the full record the canister needs. `current` is null for a new service and the existing
235
+ * record for an edit — **this merge is the whole point**: `updateSalonService` replaces every
236
+ * field, so a "just the price" call that forgot `workerIds` would unassign the entire team.
237
+ */
238
+ async function buildDraft(port, args, current) {
239
+ const notes = [];
240
+ const name = current ? (readOptionalString(args, "name") ?? current.name) : readString(args, "name");
241
+ const rawPrice = readOptionalNumber(args, "pricePln");
242
+ const price = rawPrice === undefined ? current?.pricePln : normalizeOrNote(normalizePrice(rawPrice), notes);
243
+ if (price === undefined)
244
+ throw new ArgumentError('Brakuje pola "pricePln".');
245
+ const rawDuration = readOptionalNumber(args, "durationMinutes");
246
+ const duration = rawDuration === undefined ? current?.durationMinutes : normalizeOrNote(normalizeDuration(rawDuration), notes);
247
+ if (duration === undefined)
248
+ throw new ArgumentError('Brakuje pola "durationMinutes".');
249
+ const serviceTypeIds = readOptionalStringArray(args, "serviceTypeIds") ?? current?.serviceTypeIds ?? [];
250
+ const workerIds = readOptionalStringArray(args, "workerIds") ?? current?.workerIds ?? [];
251
+ const active = args.active === undefined || args.active === null ? (current?.active ?? true) : readBoolean(args, "active");
252
+ await assertKnownServiceTypes(port, serviceTypeIds);
253
+ await assertKnownWorkers(port, workerIds);
254
+ return { input: { name, pricePln: price, durationMinutes: duration, active, serviceTypeIds, workerIds }, notes };
255
+ }
256
+ function normalizeOrNote(normalized, notes) {
257
+ if (normalized.note)
258
+ notes.push(normalized.note);
259
+ return normalized.value;
260
+ }
261
+ function noteSuffix(notes) {
262
+ return notes.length === 0 ? "" : ` Uwaga: ${notes.join("; ")}.`;
263
+ }
264
+ /** The model must never invent a service-type id — the canister would take it and the service would
265
+ * then be invisible in search under a key nobody uses. */
266
+ async function assertKnownServiceTypes(port, serviceTypeIds) {
267
+ if (serviceTypeIds.length === 0)
268
+ return;
269
+ const catalog = await port.findServiceTypes("");
270
+ const known = new Set(catalog.map((entry) => entry.id));
271
+ const unknown = serviceTypeIds.filter((id) => !known.has(id));
272
+ if (unknown.length > 0) {
273
+ throw new ArgumentError(`Nieznane typy usług: ${unknown.join(", ")}. Użyj find_service_type i podaj id z katalogu.`);
274
+ }
275
+ }
276
+ async function assertKnownWorkers(port, workerIds) {
277
+ if (workerIds.length === 0)
278
+ return;
279
+ const workers = await port.listWorkers();
280
+ const known = new Set(workers.map((worker) => worker.id));
281
+ const unknown = workerIds.filter((id) => !known.has(id));
282
+ if (unknown.length > 0) {
283
+ throw new ArgumentError(`Nieznani pracownicy: ${unknown.join(", ")}. Użyj list_workers i podaj id z zespołu.`);
284
+ }
285
+ }
286
+ /** The confirmation sentence is built from RESOLVED arguments — labels and names, not ids — so the
287
+ * owner confirms what will really happen rather than what the model believes it is doing. */
288
+ async function describe(port, input) {
289
+ const parts = [`„${input.name}”`, `${input.pricePln} zł`, `${input.durationMinutes} min`];
290
+ if (input.serviceTypeIds.length > 0) {
291
+ const catalog = await port.findServiceTypes("");
292
+ const labels = input.serviceTypeIds.map((id) => catalog.find((entry) => entry.id === id)?.label ?? id);
293
+ parts.push(`typy: ${labels.join(", ")}`);
294
+ }
295
+ if (input.workerIds.length > 0) {
296
+ const workers = await port.listWorkers();
297
+ const names = input.workerIds.map((id) => workers.find((worker) => worker.id === id)?.name ?? id);
298
+ parts.push(`pracownicy: ${names.join(", ")}`);
299
+ }
300
+ else {
301
+ parts.push("bez przypisanych pracowników");
302
+ }
303
+ parts.push(input.active ? "widoczna" : "ukryta");
304
+ return parts.join(", ");
305
+ }
306
+ async function findService(port, serviceId) {
307
+ const services = await port.listServices();
308
+ return services.find((service) => service.id === serviceId) ?? null;
309
+ }