conduyt-mcp 4.91.0 → 4.92.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
|
@@ -19,8 +19,9 @@ MCP server for [Conduyt CRM](https://conduyt.app) — expose your CRM as AI-acce
|
|
|
19
19
|
| `conduyt_create_contact` | Create a new contact (a 409 with `existingId`, `rule`, `ruleVersion`, `matchedOn`, `reason` means the identity policy identified the incoming person with that record: confirm the identity (name, company) before updating it, a shared phone or email is not proof, and the server creates (with a passive duplicate proposal) a different person on a shared identifier; 409 `identifier_changed` / `owner_busy` and 503 `IDENTITY_INDEX_NOT_READY` are retryable, nothing written; with `callId`: the lead for a contact-less inbound call, linked in the same transaction; the server derives source "Inbound Call" and the owner) |
|
|
20
20
|
| `conduyt_update_contact` | Update contact fields; optional `expectedRevision` (0..2147483647) refuses a stale save with 409 `contact_changed` (code + current revision in the error) |
|
|
21
21
|
| `conduyt_merge_contacts` | DESTRUCTIVE: merge duplicates into a survivor (`POST /contacts/merge`, owner/admin); the losers are deleted. Two steps: without `confirmImpact` a preview (`counts`, `guardCounts`, `identifierOverflow`, `blockers`); with `confirmImpact: true` plus the preview's `expectedCounts`, `expectedGuardCounts` (and `expectedIdentifiers`) the merge; the confirm is sent only against a preview this server returned in the last 10 minutes (else refused locally); the server pins the record counts, guard counts and identifier plan, not other field edits made after the preview; 75 s timeout. Moved counts 409, 503 `merge_contacts_busy` / `merge_budget_exhausted` + Retry-After surfaced verbatim, never retried |
|
|
22
|
-
| `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id |
|
|
23
|
-
| `
|
|
22
|
+
| `conduyt_list_duplicate_proposals` | List a contact's possible duplicates (`GET /contacts/{id}/duplicate-proposals`): open and not-sure proposals (`id`, `status`, `rule`, `other`, `raisedHere`) and the identifiers held from it (value-free); a record you cannot view has no id; keyset paged with `cursor` / `limit` (default 50) and `held_cursor` / `held_limit` (default 20), answers `total`, `nextCursor`, the held twins and membership revisions (re-read page one when a later page answers another revision) |
|
|
23
|
+
| `conduyt_list_import_duplicate_proposals` | The duplicate proposals one import raised on the records it created (`GET /imports/{id}/duplicate-proposals`, contacts:view): every status, oldest first, `page` / `per_page` (default 50), `{ rows, meta }`; a record you cannot view is "A contact you cannot view" with no id |
|
|
24
|
+
| `conduyt_decide_duplicate_proposal` | Decide one proposal (`POST /contacts/{id}/duplicate-proposals/{proposalId}`, contacts:edit on both records): `not_sure` keeps both visible, `different` is final (never shown again), `merge` records a pair ALREADY merged (this tool never merges: use `conduyt_merge_contacts` first, else 409 `not_merged`); 409 `already_decided`, 403 `other_record_not_editable`; answers the list's `membershipRevision` and `total` after the decision and `previousMembershipRevision`; never retried |
|
|
24
25
|
| `conduyt_add_contact_collaborator` | Add a team member as a collaborator (`POST /contacts/{id}/collaborators`); optional `expectedRevision`, 409 `contact_changed` |
|
|
25
26
|
| `conduyt_remove_contact_collaborator` | Remove a collaborator (`DELETE /contacts/{id}/collaborators`, body); optional `expectedRevision`, 409 `contact_changed`, 404 when not a collaborator |
|
|
26
27
|
| `conduyt_add_tags` | Add tags to a contact |
|
package/dist/tools/contacts.js
CHANGED
|
@@ -11,6 +11,10 @@ const IDENTITY_409_MEANING = "A 409 with { existingId, rule, ruleVersion, matche
|
|
|
11
11
|
const IDENTITY_CREATE_ANSWERS = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` or `owner_busy` = another save for the same email, phone or owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later. A possible duplicate that is not an exact match is created and shown as a duplicate proposal on the record (conduyt_list_duplicate_proposals).";
|
|
12
12
|
const IDENTITY_SAVE_RETRY = "Identity rules (v2): " + IDENTITY_409_MEANING + " 409 with code `identifier_changed` = another save for this email or phone was in progress (or the record's email or phone changed while this saved), nothing was written: reload the contact and send the change again (retryable). 409 with code `owner_busy` = another save for the same owner was in progress, nothing was written: wait a moment and send the same request again (retryable). 503 with code `IDENTITY_INDEX_NOT_READY` = the workspace's identifier index is still being built at this size, nothing was written: retry later.";
|
|
13
13
|
const PROPOSAL_DECISIONS = ["not_sure", "different", "merge"];
|
|
14
|
+
/** Duplicate proposals look (board #95, app f2d638db6): paging bounds of the proposal lists. */
|
|
15
|
+
const pageLimit = (max, dflt, name) => z.number().int().min(1).max(max).optional().describe(`${name}: integer 1-${max} (default ${dflt}); a value above ${max} is refused, not capped`);
|
|
16
|
+
const opaqueCursor = (name, from) => z.string().min(1).max(2048).optional().describe(`${name}: the ${from} of the previous page's answer, unchanged (opaque). Omit for the first page.`);
|
|
17
|
+
const MEMBERSHIP_REVISION_NOTE = "membershipRevision and heldIdentifiersMembershipRevision are opaque digests of each list's whole shown set (every page answers the same value while the list is unchanged): keep page one's revision and, when a later page answers another, discard that page and read page one again.";
|
|
14
18
|
export const CONTACT_REVISION_MAX = 2147483647;
|
|
15
19
|
const CONTACT_CHANGED = "A stale pin is refused with 409 and code contact_changed (nothing written): the error text carries `code`, the contact's CURRENT `revision` and the current `contact`; reload, decide, and call again with that revision (never retry the same pin). A malformed expectedRevision is a 400; only an omitted field means unpinned.";
|
|
16
20
|
const expectedRevision = () => z.number().int().min(0).max(CONTACT_REVISION_MAX).optional().describe("Optimistic-concurrency pin: the contact `revision` as you last read it (conduyt_get_contact / conduyt_list_contacts / a previous change). Omit for last-write-wins per field.");
|
|
@@ -100,7 +104,7 @@ export function registerContactTools(server, client) {
|
|
|
100
104
|
const result = await client.get(`/api/v1/contacts?${params}`);
|
|
101
105
|
return formatResult(result);
|
|
102
106
|
});
|
|
103
|
-
server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
|
|
107
|
+
server.tool("conduyt_get_contact", "Get a single contact with full details — profile, tags, custom fields, timeline, deals, and activity history. Includes `revision` (integer save revision): pass it as expectedRevision on conduyt_update_contact or the collaborator tools so a newer change is never overwritten. Includes `hasOutboundSms` (boolean): true when this workspace has ever PROVABLY texted the contact (a proven send only: not a draft, a queued row or a pre-send failure); use it to pick the `context` for conduyt_check_message (false = 'first', true = 'conversation'). Also includes `hasOutboundEmail` (boolean): the same for email (true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged); use it to pick the `context` for an email check. Also includes `duplicateProposals` (the open and not-sure possible-duplicate lines on this record, each naming the other record as far as your key may see it) and `heldIdentifiers` (emails or phones held from this record because another record owns them, value-free), each the FIRST page only, with duplicateProposalsTotal, duplicateProposalsNextCursor, duplicateProposalsMembershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor and heldIdentifiersMembershipRevision (page on with conduyt_list_duplicate_proposals, passing the next cursors as cursor / held_cursor); list or decide them with conduyt_list_duplicate_proposals / conduyt_decide_duplicate_proposal.", {
|
|
104
108
|
id: z.string().describe("Contact UUID"),
|
|
105
109
|
}, async ({ id }) => {
|
|
106
110
|
const result = await client.get(`/api/v1/contacts/${id}`);
|
|
@@ -240,13 +244,27 @@ export function registerContactTools(server, client) {
|
|
|
240
244
|
mergePreviews.drop(key);
|
|
241
245
|
return formatResult(result);
|
|
242
246
|
}));
|
|
243
|
-
strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers
|
|
247
|
+
strictInput(server.tool("conduyt_list_duplicate_proposals", "List the possible duplicates of one contact (GET /api/v1/contacts/{contactId}/duplicate-proposals, identity rules v2; requires contacts:view). Answers { data: { proposals, heldIdentifiers, total, nextCursor, membershipRevision, heldIdentifiersTotal, heldIdentifiersNextCursor, heldIdentifiersMembershipRevision } }. Each proposal: { id (the proposalId for conduyt_decide_duplicate_proposal), status ('open' or 'not_sure': the only two shown), rule, ruleVersion, source, createdAt, decidedAt, raisedHere (true = this record is the newer arrival the proposal was raised on), other: { id, name, createdAt, visible, editable } }. When your key cannot view the other record it is described only as 'a contact you cannot view' with id and createdAt null and visible false: you cannot decide that pair. heldIdentifiers are the emails or phones this record was NOT given because another record owns them (rule 4), value-free: { id, kind ('email' or 'phone'), rule, source, createdAt, heldBy: [{ id, name, visible }] }. Paging: both lists are keyset paged, newest first. cursor / limit (1-100, default 50) page the proposals; held_cursor / held_limit (1-100, default 20) page the held identifiers; a list sent without its cursor answers its first page. total and heldIdentifiersTotal count every line of each list (all pages); nextCursor and heldIdentifiersNextCursor are the cursors of the next pages, null on the last page. " + MEMBERSHIP_REVISION_NOTE + " Read-only, nothing is changed. Refusals: 400 code `invalid_cursor` (a cursor this list did not issue, a held cursor sent as cursor or the reverse), 400 naming limit or held_limit, 404 = no such contact for you. conduyt_get_contact carries the first page of the same two lists.", {
|
|
244
248
|
contactId: z.string().uuid().describe("Contact UUID whose possible duplicates to list"),
|
|
245
|
-
|
|
246
|
-
|
|
249
|
+
cursor: opaqueCursor("cursor", "nextCursor"),
|
|
250
|
+
limit: pageLimit(100, 50, "limit (proposals per page)"),
|
|
251
|
+
held_cursor: opaqueCursor("held_cursor", "heldIdentifiersNextCursor"),
|
|
252
|
+
held_limit: pageLimit(100, 20, "held_limit (held identifiers per page)"),
|
|
253
|
+
}, async ({ contactId, cursor, limit, held_cursor, held_limit }) => {
|
|
254
|
+
const qp = new URLSearchParams();
|
|
255
|
+
if (cursor !== undefined)
|
|
256
|
+
qp.set("cursor", cursor);
|
|
257
|
+
if (limit !== undefined)
|
|
258
|
+
qp.set("limit", String(limit));
|
|
259
|
+
if (held_cursor !== undefined)
|
|
260
|
+
qp.set("held_cursor", held_cursor);
|
|
261
|
+
if (held_limit !== undefined)
|
|
262
|
+
qp.set("held_limit", String(held_limit));
|
|
263
|
+
const qs = qp.toString();
|
|
264
|
+
const result = await client.get(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
|
|
247
265
|
return formatResult(result);
|
|
248
266
|
}));
|
|
249
|
-
strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt } } with status 'not_sure', 'different' or 'merged'. Never retried automatically.", {
|
|
267
|
+
strictInput(server.tool("conduyt_decide_duplicate_proposal", "Record a decision on one possible-duplicate pair shown on a contact (POST /api/v1/contacts/{contactId}/duplicate-proposals/{proposalId} with { decision }; requires contacts:edit on BOTH records of the pair). Get proposalId from conduyt_list_duplicate_proposals. decision, exactly one of: 'not_sure' = keep both records visible, the pair stays listed and who looked is recorded (it can be decided again later); 'different' = the two records are different people, the pair is never shown again and the decision is FINAL; 'merge' = record that the pair is ALREADY merged: this tool NEVER merges. Merge first with conduyt_merge_contacts (preview, then confirm), then send 'merge' here; before the merge exists it is refused with 409 code `not_merged`. A pair already decided 'different' or merged is final: 409 code `already_decided` (also when someone decided it just now). Other refusals, nothing written: 404 (no such proposal on this contact: for EVERY decision; or the other record is gone: for 'not_sure' and 'different' only, while 'merge' still succeeds after the other record has been merged away), 403 code `other_record_not_editable` (you cannot edit the other record), 403 (you can view this contact but not edit it), 400 (decision missing or not one of the three), 402 billing. Answers { data: { id, status, decidedAt, previousMembershipRevision, membershipRevision, total } } with status 'not_sure', 'different' or 'merged'. membershipRevision and total are this contact's proposals list AFTER the decision (the same digest and count conduyt_list_duplicate_proposals answers); previousMembershipRevision is that digest just BEFORE the write (equal to membershipRevision on an idempotent repeat): a client holding loaded pages adopts the after values only when previousMembershipRevision equals the revision it holds, otherwise it reads page one again. Never retried automatically.", {
|
|
250
268
|
contactId: z.string().uuid().describe("Contact UUID the proposal is shown on"),
|
|
251
269
|
proposalId: z.string().uuid().describe("The proposal id from conduyt_list_duplicate_proposals"),
|
|
252
270
|
decision: z.enum(PROPOSAL_DECISIONS).describe("'not_sure' keeps both visible; 'different' = different people, never shown again (final); 'merge' = the pair is already merged elsewhere (run conduyt_merge_contacts first; this tool never merges)"),
|
|
@@ -254,4 +272,18 @@ export function registerContactTools(server, client) {
|
|
|
254
272
|
const result = await client.post(`/api/v1/contacts/${encodeURIComponent(contactId)}/duplicate-proposals/${encodeURIComponent(proposalId)}`, { decision });
|
|
255
273
|
return formatResult(result);
|
|
256
274
|
}));
|
|
275
|
+
strictInput(server.tool("conduyt_list_import_duplicate_proposals", "List the duplicate proposals one import raised on the records it created (GET /api/v1/imports/{importId}/duplicate-proposals; the import results section 'Created, similar record exists'; requires contacts:view). One row per proposal, EVERY status ('open', 'not_sure', 'different', 'merged'; a pair whose records are merged reads 'merged'), oldest first. Paged with page (1-100000, default 1) and per_page (1-100, default 50): answers { data: { 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 (for a merged pair, the record that was kept) }; newRecord and similar are { id, name, visible, editable, live }. A record outside your key's visibility has id null and the name 'A contact you cannot view': that row cannot be decided. Decide a row with conduyt_decide_duplicate_proposal (contactId = newRecord.id, proposalId; needs 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. Read-only. Refusals: 400 naming page or per_page (not an integer, out of range), 403 without contacts:view, 404 for an unknown import or one this key may not read.", {
|
|
276
|
+
importId: z.string().uuid().describe("Import job UUID"),
|
|
277
|
+
page: z.number().int().min(1).max(100000).optional().describe("page: integer 1-100000 (default 1)"),
|
|
278
|
+
per_page: pageLimit(100, 50, "per_page (rows per page)"),
|
|
279
|
+
}, async ({ importId, page, per_page }) => {
|
|
280
|
+
const qp = new URLSearchParams();
|
|
281
|
+
if (page !== undefined)
|
|
282
|
+
qp.set("page", String(page));
|
|
283
|
+
if (per_page !== undefined)
|
|
284
|
+
qp.set("per_page", String(per_page));
|
|
285
|
+
const qs = qp.toString();
|
|
286
|
+
const result = await client.get(`/api/v1/imports/${encodeURIComponent(importId)}/duplicate-proposals` + (qs ? `?${qs}` : ""));
|
|
287
|
+
return formatResult(result);
|
|
288
|
+
}));
|
|
257
289
|
}
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
|
-
"generatedAt": "2026-10-
|
|
3
|
-
"toolCount":
|
|
4
|
-
"totalEndpoints":
|
|
2
|
+
"generatedAt": "2026-10-16T09:38:16.598Z",
|
|
3
|
+
"toolCount": 355,
|
|
4
|
+
"totalEndpoints": 1093,
|
|
5
5
|
"counts": {
|
|
6
|
-
"covered":
|
|
6
|
+
"covered": 314,
|
|
7
7
|
"excluded": 324,
|
|
8
8
|
"planned": 455,
|
|
9
9
|
"uncategorized": 0
|
|
@@ -2976,6 +2976,12 @@
|
|
|
2976
2976
|
"status": "planned",
|
|
2977
2977
|
"reason": "import job monitoring/resubmission (ai-import tool covers creation)"
|
|
2978
2978
|
},
|
|
2979
|
+
"GET /api/v1/imports/:id/duplicate-proposals": {
|
|
2980
|
+
"status": "covered",
|
|
2981
|
+
"tools": [
|
|
2982
|
+
"conduyt_list_import_duplicate_proposals"
|
|
2983
|
+
]
|
|
2984
|
+
},
|
|
2979
2985
|
"GET /api/v1/imports/:id/integrity": {
|
|
2980
2986
|
"status": "planned",
|
|
2981
2987
|
"reason": "import job monitoring/resubmission (ai-import tool covers creation)"
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
export type ActionConfigTypo = {
|
|
2
|
-
actionType: string;
|
|
3
|
-
key: string;
|
|
4
|
-
suggestion: string;
|
|
5
|
-
};
|
|
6
|
-
/** The first misspelled option key in an actions graph (v1 array of {type, config}, or v2 { nodes: [{ action: {type, config} }] }), or null */
|
|
7
|
-
export declare function findActionConfigTypo(graph: unknown): ActionConfigTypo | null;
|
|
8
|
-
/** The refusal text; the details object is the final token */
|
|
9
|
-
export declare function actionConfigTypoMessage(t: ActionConfigTypo): string;
|
|
10
|
-
/** Throws (a plain Error) when the graph carries a misspelled option */
|
|
11
|
-
export declare function assertNoActionConfigTypo(graph: unknown): void;
|
|
@@ -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
|
-
}
|