@oxygen-agent/cli 1.987.20 → 1.1010.1

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/admin-primary-providers-render.d.ts +0 -2
  3. package/dist/admin-primary-providers-render.js +1 -1
  4. package/dist/browser-login.js +1 -4
  5. package/dist/column-decision-options.d.ts +20 -0
  6. package/dist/column-decision-options.js +54 -0
  7. package/dist/command-manifest.d.ts +3 -2
  8. package/dist/command-manifest.js +25 -2
  9. package/dist/credentials.d.ts +1 -1
  10. package/dist/functions-commands.js +13 -9
  11. package/dist/help.d.ts +8 -0
  12. package/dist/help.js +46 -0
  13. package/dist/index.js +2751 -242
  14. package/dist/knowledge-mirror.d.ts +2 -2
  15. package/dist/runtime.d.ts +0 -15
  16. package/dist/runtime.js +1 -1
  17. package/dist/search-ai-filter-notice.d.ts +17 -0
  18. package/dist/search-ai-filter-notice.js +38 -0
  19. package/dist/session.d.ts +4 -3
  20. package/dist/skills.d.ts +8 -7
  21. package/dist/skills.js +58 -20
  22. package/dist/transcript.d.ts +2 -1
  23. package/dist/util.d.ts +10 -1
  24. package/dist/util.js +14 -2
  25. package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
  26. package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
  27. package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
  28. package/node_modules/@oxygen/formula/dist/coerce.d.ts +10 -0
  29. package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
  30. package/node_modules/@oxygen/formula/dist/formula-functions.js +65 -0
  31. package/node_modules/@oxygen/formula/dist/hash.d.ts +19 -0
  32. package/node_modules/@oxygen/formula/dist/hash.js +199 -0
  33. package/node_modules/@oxygen/formula/dist/value-cleaners.d.ts +6 -1
  34. package/node_modules/@oxygen/formula/dist/value-cleaners.js +10 -26
  35. package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
  36. package/node_modules/@oxygen/shared/dist/array-utils.d.ts +5 -0
  37. package/node_modules/@oxygen/shared/dist/array-utils.js +11 -0
  38. package/node_modules/@oxygen/shared/dist/billing.d.ts +99 -22
  39. package/node_modules/@oxygen/shared/dist/billing.js +195 -40
  40. package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
  41. package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +27 -0
  42. package/node_modules/@oxygen/shared/dist/capability-discovery.js +311 -28
  43. package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
  44. package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
  45. package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
  46. package/node_modules/@oxygen/shared/dist/column-autofill.d.ts +52 -0
  47. package/node_modules/@oxygen/shared/dist/column-autofill.js +62 -0
  48. package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
  49. package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
  50. package/node_modules/@oxygen/shared/dist/column-output-fields.js +2 -6
  51. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +113 -0
  52. package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +548 -0
  53. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +6 -6
  54. package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +6 -6
  55. package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
  56. package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
  57. package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
  58. package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
  59. package/node_modules/@oxygen/shared/dist/deploy-env.d.ts +74 -0
  60. package/node_modules/@oxygen/shared/dist/deploy-env.js +82 -0
  61. package/node_modules/@oxygen/shared/dist/dnc-rules.d.ts +130 -0
  62. package/node_modules/@oxygen/shared/dist/dnc-rules.js +221 -0
  63. package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +107 -0
  64. package/node_modules/@oxygen/shared/dist/enrichment-intents.js +809 -0
  65. package/node_modules/@oxygen/shared/dist/error-message.d.ts +1 -0
  66. package/node_modules/@oxygen/shared/dist/error-message.js +3 -0
  67. package/node_modules/@oxygen/shared/dist/error-redaction.js +1 -3
  68. package/node_modules/@oxygen/shared/dist/external-write-policy.d.ts +33 -0
  69. package/node_modules/@oxygen/shared/dist/external-write-policy.js +68 -0
  70. package/node_modules/@oxygen/shared/dist/format-percent.d.ts +8 -0
  71. package/node_modules/@oxygen/shared/dist/format-percent.js +13 -0
  72. package/node_modules/@oxygen/shared/dist/freemail-domains.d.ts +81 -0
  73. package/node_modules/@oxygen/shared/dist/freemail-domains.js +157 -0
  74. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.d.ts +1 -0
  75. package/node_modules/@oxygen/shared/dist/future-signup-lifecycle-projection.js +1 -1
  76. package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
  77. package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
  78. package/node_modules/@oxygen/shared/dist/index.d.ts +15 -0
  79. package/node_modules/@oxygen/shared/dist/index.js +15 -0
  80. package/node_modules/@oxygen/shared/dist/json-path.js +1 -3
  81. package/node_modules/@oxygen/shared/dist/knowledge-bases.js +1 -3
  82. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.d.ts +2 -2
  83. package/node_modules/@oxygen/shared/dist/knowledge-bootstrap.js +2 -2
  84. package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
  85. package/node_modules/@oxygen/shared/dist/langfuse.js +416 -14
  86. package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +33 -0
  87. package/node_modules/@oxygen/shared/dist/linkedin-countries.js +361 -0
  88. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
  89. package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
  90. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
  91. package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
  92. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
  93. package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
  94. package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
  95. package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
  96. package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
  97. package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
  98. package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
  99. package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
  100. package/node_modules/@oxygen/shared/dist/log-sink-selector.d.ts +39 -0
  101. package/node_modules/@oxygen/shared/dist/log-sink-selector.js +56 -0
  102. package/node_modules/@oxygen/shared/dist/log.d.ts +1 -0
  103. package/node_modules/@oxygen/shared/dist/log.js +6 -1
  104. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
  105. package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
  106. package/node_modules/@oxygen/shared/dist/object-storage.d.ts +17 -0
  107. package/node_modules/@oxygen/shared/dist/object-storage.js +21 -0
  108. package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
  109. package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
  110. package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +79 -0
  111. package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +366 -0
  112. package/node_modules/@oxygen/shared/dist/plan-capabilities.js +1 -0
  113. package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +23 -22
  114. package/node_modules/@oxygen/shared/dist/plan-limits.js +45 -18
  115. package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +48 -41
  116. package/node_modules/@oxygen/shared/dist/pricing-sheet.js +36 -25
  117. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +22 -22
  118. package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +40 -34
  119. package/node_modules/@oxygen/shared/dist/product-analytics-environment.js +9 -0
  120. package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +36 -2
  121. package/node_modules/@oxygen/shared/dist/product-analytics-events.js +36 -1
  122. package/node_modules/@oxygen/shared/dist/rate-window.d.ts +5 -0
  123. package/node_modules/@oxygen/shared/dist/rate-window.js +8 -0
  124. package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
  125. package/node_modules/@oxygen/shared/dist/research-output-contract.js +1 -3
  126. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
  127. package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
  128. package/node_modules/@oxygen/shared/dist/search-vocab.js +4 -5
  129. package/node_modules/@oxygen/shared/dist/select-options.js +6 -1
  130. package/node_modules/@oxygen/shared/dist/sequence-failures.js +1 -5
  131. package/node_modules/@oxygen/shared/dist/sequences.d.ts +23 -0
  132. package/node_modules/@oxygen/shared/dist/sequences.js +115 -5
  133. package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
  134. package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
  135. package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
  136. package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
  137. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
  138. package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
  139. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
  140. package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
  141. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
  142. package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
  143. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
  144. package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
  145. package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
  146. package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
  147. package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +22 -10
  148. package/node_modules/@oxygen/shared/dist/spend-safety.js +15 -21
  149. package/node_modules/@oxygen/shared/dist/sql-rows.d.ts +1 -0
  150. package/node_modules/@oxygen/shared/dist/sql-rows.js +3 -0
  151. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
  152. package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
  153. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
  154. package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
  155. package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
  156. package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
  157. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
  158. package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
  159. package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
  160. package/node_modules/@oxygen/shared/dist/telemetry.js +28 -2
  161. package/node_modules/@oxygen/shared/dist/type-guards.d.ts +22 -0
  162. package/node_modules/@oxygen/shared/dist/type-guards.js +35 -0
  163. package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
  164. package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
  165. package/node_modules/@oxygen/shared/dist/value-readers.d.ts +21 -0
  166. package/node_modules/@oxygen/shared/dist/value-readers.js +59 -0
  167. package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
  168. package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
  169. package/node_modules/@oxygen/shared/dist/version.js +8 -1
  170. package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
  171. package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
  172. package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
  173. package/node_modules/@oxygen/shared/package.json +59 -0
  174. package/node_modules/@oxygen/workflows/dist/graph/expression.js +2 -5
  175. package/node_modules/@oxygen/workflows/dist/graph/params.js +1 -1
  176. package/package.json +3 -2
