@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.
@@ -0,0 +1,227 @@
1
+ import { ArgumentError, readOptionalString, readOptionalStringArray, readString } from "../tools/args.js";
2
+ import { err, ok } from "../types.js";
3
+ import { BooksyReadError, fetchBooksyPage, fetchPhoto, normalizeBooksyUrl } from "./fetch-page.js";
4
+ import { parseBooksySalon } from "./parse-salon.js";
5
+ import { toSultanaDraft } from "./to-sultana.js";
6
+ export const URL_FIELD = {
7
+ type: "string",
8
+ description: "Adres profilu salonu na Booksy, np. https://booksy.com/pl-pl/12345_nazwa-salonu_fryzjer_3_warszawa.",
9
+ };
10
+ const PHOTO_KINDS = ["logo", "cover", "salon", "inspiration", "service", "staff"];
11
+ const DEFAULT_PHOTO_KINDS = ["logo", "cover", "salon"];
12
+ // A busy profile has about a hundred photos — a service portfolio alone runs to 70 or more.
13
+ const MAX_PHOTOS_PER_CALL = 150;
14
+ /**
15
+ * Read-only tools over a PUBLIC Booksy profile: they fetch one page and hand back its content in
16
+ * the shape Sultana's write tools take. Nothing here writes to a salon, so nothing here asks for
17
+ * a confirmation. Reviews and customer names are never read.
18
+ */
19
+ export function createBooksyReaderTools(options = {}) {
20
+ const reader = options.reader ?? new BooksyProfileReader(options.fetchFn);
21
+ const tools = [readSalonTool(reader), readServicesTool(reader)];
22
+ if (options.photoSink)
23
+ tools.push(downloadPhotosTool(reader, options.photoSink, options.fetchFn));
24
+ return tools;
25
+ }
26
+ /** Reads and recasts a whole profile — for a host or a script that wants the draft without a model. */
27
+ export async function readBooksySalonDraft(url, fetchFn) {
28
+ return new BooksyProfileReader(fetchFn).read(url);
29
+ }
30
+ /** Long enough for a preview, the owner's "tak" and the import to read one fetch; short enough
31
+ * that an owner who has just edited her Booksy profile is not shown yesterday's. */
32
+ const PROFILE_TTL_MS = 10 * 60_000;
33
+ /** A reader may live as long as its host — it must not grow with every salon ever named. */
34
+ const MAX_REMEMBERED_PROFILES = 50;
35
+ /**
36
+ * Every Booksy tool reads the same page — fetch it once. Profiles are public, so one reader can
37
+ * serve a whole host; a profile is remembered for ten minutes, the last fifty of them.
38
+ */
39
+ export class BooksyProfileReader {
40
+ fetchFn;
41
+ now;
42
+ drafts = new Map();
43
+ constructor(fetchFn, now = Date.now) {
44
+ this.fetchFn = fetchFn;
45
+ this.now = now;
46
+ }
47
+ read(rawUrl) {
48
+ const url = normalizeBooksyUrl(rawUrl);
49
+ const remembered = this.drafts.get(url);
50
+ if (remembered && this.now() - remembered.readAt <= PROFILE_TTL_MS)
51
+ return remembered.draft;
52
+ const draft = fetchBooksyPage(url, this.fetchFn).then((html) => toSultanaDraft(parseBooksySalon(html), url));
53
+ const entry = { readAt: this.now(), draft };
54
+ // A failed read must not be remembered — the next call tries again.
55
+ draft.catch(() => {
56
+ if (this.drafts.get(url) === entry)
57
+ this.drafts.delete(url);
58
+ });
59
+ // Re-inserting moves the profile to the end, so the first key is always the oldest.
60
+ this.drafts.delete(url);
61
+ this.drafts.set(url, entry);
62
+ while (this.drafts.size > MAX_REMEMBERED_PROFILES)
63
+ this.drafts.delete(this.drafts.keys().next().value);
64
+ return draft;
65
+ }
66
+ }
67
+ function readSalonTool(reader) {
68
+ return {
69
+ name: "read_booksy_salon",
70
+ progress: "Czytam profil salonu na Booksy…",
71
+ description: "Czyta publiczny profil salonu na Booksy i zwraca: nazwę, opis, adres, współrzędne, godziny otwarcia (dzień 0 = poniedziałek), zespół oraz liczbę usług i zdjęć. Niczego nie zapisuje. Usługi zwraca read_booksy_services.",
72
+ parameters: { type: "object", properties: { url: URL_FIELD }, required: ["url"], additionalProperties: false },
73
+ execute: (args) => guarded("Nie udało się odczytać profilu Booksy.", async () => {
74
+ const draft = await reader.read(readString(args, "url"));
75
+ const categories = new Set(draft.services.map((service) => service.booksyCategory));
76
+ return ok(`Salon „${draft.salon.name}” (${draft.salon.city || "miasto nieznane"}): ${draft.services.length} usług w ${categories.size} kategoriach, zespół ${draft.workers.length} os., ${draft.photos.length} zdjęć.${degradedSuffix(draft)}`, {
77
+ source: draft.source,
78
+ salon: draft.salon,
79
+ weeklyHours: draft.weeklyHours,
80
+ workers: draft.workers.map((worker) => worker.name),
81
+ serviceCount: draft.services.length,
82
+ serviceCategories: [...categories],
83
+ photoCounts: countPhotos(draft.photos),
84
+ });
85
+ }),
86
+ };
87
+ }
88
+ function readServicesTool(reader) {
89
+ return {
90
+ name: "read_booksy_services",
91
+ progress: "Czytam usługi salonu na Booksy…",
92
+ description: "Czyta usługi z publicznego profilu Booksy w postaci gotowej dla add_service: name, pricePln (pełne złote), durationMinutes (wielokrotność 5). Każdy wariant usługi Booksy to osobna usługa. serviceTypeIds są puste — dobierz je przez find_service_type, podpowiedzią jest booksyCategory. Pole notes opisuje, co zmieniono albo czego brakuje — powiedz o tym właścicielce. Niczego nie zapisuje.",
93
+ parameters: {
94
+ type: "object",
95
+ properties: {
96
+ url: URL_FIELD,
97
+ category: { type: "string", description: "Opcjonalnie: tylko usługi z tej kategorii Booksy (fragment nazwy)." },
98
+ },
99
+ required: ["url"],
100
+ additionalProperties: false,
101
+ },
102
+ execute: (args) => guarded("Nie udało się odczytać usług z Booksy.", async () => {
103
+ const draft = await reader.read(readString(args, "url"));
104
+ const category = readOptionalString(args, "category")?.toLowerCase();
105
+ const services = category
106
+ ? draft.services.filter((service) => service.booksyCategory.toLowerCase().includes(category))
107
+ : draft.services;
108
+ if (services.length === 0) {
109
+ return ok(category ? `Brak usług w kategorii „${category}”.` : "Profil Booksy nie ma żadnej usługi.", []);
110
+ }
111
+ const withNotes = services.filter((service) => service.notes.length > 0).length;
112
+ const notesPart = withNotes > 0 ? ` ${withNotes} z nich ma uwagi (pole notes).` : "";
113
+ return ok(`Usługi z Booksy: ${services.length}.${notesPart}${degradedSuffix(draft)}`, services);
114
+ }),
115
+ };
116
+ }
117
+ function downloadPhotosTool(reader, sink, fetchFn) {
118
+ return {
119
+ name: "download_booksy_photos",
120
+ progress: "Pobieram zdjęcia z Booksy…",
121
+ description: `Pobiera zdjęcia z publicznego profilu Booksy i odkłada je po stronie Sultana. Rodzaje: ${PHOTO_KINDS.join(", ")} („service” to portfolio usług — zdjęcia prac przypisane do usługi, „inspiration” to galeria prac). Domyślnie: ${DEFAULT_PHOTO_KINDS.join(", ")}. Najwyżej ${MAX_PHOTOS_PER_CALL} zdjęć na wywołanie. Zwraca listę zapisanych plików. Niczego nie zapisuje w salonie.`,
122
+ parameters: {
123
+ type: "object",
124
+ properties: {
125
+ url: URL_FIELD,
126
+ kinds: { type: "array", items: { type: "string", enum: PHOTO_KINDS }, description: "Które rodzaje zdjęć pobrać." },
127
+ },
128
+ required: ["url"],
129
+ additionalProperties: false,
130
+ },
131
+ execute: (args) => guarded("Nie udało się pobrać zdjęć z Booksy.", async () => {
132
+ const draft = await reader.read(readString(args, "url"));
133
+ const kinds = readKinds(args);
134
+ const wanted = draft.photos.filter((photo) => kinds.includes(photo.kind));
135
+ if (wanted.length === 0)
136
+ return ok("Profil Booksy nie ma zdjęć tych rodzajów.", []);
137
+ const saved = [];
138
+ const failed = [];
139
+ const counters = new Map();
140
+ for (const photo of wanted.slice(0, MAX_PHOTOS_PER_CALL)) {
141
+ const index = (counters.get(photo.kind) ?? 0) + 1;
142
+ counters.set(photo.kind, index);
143
+ // One broken photo must not cost the owner the rest of the gallery.
144
+ try {
145
+ const file = await fetchPhoto(photo.url, fetchFn);
146
+ const location = await sink.save({
147
+ salonKey: draft.source.booksyId ?? "unknown",
148
+ kind: photo.kind,
149
+ fileName: `${photo.kind}-${String(index).padStart(2, "0")}${captionSlug(photo)}.${extensionOf(file.contentType)}`,
150
+ contentType: file.contentType,
151
+ bytes: file.bytes,
152
+ sourceUrl: photo.url,
153
+ caption: photo.caption,
154
+ });
155
+ saved.push({ kind: photo.kind, location, caption: photo.caption });
156
+ }
157
+ catch {
158
+ failed.push(photo.url);
159
+ }
160
+ }
161
+ const skipped = wanted.length - Math.min(wanted.length, MAX_PHOTOS_PER_CALL);
162
+ const parts = [`Pobrano ${saved.length} z ${wanted.length} zdjęć.`];
163
+ if (failed.length > 0)
164
+ parts.push(`Nie udało się pobrać: ${failed.length}.`);
165
+ if (skipped > 0)
166
+ parts.push(`Pominięto ${skipped} ponad limit ${MAX_PHOTOS_PER_CALL}.`);
167
+ if (saved.length === 0)
168
+ return err("unavailable", parts.join(" "));
169
+ return ok(parts.join(" "), { saved, failed });
170
+ }),
171
+ };
172
+ }
173
+ function readKinds(args) {
174
+ const raw = readOptionalStringArray(args, "kinds");
175
+ if (!raw || raw.length === 0)
176
+ return DEFAULT_PHOTO_KINDS;
177
+ const unknown = raw.filter((kind) => !PHOTO_KINDS.includes(kind));
178
+ if (unknown.length > 0)
179
+ throw new ArgumentError(`Nieznany rodzaj zdjęć: ${unknown.join(", ")}. Dozwolone: ${PHOTO_KINDS.join(", ")}.`);
180
+ return raw;
181
+ }
182
+ function countPhotos(photos) {
183
+ const counts = {};
184
+ for (const photo of photos)
185
+ counts[photo.kind] = (counts[photo.kind] ?? 0) + 1;
186
+ return counts;
187
+ }
188
+ export function degradedSuffix(draft) {
189
+ return draft.source.degraded
190
+ ? " UWAGA: odczyt zastępczy — bez czasów trwania, kategorii i galerii; Booksy mogło zmienić stronę."
191
+ : "";
192
+ }
193
+ /** "-makijaz-okolicznosciowy" for a service or staff photo; empty for the rest. */
194
+ function captionSlug(photo) {
195
+ if ((photo.kind !== "service" && photo.kind !== "staff") || !photo.caption)
196
+ return "";
197
+ const slug = photo.caption
198
+ .toLowerCase()
199
+ .replace(/ł/g, "l")
200
+ .normalize("NFD")
201
+ .replace(/[\u0300-\u036f]/g, "")
202
+ .replace(/[^a-z0-9]+/g, "-")
203
+ .replace(/^-+|-+$/g, "")
204
+ .slice(0, 40);
205
+ return slug === "" ? "" : `-${slug}`;
206
+ }
207
+ function extensionOf(contentType) {
208
+ const subtype = contentType.split(";")[0].trim().split("/")[1] ?? "";
209
+ if (subtype === "jpeg" || subtype === "jpg")
210
+ return "jpeg";
211
+ if (subtype === "png" || subtype === "webp" || subtype === "gif")
212
+ return subtype;
213
+ return "bin";
214
+ }
215
+ /** `execute` never throws: every failure becomes a result the agent can read. */
216
+ export async function guarded(fallbackSummary, run) {
217
+ try {
218
+ return await run();
219
+ }
220
+ catch (error) {
221
+ if (error instanceof ArgumentError)
222
+ return err("invalid_arguments", error.message);
223
+ if (error instanceof BooksyReadError)
224
+ return err(error.code, error.summary);
225
+ return err("unavailable", fallbackSummary);
226
+ }
227
+ }
@@ -30,6 +30,7 @@ export declare class IcSalonCorePort implements SalonCorePort {
30
30
  private readonly availability;
31
31
  private readonly assets;
32
32
  private readonly promo;
33
+ private catalog;
33
34
  private readonly discountCodes;
34
35
  constructor(options: IcSalonCorePortOptions);
35
36
  listServices(): Promise<ServiceView[]>;
@@ -38,6 +39,9 @@ export declare class IcSalonCorePort implements SalonCorePort {
38
39
  removeService(serviceId: string): Promise<void>;
39
40
  listWorkers(): Promise<WorkerView[]>;
40
41
  findServiceTypes(query: string): Promise<ServiceTypeView[]>;
42
+ /** The catalogue changes when a superadmin edits it — rarely — while one Booksy import searches
43
+ * it dozens of times. One canister query a minute serves them all. */
44
+ private serviceTypesCatalog;
41
45
  getWeeklyHours(workerId: string, day: number): Promise<TimeRange[]>;
42
46
  setWeeklyHours(workerId: string, day: number, ranges: TimeRange[]): Promise<void>;
43
47
  getDailyBusy(workerId: string, date: string): Promise<TimeRange[]>;
@@ -1 +1 @@
1
- {"version":3,"file":"ic-salon-core-port.d.ts","sourceRoot":"","sources":["../src/ic-salon-core-port.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"ic-salon-core-port.d.ts","sourceRoot":"","sources":["../src/ic-salon-core-port.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,6 @@
1
1
  import { Principal } from "@icp-sdk/core/principal";
2
2
  import { AssetsActor, AvailabilityActor, DiscountCodesActor, filterServiceTypes, HrActor, PromoActor, promoMusicOf, SalonServicesActor, ServiceTypesActor, } from "@jsm-mit/sultana-core-motoko-package";
3
+ const SERVICE_TYPES_CATALOG_TTL_MS = 60_000;
3
4
  /**
4
5
  * The port over the published canister wrapper. Conversions live here and nowhere else: the
5
6
  * canister speaks `bigint` and `Principal`, the tools speak numbers and strings.
@@ -14,6 +15,7 @@ export class IcSalonCorePort {
14
15
  availability;
15
16
  assets;
16
17
  promo;
18
+ catalog = null;
17
19
  discountCodes;
18
20
  constructor(options) {
19
21
  this.salonId = options.salonId;
@@ -62,13 +64,29 @@ export class IcSalonCorePort {
62
64
  }));
63
65
  }
64
66
  async findServiceTypes(query) {
65
- const catalog = await this.serviceTypes.getServiceTypesCatalogAsyncUnsafe(this.language);
67
+ const catalog = await this.serviceTypesCatalog();
66
68
  return filterServiceTypes(catalog, query).map((entry) => ({
67
69
  id: entry.id,
68
70
  label: entry.text,
69
71
  category: entry.category,
70
72
  }));
71
73
  }
74
+ /** The catalogue changes when a superadmin edits it — rarely — while one Booksy import searches
75
+ * it dozens of times. One canister query a minute serves them all. */
76
+ serviceTypesCatalog() {
77
+ const now = Date.now();
78
+ if (!this.catalog || now - this.catalog.readAt > SERVICE_TYPES_CATALOG_TTL_MS) {
79
+ const reply = this.serviceTypes.getServiceTypesCatalogAsyncUnsafe(this.language);
80
+ const entry = { readAt: now, reply };
81
+ // A failed read is not kept: the next search asks the canister again.
82
+ reply.catch(() => {
83
+ if (this.catalog === entry)
84
+ this.catalog = null;
85
+ });
86
+ this.catalog = entry;
87
+ }
88
+ return this.catalog.reply;
89
+ }
72
90
  async getWeeklyHours(workerId, day) {
73
91
  return this.availability.getDayFromWeeklyTemplateAsyncUnsafe(BigInt(day), this.salonId, Principal.fromText(workerId));
74
92
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
2
2
  export type { ConfirmationLedgerOptions, ConfirmationRefusal, RedeemResult, TurnConfirmations, } from "./confirmations.js";
3
+ export { BooksyReadError } from "./booksy/fetch-page.js";
4
+ export type { FetchFn } from "./booksy/fetch-page.js";
5
+ export type { BooksyImage, BooksyImageKind, BooksyOpenHours, BooksyPriceType, BooksySalon, BooksyService, BooksyServiceCategory, BooksyStaffer, BooksyVariant, } from "./booksy/parse-salon.js";
6
+ export { parseBooksySalon } from "./booksy/parse-salon.js";
7
+ export { toSultanaDraft } from "./booksy/to-sultana.js";
8
+ export type { SultanaDraftDay, SultanaPhotoDraft, SultanaSalonDraft, SultanaSalonDraftProfile, SultanaServiceDraft, SultanaWorkerDraft, } from "./booksy/to-sultana.js";
9
+ export { createBooksyImportTools, createBooksyTools } from "./booksy/import-tools.js";
10
+ export type { CreateBooksyImportToolsOptions, CreateBooksyToolsOptions } from "./booksy/import-tools.js";
11
+ export { BooksyProfileReader, createBooksyReaderTools, readBooksySalonDraft } from "./booksy/tools.js";
12
+ export type { BooksyPhotoFile, BooksyPhotoSink, CreateBooksyReaderToolsOptions } from "./booksy/tools.js";
3
13
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
4
14
  export type { CreateSalonReadToolsOptions, CreateSalonToolsFromPortOptions, CreateSalonToolsOptions, } from "./create-salon-tools.js";
5
15
  export { createCustomerReadTools, createCustomerReadToolsFromPort, createCustomerTools, createCustomerToolsFromPort, DEFAULT_SALON_TIME_ZONE, } from "./customer/create-customer-tools.js";
@@ -15,7 +25,7 @@ export type { CustomerTexts } from "./customer/texts.js";
15
25
  export { hostTimeZoneProblem } from "./customer/time.js";
16
26
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
17
27
  export type { IcSalonCorePortOptions } from "./ic-salon-core-port.js";
18
- export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
28
+ export { SALON_AGENT_PERSONA_PL, SALON_BOOKSY_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
19
29
  export type { DiscountCodeInput, DiscountCodeView, MediaView, PromoView, PromoWriteInput, SalonAccess, SalonCorePort, SalonPromoPort, ServiceTypeView, ServiceView, ServiceWriteInput, SalonSchedulePort, TimeRange, WorkerView, } from "./salon-core-port.js";
20
30
  export { err, needsConfirmation, ok } from "./types.js";
21
31
  export type { AgentTool, ToolErrorCode, ToolParametersSchema, ToolResult } from "./types.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,2BAA2B,EAAE,MAAM,oBAAoB,CAAC;AACrF,YAAY,EACR,yBAAyB,EACzB,mBAAmB,EACnB,YAAY,EACZ,iBAAiB,GACpB,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACH,oBAAoB,EACpB,4BAA4B,EAC5B,gBAAgB,EAChB,wBAAwB,GAC3B,MAAM,yBAAyB,CAAC;AACjC,YAAY,EACR,2BAA2B,EAC3B,+BAA+B,EAC/B,uBAAuB,GAC1B,MAAM,yBAAyB,CAAC;AACjC,OAAO,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,eAAe,EAAE,uBAAuB,EAAE,MAAM,sBAAsB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAC5D,OAAO,EAAE,yBAAyB,EAAE,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACxF,OAAO,EAAE,kBAAkB,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACxE,YAAY,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACzD,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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js CHANGED
@@ -1,4 +1,9 @@
1
1
  export { ConfirmationLedger, DEFAULT_CONFIRMATION_TTL_MS } from "./confirmations.js";
2
+ export { BooksyReadError } from "./booksy/fetch-page.js";
3
+ export { parseBooksySalon } from "./booksy/parse-salon.js";
4
+ export { toSultanaDraft } from "./booksy/to-sultana.js";
5
+ export { createBooksyImportTools, createBooksyTools } from "./booksy/import-tools.js";
6
+ export { BooksyProfileReader, createBooksyReaderTools, readBooksySalonDraft } from "./booksy/tools.js";
2
7
  export { createSalonReadTools, createSalonReadToolsFromPort, createSalonTools, createSalonToolsFromPort, } from "./create-salon-tools.js";
3
8
  export { createCustomerReadTools, createCustomerReadToolsFromPort, createCustomerTools, createCustomerToolsFromPort, DEFAULT_SALON_TIME_ZONE, } from "./customer/create-customer-tools.js";
4
9
  export { IcCustomerCorePort } from "./customer/ic-customer-core-port.js";
@@ -7,5 +12,5 @@ export { CUSTOMER_AGENT_PERSONA_PL, customerAgentPersona } from "./customer/pers
7
12
  export { customerLanguageOf, customerTexts } from "./customer/texts.js";
8
13
  export { hostTimeZoneProblem } from "./customer/time.js";
9
14
  export { IcSalonCorePort } from "./ic-salon-core-port.js";
10
- export { SALON_AGENT_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
15
+ export { SALON_AGENT_PERSONA_PL, SALON_BOOKSY_PERSONA_PL, SALON_READ_PERSONA_PL } from "./persona.js";
11
16
  export { err, needsConfirmation, ok } from "./types.js";
package/dist/persona.d.ts CHANGED
@@ -18,4 +18,9 @@ export declare const SALON_AGENT_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u
18
18
  * promising a change or reporting a hidden service as missing.
19
19
  */
20
20
  export declare const SALON_READ_PERSONA_PL = "Jeste\u015B asystentem w\u0142a\u015Bciciela salonu w aplikacji Sultana.\nRozmawiasz po polsku, kr\u00F3tko i konkretnie. Odpowiadasz na pytania o salon za pomoc\u0105 narz\u0119dzi.\n\nZasady pracy:\n- Zanim odpowiesz o us\u0142ugach, promocjach albo typach us\u0142ug, ZAWSZE wywo\u0142aj narz\u0119dzie\n (list_services, list_promos, find_service_type). Nigdy nie zgaduj.\n- Widzisz tylko AKTYWNE us\u0142ugi i AKTYWNE promocje \u2014 to, co widz\u0105 klientki.\n Powiedz o tym, gdy ma to znaczenie, np. gdy w\u0142a\u015Bciciel pyta o us\u0142ug\u0119, kt\u00F3rej nie ma na li\u015Bcie:\n mo\u017Ce by\u0107 wy\u0142\u0105czona.\n- Nie mo\u017Cesz jeszcze niczego zmienia\u0107. Gdy w\u0142a\u015Bciciel prosi o dodanie, zmian\u0119 albo usuni\u0119cie,\n powiedz, \u017Ce edycja przez czat b\u0119dzie dost\u0119pna wkr\u00F3tce, i wska\u017C stron\u0119 w panelu:\n \u201EUs\u0142ugi\u201D dla us\u0142ug, \u201EPromocje\u201D dla promocji.\n- Ceny podawaj w pe\u0142nych z\u0142otych, czas trwania w minutach.\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- Nie obiecuj zmian, kt\u00F3rych nie mo\u017Cesz wykona\u0107.";
21
+ /**
22
+ * The rules for the Booksy set (`createBooksyTools`) — appended to `SALON_AGENT_PERSONA_PL` by a
23
+ * host that hands those tools to the agent. Separate, because a host may run without them.
24
+ */
25
+ export declare const SALON_BOOKSY_PERSONA_PL = "Kopiowanie salonu z Booksy:\n- Gdy w\u0142a\u015Bcicielka chce przenie\u015B\u0107 co\u015B z Booksy, popro\u015B o adres jej profilu\n (https://booksy.com/pl-pl/<numer>_<nazwa>\u2026), je\u015Bli go nie poda\u0142a.\n- read_booksy_salon i read_booksy_services tylko czytaj\u0105 profil \u2014 niczego nie zapisuj\u0105.\n- Us\u0142ugi kopiuj WY\u0141\u0104CZNIE przez import_booksy_services (wszystkie naraz, jedno potwierdzenie).\n Nie przepisuj ich pojedynczo przez add_service.\n- Podgl\u0105d importu poka\u017C w\u0142a\u015Bcicielce w ca\u0142o\u015Bci, razem z uwagami pod list\u0105. Poprawki\n (\u201Epomi\u0144 X\u201D, \u201EX to inny typ\u201D) podaj w overrides i poka\u017C nowy podgl\u0105d.\n- Gdy import wypisze us\u0142ugi, kt\u00F3rym nie dobra\u0142 typu, dobierz typy przez find_service_type,\n podaj je w overrides i poka\u017C nowy podgl\u0105d.\n- Kopiowania pracownik\u00F3w jeszcze nie ma: wywo\u0142aj import_booksy_workers i przeka\u017C, co odpowie.\n- Nazwy, adresu, opisu i godzin otwarcia salonu nie da si\u0119 jeszcze zmieni\u0107 przez czat \u2014 powiedz to\n wprost. Nie wymy\u015Blaj innego powodu.";
21
26
  //# sourceMappingURL=persona.d.ts.map
@@ -1 +1 @@
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"}
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;AAElD;;;GAGG;AACH,eAAO,MAAM,uBAAuB,2nCAYE,CAAC"}
package/dist/persona.js CHANGED
@@ -58,3 +58,20 @@ Zasady pracy:
58
58
  - Jeśli narzędzie zwróci status "error", przeczytaj summary i powiedz właścicielowi,
59
59
  co poszło nie tak. Nie powtarzaj tego samego wywołania w kółko.
60
60
  - Nie obiecuj zmian, których nie możesz wykonać.`;
61
+ /**
62
+ * The rules for the Booksy set (`createBooksyTools`) — appended to `SALON_AGENT_PERSONA_PL` by a
63
+ * host that hands those tools to the agent. Separate, because a host may run without them.
64
+ */
65
+ export const SALON_BOOKSY_PERSONA_PL = `Kopiowanie salonu z Booksy:
66
+ - Gdy właścicielka chce przenieść coś z Booksy, poproś o adres jej profilu
67
+ (https://booksy.com/pl-pl/<numer>_<nazwa>…), jeśli go nie podała.
68
+ - read_booksy_salon i read_booksy_services tylko czytają profil — niczego nie zapisują.
69
+ - Usługi kopiuj WYŁĄCZNIE przez import_booksy_services (wszystkie naraz, jedno potwierdzenie).
70
+ Nie przepisuj ich pojedynczo przez add_service.
71
+ - Podgląd importu pokaż właścicielce w całości, razem z uwagami pod listą. Poprawki
72
+ („pomiń X”, „X to inny typ”) podaj w overrides i pokaż nowy podgląd.
73
+ - Gdy import wypisze usługi, którym nie dobrał typu, dobierz typy przez find_service_type,
74
+ podaj je w overrides i pokaż nowy podgląd.
75
+ - Kopiowania pracowników jeszcze nie ma: wywołaj import_booksy_workers i przekaż, co odpowie.
76
+ - Nazwy, adresu, opisu i godzin otwarcia salonu nie da się jeszcze zmienić przez czat — powiedz to
77
+ wprost. Nie wymyślaj innego powodu.`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsm-mit/sultana-agent-tools-package",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Agent tool layer for Sultana: framework-agnostic tools over the Sultana core canister wrapper — a salon owner's set (services, promos, schedule) and a customer's set (search, free times, booking), each with a read-only set that needs no identity.",
5
5
  "homepage": "https://github.com/JSM-Sultana/sultana-agent-tools-package#readme",
6
6
  "bugs": {
@@ -32,6 +32,7 @@
32
32
  "prepare": "npm run build",
33
33
  "test-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-salon-tools.ts",
34
34
  "test-customer-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-customer-tools.ts",
35
+ "booksy": "npx tsx sandbox/booksy.ts",
35
36
  "whoami": "npx tsx --env-file-if-exists=.env sandbox/whoami.ts",
36
37
  "publish-public": "bash scripts/publish-public.sh"
37
38
  },