conduyt 1.77.0 → 1.79.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
@@ -35,7 +35,8 @@ conduyt contacts list --limit 25 # list contacts (supports --cursor, --qu
35
35
  conduyt contacts get <id> # fetch one contact (carries `revision`, `hasOutboundSms` and `hasOutboundEmail`: a proven send only)
36
36
  conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
37
37
  conduyt contacts merge <primaryId> <secondaryId...> [--confirm [--yes]] # DESTRUCTIVE: a dry run by default (prints the impact); --confirm shows it, asks y/N, then merges (the losers are deleted); --yes skips the question; moved counts 409 and 503 merge_contacts_busy / merge_budget_exhausted + Retry-After exit 1, never retried
38
- conduyt contacts duplicates <id> # GET /contacts/{id}/duplicate-proposals; prints { proposals, heldIdentifiers } (open and not_sure possible duplicates; read-only)
38
+ conduyt contacts duplicates <id> [--cursor <c>] [--limit <n>] [--held-cursor <c>] [--held-limit <n>] # GET /contacts/{id}/duplicate-proposals; prints { proposals, heldIdentifiers, total, nextCursor, membershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor, heldIdentifiersMembershipRevision } (open and not_sure possible duplicates; keyset paged; read-only)
39
+ conduyt imports duplicates <importId> [--page <n>] [--per-page <n>] # GET /imports/{id}/duplicate-proposals; the proposals one import raised on the records it created, every status, oldest first; prints { rows, meta: { page, per_page, total } }
39
40
  conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge # POST the decision; not_sure keeps both visible, different is final, merge only records a pair ALREADY merged (never merges); 409 already_decided / not_merged and 403 other_record_not_editable exit 1
40
41
  conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
41
42
  conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
@@ -46,6 +47,7 @@ conduyt chat mark-read <channelId> <messageId> # readUpTo: marker moves to tha
46
47
  conduyt notifications list [--page n] [--per-page 1-200] # prints { data, meta }: meta.latestByType = { type: { latest, count } } over the last 24 h, next to unreadCount / unreadByType
47
48
  conduyt deals list --pipeline <id> # list deals
48
49
  conduyt deals list --health-summary --pipeline <id> # Pipeline health strip for the filtered set (totalOpenDeals, atRiskValue, checks[]); add --health-check stale --health-limit 20 for one check's deals, --health-cursor <nextCursor> to page, --health-rows-only for just the page
50
+ conduyt deals list --board-snapshot --pipeline <id> --board-columns <stageId>[:<limit>],... # every named column of one pipeline from ONE database snapshot (a moved deal is in exactly one column); prints { columns: [{ stageId, limit, deals }], meta: { board_snapshot: true } }; --page / --all / --stage / --stage-name / --health-* refused before any request; 503 board_snapshot_busy or gate_busy exits 1 with Retry-After
49
51
  conduyt deals health --pipeline <id> --check needs_action # GET /deals/inspection: the six checks over open deals; --check adds its deals, --cursor/--offset pages
50
52
  conduyt deals health-fix <dealId> --check stale --expected-updated-at <iso> --stage-id <uuid> # Pipeline health quick fix (PATCH /deals/{id} with checkOrigin): stale = --stage-id, unassigned = --assigned, missing_close_date = --close-date, missing_contact = --contact; 409 deal_changed / deal_check_cleared and 400 health_fix_mismatch exit 1 verbatim, never retried
51
53
  conduyt deals health-next-action <dealId> --check needs_action --expected-updated-at <iso> --title "Call back" --idempotency-key <key> # "Set next action" (POST /tasks with checkOrigin): one non-done quick-fix task per deal, 409 deal_has_next_action / deal_changed / deal_check_cleared exit 1 verbatim
package/dist/index.js CHANGED
@@ -259,14 +259,28 @@ contacts
259
259
  const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
260
260
  const duplicates = contacts
261
261
  .command("duplicates <id>")
