@zackbart/connecta 0.10.5 → 0.10.6

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 (67) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/dist/activity.d.ts +11 -1
  3. package/dist/activity.d.ts.map +1 -1
  4. package/dist/activity.js +44 -3
  5. package/dist/activity.js.map +1 -1
  6. package/dist/catalog-service.d.ts +39 -0
  7. package/dist/catalog-service.d.ts.map +1 -1
  8. package/dist/catalog-service.js +72 -11
  9. package/dist/catalog-service.js.map +1 -1
  10. package/dist/errors.d.ts +48 -1
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +67 -0
  13. package/dist/errors.js.map +1 -1
  14. package/dist/execute.d.ts +72 -0
  15. package/dist/execute.d.ts.map +1 -1
  16. package/dist/execute.js +162 -9
  17. package/dist/execute.js.map +1 -1
  18. package/dist/index.d.ts +15 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/invocation.d.ts +9 -2
  23. package/dist/invocation.d.ts.map +1 -1
  24. package/dist/invocation.js +59 -29
  25. package/dist/invocation.js.map +1 -1
  26. package/dist/meta-tools.d.ts +5 -1
  27. package/dist/meta-tools.d.ts.map +1 -1
  28. package/dist/meta-tools.js +171 -26
  29. package/dist/meta-tools.js.map +1 -1
  30. package/dist/operator-ui/generated.d.ts +1 -1
  31. package/dist/operator-ui/generated.d.ts.map +1 -1
  32. package/dist/operator-ui/generated.js +1 -1
  33. package/dist/operator-ui/generated.js.map +1 -1
  34. package/dist/registry.d.ts +11 -0
  35. package/dist/registry.d.ts.map +1 -1
  36. package/dist/registry.js +5 -2
  37. package/dist/registry.js.map +1 -1
  38. package/dist/routes/mcp.d.ts.map +1 -1
  39. package/dist/routes/mcp.js +9 -0
  40. package/dist/routes/mcp.js.map +1 -1
  41. package/dist/routes/shared.d.ts +4 -0
  42. package/dist/routes/shared.d.ts.map +1 -1
  43. package/dist/routes/shared.js.map +1 -1
  44. package/dist/version.d.ts +1 -1
  45. package/dist/version.js +1 -1
  46. package/documentation/code-mode.md +119 -31
  47. package/documentation/meta-tools.md +79 -3
  48. package/documentation/rich-output-design.md +212 -0
  49. package/ethos.md +17 -19
  50. package/examples/worker/README.md +11 -3
  51. package/examples/worker/src/d1-activity-row.ts +40 -0
  52. package/examples/worker/src/d1-activity.ts +3 -2
  53. package/package.json +1 -1
  54. package/src/activity.ts +64 -3
  55. package/src/catalog-service.ts +135 -22
  56. package/src/errors.ts +102 -1
  57. package/src/execute.ts +238 -9
  58. package/src/index.ts +22 -0
  59. package/src/invocation.ts +59 -17
  60. package/src/meta-tools.ts +211 -30
  61. package/src/operator-ui/browser.ts +10 -2
  62. package/src/operator-ui/generated.ts +1 -1
  63. package/src/registry.ts +5 -2
  64. package/src/routes/mcp.ts +9 -0
  65. package/src/routes/shared.ts +4 -0
  66. package/src/version.ts +1 -1
  67. package/templates/node/package.json +1 -1
package/src/meta-tools.ts CHANGED
@@ -23,7 +23,9 @@ import {
23
23
  type DeferredWork,
24
24
  } from "./connector-scope.js";
25
25
  import {
26
+ boundedEchoText,
26
27
  classifyCallError,
28
+ echoedCallArgs,
27
29
  messageLooksRetryable,
28
30
  type CallErrorDetails,
29
31
  } from "./errors.js";
@@ -786,6 +788,10 @@ async function stashResult(
786
788
  resultId: string;
787
789
  totalBytes: number;
788
790
  hint: string;
791
+ nextAction: {
792
+ tool: "get_result";
793
+ arguments: { id: string; offset: 0 };
794
+ };
789
795
  }> {
790
796
  const id = crypto.randomUUID();
791
797
  await results.set(`result:${id}`, text, { ttlSeconds: RESULT_TTL_SECONDS });
@@ -794,16 +800,108 @@ async function stashResult(
794
800
  resultId: id,
795
801
  totalBytes,
796
802
  hint: "use get_result {id, offset} to page, or re-call with fields to select less",
803
+ nextAction: {
804
+ tool: "get_result",
805
+ arguments: { id, offset: 0 },
806
+ },
797
807
  };
798
808
  }
