@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,44 @@
1
+ export declare const LINKEDIN_POST_KEYWORD_FIELDS: {
2
+ readonly all: "all_keywords";
3
+ readonly any: "any_keywords";
4
+ readonly exclude: "exclude_keywords";
5
+ };
6
+ /** Each "any of" keyword is one upstream search; this bounds the fan-out. */
7
+ export declare const MAX_ANY_KEYWORDS = 5;
8
+ export declare const MAX_ALL_KEYWORDS = 5;
9
+ export declare const MAX_EXCLUDE_KEYWORDS = 20;
10
+ export declare const MAX_KEYWORD_LENGTH = 100;
11
+ export type LinkedInPostKeywordRules = {
12
+ /** Every one of these must appear in the post. */
13
+ all: string[];
14
+ /** At least one of these must appear in the post. */
15
+ any: string[];
16
+ /** A post containing any of these is dropped. */
17
+ exclude: string[];
18
+ };
19
+ export type LinkedInPostKeywordMatch = {
20
+ keep: boolean;
21
+ /** The "all of" and "any of" keywords found in the post, in rule order. */
22
+ matched: string[];
23
+ };
24
+ /** Trim, drop wrapping quotes and collapse inner whitespace. Case is kept for display. */
25
+ export declare function normalizeLinkedInPostKeyword(value: string): string;
26
+ /**
27
+ * Reads the three rule lists from a request (arrays, or comma-separated strings
28
+ * from a CLI flag). Returns null when no rule is present, so callers can keep
29
+ * the plain single-search path untouched.
30
+ */
31
+ export declare function readLinkedInPostKeywordRules(input: Record<string, unknown>): LinkedInPostKeywordRules | null;
32
+ /** Everything wrong with a rule set, as customer-readable sentences. Empty when valid. */
33
+ export declare function linkedInPostKeywordRuleProblems(rules: LinkedInPostKeywordRules): string[];
34
+ /**
35
+ * The keyword strings sent upstream, one search each. `search` is the caller's
36
+ * free-text query and leads every search; "all of" keywords follow; each "any
37
+ * of" keyword gets its own search. Exclusions are never sent — they are only
38
+ * enforced on the returned text. Empty when there is no positive keyword at all.
39
+ */
40
+ export declare function linkedInPostSearchQueries(search: string | null | undefined, rules: LinkedInPostKeywordRules): string[];
41
+ /** How many upstream searches a request fans out to (at least one). */
42
+ export declare function linkedInPostSearchCount(input: Record<string, unknown>): number;
43
+ /** Applies the rules to one post's text. A post with no text matches nothing positive. */
44
+ export declare function matchLinkedInPostKeywords(text: string | null | undefined, rules: LinkedInPostKeywordRules): LinkedInPostKeywordMatch;
@@ -0,0 +1,116 @@
1
+ // Boolean keyword rules for LinkedIn post search.
2
+ //
3
+ // The managed post-search endpoint takes a keyword list and documents neither
4
+ // AND/OR semantics between entries nor any boolean syntax, so OXYGEN does not
5
+ // rely on the provider for them. Instead:
6
+ //
7
+ // - every "any of" keyword becomes its own upstream search (the "all of"
8
+ // keywords ride along in each one), which is what makes an OR recall-safe;
9
+ // - the rules are then enforced here, on the returned post text, which is
10
+ // what makes them exact: case-insensitive, whitespace-normalized substring
11
+ // matching, the same way a person scanning the post would read it.
12
+ //
13
+ // The provider caps searches per hour for the whole account, so the number of
14
+ // upstream searches one request may fan out to is bounded by MAX_ANY_KEYWORDS.
15
+ // Pure and dependency-free: the provider adapter, the pricing resolver, the
16
+ // signal planner and the web filter rail all read the same rules.
17
+ export const LINKEDIN_POST_KEYWORD_FIELDS = {
18
+ all: "all_keywords",
19
+ any: "any_keywords",
20
+ exclude: "exclude_keywords",
21
+ };
22
+ /** Each "any of" keyword is one upstream search; this bounds the fan-out. */
23
+ export const MAX_ANY_KEYWORDS = 5;
24
+ export const MAX_ALL_KEYWORDS = 5;
25
+ export const MAX_EXCLUDE_KEYWORDS = 20;
26
+ export const MAX_KEYWORD_LENGTH = 100;
27
+ /** Trim, drop wrapping quotes and collapse inner whitespace. Case is kept for display. */
28
+ export function normalizeLinkedInPostKeyword(value) {
29
+ return value.trim().replace(/^["'“”‘’]+|["'“”‘’]+$/g, "").replace(/\s+/g, " ").trim();
30
+ }
31
+ // A hyphen or dash reads as a space: LinkedIn's own search matches "founder led
32
+ // sales" to posts that say "founder-led sales", and on 2026-09-24 all 25 posts it
33
+ // returned for that keyword were dropped here for the hyphen alone.
34
+ function comparable(value) {
35
+ return value.toLowerCase().replace(/[-\u2010-\u2015]/g, " ").replace(/\s+/g, " ");
36
+ }
37
+ function readTerms(value) {
38
+ if (value === undefined || value === null || value === "")
39
+ return [];
40
+ const raw = Array.isArray(value) ? value.map((entry) => String(entry)) : String(value).split(",");
41
+ const seen = new Set();
42
+ const terms = [];
43
+ for (const entry of raw) {
44
+ const term = normalizeLinkedInPostKeyword(entry);
45
+ if (!term)
46
+ continue;
47
+ const key = comparable(term);
48
+ if (seen.has(key))
49
+ continue;
50
+ seen.add(key);
51
+ terms.push(term);
52
+ }
53
+ return terms;
54
+ }
55
+ /**
56
+ * Reads the three rule lists from a request (arrays, or comma-separated strings
57
+ * from a CLI flag). Returns null when no rule is present, so callers can keep
58
+ * the plain single-search path untouched.
59
+ */
60
+ export function readLinkedInPostKeywordRules(input) {
61
+ const rules = {
62
+ all: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.all]),
63
+ any: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.any]),
64
+ exclude: readTerms(input[LINKEDIN_POST_KEYWORD_FIELDS.exclude]),
65
+ };
66
+ return rules.all.length + rules.any.length + rules.exclude.length > 0 ? rules : null;
67
+ }
68
+ /** Everything wrong with a rule set, as customer-readable sentences. Empty when valid. */
69
+ export function linkedInPostKeywordRuleProblems(rules) {
70
+ const problems = [];
71
+ if (rules.any.length > MAX_ANY_KEYWORDS) {
72
+ problems.push(`any_keywords takes at most ${MAX_ANY_KEYWORDS} keywords; each one is a separate LinkedIn search.`);
73
+ }
74
+ if (rules.all.length > MAX_ALL_KEYWORDS)
75
+ problems.push(`all_keywords takes at most ${MAX_ALL_KEYWORDS} keywords.`);
76
+ if (rules.exclude.length > MAX_EXCLUDE_KEYWORDS)
77
+ problems.push(`exclude_keywords takes at most ${MAX_EXCLUDE_KEYWORDS} keywords.`);
78
+ const long = [...rules.all, ...rules.any, ...rules.exclude].find((term) => term.length > MAX_KEYWORD_LENGTH);
79
+ if (long)
80
+ problems.push(`Keywords must be at most ${MAX_KEYWORD_LENGTH} characters.`);
81
+ const excluded = new Set(rules.exclude.map(comparable));
82
+ const conflict = [...rules.all, ...rules.any].find((term) => excluded.has(comparable(term)));
83
+ if (conflict)
84
+ problems.push(`"${conflict}" is both required and excluded.`);
85
+ return problems;
86
+ }
87
+ /**
88
+ * The keyword strings sent upstream, one search each. `search` is the caller's
89
+ * free-text query and leads every search; "all of" keywords follow; each "any
90
+ * of" keyword gets its own search. Exclusions are never sent — they are only
91
+ * enforced on the returned text. Empty when there is no positive keyword at all.
92
+ */
93
+ export function linkedInPostSearchQueries(search, rules) {
94
+ const base = [search?.trim() ?? "", ...rules.all].filter(Boolean);
95
+ if (rules.any.length === 0)
96
+ return base.length > 0 ? [base.join(" ")] : [];
97
+ return rules.any.map((term) => [...base, term].join(" "));
98
+ }
99
+ /** How many upstream searches a request fans out to (at least one). */
100
+ export function linkedInPostSearchCount(input) {
101
+ const rules = readLinkedInPostKeywordRules(input);
102
+ if (!rules)
103
+ return 1;
104
+ return Math.max(1, Math.min(MAX_ANY_KEYWORDS, rules.any.length));
105
+ }
106
+ /** Applies the rules to one post's text. A post with no text matches nothing positive. */
107
+ export function matchLinkedInPostKeywords(text, rules) {
108
+ const body = comparable(text ?? "");
109
+ const has = (term) => body.includes(comparable(term));
110
+ const matchedAll = rules.all.filter(has);
111
+ const matchedAny = rules.any.filter(has);
112
+ const keep = matchedAll.length === rules.all.length
113
+ && (rules.any.length === 0 || matchedAny.length > 0)
114
+ && !rules.exclude.some(has);
115
+ return { keep, matched: [...matchedAll, ...matchedAny] };
116
+ }
@@ -151,4 +151,100 @@ export declare function waitStepDelayMs(step: LinkedInWaitStep): number;
151
151
  * seed is derived.