262
- .description("List the possible duplicates of contact <id> (GET /contacts/{id}/duplicate-proposals, contacts:view, read-only): prints { proposals, heldIdentifiers }. Each proposal has id (the <proposalId> for `contacts duplicates decide`), status (open | not_sure), rule, raisedHere and `other` { id, name, createdAt, visible, editable }; a record you cannot view is \"a contact you cannot view\" with no id. heldIdentifiers are emails or phones this record was not given because another record owns them (value-free). Decide a pair with `conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge`")
263
- .action(run(async (client, id) => {
262
+ .description("List the possible duplicates of contact <id> (GET /contacts/{id}/duplicate-proposals, contacts:view, read-only): prints { proposals, heldIdentifiers }. Each proposal has id (the <proposalId> for `contacts duplicates decide`), status (open | not_sure), rule, raisedHere and `other` { id, name, createdAt, visible, editable }; a record you cannot view is \"a contact you cannot view\" with no id. heldIdentifiers are emails or phones this record was not given because another record owns them (value-free). Decide a pair with `conduyt contacts duplicates decide <id> <proposalId> --as not_sure|different|merge`. Paging: both lists are keyset paged, newest first; --cursor / --limit page the proposals, --held-cursor / --held-limit the held identifiers. The printed object also carries total and heldIdentifiersTotal (every line of each list, all pages), nextCursor and heldIdentifiersNextCursor (pass back as --cursor / --held-cursor; null on the last page) and membershipRevision / heldIdentifiersMembershipRevision (opaque digests of each list: when a later page answers another revision than page one, discard it and read page one again). A cursor this list did not issue is a 400 invalid_cursor (exit 1)")
263
+ .option("--cursor <cursor>", "the nextCursor of the previous proposals page, passed back UNCHANGED (opaque); omit for the first page")
264
+ .option("--limit <n>", "proposals per page, integer 1-100 (default 50); above 100 is refused, not capped")
265
+ .option("--held-cursor <cursor>", "the heldIdentifiersNextCursor of the previous held identifiers page, passed back UNCHANGED (opaque); omit for the first page")
266
+ .option("--held-limit <n>", "held identifiers per page, integer 1-100 (default 20)")
267
+ .action(run(async (client, id, opts) => {
264
268
  assertUuid(id, "contact id");
265
- return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals`);
269
+ const qs = new URLSearchParams();
270
+ if (opts.cursor !== undefined)
271
+ qs.set("cursor", opaqueCursorFlag(opts.cursor, "--cursor"));
272
+ if (opts.limit !== undefined)
273
+ qs.set("limit", String(strictIntFlag(opts.limit, "--limit", 1, 100)));
274
+ if (opts.heldCursor !== undefined)
275
+ qs.set("held_cursor", opaqueCursorFlag(opts.heldCursor, "--held-cursor"));
276
+ if (opts.heldLimit !== undefined)
277
+ qs.set("held_limit", String(strictIntFlag(opts.heldLimit, "--held-limit", 1, 100)));
278
+ const q = qs.toString();
279
+ return client.get(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals${q ? `?${q}` : ""}`);
266
280
  }));
267
281
  duplicates
268
282
  .command("decide <id> <proposalId>")
269
- .description("Record a decision on one possible-duplicate pair (POST /contacts/{id}/duplicate-proposals/{proposalId}; contacts:edit on BOTH records). --as is required, exactly one of: not_sure (keep both records visible; the pair stays listed and can be decided again), different (the two are different people: never shown again, FINAL), merge (record that the pair is ALREADY merged; this command NEVER merges: run `conduyt contacts merge` first, else 409 not_merged). Prints { id, status, decidedAt }. Refusals exit 1 with the status, message and code and are never retried: 409 already_decided, 409 not_merged, 403 other_record_not_editable, 404, 400")
283
+ .description("Record a decision on one possible-duplicate pair (POST /contacts/{id}/duplicate-proposals/{proposalId}; contacts:edit on BOTH records). --as is required, exactly one of: not_sure (keep both records visible; the pair stays listed and can be decided again), different (the two are different people: never shown again, FINAL), merge (record that the pair is ALREADY merged; this command NEVER merges: run `conduyt contacts merge` first, else 409 not_merged). Prints { id, status, decidedAt, previousMembershipRevision, membershipRevision, total }: membershipRevision and total are the proposals list AFTER the decision (the same digest and count `contacts duplicates` answers), previousMembershipRevision the digest just BEFORE it (equal on an idempotent repeat); keep loaded pages only when previousMembershipRevision equals the revision you hold. Refusals exit 1 with the status, message and code and are never retried: 409 already_decided, 409 not_merged, 403 other_record_not_editable, 404, 400")
270
284
  .option("--as <decision>", "not_sure | different | merge (required)")
271
285
  .action(run(async (client, id, proposalId, opts) => {
272
286
  assertUuid(id, "contact id");
@@ -277,6 +291,42 @@ duplicates
277
291
  throw new Error(`--as must be exactly one of ${PROPOSAL_DECISIONS.join(", ")} (got "${opts.as}").`);
278
292
  return client.post(`/api/v1/contacts/${encodeURIComponent(id)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision: opts.as });
279
293
  }));
294
+ /** a whole-number flag in [min, max]: digits only (no sign, decimal, exponent or hex), so "1e1" or "0x10" never pass as 10 or 16 */
295
+ function strictIntFlag(raw, flag, min, max) {
296
+ const t = raw.trim();
297
+ if (!/^\d+$/.test(t))
298
+ throw new Error(`${flag} must be a whole number ${min}-${max} (got ${JSON.stringify(raw)}).`);
299
+ const n = Number(t);
300
+ if (n < min || n > max)
301
+ throw new Error(`${flag} must be a whole number ${min}-${max} (got ${JSON.stringify(raw)}).`);
302
+ return n;
303
+ }
304
+ /** an opaque cursor is sent exactly as given; a blank one is refused (the API would answer page one, silently restarting) */
305
+ function opaqueCursorFlag(raw, flag) {
306
+ if (raw.trim() === "")
307
+ throw new Error(`${flag} was provided but is blank: pass the cursor from the previous page unchanged, or omit it for the first page.`);
308
+ return raw;
309
+ }
310
+ // Duplicate proposals look (board #95, app f2d638db6): the import results rows "Created, similar record exists".
311
+ const imports = program.command("imports").description("Import jobs (contacts / opportunities / companies imports)");
312
+ imports
313
+ .command("duplicates <importId>")
314
+ .description("List the duplicate proposals one import raised on the records it created (GET /imports/{id}/duplicate-proposals, contacts:view, read-only): one row per proposal, every status (open | not_sure | different | merged; a merged pair reads merged), oldest first. Prints { rows, meta: { page, per_page, total } } (total counts every row of the import). Each row: proposalId, status, rowNum (the import file's row number), createdAt, decidedAt, decidedByName, newRecord (the record the import created), similar (the existing record it may duplicate), matchedOn (kinds only, such as phone or phone + company, never a value), keptName; newRecord and similar are { id, name, visible, editable, live }. A record you cannot view has no id and the name \"A contact you cannot view\". Decide a row with `conduyt contacts duplicates decide <newRecord.id> <proposalId> --as ...` (contacts:edit on both records). An owner, admin or view-all key reads any import of the account, any other key only the imports its user ran (404 otherwise). The route is page based: --page / --per-page, not a cursor")
315
+ .option("--page <n>", "page number, integer 1-100000 (default 1)")
316
+ .option("--per-page <n>", "rows per page, integer 1-100 (default 50); above 100 is refused, not capped")
317
+ .option("--cursor <cursor>", "not supported (the import rows page with --page); passing it exits 1 and sends no request")
318
+ .action(run(async (client, importId, opts) => {
319
+ if (opts.cursor !== undefined)
320
+ throw new Error("imports duplicates does not support --cursor (the API pages the import rows with page and per_page); use --page <n> and --per-page <n>.");
321
+ assertUuid(importId, "import id");
322
+ const qs = new URLSearchParams();
323
+ if (opts.page !== undefined)
324
+ qs.set("page", String(strictIntFlag(opts.page, "--page", 1, 100000)));
325
+ if (opts.perPage !== undefined)
326
+ qs.set("per_page", String(strictIntFlag(opts.perPage, "--per-page", 1, 100)));
327
+ const q = qs.toString();
328
+ return client.get(`/api/v1/imports/${encodeURIComponent(importId)}/duplicate-proposals${q ? `?${q}` : ""}`);
329
+ }));
280
330
  contacts
281
331
  .command("merge <primaryId> <secondaryIds...>")
282
332
  .description("DESTRUCTIVE merge of duplicate contacts (POST /contacts/merge; owner or admin): <primaryId> survives and receives the records of every <secondaryIds> contact, which are then DELETED (no undelete). By default a DRY RUN: nothing changes, it prints the impact { primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow, blockers }. --confirm repeats the dry run, shows the impact (the records that move, and separately the guard counts: chat messages, quick notes, identifiers, outbox rows, which are not in the records total), asks y/N and then merges, sending the values that dry run returned (expectedCounts, expectedGuardCounts, expectedIdentifiers). LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses (409) if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected; --yes skips the question (scripts; needs --confirm). A refusal exits 1 with the status, the API's message and its code and is NEVER retried: 409 \"Merge impact changed\" (preview again), 409 queued work, 400 / 404, and 503 merge_contacts_busy / merge_budget_exhausted with `[Retry-After: <N>s]` (nothing changed: wait that long, then run again).")
@@ -466,6 +516,38 @@ function rejectBlankFlags(flags) {
466
516
  }
467
517
  }
468
518
  }
519
+ /** GET /deals board_snapshot=true limits (the app's lib/deal-board-snapshot-limits, contract at 5729bc7b4). */
520
+ const BOARD_SNAPSHOT_MAX_COLUMNS = 50;
521
+ const BOARD_SNAPSHOT_MAX_COLUMN_ROWS = 1000;
522
+ const BOARD_SNAPSHOT_MAX_TOTAL_ROWS = 5000;
523
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
524
+ /** --board-columns <stageId>[:<limit>],...: the server's own rules checked before any request (1-50 columns, UUID stage
525
+ * ids, no stage twice, limit a whole number 1-1000, default --limit, at most 5000 rows in total); the wire value. */
526
+ function parseBoardColumnsFlag(raw, defaultLimit) {
527
+ const entries = raw.split(",").map((e) => e.trim());
528
+ if (raw.trim() === "" || entries.some((e) => e === ""))
529
+ throw new Error("--board-columns must be <stageId>[:<limit>],... with no blank entry.");
530
+ if (entries.length > BOARD_SNAPSHOT_MAX_COLUMNS)
531
+ throw new Error(`--board-columns names at most ${BOARD_SNAPSHOT_MAX_COLUMNS} columns (got ${entries.length}).`);
532
+ const seen = new Set();
533
+ let total = 0;
534
+ const out = [];
535
+ for (const entry of entries) {
536
+ const parts = entry.split(":");
537
+ const id = parts[0].trim();
538
+ if (parts.length > 2 || !UUID_RE.test(id))
539
+ throw new Error(`--board-columns expects <stageId>[:<limit>] entries with a stage UUID; got ${JSON.stringify(entry)}.`);
540
+ const limit = parts.length === 2 ? strictIntFlag(parts[1], `--board-columns limit for ${id}`, 1, BOARD_SNAPSHOT_MAX_COLUMN_ROWS) : defaultLimit;
541
+ if (seen.has(id.toLowerCase()))
542
+ throw new Error(`--board-columns names stage ${id} more than once.`);
543
+ seen.add(id.toLowerCase());
544
+ total += limit;
545
+ out.push(parts.length === 2 ? `${id}:${limit}` : id);
546
+ }
547
+ if (total > BOARD_SNAPSHOT_MAX_TOTAL_ROWS)
548
+ throw new Error(`--board-columns asks for ${total} rows; one snapshot answers at most ${BOARD_SNAPSHOT_MAX_TOTAL_ROWS}. Read deeper columns with --stage.`);
549
+ return out.join(",");
550
+ }
469
551
  /** The six Pipeline health checks (GET /deals health_check, GET /deals/inspection check); the API validates the value. */
470
552
  const PIPELINE_HEALTH_CHECKS = ["high_value_idle", "stale", "unassigned", "needs_action", "missing_close_date", "missing_contact"];
471
553
  deals
@@ -484,6 +566,8 @@ deals
484
566
  .option("--health-offset <n>", "with --health-summary and --health-check: deals to skip (default 0); prefer --health-cursor")
485
567
  .option("--health-cursor <token>", "with --health-summary and --health-check: continue after this cursor, the nextCursor of the previous page, passed back UNCHANGED (opaque signed token; a blank, forged or foreign one is a 400 with health_cursor_blank / health_cursor_invalid / health_cursor_context / health_cursor_needs_check)")
486
568
  .option("--health-rows-only", "with --health-summary and --health-check: answer only that page of the check's deals ({check, offset, items, hasMore, nextCursor}), without the summary")
569
+ .option("--board-snapshot", "read every column in --board-columns from ONE database snapshot (a deal moved during the read is in exactly one column). Needs --pipeline or --pipeline-name and --board-columns; refused (exit 1, no request) with --page, --all, --stage, --stage-name or any --health-* flag. Prints { columns: [{ stageId, limit, deals }], meta: { board_snapshot: true } }: columns in the order named, each column's deals exactly what --stage <stageId> --limit <limit> rows-only answers, no totals; deals.length equal to limit means the column may hold more. A 503 board_snapshot_busy (the snapshot could not finish in its budget: read the columns one by one or retry) or gate_busy (any deals list call: the access check could not finish in time) exits 1 with the code and Retry-After; never retried")
570
+ .option("--board-columns <columns>", `with --board-snapshot: <stageId>[:<limit>],... in answer order; 1-${BOARD_SNAPSHOT_MAX_COLUMNS} live stages of the pipeline, no stage twice, limit 1-${BOARD_SNAPSHOT_MAX_COLUMN_ROWS} (default --limit, itself default 50), at most ${BOARD_SNAPSHOT_MAX_TOTAL_ROWS} rows in total`)
487
571
  .action(run(async (client, opts) => {
488
572
  // A provided-but-blank filter must error, not silently become "no filter":
489
573
  // buildQuery drops empty strings, so a blank flag would never reach the
@@ -501,6 +585,44 @@ deals
501
585
  throw new Error(`${flag} was provided but is blank. Omit it to skip the filter, or pass a real value.`);
502
586
  }
503
587
  }
588
+ // Board snapshot (#116 for app #111, contract 5729bc7b4): one pipeline's columns from ONE database snapshot. Its
589
+ // conflicts are refused here, before any request; the answer is { columns, meta } (no data.data / meta.total), so it
590
+ // never goes through the health branch or the --all / truncation logic below.
591
+ if (opts.boardSnapshot || opts.boardColumns !== undefined) {
592
+ if (!opts.boardSnapshot)
593
+ throw new Error("--board-columns needs --board-snapshot.");
594
+ const conflicts = [
595
+ ["--page", opts.page !== undefined], ["--all", opts.all === true],
596
+ ["--stage", opts.stage !== undefined], ["--stage-name", opts.stageName !== undefined],
597
+ ["--health-summary", opts.healthSummary === true], ["--health-check", opts.healthCheck !== undefined],
598
+ ["--health-limit", opts.healthLimit !== undefined], ["--health-offset", opts.healthOffset !== undefined],
599
+ ["--health-cursor", opts.healthCursor !== undefined], ["--health-rows-only", opts.healthRowsOnly === true],
600
+ ].filter(([, hit]) => hit).map(([flag]) => flag);
601
+ if (conflicts.length > 0) {
602
+ throw new Error(`--board-snapshot cannot be combined with ${conflicts.join(", ")}: it answers every named column's first rows, rows only, from one snapshot.`);
603
+ }
604
+ if (opts.pipeline === undefined && opts.pipelineName === undefined)
605
+ throw new Error("--board-snapshot needs --pipeline or --pipeline-name.");
606
+ if (opts.boardColumns === undefined)
607
+ throw new Error("--board-snapshot needs --board-columns <stageId>[:<limit>],...");
608
+ const perPage = opts.limit === undefined ? 50 : strictIntFlag(opts.limit, "--limit", 1, BOARD_SNAPSHOT_MAX_COLUMN_ROWS);
609
+ const columns = parseBoardColumnsFlag(opts.boardColumns, perPage);
610
+ const usp = new URLSearchParams();
611
+ if (opts.pipeline !== undefined)
612
+ usp.set("pipeline", opts.pipeline);
613
+ if (opts.pipelineName !== undefined)
614
+ usp.set("pipeline_name", opts.pipelineName);
615
+ if (opts.limit !== undefined)
616
+ usp.set("per_page", String(perPage));
617
+ usp.set("board_snapshot", "true");
618
+ usp.set("board_columns", columns);
619
+ const answer = await client.get(`/api/v1/deals?${usp.toString()}`);
620
+ const data = answer?.data;
621
+ if (!data || !Array.isArray(data.columns) || data.meta?.board_snapshot !== true) {
622
+ throw new Error("--board-snapshot: the API answered without { columns, meta: { board_snapshot: true } }. Aborting rather than print another shape.");
623
+ }
624
+ return answer;
625
+ }
504
626
  // Pipeline health (app 10-08): a different answer shape (no data.data / meta.total), so it never goes through the
505
627
  // --all / truncation logic below. The API validates the check, the paging and the cursor (400 + code).
506
628
  const healthFlags = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.77.0",
3
+ "version": "1.79.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,78 +0,0 @@
1
- /**
2
- * Local typo guard for automation action configs (card ownership per lane, Codex r1/r2). The server validator is the authority
3
- * and there the option names are not enforced yet, so a typoed `laneSoruce` on assign_to_user would silently assign the run's
4
- * deal instead of the lane card. Only the options below are known here.
5
- *
6
- * Traversal rule: ONLY the real action positions of the graph are visited, exactly as the graph is shaped: a v1 `actions`
7
- * array's elements `{type, config}`, and a v2 graph's `nodes[].action` `{type, config}`. For each action of a known type the
8
- * IMMEDIATE keys of `config` are checked; the config VALUES are opaque and never descended into (a webhook payload may hold any
9
- * object, including one shaped like an action), and nothing outside those positions (request metadata, other node fields) is
10
- * scanned. A key that is not a known option but is a case-insensitive match or ONE edit (substitution, insertion, deletion or
11
- * adjacent transposition) from one is refused; keys further away, and every other action type, pass through.
12
- * Source of the names: lib/automation-action-registry.ts in the app (assign_to_user, create_deal, close_other_open_deals).
13
- */
14
- const KNOWN_ACTION_OPTIONS = {
15
- assign_to_user: ["runWindow", "mode", "userId", "userIds", "groupId", "target", "applyTo", "overwriteExisting", "laneSource", "lanePipelineId", "laneStageId"],
16
- create_deal: ["pipelineId", "stageId", "dealName", "dealValue", "allowDuplicates", "status", "source", "assignedTo", "leaveUnassigned"],
17
- close_other_open_deals: ["lostReason"],
18
- };
19
- /** Damerau-Levenshtein (optimal string alignment) distance, case-insensitive */
20
- function distance(a, b) {
21
- const x = a.toLowerCase(), y = b.toLowerCase();
22
- const d = Array.from({ length: x.length + 1 }, (_, i) => [i, ...Array(y.length).fill(0)]);
23
- for (let j = 1; j <= y.length; j++)
24
- d[0][j] = j;
25
- for (let i = 1; i <= x.length; i++) {
26
- for (let j = 1; j <= y.length; j++) {
27
- d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1));
28
- if (i > 1 && j > 1 && x[i - 1] === y[j - 2] && x[i - 2] === y[j - 1])
29
- d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
30
- }
31
- }
32
- return d[x.length][y.length];
33
- }
34
- const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
35
- function checkAction(action) {
36
- if (!isObject(action) || typeof action.type !== "string" || !isObject(action.config))
37
- return null;
38
- const known = KNOWN_ACTION_OPTIONS[action.type];
39
- if (!known)
40
- return null;
41
- for (const key of Object.keys(action.config)) {
42
- if (known.includes(key))
43
- continue;
44
- const near = known.find((k) => distance(key, k) <= 1);
45
- if (near)
46
- return { actionType: action.type, key, suggestion: near };
47
- }
48
- return null;
49
- }
50
- /** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
51
- export function findActionConfigTypo(graph) {
52
- if (Array.isArray(graph)) {
53
- for (const action of graph) {
54
- const t = checkAction(action);
55
- if (t)
56
- return t;
57
- }
58
- return null;
59
- }
60
- if (isObject(graph) && Array.isArray(graph.nodes)) {
61
- for (const node of graph.nodes) {
62
- const t = isObject(node) ? checkAction(node.action) : null;
63
- if (t)
64
- return t;
65
- }
66
- }
67
- return null;
68
- }
69
- /** The refusal text; the details object is the final token */
70
- export function actionConfigTypoMessage(t) {
71
- return `Unknown option "${t.key}" in ${t.actionType} config: did you mean "${t.suggestion}"? Nothing was sent. ${JSON.stringify(t)}`;
72
- }
73
- /** Throws (a plain Error) when the graph carries a misspelled option */
74
- export function assertNoActionConfigTypo(graph) {
75
- const t = findActionConfigTypo(graph);
76
- if (t)
77
- throw new Error(actionConfigTypoMessage(t));
78
- }