@oxygen-agent/cli 1.1011.1091 → 1.1012.2
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/command-manifest.d.ts +3 -1
- package/dist/command-manifest.js +11 -0
- package/dist/customer-operation-commands.js +1 -1
- package/dist/index.js +57 -31
- package/dist/transcript.js +1 -1
- package/node_modules/@oxygen/formula/dist/expression.js +16 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.js +15 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +2 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +28 -4
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +2 -2
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +3 -3
- package/node_modules/@oxygen/shared/dist/contracts/geo.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/contracts/geo.js +138 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +10 -4
- package/node_modules/@oxygen/shared/dist/cost-estimate.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/cost-estimate.js +23 -8
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +2 -1
- package/node_modules/@oxygen/shared/dist/external-effect-arming.d.ts +54 -0
- package/node_modules/@oxygen/shared/dist/external-effect-arming.js +90 -0
- package/node_modules/@oxygen/shared/dist/geo.d.ts +247 -0
- package/node_modules/@oxygen/shared/dist/geo.js +314 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +9 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +1 -1
- package/node_modules/@oxygen/shared/dist/managed-email-branding.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/managed-email-branding.js +81 -0
- package/node_modules/@oxygen/shared/dist/pricing-sheet.d.ts +20 -24
- package/node_modules/@oxygen/shared/dist/pricing-sheet.js +15 -16
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.d.ts +3 -3
- package/node_modules/@oxygen/shared/dist/pricing-snapshot.generated.js +3 -3
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +6 -5
- package/node_modules/@oxygen/shared/dist/recipes.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/recipes.js +1 -0
- package/node_modules/@oxygen/shared/dist/repricing.d.ts +4 -14
- package/node_modules/@oxygen/shared/dist/repricing.js +25 -64
- package/node_modules/@oxygen/shared/dist/research-depth.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/research-depth.js +7 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.d.ts +46 -0
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +51 -1
- package/node_modules/@oxygen/shared/package.json +10 -0
- package/package.json +1 -1
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { OxygenError } from "./cli-result.js";
|
|
2
|
+
import { isProductionData, resolveDeployEnv } from "./deploy-env.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which background EXTERNAL EFFECTS each deployment is armed to dispatch, declared
|
|
5
|
+
* here per environment rather than implied by whichever credentials a box holds.
|
|
6
|
+
*
|
|
7
|
+
* OXP-1.6.190 (classify-A P-05). Three background families write outside OXYGEN with
|
|
8
|
+
* no human in the loop: the off-session Stripe writes of `inbox_billing_reconcile`,
|
|
9
|
+
* the Zapmail writes of `mailbox_connection_repair`, and the dedicated-egress vendor
|
|
10
|
+
* release (IPRoyal `toggle-auto-extend`) of the egress order reconcile and lifecycle.
|
|
11
|
+
* Before this table their arming was implicit: present credentials meant armed.
|
|
12
|
+
*
|
|
13
|
+
* Chosen design (coordinator, 2026-10-07, "option 2"): every CURRENT default is kept, so
|
|
14
|
+
* dev billing reconcile, Zapmail repair and egress releases keep working for dev flows,
|
|
15
|
+
* but the state is now explicit, reviewable and read back, and two mismatches fail
|
|
16
|
+
* closed outside production-data environments: a LIVE Stripe key, and a vendor release
|
|
17
|
+
* whose family is not armed here. Production stays armed; nothing here changes a
|
|
18
|
+
* production default or touches a secret. A default-off posture outside production
|
|
19
|
+
* was considered and not chosen, because other dev workflows depend on these effects.
|
|
20
|
+
*
|
|
21
|
+
* Changing an entry is a reviewed code change. `OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR`
|
|
22
|
+
* remains the existing runtime kill switch for the repair family (`false` disarms it).
|
|
23
|
+
*/
|
|
24
|
+
export const EXTERNAL_EFFECT_FAMILIES = [
|
|
25
|
+
"inbox_billing_stripe",
|
|
26
|
+
"mailbox_connection_repair",
|
|
27
|
+
"egress_vendor_release",
|
|
28
|
+
];
|
|
29
|
+
/** The checked-in declaration, per deploy tier (see `resolveDeployEnv`). */
|
|
30
|
+
export const EXTERNAL_EFFECT_ARMING = {
|
|
31
|
+
inbox_billing_stripe: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
|
|
32
|
+
mailbox_connection_repair: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
|
|
33
|
+
egress_vendor_release: { prod: "armed", shadow: "armed", dev: "armed", local: "armed" },
|
|
34
|
+
};
|
|
35
|
+
/** Per-family runtime kill switch: an explicit false disarms, nothing arms beyond the declaration. */
|
|
36
|
+
const KILL_SWITCH_ENV = {
|
|
37
|
+
mailbox_connection_repair: "OXYGEN_WORKER_MAILBOX_CONNECTION_REPAIR",
|
|
38
|
+
};
|
|
39
|
+
function killSwitchDisarms(family, env) {
|
|
40
|
+
const name = KILL_SWITCH_ENV[family];
|
|
41
|
+
const raw = name ? env[name]?.trim().toLowerCase() : undefined;
|
|
42
|
+
// Exactly the worker's readBooleanEnv falsy set, so the readback and the gate agree.
|
|
43
|
+
return raw === "false" || raw === "0" || raw === "no";
|
|
44
|
+
}
|
|
45
|
+
export function resolveExternalEffectArming(family, env = process.env) {
|
|
46
|
+
const deploy = resolveDeployEnv(env);
|
|
47
|
+
const declared = EXTERNAL_EFFECT_ARMING[family][deploy.tier];
|
|
48
|
+
const disarmedByEnv = declared === "armed" && killSwitchDisarms(family, env);
|
|
49
|
+
return {
|
|
50
|
+
family,
|
|
51
|
+
armed: declared === "armed" && !disarmedByEnv,
|
|
52
|
+
declared,
|
|
53
|
+
source: disarmedByEnv ? "env" : "declaration",
|
|
54
|
+
tier: deploy.tier,
|
|
55
|
+
deploy_env: deploy.label,
|
|
56
|
+
production_data: isProductionData(env),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** The readback: every family's effective gate for this process. */
|
|
60
|
+
export function describeExternalEffectArming(env = process.env) {
|
|
61
|
+
return EXTERNAL_EFFECT_FAMILIES.map((family) => resolveExternalEffectArming(family, env));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Throw before an effect is dispatched when this process is not armed for it.
|
|
65
|
+
* Production-data environments keep their declared state; the refusal names the
|
|
66
|
+
* family and environment so a skipped effect is never silent.
|
|
67
|
+
*/
|
|
68
|
+
export function assertExternalEffectArmed(family, env = process.env) {
|
|
69
|
+
const arming = resolveExternalEffectArming(family, env);
|
|
70
|
+
if (!arming.armed) {
|
|
71
|
+
throw new OxygenError("external_effect_not_armed", `${family} is not armed in ${arming.deploy_env} (${arming.source === "env" ? "disarmed by its kill switch" : "declared disarmed"}), so this external effect was not dispatched.`, { details: { family, deploy_env: arming.deploy_env, source: arming.source }, exitCode: 1 });
|
|
72
|
+
}
|
|
73
|
+
return arming;
|
|
74
|
+
}
|
|
75
|
+
/** Live-mode Stripe secret or restricted key. Test keys (`sk_test_`/`rk_test_`) never match. */
|
|
76
|
+
export function isLiveStripeKey(key) {
|
|
77
|
+
const value = key?.trim() ?? "";
|
|
78
|
+
return value.startsWith("sk_live_") || value.startsWith("rk_live_");
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Fail closed when a process that does NOT hold production data is about to write to
|
|
82
|
+
* Stripe with a LIVE key: that is a dev or local box pointed at real money. The key is
|
|
83
|
+
* never echoed; only its mode is named.
|
|
84
|
+
*/
|
|
85
|
+
export function assertStripeKeyMatchesEnvironment(key, env = process.env) {
|
|
86
|
+
if (!isLiveStripeKey(key) || isProductionData(env))
|
|
87
|
+
return;
|
|
88
|
+
const deploy = resolveDeployEnv(env);
|
|
89
|
+
throw new OxygenError("external_effect_environment_mismatch", `A live-mode Stripe key is configured in ${deploy.label}, which does not hold production data; refusing the Stripe write. Use a test-mode key outside production.`, { details: { family: "inbox_billing_stripe", deploy_env: deploy.label, key_mode: "live" }, exitCode: 1 });
|
|
90
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
export declare const GEO_ENGINES: readonly ["chatgpt", "gemini", "google-ai-mode", "google-ai-overview", "perplexity", "copilot", "claude"];
|
|
2
|
+
export type GeoEngine = (typeof GEO_ENGINES)[number];
|
|
3
|
+
export type GeoMarket = {
|
|
4
|
+
country: string;
|
|
5
|
+
language: string;
|
|
6
|
+
};
|
|
7
|
+
export type GeoCompetitor = {
|
|
8
|
+
name: string;
|
|
9
|
+
aliases: string[];
|
|
10
|
+
domains: string[];
|
|
11
|
+
};
|
|
12
|
+
export type GeoProject = GeoCompetitor & {
|
|
13
|
+
id: string;
|
|
14
|
+
icp: string;
|
|
15
|
+
engines: GeoEngine[];
|
|
16
|
+
markets: GeoMarket[];
|
|
17
|
+
repetitions: number;
|
|
18
|
+
schedule: "paused" | "daily" | "weekly";
|
|
19
|
+
next_run_at: string | null;
|
|
20
|
+
archived: boolean;
|
|
21
|
+
};
|
|
22
|
+
export type GeoPrompt = {
|
|
23
|
+
id: string;
|
|
24
|
+
text: string;
|
|
25
|
+
topic: string;
|
|
26
|
+
persona: string;
|
|
27
|
+
stage: string;
|
|
28
|
+
source: "hypothesis" | "research" | "customer";
|
|
29
|
+
evidence_id?: string;
|
|
30
|
+
enabled: boolean;
|
|
31
|
+
revision: number;
|
|
32
|
+
};
|
|
33
|
+
export type GeoCitation = {
|
|
34
|
+
url: string;
|
|
35
|
+
domain: string;
|
|
36
|
+
position: number;
|
|
37
|
+
category: "owned" | "competitor" | "community" | "review" | "editorial" | "other";
|
|
38
|
+
};
|
|
39
|
+
export type GeoMention = {
|
|
40
|
+
brand: string;
|
|
41
|
+
present: boolean;
|
|
42
|
+
evidence: string | null;
|
|
43
|
+
recommendation_rank: number | null;
|
|
44
|
+
};
|
|
45
|
+
export type GeoMeasurement = {
|
|
46
|
+
brand: GeoCompetitor;
|
|
47
|
+
competitors: GeoCompetitor[];
|
|
48
|
+
};
|
|
49
|
+
export type GeoAnswer = {
|
|
50
|
+
id: string;
|
|
51
|
+
run_id: string | null;
|
|
52
|
+
prompt_id: string | null;
|
|
53
|
+
prompt: string;
|
|
54
|
+
engine: GeoEngine;
|
|
55
|
+
provider: string;
|
|
56
|
+
collection: "consumer" | "import";
|
|
57
|
+
market: GeoMarket | null;
|
|
58
|
+
reported_market: GeoMarket | null;
|
|
59
|
+
captured_at: string;
|
|
60
|
+
text: string;
|
|
61
|
+
citations: GeoCitation[];
|
|
62
|
+
mentions: GeoMention[];
|
|
63
|
+
fan_out: string[];
|
|
64
|
+
outcome: "answer" | "no_overview";
|
|
65
|
+
cost_micros: number;
|
|
66
|
+
raw: unknown;
|
|
67
|
+
extraction_version: number;
|
|
68
|
+
measurement?: GeoMeasurement;
|
|
69
|
+
cost_basis?: "reported" | "reserved_estimate";
|
|
70
|
+
prompt_revision?: number;
|
|
71
|
+
topic?: string;
|
|
72
|
+
};
|
|
73
|
+
export type GeoDocumentKind = "prompt" | "competitor" | "answer" | "evidence" | "opportunity" | "experiment" | "audit";
|
|
74
|
+
export type GeoDocument = {
|
|
75
|
+
id: string;
|
|
76
|
+
project_id: string;
|
|
77
|
+
kind: GeoDocumentKind;
|
|
78
|
+
data: Record<string, unknown>;
|
|
79
|
+
created_at: string;
|
|
80
|
+
updated_at: string;
|
|
81
|
+
};
|
|
82
|
+
export type GeoRunItem = {
|
|
83
|
+
id: string;
|
|
84
|
+
run_id: string;
|
|
85
|
+
project_id: string;
|
|
86
|
+
prompt: GeoPrompt;
|
|
87
|
+
brand: GeoCompetitor;
|
|
88
|
+
competitors: GeoCompetitor[];
|
|
89
|
+
engine: GeoEngine;
|
|
90
|
+
market: GeoMarket;
|
|
91
|
+
state: "queued" | "submitting" | "collecting" | "succeeded" | "failed" | "uncertain" | "cancelled";
|
|
92
|
+
provider_task_id: string | null;
|
|
93
|
+
provider: string;
|
|
94
|
+
reserved_micros: number;
|
|
95
|
+
cost_micros: number;
|
|
96
|
+
error: string | null;
|
|
97
|
+
attempt: number;
|
|
98
|
+
terminal_before_dispatch?: boolean;
|
|
99
|
+
submission_cost_micros?: number | null;
|
|
100
|
+
};
|
|
101
|
+
export declare const GEO_PILOT_CAP_MICROS = 10000000;
|
|
102
|
+
/** The initial release gate is independent of NODE_ENV and protects shadow data. */
|
|
103
|
+
export declare function isGeoEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
104
|
+
export declare function assertGeoEnabled(env?: NodeJS.ProcessEnv): void;
|
|
105
|
+
export declare function geoDomain(value: string): string | null;
|
|
106
|
+
export declare function analyzeGeoAnswer(text: string, brand: GeoCompetitor, competitors: GeoCompetitor[], urls: string[]): {
|
|
107
|
+
mentions: GeoMention[];
|
|
108
|
+
citations: GeoCitation[];
|
|
109
|
+
measurement: GeoMeasurement;
|
|
110
|
+
};
|
|
111
|
+
/** Exact capture-time configuration, canonicalized for matched comparisons. */
|
|
112
|
+
export declare function geoMeasurementKey(answer: Pick<GeoAnswer, "measurement">): string;
|
|
113
|
+
export declare function summarizeGeoAnswers(answers: GeoAnswer[], brand: string, failedSamples?: number): {
|
|
114
|
+
successful_samples: number;
|
|
115
|
+
measurement_contexts: number;
|
|
116
|
+
failed_samples: number;
|
|
117
|
+
imported_samples: number;
|
|
118
|
+
no_overview_samples: number;
|
|
119
|
+
mentioned_samples: number;
|
|
120
|
+
visibility: number | null;
|
|
121
|
+
cited_samples: number;
|
|
122
|
+
citation_coverage: number | null;
|
|
123
|
+
share_of_voice: number | null;
|
|
124
|
+
competitors: {
|
|
125
|
+
name: string;
|
|
126
|
+
mentioned_samples: number;
|
|
127
|
+
share_of_voice: number | null;
|
|
128
|
+
}[];
|
|
129
|
+
sources: {
|
|
130
|
+
domain: string;
|
|
131
|
+
count: number;
|
|
132
|
+
category: string;
|
|
133
|
+
urls: string[];
|
|
134
|
+
}[];
|
|
135
|
+
};
|
|
136
|
+
/** No fetching or execution: customer wording and citations are evidence only. */
|
|
137
|
+
export declare function parseGeoTranscript(input: string, redactions?: string[]): {
|
|
138
|
+
messages: {
|
|
139
|
+
role: "user" | "assistant";
|
|
140
|
+
text: string;
|
|
141
|
+
}[];
|
|
142
|
+
prompts: string[];
|
|
143
|
+
sources: string[];
|
|
144
|
+
redacted_text: string;
|
|
145
|
+
};
|
|
146
|
+
/** Compare only common collection cohorts; imports and missing AIOs are excluded. */
|
|
147
|
+
export declare function compareGeoExperiment(before: GeoAnswer[], after: GeoAnswer[], brand: string): {
|
|
148
|
+
before: {
|
|
149
|
+
successful_samples: number;
|
|
150
|
+
measurement_contexts: number;
|
|
151
|
+
failed_samples: number;
|
|
152
|
+
imported_samples: number;
|
|
153
|
+
no_overview_samples: number;
|
|
154
|
+
mentioned_samples: number;
|
|
155
|
+
visibility: number | null;
|
|
156
|
+
cited_samples: number;
|
|
157
|
+
citation_coverage: number | null;
|
|
158
|
+
share_of_voice: number | null;
|
|
159
|
+
competitors: {
|
|
160
|
+
name: string;
|
|
161
|
+
mentioned_samples: number;
|
|
162
|
+
share_of_voice: number | null;
|
|
163
|
+
}[];
|
|
164
|
+
sources: {
|
|
165
|
+
domain: string;
|
|
166
|
+
count: number;
|
|
167
|
+
category: string;
|
|
168
|
+
urls: string[];
|
|
169
|
+
}[];
|
|
170
|
+
};
|
|
171
|
+
after: {
|
|
172
|
+
successful_samples: number;
|
|
173
|
+
measurement_contexts: number;
|
|
174
|
+
failed_samples: number;
|
|
175
|
+
imported_samples: number;
|
|
176
|
+
no_overview_samples: number;
|
|
177
|
+
mentioned_samples: number;
|
|
178
|
+
visibility: number | null;
|
|
179
|
+
cited_samples: number;
|
|
180
|
+
citation_coverage: number | null;
|
|
181
|
+
share_of_voice: number | null;
|
|
182
|
+
competitors: {
|
|
183
|
+
name: string;
|
|
184
|
+
mentioned_samples: number;
|
|
185
|
+
share_of_voice: number | null;
|
|
186
|
+
}[];
|
|
187
|
+
sources: {
|
|
188
|
+
domain: string;
|
|
189
|
+
count: number;
|
|
190
|
+
category: string;
|
|
191
|
+
urls: string[];
|
|
192
|
+
}[];
|
|
193
|
+
};
|
|
194
|
+
comparable_cohorts: number;
|
|
195
|
+
excluded_before: number;
|
|
196
|
+
excluded_after: number;
|
|
197
|
+
cohorts: {
|
|
198
|
+
cohort: any;
|
|
199
|
+
before: {
|
|
200
|
+
successful_samples: number;
|
|
201
|
+
measurement_contexts: number;
|
|
202
|
+
failed_samples: number;
|
|
203
|
+
imported_samples: number;
|
|
204
|
+
no_overview_samples: number;
|
|
205
|
+
mentioned_samples: number;
|
|
206
|
+
visibility: number | null;
|
|
207
|
+
cited_samples: number;
|
|
208
|
+
citation_coverage: number | null;
|
|
209
|
+
share_of_voice: number | null;
|
|
210
|
+
competitors: {
|
|
211
|
+
name: string;
|
|
212
|
+
mentioned_samples: number;
|
|
213
|
+
share_of_voice: number | null;
|
|
214
|
+
}[];
|
|
215
|
+
sources: {
|
|
216
|
+
domain: string;
|
|
217
|
+
count: number;
|
|
218
|
+
category: string;
|
|
219
|
+
urls: string[];
|
|
220
|
+
}[];
|
|
221
|
+
};
|
|
222
|
+
after: {
|
|
223
|
+
successful_samples: number;
|
|
224
|
+
measurement_contexts: number;
|
|
225
|
+
failed_samples: number;
|
|
226
|
+
imported_samples: number;
|
|
227
|
+
no_overview_samples: number;
|
|
228
|
+
mentioned_samples: number;
|
|
229
|
+
visibility: number | null;
|
|
230
|
+
cited_samples: number;
|
|
231
|
+
citation_coverage: number | null;
|
|
232
|
+
share_of_voice: number | null;
|
|
233
|
+
competitors: {
|
|
234
|
+
name: string;
|
|
235
|
+
mentioned_samples: number;
|
|
236
|
+
share_of_voice: number | null;
|
|
237
|
+
}[];
|
|
238
|
+
sources: {
|
|
239
|
+
domain: string;
|
|
240
|
+
count: number;
|
|
241
|
+
category: string;
|
|
242
|
+
urls: string[];
|
|
243
|
+
}[];
|
|
244
|
+
};
|
|
245
|
+
}[];
|
|
246
|
+
interpretation: string;
|
|
247
|
+
};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { OxygenError } from "./cli-result.js";
|
|
2
|
+
import { resolveDeployEnv } from "./deploy-env.js";
|
|
3
|
+
export const GEO_ENGINES = [
|
|
4
|
+
"chatgpt",
|
|
5
|
+
"gemini",
|
|
6
|
+
"google-ai-mode",
|
|
7
|
+
"google-ai-overview",
|
|
8
|
+
"perplexity",
|
|
9
|
+
"copilot",
|
|
10
|
+
"claude",
|
|
11
|
+
];
|
|
12
|
+
export const GEO_PILOT_CAP_MICROS = 10_000_000;
|
|
13
|
+
/** The initial release gate is independent of NODE_ENV and protects shadow data. */
|
|
14
|
+
export function isGeoEnabled(env = process.env) {
|
|
15
|
+
return resolveDeployEnv(env).tier === "dev";
|
|
16
|
+
}
|
|
17
|
+
export function assertGeoEnabled(env = process.env) {
|
|
18
|
+
if (!isGeoEnabled(env))
|
|
19
|
+
throw new OxygenError("geo_unavailable", "GEO / AEO is available to staff on dev while it is being tested.", { exitCode: 1 });
|
|
20
|
+
}
|
|
21
|
+
export function geoDomain(value) {
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(value.includes("://") ? value : `https://${value}`);
|
|
24
|
+
if (!["http:", "https:"].includes(url.protocol) ||
|
|
25
|
+
url.username ||
|
|
26
|
+
url.password)
|
|
27
|
+
return null;
|
|
28
|
+
return url.hostname
|
|
29
|
+
.toLowerCase()
|
|
30
|
+
.replace(/^www\./, "")
|
|
31
|
+
.replace(/\.$/, "");
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function matchedTerm(text, term) {
|
|
38
|
+
const escaped = term.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
39
|
+
if (!escaped)
|
|
40
|
+
return -1;
|
|
41
|
+
const match = new RegExp(`(^|[^\\p{L}\\p{N}])(${escaped})(?=$|[^\\p{L}\\p{N}])`, "iu").exec(text);
|
|
42
|
+
return match ? match.index + (match[1]?.length ?? 0) : -1;
|
|
43
|
+
}
|
|
44
|
+
export function analyzeGeoAnswer(text, brand, competitors, urls) {
|
|
45
|
+
const prose = text.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (_link, label, url) => {
|
|
46
|
+
// A domain-only citation label is source attribution, not a recommendation.
|
|
47
|
+
return label.trim().includes(".") &&
|
|
48
|
+
geoDomain(label.trim()) === geoDomain(url)
|
|
49
|
+
? ""
|
|
50
|
+
: label;
|
|
51
|
+
});
|
|
52
|
+
const subjects = [brand, ...competitors];
|
|
53
|
+
const mentions = subjects.map((subject) => {
|
|
54
|
+
const indices = [subject.name, ...subject.aliases, ...subject.domains]
|
|
55
|
+
.map((term) => matchedTerm(prose, term))
|
|
56
|
+
.filter((index) => index >= 0);
|
|
57
|
+
const first = indices.length ? Math.min(...indices) : -1;
|
|
58
|
+
let rank = null;
|
|
59
|
+
for (const line of prose.split("\n")) {
|
|
60
|
+
const list = /^\s*(\d{1,2})[.)]\s+(.+)$/.exec(line);
|
|
61
|
+
if (list &&
|
|
62
|
+
[subject.name, ...subject.aliases].some((term) => matchedTerm(list[2] ?? "", term) >= 0)) {
|
|
63
|
+
rank = Number(list[1]);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
brand: subject.name,
|
|
69
|
+
present: first >= 0,
|
|
70
|
+
evidence: first >= 0 ? prose.slice(Math.max(0, first - 60), first + 180) : null,
|
|
71
|
+
recommendation_rank: rank,
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
const citations = [];
|
|
75
|
+
const seen = new Set();
|
|
76
|
+
for (const raw of urls) {
|
|
77
|
+
try {
|
|
78
|
+
const url = new URL(raw);
|
|
79
|
+
const domain = geoDomain(raw);
|
|
80
|
+
if (!domain)
|
|
81
|
+
continue;
|
|
82
|
+
url.hash = "";
|
|
83
|
+
for (const key of [...url.searchParams.keys()])
|
|
84
|
+
if (/^(utm_|fbclid|gclid)/i.test(key))
|
|
85
|
+
url.searchParams.delete(key);
|
|
86
|
+
const normalized = url.href;
|
|
87
|
+
if (seen.has(normalized))
|
|
88
|
+
continue;
|
|
89
|
+
seen.add(normalized);
|
|
90
|
+
const belongs = (subject) => subject.domains.some((d) => {
|
|
91
|
+
const owned = geoDomain(d);
|
|
92
|
+
return owned && (domain === owned || domain.endsWith(`.${owned}`));
|
|
93
|
+
});
|
|
94
|
+
const category = belongs(brand)
|
|
95
|
+
? "owned"
|
|
96
|
+
: competitors.some(belongs)
|
|
97
|
+
? "competitor"
|
|
98
|
+
: /(^|\.)(reddit\.com|linkedin\.com|youtube\.com|quora\.com)$/.test(domain)
|
|
99
|
+
? "community"
|
|
100
|
+
: /(^|\.)(g2\.com|capterra\.com|trustpilot\.com)$/.test(domain)
|
|
101
|
+
? "review"
|
|
102
|
+
: "other";
|
|
103
|
+
citations.push({
|
|
104
|
+
url: normalized,
|
|
105
|
+
domain,
|
|
106
|
+
position: citations.length + 1,
|
|
107
|
+
category,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* An invalid provider URL is retained in raw evidence, not made clickable. */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return {
|
|
115
|
+
mentions,
|
|
116
|
+
citations,
|
|
117
|
+
measurement: structuredClone({ brand, competitors }),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
/** Exact capture-time configuration, canonicalized for matched comparisons. */
|
|
121
|
+
export function geoMeasurementKey(answer) {
|
|
122
|
+
if (!answer.measurement)
|
|
123
|
+
return "unknown";
|
|
124
|
+
const subject = (value) => ({
|
|
125
|
+
name: value.name.trim().toLowerCase(),
|
|
126
|
+
aliases: [
|
|
127
|
+
...new Set(value.aliases.map((a) => a.trim().toLowerCase())),
|
|
128
|
+
].sort(),
|
|
129
|
+
domains: [
|
|
130
|
+
...new Set(value.domains.map((d) => geoDomain(d) ?? d.toLowerCase())),
|
|
131
|
+
].sort(),
|
|
132
|
+
});
|
|
133
|
+
return JSON.stringify({
|
|
134
|
+
brand: subject(answer.measurement.brand),
|
|
135
|
+
competitors: answer.measurement.competitors
|
|
136
|
+
.map(subject)
|
|
137
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
export function summarizeGeoAnswers(answers, brand, failedSamples = 0) {
|
|
141
|
+
const samples = answers.filter((a) => a.collection === "consumer" && a.outcome === "answer");
|
|
142
|
+
const measurementContexts = new Set(samples.map(geoMeasurementKey)).size;
|
|
143
|
+
const comparable = measurementContexts <= 1;
|
|
144
|
+
const brands = new Map();
|
|
145
|
+
for (const sample of samples) {
|
|
146
|
+
const presentBrands = new Set(sample.mentions
|
|
147
|
+
.filter((m) => m.present)
|
|
148
|
+
.map((m) => m.brand === sample.measurement?.brand.name ? brand : m.brand));
|
|
149
|
+
for (const name of presentBrands)
|
|
150
|
+
brands.set(name, (brands.get(name) ?? 0) + 1);
|
|
151
|
+
}
|
|
152
|
+
const totalMentions = [...brands.values()].reduce((a, b) => a + b, 0);
|
|
153
|
+
const present = brands.get(brand) ?? 0;
|
|
154
|
+
const cited = samples.filter((a) => a.citations.some((c) => c.category === "owned")).length;
|
|
155
|
+
const sources = new Map();
|
|
156
|
+
for (const answer of samples)
|
|
157
|
+
for (const citation of answer.citations) {
|
|
158
|
+
const source = sources.get(citation.domain) ?? {
|
|
159
|
+
domain: citation.domain,
|
|
160
|
+
count: 0,
|
|
161
|
+
category: citation.category,
|
|
162
|
+
urls: [],
|
|
163
|
+
};
|
|
164
|
+
source.count++;
|
|
165
|
+
if (!source.urls.includes(citation.url))
|
|
166
|
+
source.urls.push(citation.url);
|
|
167
|
+
sources.set(citation.domain, source);
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
successful_samples: samples.length,
|
|
171
|
+
measurement_contexts: measurementContexts,
|
|
172
|
+
failed_samples: failedSamples,
|
|
173
|
+
imported_samples: answers.filter((a) => a.collection === "import").length,
|
|
174
|
+
no_overview_samples: answers.filter((a) => a.outcome === "no_overview")
|
|
175
|
+
.length,
|
|
176
|
+
mentioned_samples: present,
|
|
177
|
+
visibility: comparable && samples.length ? present / samples.length : null,
|
|
178
|
+
cited_samples: cited,
|
|
179
|
+
citation_coverage: comparable && samples.length ? cited / samples.length : null,
|
|
180
|
+
share_of_voice: comparable && totalMentions ? present / totalMentions : null,
|
|
181
|
+
competitors: [...brands].map(([name, count]) => ({
|
|
182
|
+
name,
|
|
183
|
+
mentioned_samples: count,
|
|
184
|
+
share_of_voice: comparable && totalMentions ? count / totalMentions : null,
|
|
185
|
+
})),
|
|
186
|
+
sources: [...sources.values()].sort((a, b) => b.count - a.count),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
/** No fetching or execution: customer wording and citations are evidence only. */
|
|
190
|
+
export function parseGeoTranscript(input, redactions = []) {
|
|
191
|
+
if (new TextEncoder().encode(input).byteLength > 1_000_000)
|
|
192
|
+
throw new OxygenError("geo_import_too_large", "Import one conversation of at most 1 MB.", { exitCode: 2 });
|
|
193
|
+
const redact = (value) => redactions.reduce((text, term) => term.trim() ? text.split(term).join("[redacted]") : text, value);
|
|
194
|
+
const messages = [];
|
|
195
|
+
let parsed;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(input);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
parsed = null;
|
|
201
|
+
}
|
|
202
|
+
if (Array.isArray(parsed) && parsed.length > 1)
|
|
203
|
+
throw new OxygenError("geo_conversation_required", "Import one selected conversation instead of a full account export.", { exitCode: 2 });
|
|
204
|
+
const root = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
205
|
+
if (root && typeof root === "object") {
|
|
206
|
+
const data = root;
|
|
207
|
+
if (data.mapping && typeof data.mapping === "object") {
|
|
208
|
+
const mapping = data.mapping;
|
|
209
|
+
const branch = [];
|
|
210
|
+
let node = typeof data.current_node === "string" ? data.current_node : null;
|
|
211
|
+
if (!node || !Object.hasOwn(mapping, node))
|
|
212
|
+
throw new OxygenError("geo_conversation_branch_required", "Import a conversation export with a valid selected branch.", { exitCode: 2 });
|
|
213
|
+
const visited = new Set();
|
|
214
|
+
while (node) {
|
|
215
|
+
if (!Object.hasOwn(mapping, node) || visited.has(node) || !mapping[node] || typeof mapping[node] !== "object")
|
|
216
|
+
throw new OxygenError("geo_conversation_branch_invalid", "The selected conversation branch is incomplete or invalid.", { exitCode: 2 });
|
|
217
|
+
visited.add(node);
|
|
218
|
+
const entry = mapping[node];
|
|
219
|
+
branch.unshift(entry);
|
|
220
|
+
node = entry.parent ?? null;
|
|
221
|
+
}
|
|
222
|
+
for (const entry of branch) {
|
|
223
|
+
const role = entry.message?.author?.role;
|
|
224
|
+
if (role === "user" || role === "assistant")
|
|
225
|
+
messages.push({
|
|
226
|
+
role,
|
|
227
|
+
text: (entry.message?.content?.parts ?? [])
|
|
228
|
+
.filter((x) => typeof x === "string")
|
|
229
|
+
.join("\n"),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (!messages.length)
|
|
233
|
+
throw new OxygenError("geo_conversation_branch_invalid", "The selected conversation branch contains no readable messages.", { exitCode: 2 });
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
const rows = data.chat_messages ?? data.messages;
|
|
237
|
+
if (Array.isArray(rows))
|
|
238
|
+
for (const row of rows) {
|
|
239
|
+
if (!row || typeof row !== "object")
|
|
240
|
+
continue;
|
|
241
|
+
const role = row.sender === "human" || row.role === "user"
|
|
242
|
+
? "user"
|
|
243
|
+
: row.sender === "assistant" || row.role === "assistant"
|
|
244
|
+
? "assistant"
|
|
245
|
+
: null;
|
|
246
|
+
if (role)
|
|
247
|
+
messages.push({
|
|
248
|
+
role,
|
|
249
|
+
text: typeof row.text === "string"
|
|
250
|
+
? row.text
|
|
251
|
+
: typeof row.content === "string"
|
|
252
|
+
? row.content
|
|
253
|
+
: Array.isArray(row.content)
|
|
254
|
+
? row.content
|
|
255
|
+
.map((c) => c.text ?? "")
|
|
256
|
+
.join("\n")
|
|
257
|
+
: "",
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (!messages.length)
|
|
263
|
+
messages.push({ role: "user", text: input });
|
|
264
|
+
for (const message of messages)
|
|
265
|
+
message.text = redact(message.text);
|
|
266
|
+
// Retain only the selected conversation branch and reviewed text. Hidden JSON
|
|
267
|
+
// metadata, sibling chats, attachments and discarded branches are never saved.
|
|
268
|
+
return {
|
|
269
|
+
messages,
|
|
270
|
+
prompts: messages
|
|
271
|
+
.filter((m) => m.role === "user")
|
|
272
|
+
.map((m) => m.text)
|
|
273
|
+
.filter(Boolean),
|
|
274
|
+
sources: [
|
|
275
|
+
...new Set(messages.flatMap((m) => m.text.match(/https?:\/\/[^\s<>"\])]+/g) ?? [])),
|
|
276
|
+
],
|
|
277
|
+
redacted_text: messages.map((m) => `${m.role}: ${m.text}`).join("\n\n"),
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
/** Compare only common collection cohorts; imports and missing AIOs are excluded. */
|
|
281
|
+
export function compareGeoExperiment(before, after, brand) {
|
|
282
|
+
const eligible = (answers) => answers.filter((a) => a.collection === "consumer" &&
|
|
283
|
+
a.outcome === "answer" &&
|
|
284
|
+
a.measurement !== undefined);
|
|
285
|
+
const key = (a) => JSON.stringify([
|
|
286
|
+
a.prompt_id,
|
|
287
|
+
a.prompt_revision ?? null,
|
|
288
|
+
a.engine,
|
|
289
|
+
a.provider,
|
|
290
|
+
a.market?.country,
|
|
291
|
+
a.market?.language,
|
|
292
|
+
geoMeasurementKey(a),
|
|
293
|
+
]);
|
|
294
|
+
const baseline = eligible(before);
|
|
295
|
+
const followup = eligible(after);
|
|
296
|
+
const baselineKeys = new Set(baseline.map(key));
|
|
297
|
+
const followupKeys = new Set(followup.map(key));
|
|
298
|
+
const common = [...baselineKeys].filter((k) => followupKeys.has(k));
|
|
299
|
+
const matchedBefore = baseline.filter((a) => common.includes(key(a)));
|
|
300
|
+
const matchedAfter = followup.filter((a) => common.includes(key(a)));
|
|
301
|
+
return {
|
|
302
|
+
before: summarizeGeoAnswers(matchedBefore, brand),
|
|
303
|
+
after: summarizeGeoAnswers(matchedAfter, brand),
|
|
304
|
+
comparable_cohorts: common.length,
|
|
305
|
+
excluded_before: before.length - matchedBefore.length,
|
|
306
|
+
excluded_after: after.length - matchedAfter.length,
|
|
307
|
+
cohorts: common.map((k) => ({
|
|
308
|
+
cohort: JSON.parse(k),
|
|
309
|
+
before: summarizeGeoAnswers(baseline.filter((a) => key(a) === k), brand),
|
|
310
|
+
after: summarizeGeoAnswers(followup.filter((a) => key(a) === k), brand),
|
|
311
|
+
})),
|
|
312
|
+
interpretation: "Observed association within matching prompt revision, engine, provider, locale and capture-time brand definitions. Unknown definitions are excluded. Compare individual cohorts; changing their sample mix can change aggregate rates. This does not establish causation.",
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -20,6 +20,15 @@
|
|
|
20
20
|
*/
|
|
21
21
|
/** Canonical LinkedIn country name for each ISO 3166-1 alpha-2 code, A→Z by name. */
|
|
22
22
|
export declare const LINKEDIN_COUNTRIES: ReadonlyArray<readonly [code: string, name: string]>;
|
|
23
|
+
/**
|
|
24
|
+
* Spellings a provider may return that are not the canonical name. Lowercased
|
|
25
|
+
* keys; the resolver lowercases its input before the lookup. Kept deliberately
|
|
26
|
+
* short — it covers the CLDR spellings this table overrides (so a payload
|
|
27
|
+
* carrying the un-overridden name still resolves), the common colloquial forms,
|
|
28
|
+
* and the alpha-3 codes, because Up2Data's `hq.country` is not contractually
|
|
29
|
+
* alpha-2 and has been observed carrying a display name.
|
|
30
|
+
*/
|
|
31
|
+
export declare const COUNTRY_ALIASES: Readonly<Record<string, string>>;
|
|
23
32
|
/**
|
|
24
33
|
* Resolve a provider value — an alpha-2 code, a canonical name, or a known
|
|
25
34
|
* alias — to its canonical LinkedIn country name.
|
|
@@ -279,7 +279,7 @@ export const LINKEDIN_COUNTRIES = [
|
|
|
279
279
|
* and the alpha-3 codes, because Up2Data's `hq.country` is not contractually
|
|
280
280
|
* alpha-2 and has been observed carrying a display name.
|
|
281
281
|
*/
|
|
282
|
-
const COUNTRY_ALIASES = {
|
|
282
|
+
export const COUNTRY_ALIASES = {
|
|
283
283
|
// CLDR spellings this table overrides.
|
|
284
284
|
"hong kong sar china": "HK",
|
|
285
285
|
"macao sar china": "MO",
|