799
809
 
800
- /** Keep an oversized batch's inline outcome summary at fixed string overhead. */
810
+ interface GuardedResult<T> {
811
+ result: T;
812
+ truncated: boolean;
813
+ }
814
+
815
+ /**
816
+ * Keep an oversized batch's inline outcome summary at fixed string overhead.
817
+ * The same clamp the error envelopes use — one budget, one marker, defined
818
+ * once in `errors.ts` so the two cannot drift apart.
819
+ */
801
820
  function batchSummaryString(value: string): string {
802
- const bytes = enc.encode(value);
803
- const maxBytes = 512;
804
- if (bytes.length <= maxBytes) return value;
805
- const end = alignEndToCharBoundary(bytes, 0, maxBytes, bytes.length);
806
- return `${dec.decode(bytes.slice(0, end))}…`;
821
+ return boundedEchoText(value);
822
+ }
823
+
824
+ /** Candidate addresses kept in an oversized batch's summary of one ambiguity. */
825
+ const MAX_SUMMARY_ADDRESSES = 10;
826
+
827
+ /**
828
+ * A recovery route rebuilt field by field so the summary above keeps its
829
+ * promise. Spreading `nextAction` through raw would reopen the hole this
830
+ * function closes: every variant carries free-form strings, and one of them
831
+ * carries the caller's arguments, which is exactly the payload an oversized
832
+ * batch was already too large to hold.
833
+ */
834
+ function batchSummaryNextAction(
835
+ nextAction: NonNullable<CallErrorDetails["nextAction"]>,
836
+ ): NonNullable<CallErrorDetails["nextAction"]> {
837
+ if ("function" in nextAction) {
838
+ // A batch runs on the top-level catalog, whose search route is the tool, so
839
+ // the function-keyed discovery variant does not arrive here today. Rebuild
840
+ // it anyway: a guard that silently dropped an unrecognized route would turn
841
+ // a recovery record into nothing at exactly the moment one is needed.
842
+ if (nextAction.function === "connecta.search") {
843
+ return {
844
+ function: "connecta.search",
845
+ arguments: batchSummarySearchArgs(nextAction.arguments),
846
+ purpose: batchSummaryString(nextAction.purpose),
847
+ };
848
+ }
849
+ // Say so when the candidate list is clipped. The unclipped purpose reads
850
+ // "choose the intended canonical address", which is a lie about a list
851
+ // that no longer contains every candidate — and the caller has no other
852
+ // way to learn that the address it wants was the eleventh.
853
+ const candidates = nextAction.addresses.slice(0, MAX_SUMMARY_ADDRESSES);
854
+ return {
855
+ function: nextAction.function,
856
+ addresses: candidates.map(batchSummaryString),
857
+ purpose: batchSummaryString(
858
+ candidates.length < nextAction.addresses.length
859
+ ? `${nextAction.purpose} Showing the first ${candidates.length} of ` +
860
+ `${nextAction.addresses.length} candidates; re-run the call on its ` +
861
+ "own to see them all."
862
+ : nextAction.purpose,
863
+ ),
864
+ };
865
+ }
866
+ if (nextAction.tool === "authorize_connector") {
867
+ return {
868
+ tool: "authorize_connector",
869
+ arguments: {
870
+ connector: batchSummaryString(nextAction.arguments.connector),
871
+ },
872
+ operatorHandoff: batchSummaryString(nextAction.operatorHandoff),
873
+ };
874
+ }
875
+ if (nextAction.tool === "call_destructive_tool") {
876
+ return {
877
+ tool: "call_destructive_tool",
878
+ arguments: {
879
+ address: batchSummaryString(nextAction.arguments.address),
880
+ ...echoedCallArgs(nextAction.arguments.args),
881
+ },
882
+ purpose: batchSummaryString(nextAction.purpose),
883
+ };
884
+ }
885
+ return {
886
+ tool: "search_tools",
887
+ arguments: batchSummarySearchArgs(nextAction.arguments),
888
+ purpose: batchSummaryString(nextAction.purpose),
889
+ };
890
+ }
891
+
892
+ /** The scoping arguments both discovery routes carry, bounded the same way. */
893
+ function batchSummarySearchArgs(args: {
894
+ query: string;
895
+ connector?: string;
896
+ includeSchemas: "compact";
897
+ }): { query: string; connector?: string; includeSchemas: "compact" } {
898
+ return {
899
+ query: batchSummaryString(args.query),
900
+ ...(args.connector !== undefined
901
+ ? { connector: batchSummaryString(args.connector) }
902
+ : {}),
903
+ includeSchemas: "compact",
904
+ };
807
905
  }
