@sjawhar/pi-legion-envoy 5.30.3 → 5.30.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/envoy.js +220 -46
  2. package/dist/legion.js +142 -24
  3. package/package.json +1 -1
package/dist/envoy.js CHANGED
@@ -30475,6 +30475,23 @@ var EnvelopeSchema = exports_external.object({
30475
30475
  urgency: exports_external.enum(["low", "med", "high", "blocking"]).optional(),
30476
30476
  expects_reply: exports_external.enum(["none", "optional", "required"]).optional()
30477
30477
  });
30478
+ var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
30479
+ var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
30480
+ function dedupeKeyNamesItsEvent(envelope) {
30481
+ const key = envelope.dedupe_key;
30482
+ if (key === undefined)
30483
+ return false;
30484
+ if (envelope.source === "dispatch" || mintedDedupeKey.test(key))
30485
+ return true;
30486
+ switch (envelope.source) {
30487
+ case "github":
30488
+ case "slack":
30489
+ case "ghostwispr":
30490
+ return envelope.source_event_id !== undefined && envelope.source_event_id !== "" && key === `${envelope.source}.${envelope.source_event_id}`;
30491
+ default:
30492
+ return false;
30493
+ }
30494
+ }
30478
30495
  // ../contracts/src/handoff-schema.ts
30479
30496
  var HANDOFF_SCHEMA_VERSION = 1;
30480
30497
  var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