@@ -0,0 +1,161 @@
1
+ // Prompt identity for LLM tracing (ADR 0014). Prompts stay in code or in the
2
+ // tenant database; nothing here is fetched at runtime to BUILD a prompt, so a
3
+ // Langfuse outage can never leave a model call without its instructions.
4
+ //
5
+ // Every traced model call carries a prompt reference: a stable name plus a
6
+ // content hash of the template it was rendered from. Code-owned templates are
7
+ // declared once with `defineLlmPrompt` and are REGISTERED — the deploy-time sync
8
+ // (`scripts/ops/langfuse-prompts-sync.mjs`) publishes each one to Langfuse
9
+ // Prompt Management under the label `oxygen-<hash>`, which lets the tracing
10
+ // client link generations to the native prompt version. Customer-authored
11
+ // templates (table AI columns, Agent instructions, message templates) get an
12
+ // unregistered reference: name + hash as metadata only, never published.
13
+ import { createHash } from "node:crypto";
14
+ const NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
15
+ const registry = new Map();
16
+ const conflicts = new Set();
17
+ function canonical(value) {
18
+ if (typeof value === "string")
19
+ return value;
20
+ if (Array.isArray(value))
21
+ return `[${value.map(canonical).join(",")}]`;
22
+ if (value && typeof value === "object") {
23
+ const entries = Object.entries(value)
24
+ .filter(([, entry]) => entry !== undefined)
25
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
26
+ return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${typeof entry === "string" ? JSON.stringify(entry) : canonical(entry)}`).join(",")}}`;
27
+ }
28
+ return JSON.stringify(value ?? null);
29
+ }
30
+ /** Deterministic content hash; object key order never changes it. */
31
+ export function llmPromptHash(template) {
32
+ return createHash("sha256").update(canonical(template), "utf8").digest("hex").slice(0, 12);
33
+ }
34
+ /** The Langfuse label that identifies one exact template version. */
35
+ export function llmPromptLabel(hash) {
36
+ return `oxygen-${hash}`;
37
+ }
38
+ /**
39
+ * Declare a code-owned prompt template. Call once at module scope with the
40
+ * template the call site renders from. Placeholders stay literal (`{{name}}`)
41
+ * so the hash moves only when the instructions do, not per request.
42
+ */
43
+ export function defineLlmPrompt(name, template) {
44
+ // Never throws: this runs at module scope in production code. The catalog
45
+ // test (llm-prompt-catalog.test.ts) enforces valid, unique, non-empty names.
46
+ const definition = { name, hash: llmPromptHash(template), registered: true, template };
47
+ const existing = registry.get(name);
48
+ if (existing && existing.hash !== definition.hash)
49
+ conflicts.add(name);
50
+ registry.set(name, definition);
51
+ return definition;
52
+ }
53
+ /** Names declared twice with different templates (a catalog defect). */
54
+ export function conflictingLlmPromptNames() {
55
+ return [...conflicts].sort();
56
+ }
57
+ /** The Langfuse-safe name shape code prompts must use. */
58
+ export function isValidLlmPromptName(name) {
59
+ return NAME_PATTERN.test(name);
60
+ }
61
+ /**
62
+ * An AI column's prompt as the text Langfuse stores: the instruction template
63
+ * (placeholders unresolved) plus its output schema, so a schema change is a new
64
+ * prompt version too. Customer columns and code-owned helpers share it.
65
+ */
66
+ export function aiColumnPromptTemplate(definition) {
67
+ const prompt = typeof definition.prompt === "string" ? definition.prompt : JSON.stringify(definition.prompt ?? null);
68
+ return definition.outputSchema == null
69
+ ? prompt
70
+ : `${prompt}\n\nOUTPUT SCHEMA\n${JSON.stringify(definition.outputSchema, null, 2)}`;
71
+ }
72
+ /** Declare a code-owned AI helper prompt (knowledge synthesis, drafts, ...). */
73
+ export function defineAiColumnPrompt(name, definition) {
74
+ return defineLlmPrompt(name, aiColumnPromptTemplate(definition));
75
+ }
76
+ /**
77
+ * Resolve a lazy prompt getter on a product path. Prompt identity is telemetry:
78
+ * a template renderer that throws must degrade to an unlinked trace, never
79
+ * fail the model call it describes.
80
+ */
81
+ export function safeLlmPrompt(getter) {
82
+ try {
83
+ return getter();
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /** Reference a customer-authored or per-request template: metadata only. */
90
+ export function llmPromptRef(name, template) {
91
+ return { name, hash: llmPromptHash(template), registered: false };
92
+ }
93
+ /**
94
+ * The registered code prompt whose template is exactly this text, if any: lets
95
+ * a tracer that only sees the rendered system message recognise a static
96
+ * template (compaction, sub-agent instructions) without importing its owner.
97
+ */
98
+ export function findLlmPromptByTemplate(template) {
99
+ const hash = llmPromptHash(template);
100
+ for (const definition of registry.values())
101
+ if (definition.hash === hash)
102
+ return definition;
103
+ return undefined;
104
+ }
105
+ /**
106
+ * The declared code prompt behind a reference, only when its template still
107
+ * hashes to that reference: lets the tracing client publish the exact text a
108
+ * generation was rendered from, and never a same-named neighbour.
109
+ */
110
+ export function registeredLlmPrompt(ref) {
111
+ if (!ref.registered)
112
+ return undefined;
113
+ const definition = registry.get(ref.name);
114
+ return definition?.hash === ref.hash ? definition : undefined;
115
+ }
116
+ /** Every code prompt declared by the modules imported so far, sorted by name. */
117
+ export function listLlmPrompts() {
118
+ return [...registry.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
119
+ }
120
+ function systemTexts(messages) {
121
+ if (!Array.isArray(messages))
122
+ return [];
123
+ const texts = [];
124
+ for (const message of messages) {
125
+ if (!message || typeof message !== "object" || message.role !== "system")
126
+ continue;
127
+ const content = message.content;
128
+ if (typeof content === "string")
129
+ texts.push(content);
130
+ else if (Array.isArray(content)) {
131
+ const text = content
132
+ .map((part) => (part && typeof part === "object" && typeof part.text === "string" ? part.text : ""))
133
+ .join("");
134
+ if (text)
135
+ texts.push(text);
136
+ }
137
+ }
138
+ return texts;
139
+ }
140
+ /**
141
+ * The prompt a Copilot/Agent model call ran on, from the exact messages sent.
142
+ * A main-loop call (`kind` "turn") uses the surface's versioned system
143
+ * template; compaction and sub-agent calls match a registered static template
144
+ * by the content of one of their system messages; anything else is traced by
145
+ * content hash only.
146
+ */
147
+ export function llmPromptForModelCall(input) {
148
+ if (input.kind === "turn" && input.turnPrompt)
149
+ return input.turnPrompt;
150
+ const texts = systemTexts(input.messages);
151
+ if (texts.length === 0)
152
+ return input.turnPrompt ?? null;
153
+ if (input.kind !== "turn") {
154
+ for (const text of texts) {
155
+ const registered = findLlmPromptByTemplate(text);
156
+ if (registered)
157
+ return registered;
158
+ }
159
+ }
160
+ return llmPromptRef(`${input.surface}.${input.kind === "turn" ? "system" : input.kind}`, texts.join("\n\n"));
161
+ }
@@ -0,0 +1,39 @@
1
+ import type { LogSink } from "./log.js";
2
+ /**
3
+ * Which transport(s) `log()` records leave the process on.
4
+ *
5
+ * Portability selector (`.agents/skills/oxygen-platform-portability/SKILL.md`):
6
+ * one concern, one variable, and **unset means managed**. An environment that
7
+ * sets nothing keeps today's behaviour exactly — the Axiom shipper alone, with
8
+ * the same handle and the same drain — so this can land on `dev` and flow to
9
+ * managed production with no flag day. Self-hosted opts in with `otlp` (an
10
+ * OTLP/HTTP collector instead of Axiom) or `both` (during a cutover window,
11
+ * when the two stores must be comparable before Axiom is switched off).
12
+ *
13
+ * An unrecognised value resolves to `axiom` rather than throwing: a typo in a
14
+ * telemetry variable must not decide whether a process boots.
15
+ */
16
+ export type LogSinkSelector = "axiom" | "otlp" | "both";
17
+ export declare function resolveLogSinkSelector(env?: NodeJS.ProcessEnv): LogSinkSelector;
18
+ export declare function logSinkSelectorIncludesAxiom(selector: LogSinkSelector): boolean;
19
+ export declare function logSinkSelectorIncludesOtlp(selector: LogSinkSelector): boolean;
20
+ /**
21
+ * Failed sink invocations swallowed by a composed sink since process start.
22
+ *
23
+ * A composed sink cannot report its own failure through `log()` — it IS what
24
+ * `log()` calls, so logging from it would recurse. The count is the honest
25
+ * alternative to silence: readable from a diagnostic surface without the
26
+ * failing transport ever getting a second chance to throw.
27
+ */
28
+ export declare function composedLogSinkFailureCount(): number;
29
+ /**
30
+ * Fans one record out to several transports.
31
+ *
32
+ * Every sink is called, in order, and each is wrapped: `both` exists precisely
33
+ * for the window where one of the two stores is not trusted yet, so a throwing
34
+ * Axiom sink must not stop the OTLP one from receiving the record (or the
35
+ * reverse). Nulls are dropped, so a caller can pass an adapter that declined to
36
+ * start without branching; all-null composes to `null`, which `setLogSink`
37
+ * reads as "no sink" and which keeps `log()` on its plain stdout path.
38
+ */
39
+ export declare function composeLogSinks(...sinks: Array<LogSink | null | undefined>): LogSink | null;
@@ -0,0 +1,56 @@
1
+ export function resolveLogSinkSelector(env = process.env) {
2
+ const configured = env.OXYGEN_LOG_SINK?.trim().toLowerCase();
3
+ if (configured === "otlp")
4
+ return "otlp";
5
+ if (configured === "both")
6
+ return "both";
7
+ return "axiom";
8
+ }
9
+ export function logSinkSelectorIncludesAxiom(selector) {
10
+ return selector === "axiom" || selector === "both";
11
+ }
12
+ export function logSinkSelectorIncludesOtlp(selector) {
13
+ return selector === "otlp" || selector === "both";
14
+ }
15
+ let composedSinkFailures = 0;
16
+ /**
17
+ * Failed sink invocations swallowed by a composed sink since process start.
18
+ *
19
+ * A composed sink cannot report its own failure through `log()` — it IS what
20
+ * `log()` calls, so logging from it would recurse. The count is the honest
21
+ * alternative to silence: readable from a diagnostic surface without the
22
+ * failing transport ever getting a second chance to throw.
23
+ */
24
+ export function composedLogSinkFailureCount() {
25
+ return composedSinkFailures;
26
+ }
27
+ /**
28
+ * Fans one record out to several transports.
29
+ *
30
+ * Every sink is called, in order, and each is wrapped: `both` exists precisely
31
+ * for the window where one of the two stores is not trusted yet, so a throwing
32
+ * Axiom sink must not stop the OTLP one from receiving the record (or the
33
+ * reverse). Nulls are dropped, so a caller can pass an adapter that declined to
34
+ * start without branching; all-null composes to `null`, which `setLogSink`
35
+ * reads as "no sink" and which keeps `log()` on its plain stdout path.
36
+ */
37
+ export function composeLogSinks(...sinks) {
38
+ const active = sinks.filter((sink) => typeof sink === "function");
39
+ if (active.length === 0)
40
+ return null;
41
+ const only = active[0];
42
+ if (active.length === 1 && only)
43
+ return only;
44
+ return (record) => {
45
+ for (const sink of active) {
46
+ try {
47
+ sink(record);
48
+ }
49
+ catch {
50
+ // Telemetry must never break the code it observes, and this is the one
51
+ // place that cannot say so out loud (see composedLogSinkFailureCount).
52
+ composedSinkFailures += 1;
53
+ }
54
+ }
55
+ };
56
+ }
@@ -19,6 +19,7 @@ export declare function enterLogContext(ctx: LogContext): void;
19
19
  export declare function logLevelForHttpStatus(status: number): LogLevel;
20
20
  export type LogSink = (record: Record<string, unknown>) => void;
21
21
  export declare function setLogSink(sink: LogSink | null): void;
22
+ export declare function getLogSink(): LogSink | null;
22
23
  export declare function log(level: LogLevel, msg: string, fields?: Record<string, unknown>): void;
23
24
  export declare function errorId(err: unknown): string;
24
25
  export declare function errorFields(err: unknown): Record<string, unknown>;
@@ -118,7 +118,12 @@ const LOG_SINK_SLOT = Symbol.for("oxygen.log.sink");
118
118
  export function setLogSink(sink) {
119
119
  globalThis[LOG_SINK_SLOT] = sink;
120
120
  }
121
- function getLogSink() {
121
+ // Exported so a second transport can COMPOSE with the sink already registered
122
+ // rather than replacing it: under `OXYGEN_LOG_SINK=both` the OTLP adapter starts
123
+ // after the Axiom shipper has claimed the slot, and reading the slot back is what
124
+ // lets it fan out to both (`log-sink-selector.ts`). Reading through this function,
125
+ // not the raw Symbol, keeps the slot's location owned by exactly one module.
126
+ export function getLogSink() {
122
127
  return globalThis[LOG_SINK_SLOT] ?? null;
123
128
  }
124
129
  const LEVEL_RANK = { debug: 10, info: 20, warn: 30, error: 40 };
@@ -0,0 +1,90 @@
1
+ export declare const MAILBOX_EGRESS_MODE_ENV = "OXYGEN_MAILBOX_EGRESS_MODE";
2
+ export type MailboxEgressMode = "direct" | "proxy";
3
+ /** `proxy` only when explicitly selected; anything else keeps today's transport. */
4
+ export declare function readMailboxEgressMode(env?: NodeJS.ProcessEnv): MailboxEgressMode;
5
+ export type DedicatedEgressInventoryRow = {
6
+ id: string;
7
+ organizationId: string | null;
8
+ status: string;
9
+ createdAt: Date | null;
10
+ vendorVerifiedAt: Date | null;
11
+ metadata: Record<string, unknown> | null;
12
+ };
13
+ export type DedicatedEgressReadinessInput = {
14
+ createdAt: Date | null;
15
+ environment: "dev" | "prod";
16
+ metadata: Record<string, unknown> | null;
17
+ vendorVerifiedAt: Date | null;
18
+ };
19
+ export type DedicatedEgressOwnershipSnapshot = {
20
+ activeOrganizationIds: ReadonlySet<string>;
21
+ /** Every recent retirement marker is re-evaluated on every read. */
22
+ retiredMetadataByOrganization: ReadonlyMap<string, readonly Record<string, unknown>[]>;
23
+ /** Present only when exactly one active, entitled row exists for the organization. */
24
+ readinessInputByOrganization: ReadonlyMap<string, DedicatedEgressReadinessInput>;
25
+ };
26
+ export type DedicatedEgressClassification = DedicatedEgressOwnershipSnapshot & {
27
+ /** Two active apps could race the same org through different IPs. */
28
+ conflicts: {
29
+ organizationId: string;
30
+ activeIds: string[];
31
+ }[];
32
+ /** Active inventory whose add-on entitlement is not live. */
33
+ entitlementMissing: {
34
+ organizationId: string;
35
+ activeId: string;
36
+ }[];
37
+ };
38
+ export type DedicatedEgressOwnership = {
39
+ /** Active or still-cooling dedicated inventory: never the shared IP. */
40
+ ownedOrganizationIds: ReadonlySet<string>;
41
+ /** Exact active and ready Fly app for each org; org identity alone is insufficient. */
42
+ readyAppNameByOrganization: ReadonlyMap<string, string>;
43
+ };
44
+ /**
45
+ * Classify `status in (active, retired)` dedicated Fly rows for one environment
46
+ * plus the orgs holding a live `dedicated_egress_ip` add-on. Entitlement is
47
+ * conservative ownership evidence: a paying org whose inventory row is missing
48
+ * still stays off the shared IP while its dedicated transport also blocks.
49
+ */
50
+ export declare function classifyDedicatedEgressInventory(input: {
51
+ rows: readonly DedicatedEgressInventoryRow[];
52
+ entitledOrganizationIds: ReadonlySet<string>;
53
+ environment: "dev" | "prod";
54
+ now: number;
55
+ }): DedicatedEgressClassification;
56
+ /**
57
+ * Ownership at `now`. Re-evaluated on every read of a cached snapshot, because
58
+ * caching a readiness boolean would extend a liveness grant past its expiry.
59
+ */
60
+ export declare function evaluateDedicatedEgressOwnership(snapshot: DedicatedEgressOwnershipSnapshot, now: number): DedicatedEgressOwnership;
61
+ export type MailboxEgressRoute =
62
+ /** Leave from this process's own address (direct mode only). */
63
+ {
64
+ kind: "direct";
65
+ }
66
+ /** Tunnel through the proxy Machine of this Fly app, which owns the IP. */
67
+ | {
68
+ kind: "proxy";
69
+ appName: string;
70
+ tier: "shared" | "dedicated";
71
+ }
72
+ /** No transport may carry this org right now. Fail closed: never fall back. */
73
+ | {
74
+ kind: "blocked";
75
+ reason: "dedicated_egress_not_ready" | "ownership_unreadable";
76
+ };
77
+ export declare const MAILBOX_EGRESS_SHARED_APP_ENV = "OXYGEN_MAILBOX_EGRESS_SHARED_APP";
78
+ /**
79
+ * The Fly app whose static egress IP carries every non-dedicated org. Dev sets
80
+ * OXYGEN_MAILBOX_EGRESS_SHARED_APP=oxygen-table-worker-prod and tunnels through
81
+ * the production shared proxy (founder decision 2026-09-25: Fly holds only
82
+ * production proxies). Web and worker read the same value, so they agree.
83
+ */
84
+ export declare function sharedMailboxEgressAppName(environment: "dev" | "prod", env?: NodeJS.ProcessEnv): string;
85
+ /**
86
+ * The proxy-mode route for one org. A dedicated org uses its own app once ready
87
+ * and is blocked until then: routing it through the shared IP, even briefly,
88
+ * would show its mailboxes a second, shared source address.
89
+ */
90
+ export declare function mailboxEgressRouteFor(organizationId: string, ownership: DedicatedEgressOwnership | null, environment: "dev" | "prod"): MailboxEgressRoute;
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Which static egress IP may carry an organization's mailbox traffic, as pure
3
+ * functions over control-plane rows. The worker (every org, cached) and the web
4
+ * app (one org, per request) must reach the same answer from the same rows, so
5
+ * the classification lives here rather than in either process.
6
+ *
7
+ * Two transports read it. In `direct` mode the process's own IP carries shared
8
+ * orgs and a dedicated org's own Fly drain carries it. In `proxy` mode every
9
+ * process tunnels through the one Fly proxy Machine that owns the right IP:
10
+ * `oxygen-table-worker-<env>` for shared orgs, the org's own dedicated app
11
+ * otherwise (ADR 0029 amendment 2026-09-25).
12
+ */
13
+ import { dedicatedEgressRetirementStillCooling, evaluateFlyDedicatedEgressTransportReadiness, } from "./egress-transport-readiness.js";
14
+ export const MAILBOX_EGRESS_MODE_ENV = "OXYGEN_MAILBOX_EGRESS_MODE";
15
+ /** `proxy` only when explicitly selected; anything else keeps today's transport. */
16
+ export function readMailboxEgressMode(env = process.env) {
17
+ return env[MAILBOX_EGRESS_MODE_ENV]?.trim().toLowerCase() === "proxy" ? "proxy" : "direct";
18
+ }
19
+ /**
20
+ * Classify `status in (active, retired)` dedicated Fly rows for one environment
21
+ * plus the orgs holding a live `dedicated_egress_ip` add-on. Entitlement is
22
+ * conservative ownership evidence: a paying org whose inventory row is missing
23
+ * still stays off the shared IP while its dedicated transport also blocks.
24
+ */
25
+ export function classifyDedicatedEgressInventory(input) {
26
+ const rowsByOrganization = new Map();
27
+ for (const row of input.rows) {
28
+ if (!row.organizationId)
29
+ continue;
30
+ const current = rowsByOrganization.get(row.organizationId) ?? [];
31
+ current.push(row);
32
+ rowsByOrganization.set(row.organizationId, current);
33
+ }
34
+ const activeOrganizationIds = new Set(input.entitledOrganizationIds);
35
+ const retiredMetadataByOrganization = new Map();
36
+ const readinessInputByOrganization = new Map();
37
+ const conflicts = [];
38
+ const entitlementMissing = [];
39
+ for (const [organizationId, organizationRows] of rowsByOrganization) {
40
+ const activeRows = organizationRows.filter((row) => row.status === "active");
41
+ const coolingRetiredRows = organizationRows.filter((row) => row.status === "retired" && dedicatedEgressRetirementStillCooling(row.metadata, input.now));
42
+ // Shared must keep excluding the org, but no dedicated transport may carry it
43
+ // while two active apps could race its jobs through different source IPs. A
44
+ // just-retired row also keeps shared away for one handoff window.
45
+ if (activeRows.length > 0)
46
+ activeOrganizationIds.add(organizationId);
47
+ if (coolingRetiredRows.length > 0) {
48
+ // Preserve every marker: picking one unordered row could choose an older
49
+ // retirement and hand the org back while a newer app's cache is alive.
50
+ retiredMetadataByOrganization.set(organizationId, coolingRetiredRows.map((row) => row.metadata ?? {}));
51
+ }
52
+ if (activeRows.length !== 1) {
53
+ if (activeRows.length > 1) {
54
+ conflicts.push({ organizationId, activeIds: activeRows.map((row) => row.id) });
55
+ }
56
+ continue;
57
+ }
58
+ const row = activeRows[0];
59
+ if (!input.entitledOrganizationIds.has(organizationId)) {
60
+ entitlementMissing.push({ organizationId, activeId: row.id });
61
+ continue;
62
+ }
63
+ readinessInputByOrganization.set(organizationId, {
64
+ createdAt: row.createdAt,
65
+ environment: input.environment,
66
+ metadata: row.metadata,
67
+ vendorVerifiedAt: row.vendorVerifiedAt,
68
+ });
69
+ }
70
+ return {
71
+ activeOrganizationIds,
72
+ retiredMetadataByOrganization,
73
+ readinessInputByOrganization,
74
+ conflicts,
75
+ entitlementMissing,
76
+ };
77
+ }
78
+ /**
79
+ * Ownership at `now`. Re-evaluated on every read of a cached snapshot, because
80
+ * caching a readiness boolean would extend a liveness grant past its expiry.
81
+ */
82
+ export function evaluateDedicatedEgressOwnership(snapshot, now) {
83
+ const ownedOrganizationIds = new Set(snapshot.activeOrganizationIds);
84
+ for (const [organizationId, metadataRows] of snapshot.retiredMetadataByOrganization) {
85
+ if (metadataRows.some((metadata) => dedicatedEgressRetirementStillCooling(metadata, now))) {
86
+ ownedOrganizationIds.add(organizationId);
87
+ }
88
+ }
89
+ const readyAppNameByOrganization = new Map();
90
+ for (const [organizationId, readinessInput] of snapshot.readinessInputByOrganization) {
91
+ const readiness = evaluateFlyDedicatedEgressTransportReadiness(readinessInput, now);
92
+ if (readiness.ready)
93
+ readyAppNameByOrganization.set(organizationId, readiness.appName);
94
+ }
95
+ return { ownedOrganizationIds, readyAppNameByOrganization };
96
+ }
97
+ export const MAILBOX_EGRESS_SHARED_APP_ENV = "OXYGEN_MAILBOX_EGRESS_SHARED_APP";
98
+ const FLY_APP_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/;
99
+ /**
100
+ * The Fly app whose static egress IP carries every non-dedicated org. Dev sets
101
+ * OXYGEN_MAILBOX_EGRESS_SHARED_APP=oxygen-table-worker-prod and tunnels through
102
+ * the production shared proxy (founder decision 2026-09-25: Fly holds only
103
+ * production proxies). Web and worker read the same value, so they agree.
104
+ */
105
+ export function sharedMailboxEgressAppName(environment, env = process.env) {
106
+ const override = env[MAILBOX_EGRESS_SHARED_APP_ENV]?.trim();
107
+ if (override) {
108
+ if (!FLY_APP_NAME_PATTERN.test(override)) {
109
+ throw new Error(`${MAILBOX_EGRESS_SHARED_APP_ENV} is not a Fly app name`);
110
+ }
111
+ return override;
112
+ }
113
+ return `oxygen-table-worker-${environment}`;
114
+ }
115
+ /**
116
+ * The proxy-mode route for one org. A dedicated org uses its own app once ready
117
+ * and is blocked until then: routing it through the shared IP, even briefly,
118
+ * would show its mailboxes a second, shared source address.
119
+ */
120
+ export function mailboxEgressRouteFor(organizationId, ownership, environment) {
121
+ if (!ownership)
122
+ return { kind: "blocked", reason: "ownership_unreadable" };
123
+ const dedicatedApp = ownership.readyAppNameByOrganization.get(organizationId);
124
+ if (dedicatedApp)
125
+ return { kind: "proxy", appName: dedicatedApp, tier: "dedicated" };
126
+ if (ownership.ownedOrganizationIds.has(organizationId)) {
127
+ return { kind: "blocked", reason: "dedicated_egress_not_ready" };
128
+ }
129
+ return { kind: "proxy", appName: sharedMailboxEgressAppName(environment), tier: "shared" };
130
+ }
@@ -5,6 +5,23 @@ export declare function resolveObjectStorageClient(): {
5
5
  client: S3Client;
6
6
  bucket: string;
7
7
  };
8
+ /**
9
+ * The same endpoint and credentials, aimed at a DIFFERENT bucket.
10
+ *
11
+ * Every namespace above shares one private bucket because they share one access
12
+ * story: org-scoped keys, no anonymous reads. Public blobs (agency-directory
13
+ * logos; Payload media reaches the same bucket through its own plugin) are the
14
+ * opposite — their bytes are fetched by browsers with no credential at all — so
15
+ * on a self-hosted deploy they belong in a bucket whose read policy is public,
16
+ * never in the one holding customer lead lists.
17
+ * Same S3 account, different bucket: hence the client is reused and only the
18
+ * bucket is overridden. Empty is refused rather than silently falling back to
19
+ * the import bucket, which is exactly the mix-up this split exists to prevent.
20
+ */
21
+ export declare function resolveObjectStorageClientForBucket(bucket: string): {
22
+ client: S3Client;
23
+ bucket: string;
24
+ };
8
25
  export declare function buildImportObjectKey(input: {
9
26
  organizationId: string;
10
27
  fileName?: string | null;
@@ -82,6 +82,27 @@ export function resolveObjectStorageClient() {
82
82
  const { client, config } = resolveClient();
83
83
  return { client, bucket: config.bucket };
84
84
  }
85
+ /**
86
+ * The same endpoint and credentials, aimed at a DIFFERENT bucket.
87
+ *
88
+ * Every namespace above shares one private bucket because they share one access
89
+ * story: org-scoped keys, no anonymous reads. Public blobs (agency-directory
90
+ * logos; Payload media reaches the same bucket through its own plugin) are the
91
+ * opposite — their bytes are fetched by browsers with no credential at all — so
92
+ * on a self-hosted deploy they belong in a bucket whose read policy is public,
93
+ * never in the one holding customer lead lists.
94
+ * Same S3 account, different bucket: hence the client is reused and only the
95
+ * bucket is overridden. Empty is refused rather than silently falling back to
96
+ * the import bucket, which is exactly the mix-up this split exists to prevent.
97
+ */
98
+ export function resolveObjectStorageClientForBucket(bucket) {
99
+ const target = bucket.trim();
100
+ if (!target) {
101
+ throw new OxygenError("object_storage_bucket_missing", "No object-storage bucket was resolved for this operation.", { exitCode: 1 });
102
+ }
103
+ const { client } = resolveClient();
104
+ return { client, bucket: target };
105
+ }
85
106
  export function buildImportObjectKey(input) {
86
107
  const safeName = sanitizeFileName(input.fileName) || "import";
87
108
  return `imports/${input.organizationId}/${randomUUID()}/${safeName}`;
@@ -0,0 +1,24 @@
1
+ type AttributeContainer = {
2
+ attributes?: Record<string, unknown>;
3
+ name?: string;
4
+ };
5
+ /** Sink-local copy; the managed logger retains its original record. */
6
+ export declare function operationalLogSnapshot(record: Record<string, unknown>): Record<string, unknown>;
7
+ /** Export a sanitized copy; never mutate the span another destination reads. */
8
+ export declare function operationalSpanSnapshot<T extends {
9
+ name: string;
10
+ attributes: Record<string, unknown>;
11
+ status: {
12
+ code: number;
13
+ message?: string;
14
+ };
15
+ events: ReadonlyArray<{
16
+ name: string;
17
+ attributes?: Record<string, unknown>;
18
+ }>;
19
+ links?: ReadonlyArray<AttributeContainer>;
20
+ resource?: AttributeContainer;
21
+ instrumentationScope?: AttributeContainer;
22
+ spanContext(): unknown;
23
+ }>(span: T): T;
24
+ export {};
@@ -0,0 +1,73 @@
1
+ import { normalizeTelemetryAttributes, redactSecretsInString } from "./redaction.js";
2
+ // Namespaced semantic-convention fields evade the generic exact-key log policy.
3
+ // Numeric usage remains operational data, subject to the shared secret policy.
4
+ const PAYLOAD_KEY = /(?:^|[._-])(?:prompts?|completions?|inputs?|outputs?|payloads?|rows?|messages?|content|body|customer_data)(?:$|[._-])|^(?:request|response)$/i;
5
+ const EXCEPTION_CONTENT = /^exception\.(?:message|stacktrace)$/i;
6
+ const QUERY_CONTENT = /^(?:db\.(?:statement|query\.text)(?:[._].*)?|(?:http|url)\.(?:query|query_string|request\.query)|process\.command(?:_args|_line)?)$/i;
7
+ // Addresses keep their domain (the operational signal) and drop the person.
8
+ const EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})/g;
9
+ function maskEmails(value) {
10
+ return value.replace(EMAIL_ADDRESS, "***@$1");
11
+ }
12
+ function operationalText(value) {
13
+ return maskEmails(redactSecretsInString(value).replace(/(?:https?:\/\/|\/)[^\s"'<>]+/gi, stripOperationalUrl));
14
+ }
15
+ function operationalValue(value, key, depth = 0) {
16
+ if (PAYLOAD_KEY.test(key) && typeof value !== "number" && typeof value !== "boolean")
17
+ return "[omitted]";
18
+ if (typeof value === "string")
19
+ return /(?:^|[._-])(?:url|path|target|href)(?:$|[._-])/i.test(key)
20
+ ? maskEmails(stripOperationalUrl(redactSecretsInString(value))) : operationalText(value);
21
+ if (!value || typeof value !== "object")
22
+ return value;
23
+ if (depth >= 5)
24
+ return "[truncated]";
25
+ if (value instanceof Date)
26
+ return value.toISOString();
27
+ if (Array.isArray(value))
28
+ return value.slice(0, 20).map((entry) => operationalValue(entry, key, depth + 1));
29
+ return Object.fromEntries(Object.entries(value).slice(0, 40)
30
+ .filter(([child]) => !EXCEPTION_CONTENT.test(child) && !QUERY_CONTENT.test(child))
31
+ .map(([child, entry]) => [child, operationalValue(entry, child, depth + 1)]));
32
+ }
33
+ function operationalAttributes(attributes) {
34
+ return normalizeTelemetryAttributes(Object.fromEntries(Object.entries(attributes ?? {}).slice(0, 40)
35
+ .filter(([key]) => !EXCEPTION_CONTENT.test(key) && !QUERY_CONTENT.test(key))
36
+ .map(([key, value]) => [key, operationalValue(value, key)])));
37
+ }
38
+ /** Sink-local copy; the managed logger retains its original record. */
39
+ export function operationalLogSnapshot(record) {
40
+ return operationalAttributes(record);
41
+ }
42
+ function containerSnapshot(container) {
43
+ return { ...container, attributes: operationalAttributes(container.attributes) };
44
+ }
45
+ /** Export a sanitized copy; never mutate the span another destination reads. */
46
+ export function operationalSpanSnapshot(span) {
47
+ const attributes = operationalAttributes(span.attributes);
48
+ // Exception messages/stacks can contain arbitrary customer payloads. Error
49
+ // classification lives on the span's structured attributes and correlated log.
50
+ return {
51
+ ...span,
52
+ spanContext: () => span.spanContext(),
53
+ name: operationalText(span.name),
54
+ attributes,
55
+ status: { code: span.status.code },
56
+ events: span.events.map((event) => ({
57
+ ...containerSnapshot(event),
58
+ name: operationalText(event.name),
59
+ })),
60
+ ...(span.links ? { links: span.links.map(containerSnapshot) } : {}),
61
+ ...(span.resource ? { resource: containerSnapshot(span.resource) } : {}),
62
+ ...(span.instrumentationScope ? { instrumentationScope: containerSnapshot(span.instrumentationScope) } : {}),
63
+ };
64
+ }
65
+ function stripOperationalUrl(value) {
66
+ try {
67
+ const url = new URL(value);
68
+ return `${url.origin}${url.pathname}`;
69
+ }
70
+ catch {
71
+ return value.split(/[?#]/, 1)[0] ?? "";
72
+ }
73
+ }