callkaro-cli-unofficial 0.9.4 → 0.10.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/dist/index.js CHANGED
@@ -51,6 +51,7 @@ const KPI_COLUMNS = 'action_items | in_progress | testing_ab | live_prod | done
51
51
  const analyzers_1 = require("./commands/analyzers");
52
52
  const models_1 = require("./commands/models");
53
53
  const agents_1 = require("./commands/agents");
54
+ const agentMemory_1 = require("./commands/agentMemory");
54
55
  const agentChat_1 = require("./commands/agentChat");
55
56
  const scheduled_1 = require("./commands/scheduled");
56
57
  const agentVersions_1 = require("./commands/agentVersions");
@@ -75,6 +76,7 @@ const knowledge_1 = require("./commands/knowledge");
75
76
  const aiTools_1 = require("./commands/aiTools");
76
77
  const batches_1 = require("./commands/batches");
77
78
  const simulations_1 = require("./commands/simulations");
79
+ const liveTest_1 = require("./commands/liveTest");
78
80
  const simVariables_1 = require("./commands/simVariables");
79
81
  const dashboardApiCmd_1 = require("./commands/dashboardApiCmd");
80
82
  const adminAuth_1 = require("./lib/adminAuth");
@@ -877,8 +879,33 @@ agents.command('update <agentId>').description('update fields via --set \'{json}
877
879
  .option('--versions <id>', 'target a specific version (needed for version-level fields)')
878
880
  .option('--commit <msg>', 'commit message for the version change')
879
881
  .option('--replace-functions', 'replace the whole functions list instead of merging by name (default merges; omitted functions are kept)')
882
+ .option('--dry-run', 'show which fields would change, without saving')
883
+ .option('--yes', 'allow an update that changes more than 8 fields')
880
884
  .option('--json', 'output the raw updated-agent response')
881
885
  .action(agents_1.agentsUpdateCommand);
886
+ agents.command('memory <agentId>').description('show or change call memory (x_call_history for functions); no flags = show')
887
+ .option('--versions <id>', 'version id (required)')
888
+ .option('--enable', 'turn call memory on').option('--disable', 'turn call memory off')
889
+ .option('--max-calls <n>', 'earlier calls to load, -1 (all, up to 200) to 200')
890
+ .option('--days <n>', 'only calls from the last n days, 0 = no limit')
891
+ .option('--only-connected', 'only calls that connected').option('--all-calls', 'include calls that did not connect')
892
+ .option('--with-transcript', 'include transcripts (large)').option('--without-transcript', 'leave transcripts out')
893
+ .option('--agents <scope>', 'self | all | <agentId,agentId,...> - whose calls to read')
894
+ .option('--commit <msg>', 'commit message for the version change')
895
+ .option('--json', 'output the raw agentMemory object')
896
+ .action(agentMemory_1.agentsMemoryCommand);
897
+ agents.command('voice-rotation <agentId>').description('show or change voice rotation on retry; no flags = show')
898
+ .option('--versions <id>', 'version id (required)')
899
+ .option('--enable', 'turn rotation on').option('--disable', 'turn rotation off')
900
+ .option('--trigger <mode>', 'instant_hangup | custom')
901
+ .option('--status <status>', 'custom trigger: previous call connected | not_connected')
902
+ .option('--condition <expr>', 'custom trigger condition source[.field]:operator[:value] (repeatable)', (value, previous = []) => [...previous, value])
903
+ .option('--clear-conditions', 'remove all conditions')
904
+ .option('--voices <@file.json>', 'JSON array of retry voice configurations (max 5)')
905
+ .option('--on-exhausted <mode>', 'last | primary | cycle')
906
+ .option('--commit <msg>', 'commit message for the version change')
907
+ .option('--json', 'output the raw agentMemory object')
908
+ .action(agentMemory_1.agentsVoiceRotationCommand);
882
909
  // Phone assignment (agent-centric). The one-inbound-agent rule is enforced server-side.
883
910
  agents.command('set-inbound <agentId>').description("add/remove an agent's inbound phone number")
884
911
  .option('--number <id>', 'phone number id')
@@ -1371,6 +1398,8 @@ sim.command('create <agentId>').description('create a test case')
1371
1398
  .option('--model <model>', 'test LLM model (default: platform default; see `cku sim models`)')
1372
1399
  .option('--variables <json|@file>', 'testVariables: a JSON object of name -> value (non-strings are stringified)')
1373
1400
  .option('--functions <json|@file>', 'a JSON array of {"name":"fn","mock":true,"response":{}}; "mock":false runs the REAL function (never for WhatsApp functions)')
1401
+ .option('--real-functions', 'run every agent function for real (mock:false), like switching every Mock off in the dashboard; --functions entries win by name')
1402
+ .option('--versions <versionId>', 'with --real-functions: read the functions from this version (default: the agent\'s default version)')
1374
1403
  .action(simulations_1.simCreateCommand);
1375
1404
  sim.command('update <testCaseId>').description('edit a test case (only the fields you pass are sent)')
1376
1405
  .option('--name <name>', 'test case name')
@@ -1380,6 +1409,9 @@ sim.command('update <testCaseId>').description('edit a test case (only the field
1380
1409
  .option('--model <model>', 'test LLM model')
1381
1410
  .option('--variables <json|@file>', 'testVariables: replaces the whole map')
1382
1411
  .option('--functions <json|@file>', 'function list, replaces the whole list; entries are {name, mock?, response?}, "mock":false runs the REAL function')
1412
+ .option('--real-functions', 'replace the list with every agent function run for real (mock:false); --functions entries win by name; needs --agent')
1413
+ .option('--agent <agentId>', 'agent to read functions from (for --real-functions)')
1414
+ .option('--versions <versionId>', 'with --real-functions: read the functions from this version')
1383
1415
  .action(simulations_1.simUpdateCommand);
1384
1416
  sim.command('models').description('models that can play the simulated caller').option('--json', 'raw JSON').action(simulations_1.simModelsCommand);
1385
1417
  sim.command('delete <testCaseId>').description('delete a test case').option('--yes', 'skip confirmation')
@@ -1423,6 +1455,47 @@ simVars.command('create <agentId> <name>').description('save a variable set')
1423
1455
  .action(simVariables_1.simVarsCreateCommand);
1424
1456
  simVars.command('delete <agentId> <nameOrId>').description('delete a saved set').option('--yes', 'skip confirmation')
1425
1457
  .action(simVariables_1.simVarsDeleteCommand);
1458
+ // --- live test calls (the editor's Test Chat, from a terminal) -------------
1459
+ const liveTest = program.command('test').description('live test calls against the real call runtime; you or an LLM play the caller (functions RUN FOR REAL)');
1460
+ liveTest.command('start <agentId>').description('start a test call and print the agent\'s opening turn; then `test say` / `test end`')
1461
+ .option('--versions <versionId>', 'version to test (default: the published one)')
1462
+ .option('--variables <json|@file>', 'call metadata as a JSON object')
1463
+ .option('--yes', 'confirm the real test call without asking')
1464
+ .option('--json', 'print {sessionId, callSid, events} as JSON')
1465
+ .action(liveTest_1.testStartCommand);
1466
+ liveTest.command('say <sessionId> <text...>').description('send one caller turn and print the agent\'s reply')
1467
+ .option('--timeout <seconds>', 'wait this long for the agent to finish its turn (default 60)')
1468
+ .option('--json', 'print {events, ended} as JSON')
1469
+ .action(liveTest_1.testSayCommand);
1470
+ liveTest.command('end <sessionId>').description('hang up and print the full transcript with function calls')
1471
+ .option('--json', 'print {callSid, events, ended} as JSON')
1472
+ .action(liveTest_1.testEndCommand);
1473
+ liveTest.command('_serve <sessionId>', { hidden: true }).action(liveTest_1.testServeCommand);
1474
+ liveTest.command('run <agentId>').description('the CLI plays the caller with an LLM, then grades the call against --criteria')
1475
+ .option('--test <testCaseId>', 'reuse a saved simulation test case (persona, prompt, criteria, variables)')
1476
+ .option('--persona <text>', 'who the caller is')
1477
+ .option('--prompt <text>', 'what the caller wants and does in this call')
1478
+ .option('--criteria <text>', 'success criteria for the judge; without it the call is not graded')
1479
+ .option('--variables <json|@file>', 'call metadata as a JSON object (merged over the test case\'s)')
1480
+ .option('--versions <versionId>', 'version to test (default: the published one)')
1481
+ .option('--llm <provider>', 'openai | anthropic | gemini | deepseek | groq | openrouter | ollama | custom (default: first key found in the environment)')
1482
+ .option('--llm-model <model>', 'caller model (default per provider)')
1483
+ .option('--llm-url <url>', 'OpenAI-compatible base URL (custom provider; key in CKU_LLM_API_KEY)')
1484
+ .option('--judge-model <model>', 'judge model on the same provider (default: the caller model)')
1485
+ .option('--max-turns <n>', 'caller turns before giving up (default 20)')
1486
+ .option('--timeout <seconds>', 'per agent turn (default 60)')
1487
+ .option('--yes', 'confirm the real test call without asking')
1488
+ .option('--json', 'print {callSid, endedBy, events, verdict} as JSON')
1489
+ .addHelpText('after', `
1490
+ Keys come from the environment only: OPENAI_API_KEY, ANTHROPIC_API_KEY, GEMINI_API_KEY,
1491
+ DEEPSEEK_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, or CKU_LLM_API_KEY for any provider.
1492
+ Exit 1 when the judge fails the call (or cannot grade it).
1493
+
1494
+ Examples:
1495
+ cku test run <agentId> --test <testCaseId> --yes
1496
+ cku test run <agentId> --persona "Busy dealer in Pune" --prompt "Ask for the price, then say you will think about it" \\
1497
+ --criteria "1. Agent states the price from call data. 2. Agent ends politely with end_call." --llm anthropic --yes`)
1498
+ .action(liveTest_1.testRunCommand);
1426
1499
  // --- audit strategies ------------------------------------------------------
1427
1500
  const chatSim = program.command('chat-sim').description('chat agent simulations & test cases');
1428
1501
  chatSim.command('tests <chatAgentId>').description('list chat test cases').option('--json', 'raw JSON').action(chatSimulations_1.chatSimTestsListCommand);
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hasChanges = exports.CONDITION_OPERATORS = exports.CONDITION_SOURCES = exports.ON_EXHAUSTED = exports.PREVIOUS_STATUSES = exports.TRIGGER_MODES = exports.MAX_CONDITIONS = exports.MAX_ROTATION_VOICES = exports.MAX_DAYS_RANGE = exports.MAX_CALLS_RANGE = void 0;
4
+ exports.applyMemoryFlags = applyMemoryFlags;
5
+ exports.parseCondition = parseCondition;
6
+ exports.applyRotationFlags = applyRotationFlags;
7
+ exports.describeMemory = describeMemory;
8
+ exports.describeRotation = describeRotation;
9
+ /** Helpers for call memory and retry voice rotation stored in version.agentMemory. */
10
+ exports.MAX_CALLS_RANGE = [-1, 200];
11
+ exports.MAX_DAYS_RANGE = [0, 3650];
12
+ exports.MAX_ROTATION_VOICES = 5;
13
+ exports.MAX_CONDITIONS = 20;
14
+ exports.TRIGGER_MODES = ['instant_hangup', 'custom'];
15
+ exports.PREVIOUS_STATUSES = ['connected', 'not_connected'];
16
+ exports.ON_EXHAUSTED = ['last', 'primary', 'cycle'];
17
+ exports.CONDITION_SOURCES = ['post_call_variable', 'hangup_reason', 'call_duration', 'conversion_status'];
18
+ exports.CONDITION_OPERATORS = ['equals', 'not_equals', 'empty', 'not_empty', 'eq', 'gt', 'gte', 'lt', 'lte'];
19
+ const OBJECT_ID = /^[0-9a-fA-F]{24}$/;
20
+ const clone = (value) => JSON.parse(JSON.stringify(value ?? {}));
21
+ function oneOf(label, value, allowed) {
22
+ if (!allowed.includes(value))
23
+ throw new Error(`Invalid ${label} "${value}"\n allowed: ${allowed.join(', ')}`);
24
+ return value;
25
+ }
26
+ function intIn(label, raw, [min, max]) {
27
+ const value = Number(raw);
28
+ if (raw.trim() === '' || !Number.isInteger(value) || value < min || value > max) {
29
+ throw new Error(`Invalid ${label} "${raw}": expected a whole number from ${min} to ${max}`);
30
+ }
31
+ return value;
32
+ }
33
+ function pairFlag(label, on, off, onFlag, offFlag) {
34
+ if (on && off)
35
+ throw new Error(`${onFlag} and ${offFlag} cannot be used together (${label})`);
36
+ }
37
+ const hasChanges = (flags) => Object.values(flags).some((value) => value !== undefined && value !== false);
38
+ exports.hasChanges = hasChanges;
39
+ function applyMemoryFlags(current, flags) {
40
+ pairFlag('on/off', flags.enable, flags.disable, '--enable', '--disable');
41
+ pairFlag('connected filter', flags.onlyConnected, flags.allCalls, '--only-connected', '--all-calls');
42
+ pairFlag('transcript', flags.withTranscript, flags.withoutTranscript, '--with-transcript', '--without-transcript');
43
+ const out = clone(current);
44
+ const filters = (out.call_filters = out.call_filters || {});
45
+ if (flags.enable)
46
+ out.enabled_for_call = true;
47
+ if (flags.disable)
48
+ out.enabled_for_call = false;
49
+ if (flags.maxCalls !== undefined)
50
+ filters.maxCalls = intIn('--max-calls', String(flags.maxCalls), exports.MAX_CALLS_RANGE);
51
+ if (flags.days !== undefined)
52
+ filters.maxDays = intIn('--days', String(flags.days), exports.MAX_DAYS_RANGE);
53
+ if (flags.onlyConnected)
54
+ filters.onlyConnected = true;
55
+ if (flags.allCalls)
56
+ filters.onlyConnected = false;
57
+ if (flags.withTranscript)
58
+ filters.includeTranscript = true;
59
+ if (flags.withoutTranscript)
60
+ filters.includeTranscript = false;
61
+ if (flags.agents !== undefined) {
62
+ const raw = String(flags.agents).trim();
63
+ if (raw === 'self' || raw === 'all')
64
+ filters.additionalAgents = { mode: raw, agentIds: [] };
65
+ else {
66
+ const ids = raw.split(',').map((id) => id.trim()).filter(Boolean);
67
+ const bad = ids.filter((id) => !OBJECT_ID.test(id));
68
+ if (!ids.length || bad.length) {
69
+ throw new Error(`Invalid --agents "${raw}": use self, all, or a comma-separated list of agent ids${bad.length ? ` (bad: ${bad.join(', ')})` : ''}`);
70
+ }
71
+ if (ids.length > 50)
72
+ throw new Error('--agents takes at most 50 agent ids');
73
+ filters.additionalAgents = { mode: 'selected', agentIds: ids };
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+ /** Parse source[.field]:operator[:value]. */
79
+ function parseCondition(text) {
80
+ const [lhs, operator, ...rest] = text.split(':');
81
+ if (!lhs || !operator)
82
+ throw new Error(`Bad --condition "${text}": expected source[.field]:operator[:value], e.g. hangup_reason:equals:user_busy`);
83
+ const dot = lhs.indexOf('.');
84
+ const source = dot < 0 ? lhs : lhs.slice(0, dot);
85
+ const field = dot < 0 ? undefined : lhs.slice(dot + 1);
86
+ oneOf('condition source', source, exports.CONDITION_SOURCES);
87
+ oneOf('condition operator', operator, exports.CONDITION_OPERATORS);
88
+ if (source === 'post_call_variable' && !field)
89
+ throw new Error(`Bad --condition "${text}": post_call_variable needs a field, e.g. post_call_variable.interested:equals:false`);
90
+ if (source !== 'post_call_variable' && field)
91
+ throw new Error(`Bad --condition "${text}": only post_call_variable takes a .field`);
92
+ const condition = { source, operator };
93
+ if (field)
94
+ condition.field = field;
95
+ if (operator !== 'empty' && operator !== 'not_empty') {
96
+ if (!rest.length)
97
+ throw new Error(`Bad --condition "${text}": operator "${operator}" needs a value`);
98
+ const raw = rest.join(':');
99
+ try {
100
+ condition.value = JSON.parse(raw);
101
+ }
102
+ catch {
103
+ condition.value = raw;
104
+ }
105
+ if (typeof condition.value === 'object')
106
+ condition.value = raw;
107
+ }
108
+ return condition;
109
+ }
110
+ function applyRotationFlags(current, flags) {
111
+ pairFlag('on/off', flags.enable, flags.disable, '--enable', '--disable');
112
+ if (flags.clearConditions && flags.condition?.length)
113
+ throw new Error('--clear-conditions and --condition cannot be used together');
114
+ const out = clone(current);
115
+ const rotation = (out.voice_rotation_config = out.voice_rotation_config || {});
116
+ const trigger = (rotation.trigger = rotation.trigger || {});
117
+ if (flags.enable)
118
+ rotation.enabled = true;
119
+ if (flags.disable)
120
+ rotation.enabled = false;
121
+ if (flags.trigger !== undefined)
122
+ trigger.mode = oneOf('--trigger', flags.trigger, exports.TRIGGER_MODES);
123
+ if (flags.status !== undefined)
124
+ trigger.previous_call_status = oneOf('--status', flags.status, exports.PREVIOUS_STATUSES);
125
+ if (flags.onExhausted !== undefined)
126
+ rotation.on_exhausted = oneOf('--on-exhausted', flags.onExhausted, exports.ON_EXHAUSTED);
127
+ if (flags.clearConditions)
128
+ trigger.conditions = [];
129
+ if (flags.condition?.length)
130
+ trigger.conditions = flags.condition.map(parseCondition);
131
+ if ((trigger.conditions || []).length > exports.MAX_CONDITIONS)
132
+ throw new Error(`At most ${exports.MAX_CONDITIONS} conditions`);
133
+ if ((trigger.conditions || []).length && (trigger.mode || 'instant_hangup') !== 'custom') {
134
+ throw new Error('Conditions only apply with --trigger custom (instant_hangup ignores them)');
135
+ }
136
+ if (flags.voices !== undefined) {
137
+ if (!Array.isArray(flags.voices) || flags.voices.some((voice) => !voice || typeof voice !== 'object' || Array.isArray(voice))) {
138
+ throw new Error('--voices must be a JSON array of voice configuration objects');
139
+ }
140
+ if (flags.voices.length > exports.MAX_ROTATION_VOICES)
141
+ throw new Error(`At most ${exports.MAX_ROTATION_VOICES} retry voices (got ${flags.voices.length})`);
142
+ flags.voices.forEach((voice, index) => {
143
+ if (typeof voice.voice_provider !== 'string' || !voice.voice_provider.trim()) {
144
+ throw new Error(`voices[${index}]: voice_provider is required - copy the "configuration" object from \`cku voices --json\` unchanged`);
145
+ }
146
+ });
147
+ rotation.voices = flags.voices;
148
+ }
149
+ if (rotation.enabled && !(rotation.voices || []).length)
150
+ throw new Error('Rotation needs at least one retry voice - pass --voices @voices.json (a JSON array), or leave it disabled');
151
+ return out;
152
+ }
153
+ function describeMemory(memory) {
154
+ const value = memory || {};
155
+ const filters = value.call_filters || {};
156
+ const agents = filters.additionalAgents || {};
157
+ const maxCalls = filters.maxCalls ?? 10;
158
+ return {
159
+ 'call memory': value.enabled_for_call ? 'on' : 'off',
160
+ 'max calls': maxCalls < 0 ? 'all (up to 200)' : String(maxCalls),
161
+ 'max age (days)': (filters.maxDays ?? 0) > 0 ? String(filters.maxDays) : 'no limit',
162
+ 'connected only': filters.onlyConnected === false ? 'no' : 'yes',
163
+ transcripts: filters.includeTranscript ? 'yes' : 'no',
164
+ 'agents read': agents.mode === 'selected' ? `selected (${(agents.agentIds || []).length})` : agents.mode || 'self',
165
+ };
166
+ }
167
+ function describeRotation(memory) {
168
+ const rotation = (memory || {}).voice_rotation_config || {};
169
+ const trigger = rotation.trigger || {};
170
+ const mode = trigger.mode || 'instant_hangup';
171
+ return {
172
+ 'voice rotation': rotation.enabled ? 'on' : 'off',
173
+ trigger: mode === 'custom' ? `custom (previous call ${trigger.previous_call_status || 'connected'}, ${(trigger.conditions || []).length} condition(s))` : 'instant_hangup',
174
+ 'retry voices': String((rotation.voices || []).length),
175
+ 'when exhausted': rotation.on_exhausted || 'last',
176
+ };
177
+ }
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ENUMS = exports.STRING_ARRAY_KEYS = exports.REPLACE_WHOLESALE_KEYS = void 0;
3
+ exports.MAX_FIELDS_WITHOUT_YES = exports.ENUMS = exports.STRING_ARRAY_KEYS = exports.REPLACE_WHOLESALE_KEYS = void 0;
4
4
  exports.applyLiveModels = applyLiveModels;
5
5
  exports.validateEnums = validateEnums;
6
6
  exports.validateCallSettings = validateCallSettings;
7
7
  exports.validateProviderConfigs = validateProviderConfigs;
8
8
  exports.preparePatch = preparePatch;
9
+ exports.jsonEqual = jsonEqual;
10
+ exports.planUpdate = planUpdate;
9
11
  exports.coerce = coerce;
10
12
  exports.deepMerge = deepMerge;
11
13
  exports.mergeWholesaleKeys = mergeWholesaleKeys;
@@ -28,7 +30,7 @@ const catalog_1 = require("./catalog");
28
30
  exports.REPLACE_WHOLESALE_KEYS = new Set([
29
31
  'preFormatVariables', 'customPronunciations', 'transcriber', 'secondary_transcriber',
30
32
  'voice_configuration', 'secondary_voice_configuration', 'callFlow_json',
31
- 'variableSource', 'callPropertyMapping',
33
+ 'variableSource', 'callPropertyMapping', 'agentMemory',
32
34
  ]);
33
35
  /** String-array fields the web trims + drops empties on; we match that. */
34
36
  exports.STRING_ARRAY_KEYS = new Set([
@@ -160,8 +162,10 @@ const PROVIDER_CONFIGS = [
160
162
  * Patches may be partial (a dotted `--set` touching only `voice_speed`), so each
161
163
  * check runs only when the keys it needs are actually present.
162
164
  */
163
- function validateProviderConfigs(obj) {
165
+ function validateProviderConfigs(obj, opts = {}) {
164
166
  for (const { key, kind } of PROVIDER_CONFIGS) {
167
+ if (opts.skipVoices && kind === 'voice')
168
+ continue;
165
169
  const cfg = obj[key];
166
170
  if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg))
167
171
  continue;
@@ -199,7 +203,7 @@ function validateProviderConfigs(obj) {
199
203
  * `wholesaleKeys` lists the replace-wholesale objects present in the patch, so
200
204
  * the caller can merge them against the agent's current value before sending.
201
205
  */
202
- function preparePatch(obj) {
206
+ function preparePatch(obj, opts = {}) {
203
207
  if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
204
208
  throw new Error('--set must be a JSON object of fields, e.g. \'{"name":"x","temperature":5}\'');
205
209
  }
@@ -207,7 +211,7 @@ function preparePatch(obj) {
207
211
  validateEnums(source);
208
212
  validateCallSettings(source);
209
213
  const patch = { ...source };
210
- validateProviderConfigs(patch);
214
+ validateProviderConfigs(patch, opts);
211
215
  for (const key of Object.keys(patch)) {
212
216
  if (exports.STRING_ARRAY_KEYS.has(key))
213
217
  patch[key] = normalizeStringArray(patch[key]);
@@ -216,6 +220,32 @@ function preparePatch(obj) {
216
220
  const wholesaleKeys = Object.keys(patch).filter((k) => exports.REPLACE_WHOLESALE_KEYS.has(k) && patch[k] && typeof patch[k] === 'object' && !Array.isArray(patch[k]));
217
221
  return { patch, versionKeys, wholesaleKeys };
218
222
  }
223
+ exports.MAX_FIELDS_WITHOUT_YES = 8;
224
+ const isObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
225
+ /** Structural JSON equality: object key order does not matter; array order does. */
226
+ function jsonEqual(a, b) {
227
+ if (Array.isArray(a) || Array.isArray(b))
228
+ return Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, i) => jsonEqual(value, b[i]));
229
+ if (isObject(a) && isObject(b))
230
+ return [...new Set([...Object.keys(a), ...Object.keys(b)])].every((key) => jsonEqual(a[key], b[key]));
231
+ return a === b;
232
+ }
233
+ function planUpdate(current, patch) {
234
+ const send = {};
235
+ const changed = [];
236
+ const unchanged = [];
237
+ for (const [key, value] of Object.entries(patch)) {
238
+ const before = current[key];
239
+ const next = exports.REPLACE_WHOLESALE_KEYS.has(key) && isObject(before) && isObject(value) ? deepMerge(before, value) : value;
240
+ if (jsonEqual(before, next))
241
+ unchanged.push(key);
242
+ else {
243
+ send[key] = next;
244
+ changed.push(key);
245
+ }
246
+ }
247
+ return { send, changed, unchanged };
248
+ }
219
249
  /** JSON-first value coercion: `true`→bool, `5`→num, `[...]`→array, else the raw string. */
220
250
  function coerce(raw) {
221
251
  try {