152
152
  */
153
153
  export declare function renderLinkedInTemplate(template: string, values: Record<string, unknown>, options?: RenderTemplateOptions): string;
154
+ /**
155
+ * LinkedIn's connection-note ceiling for a PREMIUM sending account.
156
+ *
157
+ * Basic accounts are capped lower — see {@link LINKEDIN_INVITE_NOTE_LIMIT_BASIC}.
158
+ * One constant, three consumers (the definition validator, the launch copy
159
+ * preflight and the dispatcher); they carried three independent `300`s before.
160
+ */
161
+ export declare const LINKEDIN_INVITE_NOTE_LIMIT = 300;
162
+ /**
163
+ * The ceiling that actually holds for a sending account whose tier we do not
164
+ * know, which today is every account: nothing in `sender_accounts.metadata`
165
+ * records a LinkedIn Premium signal.
166
+ *
167
+ * Measured on production (2026-09-20, one tenant, 30 days). Two senders, same
168
+ * tenant, same table:
169
+ *
170
+ * eca988d5 notes > 200 chars: 74 sent, 1 failed, 0 length refusals
171
+ * d44fe895 notes > 200 chars: 0 sent, 138 failed, 138 length refusals
172
+ *
173
+ * d44fe895 has never delivered a single noted invite over 200 characters, and
174
+ * the length refusal never fires below 200 on either account. Sequence f729fc2c
175
+ * rendered 205–238 character notes onto that sender and destroyed 138 leads at a
176
+ * 100% invite failure rate, because a validator that only knows the Premium
177
+ * ceiling let every one of them through.
178
+ *
179
+ * 200 is safe on any tier, so it is the default and the account may only be
180
+ * relaxed upward from evidence.
181
+ */
182
+ export declare const LINKEDIN_INVITE_NOTE_LIMIT_BASIC = 200;
183
+ /**
184
+ * What a LinkedIn refusal is actually ABOUT — the question the HTTP status band
185
+ * cannot answer.
186
+ *
187
+ * Measured on production (2026-09-21, 14 days, one tenant), a single 403 covered
188
+ * a sender-side allowance, an unreadable profile and a step sent to a
189
+ * non-connection, and all three terminated the lead as a recipient bounce. The
190
+ * provider's own sentence separates them; the status never could.
191
+ */
192
+ export type LinkedInRefusalScope =
193
+ /** Our request was malformed for this account. Ours to fix; the lead is fine. */
194
+ "oxygen_request_invalid"
195
+ /** We could not READ the lead's profile. Not a verdict on a send. */
196
+ | "profile_unreadable"
197
+ /** This sender has exhausted a LinkedIn allowance. Nothing to do with the lead. */
198
+ | "sender_allowance"
199
+ /** The step needs a connection this lead has not accepted. Ours to sequence. */
200
+ | "relationship_required"
201
+ /** An invite already exists for this lead. The journey is effectively running. */
202
+ | "already_engaged"
203
+ /** Unrecognised. Callers MUST preserve their existing behaviour. */
204
+ | "unknown";
205
+ /**
206
+ * Classify a LinkedIn refusal from the provider's own account of it.
207
+ *
208
+ * Deliberately a CLOSED set of anchored, full-string matches, in the same
209
+ * discipline as the sequence-recovery allowlists: matching vendor prose is
210
+ * fragile, so an unrecognised message must degrade to `unknown` and leave the
211
+ * caller's behaviour exactly as it was. Extending the set is an evidence-driven
212
+ * act — callers log what they could not classify.
213
+ *
214
+ * Pure and total; never throws.
215
+ */
216
+ export declare function classifyLinkedInRefusal(input: {
217
+ providerType: string | null;
218
+ providerMessage: string | null;
219
+ status: number | null;
220
+ operation: string | null;
221
+ }): LinkedInRefusalScope;
222
+ /**
223
+ * Does this refusal scope say the LEAD is unreachable?
224
+ *
225
+ * Only `already_engaged` is genuinely about the lead, and even that one means
226
+ * "an invite exists", not "this person is dead". Everything else is about our
227
+ * request, our sender or our sequencing, and must never brand the lead.
228
+ */
229
+ export declare function linkedInRefusalBlamesLead(scope: LinkedInRefusalScope): boolean;
230
+ /**
231
+ * Trim a rendered connection note to a LinkedIn account's ceiling, at a word
232
+ * boundary.
233
+ *
234
+ * Counts CODE POINTS, matching the launch copy preflight exactly — `.length`
235
+ * would count an emoji or a ZWJ sequence as two or more and the two surfaces
236
+ * would then disagree about the same note.
237
+ *
238
+ * Cuts at the last whitespace inside the limit so the note never ends mid-word;
239
+ * a single unbroken run longer than the limit has no boundary to find and is cut
240
+ * hard, which is the only case where that can happen.
241
+ *
242
+ * Returns the note unchanged (and `truncated: false`) when it already fits, so
243
+ * callers can record the trim only when there was one.
244
+ */
245
+ export declare function truncateLinkedInInviteNote(note: string, limit: number): {
246
+ note: string;
247
+ truncated: boolean;
248
+ originalLength: number;
249
+ };
154
250
  export {};
