@elitedcs/ghl-mcp 3.97.1 → 3.97.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.97.3: A sub-account is never created twice at the same moment, and brain setup names the right fix
4
+
5
+ **Two Creates of one business name at the same moment: the second is refused.** When the same name is being created in the cockpit and in chat, or in two assistant windows on one computer, the second Create now says the sub-account is being created right now and to wait for that to finish. Before, both could go through and make the sub-account twice. This works when every window runs 3.97.3 or later. If GHL Command cannot check this because its shared file is busy, or cannot be read or written, nothing is created and it says to try again in a moment.
6
+
7
+ **Brain setup now gives the right fix for each Anthropic refusal:** a key it does not accept needs a new key; billing, credit or a usage limit keep the same key and say what to fix; a model your workspace cannot use is named, with how to turn on access.
8
+
9
+ The brain's send-or-hold check now has up to 15 seconds to answer (was 6), so the first check after a quiet spell is no longer held just for being slow.
10
+
11
+ ## 3.97.2: Two assistant windows share one computer more safely, cockpit runs never wait on a question, and the client brain fails safe
12
+
13
+ **Two assistant windows on one computer keep each other's unrelated changes.** When GHL Command runs in more than one assistant window at once, each one now saves your registered accounts, connections and cockpit board under a shared lock, and merges its change onto what the other saved. Before, one could save over an account the other had just registered. Editing the same details in both windows at once can still replace the earlier change. If the lock stays busy for 5 seconds, nothing is written, and the tool or the card says what was not saved and what to do next; only a few background notes (such as when the desktop icon was offered) are skipped without a message. Most of a cockpit run's own saves (a step's status and record, its job list and its check record) try three times over about 2 to 3 seconds instead. If the lock is still busy, the run's progress says in plain words which one could not be saved and what to do next, and a step that finished still reads as finished; its card shows what was last saved. A sub-account that was created is always reported as created, in chat and in the cockpit, even when its key or its card could not be saved. If the cockpit could not save it, the New Client page lists it as created until you register it with its key or press "Dismiss: I removed this sub-account", and neither the cockpit nor chat will create that name again until then.
14
+
15
+ **Capability runs in the cockpit never stop to ask a question nobody can answer.** They run without a person watching. Anything only you can decide now comes back on the capability card as "Needs your answer:", before "Waiting on a person:", including when the run did not finish. If the approved plan names no packages, the payments run creates no products and asks you instead.
16
+
17
+ **Build and Verify check every approval again right before they start**, on a fresh read, so an approval withdrawn a moment earlier is never acted on.
18
+
19
+ **health_check now names the assistant it is running in** and the license plan this session runs on.
20
+
21
+ **The client brain fails safe:**
22
+ - When the judge leans toward sending a reply to a lead but is not sure, it holds the reply for a person and tries to add the judge's reason as a note on the contact. Appointment reminders still send.
23
+ - When Anthropic refuses the brain's key, the brain records it, shows a red line on the brain pages, and counts what went out unchecked. Saving a new key clears it once Anthropic accepts that key.
24
+ - When writing a draft fails, nothing is sent: the brain tries to add one note to the lead's contact saying so, and the drafts page shows a "Draft failed" card in the same words (the newest 20 from the last 48 hours).
25
+ - A suggestion that ran out of time can be dismissed: too late, not wrong. Dismissing teaches the brain nothing, so it may suggest it again. Rejecting still means "never suggest this again".
26
+
27
+ **Fixes:**
28
+ - A value that still holds a merge placeholder, with either "{{" or "}}", is never counted as filled on the client page or the switched-off list, and never releases a held automation.
29
+ - A value the plan itself carries no longer releases a held automation when the latest check, or the build's own read, read that value blank or as a placeholder.
30
+
3
31
  ## 3.97.1: The cockpit says what is true about your computer, your build and your handoff
4
32
 
5
33
  **The build steps now open with a "This computer" strip.** Above step 1 it shows, from your last readiness check, whether Claude Code is installed, whether it is signed in, and whether GHL Command is ready in it. The sign-in check costs nothing: it asks Claude Code directly and runs no model. A Claude Code too old to answer says "Not checked", never "Signed in".
package/README.md CHANGED
@@ -904,7 +904,7 @@ Built by **[Elite DCs, LLC](https://elitedcs.com)** — a digital marketing and
904
904
 
905
905
  **Tech stack:** TypeScript, Node.js, esbuild, MCP SDK, Zod, GHL API v2, Firebase Auth
906
906
 
907
- **Version:** 3.97.1
907
+ **Version:** 3.97.3
908
908
 
909
909
  ---
910
910
 
@@ -15031,8 +15031,17 @@ var NOTE_HEADINGS = {
15031
15031
  needsHuman: "Needs a human:",
15032
15032
  brainUnreadable: "Could not read the brain; a person should reply.",
15033
15033
  /** Brain v1 step 2: the gate held a draft. Followed by the reasons, then the draft. */
15034
- held: "HELD, not sent:"
15034
+ held: "HELD, not sent:",
15035
+ /** R5 (2026-11-02): the model call failed, so there is no draft. Followed by the plain reason (draftFailedNote). */
15036
+ draftFailed: "Brain draft failed:"
15035
15037
  };
15038
+ var DRAFT_FAILED_CHECK = "draft_failed";
15039
+ var REFUSAL_PREFIX = {
15040
+ key: "the brain's Anthropic key stopped working",
15041
+ organization: "Anthropic stopped the organization behind the brain's key (billing, credit or a usage limit; the key itself is fine)",
15042
+ model: "Anthropic will not let the brain's key use a model the brain needs (the key itself is fine)"
15043
+ };
15044
+ var draftFailedNote = (reason) => `${NOTE_HEADINGS.draftFailed} ${reason}. Nothing was sent.`;
15036
15045
 
15037
15046
  // src/brain/booking.ts
15038
15047
  var ENDED_APPOINTMENT_STATUSES = /* @__PURE__ */ new Set(["cancelled", "canceled", "noshow", "no_show", "no-show", "showed"]);
@@ -15743,6 +15752,72 @@ function createGhlHttp(opts) {
15743
15752
  };
15744
15753
  }
15745
15754
 
15755
+ // src/brain/key-health.ts
15756
+ var AnthropicRefusedError = class extends Error {
15757
+ constructor(status, refusal) {
15758
+ super(`the model answered ${status}`);
15759
+ this.status = status;
15760
+ this.refusal = refusal;
15761
+ this.name = "AnthropicRefusedError";
15762
+ }
15763
+ status;
15764
+ refusal;
15765
+ };
15766
+ var NO_CREDIT = /credit balance is too low|insufficient (?:credit|funds)/i;
15767
+ var IDENTITY_LINKED = /anthropic-workspace-id/i;
15768
+ var USAGE_LIMIT = /usage limit/i;
15769
+ var NAMES_THE_MODEL = /\b(?:specified|requested|this) model\b/i;
15770
+ var namesTheModel = (message, model) => model.length > 0 && message.includes(model) || NAMES_THE_MODEL.test(message);
15771
+ var escaped = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15772
+ var modelAccessWords = (message, model) => {
15773
+ const id = model ? escaped(model) : "(?!)";
15774
+ return new RegExp(`access to (?:the )?(?:model\\b|${id})|have access to (?:the )?(?:model\\b|${id})|permission to use|not permitted to use|not available to (?:this|your) (?:workspace|organization)`, "i").test(message);
15775
+ };
15776
+ var aboutTheModel = (type, message, model) => namesTheModel(message, model) && (type === "permission_error" || type === "invalid_request_error" && modelAccessWords(message, model));
15777
+ var NOT_RECOGNIZED = "Anthropic does not recognize the key, which may have been deleted, replaced or copied incompletely";
15778
+ function anthropicRefusal(status, body, model) {
15779
+ const r = (cause, reason) => ({ cause, reason, model });
15780
+ if (status === 401) return r("key", NOT_RECOGNIZED);
15781
+ let error51 = null;
15782
+ try {
15783
+ const parsed = JSON.parse(body);
15784
+ if (parsed && parsed.type === "error" && parsed.error && typeof parsed.error === "object") error51 = parsed.error;
15785
+ } catch {
15786
+ error51 = null;
15787
+ }
15788
+ if (!error51 || status >= 500) return null;
15789
+ const type = typeof error51.type === "string" ? error51.type : "";
15790
+ const message = typeof error51.message === "string" ? error51.message : "";
15791
+ if (USAGE_LIMIT.test(message)) return r("organization", "a usage limit set on the Anthropic organization or its workspace stops it");
15792
+ if (status === 429) return null;
15793
+ if (type === "billing_error") return r("organization", "the Anthropic organization's billing stops it");
15794
+ if (NO_CREDIT.test(message)) return r("organization", "the Anthropic organization's credit balance is empty");
15795
+ if (IDENTITY_LINKED.test(message)) return r("key", "the key is an identity-linked key, which Anthropic refuses for the brain");
15796
+ if (type === "not_found_error" || aboutTheModel(type, message, model)) {
15797
+ return r("model", `the model is ${model}`);
15798
+ }
15799
+ if (type === "authentication_error") return r("key", NOT_RECOGNIZED);
15800
+ if (type === "permission_error") return r("key", "Anthropic says the key may not do what the brain needs");
15801
+ return null;
15802
+ }
15803
+ async function responseText(res) {
15804
+ try {
15805
+ return typeof res.text === "function" ? (await res.text()).slice(0, 4e3) : "";
15806
+ } catch {
15807
+ return "";
15808
+ }
15809
+ }
15810
+ async function recordKeyHealth(db2, client, signal, now, judgmentId = null) {
15811
+ if (!signal) return;
15812
+ try {
15813
+ await asRuntime(db2, client, (tx) => tx.query(
15814
+ "select brain.note_anthropic_health($1, $2, $3, $4::timestamptz, $5::bigint)",
15815
+ signal.ok ? [signal.model.slice(0, 100), null, null, now.toISOString(), judgmentId] : [signal.refusal.model.slice(0, 100), signal.refusal.cause, signal.refusal.reason.slice(0, 300), now.toISOString(), judgmentId]
15816
+ ));
15817
+ } catch {
15818
+ }
15819
+ }
15820
+
15746
15821
  // src/brain/model.ts
15747
15822
  var MessagesResponse = external_exports.object({
15748
15823
  model: external_exports.string().optional(),
@@ -15774,7 +15849,11 @@ function createAnthropicDraftModel(opts) {
15774
15849
  }),
15775
15850
  signal: AbortSignal.timeout(Math.min(req.timeoutMs, opts.timeoutMs ?? req.timeoutMs))
15776
15851
  });
