@oxygen-agent/cli 1.377.3 → 1.575.19

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 (95) hide show
  1. package/README.md +1 -1
  2. package/dist/column-run-notices.d.ts +11 -0
  3. package/dist/column-run-notices.js +37 -0
  4. package/dist/command-manifest.js +13 -8
  5. package/dist/help.js +78 -16
  6. package/dist/index.js +3450 -447
  7. package/dist/skills.js +106 -1
  8. package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
  9. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  10. package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
  11. package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
  12. package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
  13. package/node_modules/@oxygen/formula/dist/expression.js +428 -0
  14. package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
  15. package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
  16. package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
  17. package/node_modules/@oxygen/formula/dist/index.js +17 -0
  18. package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
  19. package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
  20. package/node_modules/@oxygen/formula/package.json +26 -0
  21. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
  22. package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
  23. package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
  24. package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
  25. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -5
  26. package/node_modules/@oxygen/shared/dist/billing.js +185 -8
  27. package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
  28. package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
  29. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  30. package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
  31. package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
  32. package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
  33. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
  34. package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
  35. package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
  36. package/node_modules/@oxygen/shared/dist/directory.js +1 -0
  37. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +15 -0
  38. package/node_modules/@oxygen/shared/dist/hosted-ai.js +19 -0
  39. package/node_modules/@oxygen/shared/dist/index.d.ts +9 -0
  40. package/node_modules/@oxygen/shared/dist/index.js +9 -0
  41. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
  42. package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
  43. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
  44. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
  45. package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
  46. package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
  47. package/node_modules/@oxygen/shared/dist/log.js +41 -2
  48. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
  49. package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
  50. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
  51. package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
  52. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
  53. package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
  54. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
  55. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
  56. package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
  57. package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
  58. package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
  59. package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
  60. package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
  61. package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
  62. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
  63. package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
  64. package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
  65. package/node_modules/@oxygen/shared/dist/tags.js +122 -6
  66. package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
  67. package/node_modules/@oxygen/shared/dist/version.js +1 -1
  68. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
  69. package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
  70. package/node_modules/@oxygen/shared/package.json +95 -0
  71. package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
  72. package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
  73. package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
  74. package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
  75. package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
  76. package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
  77. package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
  78. package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
  79. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
  80. package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
  81. package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
  82. package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
  83. package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
  84. package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
  85. package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
  86. package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
  87. package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
  88. package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
  89. package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
  90. package/node_modules/@oxygen/workflows/dist/index.js +179 -13
  91. package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
  92. package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
  93. package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
  94. package/node_modules/@oxygen/workflows/package.json +4 -0
  95. package/package.json +7 -5
@@ -2,11 +2,15 @@ export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_V
2
2
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
3
  export { WORKFLOW_STATUS_CHANGE_METADATA_KEY, type WorkflowStatusChange, type WorkflowStatusChangeActor, type WorkflowStatusChangeSource, describeWorkflowStatusChange, formatWorkflowStatusChangeTimestamp, parseWorkflowStatusChange, readWorkflowStatusChange, } from "./workflow-status-change.js";
4
4
  export * from "./billing.js";
5
+ export * from "./billing-anchors.js";
5
6
  export * from "./budget-scopes.js";
7
+ export * from "./plan-limits.js";
8
+ export * from "./spend-safety.js";
6
9
  export * from "./cell-format.js";
7
10
  export * from "./cli-envelope.js";
8
11
  export * from "./cli-login-code.js";
9
12
  export * from "./cli-result.js";
13
+ export * from "./crm-reply-events.js";
10
14
  export * from "./column-types.js";
11
15
  export * from "./copilot-journeys.js";
12
16
  export * from "./credit-guidance.js";
@@ -23,11 +27,16 @@ export * from "./knowledge-seed-content.js";
23
27
  export * from "./langfuse.js";
24
28
  export * from "./linkedin-mentions.js";
25
29
  export * from "./linkedin-post-url.js";
30
+ export * from "./linkedin-quota-denial.js";
26
31
  export * from "./linkedin-url.js";
