@jsm-mit/sultana-agent-tools-package 0.6.0 → 0.7.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
@@ -186,6 +186,70 @@ const tools = session
186
186
  `unavailable` instead of booking hours off (`timeZone: null` skips the guard; `hostTimeZoneProblem`
187
187
  lets a host check at start-up).
188
188
 
189
+ ## Booksy tools (experimental, since 0.7.0)
190
+
191
+ "Copy my salon from Booksy": three read-only tools that take the address of a PUBLIC Booksy profile
192
+ and return its content in the shape Sultana's write tools take, and one write tool that copies the
193
+ whole service catalogue under ONE confirmation.
194
+
195
+ ```ts
196
+ const port = new IcSalonCorePort({ … });
197
+ const reader = new BooksyProfileReader(); // keep it for the conversation: one fetch per profile
198
+
199
+ const tools = [
200
+ ...createSalonToolsFromPort(port, { confirmations }),
201
+ ...createBooksyTools(port, { confirmations, reader, photoSink }), // no photoSink → no download tool
202
+ ];
203
+ ```
204
+
205
+ `createBooksyReaderTools(…)` gives the three read tools alone — they need no salon and no `confirmed`.
206
+
207
+ | tool | what it returns |
208
+ |---|---|
209
+ | `read_booksy_salon` | name, description, address, coordinates, weekly opening hours (day 0 = Monday, `ranges` as `set_weekly_hours` reads them), the team, counts of services and photos |
210
+ | `read_booksy_services` | services ready for `add_service`: `name`, `pricePln`, `durationMinutes`, plus `booksyCategory`, `description`, `workerNames`, `notes`; optional `category` filter |
211
+ | `download_booksy_photos` | fetches photos (`logo`, `cover`, `salon` by default; also `inspiration`, `service` — the per-service portfolio —, `staff`) and hands each to the host's `BooksyPhotoSink`; up to 150 a call |
212
+ | `import_booksy_services` | **write**: copies the catalogue into this salon. Arguments: `url`, optional `category`, optional `overrides` (`{name, skip?, serviceTypeIds?}`), `confirmed` |
213
+ | `import_booksy_workers` | **placeholder**: says that copying the team is not built yet, lists the people on Booksy and who is missing in the salon; writes nothing |
214
+
215
+ `import_booksy_services`:
216
+
217
+ - **The model never carries the services.** It names the profile; the tool reads it again itself
218
+ (from the shared reader), so sixty services cost the context nothing and cannot be retyped wrong.
219
+ - **One confirmation.** `confirmed: false` returns the full list — name, price, time, the service
220
+ type and the people it found — and an echo with `expectedCount`. The confirming call is refused
221
+ when the plan no longer has that many services (the profile or the salon changed in between).
222
+ - **Service types are chosen in the tool**, with the catalogue's own search: points for the whole
223
+ name, for each word (earlier words weigh more) and for the Booksy category; a type must be
224
+ pointed at by the whole name, the FIRST word or the category. No match → the service is NOT copied
225
+ (core refuses a service without a type); the preview lists it and asks for a type. `overrides` sets a type by hand.
226
+ - **People by name.** A Booksy first name that matches exactly one worker of the salon is
227
+ assigned; the rest are listed once under the preview.
228
+ - **Idempotent.** A service whose name the salon already has is skipped, so an import that stopped
229
+ half-way (quota, connection) is finished by running it again. Writes go 4 at a time and stop at
230
+ the first failure; the result says how many were copied and how many are left.
231
+
232
+ - **One variant, one service.** Booksy nests price-and-time variants under a service; each becomes
233
+ `"Service — variant"`.
234
+ - **Sultana's units.** Whole zloty, a multiple of 5 minutes, at least 5. Every adjustment, and a
235
+ "from" / varying price, is written into `notes` for the agent to tell the owner.
236
+ - **Ids stay empty.** `serviceTypeIds` and `workerIds` live in Sultana; the agent resolves them with
237
+ `find_service_type` (hint: `booksyCategory`) and `list_workers`.
238
+ - **The source** is the page's `__NUXT_DATA__` block (`src/booksy/nuxt-payload.ts` reads the devalue
239
+ format); the JSON-LD block is a degraded fallback without durations, flagged `source.degraded`.
240
+ - **The URL is untrusted**: only `https://booksy.com/<locale>/<id>_…` is fetched; the fragment and
241
+ the query are dropped. One fetch per profile per tool set.
242
+ - **The package touches no disk.** Photos go to the sink the host passes; the sandbox's sink writes
243
+ to the git-ignored `sandbox/booksy-out/<booksy id>/photos/`.
244
+ - **Reviews and customer names are never read.** Reading a profile by a script may be against
245
+ Booksy's terms of use — this is a trial to see whether the idea is worth a proper agreement.
246
+
247
+ `readBooksySalonDraft(url)` gives the whole `SultanaSalonDraft` without a model.
248
+
249
+ A host that hands these tools to an agent appends `SALON_BOOKSY_PERSONA_PL` to
250
+ `SALON_AGENT_PERSONA_PL`. One `BooksyProfileReader` may serve the whole host: profiles are public,
251
+ it remembers a profile for ten minutes and keeps the last fifty.
252
+
189
253
  ## What the tools guarantee