15777
- if (!res.ok) throw new Error(`the model answered ${res.status}`);
15852
+ if (!res.ok) {
15853
+ const refused = anthropicRefusal(res.status, await responseText(res), req.model);
15854
+ if (refused) throw new AnthropicRefusedError(res.status, refused);
15855
+ throw new Error(`the model answered ${res.status}`);
15856
+ }
15778
15857
  const body = MessagesResponse.parse(await res.json());
15779
15858
  const text2 = body.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
15780
15859
  return {
@@ -15880,28 +15959,40 @@ function judgedHoldWhy(judgmentId) {
15880
15959
  const ref = judgmentId ? ` (judgment ${judgmentId})` : "";
15881
15960
  return `The brain's judge said not to send this message now. The brain is recording its reason${ref} and will try to add it to the contact as a note.`;
15882
15961
  }
15962
+ var LOW_CONFIDENCE_RULE = { holdBelow: "medium", reminder: "send" };
15963
+ var CONFIDENCE_RANK = { low: 0, medium: 1, high: 2 };
15964
+ function unsureSendHolds(answer, kind, rule = LOW_CONFIDENCE_RULE) {
15965
+ if (answer.decision !== "send" || CONFIDENCE_RANK[answer.confidence] >= CONFIDENCE_RANK[rule.holdBelow]) return false;
15966
+ return kind === "ask" || rule.reminder === "hold";
15967
+ }
15968
+ var UNSURE_HOLD_WHY = "The brain's judge leaned toward sending this message but was not sure, so it held it for a person to check.";
15883
15969
  function askReply(answer, outcome) {
15884
- if (!outcome.ok || answer.why === STOPPED_WHY) return answer;
15970
+ if (!outcome.ok || answer.why === STOPPED_WHY || answer.why === UNSURE_HOLD_WHY) return answer;
15885
15971
  return { ...answer, why: answer.send === "yes" ? JUDGED_SEND_WHY : judgedHoldWhy(answer.judgmentId) };
15886
15972
  }
15887
15973
  function judgeNote(judgmentId, message, answer) {
15888
15974
  const esc2 = (v) => v.replace(/</g, "\u2039").replace(/>/g, "\u203A");
15889
15975
  const lines = [
15890
- `The brain's judge said not to send a message to this lead (judgment ${judgmentId}).`,
15976
+ answer.decision === "send" ? `The brain's judge was not sure about a message to this lead, so the brain held it for a person (judgment ${judgmentId}). The judge leaned toward sending it.` : `The brain's judge said not to send a message to this lead (judgment ${judgmentId}).`,
15891
15977
  `Message: ${esc2(message.slice(0, 600))}`,
15892
15978
  `Why: ${esc2(answer.reason)}`,
15979
+ ...answer.decision === "send" ? [`How sure the judge was: ${answer.confidence}`] : [],
15893
15980
  `Where the lead is: ${esc2(answer.lead_stage)}`
15894
15981
  ];
15895
15982
  if (answer.better_message) lines.push(`What would fit now: ${esc2(answer.better_message)}`);
15896
15983
  return lines.join("\n");
15897
15984
  }
15898
- var JUDGE_ASK_DEADLINE_MS = 6e3;
15985
+ var JUDGE_ASK_DEADLINE_MS = 15e3;
15899
15986
  var JUDGE_SETTLE_MS = 700;
15900
15987
  function workflowAnswer(outcome, judgmentId, kind = "ask", facts = null, rule = DEFAULT_FAIL_RULE, known = {}) {
15901
15988
  const hold = (why) => ({ send: "no", why, judgmentId, held: true, task: rule.taskOnHold ? `The brain held a message to this lead. ${why} Check it and send it by hand if it should go.` : null });
15902
15989
  const optedOut = facts?.brainState?.optedOut === true || facts?.lead.doNotDisturb === true || known.optedOut === true;
15903
15990
  if (outcome.ok) {
15904
- if (outcome.answer.decision === "send") return optedOut ? { send: "no", why: STOPPED_WHY, judgmentId, held: false, task: null } : { send: "yes", why: outcome.answer.reason, judgmentId, held: false, task: null };
15991
+ if (outcome.answer.decision === "send") {
15992
+ if (optedOut) return { send: "no", why: STOPPED_WHY, judgmentId, held: false, task: null };
15993
+ if (unsureSendHolds(outcome.answer, kind)) return hold(UNSURE_HOLD_WHY);
15994
+ return { send: "yes", why: outcome.answer.reason, judgmentId, held: false, task: null };
15995
+ }
15905
15996
  return { send: "no", why: outcome.answer.reason, judgmentId, held: false, task: null };
15906
15997
  }
15907
15998
  const policy = kind === "reminder" ? rule.reminderWhenUnjudged : rule.askWhenUnjudged;
@@ -15929,14 +16020,18 @@ function createAnthropicJudgeModel(opts) {
15929
16020
  } catch (error51) {
15930
16021
  return failed(error51 instanceof Error && (error51.name === "TimeoutError" || error51.name === "AbortError") ? "the model did not answer in time" : "the model could not be reached", true);
15931
16022
  }
15932
- if (!res.ok) return failed(`the model answered ${res.status}`);
16023
+ if (!res.ok) {
16024
+ const refused = anthropicRefusal(res.status, await responseText(res), JUDGE_MODEL);
16025
+ const out = failed(refused ? `${REFUSAL_PREFIX[refused.cause]}: ${refused.reason}` : `the model answered ${res.status}`);
16026
+ return refused ? { ...out, outcome: { ...out.outcome, refused } } : out;
16027
+ }
15933
16028
  let body;
15934
16029
  try {
15935
16030
  body = await res.json();
15936
16031
  } catch {
15937
16032
  return failed("the model's reply was not JSON", true);
15938
16033
  }
15939
- return { outcome: readJudgeResponse(body), ms: clock3() - t0 };
16034
+ return { outcome: { ...readJudgeResponse(body), keyWorked: true }, ms: clock3() - t0 };
15940
16035
  }
15941
16036
  };