808
906
 
809
907
  /**
@@ -817,16 +915,22 @@ async function guardEncoded(
817
915
  bytes: Uint8Array,
818
916
  results: KVStorage,
819
917
  cap: number,
820
- ): Promise<ToolResult> {
918
+ ): Promise<GuardedResult<ToolResult>> {
821
919
  if (bytes.length <= cap) {
822
- return { content: [{ type: "text", text }] };
920
+ return {
921
+ result: { content: [{ type: "text", text }] },
922
+ truncated: false,
923
+ };
823
924
  }
824
925
  const notice = await stashResult(text, results, bytes.length);
825
926
  const head = dec.decode(
826
927
  bytes.slice(0, alignEndToCharBoundary(bytes, 0, cap, bytes.length)),
827
928
  );
828
929
  return {
829
- content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
930
+ result: {
931
+ content: [{ type: "text", text: `${head}\n${JSON.stringify(notice)}` }],
932
+ },
933
+ truncated: true,
830
934
  };
831
935
  }
832
936
 
@@ -835,7 +939,7 @@ async function guardText(
835
939
  text: string,
836
940
  results: KVStorage,
837
941
  cap: number,
838
- ): Promise<ToolResult> {
942
+ ): Promise<GuardedResult<ToolResult>> {
839
943
  // `JSON.stringify`'s type says `string` where its behavior says `string |
840
944
  // undefined`, so TypeScript alone does not keep a non-string out of here.
841
945
  // Normalizing at the door means the size check below always measures exactly
@@ -851,11 +955,14 @@ async function guardValue(
851
955
  value: unknown,
852
956
  results: KVStorage,
853
957
  cap: number,
854
- ): Promise<unknown> {
958
+ ): Promise<GuardedResult<unknown>> {
855
959
  const text = serializeResultText(value);
856
960
  const bytes = enc.encode(text);
857
- if (bytes.length <= cap) return value;
858
- return stashResult(text, results, bytes.length);
961
+ if (bytes.length <= cap) return { result: value, truncated: false };
962
+ return {
963
+ result: await stashResult(text, results, bytes.length),
964
+ truncated: true,
965
+ };
859
966
  }
860
967
 
861
968
  /**
@@ -880,7 +987,7 @@ async function guardContent(
880
987
  content: TextContent[],
881
988
  results: KVStorage,
882
989
  cap: number,
883
- ): Promise<ToolResult> {
990
+ ): Promise<GuardedResult<ToolResult>> {
884
991
  let text: string;
885
992
  try {
886
993
  text = JSON.stringify(content);
@@ -889,17 +996,22 @@ async function guardContent(
889
996
  // be measured, stashed, or paged either — there is nothing this guard could
890
997
  // do with it. Pass it through as the old text-only measure did, rather than
891
998
  // turning a call that used to succeed into result_processing_failed.
892
- return { content };
999
+ return { result: { content }, truncated: false };
893
1000
  }
894
1001
  const bytes = enc.encode(text);
895
1002
  // Under the cap the downstream blocks pass through untouched, non-text ones
896
1003
  // included, in their original order.
897
- if (bytes.length <= cap) return { content };
1004
+ if (bytes.length <= cap) {
1005
+ return { result: { content }, truncated: false };
1006
+ }
898
1007
  if (content.every((b) => b.type === "text")) {
899
1008
  return guardEncoded(text, bytes, results, cap);
900
1009
  }
901
1010
  const notice = await stashResult(text, results, bytes.length);
902
- return { content: [{ type: "text", text: JSON.stringify(notice) }] };
1011
+ return {
1012
+ result: { content: [{ type: "text", text: JSON.stringify(notice) }] },
1013
+ truncated: true,
1014
+ };
903
1015
  }
904
1016
 
905
1017
  // --- compact schema rendering (feature 3a) --------------------------------
@@ -938,6 +1050,10 @@ export interface CallArgs {
938
1050
  /** Include connector/catalog/result-processing timing segments. */
