@jsm-mit/sultana-agent-tools-package 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +106 -8
  2. package/dist/confirmations.d.ts +61 -0
  3. package/dist/confirmations.d.ts.map +1 -0
  4. package/dist/confirmations.js +100 -0
  5. package/dist/create-salon-tools.d.ts +13 -2
  6. package/dist/create-salon-tools.d.ts.map +1 -1
  7. package/dist/create-salon-tools.js +10 -4
  8. package/dist/customer/create-customer-tools.d.ts +38 -0
  9. package/dist/customer/create-customer-tools.d.ts.map +1 -0
  10. package/dist/customer/create-customer-tools.js +47 -0
  11. package/dist/customer/customer-core-port.d.ts +94 -0
  12. package/dist/customer/customer-core-port.d.ts.map +1 -0
  13. package/dist/customer/customer-core-port.js +1 -0
  14. package/dist/customer/ic-customer-core-port.d.ts +45 -0
  15. package/dist/customer/ic-customer-core-port.d.ts.map +1 -0
  16. package/dist/customer/ic-customer-core-port.js +132 -0
  17. package/dist/customer/persona.d.ts +6 -0
  18. package/dist/customer/persona.d.ts.map +1 -0
  19. package/dist/customer/persona.js +27 -0
  20. package/dist/customer/time.d.ts +27 -0
  21. package/dist/customer/time.d.ts.map +1 -0
  22. package/dist/customer/time.js +113 -0
  23. package/dist/customer/tools.d.ts +17 -0
  24. package/dist/customer/tools.d.ts.map +1 -0
  25. package/dist/customer/tools.js +415 -0
  26. package/dist/index.d.ts +10 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +5 -0
  29. package/dist/persona.d.ts +4 -1
  30. package/dist/persona.d.ts.map +1 -1
  31. package/dist/persona.js +18 -7
  32. package/dist/tools/promos.d.ts +2 -1
  33. package/dist/tools/promos.d.ts.map +1 -1
  34. package/dist/tools/promos.js +79 -39
  35. package/dist/tools/schedule.d.ts +2 -1
  36. package/dist/tools/schedule.d.ts.map +1 -1
  37. package/dist/tools/schedule.js +51 -31
  38. package/dist/tools/services.d.ts +4 -2
  39. package/dist/tools/services.d.ts.map +1 -1
  40. package/dist/tools/services.js +75 -30
  41. package/dist/tools/write-gate.d.ts +29 -0
  42. package/dist/tools/write-gate.d.ts.map +1 -0
  43. package/dist/tools/write-gate.js +37 -0
  44. package/package.json +4 -3
@@ -1,18 +1,21 @@
1
1
  import { toToolError } from "../errors.js";
2
- import { err, needsConfirmation, ok } from "../types.js";
2
+ import { err, ok } from "../types.js";
3
3
  import { ArgumentError, normalizeDuration, normalizePrice, phrasesMatch, readBoolean, readNumber, readOptionalNumber, readOptionalString, readOptionalStringArray, readString, } from "./args.js";
4
+ import { writeGate } from "./write-gate.js";
4
5
  const CONFIRMED_FIELD = {
5
6
  type: "boolean",
6
7
  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
  };