190
254
 
191
255
  - **`execute` never throws.** Every call returns `{status: "ok" | "confirmation_required" | "error"}`
@@ -217,6 +281,8 @@ const tools = session
217
281
  | `npm run whoami` | setup check: the identity, its salons, the service-type catalogue |
218
282
  | `npm run sandbox -- --fake` | talk to the tools against an in-memory salon |
219
283
  | `npm run sandbox` | the same conversation against a real canister |
284
+ | `npm run booksy -- <profile url>` | the Booksy reader alone: prints the draft, writes `sandbox/booksy-out/<id>/draft.json` and the photos (`--no-photos`, `--all-photos`) — no model, no canister |
285
+ | `npm run booksy -- <profile url> --import` | `import_booksy_services` against the in-memory salon: the preview, then the confirmed import |
220
286
  | `npm run test-tools` | the round trip against a live canister (creates its own salon) |
221
287
  | `npm run test-customer-tools` | the customer's round trip against a live, seeded canister: search, book, list, cancel (a fresh identity per run) |
222
288
  | `npm run publish-public` | publishes to npm from master — see below |
@@ -0,0 +1,25 @@
1
+ import type { ToolErrorCode } from "../types.js";
2
+ /** `fetch`, injectable — tests and hosts with their own HTTP stack pass a replacement. */
3
+ export type FetchFn = (url: string, init?: {
4
+ headers?: Record<string, string>;
5
+ signal?: AbortSignal;
6
+ }) => Promise<Response>;
7
+ /** A failure with the tool error code and an owner-readable sentence already chosen. */
8
+ export declare class BooksyReadError extends Error {
9
+ readonly code: ToolErrorCode;
10
+ readonly summary: string;
11
+ constructor(code: ToolErrorCode, summary: string);
12
+ }
13
+ /**
14
+ * The model passes the URL, so it is untrusted: only an https Booksy profile goes through. The
15
+ * fragment (`#ba_s=…`, a tracking tag) and the query are dropped.
16
+ */
17
+ export declare function normalizeBooksyUrl(raw: string): string;
18
+ export declare function fetchBooksyPage(url: string, fetchFn?: FetchFn): Promise<string>;
19
+ export interface FetchedPhoto {
20
+ bytes: Uint8Array;
21
+ contentType: string;
22
+ }
23
+ /** Photo URLs come out of the Booksy page, not from the model — still https only, and capped. */
24
+ export declare function fetchPhoto(url: string, fetchFn?: FetchFn): Promise<FetchedPhoto>;
25
+ //# sourceMappingURL=fetch-page.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-page.d.ts","sourceRoot":"","sources":["../../src/booksy/fetch-page.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAEjD,0FAA0F;AAC1F,MAAM,MAAM,OAAO,GAAG,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AAE5H,wFAAwF;AACxF,qBAAa,eAAgB,SAAQ,KAAK;IAElC,QAAQ,CAAC,IAAI,EAAE,aAAa;IAC5B,QAAQ,CAAC,OAAO,EAAE,MAAM;gBADf,IAAI,EAAE,aAAa,EACnB,OAAO,EAAE,MAAM;CAI/B;AAcD;;;GAGG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAqBtD;AAED,wBAAsB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,OAAe,GAAG,OAAO,CAAC,MAAM,CAAC,CAiB5F;AAED,MAAM,WAAW,YAAY;IACzB,KAAK,EAAE,UAAU,CAAC;IAClB,WAAW,EAAE,MAAM,CAAC;CACvB;AAED,iGAAiG;AACjG,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,OAAe,GAAG,OAAO,CAAC,YAAY,CAAC,CAe7F"}
@@ -0,0 +1,93 @@
1
+ /** A failure with the tool error code and an owner-readable sentence already chosen. */
2
+ export class BooksyReadError extends Error {
3
+ code;
4
+ summary;
5
+ constructor(code, summary) {
6
+ super(summary);
7
+ this.code = code;
8
+ this.summary = summary;
9
+ }
10
+ }
11
+ const BOOKSY_HOSTS = new Set(["booksy.com", "www.booksy.com"]);
12
+ const FETCH_TIMEOUT_MS = 15_000;
13
+ const MAX_PAGE_BYTES = 5 * 1024 * 1024;
14
+ const MAX_PHOTO_BYTES = 15 * 1024 * 1024;
15
+ // Booksy answers a bare client with a challenge page; it answers a browser with the profile.
16
+ const BROWSER_HEADERS = {
17
+ "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
18
+ "accept-language": "pl-PL,pl;q=0.9",
19
+ };
20
+ /**
21
+ * The model passes the URL, so it is untrusted: only an https Booksy profile goes through. The
22
+ * fragment (`#ba_s=…`, a tracking tag) and the query are dropped.
23
+ */
24
+ export function normalizeBooksyUrl(raw) {
25
+ let url;
26
+ try {
27
+ url = new URL(raw.trim());
28
+ }
29
+ catch {
30
+ throw new BooksyReadError("invalid_arguments", "To nie jest poprawny adres URL.");
31
+ }
32
+ if (url.protocol !== "https:" || !BOOKSY_HOSTS.has(url.hostname)) {
33
+ throw new BooksyReadError("invalid_arguments", "Adres musi być profilem salonu na https://booksy.com/.");
34
+ }
35
+ // A profile path is "/<locale>/<id>_<slug>…"; a listing or the home page has no salon in it.
36
+ if (!/^\/[a-z]{2}-[a-z]{2}\/\d+_/.test(url.pathname)) {
37
+ throw new BooksyReadError("invalid_arguments", "To nie wygląda na profil salonu. Adres profilu ma postać https://booksy.com/pl-pl/<numer>_<nazwa>….");
38
+ }
39
+ return `https://booksy.com${url.pathname}`;
40
+ }
41
+ export async function fetchBooksyPage(url, fetchFn = fetch) {
42
+ const response = await request(url, fetchFn, "Nie udało się połączyć z Booksy. Spróbuj ponownie za chwilę.");
43
+ if (response.status === 404)
44
+ throw new BooksyReadError("not_found", "Booksy nie ma profilu pod tym adresem.");
45
+ if (!response.ok) {
46
+ throw new BooksyReadError("unavailable", `Booksy odpowiedziało kodem ${response.status}. Spróbuj ponownie za chwilę.`);
47
+ }
48
+ // A redirect off Booksy means the page we would parse is not the page the owner named.
49
+ if (response.url && !BOOKSY_HOSTS.has(new URL(response.url).hostname)) {
50
+ throw new BooksyReadError("unavailable", "Booksy przekierowało poza swoją domenę — nie czytam tej strony.");
51
+ }
52
+ const html = await response.text();
53
+ if (html.length > MAX_PAGE_BYTES)
54
+ throw new BooksyReadError("unavailable", "Strona profilu jest zbyt duża.");
55
+ return html;
56
+ }
57
+ /** Photo URLs come out of the Booksy page, not from the model — still https only, and capped. */
58
+ export async function fetchPhoto(url, fetchFn = fetch) {
59
+ if (!url.startsWith("https://"))
60
+ throw new BooksyReadError("invalid_arguments", "Adres zdjęcia nie jest https.");
61
+ const response = await request(url, fetchFn, "Nie udało się pobrać zdjęcia.");
62
+ if (!response.ok)
63
+ throw new BooksyReadError("unavailable", `Serwer zdjęć odpowiedział kodem ${response.status}.`);
64
+ const bytes = new Uint8Array(await response.arrayBuffer());
65
+ if (bytes.length > MAX_PHOTO_BYTES)
66
+ throw new BooksyReadError("unavailable", "Zdjęcie jest zbyt duże.");
67
+ // Booksy's photo server labels everything a bare "image", so the header says nothing — the
68
+ // first bytes do.
69
+ const contentType = sniffImageType(bytes);
70
+ if (contentType === null)
71
+ throw new BooksyReadError("unavailable", "Pod adresem zdjęcia nie ma obrazu.");
72
+ return { bytes, contentType };
73
+ }
74
+ function sniffImageType(bytes) {
75
+ const startsWith = (signature, offset = 0) => signature.every((byte, at) => bytes[offset + at] === byte);
76
+ if (startsWith([0xff, 0xd8, 0xff]))
77
+ return "image/jpeg";
78
+ if (startsWith([0x89, 0x50, 0x4e, 0x47]))
79
+ return "image/png";
80
+ if (startsWith([0x47, 0x49, 0x46, 0x38]))
81
+ return "image/gif";
82
+ if (startsWith([0x52, 0x49, 0x46, 0x46]) && startsWith([0x57, 0x45, 0x42, 0x50], 8))
83
+ return "image/webp";
84
+ return null;
85
+ }
86
+ async function request(url, fetchFn, failure) {
87
+ try {
88
+ return await fetchFn(url, { headers: BROWSER_HEADERS, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
89
+ }
90
+ catch {
91
+ throw new BooksyReadError("unavailable", failure);
92
+ }
93
+ }
@@ -0,0 +1,22 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
2
+ import type { SalonCorePort } from "../salon-core-port.js";
3
+ import { type AgentTool } from "../types.js";
4
+ import { BooksyProfileReader, type CreateBooksyReaderToolsOptions } from "./tools.js";
5
+ export interface CreateBooksyImportToolsOptions {
6
+ /** The reader the read tools use — shared, so a preview and its import read one page once. */
7
+ reader?: BooksyProfileReader;
8
+ confirmations?: TurnConfirmations;
9
+ }
10
+ /**
11
+ * The write half of "copy my salon from Booksy": the whole service catalogue under ONE
12
+ * confirmation. The model never carries sixty services through its context — it names the profile,
13
+ * the tool reads it again on its own, and the owner confirms the list the tool showed her.
14
+ */
15
+ export declare function createBooksyImportTools(port: SalonCorePort, options?: CreateBooksyImportToolsOptions): AgentTool[];
16
+ export interface CreateBooksyToolsOptions extends CreateBooksyReaderToolsOptions {
17
+ confirmations?: TurnConfirmations;
18
+ }
19
+ /** The whole Booksy set for ONE salon — the read tools and the import — over one shared reader.
20
+ * A host adds it next to `createSalonTools`; the port is the same one. */
21
+ export declare function createBooksyTools(port: SalonCorePort, options?: CreateBooksyToolsOptions): AgentTool[];
22
+ //# sourceMappingURL=import-tools.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"import-tools.d.ts","sourceRoot":"","sources":["../../src/booksy/import-tools.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAE7D,OAAO,KAAK,EAAE,aAAa,EAAkD,MAAM,uBAAuB,CAAC;AAG3G,OAAO,EAAW,KAAK,SAAS,EAAmB,MAAM,aAAa,CAAC;AAGvE,OAAO,EACH,mBAAmB,EAInB,KAAK,8BAA8B,EACtC,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,8BAA8B;IAC3C,8FAA8F;IAC9F,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,aAAa,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAMD;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,8BAAmC,GAAG,SAAS,EAAE,CAItH;AAED,MAAM,WAAW,wBAAyB,SAAQ,8BAA8B;IAC5E,aAAa,CAAC,EAAE,iBAAiB,CAAC;CACrC;AAED;0EAC0E;AAC1E,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,aAAa,EAAE,OAAO,GAAE,wBAA6B,GAAG,SAAS,EAAE,CAO1G"}
@@ -0,0 +1,413 @@
1
+ import { toToolError } from "../errors.js";
2
+ import { ArgumentError, readBoolean, readOptionalNumber, readOptionalString, readOptionalStringArray, readString } from "../tools/args.js";
3
+ import { writeGate } from "../tools/write-gate.js";
4
+ import { err, ok } from "../types.js";
5
+ import { BooksyReadError, normalizeBooksyUrl } from "./fetch-page.js";
6
+ import { BooksyProfileReader, URL_FIELD, createBooksyReaderTools, degradedSuffix, } from "./tools.js";
7
+ /** Writes at a time. Enough to bring a 60-service catalogue down from minutes to well under one;
8
+ * low enough that a canister short on cycles still has room for every call in flight. */
9
+ const WRITE_CONCURRENCY = 4;
10
+ /**
11
+ * The write half of "copy my salon from Booksy": the whole service catalogue under ONE
12
+ * confirmation. The model never carries sixty services through its context — it names the profile,
13
+ * the tool reads it again on its own, and the owner confirms the list the tool showed her.
14
+ */
15
+ export function createBooksyImportTools(port, options = {}) {
16
+ const reader = options.reader ?? new BooksyProfileReader();
17
+ return [importServicesTool(port, reader, options.confirmations), importWorkersTool(port, reader)];
18
+ }
19
+ /** The whole Booksy set for ONE salon — the read tools and the import — over one shared reader.
20
+ * A host adds it next to `createSalonTools`; the port is the same one. */
21
+ export function createBooksyTools(port, options = {}) {
22
+ const reader = options.reader ?? new BooksyProfileReader(options.fetchFn);
23
+ return [
24
+ ...createBooksyReaderTools({ ...options, reader }),
25
+ ...createBooksyImportTools(port, { reader, confirmations: options.confirmations }),
26
+ ];
27
+ }
28
+ function importServicesTool(port, reader, confirmations) {
29
+ const gate = writeGate("import_booksy_services", confirmations);
30
+ return {
31
+ name: "import_booksy_services",
32
+ progress: "Kopiuję usługi z Booksy…",
33
+ description: "Kopiuje usługi z publicznego profilu Booksy do katalogu tego salonu — wszystkie naraz, pod JEDNYM potwierdzeniem. Narzędzie samo czyta profil, dobiera typy usług z katalogu Sultana i przypisuje pracowników po imieniu; usługi, które salon już ma pod tą samą nazwą, pomija. Usługi, której typu nie dobrało, NIE kopiuje (Sultana wymaga typu) — wypisuje ją, a Ty dobierasz typ przez find_service_type i podajesz w overrides. Przy confirmed=false niczego nie zapisuje i oddaje pełną listę do pokazania właścicielce — pokaż ją CAŁĄ. Poprawki właścicielki („pomiń X”, „X to inny typ”) podaj w overrides i pokaż nowy podgląd.",
34
+ parameters: gate.parameters({
35
+ type: "object",
36
+ properties: {
37
+ url: URL_FIELD,
38
+ category: { type: "string", description: "Opcjonalnie: tylko usługi z tej kategorii Booksy (fragment nazwy)." },
39
+ overrides: {
40
+ type: "array",
41
+ description: "Poprawki do planu, po nazwie usługi z podglądu.",
42
+ items: {
43
+ type: "object",
44
+ properties: {
45
+ name: { type: "string", description: "Dokładna nazwa usługi z podglądu." },
46
+ skip: { type: "boolean", description: "true — nie kopiuj tej usługi." },
47
+ serviceTypeIds: {
48
+ type: "array",
49
+ items: { type: "string" },
50
+ description: "Id typów usług z find_service_type — zamiast dobranych automatycznie.",
51
+ },
52
+ },
53
+ required: ["name"],
54
+ additionalProperties: false,
55
+ },
56
+ },
57
+ expectedCount: {
58
+ type: "number",
59
+ description: "Liczba usług z podglądu. Narzędzie odmówi, gdy profil zmienił się od podglądu.",
60
+ },
61
+ confirmed: {
62
+ type: "boolean",
63
+ description: "false dla podglądu: narzędzie nic nie zapisze i odda listę usług do zatwierdzenia przez właścicielkę. true dopiero po tym, jak właścicielka potwierdzi.",
64
+ },
65
+ },
66
+ required: ["url", "confirmed"],
67
+ additionalProperties: false,
68
+ }),
69
+ execute: async (args) => {
70
+ try {
71
+ const url = normalizeBooksyUrl(readString(args, "url"));
72
+ const category = readOptionalString(args, "category");
73
+ const overrides = readOverrides(args);
74
+ const draft = await reader.read(url);
75
+ const plan = await buildPlan(port, draft.services, category, overrides);
76
+ // The count ties the confirmation to the list the owner saw: the ledger compares
77
+ // arguments, and a profile edited between the two calls would otherwise slip through.
78
+ const expectedCount = readOptionalNumber(args, "expectedCount") ?? plan.toAdd.length;
79
+ const echo = compact({ url, category, overrides: overrides.length > 0 ? echoOverrides(overrides) : undefined, expectedCount });
80
+ const refusal = gate.refusal(args, echo);
81
+ if (refusal)
82
+ return refusal;
83
+ if (expectedCount !== plan.toAdd.length) {
84
+ return err("invalid_arguments", `Plan ma teraz ${plan.toAdd.length} usług, a podgląd miał ${expectedCount} — profil albo katalog salonu zmienił się. Pokaż nowy podgląd (confirmed=false, bez expectedCount).`);
85
+ }
86
+ if (plan.toAdd.length === 0)
87
+ return ok(`Nie ma nic do skopiowania.${describeLeftOut(plan)}`, { added: 0 });
88
+ if (!readBoolean(args, "confirmed")) {
89
+ return gate.preview(`${describePlan(plan)}${degradedSuffix(draft)}`, echo);
90
+ }
91
+ const refused = gate.redeem(args, echo);
92
+ if (refused)
93
+ return refused;
94
+ return await writePlan(port, plan);
95
+ }
96
+ catch (error) {
97
+ if (error instanceof ArgumentError)
98
+ return err("invalid_arguments", error.message);
99
+ if (error instanceof BooksyReadError)
100
+ return err(error.code, error.summary);
101
+ return toToolError(error, "Nie udało się skopiować usług z Booksy.");
102
+ }
103
+ },
104
+ };
105
+ }
106
+ /**
107
+ * A placeholder with a purpose. Without it a model asked to "copy my team" looks for the nearest
108
+ * tool and makes up a reason why it cannot — with it, the owner hears the truth: the team is
109
+ * readable, the write is not built yet, and here is what to do meanwhile. It writes nothing, so it
110
+ * asks for no confirmation.
111
+ */
112
+ function importWorkersTool(port, reader) {
113
+ return {
114
+ name: "import_booksy_workers",
115
+ progress: "Sprawdzam zespół na Booksy…",
116
+ description: "Kopiowanie pracowników z Booksy do salonu. TEJ FUNKCJI JESZCZE NIE MA — narzędzie niczego nie zapisuje. Wywołaj je, gdy właścicielka prosi o skopiowanie zespołu: odda listę osób z profilu Booksy, powie, kogo brakuje w Sultana, i co zrobić teraz. Przekaż właścicielce wprost, że kopiowanie pracowników nie jest jeszcze dostępne.",
117
+ parameters: { type: "object", properties: { url: URL_FIELD }, required: ["url"], additionalProperties: false },
118
+ execute: async (args) => {
119
+ try {
120
+ const draft = await reader.read(normalizeBooksyUrl(readString(args, "url")));
121
+ const team = await port.listWorkers();
122
+ const onBooksy = draft.workers.map((worker) => worker.name);
123
+ const missing = matchWorkers(onBooksy, team).missing;
124
+ const parts = ["Kopiowanie pracowników z Booksy nie jest jeszcze dostępne — niczego nie zapisałem."];
125
+ if (onBooksy.length === 0)
126
+ parts.push("Profil Booksy nie pokazuje zespołu.");
127
+ else
128
+ parts.push(`Na Booksy są: ${onBooksy.join(", ")}.`);
129
+ if (missing.length > 0) {
130
+ parts.push(`W zespole Sultana brakuje: ${missing.join(", ")}. Na razie dodaj te osoby w panelu salonu (zakładka zespołu), a potem przypisz je do usług przez update_service.`);
131
+ }
132
+ else if (onBooksy.length > 0)
133
+ parts.push("Wszystkie te osoby są już w zespole Sultana.");
134
+ return ok(parts.join(" "), { supported: false, onBooksy, missingInSultana: missing });
135
+ }
136
+ catch (error) {
137
+ if (error instanceof ArgumentError)
138
+ return err("invalid_arguments", error.message);
139
+ if (error instanceof BooksyReadError)
140
+ return err(error.code, error.summary);
141
+ return toToolError(error, "Nie udało się sprawdzić zespołu.");
142
+ }
143
+ },
144
+ };
145
+ }
146
+ // --- the plan ----------------------------------------------------------------------------------
147
+ async function buildPlan(port, drafts, category, overrides) {
148
+ const [current, workers, catalog] = await Promise.all([port.listServices(), port.listWorkers(), port.findServiceTypes("")]);
149
+ const taken = new Set(current.map((service) => fold(service.name)));
150
+ const labels = new Map(catalog.map((type) => [type.id, type.label]));
151
+ const overrideByName = new Map(overrides.map((override) => [fold(override.name), override]));
152
+ const matcher = new ServiceTypeMatcher(port);
153
+ const plan = { toAdd: [], existing: [], skipped: [], remarks: [], untyped: [], missingWorkers: [] };
154
+ const inCategory = category ? drafts.filter((draft) => fold(draft.booksyCategory).includes(fold(category))) : drafts;
155
+ const wanted = mergeRepeatedServices(inCategory, plan.remarks);
156
+ const unknownOverrides = overrides.filter((override) => !wanted.some((draft) => fold(draft.name) === fold(override.name)));
157
+ if (unknownOverrides.length > 0) {
158
+ throw new ArgumentError(`W overrides są nazwy, których nie ma w profilu: ${unknownOverrides.map((override) => `„${override.name}”`).join(", ")}. Użyj dokładnych nazw z podglądu.`);
159
+ }
160
+ for (const draft of wanted) {
161
+ const override = overrideByName.get(fold(draft.name));
162
+ if (override?.skip) {
163
+ plan.skipped.push(`„${draft.name}” — pominięta na życzenie`);
164
+ continue;
165
+ }
166
+ if (taken.has(fold(draft.name))) {
167
+ plan.existing.push(draft.name);
168
+ continue;
169
+ }
170
+ if (draft.durationMinutes === null) {
171
+ plan.skipped.push(`„${draft.name}” — Booksy nie podaje czasu trwania; dodaj ją przez add_service`);
172
+ continue;
173
+ }
174
+ const notes = [...draft.notes];
175
+ const serviceTypeIds = override?.serviceTypeIds ?? (await matcher.match(draft));
176
+ const unknownTypes = serviceTypeIds.filter((id) => !labels.has(id));
177
+ if (unknownTypes.length > 0) {
178
+ throw new ArgumentError(`Nieznane typy usług: ${unknownTypes.join(", ")}. Użyj find_service_type i podaj id z katalogu.`);
179
+ }
180
+ if (serviceTypeIds.length === 0) {
181
+ plan.untyped.push(draft.name);
182
+ continue;
183
+ }
184
+ // Two variants with one label would collide in the salon just as they would on a list.
185
+ taken.add(fold(draft.name));
186
+ // A missing person repeats down the whole list — said once, under it, not on every line.
187
+ const assigned = matchWorkers(draft.workerNames, workers);
188
+ for (const name of assigned.missing)
189
+ if (!plan.missingWorkers.includes(name))
190
+ plan.missingWorkers.push(name);
191
+ plan.toAdd.push({
192
+ input: {
193
+ name: draft.name,
194
+ pricePln: draft.pricePln,
195
+ durationMinutes: draft.durationMinutes,
196
+ active: true,
197
+ serviceTypeIds,
198
+ workerIds: assigned.found.map((worker) => worker.id),
199
+ },
200
+ category: draft.booksyCategory,
201
+ typeLabels: serviceTypeIds.map((id) => labels.get(id) ?? id),
202
+ workerNames: assigned.found.map((worker) => worker.name),
203
+ notes,
204
+ });
205
+ }
206
+ return plan;
207
+ }
208
+ /** A whole name found in the catalogue outweighs any single word. */
209
+ const PHRASE_SCORE = 3;
210
+ /** The Booksy category is a weaker hint than the service's own name. */
211
+ const CATEGORY_WORD_SCORE = 0.75;
212
+ /**
213
+ * Booksy has no "who does it" on a shared service, so a salon lists the same service once per
214
+ * person ("Zdejmowanie rzęs" under Oksana's category and again under Ilona's). Sultana's service
215
+ * holds its people, so the repeats fold into one — when price and time agree. When they do not,
216
+ * they are different services, and the later one takes its Booksy category into its name.
217
+ */
218
+ function mergeRepeatedServices(drafts, remarks) {
219
+ const merged = [];
220
+ for (const draft of drafts) {
221
+ const twin = merged.find((earlier) => fold(earlier.name) === fold(draft.name));
222
+ if (!twin) {
223
+ merged.push({ ...draft, workerNames: [...draft.workerNames], notes: [...draft.notes] });
224
+ }
225
+ else if (twin.pricePln === draft.pricePln && twin.durationMinutes === draft.durationMinutes) {
226
+ for (const name of draft.workerNames)
227
+ if (!twin.workerNames.includes(name))
228
+ twin.workerNames.push(name);
229
+ remarks.push(`„${draft.name}” jest na Booksy kilka razy z tą samą ceną i czasem — łączę w jedną usługę`);
230
+ }
231
+ else {
232
+ const renamed = `${draft.name} (${draft.booksyCategory || "wariant"})`;
233
+ remarks.push(`„${draft.name}” jest na Booksy kilka razy z różną ceną albo czasem — drugą nazywam „${renamed}”`);
234
+ merged.push({ ...draft, name: renamed, workerNames: [...draft.workerNames], notes: [...draft.notes] });
235
+ }
236
+ }
237
+ return merged;
238
+ }
239
+ /**
240
+ * Picks a service type with the catalogue's own search (`findServiceTypes` — the matcher both
241
+ * frontends use, synonyms included). Every type collects points: for the whole name, for each word
242
+ * of the name — the earlier the word, the more, because a Polish service name leads with what it
243
+ * is ("Pedicure męski" is a pedicure, not something for men) — and for the words of the Booksy
244
+ * category. The best score wins; a tie goes to the catalogue's order.
245
+ *
246
+ * A type is a candidate only when something that says what the service IS points at it: the whole
247
+ * name, the name's first word, or the Booksy category. A later word alone is an adjective — in a
248
+ * catalogue without pedicures, "Pedicure męski" must get no type rather than "Strzyżenie męskie".
249
+ * It is still a guess — the preview shows it, and `overrides` corrects it.
250
+ */
251
+ class ServiceTypeMatcher {
252
+ port;
253
+ hits = new Map();
254
+ constructor(port) {
255
+ this.port = port;
256
+ }
257
+ async match(draft) {
258
+ const scores = new Map();
259
+ const anchored = new Set();
260
+ const credit = async (query, points, anchors) => {
261
+ for (const type of await this.search(query)) {
262
+ scores.set(type.id, (scores.get(type.id) ?? 0) + points);
263
+ if (anchors)
264
+ anchored.add(type.id);
265
+ }
266
+ };
267
+ await credit(draft.booksyService, PHRASE_SCORE, true);
268
+ if (draft.name !== draft.booksyService)
269
+ await credit(draft.name, PHRASE_SCORE, true);
270
+ const nameWords = [...new Set(words(draft.name))];
271
+ for (const [position, word] of nameWords.entries())
272
+ await credit(word, 2 / (1 + position), position === 0);
273
+ for (const word of new Set(words(draft.booksyCategory)))
274
+ await credit(word, CATEGORY_WORD_SCORE, true);
275
+ let best = null;
276
+ let bestScore = 0;
277
+ // A Map keeps insertion order, and the first credited query lists types in catalogue
278
+ // order — so ">" alone leaves a tie with the earlier type.
279
+ for (const [id, score] of scores) {
280
+ if (anchored.has(id) && score > bestScore) {
281
+ best = id;
282
+ bestScore = score;
283
+ }
284
+ }
285
+ return best === null ? [] : [best];
286
+ }
287
+ search(query) {
288
+ const key = fold(query);
289
+ let hits = this.hits.get(key);
290
+ if (!hits) {
291
+ hits = this.port.findServiceTypes(query);
292
+ this.hits.set(key, hits);
293
+ }
294
+ return hits;
295
+ }
296
+ }
297
+ /** Booksy shows "Ciarkowska Magdalena", Sultana may hold "Magdalena" — any shared word is a match,
298
+ * as long as it points at exactly one person. */
299
+ function matchWorkers(names, workers) {
300
+ const found = [];
301
+ const missing = [];
302
+ for (const name of names) {
303
+ const parts = new Set(fold(name).split(" "));
304
+ const candidates = workers.filter((worker) => fold(worker.name).split(" ").some((part) => parts.has(part)));
305
+ if (candidates.length === 1) {
306
+ if (!found.includes(candidates[0]))
307
+ found.push(candidates[0]);
308
+ }
309
+ else
310
+ missing.push(name);
311
+ }
312
+ return { found, missing };
313
+ }
314
+ // --- the write ---------------------------------------------------------------------------------
315
+ async function writePlan(port, plan) {
316
+ const added = [];
317
+ let failure = null;
318
+ for (let at = 0; at < plan.toAdd.length && failure === null; at += WRITE_CONCURRENCY) {
319
+ const batch = plan.toAdd.slice(at, at + WRITE_CONCURRENCY);
320
+ const results = await Promise.allSettled(batch.map((service) => port.addService(service.input)));
321
+ for (const [index, result] of results.entries()) {
322
+ if (result.status === "fulfilled")
323
+ added.push(batch[index].input.name);
324
+ // A spent quota or a dead connection will not get better on the next batch — stop, and
325
+ // say how far it got. Running the import again picks up where this one ended.
326
+ else
327
+ failure ??= toToolError(result.reason, "Kanister odrzucił zapis usługi.");
328
+ }
329
+ }
330
+ if (failure === null)
331
+ return ok(`Skopiowano ${added.length} usług z Booksy.${describeLeftOut(plan)}`, { added: added.length });
332
+ const left = plan.toAdd.length - added.length;
333
+ const reason = failure.summary;
334
+ if (added.length === 0)
335
+ return { ...failure, summary: `Nie skopiowano żadnej usługi. ${reason}` };
336
+ return ok(`Skopiowano ${added.length} z ${plan.toAdd.length} usług, potem zapis się nie udał: ${reason} Zostało ${left}. Ponowny import (nowy podgląd i zgoda) pominie to, co już jest w salonie.`, { added: added.length, remaining: left });
337
+ }
338
+ // --- texts -------------------------------------------------------------------------------------
339
+ function describePlan(plan) {
340
+ const lines = [`Skopiuję z Booksy ${plan.toAdd.length} usług:`];
341
+ let category = null;
342
+ for (const service of plan.toAdd) {
343
+ if (service.category !== category) {
344
+ category = service.category;
345
+ lines.push(`[${category || "bez kategorii"}]`);
346
+ }
347
+ const type = service.typeLabels.join(", ");
348
+ const people = service.workerNames.length > 0 ? `; ${service.workerNames.join(", ")}` : "";
349
+ const notes = service.notes.length > 0 ? ` (uwaga: ${service.notes.join("; ")})` : "";
350
+ lines.push(`- ${service.input.name} — ${service.input.pricePln} zł, ${service.input.durationMinutes} min; typ: ${type}${people}${notes}`);
351
+ }
352
+ return `${lines.join("\n")}${describeLeftOut(plan)}`;
353
+ }
354
+ function describeLeftOut(plan) {
355
+ const parts = [];
356
+ if (plan.untyped.length > 0) {
357
+ parts.push(`NIE skopiuję ${plan.untyped.length} usług, bo nie dobrałem im typu, a Sultana wymaga typu: ${plan.untyped.map((name) => `„${name}”`).join(", ")}. Dobierz typy przez find_service_type i podaj je w overrides (name + serviceTypeIds), potem pokaż nowy podgląd.`);
358
+ }
359
+ if (plan.missingWorkers.length > 0) {
360
+ parts.push(`Osoby z Booksy, których nie ma w zespole Sultana: ${plan.missingWorkers.join(", ")} — ich usługi zostaną bez przypisanej osoby.`);
361
+ }
362
+ if (plan.remarks.length > 0)
363
+ parts.push(`${[...new Set(plan.remarks)].join("; ")}.`);
364
+ if (plan.existing.length > 0)
365
+ parts.push(`Już są w salonie (pomijam): ${plan.existing.length}.`);
366
+ if (plan.skipped.length > 0)
367
+ parts.push(`Nie kopiuję: ${plan.skipped.join("; ")}.`);
368
+ return parts.length === 0 ? "" : `\n${parts.join(" ")}`;
369
+ }
370
+ // --- arguments ---------------------------------------------------------------------------------
371
+ function readOverrides(args) {
372
+ const raw = args.overrides;
373
+ if (raw === undefined || raw === null)
374
+ return [];
375
+ if (!Array.isArray(raw))
376
+ throw new ArgumentError('Pole "overrides" musi być listą.');
377
+ return raw.map((entry) => {
378
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
379
+ throw new ArgumentError('Każda pozycja "overrides" musi być obiektem z polem name.');
380
+ }
381
+ const override = entry;
382
+ return {
383
+ name: readString(override, "name"),
384
+ skip: readBoolean(override, "skip"),
385
+ serviceTypeIds: readOptionalStringArray(override, "serviceTypeIds"),
386
+ };
387
+ });
388
+ }
389
+ /** Overrides in one canonical form, so resending the model's own arguments and resending the echo
390
+ * confirm the same change. */
391
+ function echoOverrides(overrides) {
392
+ return overrides.map((override) => compact({ name: override.name, skip: override.skip || undefined, serviceTypeIds: override.serviceTypeIds }));
393
+ }
394
+ function compact(record) {
395
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined));
396
+ }
397
+ /** The words worth searching for. Diacritics stay — folding them is the catalogue search's job. */
398
+ function words(text) {
399
+ return text
400
+ .toLowerCase()
401
+ .split(/[^\p{L}\p{N}]+/u)
402
+ .filter((word) => word.length >= 4);
403
+ }
404
+ /** Case, diacritics and spacing do not make two names different. */
405
+ function fold(text) {
406
+ return text
407
+ .toLowerCase()
408
+ .replace(/ł/g, "l")
409
+ .normalize("NFD")
410
+ .replace(/[̀-ͯ]/g, "")
411
+ .replace(/\s+/g, " ")
412
+ .trim();
413
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A Booksy profile is a Nuxt page, and Nuxt ships its state as a `devalue` payload: one flat JSON
3
+ * array in which every object value and every array element is an INDEX into that same array.
4
+ * Only the entries themselves hold literal values.
5
+ *
6
+ * This is the reader for that format — small on purpose, so the package takes no dependency for
7
+ * a single page type.
8
+ */
9
+ /** The raw payload array of a Nuxt page, or `null` when the page has none. */
10
+ export declare function extractNuxtPayload(html: string): unknown[] | null;
11
+ /** The index of the first plain object in the payload that has every one of `keys`. */
12
+ export declare function findPayloadObject(payload: unknown[], keys: string[]): number;
13
+ /** Resolves the entry at `index` into a plain value — references followed, wrappers unwrapped. */
14
+ export declare function hydratePayload(payload: unknown[], index: number): unknown;
15
+ //# sourceMappingURL=nuxt-payload.d.ts.map