939
1051
  diagnostics?: boolean;
940
1052
  }
1053
+ export interface DestructiveCallArgs extends CallArgs {
1054
+ /** Short model-authored context for the host's approval UI; never downstream input. */
1055
+ reason?: string;
1056
+ }
941
1057
  export interface GetResultArgs {
942
1058
  id: string;
943
1059
  /**
@@ -964,6 +1080,21 @@ export interface SkillArgs {
964
1080
  name?: string;
965
1081
  }
966
1082
 
1083
+ /**
1084
+ * The sentence that closes the OAuth handoff, telling the operator's agent how
1085
+ * to confirm the flow landed. `authorize_connector` is registered on both
1086
+ * surfaces but `list_connectors` is not, so the classic status check cannot be
1087
+ * the only one offered: a code-first agent handed that advice gets an
1088
+ * unknown-tool error at exactly the moment it is trying to recover. It gets the
1089
+ * check its own surface serves instead — the same folded-name defect as the
1090
+ * describe path (#261), one tool result further along.
1091
+ */
1092
+ function oauthFollowUp(surface: ConnectaSurface, connectorId: string): string {
1093
+ return surface === "code-first"
1094
+ ? `Then retry the original call; connecta.search({ connector: ${JSON.stringify(connectorId)} }) inside execute_code confirms the catalog now loads.`
1095
+ : "Re-run list_connectors afterwards to confirm status is ok.";
1096
+ }
1097
+
967
1098
  /**
968
1099
  * Every base meta-tool handler over a registry — all nine, whichever surface is
969
1100
  * advertised, since folding a tool away only skips its registration and never
@@ -1018,6 +1149,14 @@ export function createMetaTools(
1018
1149
  requestScope,
1019
1150
  probeTimeoutMs,
1020
1151
  concurrency: discoveryConcurrency,
1152
+ // These handlers are the top-level tools, so the route is the advertised
1153
+ // one; in-program describes route through connecta.describe instead and
1154
+ // are built with their own CatalogService in execute.ts.
1155
+ describeRoute:
1156
+ surface === "code-first" ? "connecta.describe" : "describe_tools",
1157
+ // searchRoute keeps its default: unlike describe_tools, search_tools is
1158
+ // served by both advertised surfaces, so a top-level handler has nothing to
1159
+ // derive. Only an in-program caller needs to be sent to connecta.search.
1021
1160
  });
1022
1161
  const invocation = new InvocationService(registry, catalog, opts.activity);
1023
1162
  const withProbeDeadline = <T>(
@@ -1045,6 +1184,12 @@ export function createMetaTools(
1045
1184
  interface ProcessedCallResult {
1046
1185
  toolResult: ToolResult;
1047
1186
  value?: unknown;
1187
+ /**
1188
+ * Friction on a call that *succeeded*. It travels as a friction class, not
1189
+ * as an `errorCode`, so persistence keyed on "this row has an error code"
1190
+ * keeps counting failures rather than truncations.
1191
+ */
1192
+ friction?: "result_too_large";
1048
1193
  }
1049
1194
 
1050
1195
  /** MCP adapter: shared invocation semantics plus MCP-only result shaping. */
@@ -1091,10 +1236,14 @@ export function createMetaTools(
1091
1236
  resolved.definition.outputSchema,
1092
1237
  )
1093
1238
  : result;
1094
- value = await guardValue(value, results, cap);
1239
+ const guarded = await guardValue(value, results, cap);
1240
+ value = guarded.result;
1095
1241
  return {
1096
1242
  toolResult: jsonResult({ ok: true, data: value }),
1097
1243
  value,
1244
+ ...(guarded.truncated
1245
+ ? { friction: "result_too_large" as const }
1246
+ : {}),
1098
1247
  };
1099
1248
  }
