@oxygen-agent/cli 1.1011.974 → 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 +29 -0
- package/dist/crm-support-flags.d.ts +1 -0
- package/dist/crm-support-flags.js +5 -1
- package/dist/customer-operation-commands.js +30 -1
- package/dist/help.js +10 -2
- package/dist/http-client.d.ts +4 -0
- package/dist/http-client.js +21 -10
- package/dist/index.js +326 -111
- package/dist/sender-limits-flags.d.ts +7 -0
- package/dist/sender-limits-flags.js +13 -0
- 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 +30 -0
- package/node_modules/@oxygen/shared/dist/attempt-provenance.js +98 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +2 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +8 -1
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +172 -13
- package/node_modules/@oxygen/shared/dist/cli-result.js +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +19 -7
- package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.d.ts +7 -1
- package/node_modules/@oxygen/shared/dist/contracts/crm-attributes.js +5 -1
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.d.ts +14 -0
- package/node_modules/@oxygen/shared/dist/contracts/crm-support.js +4 -0
- package/node_modules/@oxygen/shared/dist/contracts/customer-operations.js +5 -5
- 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.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/publishing-reads.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.d.ts +16 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-row-removal.js +27 -0
- 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/knowledge-seed-content.js +2 -2
- 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/linkedin-quota-denial.d.ts +48 -0
- package/node_modules/@oxygen/shared/dist/linkedin-quota-denial.js +94 -0
- 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/provider-balance-signal.js +3 -2
- 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 +88 -0
- package/node_modules/@oxygen/shared/dist/whatsapp-sender-limits.js +118 -0
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.d.ts +15 -0
- package/node_modules/@oxygen/shared/dist/workflow-trigger-metadata.js +33 -0
- package/node_modules/@oxygen/shared/package.json +15 -5
- package/node_modules/@oxygen/workflows/dist/usage-estimate.js +9 -5
- package/package.json +1 -1
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.d.ts +0 -9
- package/node_modules/@oxygen/shared/dist/vercel-sandbox-fetch.js +0 -33
|
@@ -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
|
+
}
|
|
@@ -137,7 +137,7 @@ const ICP_BODY = `# ICP — ideal customer profile
|
|
|
137
137
|
`;
|
|
138
138
|
const POSITIONING_BODY = `# Positioning
|
|
139
139
|
|
|
140
|
-
> **Stub — fill me.** Non-canonical draft. Fill it, then
|
|
140
|
+
> **Stub — fill me.** Non-canonical draft. Fill it, then pin it (\`oxygen knowledge page pin positioning\`): the pin files a proposal a human approves, because canonical positioning grounds live copy. See [[conventions]].
|
|
141
141
|
|
|
142
142
|
## For whom
|
|
143
143
|
- Our [[icp]], and the moment they buy.
|
|
@@ -251,7 +251,7 @@ Banned hedges: just, hope this finds you well, wanted to reach out, circling bac
|
|
|
251
251
|
- **We would not send:** the same idea, wrong — and why it fails
|
|
252
252
|
|
|
253
253
|
## Canonical
|
|
254
|
-
Not pinned. Pinning it (\`oxygen knowledge page pin voice
|
|
254
|
+
Not pinned. Pinning it (\`oxygen knowledge page pin voice\`) files a proposal a human approves — canonical voice grounds live copy. Claims are [[brand]]; what we do is [[positioning]].
|
|
255
255
|
`;
|
|
256
256
|
const BRAND_BODY = `# Brand
|
|
257
257
|
|
|
@@ -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",
|
|
@@ -31,3 +31,51 @@ export declare function linkedInQuotaBackoffUntil(error: unknown, now: Date): Da
|
|
|
31
31
|
* `sender_accounts.metadata` jsonb — expires; 0 when it is not parked.
|
|
32
32
|
*/
|
|
33
33
|
export declare function linkedInQuotaParkedUntil(metadata: unknown, key: string): number;
|
|
34
|
+
/**
|
|
35
|
+
* Where a sender's inbox sync is parked until, quota or failure backoff alike,
|
|
36
|
+
* in the freeform `sender_accounts.metadata` jsonb.
|
|
37
|
+
*/
|
|
38
|
+
export declare const INBOX_SYNC_BACKOFF_KEY = "inbox_sync_backoff_until";
|
|
39
|
+
/** The sender metadata key holding the latest non-quota inbox sync failure. */
|
|
40
|
+
export declare const INBOX_SYNC_FAILURE_KEY = "inbox_sync_failure";
|
|
41
|
+
export type InboxSyncFailureClass = "quota" | "authentication" | "transient" | "other";
|
|
42
|
+
export type InboxSyncFailureRecord = {
|
|
43
|
+
class: Exclude<InboxSyncFailureClass, "quota">;
|
|
44
|
+
code: string | null;
|
|
45
|
+
status: number | null;
|
|
46
|
+
/** Consecutive failed syncs, reset by a successful one. */
|
|
47
|
+
consecutive: number;
|
|
48
|
+
first_at: string;
|
|
49
|
+
last_at: string;
|
|
50
|
+
retry_at: string;
|
|
51
|
+
};
|
|
52
|
+
/** First park after a non-quota failure; it doubles per consecutive failure. */
|
|
53
|
+
export declare const INBOX_SYNC_FAILURE_BASE_BACKOFF_MS: number;
|
|
54
|
+
/** No non-quota failure parks a sender longer than this. */
|
|
55
|
+
export declare const INBOX_SYNC_FAILURE_MAX_BACKOFF_MS: number;
|
|
56
|
+
/** Quota, authentication (401/403), transient (capacity, 429, 5xx, retryable), else other. */
|
|
57
|
+
export declare function classifyInboxSyncFailure(error: unknown): InboxSyncFailureClass;
|
|
58
|
+
/** The latest non-quota inbox sync failure recorded on a sender, or null. */
|
|
59
|
+
export declare function readInboxSyncFailure(metadata: unknown): InboxSyncFailureRecord | null;
|
|
60
|
+
/**
|
|
61
|
+
* The park and record for a whole-sender inbox sync failure that is not a quota
|
|
62
|
+
* denial: 5 min, doubling per consecutive failure, capped at 2 h. Null for a
|
|
63
|
+
* quota denial, which keeps its own park (linkedInQuotaBackoffUntil).
|
|
64
|
+
*/
|
|
65
|
+
export declare function inboxSyncFailureBackoff(error: unknown, metadata: unknown, now: Date): {
|
|
66
|
+
until: Date;
|
|
67
|
+
record: InboxSyncFailureRecord;
|
|
68
|
+
} | null;
|
|
69
|
+
/**
|
|
70
|
+
* A sender's whole-inbox sync state for health reads: the last successful sync,
|
|
71
|
+
* the latest failure that was not a quota denial (cleared by the next success),
|
|
72
|
+
* and until when the background sync leaves the sender alone.
|
|
73
|
+
*/
|
|
74
|
+
export declare function inboxSyncHealth(sender: {
|
|
75
|
+
lastInboxSyncAt: Date | null;
|
|
76
|
+
metadata: unknown;
|
|
77
|
+
}, now?: Date): {
|
|
78
|
+
last_synced_at: string | null;
|
|
79
|
+
background_sync_paused_until: string | null;
|
|
80
|
+
last_failure: InboxSyncFailureRecord | null;
|
|
81
|
+
};
|