@oxygen-agent/cli 1.1012.2 → 1.1013.15
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/customer-operation-commands.js +6 -2
- package/dist/diagnostic-upload-main.d.ts +1 -0
- package/dist/diagnostic-upload-main.js +2 -0
- package/dist/diagnostic-uploader.d.ts +3 -0
- package/dist/diagnostic-uploader.js +68 -0
- package/dist/diagnostics.d.ts +22 -0
- package/dist/diagnostics.js +219 -0
- package/dist/http-client.d.ts +7 -0
- package/dist/http-client.js +12 -2
- package/dist/index.js +15 -3
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +4 -4
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -1
- package/node_modules/@oxygen/shared/dist/contracts/geo.js +30 -8
- package/node_modules/@oxygen/shared/dist/contracts/operations.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-context.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-context.js +17 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.d.ts +4 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-credentials.js +12 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-lock.js +60 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-receipt.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-receipt.js +22 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage-lock.js +39 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/diagnostic-storage.js +233 -0
- package/node_modules/@oxygen/shared/dist/diagnostics.d.ts +49 -0
- package/node_modules/@oxygen/shared/dist/diagnostics.js +253 -0
- package/node_modules/@oxygen/shared/dist/geo.d.ts +17 -0
- package/node_modules/@oxygen/shared/dist/geo.js +31 -3
- package/node_modules/@oxygen/shared/dist/onboarding-first-leads.d.ts +19 -0
- package/node_modules/@oxygen/shared/dist/onboarding-first-leads.js +25 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +4 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -0
- 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 +3 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -116,7 +116,11 @@ export function registerCustomerOperationCommands(program, handle) {
|
|
|
116
116
|
if (spec.binary) {
|
|
117
117
|
if (spec.binary.stream)
|
|
118
118
|
return requestOxygen(request.path, { method: request.method, binaryOutputPath: resolve(String(options.output)) });
|
|
119
|
-
const
|
|
119
|
+
const download = await requestOxygen(request.path, { method: request.method, binaryMaxBytes: spec.binary.maxBytes, ...(spec.binary.paginated ? { binaryMetadata: true } : {}) });
|
|
120
|
+
const bytes = download instanceof Uint8Array ? download : download.bytes;
|
|
121
|
+
const pagination = download instanceof Uint8Array ? undefined : download.pagination;
|
|
122
|
+
if (spec.binary.paginated && !pagination)
|
|
123
|
+
throw new OxygenError("invalid_response", "The export did not include its evidence pagination. Retry the same read.");
|
|
120
124
|
const output = resolve(String(options.output));
|
|
121
125
|
try {
|
|
122
126
|
await writeFile(output, bytes, { flag: "wx" });
|
|
@@ -127,7 +131,7 @@ export function registerCustomerOperationCommands(program, handle) {
|
|
|
127
131
|
}
|
|
128
132
|
throw error;
|
|
129
133
|
}
|
|
130
|
-
return { path: output, bytes: bytes.byteLength };
|
|
134
|
+
return { path: output, bytes: bytes.byteLength, ...(pagination ? { pagination } : {}) };
|
|
131
135
|
}
|
|
132
136
|
if (spec.multipart) {
|
|
133
137
|
let formData;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, readdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { acquireDiagnosticLock } from "@oxygen/shared/diagnostic-lock";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { gunzipSync, gzipSync } from "node:zlib";
|
|
6
|
+
import { DIAGNOSTIC_RECORD_MAX_BYTES, DIAGNOSTIC_UPLOAD_CHUNK_BYTES, parseDiagnosticRecord } from "@oxygen/shared/diagnostics";
|
|
7
|
+
import { loadCredentialsWithEndpoint } from "./credentials.js";
|
|
8
|
+
import { cliDiagnosticDirectory, credentialFingerprint } from "./diagnostics.js";
|
|
9
|
+
import { requestOxygen } from "./http-client.js";
|
|
10
|
+
/** Dedicated detached process: credential values never enter argv or the spool. */
|
|
11
|
+
export async function uploadCliDiagnostics(directory = cliDiagnosticDirectory(), credentials) {
|
|
12
|
+
if (process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "1")
|
|
13
|
+
return 0;
|
|
14
|
+
const loaded = credentials ?? (await loadCredentialsWithEndpoint()).credentials;
|
|
15
|
+
if (!loaded)
|
|
16
|
+
return 0;
|
|
17
|
+
const lockPath = join(directory, ".upload.lock");
|
|
18
|
+
const release = await acquireDiagnosticLock(lockPath);
|
|
19
|
+
if (!release)
|
|
20
|
+
return 0;
|
|
21
|
+
let uploaded = 0;
|
|
22
|
+
try {
|
|
23
|
+
for (const name of (await readdir(directory)).filter((name) => /^[0-9a-f-]+\.json\.gz$/.test(name)).slice(0, 20)) {
|
|
24
|
+
const path = join(directory, name);
|
|
25
|
+
try {
|
|
26
|
+
const entry = JSON.parse(gunzipSync(await readFile(path), { maxOutputLength: DIAGNOSTIC_RECORD_MAX_BYTES }).toString("utf8"));
|
|
27
|
+
parseDiagnosticRecord(entry.record);
|
|
28
|
+
if (Date.now() - Date.parse(entry.record.started_at) >= 72 * 60 * 60 * 1000)
|
|
29
|
+
continue;
|
|
30
|
+
// Never move an old invocation to a different endpoint, user or workspace.
|
|
31
|
+
if (new URL(entry.scope.api_url).origin !== new URL(loaded.apiUrl).origin
|
|
32
|
+
|| (entry.scope.fingerprint && entry.scope.fingerprint !== credentialFingerprint(loaded)))
|
|
33
|
+
continue;
|
|
34
|
+
const bytes = gzipSync(Buffer.from(JSON.stringify(entry)));
|
|
35
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
36
|
+
const total = Math.ceil(bytes.length / DIAGNOSTIC_UPLOAD_CHUNK_BYTES);
|
|
37
|
+
const selectedOrganization = entry.scope.organization ?? undefined;
|
|
38
|
+
const progressPath = path + ".upload.json";
|
|
39
|
+
const progress = JSON.parse(await readFile(progressPath, "utf8").catch(() => "null"));
|
|
40
|
+
// Staging expires after one day. Restart from zero after that boundary.
|
|
41
|
+
const next = progress?.sha256 === sha256 && Date.now() - progress.updated_at < 23 * 3_600_000
|
|
42
|
+
&& Number.isInteger(progress.next) && progress.next >= 0 && progress.next <= total ? progress.next : 0;
|
|
43
|
+
for (let index = next; index < total; index++) {
|
|
44
|
+
await requestOxygen("/api/cli/observability/diagnostics", { method: "POST", credentials: loaded,
|
|
45
|
+
...(selectedOrganization ? { selectedOrganization } : {}), timeoutMs: 15_000,
|
|
46
|
+
body: { id: entry.record.id, index, total, sha256,
|
|
47
|
+
chunk: bytes.subarray(index * DIAGNOSTIC_UPLOAD_CHUNK_BYTES, (index + 1) * DIAGNOSTIC_UPLOAD_CHUNK_BYTES).toString("base64") },
|
|
48
|
+
});
|
|
49
|
+
const temporary = progressPath + ".tmp";
|
|
50
|
+
await writeFile(temporary, JSON.stringify({ sha256, next: index + 1, updated_at: Date.now() }), { mode: 0o600 });
|
|
51
|
+
await rename(temporary, progressPath);
|
|
52
|
+
}
|
|
53
|
+
const receipt = await requestOxygen("/api/cli/observability/diagnostics", { method: "POST", credentials: loaded,
|
|
54
|
+
...(selectedOrganization ? { selectedOrganization } : {}), timeoutMs: 15_000, body: { id: entry.record.id, total, sha256, complete: true } });
|
|
55
|
+
if (receipt.accepted !== true || receipt.complete !== true || receipt.diagnostic_id !== entry.record.id)
|
|
56
|
+
continue;
|
|
57
|
+
await unlink(path);
|
|
58
|
+
await unlink(progressPath).catch(() => undefined);
|
|
59
|
+
uploaded++;
|
|
60
|
+
}
|
|
61
|
+
catch { /* leave the original for a later invocation; never replay product actions */ }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await release();
|
|
66
|
+
}
|
|
67
|
+
return uploaded;
|
|
68
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type DiagnosticRecord } from "@oxygen/shared/diagnostics";
|
|
2
|
+
import { type StoredCredentials } from "./credentials.js";
|
|
3
|
+
export type CliDiagnosticScope = {
|
|
4
|
+
api_url: string;
|
|
5
|
+
fingerprint: string | null;
|
|
6
|
+
organization: string | null;
|
|
7
|
+
user: string | null;
|
|
8
|
+
};
|
|
9
|
+
export type CliDiagnosticSpoolEntry = {
|
|
10
|
+
scope: CliDiagnosticScope;
|
|
11
|
+
record: DiagnosticRecord;
|
|
12
|
+
};
|
|
13
|
+
export declare function cliDiagnosticDirectory(): string;
|
|
14
|
+
export declare function credentialFingerprint(credentials: StoredCredentials): string;
|
|
15
|
+
export declare function beginCliDiagnostics(argv?: string[]): void;
|
|
16
|
+
export declare function setCliDiagnosticOperation(command: string): void;
|
|
17
|
+
export declare function cliDiagnosticInvocation(): string | undefined;
|
|
18
|
+
export declare function cliDiagnosticHeaders(): Record<string, string>;
|
|
19
|
+
export declare function recordCliDiagnosticRequest(input: unknown): void;
|
|
20
|
+
export declare function recordCliDiagnosticFailure(error: unknown): void;
|
|
21
|
+
export declare function finishCliDiagnostics(exitCode: number): Promise<string | null>;
|
|
22
|
+
export declare function writeCliDiagnosticSpool(directory: string, entry: CliDiagnosticSpoolEntry): Promise<string | null>;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdir, open, readdir, readFile, rename, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { acquireDiagnosticLock } from "@oxygen/shared/diagnostic-lock";
|
|
8
|
+
import { StringDecoder } from "node:string_decoder";
|
|
9
|
+
import { gzipSync } from "node:zlib";
|
|
10
|
+
import { createDiagnosticRecord, credentialSafeDiagnostic, diagnosticId, DIAGNOSTIC_INVOCATION_HEADER, DIAGNOSTIC_MAX_BYTES, DIAGNOSTIC_RECORD_MAX_BYTES, diagnosticOutcome, sanitizeDiagnosticArgv, sanitizeDiagnosticContent } from "@oxygen/shared/diagnostics";
|
|
11
|
+
import { OXYGEN_VERSION } from "@oxygen/shared/version";
|
|
12
|
+
import { loadCredentialsWithEndpoint } from "./credentials.js";
|
|
13
|
+
let active;
|
|
14
|
+
export function cliDiagnosticDirectory() {
|
|
15
|
+
return join(process.env.OXYGEN_CONFIG_DIR?.trim() || (process.platform === "win32"
|
|
16
|
+
? join(process.env.APPDATA || join(homedir(), "AppData", "Roaming"), "oxygen")
|
|
17
|
+
: join(homedir(), ".config", "oxygen")), "diagnostics", "v1");
|
|
18
|
+
}
|
|
19
|
+
export function credentialFingerprint(credentials) {
|
|
20
|
+
return createHash("sha256").update(credentials.token).digest("hex");
|
|
21
|
+
}
|
|
22
|
+
export function beginCliDiagnostics(argv = process.argv.slice(2)) {
|
|
23
|
+
if (active || process.env.OXYGEN_DIAGNOSTICS_ENABLED !== "1")
|
|
24
|
+
return;
|
|
25
|
+
const invocation = createDiagnosticRecord({ surface: "cli", operation: "cli parse",
|
|
26
|
+
client_version: OXYGEN_VERSION, input: { argv: sanitizeDiagnosticArgv(argv) },
|
|
27
|
+
trace_id: randomUUID().replaceAll("-", ""),
|
|
28
|
+
});
|
|
29
|
+
invocation.trace_id = invocation.invocation_id.replaceAll("-", "");
|
|
30
|
+
invocation.traceparent = `00-${invocation.trace_id}-${randomUUID().replaceAll("-", "").slice(0, 16)}-01`;
|
|
31
|
+
const credentialEnv = diagnosticCredentialEnvironment(argv);
|
|
32
|
+
const context = { record: invocation, credentialEnv, scope: loadCredentialsWithEndpoint(credentialEnv).catch(() => null),
|
|
33
|
+
organizationRef: credentialEnv.OXYGEN_ORG?.trim() || null, signalHandlers: [],
|
|
34
|
+
requests: [], bytes: 0, droppedBytes: 0, stdout: [], stderr: [], restore: () => undefined };
|
|
35
|
+
active = context;
|
|
36
|
+
const restorers = [];
|
|
37
|
+
for (const [stream, output] of [[process.stdout, context.stdout], [process.stderr, context.stderr]]) {
|
|
38
|
+
const original = stream.write;
|
|
39
|
+
const decoder = new StringDecoder("utf8");
|
|
40
|
+
const wrapped = function (...args) {
|
|
41
|
+
try {
|
|
42
|
+
const chunk = args[0];
|
|
43
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk);
|
|
44
|
+
if (context.bytes + bytes.length <= DIAGNOSTIC_RECORD_MAX_BYTES) {
|
|
45
|
+
context.bytes += bytes.length;
|
|
46
|
+
output.push(decoder.write(bytes));
|
|
47
|
+
}
|
|
48
|
+
else
|
|
49
|
+
context.droppedBytes += bytes.length;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
context.droppedBytes++;
|
|
53
|
+
}
|
|
54
|
+
return original.apply(stream, args);
|
|
55
|
+
};
|
|
56
|
+
stream.write = wrapped;
|
|
57
|
+
restorers.push(() => { output.push(decoder.end()); stream.write = original; });
|
|
58
|
+
}
|
|
59
|
+
context.restore = () => { for (const restore of restorers)
|
|
60
|
+
restore(); };
|
|
61
|
+
for (const [signal, code] of [["SIGINT", 130], ["SIGTERM", 143]]) {
|
|
62
|
+
const handler = () => {
|
|
63
|
+
recordCliDiagnosticFailure({ code: "interrupted" });
|
|
64
|
+
void finishCliDiagnostics(code).finally(() => process.exit(code));
|
|
65
|
+
};
|
|
66
|
+
process.once(signal, handler);
|
|
67
|
+
context.signalHandlers.push(() => process.removeListener(signal, handler));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function setCliDiagnosticOperation(command) {
|
|
71
|
+
if (active)
|
|
72
|
+
active.record.operation = command.slice(0, 512);
|
|
73
|
+
}
|
|
74
|
+
export function cliDiagnosticInvocation() { return active?.record.invocation_id; }
|
|
75
|
+
export function cliDiagnosticHeaders() {
|
|
76
|
+
return active ? { [DIAGNOSTIC_INVOCATION_HEADER]: active.record.invocation_id, traceparent: active.record.traceparent } : {};
|
|
77
|
+
}
|
|
78
|
+
export function recordCliDiagnosticRequest(input) {
|
|
79
|
+
if (!active)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
const sanitized = sanitizeDiagnosticContent(input);
|
|
83
|
+
const size = Buffer.byteLength(JSON.stringify(sanitized));
|
|
84
|
+
if (active.bytes + size <= DIAGNOSTIC_RECORD_MAX_BYTES) {
|
|
85
|
+
active.requests.push(sanitized);
|
|
86
|
+
active.bytes += size;
|
|
87
|
+
}
|
|
88
|
+
else
|
|
89
|
+
active.droppedBytes += size;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
active.droppedBytes++;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function recordCliDiagnosticFailure(error) {
|
|
96
|
+
if (!active)
|
|
97
|
+
return;
|
|
98
|
+
const code = error && typeof error === "object" && "code" in error ? String(error.code) : "unexpected_error";
|
|
99
|
+
active.record.error_code = code;
|
|
100
|
+
active.record.outcome = diagnosticOutcome(false, null, code);
|
|
101
|
+
recordCliDiagnosticRequest({ error });
|
|
102
|
+
}
|
|
103
|
+
export async function finishCliDiagnostics(exitCode) {
|
|
104
|
+
const context = active;
|
|
105
|
+
if (!context)
|
|
106
|
+
return null;
|
|
107
|
+
context.restore();
|
|
108
|
+
for (const remove of context.signalHandlers)
|
|
109
|
+
remove();
|
|
110
|
+
active = undefined;
|
|
111
|
+
try {
|
|
112
|
+
const loaded = await context.scope;
|
|
113
|
+
const credentials = loaded?.credentials;
|
|
114
|
+
const storedOrganization = credentials?.activeOrganization ?? credentials?.identity?.organization;
|
|
115
|
+
const organization = !context.organizationRef ? storedOrganization?.id ?? null
|
|
116
|
+
: context.organizationRef === storedOrganization?.id || context.organizationRef === storedOrganization?.slug
|
|
117
|
+
? storedOrganization.id : diagnosticId(context.organizationRef);
|
|
118
|
+
const user = credentials?.identity?.user.id ?? null;
|
|
119
|
+
const record = context.record;
|
|
120
|
+
record.ended_at = new Date().toISOString();
|
|
121
|
+
if (record.outcome === "pending")
|
|
122
|
+
record.outcome = exitCode === 0 ? "ok" : "invalid_input";
|
|
123
|
+
record.status = exitCode;
|
|
124
|
+
record.organization_id = organization;
|
|
125
|
+
record.user_id = user;
|
|
126
|
+
record.capture_state = context.droppedBytes ? "partial" : "complete";
|
|
127
|
+
record.output = { stdout: context.stdout.join(""), stderr: context.stderr.join("") };
|
|
128
|
+
record.metadata = { requests: context.requests, dropped_bytes: context.droppedBytes, client_reported: true,
|
|
129
|
+
original_organization_ref: context.organizationRef };
|
|
130
|
+
const entry = { scope: { api_url: loaded?.endpoint.apiUrl ?? process.env.OXYGEN_API_URL?.trim() ?? "https://oxygen-agent.com",
|
|
131
|
+
fingerprint: credentials ? credentialFingerprint(credentials) : null, organization: organization ?? context.organizationRef, user },
|
|
132
|
+
record: sanitizeDiagnosticContent(credentialSafeDiagnostic(record), [credentials?.token ?? ""]) };
|
|
133
|
+
const directory = cliDiagnosticDirectory();
|
|
134
|
+
const path = await writeCliDiagnosticSpool(directory, entry);
|
|
135
|
+
if (path && process.env.OXYGEN_DIAGNOSTICS_UPLOAD_DISABLED !== "1") {
|
|
136
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL("./diagnostic-upload-main.js", import.meta.url))], { detached: true, stdio: "ignore", env: context.credentialEnv });
|
|
137
|
+
child.on("error", () => undefined);
|
|
138
|
+
child.unref();
|
|
139
|
+
}
|
|
140
|
+
return path;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export async function writeCliDiagnosticSpool(directory, entry) {
|
|
147
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
148
|
+
const lockPath = join(directory, ".admission.lock");
|
|
149
|
+
const release = await acquireDiagnosticLock(lockPath);
|
|
150
|
+
if (!release)
|
|
151
|
+
return null;
|
|
152
|
+
try {
|
|
153
|
+
let used = 0;
|
|
154
|
+
let dropped = 0;
|
|
155
|
+
for (const name of await readdir(directory)) {
|
|
156
|
+
if (!/^[0-9a-f-]+\.json\.gz$/.test(name) && !/^[0-9a-f-]+\.json\.gz\.[0-9a-f-]+\.tmp$/.test(name))
|
|
157
|
+
continue;
|
|
158
|
+
const path = join(directory, name);
|
|
159
|
+
const info = await stat(path).catch(() => null);
|
|
160
|
+
if (!info)
|
|
161
|
+
continue;
|
|
162
|
+
if (Date.now() - info.mtimeMs >= 72 * 60 * 60 * 1000) {
|
|
163
|
+
await unlink(path);
|
|
164
|
+
dropped += info.size;
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
used += info.size;
|
|
168
|
+
}
|
|
169
|
+
const lossPath = join(directory, "loss.json");
|
|
170
|
+
const previous = JSON.parse(await readFile(lossPath, "utf8").catch(() => "{\"bytes\":0}"));
|
|
171
|
+
let bytes = Buffer.from(JSON.stringify({ ...entry, record: sanitizeDiagnosticContent({ ...entry.record,
|
|
172
|
+
metadata: { ...entry.record.metadata, prior_spool_lost_bytes: previous.bytes + dropped } }) }));
|
|
173
|
+
if (bytes.length > DIAGNOSTIC_RECORD_MAX_BYTES) {
|
|
174
|
+
bytes = Buffer.from(JSON.stringify({ ...entry, record: { ...entry.record, input: null, output: null,
|
|
175
|
+
capture_state: "partial", metadata: { capture_reason: "oversized_content", dropped_bytes: bytes.length } } }));
|
|
176
|
+
}
|
|
177
|
+
const compressed = gzipSync(bytes);
|
|
178
|
+
if (compressed.length + used > DIAGNOSTIC_MAX_BYTES)
|
|
179
|
+
dropped += compressed.length;
|
|
180
|
+
if (dropped) {
|
|
181
|
+
const loss = await open(lossPath, "w", 0o600);
|
|
182
|
+
try {
|
|
183
|
+
await loss.writeFile(JSON.stringify({ bytes: previous.bytes + dropped }));
|
|
184
|
+
await loss.sync();
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
await loss.close();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (compressed.length + used > DIAGNOSTIC_MAX_BYTES)
|
|
191
|
+
return null;
|
|
192
|
+
const path = join(directory, `${entry.record.id}.json.gz`);
|
|
193
|
+
const temporary = path + `.${randomUUID()}.tmp`;
|
|
194
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
195
|
+
try {
|
|
196
|
+
await handle.writeFile(compressed);
|
|
197
|
+
await handle.sync();
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
await handle.close();
|
|
201
|
+
}
|
|
202
|
+
await rename(temporary, path);
|
|
203
|
+
return path;
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await release();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
function diagnosticCredentialEnvironment(argv) {
|
|
210
|
+
const env = { ...process.env };
|
|
211
|
+
for (const [flag, name] of [["--profile", "OXYGEN_PROFILE"], ["--org", "OXYGEN_ORG"]]) {
|
|
212
|
+
const at = argv.indexOf(flag);
|
|
213
|
+
const equals = argv.find(value => value.startsWith(flag + "="));
|
|
214
|
+
const value = equals?.slice(flag.length + 1) ?? (at >= 0 ? argv[at + 1] : undefined);
|
|
215
|
+
if (value && !value.startsWith("-"))
|
|
216
|
+
env[name] = value;
|
|
217
|
+
}
|
|
218
|
+
return env;
|
|
219
|
+
}
|
package/dist/http-client.d.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { type GeoExportPagination } from "@oxygen/shared/geo";
|
|
1
2
|
import { type StoredCredentials } from "./credentials.js";
|
|
2
3
|
import { type CliEndpoint } from "./runtime.js";
|
|
3
4
|
type RequestOptions = {
|
|
4
5
|
/** Internal bounded file download; never follows a caller-supplied URL. */
|
|
5
6
|
binaryMaxBytes?: number;
|
|
7
|
+
/** Return pagination beside bytes for operations advertising paginated downloads. */
|
|
8
|
+
binaryMetadata?: boolean;
|
|
6
9
|
/** Stream a native authenticated download to an exclusively created file. */
|
|
7
10
|
binaryOutputPath?: string;
|
|
8
11
|
method?: "GET" | "POST" | "PATCH" | "DELETE";
|
|
@@ -25,6 +28,10 @@ type RequestOptions = {
|
|
|
25
28
|
};
|
|
26
29
|
export declare function requestOxygen<T>(// skipcq: JS-R1005
|
|
27
30
|
path: string, options?: RequestOptions): Promise<T>;
|
|
31
|
+
export type PaginatedFileDownload = {
|
|
32
|
+
bytes: Uint8Array;
|
|
33
|
+
pagination?: GeoExportPagination;
|
|
34
|
+
};
|
|
28
35
|
export declare function ensureFreshCliForApiUrl(apiUrl: string, options?: {
|
|
29
36
|
fetch?: typeof fetch;
|
|
30
37
|
endpoint?: CliEndpoint;
|
package/dist/http-client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { readGeoExportPagination } from "@oxygen/shared/geo";
|
|
1
2
|
import { OXYGEN_VERSION, OxygenError, isCliResult, isVersionGreater, parseSemver, readEnvelopeCompatibility, vercelProtectionBypassHeaders, withRetryAfterDetails, } from "@oxygen/shared";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { cliDiagnosticHeaders, cliDiagnosticInvocation, recordCliDiagnosticRequest } from "./diagnostics.js";
|
|
3
5
|
import { open, unlink } from "node:fs/promises";
|
|
4
6
|
import { recordMutatingRequestAnswered, recoverFromCliFloorRejection } from "./auto-update.js";
|
|
5
7
|
import { defaultApiUrl, loadCredentialsWithEndpoint } from "./credentials.js";
|
|
@@ -49,8 +51,9 @@ path, options = {}) {
|
|
|
49
51
|
}
|
|
50
52
|
}
|
|
51
53
|
options.signal?.throwIfAborted();
|
|
52
|
-
const traceId = options.traceId ?? randomUUID();
|
|
54
|
+
const traceId = options.traceId ?? cliDiagnosticInvocation() ?? randomUUID();
|
|
53
55
|
const headers = {
|
|
56
|
+
...cliDiagnosticHeaders(),
|
|
54
57
|
Accept: "application/json",
|
|
55
58
|
"X-Oxygen-Trace-Id": traceId,
|
|
56
59
|
// Advertise the CLI version so the server can enforce its minimum-CLI floor
|
|
@@ -104,15 +107,19 @@ path, options = {}) {
|
|
|
104
107
|
else if (timeout.signal)
|
|
105
108
|
requestInit.signal = timeout.signal;
|
|
106
109
|
try {
|
|
110
|
+
recordCliDiagnosticRequest({ event: "request", trace_id: traceId, api_url: apiUrl, path, method,
|
|
111
|
+
input: options.body ?? (options.formData ? { withheld: "multipart_binary" } : null) });
|
|
107
112
|
response = await fetchImpl(`${apiUrl}${path}`, requestInit);
|
|
108
113
|
if (response.ok && options.binaryOutputPath !== undefined) {
|
|
109
114
|
return await streamBinaryResponse(response, options.binaryOutputPath);
|
|
110
115
|
}
|
|
111
116
|
if (response.ok && options.binaryMaxBytes !== undefined) {
|
|
112
|
-
|
|
117
|
+
const bytes = await readBoundedBinaryResponse(response, options.binaryMaxBytes);
|
|
118
|
+
return (options.binaryMetadata ? { bytes, pagination: readGeoExportPagination(response.headers) } : bytes);
|
|
113
119
|
}
|
|
114
120
|
}
|
|
115
121
|
catch (error) {
|
|
122
|
+
recordCliDiagnosticRequest({ event: "transport_failure", trace_id: traceId, error });
|
|
116
123
|
if (error instanceof OxygenError)
|
|
117
124
|
throw error;
|
|
118
125
|
if (timeout.timedOut() || (timeoutMs !== undefined && isAbortError(error))) {
|
|
@@ -163,6 +170,7 @@ path, options = {}) {
|
|
|
163
170
|
});
|
|
164
171
|
});
|
|
165
172
|
const compatibility = readEnvelopeCompatibility(envelope);
|
|
173
|
+
recordCliDiagnosticRequest({ event: "response", trace_id: traceId, status: response.status, output: envelope });
|
|
166
174
|
const failed = !response.ok || !envelope.ok;
|
|
167
175
|
try {
|
|
168
176
|
assertCliMeetsMinimumApiVersion(compatibility, options, endpoint);
|
|
@@ -452,6 +460,7 @@ async function readEnvelope(response) {
|
|
|
452
460
|
parsed = text ? JSON.parse(text) : null;
|
|
453
461
|
}
|
|
454
462
|
catch {
|
|
463
|
+
recordCliDiagnosticRequest({ event: "invalid_response", status: response.status, output: text });
|
|
455
464
|
throw new OxygenError("invalid_response", "Oxygen API returned a non-JSON response.", {
|
|
456
465
|
details: {
|
|
457
466
|
status: response.status,
|
|
@@ -462,6 +471,7 @@ async function readEnvelope(response) {
|
|
|
462
471
|
}
|
|
463
472
|
if (isCliResult(parsed))
|
|
464
473
|
return parsed;
|
|
474
|
+
recordCliDiagnosticRequest({ event: "invalid_response", status: response.status, output: parsed });
|
|
465
475
|
throw new OxygenError("invalid_response", "Oxygen API returned an invalid response envelope.", {
|
|
466
476
|
details: { status: response.status },
|
|
467
477
|
exitCode: 1,
|
package/dist/index.js
CHANGED
|
@@ -13,6 +13,7 @@ import { registerCrmFileReadCommands } from "./crm-file-read-commands.js";
|
|
|
13
13
|
import { registerCrmSupportFlags, readCrmNoteEditFields, readCrmPhotoFields, readCrmFileLabel, buildCrmObjectsPath, buildCrmConnectionsPath, buildCrmRecordPath } from "./crm-support-flags.js";
|
|
14
14
|
import { registerCustomerOperationCommands } from "./customer-operation-commands.js";
|
|
15
15
|
import { DATABASE_QUERY_FLAG_OVERLAY } from "./database-query-flags.js";
|
|
16
|
+
import { beginCliDiagnostics, finishCliDiagnostics, setCliDiagnosticOperation, recordCliDiagnosticFailure } from "./diagnostics.js";
|
|
16
17
|
import { addFeedReadFlags, feedReadWindowParams } from "./feed-read-flags.js";
|
|
17
18
|
import { registerFunctionsCommands } from "./functions-commands.js";
|
|
18
19
|
import { applyOxygenHelp, argvRequestsJson, commanderUsageFailure, commandNameForArgv, enableCommandSuggestions, unknownCommandHint, unknownOptionHint, withoutHelpForUnknownCommand, validateRequiredOptionValues, } from "./help.js";
|
|
@@ -961,6 +962,7 @@ function writeManagedProviderAvailabilityNotice(data) {
|
|
|
961
962
|
// process exit code from the error. Command handlers that don't route through
|
|
962
963
|
// handleAsyncAction catch straight into this so the shape can't drift.
|
|
963
964
|
function emitCliFailure(command, error) {
|
|
965
|
+
recordCliDiagnosticFailure(error);
|
|
964
966
|
writeJson(toFailure(command, error));
|
|
965
967
|
writeMaxCreditsHint(error);
|
|
966
968
|
process.exitCode = error instanceof OxygenError ? exitCodeForOxygenError(error) : 1;
|
|
@@ -30337,20 +30339,27 @@ function muteTokenEcho() {
|
|
|
30337
30339
|
// head -c 20`) otherwise crashes with a raw EPIPE stack after the consumer
|
|
30338
30340
|
// closes the pipe; a closed pipe is normal shell behavior, not an error.
|
|
30339
30341
|
process.stdout.on("error", (error) => {
|
|
30340
|
-
if (error.code === "EPIPE")
|
|
30341
|
-
process.exit(0);
|
|
30342
|
+
if (error.code === "EPIPE") {
|
|
30343
|
+
void finishCliDiagnostics(0).finally(() => process.exit(0));
|
|
30344
|
+
return;
|
|
30345
|
+
}
|
|
30342
30346
|
process.stderr.write(`stdout error: ${error.message}\n`);
|
|
30343
|
-
|
|
30347
|
+
recordCliDiagnosticFailure(error);
|
|
30348
|
+
void finishCliDiagnostics(1).finally(() => process.exit(1));
|
|
30344
30349
|
});
|
|
30350
|
+
beginCliDiagnostics();
|
|
30345
30351
|
maybeScheduleBackgroundUpdate();
|
|
30346
30352
|
const program = createProgram();
|
|
30347
30353
|
installCommanderExitOverride(program);
|
|
30348
30354
|
const argv = withoutHelpForUnknownCommand(program, process.argv);
|
|
30355
|
+
setCliDiagnosticOperation(commandNameForArgv(program, argv.slice(2)));
|
|
30349
30356
|
try {
|
|
30350
30357
|
await program.parseAsync(argv);
|
|
30351
30358
|
}
|
|
30352
30359
|
catch (error) {
|
|
30353
30360
|
if (error instanceof CommanderError) {
|
|
30361
|
+
if (error.exitCode !== 0)
|
|
30362
|
+
recordCliDiagnosticFailure(error);
|
|
30354
30363
|
const cliArgs = argv.slice(2);
|
|
30355
30364
|
// Every hint goes to stderr as it always has; the first, the most specific,
|
|
30356
30365
|
// also becomes the --json envelope's details.next_step below.
|
|
@@ -30417,6 +30426,8 @@ catch (error) {
|
|
|
30417
30426
|
emitCliFailure(commandNameForArgv(program, argv.slice(2)), error);
|
|
30418
30427
|
}
|
|
30419
30428
|
else {
|
|
30429
|
+
recordCliDiagnosticFailure(error);
|
|
30430
|
+
await finishCliDiagnostics(1);
|
|
30420
30431
|
throw error;
|
|
30421
30432
|
}
|
|
30422
30433
|
}
|
|
@@ -30424,6 +30435,7 @@ catch (error) {
|
|
|
30424
30435
|
// pipe, wait for the queued write callback before Node exits or the tail of
|
|
30425
30436
|
// `oxygen --help` can be truncated at the pipe high-water mark.
|
|
30426
30437
|
await Promise.all([flushWritable(process.stdout), flushWritable(process.stderr)]);
|
|
30438
|
+
await finishCliDiagnostics(Number(process.exitCode ?? 0));
|
|
30427
30439
|
function flushWritable(stream) {
|
|
30428
30440
|
return new Promise((resolve) => stream.write("", () => resolve()));
|
|
30429
30441
|
}
|
|
@@ -9,10 +9,10 @@ export const OXYGEN_CAPABILITY_ROUTES = [
|
|
|
9
9
|
owns: "GEO / AEO AI-search tracking: buyer prompts, consumer answers, citations, competitive visibility, customer chat evidence and optimization experiments. Experimental staff/dev access.",
|
|
10
10
|
notFor: "Direct model API benchmarks, automatic public edits, autonomous publishing or personalized chat attribution.",
|
|
11
11
|
execution: "Staff in dev workspaces only. Preview consumer collection and cost, start within the $10 shared pilot cap, inspect answers and citations, then record reviewed changes and compare later samples. Reads never collect or spend.",
|
|
12
|
-
gatewayTools: ["oxygen_geo_projects_list", "oxygen_geo_engines_list", "oxygen_geo_runs_preview"],
|
|
13
|
-
gatewayCommands: ["geo projects list", "geo engines list", "geo runs preview"],
|
|
12
|
+
gatewayTools: ["oxygen_geo_projects_list", "oxygen_geo_engines_list", "oxygen_geo_analytics_get", "oxygen_geo_runs_preview"],
|
|
13
|
+
gatewayCommands: ["geo projects list", "geo engines list", "geo analytics get", "geo runs preview"],
|
|
14
14
|
endpointSections: ["geo"], skills: ["oxygen-geo"],
|
|
15
|
-
intentTerms: ["GEO", "AEO", "answer engine optimization", "generative engine optimization", "AI search visibility", "AI visibility", "consumer AI answers", "answer engines", "brand visibility", "brand citations", "chatgpt mentions", "citation gaps"],
|
|
15
|
+
intentTerms: ["GEO", "AEO", "answer engine optimization", "generative engine optimization", "AI search visibility", "AI answer visibility", "AI visibility", "consumer AI answers", "answer engines", "brand visibility", "brand citations", "chatgpt mentions", "citation gaps"],
|
|
16
16
|
},
|
|
17
17
|
{
|
|
18
18
|
id: "visual-rendering",
|
|
@@ -1791,7 +1791,7 @@ function isSpintaxIntent(query) {
|
|
|
1791
1791
|
}
|
|
1792
1792
|
function explicitCapabilityIntent(query) {
|
|
1793
1793
|
// Importing an AI answer is GEO evidence, rather than a Table file import.
|
|
1794
|
-
if (/\b(?:geo|aeo|ai (?:search )?visibility|consumer ai answers?|answer engine optimization|generative engine optimization)\b/.test(query))
|
|
1794
|
+
if (/\b(?:geo|aeo|ai (?:(?:search|answer) )?visibility|consumer ai answers?|answer engine optimization|generative engine optimization)\b/.test(query))
|
|
1795
1795
|
return ROUTE_BY_ID.get("geo") ?? null;
|
|
1796
1796
|
if (isConnectionPriceIntent(query))
|
|
1797
1797
|
return ROUTE_BY_ID.get("billing-and-budgets") ?? null;
|
|
@@ -120,5 +120,5 @@ export const BROWSER_OPERATIONS = [
|
|
|
120
120
|
operation("notetaker.calendar.schedule", "/api/cli/notetaker/calendar", "POST", "notetaker calendar schedule", "oxygen_notetaker_calendar_schedule", "crm", "Schedule recording for an existing calendar event after approval; an external CLI or MCP caller must supply an explicit max_credits ceiling.", { event_id: text(), approved: bool(), max_credits: num({ minimum: 0 }) }, ["event_id", "approved"], { defaults: { action: "schedule" }, spends: true }),
|
|
121
121
|
operation("notetaker.note.edit", "/api/cli/notetaker/sessions/{session}/note", "PATCH", "notetaker notes edit", "oxygen_notetaker_note_edit", "crm", "Save the markdown note for one meeting session and return the committed note.", { markdown: text() }, ["markdown"]),
|
|
122
122
|
operation("orgs.create", "/api/cli/orgs/create", "POST", "orgs create", "oxygen_workspace_create", "workspace", "Create a workspace for the authenticated person's account and begin native company setup. Workspace API keys cannot create personal workspaces.", { name: text(), company_domain: text("Company website or domain, or its LinkedIn company page URL when it has no website."), linkedin_profile_url: text("Creator's LinkedIn profile URL, separate from the company's page.") }, ["name", "company_domain"]),
|
|
123
|
-
operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "youtube", "google", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
|
|
123
|
+
operation("onboarding.complete", "/api/cli/onboarding/complete", "POST", "onboarding complete", "oxygen_onboarding_complete", "workspace", "Complete the active workspace's onboarding step and record its discovery-source choices. The other choice requires source_other.", { sources: arr(enumOf(["linkedin_content", "linkedin_dm", "linkedin_ads", "x", "youtube", "google", "email", "word_of_mouth", "referral", "event", "other"])), source_other: text(), redirect_url: text() }, ["sources"]),
|
|
124
124
|
];
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { arr, bool, enumOf, int, obj, str, } from "./schema.js";
|
|
2
2
|
import { GEO_ENGINES } from "../geo.js";
|
|
3
3
|
const text = (maxLength = 1000) => str({ maxLength });
|
|
4
|
-
const strings = () => arr(text(), {
|
|
4
|
+
const strings = () => arr(text(), {
|
|
5
|
+
maxItems: 100,
|
|
6
|
+
description: "List of strings. CLI: comma-separated values; MCP: a JSON array.",
|
|
7
|
+
});
|
|
5
8
|
const engine = enumOf(GEO_ENGINES);
|
|
6
9
|
const market = obj({
|
|
7
10
|
country: str({ minLength: 2, maxLength: 2 }),
|
|
@@ -10,10 +13,22 @@ const market = obj({
|
|
|
10
13
|
const project = {
|
|
11
14
|
name: str({ minLength: 1, maxLength: 120 }),
|
|
12
15
|
aliases: strings(),
|
|
13
|
-
domains: arr(text(), {
|
|
16
|
+
domains: arr(text(), {
|
|
17
|
+
minItems: 1,
|
|
18
|
+
maxItems: 100,
|
|
19
|
+
description: "Owned public domains. CLI: comma-separated values; MCP: a JSON array.",
|
|
20
|
+
}),
|
|
14
21
|
icp: text(5000),
|
|
15
|
-
engines: arr(engine, {
|
|
16
|
-
|
|
22
|
+
engines: arr(engine, {
|
|
23
|
+
minItems: 1,
|
|
24
|
+
maxItems: 7,
|
|
25
|
+
description: "Engine names. CLI: comma-separated values; MCP: a JSON array. Omit to select ready collectors, or Claude import-only if none are ready.",
|
|
26
|
+
}),
|
|
27
|
+
markets: arr(market, {
|
|
28
|
+
minItems: 1,
|
|
29
|
+
maxItems: 10,
|
|
30
|
+
description: 'JSON array of country/language pairs, e.g. [{"country":"US","language":"en"}].',
|
|
31
|
+
}),
|
|
17
32
|
repetitions: int({ minimum: 1, maximum: 5 }),
|
|
18
33
|
archived: bool(),
|
|
19
34
|
};
|
|
@@ -61,7 +76,7 @@ function operation(id, suffix, method, command, summary, fields = {}, required =
|
|
|
61
76
|
}
|
|
62
77
|
export const GEO_OPERATIONS = [
|
|
63
78
|
operation("projects.list", "/projects", "GET", "projects list", "List GEO / AEO AI-search tracking projects."),
|
|
64
|
-
operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project.", project, ["name", "domains"]),
|
|
79
|
+
operation("projects.create", "/projects", "POST", "projects create", "Create a GEO / AEO brand tracking project, private to the selected workspace and its authorized staff in dev. This never publishes data.", project, ["name", "domains"]),
|
|
65
80
|
operation("projects.get", "/projects/{project_id}", "GET", "projects get", "Read a GEO / AEO project and its tracking settings."),
|
|
66
81
|
operation("projects.update", "/projects/{project_id}", "PATCH", "projects update", "Change brand aliases, ICP, markets or tracking settings.", project),
|
|
67
82
|
operation("context.get", "/context", "GET", "context get", "Read editable brand, ICP and offering context from workspace Knowledge."),
|
|
@@ -83,6 +98,7 @@ export const GEO_OPERATIONS = [
|
|
|
83
98
|
operation("runs.list", "/projects/{project_id}/runs", "GET", "runs list", "List tracking runs with completion and costs."),
|
|
84
99
|
operation("runs.get", "/projects/{project_id}/runs/{run_id}", "GET", "runs get", "Inspect a tracking run, provider tasks, costs, failures and retries."),
|
|
85
100
|
operation("runs.retry", "/projects/{project_id}/runs/{run_id}/retry", "POST", "runs retry", "Resume failed reads of saved provider tasks without a new paid submission."),
|
|
101
|
+
operation("runs.reconcile", "/projects/{project_id}/runs/{run_id}/reconcile", "POST", "runs reconcile", "Reconcile uncertain submissions using a matching saved no-job supplier receipt. No new collection or paid submission."),
|
|
86
102
|
operation("runs.cancel", "/projects/{project_id}/runs/{run_id}/cancel", "POST", "runs cancel", "Stop unsubmitted samples; submitted collection retains its spend reservation."),
|
|
87
103
|
operation("schedules.set", "/projects/{project_id}/schedule", "PATCH", "schedule set", "Pause or configure daily/weekly GEO tracking within a reviewed cost ceiling.", {
|
|
88
104
|
cadence: enumOf(["paused", "daily", "weekly"]),
|
|
@@ -92,10 +108,16 @@ export const GEO_OPERATIONS = [
|
|
|
92
108
|
operation("answers.get", "/projects/{project_id}/answers/{document_id}", "GET", "answers get", "Read the immutable original answer and full provider payload."),
|
|
93
109
|
operation("answers.import", "/projects/{project_id}/answers", "POST", "answers import", "Import a manually observed consumer answer, separate from controlled tracker metrics.", {
|
|
94
110
|
engine,
|
|
111
|
+
prompt_id: str({
|
|
112
|
+
description: "Optional tracked question ID. The prompt text must match its current revision; the import stays outside controlled measurements.",
|
|
113
|
+
}),
|
|
95
114
|
prompt: text(500),
|
|
96
115
|
text: text(100000),
|
|
97
116
|
urls: strings(),
|
|
98
|
-
captured_at:
|
|
117
|
+
captured_at: str({
|
|
118
|
+
maxLength: 40,
|
|
119
|
+
description: "ISO 8601 capture timestamp, e.g. 2026-10-09T00:00:00Z. Omit to record the import time.",
|
|
120
|
+
}),
|
|
99
121
|
}, ["engine", "prompt", "text"]),
|
|
100
122
|
operation("analytics.get", "/projects/{project_id}/analytics", "GET", "analytics get", "Read AI visibility, citations, share of voice and trends with sample denominators.", filters),
|
|
101
123
|
operation("sources.list", "/projects/{project_id}/sources", "GET", "sources list", "Find domains and pages cited by AI, including competitive citation gaps.", filters),
|
|
@@ -132,7 +154,7 @@ export const GEO_OPERATIONS = [
|
|
|
132
154
|
notes: text(10000),
|
|
133
155
|
}),
|
|
134
156
|
{
|
|
135
|
-
...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download GEO evidence
|
|
136
|
-
binary: { maxBytes: 20_000_000 },
|
|
157
|
+
...operation("exports.get", "/projects/{project_id}/export", "GET", "export", "Download a bounded page of GEO evidence or a PDF report. Follow next_cursor in the result with --cursor; metrics describe all filtered answers.", { format: enumOf(["json", "csv", "pdf"]), ...filters }),
|
|
158
|
+
binary: { maxBytes: 20_000_000, paginated: true },
|
|
137
159
|
},
|
|
138
160
|
];
|