@@ -73,3 +73,126 @@ export function waitStepDelayMs(step) {
73
73
  export function renderLinkedInTemplate(template, values, options) {
74
74
  return renderTemplate(template, values, options);
75
75
  }
76
+ /**
77
+ * LinkedIn's connection-note ceiling for a PREMIUM sending account.
78
+ *
79
+ * Basic accounts are capped lower — see {@link LINKEDIN_INVITE_NOTE_LIMIT_BASIC}.
80
+ * One constant, three consumers (the definition validator, the launch copy
81
+ * preflight and the dispatcher); they carried three independent `300`s before.
82
+ */
83
+ export const LINKEDIN_INVITE_NOTE_LIMIT = 300;
84
+ /**
85
+ * The ceiling that actually holds for a sending account whose tier we do not
86
+ * know, which today is every account: nothing in `sender_accounts.metadata`
87
+ * records a LinkedIn Premium signal.
88
+ *
89
+ * Measured on production (2026-09-20, one tenant, 30 days). Two senders, same
90
+ * tenant, same table:
91
+ *
92
+ * eca988d5 notes > 200 chars: 74 sent, 1 failed, 0 length refusals
93
+ * d44fe895 notes > 200 chars: 0 sent, 138 failed, 138 length refusals
94
+ *
95
+ * d44fe895 has never delivered a single noted invite over 200 characters, and
96
+ * the length refusal never fires below 200 on either account. Sequence f729fc2c
97
+ * rendered 205–238 character notes onto that sender and destroyed 138 leads at a
98
+ * 100% invite failure rate, because a validator that only knows the Premium
99
+ * ceiling let every one of them through.
100
+ *
101
+ * 200 is safe on any tier, so it is the default and the account may only be
102
+ * relaxed upward from evidence.
103
+ */
104
+ export const LINKEDIN_INVITE_NOTE_LIMIT_BASIC = 200;
105
+ /**
106
+ * Classify a LinkedIn refusal from the provider's own account of it.
107
+ *
108
+ * Deliberately a CLOSED set of anchored, full-string matches, in the same
109
+ * discipline as the sequence-recovery allowlists: matching vendor prose is
110
+ * fragile, so an unrecognised message must degrade to `unknown` and leave the
111
+ * caller's behaviour exactly as it was. Extending the set is an evidence-driven
112
+ * act — callers log what they could not classify.
113
+ *
114
+ * Pure and total; never throws.
115
+ */
116
+ export function classifyLinkedInRefusal(input) {
117
+ const message = typeof input.providerMessage === "string" ? input.providerMessage.trim() : "";
118
+ if (message.length === 0)
119
+ return "unknown";
120
+ // Our invite note is longer than THIS account's ceiling. The 164 rows this
121
+ // matched in 14 days are leads we broke, filed as dead recipients.
122
+ if (input.status === 400
123
+ && input.providerType === "provider/invalid_parameters"
124
+ && /^Invalid parameters: You have reached the maximum number of characters for the custom message\.$/u.test(message)) {
125
+ return "oxygen_request_invalid";
126
+ }
127
+ // The profile READ failed. 121 of 318 failures in 14 days were this, on
128
+ // `users_get` — the lookup that runs BEFORE the invite or message, so LinkedIn
129
+ // never saw a send at all. It can be a genuinely unreachable profile or this
130
+ // sender's own profile-view allowance, and the message cannot tell them apart,
131
+ // which is exactly why it must not terminate the lead on the first attempt.
132
+ if (input.status === 403
133
+ && input.operation === "users_get"
134
+ && /^Insufficient permissions: This profile can't be accessed$/u.test(message)) {
135
+ return "profile_unreadable";
136
+ }
137
+ if (input.status === 403
138
+ && input.providerType === "provider/access_restricted"
139
+ && /^Access restricted: You have reached the limit for custom messages\.$/u.test(message)) {
140
+ return "sender_allowance";
141
+ }
142
+ if (input.status === 403
143
+ && input.providerType === "provider/access_restricted"
144
+ && /^Access restricted: This user is not a relation\.$/u.test(message)) {
145
+ return "relationship_required";
146
+ }
147
+ if (input.status === 422
148
+ && input.providerType === "provider/unprocessable_entity"
149
+ && /^Unprocessable entity: This user has already handled a request from you in the past 3 weeks\./u.test(message)) {
150
+ return "already_engaged";
151
+ }
152
+ if (input.status === 409
153
+ && input.providerType === "provider/conflict"
154
+ && /^Conflict: A relation request has already been sent to this user\.$/u.test(message)) {
155
+ return "already_engaged";
156
+ }
157
+ return "unknown";
158
+ }
159
+ /**
160
+ * Does this refusal scope say the LEAD is unreachable?
161
+ *
162
+ * Only `already_engaged` is genuinely about the lead, and even that one means
163
+ * "an invite exists", not "this person is dead". Everything else is about our
164
+ * request, our sender or our sequencing, and must never brand the lead.
165
+ */
166
+ export function linkedInRefusalBlamesLead(scope) {
167
+ return scope === "already_engaged";
168
+ }
169
+ /**
170
+ * Trim a rendered connection note to a LinkedIn account's ceiling, at a word
171
+ * boundary.
172
+ *
173
+ * Counts CODE POINTS, matching the launch copy preflight exactly — `.length`
174
+ * would count an emoji or a ZWJ sequence as two or more and the two surfaces
175
+ * would then disagree about the same note.
176
+ *
177
+ * Cuts at the last whitespace inside the limit so the note never ends mid-word;
178
+ * a single unbroken run longer than the limit has no boundary to find and is cut
179
+ * hard, which is the only case where that can happen.
180
+ *
181
+ * Returns the note unchanged (and `truncated: false`) when it already fits, so
182
+ * callers can record the trim only when there was one.
183
+ */
184
+ export function truncateLinkedInInviteNote(note, limit) {
185
+ const points = [...note];
186
+ const originalLength = points.length;
187
+ if (limit <= 0 || originalLength <= limit) {
188
+ return { note, truncated: false, originalLength };
189
+ }
190
+ const head = points.slice(0, limit);
191
+ let cut = head.length;
192
+ while (cut > 0 && !/\s/u.test(head[cut - 1]))
193
+ cut -= 1;
194
+ // No whitespace inside the window: one unbroken run, so a hard cut is the
195
+ // only option left.
196
+ const kept = cut === 0 ? head : head.slice(0, cut);
197
+ return { note: kept.join("").trimEnd(), truncated: true, originalLength };
198
+ }
@@ -0,0 +1,24 @@
1
+ import type { LlmTraceBody, LlmTracingClient } from "./langfuse.js";
2
+ export type LlmCaptureTimestampLookup = {
3
+ type: "root";
4
+ id: string;
5
+ } | {
6
+ type: "event";
7
+ key: string;
8
+ } | {
9
+ type: "approval";
10
+ id: string;
11
+ phase: "requested" | "decided";
12
+ };
13
+ /**
14
+ * Langfuse v3 deduplication includes the start date. Stable ids alone do not
15
+ * prevent cross-day duplicates. This per-trace adapter obtains the original
16
+ * ledger clock without blocking runtime methods or borrowing their transaction.
17
+ * The underlying transport still owns identity, payloads, isolation and flush.
18
+ */
19
+ export declare function withDurableLlmCapture(input: {
20
+ client: LlmTracingClient;
21
+ enabled: boolean;
22
+ root: LlmTraceBody;
23
+ readTimestamp?: ((lookup: LlmCaptureTimestampLookup) => Promise<Date | null>) | undefined;
24
+ }): LlmTracingClient;
@@ -0,0 +1,89 @@
1
+ import { log } from "./log.js";
2
+ /**
3
+ * Langfuse v3 deduplication includes the start date. Stable ids alone do not
4
+ * prevent cross-day duplicates. This per-trace adapter obtains the original
5
+ * ledger clock without blocking runtime methods or borrowing their transaction.
6
+ * The underlying transport still owns identity, payloads, isolation and flush.
7
+ */
8
+ export function withDurableLlmCapture(input) {
9
+ if (!input.enabled)
10
+ return input.client;
11
+ const pending = new Set();
12
+ const warned = new Set();
13
+ const missing = (stage) => {
14
+ if (warned.has(stage))
15
+ return;
16
+ warned.add(stage);
17
+ log("warn", "llm_tracing.missing_capture", { provider: "langfuse", stage, error_code: "langfuse_immutable_timestamp_unavailable" });
18
+ };
19
+ const valid = (value) => value instanceof Date && Number.isFinite(value.getTime());
20
+ const read = (lookup) => new Promise((resolve) => {
21
+ // Bounds injected readers too. A DB reader must independently bound its
22
+ // statement and release its own scope when a late acquisition completes.
23
+ const timer = setTimeout(() => resolve(null), 750);
24
+ timer.unref?.();
25
+ void Promise.resolve().then(() => input.readTimestamp?.(lookup) ?? null).then((date) => resolve(valid(date) ? date : null), () => resolve(null)).finally(() => clearTimeout(timer));
26
+ });
27
+ const seed = (startTime) => {
28
+ if (!valid(startTime)) {
29
+ missing("root_timestamp");
30
+ return null;
31
+ }
32
+ try {
33
+ input.client.trace({ ...input.root, startTime, endTime: startTime });
34
+ }
35
+ catch {
36
+ missing("root_emission");
37
+ return null;
38
+ }
39
+ return startTime;
40
+ };
41
+ // Seed synchronously when the caller already has the run/turn row. A web
42
+ // approval-only tracer can instead look up its root before queued children.
43
+ const knownRoot = valid(input.root.startTime) ? seed(input.root.startTime) : undefined;
44
+ const rootReady = knownRoot !== undefined ? Promise.resolve(knownRoot)
45
+ : read({ type: "root", id: input.root.id }).then(seed);
46
+ const enqueue = (action) => {
47
+ const task = rootReady.then(async (start) => { if (start)
48
+ await action(start); }).catch(() => missing("capture"));
49
+ pending.add(task);
50
+ void task.finally(() => pending.delete(task));
51
+ };
52
+ const eventLookup = (body) => {
53
+ const key = body.metadata?.oxygen_durable_event_key;
54
+ if (typeof key === "string" && key) {
55
+ // Auto-approval requests have a persisted approval card before an event
56
+ // append. The card clock is canonical for both approval capture paths.
57
+ const approvalKey = /^approval_(requested|decided):(.+)$/.exec(key);
58
+ if (approvalKey)
59
+ return { type: "approval", phase: approvalKey[1], id: approvalKey[2] };
60
+ return { type: "event", key };
61
+ }
62
+ const approval = /^apr:(requested|decided):(.+)$/.exec(body.id);
63
+ return approval ? { type: "approval", phase: approval[1], id: approval[2] } : null;
64
+ };
65
+ return {
66
+ trace: (body) => enqueue((startTime) => input.client.trace({ ...body, startTime })),
67
+ generation: (body) => enqueue(() => input.client.generation(body)),
68
+ embedding: (body) => enqueue(() => input.client.embedding(body)),
69
+ span: (body) => enqueue(() => input.client.span(body)),
70
+ event: (body) => {
71
+ const lookup = eventLookup(body);
72
+ enqueue(async () => {
73
+ if (!lookup) {
74
+ input.client.event(body);
75
+ return;
76
+ }
77
+ const startTime = valid(body.startTime) ? body.startTime : await read(lookup);
78
+ if (!startTime) {
79
+ missing("event_timestamp");
80
+ return;
81
+ }
82
+ input.client.event({ ...body, startTime });
83
+ });
84
+ },
85
+ score: (body) => input.client.score(body),
86
+ flush: async () => { await rootReady; await Promise.allSettled([...pending]); await input.client.flush(); },
87
+ shutdown: async () => { await rootReady; await Promise.allSettled([...pending]); await input.client.shutdown(); },
88
+ };
89
+ }
@@ -0,0 +1,75 @@
1
+ export type LlmPromptRef = {
2
+ /** Langfuse prompt name, e.g. `copilot.system` or `table_column`. */
3
+ name: string;
4
+ /** First 12 hex chars of sha256(template); changes whenever the template does. */
5
+ hash: string;
6
+ /** True only for code-owned templates published by the prompt sync. */
7
+ registered: boolean;
8
+ };
9
+ export type LlmPromptDefinition = LlmPromptRef & {
10
+ registered: true;
11
+ template: string;
12
+ };
13
+ /** Deterministic content hash; object key order never changes it. */
14
+ export declare function llmPromptHash(template: unknown): string;
15
+ /** The Langfuse label that identifies one exact template version. */
16
+ export declare function llmPromptLabel(hash: string): string;
17
+ /**
18
+ * Declare a code-owned prompt template. Call once at module scope with the
19
+ * template the call site renders from. Placeholders stay literal (`{{name}}`)
20
+ * so the hash moves only when the instructions do, not per request.
21
+ */
22
+ export declare function defineLlmPrompt(name: string, template: string): LlmPromptDefinition;
23
+ /** Names declared twice with different templates (a catalog defect). */
24
+ export declare function conflictingLlmPromptNames(): string[];
25
+ /** The Langfuse-safe name shape code prompts must use. */
26
+ export declare function isValidLlmPromptName(name: string): boolean;
27
+ /**
28
+ * An AI column's prompt as the text Langfuse stores: the instruction template
29
+ * (placeholders unresolved) plus its output schema, so a schema change is a new
30
+ * prompt version too. Customer columns and code-owned helpers share it.
31
+ */
32
+ export declare function aiColumnPromptTemplate(definition: {
33
+ prompt?: unknown;
34
+ outputSchema?: unknown;
35
+ }): string;
36
+ /** Declare a code-owned AI helper prompt (knowledge synthesis, drafts, ...). */
37
+ export declare function defineAiColumnPrompt(name: string, definition: {
38
+ prompt: string;
39
+ outputSchema?: Record<string, unknown> | null;
40
+ }): LlmPromptDefinition;
41
+ /**
42
+ * Resolve a lazy prompt getter on a product path. Prompt identity is telemetry:
43
+ * a template renderer that throws must degrade to an unlinked trace, never
44
+ * fail the model call it describes.
45
+ */
46
+ export declare function safeLlmPrompt<T extends LlmPromptRef>(getter: () => T | null): T | null;
47
+ /** Reference a customer-authored or per-request template: metadata only. */
48
+ export declare function llmPromptRef(name: string, template: unknown): LlmPromptRef;
49
+ /**
50
+ * The registered code prompt whose template is exactly this text, if any: lets
51
+ * a tracer that only sees the rendered system message recognise a static
52
+ * template (compaction, sub-agent instructions) without importing its owner.
53
+ */
54
+ export declare function findLlmPromptByTemplate(template: string): LlmPromptDefinition | undefined;
55
+ /**
56
+ * The declared code prompt behind a reference, only when its template still
57
+ * hashes to that reference: lets the tracing client publish the exact text a
58
+ * generation was rendered from, and never a same-named neighbour.
59
+ */
60
+ export declare function registeredLlmPrompt(ref: LlmPromptRef): LlmPromptDefinition | undefined;
61
+ /** Every code prompt declared by the modules imported so far, sorted by name. */
62
+ export declare function listLlmPrompts(): LlmPromptDefinition[];
63
+ /**
64
+ * The prompt a Copilot/Agent model call ran on, from the exact messages sent.
65
+ * A main-loop call (`kind` "turn") uses the surface's versioned system
66
+ * template; compaction and sub-agent calls match a registered static template
67
+ * by the content of one of their system messages; anything else is traced by
68
+ * content hash only.
69
+ */
70
+ export declare function llmPromptForModelCall(input: {
71
+ surface: "copilot" | "agent";
72
+ kind: string;
73
+ messages: unknown;
74
+ turnPrompt?: LlmPromptRef | null;
75
+ }): LlmPromptRef | null;