8
- export function createServiceTools(port) {
9
+ /** The fields of a service a model may set — what `update_service` echoes back when it was sent. */
10
+ const SERVICE_FIELDS = ["name", "pricePln", "durationMinutes", "serviceTypeIds", "workerIds", "active"];
11
+ export function createServiceTools(port, confirmations) {
9
12
  return [
10
13
  listServicesTool(port),
11
14
  findServiceTypeTool(port),
12
15
  listWorkersTool(port),
13
- addServiceTool(port),
14
- updateServiceTool(port),
15
- removeServiceTool(port),
16
+ addServiceTool(port, confirmations),
17
+ updateServiceTool(port, confirmations),
18
+ removeServiceTool(port, confirmations),
16
19
  ];
17
20
  }
18
21
  /** What the model reads about the list depends on whose view the port returns: a public read has
@@ -51,6 +54,7 @@ export function listServicesTool(port, access = "owner") {
51
54
  },
52
55
  };
53
56
  }
57
+ /** Takes any port that can search the catalogue — the customer set shares this tool. */
54
58
  export function findServiceTypeTool(port) {
55
59
  return {
56
60
  name: "find_service_type",
@@ -102,12 +106,13 @@ function listWorkersTool(port) {
102
106
  },
103
107
  };
104
108
  }
105
- function addServiceTool(port) {
109
+ function addServiceTool(port, confirmations) {
110
+ const gate = writeGate("add_service", confirmations);
106
111
  return {
107
112
  name: "add_service",
108
113
  progress: "Dodaję usługę…",
109
114
  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: {
115
+ parameters: gate.parameters({
111
116
  type: "object",
112
117
  properties: {
113
118
  name: { type: "string", description: "Nazwa usługi widoczna dla klientek." },
@@ -128,17 +133,23 @@ function addServiceTool(port) {
128
133
  },
129
134
  required: ["name", "pricePln", "durationMinutes", "confirmed"],
130
135
  additionalProperties: false,
131
- },
136
+ }),
132
137
  execute: async (args) => {
133
138
  try {
134
- const draft = await buildDraft(port, args, null);
139
+ // The echo is the draft laid over the model's own arguments, and a draft read from an
140
+ // echo is that echo again — resending either one confirms the same change.
141
+ const draft = readDraft(args, null);
142
+ const echo = { ...args, ...draft.input };
143
+ const refusal = gate.refusal(args, echo);
144
+ if (refusal)
145
+ return refusal;
146
+ await assertDraftReferences(port, draft.input);
135
147
  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
- });
148
+ return gate.preview(`Dodam usługę: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, echo);
141
149
  }
150
+ const refused = gate.redeem(args, echo);
151
+ if (refused)
152
+ return refused;
142
153
  const serviceId = await port.addService(draft.input);
143
154
  return ok(`Dodano usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`, { serviceId });
144
155
  }
@@ -150,12 +161,13 @@ function addServiceTool(port) {
150
161
  },
151
162
  };
152
163
  }
153
- function updateServiceTool(port) {
164
+ function updateServiceTool(port, confirmations) {
165
+ const gate = writeGate("update_service", confirmations);
154
166
  return {
155
167
  name: "update_service",
156
168
  progress: "Zmieniam usługę…",
157
169
  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: {
170
+ parameters: gate.parameters({
159
171
  type: "object",
160
172
  properties: {
161
173
  salonServiceId: { type: "string", description: "Id usługi z list_services." },
@@ -169,17 +181,27 @@ function updateServiceTool(port) {
169
181
  },
170
182
  required: ["salonServiceId", "confirmed"],
171
183
  additionalProperties: false,
172
- },
184
+ }),
173
185
  execute: async (args) => {
174
186
  try {
175
187
  const serviceId = readString(args, "salonServiceId");
188
+ const echo = requestedServiceChange(serviceId, args);
189
+ const refusal = gate.refusal(args, echo);
190
+ if (refusal)
191
+ return refusal;
176
192
  const current = await findService(port, serviceId);
177
193
  if (!current)
178
194
  return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
179
- const draft = await buildDraft(port, args, current);
195
+ // The summary shows the merged record the owner will end up with; the write merges
196
+ // again onto the record as it is by then.
197
+ const draft = readDraft(args, current);
198
+ await assertDraftReferences(port, draft.input);
180
199
  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 });
200
+ return gate.preview(`Zmienię usługę „${current.name}” na: ${await describe(port, draft.input)}.${noteSuffix(draft.notes)}`, echo);
182
201
  }
202
+ const refused = gate.redeem(args, echo);
203
+ if (refused)
204
+ return refused;
183
205
  await port.updateService(serviceId, draft.input);
184
206
  return ok(`Zmieniono usługę „${draft.input.name}”.${noteSuffix(draft.notes)}`);
185
207
  }
@@ -191,12 +213,13 @@ function updateServiceTool(port) {
191
213
  },
192
214
  };
193
215
  }
194
- function removeServiceTool(port) {
216
+ function removeServiceTool(port, confirmations) {
217
+ const gate = writeGate("remove_service", confirmations);
195
218
  return {
196
219
  name: "remove_service",
197
220
  progress: "Usuwam usługę…",
198
221
  description: "Usuwa usługę z katalogu. Operacja jest nieodwracalna — właściciel musi przepisać dokładną nazwę usługi w polu confirmationPhrase.",
199
- parameters: {
222
+ parameters: gate.parameters({
200
223
  type: "object",
201
224
  properties: {
202
225
  salonServiceId: { type: "string", description: "Id usługi z list_services." },
@@ -205,20 +228,29 @@ function removeServiceTool(port) {
205
228
  },
206
229
  required: ["salonServiceId", "confirmed"],
207
230
  additionalProperties: false,
208
- },
231
+ }),
209
232
  execute: async (args) => {
210
233
  try {
211
234
  const serviceId = readString(args, "salonServiceId");
235
+ // No confirmationPhrase in the echo: the name is the owner's to type, so a model that
236
+ // simply sends the echo back cannot delete anything.
237
+ const echo = { salonServiceId: serviceId };
238
+ const refusal = gate.refusal(args, echo);
239
+ if (refusal)
240
+ return refusal;
212
241
  const current = await findService(port, serviceId);
213
242
  if (!current)
214
243
  return err("not_found", `W tym salonie nie ma usługi o id ${serviceId}.`);
215
244
  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 });
245
+ return gate.preview(`Usunę usługę „${current.name}” (${current.pricePln} zł, ${current.durationMinutes} min). Tego nie da się cofnąć — poproś właścicielkę, żeby sama napisała dokładną nazwę usługi, i wstaw ją w confirmationPhrase.`, echo);
217
246
  }
218
247
  const phrase = readOptionalString(args, "confirmationPhrase");
219
248
  if (!phrase || !phrasesMatch(phrase, current.name)) {
220
- return err("invalid_arguments", `Aby usunąć usługę, właściciel musi przepisać jej nazwę: „${current.name}”.`);
249
+ return err("invalid_arguments", `Aby usunąć usługę, właścicielka musi sama napisać jej dokładną nazwę: „${current.name}”.`);
221
250
  }
251
+ const refused = gate.redeem(args, echo);
252
+ if (refused)
253
+ return refused;
222
254
  await port.removeService(serviceId);
223
255
  return ok(`Usunięto usługę „${current.name}”.`);
224
256
  }
@@ -230,12 +262,23 @@ function removeServiceTool(port) {
230
262
  },
231
263
  };
232
264
  }
265
+ /** `update_service`'s echo: the id and only the fields the model sent, as it sent them — never the
266
+ * merged record, which would pin fields the owner did not mention to what they were at preview. */
267
+ function requestedServiceChange(serviceId, args) {
268
+ const change = { salonServiceId: serviceId };
269
+ for (const field of SERVICE_FIELDS) {
270
+ if (args[field] !== undefined && args[field] !== null)
271
+ change[field] = args[field];
272
+ }
273
+ return change;
274
+ }
233
275
  /**
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.
276
+ * Builds the full record the canister needs, without calling it. `current` is null for a new
277
+ * service and the existing record for an edit — **this merge is the whole point**:
278
+ * `updateSalonService` replaces every field, so a "just the price" call that forgot `workerIds`
279
+ * would unassign the entire team.
237
280
  */
238
- async function buildDraft(port, args, current) {
281
+ function readDraft(args, current) {
239
282
  const notes = [];
240
283
  const name = current ? (readOptionalString(args, "name") ?? current.name) : readString(args, "name");
241
284
  const rawPrice = readOptionalNumber(args, "pricePln");
@@ -249,10 +292,12 @@ async function buildDraft(port, args, current) {
249
292
  const serviceTypeIds = readOptionalStringArray(args, "serviceTypeIds") ?? current?.serviceTypeIds ?? [];
250
293
  const workerIds = readOptionalStringArray(args, "workerIds") ?? current?.workerIds ?? [];
251
294
  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
295
  return { input: { name, pricePln: price, durationMinutes: duration, active, serviceTypeIds, workerIds }, notes };
255
296
  }
297
+ async function assertDraftReferences(port, input) {
298
+ await assertKnownServiceTypes(port, input.serviceTypeIds);
299
+ await assertKnownWorkers(port, input.workerIds);
300
+ }
256
301
  function normalizeOrNote(normalized, notes) {
257
302
  if (normalized.note)
258
303
  notes.push(normalized.note);
@@ -0,0 +1,29 @@
1
+ import type { TurnConfirmations } from "../confirmations.js";
2
+ import { type ToolParametersSchema, type ToolResult } from "../types.js";
3
+ /**
4
+ * The confirmation protocol of ONE write tool, in one place for all of them.
5
+ *
6
+ * `echo` is the change as the tool describes it back to the model — the arguments to send again
7
+ * with `confirmed: true`; it defaults to `args` itself. A tool must build it from `args` the same
8
+ * way on both calls, and building it from an echo must give that echo back, so a model that resends
9
+ * its own arguments and a model that resends the echo confirm the same change.
10
+ *
11
+ * Without a ledger the gate lets every `confirmed: true` through, which is the protocol as it was
12
+ * before the ledger: the rule then lives in the persona only.
13
+ */
14
+ export interface WriteGate {
15
+ /** The tool's schema, with `confirmationId` added when the tool set has a ledger. */
16
+ parameters(schema: ToolParametersSchema): ToolParametersSchema;
17
+ /** The `confirmed: false` answer: `echo` goes back with `confirmed: true` and, with a ledger,
18
+ * the id of this preview. */
19
+ preview(summary: string, echo: Record<string, unknown>): ToolResult;
20
+ /** Run first, before any canister call. For a `confirmed: true` call whose id would not redeem,
21
+ * the refusal to return; otherwise `null`. Does not use the id up, so a write that then fails
22
+ * on its arguments (a mistyped name, say) can be retried with the same id. */
23
+ refusal(args: Record<string, unknown>, echo?: Record<string, unknown>): ToolResult | null;
24
+ /** Run right before the write: uses the id up. `null` lets the write go ahead; a refusal here
25
+ * means a parallel call used the id first, or the preview expired in the meantime. */
26
+ redeem(args: Record<string, unknown>, echo?: Record<string, unknown>): ToolResult | null;
27
+ }
28
+ export declare function writeGate(toolName: string, confirmations: TurnConfirmations | undefined): WriteGate;
29
+ //# sourceMappingURL=write-gate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"write-gate.d.ts","sourceRoot":"","sources":["../../src/tools/write-gate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqC,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAChG,OAAO,EAA0B,KAAK,oBAAoB,EAAE,KAAK,UAAU,EAAE,MAAM,aAAa,CAAC;AAkBjG;;;;;;;;;;GAUG;AACH,MAAM,WAAW,SAAS;IACtB,qFAAqF;IACrF,UAAU,CAAC,MAAM,EAAE,oBAAoB,GAAG,oBAAoB,CAAC;IAC/D;iCAC6B;IAC7B,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,CAAC;IACpE;;kFAE8E;IAC9E,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,GAAG,IAAI,CAAC;IAC1F;0FACsF;IACtF,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,UAAU,GAAG,IAAI,CAAC;CAC5F;AAED,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,EAAE,iBAAiB,GAAG,SAAS,GAAG,SAAS,CA6BnG"}
@@ -0,0 +1,37 @@
1
+ import { err, needsConfirmation } from "../types.js";
2
+ import { readBoolean, readOptionalString } from "./args.js";
3
+ const CONFIRMATION_ID_FIELD = {
4
+ type: "string",
5
+ description: "Id z podglądu (confirmed=false). Wymagane przy confirmed=true.",
6
+ };
7
+ const NO_PREVIEW = "Brak ważnego podglądu tej zmiany — wywołaj narzędzie z confirmed=false i pokaż właścicielce podsumowanie.";
8
+ const REFUSALS = {
9
+ missing: NO_PREVIEW,
10
+ unknown: NO_PREVIEW,
11
+ expired: NO_PREVIEW,
12
+ same_turn: "Zgoda musi przyjść w osobnej wiadomości właścicielki — najpierw pokaż podsumowanie i poczekaj na odpowiedź.",
13
+ mismatch: "Argumenty różnią się od pokazanego podglądu — pokaż nowy podgląd (confirmed=false).",
14
+ };
15
+ export function writeGate(toolName, confirmations) {
16
+ const decide = (args, verdict) => {
17
+ if (!confirmations || !readBoolean(args, "confirmed"))
18
+ return null;
19
+ const result = verdict(confirmations, readOptionalString(args, "confirmationId"));
20
+ return result.ok ? null : err("invalid_arguments", REFUSALS[result.reason]);
21
+ };
22
+ return {
23
+ parameters: (schema) => {
24
+ if (!confirmations)
25
+ return schema;
26
+ return { ...schema, properties: { ...schema.properties, confirmationId: CONFIRMATION_ID_FIELD } };
27
+ },
28
+ preview: (summary, echo) => {
29
+ if (!confirmations)
30
+ return needsConfirmation(summary, { ...echo, confirmed: true });
31
+ const confirmationId = confirmations.issue(toolName, echo);
32
+ return needsConfirmation(summary, { ...echo, confirmed: true, confirmationId });
33
+ },
34
+ refusal: (args, echo = args) => decide(args, (turn, confirmationId) => turn.check(toolName, echo, confirmationId)),
35
+ redeem: (args, echo = args) => decide(args, (turn, confirmationId) => turn.redeem(toolName, echo, confirmationId)),
36
+ };
37
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jsm-mit/sultana-agent-tools-package",
3
- "version": "0.2.0",
4
- "description": "Agent tool layer for a Sultana salon: framework-agnostic tools (services, promos, schedule) over the Sultana core canister wrapper, plus a read-only set that needs no identity.",
3
+ "version": "0.4.0",
4
+ "description": "Agent tool layer for Sultana: framework-agnostic tools over the Sultana core canister wrapper — a salon owner's set (services, promos, schedule) and a customer's set (search, free times, booking), each with a read-only set that needs no identity.",
5
5
  "homepage": "https://github.com/JSM-Sultana/sultana-agent-tools-package#readme",
6
6
  "bugs": {
7
7
  "url": "https://github.com/JSM-Sultana/sultana-agent-tools-package/issues"
@@ -31,12 +31,13 @@
31
31
  "sandbox": "npx tsx --env-file-if-exists=.env sandbox/main.ts",
32
32
  "prepare": "npm run build",
33
33
  "test-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-salon-tools.ts",
34
+ "test-customer-tools": "npx tsx --env-file-if-exists=.env --test tests/integration/test-customer-tools.ts",
34
35
  "whoami": "npx tsx --env-file-if-exists=.env sandbox/whoami.ts",
35
36
  "publish-public": "bash scripts/publish-public.sh"
36
37
  },
37
38
  "dependencies": {
38
39
  "@icp-sdk/core": "^6.1.0",
39
- "@jsm-mit/sultana-core-motoko-package": "^0.16.0"
40
+ "@jsm-mit/sultana-core-motoko-package": "^0.21.1"
40
41
  },
41
42
  "devDependencies": {
42
43
  "@jsm-mit/utils-package": "^0.5.0",