15942
16037
  }
@@ -17895,6 +17990,15 @@ ${fence(latest)}
17895
17990
  // src/brain/responder.ts
17896
17991
  var mappingWhere = (clientName, ids) => `Open the cockpit, Brain, client ${clientName}, proposal ${ids.map((id) => `#${id}`).join(", ")}, and confirm it with Touch ID.`;
17897
17992
  var Reservation = external_exports.object({ ok: external_exports.boolean(), id: external_exports.number().int().optional(), why: external_exports.string().optional() });
17993
+ var DRAFT_UNREADABLE_REASON = "Anthropic's model answered, but not with a reply the brain could use";
17994
+ var NOTE_READ_BACK_FAILED = "the note was written, but reading it back from GoHighLevel failed";
17995
+ function draftFailureReason(e) {
17996
+ if (e instanceof AnthropicRefusedError) return `${REFUSAL_PREFIX[e.refusal.cause]}: ${e.refusal.reason}`;
17997
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) return "Anthropic's model did not answer in time";
17998
+ const status = e instanceof Error ? /^the model answered (\d{3})$/.exec(e.message)?.[1] : void 0;
17999
+ if (status) return `Anthropic's model answered with an error (${status})`;
18000
+ return "Anthropic's model could not be reached, or its reply could not be read";
18001
+ }
17898
18002
  var Stop = class extends Error {
17899
18003
  constructor(outcome, why) {
17900
18004
  super(why);
@@ -17935,6 +18039,7 @@ async function runResponder(deps, client, input) {
17935
18039
  let modelCalls = 0;
17936
18040
  const typeFlags = [];
17937
18041
  let reservedId = null;
18042
+ let keySignal = null;
17938
18043
  const maxTokens = deps.env.maxOutputTokens ?? 600;
17939
18044
  const log = {
17940
18045
  brief: { stage: "not built" },
@@ -17974,6 +18079,29 @@ async function runResponder(deps, client, input) {
17974
18079
  log.noteId = (await writeNoteMarked(`${NOTE_HEADINGS.held} ${why}`)).id;
17975
18080
  throw new Stop("held", why);
17976
18081
  };
18082
+ const noteDraftFailed = async (reason) => {
18083
+ typeFlags.push({ check: DRAFT_FAILED_CHECK, pass: false, why: cut(reason, 300) });
18084
+ if (Date.now() > deadline) {
18085
+ typeFlags.push({ check: "note_read_back", pass: false, why: "no time was left in the run to write the note" });
18086
+ return;
18087
+ }
18088
+ const note = draftFailedNote(reason);
18089
+ let written;
18090
+ try {
18091
+ written = await writeNoteMarked(note);
18092
+ log.noteId = written.id;
18093
+ } catch {
18094
+ typeFlags.push({ check: "note_read_back", pass: false, why: "the note saying the draft failed could not be written to GoHighLevel" });
18095
+ return;
18096
+ }
18097
+ try {
18098
+ const notes = await ghl.readNotes(input.contactId);
18099
+ const arrived = notes.some((n) => written.id !== null && n.id === written.id || n.body === note);
18100
+ typeFlags.push(arrived ? { check: "note_read_back", pass: true } : { check: "note_read_back", pass: false, why: "the note was not found in GoHighLevel after writing it" });
18101
+ } catch {
18102
+ typeFlags.push({ check: "note_read_back", pass: false, why: NOTE_READ_BACK_FAILED });
18103
+ }
18104
+ };
17977
18105
  const body = async () => {
17978
18106
  if (!deps.env.responderEnabled) throw new Stop("skipped_off", "BRAIN_RESPONDER_ENABLED is off");
17979
18107
  if (client.responderMode === "off") throw new Stop("skipped_off", "responder is off for this client");
@@ -18094,11 +18222,22 @@ async function runResponder(deps, client, input) {
18094
18222
  log.model = deps.env.modelId;
18095
18223
  const modelBudget = Math.min(RUN_LIMITS.modelTimeoutMs, deadline - Date.now() - 2e3);
18096
18224
  if (modelBudget <= 0) throw new Stop("error", `per-run limit: no time left for the model within ${RUN_LIMITS.timeoutMs} ms`);
18097
- const result = await deps.model.draft({ model: deps.env.modelId, system: prompt.system, user: prompt.user, maxTokens, timeoutMs: modelBudget, purposes });
18225
+ let result;
18226
+ try {
18227
+ result = await deps.model.draft({ model: deps.env.modelId, system: prompt.system, user: prompt.user, maxTokens, timeoutMs: modelBudget, purposes });
18228
+ keySignal = { ok: true, model: deps.env.modelId };
18229
+ } catch (e) {
18230
+ if (e instanceof AnthropicRefusedError) keySignal = { ok: false, refusal: e.refusal };
18231
+ await noteDraftFailed(draftFailureReason(e));
18232
+ throw new Stop("error", e instanceof Error ? cut(e.message, 200) : "unexpected failure");
18233
+ }
18098
18234
  log.inputTokens = result.inputTokens;
18099
18235
  log.outputTokens = result.outputTokens;
18100
18236
  log.cost = costUsd(deps.env.modelId, result.inputTokens, result.outputTokens) ?? 0;
18101
- if (!result.answer) throw new Stop("error", "the model's answer was not the expected shape");
18237
+ if (!result.answer) {
18238
+ await noteDraftFailed(DRAFT_UNREADABLE_REASON);
18239
+ throw new Stop("error", "the model's answer was not the expected shape");
18240
+ }
18102
18241
  if (!wellFormed(result.answer)) await refuseBroken("The model's reply");
18103
18242
  log.draft = result.answer.reply;
18104
18243
  log.purpose = result.answer.purpose;
@@ -18293,6 +18432,7 @@ ${stamp}`;
18293
18432
  } finally {
18294
18433
  if (timer) clearTimeout(timer);
18295
18434
  }
18435
+ await recordKeyHealth(deps.db, client, keySignal, now);
18296
18436
  const checks = toWellFormed([...typeFlags, ...log.why ? [...log.checks, { check: "outcome", pass: log.outcome === "drafted", why: log.why }] : log.checks]);
18297
18437
  log.draft = log.draft === null ? null : toWellFormed(log.draft);
18298
18438
  log.brief = toWellFormed(log.brief);
@@ -18739,6 +18879,12 @@ function createBrainHandler(deps) {
18739
18879
  }
18740
18880
 
18741
18881
  // src/brain/judge-runtime.ts
18882
+ var keySignalOf = (o) => {
18883
+ if (!o.ok && o.refused) return { ok: false, refusal: o.refused };
18884
+ if (!o.keyWorked) return null;
18885
+ const answered2 = o.model ?? JUDGE_MODEL;
18886
+ return { ok: true, model: answered2.startsWith(JUDGE_MODEL) ? JUDGE_MODEL : answered2 };
18887
+ };
18742
18888
  function judgeWorstCost(prompt) {
18743
18889
  return costUsdAtMost(prompt.system.length + prompt.user.length, 4e3);
18744
18890
  }
@@ -18867,7 +19013,7 @@ async function judgeOnce(deps, client, contactId, message, kind, source, facts,
18867
19013
  const timings = { total: ms2(clock2() - timing.startedAt), db: ms2(db2), ghl: ms2(timing.ghl), license: ms2(licence2?.ms), model: ms2(modelMs) };
18868
19014
  const a = outcome.ok ? outcome.answer : null;
18869
19015
  const given = timing.answered?.(outcome) ?? workflowAnswer(outcome, null, kind, facts);
18870
- const held = !outcome.ok && given.held;
19016
+ const held = given.held;
18871
19017
  const settleOnce = () => asRuntime(deps.db, client, (tx) => tx.query(
18872
19018
  // `::text::jsonb`: the live driver (postgres.js) JSON-encodes a parameter typed jsonb, so a plain `::jsonb` stored these
18873
19019
  // as jsonb STRINGS on brain-demo (judgments 1-18, found 2026-10-29: every timing read 0). See payload-string-2026-09-27.
@@ -18892,8 +19038,12 @@ async function judgeOnce(deps, client, contactId, message, kind, source, facts,
18892
19038
  ]
18893
19039
  ));
18894
19040
  const settled = timing.claim ? retried(settleOnce, SETTLE_RETRY_MS) : settleOnce();
18895
- if (timing.claim) deps.waitUntil(settled.catch(() => void 0));
18896
- else await settled;
19041
+ const recordKey = () => recordKeyHealth(deps.db, client, keySignalOf(judged.outcome), now, String(r.id));
19042
+ if (timing.claim) deps.waitUntil(settled.catch(() => void 0).then(recordKey));
19043
+ else {
19044
+ await settled;
19045
+ await recordKey();
19046
+ }
18897
19047
  return { outcome, id: String(r.id), settled };
18898
19048
  }
18899
19049
  var SETTLE_RETRY_MS = [1e3, 3e3];
@@ -19010,7 +19160,7 @@ async function askOnce(deps, askKeyHash, raw, arrivedAt, seen, finish) {
19010
19160
  { startedAt, db: dbMs, ghl: ghlMs, licence: licenceCheck, deadlineAt, claim: (o, judgmentId) => finish(reply2(o, judgmentId)), answered: (o) => reply2(o, null) }
19011
19161
  );
19012
19162
  const answer = reply2(outcome, id);
19013
- if (outcome.ok && outcome.answer.decision === "dont_send" && id !== null && answer.send === "no") {
19163
+ if (outcome.ok && (outcome.answer.decision === "dont_send" || answer.why === UNSURE_HOLD_WHY) && id !== null && answer.send === "no") {
19014
19164
  const port = value.ghl;
19015
19165
  const verdict = outcome.answer;
19016
19166
  const write = (p) => p.writeNote(contactId, judgeNote(id, message, verdict));
@@ -19067,7 +19217,8 @@ async function judgeReplay(deps, client, raw = null) {
19067
19217
  for (const c of cases) {
19068
19218
  const { outcome } = await judgeOnce(deps, client, c.ghl_contact_id, c.message, c.kind, "replay", c.facts, ctx, now);
19069
19219
  const decision = outcome.ok ? outcome.answer.decision : null;
19070
- results.push({ judgmentId: String(c.id), owner: c.owner, before: c.decision, now: decision, agrees: decision === c.owner, why: outcome.ok ? outcome.answer.reason : outcome.why });
19220
+ const did = outcome.ok && unsureSendHolds(outcome.answer, c.kind) ? "dont_send" : decision;
19221
+ results.push({ judgmentId: String(c.id), owner: c.owner, before: c.decision, now: decision, wouldDo: did, agrees: did === c.owner, why: outcome.ok ? outcome.answer.reason : outcome.why });
19071
19222
  }
19072
19223
  const left = Math.max(0, page.total - cases.length);
19073
19224
  return { status: 200, body: { cases: results.length, agree: results.filter((r) => r.agrees).length, results, left, nextAfterId: left && cases.length ? String(cases[cases.length - 1].id) : null } };