@oxygen-agent/cli 1.365.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.
- package/README.md +1 -1
- package/dist/column-run-notices.d.ts +11 -0
- package/dist/column-run-notices.js +37 -0
- package/dist/command-manifest.js +13 -8
- package/dist/help.js +78 -16
- package/dist/index.js +3873 -514
- package/dist/skills.js +106 -1
- package/node_modules/@oxygen/formula/dist/coerce.d.ts +8 -0
- package/node_modules/@oxygen/formula/dist/coerce.js +10 -0
- package/node_modules/@oxygen/formula/dist/evaluate.d.ts +31 -0
- package/node_modules/@oxygen/formula/dist/evaluate.js +248 -0
- package/node_modules/@oxygen/formula/dist/expression.d.ts +64 -0
- package/node_modules/@oxygen/formula/dist/expression.js +428 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.d.ts +71 -0
- package/node_modules/@oxygen/formula/dist/formula-functions.js +1100 -0
- package/node_modules/@oxygen/formula/dist/index.d.ts +17 -0
- package/node_modules/@oxygen/formula/dist/index.js +17 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.d.ts +30 -0
- package/node_modules/@oxygen/formula/dist/value-normalizers.js +80 -0
- package/node_modules/@oxygen/formula/package.json +26 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +30 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.js +2 -2
- package/node_modules/@oxygen/shared/dist/billing-anchors.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/billing-anchors.js +135 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +101 -5
- package/node_modules/@oxygen/shared/dist/billing.js +192 -8
- package/node_modules/@oxygen/shared/dist/call-outcomes.d.ts +59 -0
- package/node_modules/@oxygen/shared/dist/call-outcomes.js +73 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/credit-guidance.js +3 -1
- package/node_modules/@oxygen/shared/dist/crm-reply-events.d.ts +35 -0
- package/node_modules/@oxygen/shared/dist/crm-reply-events.js +31 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/dial-guardrail-overrides.js +65 -0
- package/node_modules/@oxygen/shared/dist/directory.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/directory.js +1 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +17 -1
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +52 -3
- package/node_modules/@oxygen/shared/dist/index.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/index.js +15 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +77 -0
- package/node_modules/@oxygen/shared/dist/langfuse.js +231 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +56 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +5 -4
- package/node_modules/@oxygen/shared/dist/linkedin-url.d.ts +22 -0
- package/node_modules/@oxygen/shared/dist/linkedin-url.js +7 -4
- package/node_modules/@oxygen/shared/dist/log.js +56 -4
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.d.ts +7 -0
- package/node_modules/@oxygen/shared/dist/microsoft-consent-url.js +29 -0
- package/node_modules/@oxygen/shared/dist/object-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/object-storage.js +61 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.d.ts +636 -0
- package/node_modules/@oxygen/shared/dist/plan-limits.js +199 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +89 -23
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +88 -24
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.d.ts +291 -0
- package/node_modules/@oxygen/shared/dist/sequence-crm-events.js +224 -0
- package/node_modules/@oxygen/shared/dist/sequence-template.d.ts +42 -1
- package/node_modules/@oxygen/shared/dist/sequence-template.js +0 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +287 -24
- package/node_modules/@oxygen/shared/dist/sequences.js +940 -60
- package/node_modules/@oxygen/shared/dist/spend-safety.d.ts +70 -0
- package/node_modules/@oxygen/shared/dist/spend-safety.js +106 -0
- package/node_modules/@oxygen/shared/dist/tags.d.ts +90 -1
- package/node_modules/@oxygen/shared/dist/tags.js +126 -6
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +4 -0
- package/node_modules/@oxygen/shared/dist/workspace-agents.d.ts +8 -7
- package/node_modules/@oxygen/shared/dist/workspace-agents.js +34 -7
- package/node_modules/@oxygen/shared/package.json +95 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.d.ts +126 -0
- package/node_modules/@oxygen/workflows/dist/event-dispatch.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.d.ts +78 -0
- package/node_modules/@oxygen/workflows/dist/graph/expression.js +700 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.d.ts +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/index.js +20 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.d.ts +4 -0
- package/node_modules/@oxygen/workflows/dist/graph/lint.js +812 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.d.ts +501 -0
- package/node_modules/@oxygen/workflows/dist/graph/manifest-schema.js +200 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.d.ts +86 -0
- package/node_modules/@oxygen/workflows/dist/graph/params.js +173 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.d.ts +48 -0
- package/node_modules/@oxygen/workflows/dist/graph/remap.js +213 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.d.ts +46 -0
- package/node_modules/@oxygen/workflows/dist/graph/topology.js +280 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.d.ts +270 -0
- package/node_modules/@oxygen/workflows/dist/graph/types.js +93 -0
- package/node_modules/@oxygen/workflows/dist/index.d.ts +113 -1
- package/node_modules/@oxygen/workflows/dist/index.js +179 -13
- package/node_modules/@oxygen/workflows/dist/tool-effects.d.ts +1 -0
- package/node_modules/@oxygen/workflows/dist/tool-effects.js +19 -0
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +135 -4
- package/node_modules/@oxygen/workflows/package.json +4 -0
- package/package.json +10 -5
|
@@ -77,9 +77,57 @@ export const HOSTED_AI_MODEL_REGISTRY = {
|
|
|
77
77
|
maxOutputTokens: 8192,
|
|
78
78
|
},
|
|
79
79
|
},
|
|
80
|
+
agent: {
|
|
81
|
+
low: {
|
|
82
|
+
model: "deepseek/deepseek-v4-flash",
|
|
83
|
+
displayName: "DeepSeek V4 Flash",
|
|
84
|
+
fallbackModels: [],
|
|
85
|
+
reasoningEffort: "medium",
|
|
86
|
+
estPromptUsdPerM: 0.09,
|
|
87
|
+
estCompletionUsdPerM: 0.18,
|
|
88
|
+
maxOutputTokens: 8192,
|
|
89
|
+
},
|
|
90
|
+
medium: {
|
|
91
|
+
model: "deepseek/deepseek-v4-pro",
|
|
92
|
+
displayName: "DeepSeek V4 Pro",
|
|
93
|
+
fallbackModels: ["deepseek/deepseek-v4-flash"],
|
|
94
|
+
reasoningEffort: "high",
|
|
95
|
+
estPromptUsdPerM: 0.435,
|
|
96
|
+
estCompletionUsdPerM: 0.87,
|
|
97
|
+
maxOutputTokens: 8192,
|
|
98
|
+
},
|
|
99
|
+
high: {
|
|
100
|
+
model: "moonshotai/kimi-k2.6",
|
|
101
|
+
displayName: "Kimi K2.6",
|
|
102
|
+
fallbackModels: ["deepseek/deepseek-v4-pro"],
|
|
103
|
+
estPromptUsdPerM: 0.66,
|
|
104
|
+
estCompletionUsdPerM: 3.41,
|
|
105
|
+
maxOutputTokens: 8192,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* What a MANAGED AI column's reasoning tier is called in front of a customer.
|
|
111
|
+
*
|
|
112
|
+
* A managed customer buys an Oxygen tier at a fixed credit price, not a specific
|
|
113
|
+
* third-party model — which is why the web editor shows these names and never the
|
|
114
|
+
* vendor model id. The names live here, beside the registry that decides which
|
|
115
|
+
* model each tier resolves to, so every surface says the same word: a blind user
|
|
116
|
+
* eval found the web UI offering "Oxygen Balanced" while the CLI's dry-run
|
|
117
|
+
* reported only `deepseek/deepseek-v4-pro`, leaving a customer to guess whether
|
|
118
|
+
* they were the same thing.
|
|
119
|
+
*
|
|
120
|
+
* BYOK is deliberately excluded: there the user picks real OpenRouter models on
|
|
121
|
+
* their own key, so the real model id is the correct thing to show.
|
|
122
|
+
*/
|
|
123
|
+
export const MANAGED_AI_TIER_LABELS = {
|
|
124
|
+
low: "Oxygen Fast",
|
|
125
|
+
medium: "Oxygen Balanced",
|
|
126
|
+
high: "Oxygen Max",
|
|
80
127
|
};
|
|
81
128
|
/** The reasoning tier the hosted copilot runs at by default. */
|
|
82
129
|
export const COPILOT_DEFAULT_LEVEL = "high";
|
|
130
|
+
export const AGENT_DEFAULT_LEVEL = "medium";
|
|
83
131
|
const LEVEL_ENV_SUFFIX = {
|
|
84
132
|
low: "LOW",
|
|
85
133
|
medium: "MEDIUM",
|
|
@@ -134,12 +182,13 @@ export function findHostedAiModelSpec(model, preferredUseCase) {
|
|
|
134
182
|
}
|
|
135
183
|
export function resolveHostedAiModel(input) {
|
|
136
184
|
const base = HOSTED_AI_MODEL_REGISTRY[input.useCase][input.level];
|
|
137
|
-
if (input.useCase
|
|
185
|
+
if (input.useCase === "ai_column")
|
|
138
186
|
return base;
|
|
139
187
|
const env = input.env ?? process.env;
|
|
140
188
|
const suffix = LEVEL_ENV_SUFFIX[input.level];
|
|
141
|
-
const
|
|
142
|
-
const
|
|
189
|
+
const envPrefix = input.useCase === "agent" ? "OXYGEN_AGENT" : "OXYGEN_COPILOT";
|
|
190
|
+
const modelOverride = readTrimmedEnv(env, `${envPrefix}_MODEL_${suffix}`);
|
|
191
|
+
const rawFallback = readTrimmedEnv(env, `${envPrefix}_FALLBACK_${suffix}`);
|
|
143
192
|
const fallbackOverride = rawFallback === undefined ? undefined : parseFallbackCsv(rawFallback);
|
|
144
193
|
if (modelOverride === undefined && fallbackOverride === undefined)
|
|
145
194
|
return base;
|
|
@@ -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";
|
|
@@ -20,16 +24,23 @@ export * from "./knowledge-constants.js";
|
|
|
20
24
|
export * from "./knowledge-links.js";
|
|
21
25
|
export * from "./knowledge-markdown.js";
|
|
22
26
|
export * from "./knowledge-seed-content.js";
|
|
27
|
+
export * from "./langfuse.js";
|
|
23
28
|
export * from "./linkedin-mentions.js";
|
|
24
29
|
export * from "./linkedin-post-url.js";
|
|
30
|
+
export * from "./linkedin-quota-denial.js";
|
|
25
31
|
export * from "./linkedin-url.js";
|
|
26
32
|
export * from "./linkedin-sequences.js";
|
|
33
|
+
export * from "./microsoft-consent-url.js";
|
|
27
34
|
export * from "./networks.js";
|
|
28
35
|
export * from "./recipes.js";
|
|
29
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";
|
|
30
40
|
export * from "./sequences.js";
|
|
31
41
|
export * from "./suppression-entries.js";
|
|
32
42
|
export * from "./log.js";
|
|
43
|
+
export { sanitizeLogFields } from "./redaction.js";
|
|
33
44
|
export * from "./provider-request-outcomes.js";
|
|
34
45
|
export * from "./schedule-label.js";
|
|
35
46
|
export * from "./signup-lead-deliveries.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";
|
|
@@ -20,16 +24,27 @@ export * from "./knowledge-constants.js";
|
|
|
20
24
|
export * from "./knowledge-links.js";
|
|
21
25
|
export * from "./knowledge-markdown.js";
|
|
22
26
|
export * from "./knowledge-seed-content.js";
|
|
27
|
+
export * from "./langfuse.js";
|
|
23
28
|
export * from "./linkedin-mentions.js";
|
|
24
29
|
export * from "./linkedin-post-url.js";
|
|
30
|
+
export * from "./linkedin-quota-denial.js";
|
|
25
31
|
export * from "./linkedin-url.js";
|
|
26
32
|
export * from "./linkedin-sequences.js";
|
|
33
|
+
export * from "./microsoft-consent-url.js";
|
|
27
34
|
export * from "./networks.js";
|
|
28
35
|
export * from "./recipes.js";
|
|
29
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";
|
|
30
40
|
export * from "./sequences.js";
|
|
31
41
|
export * from "./suppression-entries.js";
|
|
32
42
|
export * from "./log.js";
|
|
43
|
+
// Narrow, deliberate export (ADR 0014): lets telemetry emitters regression-test
|
|
44
|
+
// their field names against the REAL log sanitizer — the unanchored
|
|
45
|
+
// SECRET_KEY_PATTERN redacts any name containing "token", which mocked loggers
|
|
46
|
+
// cannot catch. Not a license to pre-sanitize outside log().
|
|
47
|
+
export { sanitizeLogFields } from "./redaction.js";
|
|
33
48
|
export * from "./provider-request-outcomes.js";
|
|
34
49
|
export * from "./schedule-label.js";
|
|
35
50
|
export * from "./signup-lead-deliveries.js";
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { Langfuse } from "langfuse";
|
|
2
|
+
type EnvMap = Record<string, string | undefined>;
|
|
3
|
+
export type LlmObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
|
|
4
|
+
export type LlmTraceBody = {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
sessionId?: string | null;
|
|
8
|
+
userId?: string | null;
|
|
9
|
+
input?: unknown;
|
|
10
|
+
output?: unknown;
|
|
11
|
+
metadata?: Record<string, unknown>;
|
|
12
|
+
tags?: string[];
|
|
13
|
+
};
|
|
14
|
+
export type LlmSpanBody = {
|
|
15
|
+
id: string;
|
|
16
|
+
traceId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
input?: unknown;
|
|
19
|
+
output?: unknown;
|
|
20
|
+
metadata?: Record<string, unknown>;
|
|
21
|
+
startTime?: Date;
|
|
22
|
+
endTime?: Date;
|
|
23
|
+
level?: LlmObservationLevel;
|
|
24
|
+
statusMessage?: string | null;
|
|
25
|
+
};
|
|
26
|
+
export type LlmGenerationBody = LlmSpanBody & {
|
|
27
|
+
model?: string | null;
|
|
28
|
+
completionStartTime?: Date | null;
|
|
29
|
+
usageDetails?: Record<string, number>;
|
|
30
|
+
costDetails?: Record<string, number>;
|
|
31
|
+
};
|
|
32
|
+
export type LlmEventBody = {
|
|
33
|
+
id: string;
|
|
34
|
+
traceId: string;
|
|
35
|
+
name: string;
|
|
36
|
+
input?: unknown;
|
|
37
|
+
metadata?: Record<string, unknown>;
|
|
38
|
+
startTime?: Date;
|
|
39
|
+
};
|
|
40
|
+
export type LlmTracingClient = {
|
|
41
|
+
trace(body: LlmTraceBody): void;
|
|
42
|
+
span(body: LlmSpanBody): void;
|
|
43
|
+
generation(body: LlmGenerationBody): void;
|
|
44
|
+
event(body: LlmEventBody): void;
|
|
45
|
+
/** Never rejects; bounded at ~5s. */
|
|
46
|
+
flush(): Promise<void>;
|
|
47
|
+
/** Flush + stop background timers. Never rejects; bounded at ~5s. */
|
|
48
|
+
shutdown(): Promise<void>;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
52
|
+
* "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
|
|
53
|
+
*/
|
|
54
|
+
export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
|
|
55
|
+
export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
|
|
56
|
+
type LangfuseLike = Pick<Langfuse, "trace" | "span" | "generation" | "event" | "flushAsync" | "shutdownAsync"> & {
|
|
57
|
+
on?: (event: string, listener: (...args: unknown[]) => void) => void;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
61
|
+
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
62
|
+
* this direct factory exists for tests (inject `langfuseImpl`).
|
|
63
|
+
*/
|
|
64
|
+
export declare function createLlmTracingClient(env?: EnvMap, options?: {
|
|
65
|
+
langfuseImpl?: LangfuseLike;
|
|
66
|
+
}): LlmTracingClient | null;
|
|
67
|
+
export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
|
|
68
|
+
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
69
|
+
export declare function flushLlmTracing(env?: EnvMap): Promise<void>;
|
|
70
|
+
type FlushScheduler = (task: () => Promise<void>) => void;
|
|
71
|
+
export declare function setLlmTracingFlushScheduler(scheduler: FlushScheduler | null): void;
|
|
72
|
+
/** Request-safe flush trigger: post-response on web, fire-and-forget elsewhere. */
|
|
73
|
+
export declare function scheduleLlmTracingFlush(env?: EnvMap): void;
|
|
74
|
+
/** Shutdown the singleton (worker exit). Never rejects. */
|
|
75
|
+
export declare function shutdownLlmTracing(env?: EnvMap): Promise<void>;
|
|
76
|
+
export declare function __resetLlmTracingForTests(): void;
|
|
77
|
+
export {};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// LLM-observability transport (ADR 0014): Langfuse is the ONE sanctioned store
|
|
2
|
+
// for full prompt/completion/tool-IO payloads. Axiom stays metadata-only (log.ts
|
|
3
|
+
// redaction drops prompt/input/output-named fields BY DESIGN — that boundary is
|
|
4
|
+
// unchanged), and PostHog stays sanitized product analytics. This module
|
|
5
|
+
// deliberately rides the official `langfuse` SDK's own batched ingestion instead
|
|
6
|
+
// of the OTel pipeline so prompt-bearing telemetry can never reach the Axiom
|
|
7
|
+
// OTLP exporters by construction.
|
|
8
|
+
//
|
|
9
|
+
// Fail-open doctrine: tracing must never fail or stall product work. Every
|
|
10
|
+
// method swallows internally (worst case: one throttled metadata-only warn via
|
|
11
|
+
// log()), `flush()`/`shutdown()` never reject and are time-bounded, and a
|
|
12
|
+
// missing flag/key yields `null` (callers no-op). Deterministic observation ids
|
|
13
|
+
// are the caller's job — Langfuse upserts by id, which is what makes worker
|
|
14
|
+
// lease-reclaim replays converge instead of duplicating.
|
|
15
|
+
import { Langfuse } from "langfuse";
|
|
16
|
+
import { log } from "./log.js";
|
|
17
|
+
const FLUSH_TIMEOUT_MS = 5_000;
|
|
18
|
+
const WARN_THROTTLE_MS = 30_000;
|
|
19
|
+
// Defensive per-field bound, well under Langfuse's ~1 MB event cap. Copilot
|
|
20
|
+
// transcripts max out around 150 KB; anything larger is truncated with an
|
|
21
|
+
// explicit marker rather than risking a rejected ingestion batch.
|
|
22
|
+
const MAX_JSON_FIELD_CHARS = 400_000;
|
|
23
|
+
/**
|
|
24
|
+
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
25
|
+
* "1"/"true" (trimmed, case-insensitive) AND both Langfuse keys are present.
|
|
26
|
+
*/
|
|
27
|
+
export function isLlmTracingEnabled(env = process.env) {
|
|
28
|
+
const raw = env.OXYGEN_LLM_TRACING_ENABLED;
|
|
29
|
+
if (typeof raw !== "string")
|
|
30
|
+
return false;
|
|
31
|
+
const normalized = raw.trim().toLowerCase();
|
|
32
|
+
if (normalized !== "1" && normalized !== "true")
|
|
33
|
+
return false;
|
|
34
|
+
return ((env.LANGFUSE_PUBLIC_KEY?.trim() ?? "") !== "" &&
|
|
35
|
+
(env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== "");
|
|
36
|
+
}
|
|
37
|
+
// The Langfuse "environment" dimension separates dev/prod traces inside one
|
|
38
|
+
// project. Explicit LANGFUSE_TRACING_ENVIRONMENT (Doppler, per config) wins.
|
|
39
|
+
// Defaults per runtime: web has VERCEL_ENV; the Fly worker has NO VERCEL_ENV
|
|
40
|
+
// (and NODE_ENV=production on BOTH worker apps), so mirror the worker telemetry
|
|
41
|
+
// convention (apps/worker/src/telemetry.ts): the Fly-injected app name is the
|
|
42
|
+
// ground truth for prod vs dev — the "-dev" suffix marks the dev app; a
|
|
43
|
+
// cross-env Doppler drift cannot fake it (the OXY-1183 lesson) — then
|
|
44
|
+
// FLY_ENVIRONMENT as the softer signal. Everything else → development.
|
|
45
|
+
export function resolveLlmTracingEnvironment(env = process.env) {
|
|
46
|
+
const explicit = env.LANGFUSE_TRACING_ENVIRONMENT?.trim().toLowerCase();
|
|
47
|
+
if (explicit)
|
|
48
|
+
return explicit.replace(/[^a-z0-9_-]/g, "-");
|
|
49
|
+
if (env.VERCEL_ENV)
|
|
50
|
+
return env.VERCEL_ENV === "production" ? "production" : "development";
|
|
51
|
+
const flyApp = env.FLY_APP_NAME ?? env.FLY_APP;
|
|
52
|
+
if (flyApp)
|
|
53
|
+
return flyApp.includes("-dev") ? "development" : "production";
|
|
54
|
+
const flyEnv = env.FLY_ENVIRONMENT?.trim().toLowerCase();
|
|
55
|
+
if (flyEnv)
|
|
56
|
+
return flyEnv === "production" ? "production" : "development";
|
|
57
|
+
return "development";
|
|
58
|
+
}
|
|
59
|
+
// Bound one JSON-bearing field. Over the cap → an explicit truncation marker
|
|
60
|
+
// (never a silently clipped payload that parses as complete).
|
|
61
|
+
function boundJsonField(value) {
|
|
62
|
+
if (value === undefined || value === null)
|
|
63
|
+
return value;
|
|
64
|
+
let serialized;
|
|
65
|
+
try {
|
|
66
|
+
serialized = JSON.stringify(value) ?? "";
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return { truncated: true, reason: "unserializable" };
|
|
70
|
+
}
|
|
71
|
+
if (serialized.length <= MAX_JSON_FIELD_CHARS)
|
|
72
|
+
return value;
|
|
73
|
+
return {
|
|
74
|
+
truncated: true,
|
|
75
|
+
chars: serialized.length,
|
|
76
|
+
preview: serialized.slice(0, MAX_JSON_FIELD_CHARS),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function compact(body) {
|
|
80
|
+
const out = {};
|
|
81
|
+
for (const [key, value] of Object.entries(body)) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
function boundedNever(rejectable, warn) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const timer = setTimeout(resolve, FLUSH_TIMEOUT_MS);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
rejectable
|
|
93
|
+
.catch((error) => warn(error))
|
|
94
|
+
.finally(() => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
resolve();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
102
|
+
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
103
|
+
* this direct factory exists for tests (inject `langfuseImpl`).
|
|
104
|
+
*/
|
|
105
|
+
export function createLlmTracingClient(env = process.env, options) {
|
|
106
|
+
if (!options?.langfuseImpl && !isLlmTracingEnabled(env))
|
|
107
|
+
return null;
|
|
108
|
+
let lastWarnAtMs = 0;
|
|
109
|
+
const warn = (error, context) => {
|
|
110
|
+
const nowMs = Date.now();
|
|
111
|
+
if (nowMs - lastWarnAtMs < WARN_THROTTLE_MS)
|
|
112
|
+
return;
|
|
113
|
+
lastWarnAtMs = nowMs;
|
|
114
|
+
log("warn", "llm_tracing.ingest_failed", {
|
|
115
|
+
provider: "langfuse",
|
|
116
|
+
error_message: error instanceof Error ? error.message : String(error),
|
|
117
|
+
...context,
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
let sdk;
|
|
121
|
+
try {
|
|
122
|
+
sdk =
|
|
123
|
+
options?.langfuseImpl ??
|
|
124
|
+
new Langfuse({
|
|
125
|
+
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
|
126
|
+
secretKey: env.LANGFUSE_SECRET_KEY,
|
|
127
|
+
...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
|
|
128
|
+
environment: resolveLlmTracingEnvironment(env),
|
|
129
|
+
sdkIntegration: "oxygen",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
warn(error, { stage: "construct" });
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
// The SDK surfaces async ingest failures on its emitter; unheard, they are
|
|
137
|
+
// unhandled rejections. Route them into the throttled warn.
|
|
138
|
+
try {
|
|
139
|
+
sdk.on?.("error", (error) => warn(error, { stage: "ingest" }));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// an emitter-less test double is fine
|
|
143
|
+
}
|
|
144
|
+
const guarded = (fn, stage) => {
|
|
145
|
+
try {
|
|
146
|
+
fn();
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
warn(error, { stage });
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
return {
|
|
153
|
+
trace: (body) => guarded(() => void sdk.trace(compact(body)), "trace"),
|
|
154
|
+
span: (body) => guarded(() => void sdk.span(compact(body)), "span"),
|
|
155
|
+
generation: (body) => guarded(() => void sdk.generation(compact(body)), "generation"),
|
|
156
|
+
event: (body) => guarded(() => void sdk.event(compact(body)), "event"),
|
|
157
|
+
flush: () => boundedNever(sdk.flushAsync(), (error) => warn(error, { stage: "flush" })),
|
|
158
|
+
shutdown: () => boundedNever(sdk.shutdownAsync(), (error) => warn(error, { stage: "shutdown" })),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
// --- Process-wide singleton (both runtimes construct at most one client) ------
|
|
162
|
+
let cached = null;
|
|
163
|
+
function cacheKey(env) {
|
|
164
|
+
return [
|
|
165
|
+
env.OXYGEN_LLM_TRACING_ENABLED ?? "",
|
|
166
|
+
env.LANGFUSE_PUBLIC_KEY ?? "",
|
|
167
|
+
env.LANGFUSE_SECRET_KEY ?? "",
|
|
168
|
+
env.LANGFUSE_BASE_URL ?? "",
|
|
169
|
+
resolveLlmTracingEnvironment(env),
|
|
170
|
+
].join("|");
|
|
171
|
+
}
|
|
172
|
+
export function getLlmTracingClient(env = process.env) {
|
|
173
|
+
const key = cacheKey(env);
|
|
174
|
+
if (cached && cached.key === key)
|
|
175
|
+
return cached.client;
|
|
176
|
+
cached = { key, client: createLlmTracingClient(env) };
|
|
177
|
+
return cached.client;
|
|
178
|
+
}
|
|
179
|
+
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
180
|
+
export function flushLlmTracing(env = process.env) {
|
|
181
|
+
if (!isLlmTracingEnabled(env))
|
|
182
|
+
return Promise.resolve();
|
|
183
|
+
const client = getLlmTracingClient(env);
|
|
184
|
+
return client ? client.flush() : Promise.resolve();
|
|
185
|
+
}
|
|
186
|
+
// Serverless flush scheduling (the axiom-log-shipper pattern): the web runtime
|
|
187
|
+
// registers next/server's after() here at instrumentation time; emission sites
|
|
188
|
+
// then call scheduleLlmTracingFlush() and the flush runs post-response instead
|
|
189
|
+
// of adding latency inside the request. Off-web (worker, tests) there is no
|
|
190
|
+
// scheduler and the flush degrades to fire-and-forget — the worker's cycle-end
|
|
191
|
+
// awaited flush + SDK interval flush are the durability guarantee there.
|
|
192
|
+
//
|
|
193
|
+
// The registration lives on globalThis, not in a module-local: Next.js gives
|
|
194
|
+
// instrumentation.ts and each route handler their own copy of this module, so a
|
|
195
|
+
// module-local set at instrumentation time is invisible to the routes that emit
|
|
196
|
+
// traces — their flushes silently degraded to fire-and-forget, which a Vercel
|
|
197
|
+
// post-response freeze can kill. Same slot pattern as setLogSink and the
|
|
198
|
+
// LinkedIn/WhatsApp quota enforcers. (The `cached` client stays module-local on
|
|
199
|
+
// purpose: each bundle flushing its own client is correct — the run closure
|
|
200
|
+
// below flushes the CALLING bundle's client through the shared scheduler.)
|
|
201
|
+
const FLUSH_SCHEDULER_SLOT = Symbol.for("oxygen.llm_tracing.flush_scheduler");
|
|
202
|
+
export function setLlmTracingFlushScheduler(scheduler) {
|
|
203
|
+
globalThis[FLUSH_SCHEDULER_SLOT] = scheduler;
|
|
204
|
+
}
|
|
205
|
+
/** Request-safe flush trigger: post-response on web, fire-and-forget elsewhere. */
|
|
206
|
+
export function scheduleLlmTracingFlush(env = process.env) {
|
|
207
|
+
if (!isLlmTracingEnabled(env))
|
|
208
|
+
return;
|
|
209
|
+
const run = () => flushLlmTracing(env);
|
|
210
|
+
const flushScheduler = globalThis[FLUSH_SCHEDULER_SLOT] ?? null;
|
|
211
|
+
if (flushScheduler) {
|
|
212
|
+
try {
|
|
213
|
+
flushScheduler(run);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Outside a request scope after() throws — degrade to fire-and-forget.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
void run();
|
|
221
|
+
}
|
|
222
|
+
/** Shutdown the singleton (worker exit). Never rejects. */
|
|
223
|
+
export function shutdownLlmTracing(env = process.env) {
|
|
224
|
+
const client = cached?.client;
|
|
225
|
+
cached = null;
|
|
226
|
+
return client ? client.shutdown() : Promise.resolve();
|
|
227
|
+
}
|
|
228
|
+
export function __resetLlmTracingForTests() {
|
|
229
|
+
cached = null;
|
|
230
|
+
globalThis[FLUSH_SCHEDULER_SLOT] = null;
|
|
231
|
+
}
|
|
@@ -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|…}}`
|
|
148
|
-
* Unknown `{{column}}` placeholders render empty. Used
|
|
149
|
-
* produce the final message text. Pass `{ seed }` to make
|
|
150
|
-
* replayable (crash-safe); with no seed a stable template+values
|
|
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|…}}`
|
|
68
|
-
* Unknown `{{column}}` placeholders render empty. Used
|
|
69
|
-
* produce the final message text. Pass `{ seed }` to make
|
|
70
|
-
* replayable (crash-safe); with no seed a stable template+values
|
|
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
|
|
51
|
+
let decoded;
|
|
52
52
|
try {
|
|
53
|
-
|
|
53
|
+
decoded = decodeURIComponent(rawHandle).trim();
|
|
54
54
|
}
|
|
55
55
|
catch {
|
|
56
|
-
|
|
56
|
+
decoded = rawHandle.trim();
|
|
57
57
|
}
|
|
58
|
-
if (!
|
|
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:
|