27
32
  export * from "./linkedin-sequences.js";
33
+ export * from "./microsoft-consent-url.js";
28
34
  export * from "./networks.js";
29
35
  export * from "./recipes.js";
30
36
  export * from "./sequence-template.js";
37
+ export * from "./sequence-crm-events.js";
38
+ export * from "./call-outcomes.js";
39
+ export * from "./dial-guardrail-overrides.js";
31
40
  export * from "./sequences.js";
32
41
  export * from "./suppression-entries.js";
33
42
  export * from "./log.js";
@@ -2,11 +2,15 @@ export { MANAGED_INBOX_MINIMUM_CLI_VERSION, OXYGEN_MINIMUM_CLI_VERSION, OXYGEN_V
2
2
  export { WORKFLOW_TRIGGER_AUTO_PAUSE_METADATA_KEYS, clearWorkflowTriggerAutoPauseMetadata, } from "./workflow-trigger-metadata.js";
3
3
  export { WORKFLOW_STATUS_CHANGE_METADATA_KEY, describeWorkflowStatusChange, formatWorkflowStatusChangeTimestamp, parseWorkflowStatusChange, readWorkflowStatusChange, } from "./workflow-status-change.js";
4
4
  export * from "./billing.js";
5
+ export * from "./billing-anchors.js";
5
6
  export * from "./budget-scopes.js";
7
+ export * from "./plan-limits.js";
8
+ export * from "./spend-safety.js";
6
9
  export * from "./cell-format.js";
7
10
  export * from "./cli-envelope.js";
8
11
  export * from "./cli-login-code.js";
9
12
  export * from "./cli-result.js";
13
+ export * from "./crm-reply-events.js";
10
14
  export * from "./column-types.js";
11
15
  export * from "./copilot-journeys.js";
12
16
  export * from "./credit-guidance.js";
@@ -23,11 +27,16 @@ export * from "./knowledge-seed-content.js";
23
27
  export * from "./langfuse.js";
24
28
  export * from "./linkedin-mentions.js";
25
29
  export * from "./linkedin-post-url.js";
30
+ export * from "./linkedin-quota-denial.js";
26
31
  export * from "./linkedin-url.js";
27
32
  export * from "./linkedin-sequences.js";
33
+ export * from "./microsoft-consent-url.js";
28
34
  export * from "./networks.js";
29
35
  export * from "./recipes.js";
30
36
  export * from "./sequence-template.js";
37
+ export * from "./sequence-crm-events.js";
38
+ export * from "./call-outcomes.js";
39
+ export * from "./dial-guardrail-overrides.js";
31
40
  export * from "./sequences.js";
32
41
  export * from "./suppression-entries.js";
33
42
  export * from "./log.js";
@@ -0,0 +1,31 @@
1
+ /**
2
+ * How a LinkedIn quota denial is read by the background jobs that hit it.
3
+ *
4
+ * The denial itself is raised by the chokepoint in
5
+ * packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
6
+ * and it lives here because the consumers are spread across the worker, the web
7
+ * app, and the integrations package — three copies of "is this a denial, and
8
+ * until when" is exactly how the inbox sync and the publishing metrics sync ended
9
+ * up with different behavior for the same condition.
10
+ *
11
+ * The pattern (established in v1.423.0 for `linkedin.inbox_sync_failed`, which
12
+ * emitted 158,882 warn lines in 31h for one account): a denial is logged at
13
+ * `info`, not `warn` — a rate budget doing its job is normal operation, and at
14
+ * background cadence it buries the real warnings — and the account is parked
15
+ * until its budget resets rather than re-denied on every tenant tick.
16
+ */
17
+ import { OxygenError } from "./cli-result.js";
18
+ /** Was this thrown error the quota chokepoint refusing the call? */
19
+ export declare function isLinkedInQuotaDenial(error: unknown): error is OxygenError;
20
+ /**
21
+ * When a quota-denied caller may try again — the denial's own `resets_at` when it
22
+ * carries a usable one, else a flat hour. Null for anything that is NOT a quota
23
+ * denial: an ordinary failure (provider timeout, malformed payload) has no reset
24
+ * instant and must keep retrying on its caller's normal cadence.
25
+ */
26
+ export declare function linkedInQuotaBackoffUntil(error: unknown, now: Date): Date | null;
27
+ /**
28
+ * Epoch-ms a sender's park — written under `key` in the freeform
29
+ * `sender_accounts.metadata` jsonb — expires; 0 when it is not parked.
30
+ */
31
+ export declare function linkedInQuotaParkedUntil(metadata: unknown, key: string): number;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * How a LinkedIn quota denial is read by the background jobs that hit it.
3
+ *
4
+ * The denial itself is raised by the chokepoint in
5
+ * packages/integrations/src/linkedin-quota.ts; this module is the consumer half,
6
+ * and it lives here because the consumers are spread across the worker, the web
7
+ * app, and the integrations package — three copies of "is this a denial, and
8
+ * until when" is exactly how the inbox sync and the publishing metrics sync ended
9
+ * up with different behavior for the same condition.
10
+ *
11
+ * The pattern (established in v1.423.0 for `linkedin.inbox_sync_failed`, which
12
+ * emitted 158,882 warn lines in 31h for one account): a denial is logged at
13
+ * `info`, not `warn` — a rate budget doing its job is normal operation, and at
14
+ * background cadence it buries the real warnings — and the account is parked
15
+ * until its budget resets rather than re-denied on every tenant tick.
16
+ */
17
+ import { OxygenError } from "./cli-result.js";
18
+ import { isRecord } from "./type-guards.js";
19
+ // The two codes a denial is raised with. Either is a "come back later" signal,
20
+ // not a broken caller: a daily cap / closed active window that reopens on its own
21
+ // clock, or an account the status webhook will reactivate.
22
+ const QUOTA_DENIED_CODES = new Set(["linkedin_rate_limited", "linkedin_account_unavailable"]);
23
+ /** Park length when a denial carries no usable `resets_at` hint. */
24
+ const QUOTA_FALLBACK_BACKOFF_MS = 60 * 60 * 1000;
25
+ /** Was this thrown error the quota chokepoint refusing the call? */
26
+ export function isLinkedInQuotaDenial(error) {
27
+ return error instanceof OxygenError && QUOTA_DENIED_CODES.has(error.code);
28
+ }
29
+ /**
30
+ * When a quota-denied caller may try again — the denial's own `resets_at` when it
31
+ * carries a usable one, else a flat hour. Null for anything that is NOT a quota
32
+ * denial: an ordinary failure (provider timeout, malformed payload) has no reset
33
+ * instant and must keep retrying on its caller's normal cadence.
34
+ */
35
+ export function linkedInQuotaBackoffUntil(error, now) {
36
+ if (!isLinkedInQuotaDenial(error))
37
+ return null;
38
+ const raw = isRecord(error.details) ? error.details.resets_at : undefined;
39
+ if (typeof raw === "string") {
40
+ const ms = Date.parse(raw);
41
+ if (!Number.isNaN(ms) && ms > now.getTime())
42
+ return new Date(ms);
43
+ }
44
+ return new Date(now.getTime() + QUOTA_FALLBACK_BACKOFF_MS);
45
+ }
46
+ /**
47
+ * Epoch-ms a sender's park — written under `key` in the freeform
48
+ * `sender_accounts.metadata` jsonb — expires; 0 when it is not parked.
49
+ */
50
+ export function linkedInQuotaParkedUntil(metadata, key) {
51
+ const raw = isRecord(metadata) ? metadata[key] : undefined;
52
+ if (typeof raw !== "string")
53
+ return 0;
54
+ const ms = Date.parse(raw);
55
+ return Number.isNaN(ms) ? 0 : ms;
56
+ }
@@ -144,10 +144,11 @@ export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
144
144
  /**
145
145
  * Render a sequence-copy template against a row's values. Delegates to the shared
146
146
  * deterministic engine, so beyond `{{column}}` substitution it also handles
147
- * `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
148
- * Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
149
- * produce the final message text. Pass `{ seed }` to make spintax choices
150
- * replayable (crash-safe); with no seed a stable template+values seed is derived.
147
+ * `{{column|fallback}}`, `{{RANDOM|…}}` and bare `{a|b|c}` spintax (nestable), and
148
+ * `{% if … %}` conditionals. Unknown `{{column}}` placeholders render empty. Used
149
+ * by the dispatch engine to produce the final message text. Pass `{ seed }` to make
150
+ * spintax choices replayable (crash-safe); with no seed a stable template+values
151
+ * seed is derived.
151
152
  */
152
153
  export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
153
154
  export {};
@@ -64,10 +64,11 @@ export function waitStepDelayMs(step) {
64
64
  /**
65
65
  * Render a sequence-copy template against a row's values. Delegates to the shared
66
66
  * deterministic engine, so beyond `{{column}}` substitution it also handles
67
- * `{{column|fallback}}`, `{{RANDOM|…}}` spintax, and `{% if … %}` conditionals.
68
- * Unknown `{{column}}` placeholders render empty. Used by the dispatch engine to
69
- * produce the final message text. Pass `{ seed }` to make spintax choices
70
- * replayable (crash-safe); with no seed a stable template+values seed is derived.
67
+ * `{{column|fallback}}`, `{{RANDOM|…}}` and bare `{a|b|c}` spintax (nestable), and
68
+ * `{% if … %}` conditionals. Unknown `{{column}}` placeholders render empty. Used
69
+ * by the dispatch engine to produce the final message text. Pass `{ seed }` to make
70
+ * spintax choices replayable (crash-safe); with no seed a stable template+values
71
+ * seed is derived.
71
72
  */
72
73
  export function renderLinkedInTemplate(template, values, options) {
73
74
  return renderTemplate(template, values, options);
@@ -1,6 +1,28 @@
1
1
  export type LinkedinUrlNormalization = {
2
+ /**
3
+ * Lowercase canonical URL — the stable MATCH key (CRM `linkedin_url_v1`
4
+ * identities, suppression entries). Lowercasing is what makes two spellings of
5
+ * the same profile compare equal, so this must stay case-folded forever: a
6
+ * suppression key that changed case would stop matching every row already
7
+ * stored under the old spelling, and someone who opted out would be contacted.
8
+ */
2
9
  normalized: string;
10
+ /** Lowercase handle — the stable match key. Same contract as `normalized`. */
3
11
  handle: string;
12
+ /**
13
+ * The handle with its ORIGINAL case, for anything that hands the identifier
14
+ * back to a provider or stores it on a record.
15
+ *
16
+ * A public slug is lowercase by construction, so this equals `handle` for it.
17
+ * An `ACoAA…` provider id is base64url of a member URN — its case is
18
+ * significant, and folding it is irreversible data loss: the id then resolves
19
+ * at neither Unipile nor HarvestAPI (which answers HTTP 400 "You need to
20
+ * provide at least one valid argument"). Reach for this whenever the value
21
+ * leaves Oxygen; reach for `handle`/`normalized` whenever it is compared.
22
+ */
23
+ dispatchHandle: string;
24
+ /** Canonical URL built from `dispatchHandle` — what we store on a record. */
25
+ dispatchUrl: string;
4
26
  } | null;
5
27
  export declare function normalizeLinkedinProfileUrl(raw: string | null | undefined): LinkedinUrlNormalization;
6
28
  /**
@@ -48,18 +48,21 @@ export function normalizeLinkedinProfileUrl(raw) {
48
48
  const rawHandle = parts[markerIndex + 1];
49
49
  if (!rawHandle)
50
50
  return null;
51
- let handle;
51
+ let decoded;
52
52
  try {
53
- handle = decodeURIComponent(rawHandle).trim().toLowerCase();
53
+ decoded = decodeURIComponent(rawHandle).trim();
54
54
  }
55
55
  catch {
56
- handle = rawHandle.trim().toLowerCase();
56
+ decoded = rawHandle.trim();
57
57
  }
58
- if (!handle)
58
+ if (!decoded)
59
59
  return null;
60
+ const handle = decoded.toLowerCase();
60
61
  return {
61
62
  normalized: `https://www.linkedin.com/in/${handle}`,
62
63
  handle,
64
+ dispatchHandle: decoded,
65
+ dispatchUrl: `https://www.linkedin.com/in/${decoded}`,
63
66
  };
64
67
  }
65
68
  // A LinkedIn post URL carries the numeric activity id we address the post by:
@@ -73,6 +73,35 @@ export function setLogSink(sink) {
73
73
  function getLogSink() {
74
74
  return globalThis[LOG_SINK_SLOT] ?? null;
75
75
  }
76
+ const LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40 };
77
+ /**
78
+ * Minimum level that reaches the Axiom SINK. stdout is deliberately untouched —
79
+ * `flyctl logs`, `vercel logs`, and local runs keep every line, so nothing becomes
80
+ * un-debuggable; this only decides what we pay to store and query.
81
+ *
82
+ * Default `info`, i.e. debug never ships. Until 2026-07-31 `log()` had no level gate
83
+ * at all, so debug lines were shipped exactly like info — which contradicted the code
84
+ * that chose debug specifically to stay quiet (see the `work_queue.wake.failed` call
85
+ * in packages/work-queue/src/wake-client.ts: "Log at debug level so this doesn't
86
+ * generate noise during deploys" — it generated 10k rows/week regardless). Measured
87
+ * the day this landed: debug was 97,704 rows/day of `oxygen-logs`, 24% of all
88
+ * in-process rows, and 98% of that was a single `crm_person_photo_cycle_skipped`
89
+ * line reporting that nothing happened.
90
+ *
91
+ * Set `OXYGEN_LOG_SHIP_MIN_LEVEL=debug` to restore the old behaviour (e.g. while
92
+ * debugging a specific incident), or raise it to `warn` to ship only actionable lines.
93
+ */
94
+ function shipMinRank() {
95
+ const configured = process.env.OXYGEN_LOG_SHIP_MIN_LEVEL?.trim().toLowerCase();
96
+ if (configured
97
+ && Object.prototype.hasOwnProperty.call(LEVEL_RANK, configured)) {
98
+ return LEVEL_RANK[configured];
99
+ }
100
+ return LEVEL_RANK.info;
101
+ }
102
+ function shouldShip(level) {
103
+ return LEVEL_RANK[level] >= shipMinRank();
104
+ }
76
105
  export function log(level, msg, fields) {
77
106
  const context = getLogContext();
78
107
  const record = {
@@ -101,6 +130,8 @@ export function log(level, msg, fields) {
101
130
  else {
102
131
  console.log(line);
103
132
  }
133
+ if (!shouldShip(level))
134
+ return;
104
135
  const logSink = getLogSink();
105
136
  if (!logSink)
106
137
  return;
@@ -131,8 +162,16 @@ function hashErrorKey(input) {
131
162
  export function errorId(err) {
132
163
  if (!(err instanceof Error))
133
164
  return "non_error";
134
- const firstFrame = (err.stack ?? "").split("\n").slice(1, 2).join("");
135
- return hashErrorKey(`${err.name}:${err.message}:${firstFrame}`);
165
+ // A drizzle DrizzleQueryError carries the bound parameter values inside its
166
+ // own message (`Failed query: <sql>\nparams: <values>`), which also makes
167
+ // stack line 1 the `params:` continuation instead of a frame. Hashing either
168
+ // raw gave every repetition of one DB failure its own id — 101 identical
169
+ // oxygen_lead_db_cache.write_failed events grouped as 101 buckets of one, and
170
+ // the hash input was derived from customer row values (OXY-46). Redact the
171
+ // params tail and hash the first real frame so DB errors group again.
172
+ const message = redactSqlParameters(err.message);
173
+ const firstFrame = (err.stack ?? "").split("\n").find((line) => /^\s+at\s/.test(line)) ?? "";
174
+ return hashErrorKey(`${err.name}:${message}:${firstFrame}`);
136
175
  }
137
176
  export function errorFields(err) {
138
177
  if (err instanceof Error) {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Normalize a Microsoft Entra admin-consent URL for an operator handoff.
3
+ *
4
+ * Provider responses are external input. Only Microsoft's documented HTTPS
5
+ * login host and either provider-supported admin-consent path may become clickable.
6
+ */
7
+ export declare function normalizeMicrosoftAdminConsentUrl(value: unknown): string | null;
@@ -0,0 +1,29 @@
1
+ const MICROSOFT_LOGIN_HOST = "login.microsoftonline.com";
2
+ const ADMIN_CONSENT_PATH = /^\/(?:[^/]+\/)?adminconsent\/?$/i;
3
+ /**
4
+ * Normalize a Microsoft Entra admin-consent URL for an operator handoff.
5
+ *
6
+ * Provider responses are external input. Only Microsoft's documented HTTPS
7
+ * login host and either provider-supported admin-consent path may become clickable.
8
+ */
9
+ export function normalizeMicrosoftAdminConsentUrl(value) {
10
+ if (typeof value !== "string" || value.trim() === "")
11
+ return null;
12
+ let url;
13
+ try {
14
+ url = new URL(value.trim());
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ if (url.protocol !== "https:" ||
20
+ url.hostname.toLowerCase() !== MICROSOFT_LOGIN_HOST ||
21
+ url.username !== "" ||
22
+ url.password !== "" ||
23
+ url.port !== "" ||
24
+ url.hash !== "" ||
25
+ !ADMIN_CONSENT_PATH.test(url.pathname)) {
26
+ return null;
27
+ }
28
+ return url.toString();
29
+ }
@@ -7,7 +7,12 @@ export declare function buildPublishingMediaObjectKey(input: {
7
7
  organizationId: string;
8
8
  fileName?: string | null;
9
9
  }): string;
10
+ export declare function buildCopilotAttachmentObjectKey(input: {
11
+ organizationId: string;
12
+ fileName?: string | null;
13
+ }): string;
10
14
  export declare function isImportObjectKeyForOrganization(key: string, organizationId: string): boolean;
15
+ export declare function isCopilotAttachmentObjectKeyForOrganization(key: string, organizationId: string): boolean;
11
16
  export declare function isPublishingMediaObjectKeyForOrganization(key: string, organizationId: string): boolean;
12
17
  export type PresignedImportUpload = {
13
18
  uploadUrl: string;
@@ -30,6 +35,32 @@ export declare function presignPublishingMediaUpload(input: {
30
35
  contentType?: string | null;
31
36
  contentLength: number;
32
37
  }): Promise<PresignedPublishingMediaUpload>;
38
+ export declare function presignCopilotAttachmentUpload(input: {
39
+ organizationId: string;
40
+ fileName?: string | null;
41
+ contentType?: string | null;
42
+ contentLength: number;
43
+ }): Promise<PresignedImportUpload>;
44
+ /**
45
+ * Read an uploaded attachment back into memory. Bounded by `maxBytes` because
46
+ * the callers are a CSV header sniff and a text extraction, neither of which
47
+ * needs the whole object — and a 400MB upload must not become a 400MB buffer in
48
+ * a Vercel handler.
49
+ */
50
+ export declare function readCopilotAttachmentObject(input: {
51
+ storageKey: string;
52
+ maxBytes: number;
53
+ }): Promise<{
54
+ body: Uint8Array;
55
+ contentType: string | null;
56
+ truncated: boolean;
57
+ }>;
58
+ export declare function getCopilotAttachmentObjectMetadata(input: {
59
+ storageKey: string;
60
+ }): Promise<{
61
+ contentLength: number | null;
62
+ contentType: string | null;
63
+ }>;
33
64
  export declare function getImportObjectMetadata(input: {
34
65
  storageKey: string;
35
66
  }): Promise<{
@@ -65,9 +65,16 @@ export function buildPublishingMediaObjectKey(input) {
65
65
  const safeName = sanitizeFileName(input.fileName) || "media";
66
66
  return `publishing-media/${input.organizationId}/${randomUUID()}/${safeName}`;
67
67
  }
68
+ export function buildCopilotAttachmentObjectKey(input) {
69
+ const safeName = sanitizeFileName(input.fileName) || "attachment";
70
+ return `copilot/${input.organizationId}/${randomUUID()}/${safeName}`;
71
+ }
68
72
  export function isImportObjectKeyForOrganization(key, organizationId) {
69
73
  return key.startsWith(`imports/${organizationId}/`);
70
74
  }
75
+ export function isCopilotAttachmentObjectKeyForOrganization(key, organizationId) {
76
+ return key.startsWith(`copilot/${organizationId}/`);
77
+ }
71
78
  export function isPublishingMediaObjectKeyForOrganization(key, organizationId) {
72
79
  return key.startsWith(`publishing-media/${organizationId}/`);
73
80
  }
@@ -115,6 +122,60 @@ export async function presignPublishingMediaUpload(input) {
115
122
  expiresInSeconds: PRESIGN_EXPIRY_SECONDS,
116
123
  };
117
124
  }
125
+ export async function presignCopilotAttachmentUpload(input) {
126
+ const { client, config } = resolveClient();
127
+ const storageKey = buildCopilotAttachmentObjectKey({
128
+ organizationId: input.organizationId,
129
+ fileName: input.fileName ?? null,
130
+ });
131
+ const command = new PutObjectCommand({
132
+ Bucket: config.bucket,
133
+ Key: storageKey,
134
+ ContentLength: input.contentLength,
135
+ ...(input.contentType ? { ContentType: input.contentType } : {}),
136
+ });
137
+ const uploadUrl = await getSignedUrl(client, command, { expiresIn: PRESIGN_EXPIRY_SECONDS });
138
+ return {
139
+ uploadUrl,
140
+ bucket: config.bucket,
141
+ storageKey,
142
+ contentLength: input.contentLength,
143
+ provider: "s3",
144
+ expiresInSeconds: PRESIGN_EXPIRY_SECONDS,
145
+ };
146
+ }
147
+ /**
148
+ * Read an uploaded attachment back into memory. Bounded by `maxBytes` because
149
+ * the callers are a CSV header sniff and a text extraction, neither of which
150
+ * needs the whole object — and a 400MB upload must not become a 400MB buffer in
151
+ * a Vercel handler.
152
+ */
153
+ export async function readCopilotAttachmentObject(input) {
154
+ const { client, config } = resolveClient();
155
+ const result = await client.send(new GetObjectCommand({
156
+ Bucket: config.bucket,
157
+ Key: input.storageKey,
158
+ // Ask for one byte past the cap so a full read is distinguishable from a
159
+ // file that happens to be exactly maxBytes long.
160
+ Range: `bytes=0-${input.maxBytes}`,
161
+ }));
162
+ const raw = await result.Body?.transformToByteArray();
163
+ const body = raw ?? new Uint8Array();
164
+ const truncated = body.byteLength > input.maxBytes;
165
+ return {
166
+ body: truncated ? body.subarray(0, input.maxBytes) : body,
167
+ contentType: typeof result.ContentType === "string" ? result.ContentType : null,
168
+ truncated,
169
+ };
170
+ }
171
+ export async function getCopilotAttachmentObjectMetadata(input) {
172
+ const { client, config } = resolveClient();
173
+ const result = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
174
+ return {
175
+ contentLength: typeof result.ContentLength === "number" ? result.ContentLength : null,
176
+ contentType: typeof result.ContentType === "string" ? result.ContentType : null,
177
+ };
178
+ }
118
179
  export async function getImportObjectMetadata(input) {
119
180
  const { client, config } = resolveClient();
120
181
  const result = await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));