1100
1249
  if (resolved.connector.kind === "mcp") {
@@ -1107,7 +1256,13 @@ export function createMetaTools(
1107
1256
  resolved.definition.outputSchema,
1108
1257
  );
1109
1258
  }
1110
- return { toolResult: await guardContent(content, results, cap) };
1259
+ const guarded = await guardContent(content, results, cap);
1260
+ return {
1261
+ toolResult: guarded.result,
1262
+ ...(guarded.truncated
1263
+ ? { friction: "result_too_large" as const }
1264
+ : {}),
1265
+ };
1111
1266
  }
1112
1267
  const value = fields
1113
1268
  ? projectionValue(
@@ -1116,19 +1271,26 @@ export function createMetaTools(
1116
1271
  resolved.definition.outputSchema,
1117
1272
  )
1118
1273
  : result;
1274
+ const guarded = await guardText(
1275
+ serializeResultText(value),
1276
+ results,
1277
+ cap,
1278
+ );
1119
1279
  return {
1120
- toolResult: await guardText(
1121
- serializeResultText(value),
1122
- results,
1123
- cap,
1124
- ),
1280
+ toolResult: guarded.result,
1125
1281
  value,
1282
+ ...(guarded.truncated
1283
+ ? { friction: "result_too_large" as const }
1284
+ : {}),
1126
1285
  };
1127
1286
  },
1287
+ activityFriction: (processed) => processed.friction,
1128
1288
  },
1129
1289
  );
1130
1290
  if (!outcome.ok) {
1291
+ const structuredRecovery = outcome.error.nextAction !== undefined;
1131
1292
  const failedResult =
1293
+ structuredRecovery ||
1132
1294
  outcome.error.code === "auth_required" ||
1133
1295
  outcome.error.code === "invalid_args" ||
1134
1296
  outcome.error.code === "input_required_unsupported" ||
@@ -1142,6 +1304,7 @@ export function createMetaTools(
1142
1304
  })
1143
1305
  : errorResult(outcome.error.message);