@@ -31612,6 +31629,7 @@ var InboundSenderSchema = exports_external.object({
31612
31629
  var InboundEnvelopeSchema = exports_external.object({
31613
31630
  event_id: exports_external.string().optional(),
31614
31631
  source: exports_external.string(),
31632
+ source_event_id: exports_external.string().optional(),
31615
31633
  source_session: exports_external.string().optional(),
31616
31634
  topic: exports_external.string().optional(),
31617
31635
  dedupe_key: exports_external.string().optional(),
@@ -31655,13 +31673,72 @@ async function postDeliveryReply(config2, sessionId, delivery, result) {
31655
31673
  function expectsLaneReceipt(frame) {
31656
31674
  return frame.reply !== undefined && frame.reply !== "" && frame.subject === frame.directSubject && frame.envelopeTopic !== undefined && frame.envelopeTopic !== frame.directSubject;
31657
31675
  }
31658
- function rememberBounded(seen, key, limit) {
31659
- seen.add(key);
31660
- if (seen.size > limit) {
31661
- const oldest = seen.values().next();
31662
- if (!oldest.done)
31663
- seen.delete(oldest.value);
31664
- }
31676
+ var DedupeIdentitySchema = exports_external.object({
31677
+ event_id: exports_external.string().min(1).optional(),
31678
+ dedupe_key: exports_external.string().min(1).optional(),
31679
+ source: exports_external.string().optional(),
31680
+ source_event_id: exports_external.string().optional()
31681
+ });
31682
+ var DELIVERY_DEDUPE_KEY_LIMIT = 250000;
31683
+ var DELIVERY_EVENT_ID_LIMIT = 1e4;
31684
+ function createDeliveryDedupe(now = Date.now) {
31685
+ const keys = new Map;
31686
+ let events = new Map;
31687
+ let olderEvents = new Map;
31688
+ let claims = 0;
31689
+ return {
31690
+ claim(frame) {
31691
+ const eventId = frame?.event_id;
31692
+ if (eventId !== undefined && (events.has(eventId) || olderEvents.has(eventId))) {
31693
+ return;
31694
+ }
31695
+ const key = frame?.dedupe_key;
31696
+ const named = frame !== undefined && key !== undefined && dedupeKeyNamesItsEvent(frame);
31697
+ const at = now();
31698
+ if (named) {
31699
+ const claimedAt = keys.get(key);
31700
+ if (claimedAt !== undefined && at - claimedAt < DELIVERY_DUPLICATE_WINDOW_MS) {
31701
+ return;
31702
+ }
31703
+ for (const [oldest, deliveredAt] of keys) {
31704
+ if (at - deliveredAt < DELIVERY_DUPLICATE_WINDOW_MS)
31705
+ break;
31706
+ keys.delete(oldest);
31707
+ }
31708
+ keys.delete(key);
31709
+ if (keys.size >= DELIVERY_DEDUPE_KEY_LIMIT) {
31710
+ const oldest = keys.keys().next();
31711
+ if (oldest.done !== true)
31712
+ keys.delete(oldest.value);
31713
+ }
31714
+ keys.set(key, at);
31715
+ }
31716
+ const claim = ++claims;
31717
+ if (eventId !== undefined) {
31718
+ if (events.size >= DELIVERY_EVENT_ID_LIMIT / 2) {
31719
+ olderEvents = events;
31720
+ events = new Map;
31721
+ }
31722
+ events.set(eventId, claim);
31723
+ }
31724
+ let released = false;
31725
+ return {
31726
+ release() {
31727
+ if (released)
31728
+ return;
31729
+ released = true;
31730
+ if (named && keys.get(key) === at)
31731
+ keys.delete(key);
31732
+ if (eventId === undefined)
31733
+ return;
31734
+ if (events.get(eventId) === claim)
31735
+ events.delete(eventId);
31736
+ if (olderEvents.get(eventId) === claim)
31737
+ olderEvents.delete(eventId);
31738
+ }
31739
+ };
31740
+ }
31741
+ };
31665
31742
  }
31666
31743
  function isCommentTargetedDelivery(delivery) {
31667
31744
  return "comment_id" in delivery;
@@ -32386,6 +32463,7 @@ class DispatchServiceError extends Error {
32386
32463
  current;
32387
32464
  mismatches;
32388
32465
  name = "DispatchServiceError";
32466
+ fromDispatch = true;
32389
32467
  constructor(code, status, message, candidates, current, mismatches) {
32390
32468
  super(message);
32391
32469
  this.code = code;
@@ -32395,6 +32473,21 @@ class DispatchServiceError extends Error {
32395
32473
  this.mismatches = mismatches;
32396
32474
  }
32397
32475
  }
32476
+
32477
+ class DispatchGatewayError extends DispatchServiceError {
32478
+ answer;
32479
+ advice;
32480
+ fromDispatch = false;
32481
+ transient;
32482
+ mayHaveReachedDispatch;
32483
+ constructor(status, answer, advice, message = `${answer}, so ${advice}.`) {
32484
+ super(`HTTP_${status}`, status, message);
32485
+ this.answer = answer;
32486
+ this.advice = advice;
32487
+ this.transient = transientStatus(status);
32488
+ this.mayHaveReachedDispatch = reachesDispatch(status);
32489
+ }
32490
+ }
32398
32491
  function asErrorShape(value) {
32399
32492
  return typeof value === "object" && value !== null ? value : {};
32400
32493
  }
@@ -32408,7 +32501,7 @@ function requestSignal(signal) {
32408
32501
  }
32409
32502
  function whyNotAPage(answer) {
32410
32503
  if (typeof answer === "string") {
32411
- return "text that is not a JSON page, which looks like a proxy or gateway page rather than " + "Dispatch's own answer, so a retry may succeed.";
32504
+ return `text that is not a JSON page, ${GATEWAY_PAGE}, so a retry may succeed.`;
32412
32505
  }
32413
32506
  let what;
32414
32507
  if (Array.isArray(answer)) {
@@ -32427,6 +32520,53 @@ function whyNotAPage(answer) {
32427
32520
  }
32428
32521
  return `${what}. Retrying will not help: the same request gets the same answer until that ` + "Dispatch is upgraded or fixed.";
32429
32522
  }
32523
+ var GATEWAY_PAGE = "which looks like a proxy or gateway page rather than Dispatch's own answer";
32524
+ function gatewayAdvice(method, status) {
32525
+ if (!transientStatus(status)) {
32526
+ return "a retry gets the same answer until the Dispatch URL, or whatever answers in its place, is fixed";
32527
+ }
32528
+ if (method === "GET")
32529
+ return "a retry may succeed";
32530
+ return reachesDispatch(status) ? "the write may or may not have reached Dispatch: check whether it took effect before retrying it" : "the write did not reach Dispatch, and a retry may succeed";
32531
+ }
32532
+ function transientStatus(status) {
32533
+ return status >= 500 || status === 408 || status === 429;
32534
+ }
32535
+ function reachesDispatch(status) {
32536
+ return status >= 500;
32537
+ }
32538
+ var REDACTED = "[redacted]";
32539
+ var EXCERPT_SCAN_LIMIT = 64 * 1024;
32540
+ var BEARER_PIECE = 8;
32541
+ function redactBearer(text, bearer) {
32542
+ if (bearer === "")
32543
+ return text.slice(0, EXCERPT_SCAN_LIMIT);
32544
+ const length = Math.min(BEARER_PIECE, bearer.length);
32545
+ const scanned = text.slice(0, EXCERPT_SCAN_LIMIT + length - 1);
32546
+ const covered = new Uint8Array(scanned.length);
32547
+ const pieces = new Set;
32548
+ for (let at = 0;at + length <= bearer.length; at++)
32549
+ pieces.add(bearer.slice(at, at + length));
32550
+ for (const piece of pieces) {
32551
+ let found = scanned.indexOf(piece);
32552
+ while (found !== -1) {
32553
+ covered.fill(1, found, found + length);
32554
+ found = scanned.indexOf(piece, found + 1);
32555
+ }
32556
+ }
32557
+ let redacted = "";
32558
+ let kept = 0;
32559
+ for (let start = covered.indexOf(1);start !== -1; start = covered.indexOf(1, kept)) {
32560
+ redacted += `${scanned.slice(kept, start)}${REDACTED}`;
32561
+ const end = covered.indexOf(0, start);
32562
+ kept = end === -1 ? scanned.length : end;
32563
+ }
32564
+ return `${redacted}${scanned.slice(kept, EXCERPT_SCAN_LIMIT)}`.slice(0, EXCERPT_SCAN_LIMIT);
32565
+ }
32566
+ function excerpt(text, token) {
32567
+ const plain = redactBearer(text, token.trim()).replace(/<(script|style)\b[^<>]*>(?:[\s\S]*?<\/\1\s*>|[\s\S]*$)/gi, " ").replace(/<[^<>]*>/g, " ");
32568
+ return textHead(plain.replace(/(\bauthorization["']?\s*[:=]\s*["']?(?:(?:bearer|basic|digest|token)\s+)?)[^\s"'<>,;]+/gi, `$1${REDACTED}`).replace(/(\bbearer\s+)[^\s"'<>,;]+/gi, `$1${REDACTED}`));
32569
+ }
32430
32570
 
32431
32571
  class DispatchClient {
32432
32572
  token;
@@ -32684,22 +32824,24 @@ class DispatchClient {
32684
32824
  };
32685
32825
  if (body !== undefined)
32686
32826
  headers["Content-Type"] = "application/json";
32687
- const response = await this.fetchImpl(this.#url(path2, query), {
32827
+ const url2 = this.#url(path2, query);
32828
+ const response = await this.fetchImpl(url2, {
32688
32829
  method,
32689
32830
  headers,
32690
32831
  signal: this.#signal,
32691
32832
  ...body === undefined ? {} : { body: JSON.stringify(body) }
32692
32833
  });
32693
- return this.#response(response);
32834
+ return this.#response(method, url2, response);
32694
32835
  }
32695
32836
  async#form(method, path2, body) {
32696
- const response = await this.fetchImpl(this.#url(path2), {
32837
+ const url2 = this.#url(path2);
32838
+ const response = await this.fetchImpl(url2, {
32697
32839
  method,
32698
32840
  headers: { Accept: "application/json", Authorization: `Bearer ${this.token}` },
32699
32841
  body,
32700
32842
  signal: this.#signal
32701
32843
  });
32702
- return this.#response(response);
32844
+ return this.#response(method, url2, response);
32703
32845
  }
32704
32846
  #url(path2, query) {
32705
32847
  const url2 = new URL(`${path2.map((segment) => encodeURIComponent(segment)).join("/")}`, `${this.#baseUrl}/`);
@@ -32715,7 +32857,7 @@ class DispatchClient {
32715
32857
  }
32716
32858
  return url2.toString();
32717
32859
  }
32718
- async#response(response) {
32860
+ async#response(method, url2, response) {
32719
32861
  const text = await response.text();
32720
32862
  let payload = text;
32721
32863
  if (text && isJson(response)) {
@@ -32727,7 +32869,23 @@ class DispatchClient {
32727
32869
  }
32728
32870
  if (!response.ok) {
32729
32871
  const error48 = asErrorShape(payload);
32730
- throw new DispatchServiceError(error48.code ?? `HTTP_${response.status}`, response.status, error48.error ?? (typeof payload === "string" && payload ? payload : response.statusText), error48.candidates, error48.current, error48.mismatches);
32872
+ if (typeof error48.error === "string") {
32873
+ throw new DispatchServiceError(error48.code ?? `HTTP_${response.status}`, response.status, error48.error, error48.candidates, error48.current, error48.mismatches);
32874
+ }
32875
+ const quoted = excerpt(text, this.token);
32876
+ let body;
32877
+ if (quoted !== "") {
32878
+ body = `a body that is not Dispatch's error JSON (${JSON.stringify(quoted)})`;
32879
+ } else if (text === "") {
32880
+ body = "an empty body";
32881
+ } else {
32882
+ const within = text.length > EXCERPT_SCAN_LIMIT ? ` in its first ${EXCERPT_SCAN_LIMIT / 1024} KiB` : "";
32883
+ const size = Buffer.byteLength(text).toLocaleString("en-US");
32884
+ body = `a body of ${size} bytes and no readable text${within}`;
32885
+ }
32886
+ const reasonPhrase = excerpt(response.statusText, this.token);
32887
+ const reason = reasonPhrase === "" ? "" : ` ${reasonPhrase}`;
32888
+ throw new DispatchGatewayError(response.status, `${method} ${url2} answered ${response.status}${reason} with ${body}, ${GATEWAY_PAGE}`, gatewayAdvice(method, response.status));
32731
32889
  }
32732
32890
  return payload;
32733
32891
  }
@@ -33447,7 +33605,7 @@ async function resolveArtifact(client, owner, artifactReference, { canonical = f
33447
33605
  throw new Error("artifact is required for a project document");
33448
33606
  }
33449
33607
  const routed = await client.getProjectArtifact(owner.project, artifactReference).catch((error48) => {
33450
- if (!(error48 instanceof DispatchServiceError) || error48.status !== 404)
33608
+ if (!dispatchAnswered(error48, 404))
33451
33609
  throw error48;
33452
33610
  return;
33453
33611
  });
@@ -33618,12 +33776,12 @@ function referenceLines(edges) {
33618
33776
  if (edges.length === 0)
33619
33777
  return ["- none"];
33620
33778
  return edges.map((edge) => {
33621
- const excerpt = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
33622
- return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt}${edge.created_at})`;
33779
+ const excerpt2 = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
33780
+ return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt2}${edge.created_at})`;
33623
33781
  });
33624
33782
  }
33625
33783
  function unavailableReason(error48) {
33626
- return error48 instanceof DispatchServiceError && error48.status === 404 ? "unavailable" : `unavailable: ${messageFor(error48)}`;
33784
+ return dispatchAnswered(error48, 404) ? "unavailable" : `unavailable: ${messageFor(error48)}`;
33627
33785
  }
33628
33786
  async function graphEdges(client, query) {
33629
33787
  try {
@@ -33813,9 +33971,8 @@ async function openArtifactMarks(client, resolved) {
33813
33971
  const marks = asks.filter((ask) => ask.state === "open" && ask.anchor?.artifact_id === resolved.artifact.id).map((ask) => `ask ${ask.id}`);
33814
33972
  const commentsResult = await commentsResultPromise;
33815
33973
  if (commentsResult.status === "rejected") {
33816
- if (commentsResult.reason instanceof DispatchServiceError && commentsResult.reason.status === 404) {
33974
+ if (dispatchAnswered(commentsResult.reason, 404))
33817
33975
  return marks;
33818
- }
33819
33976
  throw commentsResult.reason;
33820
33977
  }
33821
33978
  return [
@@ -33902,10 +34059,30 @@ async function refuseRemovingOpenDecisionBlocks(client, tool, resolved, ops) {
33902
34059
  `));
33903
34060
  }
33904
34061
  function refusalWithCode(error48, suffix = "") {
34062
+ if (error48 instanceof DispatchGatewayError) {
34063
+ let told = error48.message;
34064
+ if (suffix !== "") {
34065
+ told = error48.mayHaveReachedDispatch ? `${error48.answer}${suffix}` : `${error48.answer}, so ${error48.advice}${suffix}`;
34066
+ }
34067
+ return new DispatchGatewayError(error48.status, error48.answer, error48.advice, `${error48.code}: ${told}`);
34068
+ }
33905
34069
  if (!(error48 instanceof DispatchServiceError))
33906
34070
  return error48;
33907
34071
  return new DispatchServiceError(error48.code, error48.status, `${error48.code}: ${error48.message}${suffix}`, error48.candidates, error48.current, error48.mismatches);
33908
34072
  }
34073
+ function dispatchAnswered(error48, status) {
34074
+ return error48 instanceof DispatchServiceError && error48.fromDispatch && error48.status === status;
34075
+ }
34076
+ function writeMayHaveLanded(error48) {
34077
+ if (error48 instanceof DispatchGatewayError)
34078
+ return error48.mayHaveReachedDispatch;
34079
+ return !(error48 instanceof DispatchServiceError) || error48.status >= 500;
34080
+ }
34081
+ function withAccount(error48, account) {
34082
+ const message = messageFor(error48);
34083
+ const told = /[.!?]$/.test(message) ? `${message} ${account.charAt(0).toUpperCase()}${account.slice(1)}` : `${message}; ${account}`;
34084
+ return new Error(told, { cause: error48 });
34085
+ }
33909
34086
  async function executeDispatchTool(input) {
33910
34087
  const configUrl = input.config.url;
33911
34088
  const configToken = input.config.token;
@@ -34075,7 +34252,10 @@ async function executeDispatchTool(input) {
34075
34252
  ref: dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id)
34076
34253
  };
34077
34254
  } catch (error48) {
34078
- throw refusalWithCode(error48, "; the reason was not posted, so the close was not sent");
34255
+ const told = writeMayHaveLanded(error48) ? "the reason may or may not have been posted, and the close was not sent: read the issue's messages before retrying, since retrying this call posts its reason again" : "the reason was not posted, so the close was not sent";
34256
+ if (error48 instanceof DispatchServiceError)
34257
+ throw refusalWithCode(error48, `; ${told}`);
34258
+ throw withAccount(error48, told);
34079
34259
  }
34080
34260
  }
34081
34261
  const linked = before.external_links.map((link) => link.url);
@@ -34094,17 +34274,15 @@ async function executeDispatchTool(input) {
34094
34274
  actor
34095
34275
  });
34096
34276
  } catch (error48) {
34097
- const taken = error48 instanceof DispatchServiceError && error48.status === 500 && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
34277
+ const taken = dispatchAnswered(error48, 500) && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
34098
34278
  if (closingNote === undefined)
34099
34279
  throw refusalWithCode(error48, taken);
34100
- const refused = error48 instanceof DispatchServiceError && error48.status < 500;
34101
- const posted = `; the reason already landed as message ${closingNote.id} (${closingNote.ref})`;
34102
- const landed = refused ? `${posted} but the issue did not close. Retrying this call posts its reason again, so fix what refused the close, then retry with a reason that points at message ${closingNote.id}` : `${posted}, and the close may or may not have taken effect. Read the issue's status before retrying: done means it closed; otherwise retry with a reason that points at message ${closingNote.id}, since retrying this call posts its reason again`;
34280
+ const posted = `the reason already landed as message ${closingNote.id} (${closingNote.ref})`;
34281
+ const fix = error48 instanceof DispatchGatewayError && error48.transient ? "" : "fix what refused the close, then ";
34282
+ const landed = writeMayHaveLanded(error48) ? `${posted}, and the close may or may not have taken effect. Read the issue's status before retrying: done means it closed; otherwise retry with a reason that points at message ${closingNote.id}, since retrying this call posts its reason again` : `${posted} but the issue did not close. Retrying this call posts its reason again, so ${fix}retry with a reason that points at message ${closingNote.id}`;
34103
34283
  if (error48 instanceof DispatchServiceError)
34104
- throw refusalWithCode(error48, taken + landed);
34105
- throw new Error(`${error48 instanceof Error ? error48.message : String(error48)}${landed}`, {
34106
- cause: error48
34107
- });
34284
+ throw refusalWithCode(error48, `${taken}; ${landed}`);
34285
+ throw withAccount(error48, landed);
34108
34286
  }
34109
34287
  const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
34110
34288
  const changes = [
@@ -34708,7 +34886,7 @@ async function resolveExistingIssue(client, issueReference) {
34708
34886
  try {
34709
34887
  return await client.resolveIssue(issueReference);
34710
34888
  } catch (error48) {
34711
- if (error48 instanceof DispatchServiceError && error48.status === 404) {
34889
+ if (dispatchAnswered(error48, 404)) {
34712
34890
  throw new Error(`no Dispatch issue is linked to ${issueReference}; create it first with ` + `dispatch_issue({ external: "${issueReference}", ... })`);
34713
34891
  }
34714
34892
  throw error48;
@@ -35893,7 +36071,7 @@ function envoyExtension(pi) {
35893
36071
  };
35894
36072
  const client = createEnvoyClient({ baseUrl: defaults.envoyUrl, fetch });
35895
36073
  const subscriptions = new Map;
35896
- const dedupeKeys = new Set;
36074
+ const delivered = createDeliveryDedupe();
35897
36075
  let connection;
35898
36076
  let sessionDirectory = "";
35899
36077
  let sessionID = "";
@@ -36051,9 +36229,8 @@ function envoyExtension(pi) {
36051
36229
  }
36052
36230
  for (const topic of subscriptionRemovedTopics(raw, sessionID) ?? [])
36053
36231
  closeIntentionally(topic);
36054
- const dedupeKey = rendered.envelope?.dedupe_key;
36055
- const duplicate = dedupeKey !== undefined && dedupeKeys.has(dedupeKey);
36056
- if (!duplicate && !rendered.skip) {
36232
+ const claim2 = rendered.skip ? undefined : delivered.claim(rendered.envelope);
36233
+ if (claim2 !== undefined) {
36057
36234
  const envelope2 = rendered.envelope;
36058
36235
  if (envelope2 !== undefined) {
36059
36236
  inbox.unshift({
@@ -36065,30 +36242,28 @@ function envoyExtension(pi) {
36065
36242
  if (inbox.length > 50)
36066
36243
  inbox.pop();
36067
36244
  }
36245
+ const refuse = async (delivery, error48) => {
36246
+ claim2.release();
36247
+ await postDispatchReply(delivery, { error: error48 });
36248
+ };
36068
36249
  try {
36069
36250
  if (rendered.rejectedDelivery !== undefined) {
36070
36251
  console.warn(`[envoy] rejecting malformed Dispatch targeted delivery ${rendered.rejectedDelivery.id}`);
36071
- await postDispatchReply(rendered.rejectedDelivery, {
36072
- error: "Invalid Dispatch targeted delivery frame"
36073
- });
36252
+ await refuse(rendered.rejectedDelivery, "Invalid Dispatch targeted delivery frame");
36074
36253
  } else if (rendered.malformedDelivery === true) {
36075
36254
  console.warn("[envoy] dropping malformed Dispatch targeted delivery without a reply address");
36076
36255
  } else if (rendered.delivery?.mode === "btw") {
36077
36256
  const answer = sideTurn(pi, activeSessionContext);
36078
36257
  if (shuttingDown) {
36079
- await postDispatchReply(rendered.delivery, {
36080
- error: "This OMP session is shutting down"
36081
- });
36258
+ await refuse(rendered.delivery, "This OMP session is shutting down");
36082
36259
  } else if (answer === undefined) {
36083
- await postDispatchReply(rendered.delivery, {
36084
- error: "This OMP host does not support BTW delivery"
36085
- });
36260
+ await refuse(rendered.delivery, "This OMP host does not support BTW delivery");
36086
36261
  } else {
36087
36262
  try {
36088
36263
  const reply2 = await answer({ prompt: rendered.delivery.body });
36089
36264
  await postDispatchReply(rendered.delivery, { body: reply2.replyText });
36090
36265
  } catch (error48) {
36091
- await postDispatchReply(rendered.delivery, { error: messageFor(error48) });
36266
+ await refuse(rendered.delivery, messageFor(error48));
36092
36267
  }
36093
36268
  }
36094
36269
  } else {
@@ -36104,11 +36279,10 @@ function envoyExtension(pi) {
36104
36279
  }
36105
36280
  }
36106
36281
  } catch (error48) {
36282
+ claim2.release();
36107
36283
  console.warn(`[envoy] failed to deliver envelope ${envelope2?.event_id ?? "unknown"}`, error48);
36108
36284
  throw error48;
36109
36285
  }
36110
- if (dedupeKey !== undefined)
36111
- rememberBounded(dedupeKeys, dedupeKey, 1000);
36112
36286
  }
36113
36287
  };
36114
36288
  const RESUBSCRIBE_DELAY_MS = Number(process.env.ENVOY_RESUBSCRIBE_DELAY_MS ?? "") || 5000;
package/dist/legion.js CHANGED
@@ -30644,6 +30644,23 @@ var EnvelopeSchema = exports_external.object({
30644
30644
  urgency: exports_external.enum(["low", "med", "high", "blocking"]).optional(),
30645
30645
  expects_reply: exports_external.enum(["none", "optional", "required"]).optional()
30646
30646
  });
30647
+ var MINTED_DEDUPE_KEY_PATTERN = "^(?:envoy\\.role\\.forward\\.)?(?:publish|agent\\.[^.]+)\\.(?:[0-9a-f]{32}|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$";
30648
+ var mintedDedupeKey = new RegExp(MINTED_DEDUPE_KEY_PATTERN);
30649
+ function dedupeKeyNamesItsEvent(envelope) {
30650
+ const key = envelope.dedupe_key;
30651
+ if (key === undefined)
30652
+ return false;
30653
+ if (envelope.source === "dispatch" || mintedDedupeKey.test(key))
30654
+ return true;
30655
+ switch (envelope.source) {
30656
+ case "github":
30657
+ case "slack":
30658
+ case "ghostwispr":
30659
+ return envelope.source_event_id !== undefined && envelope.source_event_id !== "" && key === `${envelope.source}.${envelope.source_event_id}`;
30660
+ default:
30661
+ return false;
30662
+ }
30663
+ }
30647
30664
  // ../contracts/src/handoff-schema.ts
30648
30665
  var HANDOFF_SCHEMA_VERSION = 1;
30649
30666
  var HANDOFF_PHASES = ["architect", "plan", "implement", "test", "review"];
@@ -31291,6 +31308,7 @@ class DispatchServiceError extends Error {
31291
31308
  current;
31292
31309
  mismatches;
31293
31310
  name = "DispatchServiceError";
31311
+ fromDispatch = true;
31294
31312
  constructor(code, status, message, candidates, current, mismatches) {
31295
31313
  super(message);
31296
31314
  this.code = code;
@@ -31300,6 +31318,21 @@ class DispatchServiceError extends Error {
31300
31318
  this.mismatches = mismatches;
31301
31319
  }
31302
31320
  }
31321
+
31322
+ class DispatchGatewayError extends DispatchServiceError {
31323
+ answer;
31324
+ advice;
31325
+ fromDispatch = false;
31326
+ transient;
31327
+ mayHaveReachedDispatch;
31328
+ constructor(status, answer, advice, message = `${answer}, so ${advice}.`) {
31329
+ super(`HTTP_${status}`, status, message);
31330
+ this.answer = answer;
31331
+ this.advice = advice;
31332
+ this.transient = transientStatus(status);
31333
+ this.mayHaveReachedDispatch = reachesDispatch(status);
31334
+ }
31335
+ }
31303
31336
  function asErrorShape(value) {
31304
31337
  return typeof value === "object" && value !== null ? value : {};
31305
31338
  }
@@ -31313,7 +31346,7 @@ function requestSignal(signal) {
31313
31346
  }
31314
31347
  function whyNotAPage(answer) {
31315
31348
  if (typeof answer === "string") {
31316
- return "text that is not a JSON page, which looks like a proxy or gateway page rather than " + "Dispatch's own answer, so a retry may succeed.";
31349
+ return `text that is not a JSON page, ${GATEWAY_PAGE}, so a retry may succeed.`;
31317
31350
  }
31318
31351
  let what;
31319
31352
  if (Array.isArray(answer)) {
@@ -31332,6 +31365,53 @@ function whyNotAPage(answer) {
31332
31365
  }
31333
31366
  return `${what}. Retrying will not help: the same request gets the same answer until that ` + "Dispatch is upgraded or fixed.";
31334
31367
  }
31368
+ var GATEWAY_PAGE = "which looks like a proxy or gateway page rather than Dispatch's own answer";
31369
+ function gatewayAdvice(method, status) {
31370
+ if (!transientStatus(status)) {
31371
+ return "a retry gets the same answer until the Dispatch URL, or whatever answers in its place, is fixed";
31372
+ }
31373
+ if (method === "GET")
31374
+ return "a retry may succeed";
31375
+ return reachesDispatch(status) ? "the write may or may not have reached Dispatch: check whether it took effect before retrying it" : "the write did not reach Dispatch, and a retry may succeed";
31376
+ }
31377
+ function transientStatus(status) {
31378
+ return status >= 500 || status === 408 || status === 429;
31379
+ }
31380
+ function reachesDispatch(status) {
31381
+ return status >= 500;
31382
+ }
31383
+ var REDACTED = "[redacted]";
31384
+ var EXCERPT_SCAN_LIMIT = 64 * 1024;
31385
+ var BEARER_PIECE = 8;
31386
+ function redactBearer(text, bearer) {
31387
+ if (bearer === "")
31388
+ return text.slice(0, EXCERPT_SCAN_LIMIT);
31389
+ const length = Math.min(BEARER_PIECE, bearer.length);
31390
+ const scanned = text.slice(0, EXCERPT_SCAN_LIMIT + length - 1);
31391
+ const covered = new Uint8Array(scanned.length);
31392
+ const pieces = new Set;
31393
+ for (let at = 0;at + length <= bearer.length; at++)
31394
+ pieces.add(bearer.slice(at, at + length));
31395
+ for (const piece of pieces) {
31396
+ let found = scanned.indexOf(piece);
31397
+ while (found !== -1) {
31398
+ covered.fill(1, found, found + length);
31399
+ found = scanned.indexOf(piece, found + 1);
31400
+ }
31401
+ }
31402
+ let redacted = "";
31403
+ let kept = 0;
31404
+ for (let start = covered.indexOf(1);start !== -1; start = covered.indexOf(1, kept)) {
31405
+ redacted += `${scanned.slice(kept, start)}${REDACTED}`;
31406
+ const end = covered.indexOf(0, start);
31407
+ kept = end === -1 ? scanned.length : end;
31408
+ }
31409
+ return `${redacted}${scanned.slice(kept, EXCERPT_SCAN_LIMIT)}`.slice(0, EXCERPT_SCAN_LIMIT);
31410
+ }
31411
+ function excerpt(text, token) {
31412
+ const plain = redactBearer(text, token.trim()).replace(/<(script|style)\b[^<>]*>(?:[\s\S]*?<\/\1\s*>|[\s\S]*$)/gi, " ").replace(/<[^<>]*>/g, " ");
31413
+ return textHead(plain.replace(/(\bauthorization["']?\s*[:=]\s*["']?(?:(?:bearer|basic|digest|token)\s+)?)[^\s"'<>,;]+/gi, `$1${REDACTED}`).replace(/(\bbearer\s+)[^\s"'<>,;]+/gi, `$1${REDACTED}`));
31414
+ }
31335
31415
 
31336
31416
  class DispatchClient {
31337
31417
  token;
@@ -31589,22 +31669,24 @@ class DispatchClient {
31589
31669
  };
31590
31670
  if (body !== undefined)
31591
31671
  headers["Content-Type"] = "application/json";
31592
- const response = await this.fetchImpl(this.#url(path2, query), {
31672
+ const url2 = this.#url(path2, query);
31673
+ const response = await this.fetchImpl(url2, {
31593
31674
  method,
31594
31675
  headers,
31595
31676
  signal: this.#signal,
31596
31677
  ...body === undefined ? {} : { body: JSON.stringify(body) }
31597
31678
  });
31598
- return this.#response(response);
31679
+ return this.#response(method, url2, response);
31599
31680
  }
31600
31681
  async#form(method, path2, body) {
31601
- const response = await this.fetchImpl(this.#url(path2), {
31682
+ const url2 = this.#url(path2);
31683
+ const response = await this.fetchImpl(url2, {
31602
31684
  method,
31603
31685
  headers: { Accept: "application/json", Authorization: `Bearer ${this.token}` },
31604
31686
  body,
31605
31687
  signal: this.#signal
31606
31688
  });
31607
- return this.#response(response);
31689
+ return this.#response(method, url2, response);
31608
31690
  }
31609
31691
  #url(path2, query) {
31610
31692
  const url2 = new URL(`${path2.map((segment) => encodeURIComponent(segment)).join("/")}`, `${this.#baseUrl}/`);
@@ -31620,7 +31702,7 @@ class DispatchClient {
31620
31702
  }
31621
31703
  return url2.toString();
31622
31704
  }
31623
- async#response(response) {
31705
+ async#response(method, url2, response) {
31624
31706
  const text = await response.text();
31625
31707
  let payload = text;
31626
31708
  if (text && isJson(response)) {
@@ -31632,7 +31714,23 @@ class DispatchClient {
31632
31714
  }
31633
31715
  if (!response.ok) {
31634
31716
  const error48 = asErrorShape(payload);
31635
- throw new DispatchServiceError(error48.code ?? `HTTP_${response.status}`, response.status, error48.error ?? (typeof payload === "string" && payload ? payload : response.statusText), error48.candidates, error48.current, error48.mismatches);
31717
+ if (typeof error48.error === "string") {
31718
+ throw new DispatchServiceError(error48.code ?? `HTTP_${response.status}`, response.status, error48.error, error48.candidates, error48.current, error48.mismatches);
31719
+ }
31720
+ const quoted = excerpt(text, this.token);
31721
+ let body;
31722
+ if (quoted !== "") {
31723
+ body = `a body that is not Dispatch's error JSON (${JSON.stringify(quoted)})`;
31724
+ } else if (text === "") {
31725
+ body = "an empty body";
31726
+ } else {
31727
+ const within = text.length > EXCERPT_SCAN_LIMIT ? ` in its first ${EXCERPT_SCAN_LIMIT / 1024} KiB` : "";
31728
+ const size = Buffer.byteLength(text).toLocaleString("en-US");
31729
+ body = `a body of ${size} bytes and no readable text${within}`;
31730
+ }
31731
+ const reasonPhrase = excerpt(response.statusText, this.token);
31732
+ const reason = reasonPhrase === "" ? "" : ` ${reasonPhrase}`;
31733
+ throw new DispatchGatewayError(response.status, `${method} ${url2} answered ${response.status}${reason} with ${body}, ${GATEWAY_PAGE}`, gatewayAdvice(method, response.status));
31636
31734
  }
31637
31735
  return payload;
31638
31736
  }
@@ -32375,7 +32473,7 @@ async function resolveArtifact(client, owner, artifactReference, { canonical = f
32375
32473
  throw new Error("artifact is required for a project document");
32376
32474
  }
32377
32475
  const routed = await client.getProjectArtifact(owner.project, artifactReference).catch((error48) => {
32378
- if (!(error48 instanceof DispatchServiceError) || error48.status !== 404)
32476
+ if (!dispatchAnswered(error48, 404))
32379
32477
  throw error48;
32380
32478
  return;
32381
32479
  });
@@ -32546,12 +32644,12 @@ function referenceLines(edges) {
32546
32644
  if (edges.length === 0)
32547
32645
  return ["- none"];
32548
32646
  return edges.map((edge) => {
32549
- const excerpt = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
32550
- return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt}${edge.created_at})`;
32647
+ const excerpt2 = edge.excerpt === undefined ? "" : `${textHead(edge.excerpt.text)} \xB7 `;
32648
+ return `- ${edge.kind} ${edge.node.kind} ${edge.node.ref ?? edge.node.id} (${excerpt2}${edge.created_at})`;
32551
32649
  });
32552
32650
  }
32553
32651
  function unavailableReason(error48) {
32554
- return error48 instanceof DispatchServiceError && error48.status === 404 ? "unavailable" : `unavailable: ${messageFor(error48)}`;
32652
+ return dispatchAnswered(error48, 404) ? "unavailable" : `unavailable: ${messageFor(error48)}`;
32555
32653
  }
32556
32654
  async function graphEdges(client, query) {
32557
32655
  try {
@@ -32741,9 +32839,8 @@ async function openArtifactMarks(client, resolved) {
32741
32839
  const marks = asks.filter((ask) => ask.state === "open" && ask.anchor?.artifact_id === resolved.artifact.id).map((ask) => `ask ${ask.id}`);
32742
32840
  const commentsResult = await commentsResultPromise;
32743
32841
  if (commentsResult.status === "rejected") {
32744
- if (commentsResult.reason instanceof DispatchServiceError && commentsResult.reason.status === 404) {
32842
+ if (dispatchAnswered(commentsResult.reason, 404))
32745
32843
  return marks;
32746
- }
32747
32844
  throw commentsResult.reason;
32748
32845
  }
32749
32846
  return [
@@ -32830,10 +32927,30 @@ async function refuseRemovingOpenDecisionBlocks(client, tool, resolved, ops) {
32830
32927
  `));
32831
32928
  }
32832
32929
  function refusalWithCode(error48, suffix = "") {
32930
+ if (error48 instanceof DispatchGatewayError) {
32931
+ let told = error48.message;
32932
+ if (suffix !== "") {
32933
+ told = error48.mayHaveReachedDispatch ? `${error48.answer}${suffix}` : `${error48.answer}, so ${error48.advice}${suffix}`;
32934
+ }
32935
+ return new DispatchGatewayError(error48.status, error48.answer, error48.advice, `${error48.code}: ${told}`);
32936
+ }
32833
32937
  if (!(error48 instanceof DispatchServiceError))
32834
32938
  return error48;
32835
32939
  return new DispatchServiceError(error48.code, error48.status, `${error48.code}: ${error48.message}${suffix}`, error48.candidates, error48.current, error48.mismatches);
32836
32940
  }
32941
+ function dispatchAnswered(error48, status) {
32942
+ return error48 instanceof DispatchServiceError && error48.fromDispatch && error48.status === status;
32943
+ }
32944
+ function writeMayHaveLanded(error48) {
32945
+ if (error48 instanceof DispatchGatewayError)
32946
+ return error48.mayHaveReachedDispatch;
32947
+ return !(error48 instanceof DispatchServiceError) || error48.status >= 500;
32948
+ }
32949
+ function withAccount(error48, account) {
32950
+ const message = messageFor(error48);
32951
+ const told = /[.!?]$/.test(message) ? `${message} ${account.charAt(0).toUpperCase()}${account.slice(1)}` : `${message}; ${account}`;
32952
+ return new Error(told, { cause: error48 });
32953
+ }
32837
32954
  async function executeDispatchTool(input) {
32838
32955
  const configUrl = input.config.url;
32839
32956
  const configToken = input.config.token;
@@ -33003,7 +33120,10 @@ async function executeDispatchTool(input) {
33003
33120
  ref: dispatchChildRef(dispatchIssueRef(issueKey), "message", message.id)
33004
33121
  };
33005
33122
  } catch (error48) {
33006
- throw refusalWithCode(error48, "; the reason was not posted, so the close was not sent");
33123
+ const told = writeMayHaveLanded(error48) ? "the reason may or may not have been posted, and the close was not sent: read the issue's messages before retrying, since retrying this call posts its reason again" : "the reason was not posted, so the close was not sent";
33124
+ if (error48 instanceof DispatchServiceError)
33125
+ throw refusalWithCode(error48, `; ${told}`);
33126
+ throw withAccount(error48, told);
33007
33127
  }
33008
33128
  }
33009
33129
  const linked = before.external_links.map((link) => link.url);
@@ -33022,17 +33142,15 @@ async function executeDispatchTool(input) {
33022
33142
  actor
33023
33143
  });
33024
33144
  } catch (error48) {
33025
- const taken = error48 instanceof DispatchServiceError && error48.status === 500 && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
33145
+ const taken = dispatchAnswered(error48, 500) && newLinks.length > 0 ? `; one of ${newLinks.join(", ")} may already be linked from another issue (a URL links exactly one issue)` : "";
33026
33146
  if (closingNote === undefined)
33027
33147
  throw refusalWithCode(error48, taken);
33028
- const refused = error48 instanceof DispatchServiceError && error48.status < 500;
33029
- const posted = `; the reason already landed as message ${closingNote.id} (${closingNote.ref})`;
33030
- const landed = refused ? `${posted} but the issue did not close. Retrying this call posts its reason again, so fix what refused the close, then retry with a reason that points at message ${closingNote.id}` : `${posted}, and the close may or may not have taken effect. Read the issue's status before retrying: done means it closed; otherwise retry with a reason that points at message ${closingNote.id}, since retrying this call posts its reason again`;
33148
+ const posted = `the reason already landed as message ${closingNote.id} (${closingNote.ref})`;
33149
+ const fix = error48 instanceof DispatchGatewayError && error48.transient ? "" : "fix what refused the close, then ";
33150
+ const landed = writeMayHaveLanded(error48) ? `${posted}, and the close may or may not have taken effect. Read the issue's status before retrying: done means it closed; otherwise retry with a reason that points at message ${closingNote.id}, since retrying this call posts its reason again` : `${posted} but the issue did not close. Retrying this call posts its reason again, so ${fix}retry with a reason that points at message ${closingNote.id}`;
33031
33151
  if (error48 instanceof DispatchServiceError)
33032
- throw refusalWithCode(error48, taken + landed);
33033
- throw new Error(`${error48 instanceof Error ? error48.message : String(error48)}${landed}`, {
33034
- cause: error48
33035
- });
33152
+ throw refusalWithCode(error48, `${taken}; ${landed}`);
33153
+ throw withAccount(error48, landed);
33036
33154
  }
33037
33155
  const linkCount = `(${after.external_links.length} ${after.external_links.length === 1 ? "link" : "links"})`;
33038
33156
  const changes = [
@@ -33636,7 +33754,7 @@ async function resolveExistingIssue(client, issueReference) {
33636
33754
  try {
33637
33755
  return await client.resolveIssue(issueReference);
33638
33756
  } catch (error48) {
33639
- if (error48 instanceof DispatchServiceError && error48.status === 404) {
33757
+ if (dispatchAnswered(error48, 404)) {
33640
33758
  throw new Error(`no Dispatch issue is linked to ${issueReference}; create it first with ` + `dispatch_issue({ external: "${issueReference}", ... })`);
33641
33759
  }
33642
33760
  throw error48;
@@ -33685,7 +33803,7 @@ import { logger } from "@oh-my-pi/pi-utils";
33685
33803
  // package.json
33686
33804
  var package_default = {
33687
33805
  name: "@sjawhar/pi-legion-envoy",
33688
- version: "5.30.3",
33806
+ version: "5.30.5",
33689
33807
  type: "module",
33690
33808
  omp: {
33691
33809
  extensions: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sjawhar/pi-legion-envoy",
3
- "version": "5.30.3",
3
+ "version": "5.30.5",
4
4
  "type": "module",
5
5
  "omp": {
6
6
  "extensions": [