1144
1306
  if (
1307
+ structuredRecovery ||
1145
1308
  outcome.error.code === "auth_required" ||
1146
1309
  outcome.error.code === "invalid_args" ||
1147
1310
  outcome.error.code === "input_required_unsupported"
@@ -1348,7 +1511,9 @@ export function createMetaTools(
1348
1511
  return (await runCall(args, "call_tool")).toolResult;
1349
1512
  },
1350
1513
 
1351
- async callDestructiveTool(args: CallArgs): Promise<ToolResult> {
1514
+ async callDestructiveTool(args: DestructiveCallArgs): Promise<ToolResult> {
1515
+ // `reason` is read by the host's approval view and stops there — runCall
1516
+ // forwards only the call fields, so it never reaches the connector.
1352
1517
  return (
1353
1518
  await runCall(args, "call_destructive_tool", { allowDestructive: true })
1354
1519
  ).toolResult;
@@ -1537,7 +1702,7 @@ export function createMetaTools(
1537
1702
  ? { recovery: details.recovery }
1538
1703
  : {}),
1539
1704
  ...(details.nextAction !== undefined
1540
- ? { nextAction: details.nextAction }
1705
+ ? { nextAction: batchSummaryNextAction(details.nextAction) }
1541
1706
  : {}),
1542
1707
  ...(details.retry !== undefined
1543
1708
  ? { retry: batchSummaryString(details.retry) }
@@ -1626,7 +1791,8 @@ export function createMetaTools(
1626
1791
  ? {
1627
1792
  authorizationUrl: status.authorizationUrl,
1628
1793
  instructions:
1629
- "Have the operator open authorizationUrl in a browser and complete the consent flow. The provider then redirects back to this server's /oauth/callback/<connector> route, which finishes the flow automatically. Re-run list_connectors afterwards to confirm status is ok.",
1794
+ "Have the operator open authorizationUrl in a browser and complete the consent flow. The provider then redirects back to this server's /oauth/callback/<connector> route, which finishes the flow automatically. " +
1795
+ oauthFollowUp(surface, connector.id),
1630
1796
  }
1631
1797
  : {}),
1632
1798
  ...(status.message ? { message: status.message } : {}),
@@ -1649,7 +1815,7 @@ const DESCRIBE_DESC = `Only when search_tools omitted schemas, a compact shape i
1649
1815
  const CALL_DESC =
1650
1816
  'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths; traverse arrays with [] (for example results[].id). Misses return data plus `$connecta` feedback. resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
1651
1817
  const CALL_DESTRUCTIVE_DESC =
1652
- "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
1818
+ "Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. Include a short reason explaining the intended consequence for the human reviewer; it grants no authority and is never passed downstream. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
1653
1819
  const GET_RESULT_DESC =
1654
1820
  "Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default) and offset a whole number of bytes >= 0; an offset inside a multi-byte character is moved back to that character's first byte and the offset served is returned. Unknown/expired id is an error.";
1655
1821
  const BATCH_DESC =
@@ -1864,14 +2030,29 @@ export function registerMetaTools(
1864
2030
  "call_destructive_tool",
1865
2031
  {
1866
2032
  description: CALL_DESTRUCTIVE_DESC,
1867
- inputSchema: z.object(CALL_INPUT_SCHEMA),
2033
+ inputSchema: z.object({
2034
+ ...CALL_INPUT_SCHEMA,
2035
+ // Bounded above, but with no lower bound: a model that sends `""` or
2036
+ // whitespace has written no reason, and failing an entire consequential
2037
+ // call over a cosmetic field the host merely displays is the wrong
2038
+ // trade. It is normalized to absent below instead.
2039
+ reason: z.string().max(500).optional(),
2040
+ }),
1868
2041
  annotations: {
1869
2042
  destructiveHint: true,
1870
2043
  readOnlyHint: false,
1871
2044
  openWorldHint: true,
1872
2045
  },
1873
2046
  },
1874
- async (args) => mt.callDestructiveTool(args as CallArgs),
2047
+ async (args) => {
2048
+ // `reason` is the host's to display and connecta's to keep out of the
2049
+ // downstream call, so this destructuring is the whole of its handling:
2050
+ // nothing below reads it. Dropping it is also what makes an empty or
2051
+ // whitespace-only one "absent" rather than a validation failure — there
2052
+ // is no field left for it to be absent from.
2053
+ const { reason: _hostContext, ...call } = args as DestructiveCallArgs;
2054
+ return mt.callDestructiveTool(call);
2055
+ },
1875
2056
  );
1876
2057
 
1877
2058
  server.registerTool(
@@ -58,6 +58,7 @@ interface UiActivityEvent {
58
58
  durationMs: number;
59
59
  attempts: number;
60
60
  errorCode?: string;
61
+ friction?: string;
61
62
  }
62
63
 
63
64
  interface UiActivityResponse {
@@ -425,6 +426,7 @@ function renderActivity(): void {
425
426
  event.source,
426
427
  event.outcome,
427
428
  event.errorCode,
429
+ event.friction,
428
430
  actor.kind,
429
431
  actor.id,
430
432
  actor.namespace,
@@ -454,7 +456,13 @@ function renderActivity(): void {
454
456
  const retryCopy = event.attempts > 1
455
457
  ? " · " + esc(event.attempts) + " attempts"
456
458
  : "";
457
- const errorCopy = event.errorCode ? " · " + esc(event.errorCode) : "";
459
+ const frictionCopy = event.friction ? " · " + esc(event.friction) : "";
460
+ // The friction class and the code coincide for auth_required and
461
+ // result_too_large. Printing "· auth_required · auth_required" says nothing
462
+ // twice, so the coarse class stands in for both when they agree.
463
+ const errorCopy = event.errorCode && event.errorCode !== event.friction
464
+ ? " · " + esc(event.errorCode)
465
+ : "";
458
466
  const actorId = event.actor?.id
459
467
  ? (event.actor.namespace
460
468
  ? event.actor.namespace + " · " + event.actor.id
@@ -474,7 +482,7 @@ function renderActivity(): void {
474
482
  "</div>" + stableActorId + "</div></div>" +
475
483
  '<div><div class="activity-address">' + esc(event.address) +
476
484
  '</div><div class="activity-detail">' + esc(event.source) + retryCopy +
477
- errorCopy + '</div></div>' +
485
+ frictionCopy + errorCopy + '</div></div>' +
478
486
  '<div><div class="activity-outcome">' + esc(event.outcome) +
479
487
  '</div><div class="activity-detail">' + esc(event.durationMs) + ' ms</div></div>';
480
488
  list.appendChild(item);