diff --git a/orbio-openclaw-plugin/README.md b/orbio-openclaw-plugin/README.md index d1d6849..5687259 100644 --- a/orbio-openclaw-plugin/README.md +++ b/orbio-openclaw-plugin/README.md @@ -73,6 +73,6 @@ Reference env file: `.env.smoke.example` 1. Bump version in `package.json`. 2. Sync version in `openclaw.plugin.json`. -3. Sync `PLUGIN_VERSION` in `src/index.ts`. +3. Sync `PLUGIN_VERSION` in `src/constants.ts`. 4. Run `pnpm verify` and `pnpm pack --dry-run`. 5. Publish with `pnpm publish --access public --no-git-checks --provenance`. diff --git a/orbio-openclaw-plugin/scripts/check-env-contract.mjs b/orbio-openclaw-plugin/scripts/check-env-contract.mjs index 540f131..78fbe9e 100644 --- a/orbio-openclaw-plugin/scripts/check-env-contract.mjs +++ b/orbio-openclaw-plugin/scripts/check-env-contract.mjs @@ -3,7 +3,7 @@ import path from "node:path"; const ROOT = process.cwd(); const ENV_TEMPLATE_PATH = path.join(ROOT, ".env.smoke.example"); -const INDEX_PATH = path.join(ROOT, "src/index.ts"); +const SRC_PATH = path.join(ROOT, "src"); const LIVE_SMOKE_PATH = path.join(ROOT, "scripts/live-smoke.mjs"); function readEnvKeys(filePath) { @@ -38,22 +38,44 @@ function difference(left, right) { return [...left].filter((value) => !right.has(value)).sort(); } +function collectSourceFiles(dirPath) { + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + files.push(...collectSourceFiles(entryPath)); + continue; + } + if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(entryPath); + } + } + return files; +} + function main() { if (!fs.existsSync(ENV_TEMPLATE_PATH)) { throw new Error(`Missing env template: ${ENV_TEMPLATE_PATH}`); } - if (!fs.existsSync(INDEX_PATH)) { - throw new Error(`Missing plugin source: ${INDEX_PATH}`); + if (!fs.existsSync(SRC_PATH)) { + throw new Error(`Missing plugin source directory: ${SRC_PATH}`); } if (!fs.existsSync(LIVE_SMOKE_PATH)) { throw new Error(`Missing smoke script: ${LIVE_SMOKE_PATH}`); } const envKeys = readEnvKeys(ENV_TEMPLATE_PATH); - const indexContent = fs.readFileSync(INDEX_PATH, "utf-8"); const smokeContent = fs.readFileSync(LIVE_SMOKE_PATH, "utf-8"); + const srcFiles = collectSourceFiles(SRC_PATH); - const pluginEnvRefs = extractMatches(indexContent, /env\.([A-Z0-9_]+)/gu); + const pluginEnvRefs = new Set(); + for (const filePath of srcFiles) { + const content = fs.readFileSync(filePath, "utf-8"); + for (const key of extractMatches(content, /env\.([A-Z0-9_]+)/gu)) { + pluginEnvRefs.add(key); + } + } const smokeEnvRefs = extractMatches( smokeContent, /(requiredEnv|optionalEnv)\("([A-Z0-9_]+)"/gu, diff --git a/orbio-openclaw-plugin/src/commands.ts b/orbio-openclaw-plugin/src/commands.ts new file mode 100644 index 0000000..14da1ca --- /dev/null +++ b/orbio-openclaw-plugin/src/commands.ts @@ -0,0 +1,139 @@ +import { createHash, randomUUID } from "node:crypto"; + +import { toTrimmedString } from "./config"; +import type { CommandToolInput } from "./schemas"; + +export type ParsedCommand = + | { + action: "search"; + queryText: string; + limit: number | undefined; + withContact: boolean; + } + | { + action: "export"; + queryText: string; + limit: number | undefined; + withContact: boolean; + format: "csv" | "html"; + } + | { + action: "export-status"; + exportId: string; + }; + +export function clampLimit(raw: number | undefined): number { + const fallback = 20; + if (raw === undefined || raw === null || !Number.isFinite(raw)) { + return fallback; + } + return Math.min(50000, Math.max(1, Math.floor(raw))); +} + +function parseTokens(raw: string): string[] { + const out: string[] = []; + const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; + let match: RegExpExecArray | null; + while ((match = regex.exec(raw)) !== null) { + const token = match[1] ?? match[2] ?? match[3] ?? ""; + if (token) { + out.push(token); + } + } + return out; +} + +export function usageText(): string { + return [ + "Usage:", + "/orbio search [--limit N] [--with-contact]", + "/orbio export [--limit N] [--format csv|html] [--with-contact]", + "/orbio export-status ", + ].join("\n"); +} + +export function parseCommand(raw: string): ParsedCommand | { error: string } { + const tokens = parseTokens(raw); + if (tokens.length === 0) { + return { error: usageText() }; + } + + const action = tokens[0]?.toLowerCase(); + const rest = tokens.slice(1); + + if (action === "search" || action === "export") { + let withContact = false; + let limit: number | undefined; + let format: "csv" | "html" = "csv"; + const queryParts: string[] = []; + + for (let idx = 0; idx < rest.length; idx += 1) { + const token = rest[idx] ?? ""; + if (token === "--with-contact") { + withContact = true; + continue; + } + if (token === "--limit") { + const rawLimit = rest[idx + 1]; + const parsed = rawLimit ? Number(rawLimit) : Number.NaN; + if (!Number.isFinite(parsed) || parsed <= 0) { + return { error: "Invalid --limit value. Use an integer >= 1." }; + } + limit = Math.floor(parsed); + idx += 1; + continue; + } + if (action === "export" && token === "--format") { + const rawFormat = String(rest[idx + 1] ?? "").toLowerCase(); + if (rawFormat !== "csv" && rawFormat !== "html") { + return { error: "Invalid --format value. Use csv or html." }; + } + format = rawFormat; + idx += 1; + continue; + } + queryParts.push(token); + } + + const queryText = queryParts.join(" ").trim(); + if (!queryText) { + return { error: `Missing query text.\n\n${usageText()}` }; + } + + if (action === "search") { + return { action: "search", queryText, limit, withContact }; + } + + return { action: "export", queryText, limit, withContact, format }; + } + + if (action === "export-status" || action === "status") { + const exportId = (rest[0] ?? "").trim(); + if (!exportId) { + return { error: "Missing export_id. Use: /orbio export-status " }; + } + return { action: "export-status", exportId }; + } + + return { error: `Unknown command: ${action}\n\n${usageText()}` }; +} + +export function buildIdempotencyKey(prefix: string, payload: unknown): string { + const digest = createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24); + const suffix = randomUUID().replace(/-/g, "").slice(0, 12); + return `openclaw:${prefix}:${digest}:${suffix}`; +} + +export function resolveCommandRaw(args: CommandToolInput): string { + const raw = args.command ?? args.command_arg ?? args.commandArg; + const commandName = args.command_name ?? args.commandName; + const rawText = toTrimmedString(raw); + if (rawText) { + return rawText; + } + const commandText = toTrimmedString(commandName); + if (commandText) { + return commandText; + } + return ""; +} diff --git a/orbio-openclaw-plugin/src/config.ts b/orbio-openclaw-plugin/src/config.ts new file mode 100644 index 0000000..5d3be55 --- /dev/null +++ b/orbio-openclaw-plugin/src/config.ts @@ -0,0 +1,119 @@ +import { PLUGIN_ID, PLUGIN_VERSION } from "./constants"; +import type { JsonRecord, OrbioPluginConfig } from "./types"; + +export function toTrimmedString(value: unknown): string { + if (typeof value === "string") { + return value.trim(); + } + if (typeof value === "number" || typeof value === "boolean" || value == null) { + return String(value ?? "").trim(); + } + try { + return String(value).trim(); + } catch { + return ""; + } +} + +export function asJsonRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" ? (value as JsonRecord) : null; +} + +function parsePositiveInt(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return fallback; + } + return Math.floor(value); +} + +function parseNonNegativeInt(value: unknown, fallback: number): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + return fallback; + } + return Math.floor(value); +} + +function parseBoolean(value: unknown, fallback: boolean): boolean { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string" || value instanceof String) { + const normalized = toTrimmedString(value).toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + } + return fallback; +} + +function normalizeChannel(value: unknown): string { + const raw = toTrimmedString(value).toLowerCase(); + if (!raw) { + return "chat"; + } + const normalized = raw.replaceAll(" ", "_").replace(/[^a-z0-9_-]/g, "").slice(0, 64); + return normalized || "chat"; +} + +function normalizeBaseUrl(value: string): string { + return value.endsWith("/") ? value.slice(0, -1) : value; +} + +export function readConfig(api: unknown): OrbioPluginConfig { + const asRecord = (api ?? {}) as JsonRecord; + const pluginConfigEnvelope = asJsonRecord(asRecord.pluginConfig); + const pluginConfig = asJsonRecord(pluginConfigEnvelope?.config) ?? pluginConfigEnvelope; + const rootConfig = asJsonRecord(asRecord.config); + const rootPlugins = asJsonRecord(rootConfig?.plugins); + const rootPluginEntries = asJsonRecord(rootPlugins?.entries); + const rootPluginEntry = asJsonRecord(rootPluginEntries?.[PLUGIN_ID]); + const rootPluginConfig = asJsonRecord(rootPluginEntry?.config) ?? rootPluginEntry; + const legacyConfig = + rootConfig && + (Object.prototype.hasOwnProperty.call(rootConfig, "baseUrl") || + Object.prototype.hasOwnProperty.call(rootConfig, "apiKey")) + ? rootConfig + : null; + const rawConfig = pluginConfig ?? rootPluginConfig ?? legacyConfig ?? {}; + const envSource = asJsonRecord(asRecord.env); + const env = ((envSource ?? process.env) as Record) ?? {}; + + const baseUrl = toTrimmedString(rawConfig.baseUrl ?? env.ORBIO_BASE_URL ?? ""); + const apiKey = toTrimmedString(rawConfig.apiKey ?? env.ORBIO_API_KEY ?? ""); + + if (!baseUrl) { + throw new Error("Missing plugin config: baseUrl"); + } + if (!apiKey) { + throw new Error("Missing plugin config: apiKey"); + } + + const timeoutMs = parsePositiveInt(rawConfig.timeoutMs, 20_000); + const maxRequestsPerMinute = parsePositiveInt(rawConfig.maxRequestsPerMinute, 30); + const retryCount = Math.min(3, parseNonNegativeInt(rawConfig.retryCount, 1)); + const retryBackoffMs = parsePositiveInt(rawConfig.retryBackoffMs, 300); + const capabilitiesTtlMs = parsePositiveInt(rawConfig.capabilitiesTtlMs, 60_000); + const workspaceId = toTrimmedString(rawConfig.workspaceId ?? env.ORBIO_WORKSPACE_ID ?? "default"); + const channel = normalizeChannel(rawConfig.channel ?? env.ORBIO_CHANNEL ?? "chat"); + const sendExecutionContext = parseBoolean( + rawConfig.sendExecutionContext ?? env.ORBIO_SEND_EXECUTION_CONTEXT, + true, + ); + + return { + baseUrl: normalizeBaseUrl(baseUrl), + apiKey, + workspaceId: workspaceId || "default", + channel, + sendExecutionContext, + timeoutMs, + maxRequestsPerMinute, + retryCount, + retryBackoffMs, + capabilitiesTtlMs, + userAgent: `${PLUGIN_ID}/${PLUGIN_VERSION}`, + }; +} diff --git a/orbio-openclaw-plugin/src/constants.ts b/orbio-openclaw-plugin/src/constants.ts new file mode 100644 index 0000000..5123d46 --- /dev/null +++ b/orbio-openclaw-plugin/src/constants.ts @@ -0,0 +1,5 @@ +export const PLUGIN_ID = "orbio-openclaw"; +export const PLUGIN_NAME = "Orbio (official)"; +export const PLUGIN_VERSION = "0.1.0"; +export const EXECUTION_CONTEXT_HEADER = "X-Orbio-Execution-Context"; +export const EXECUTION_CONTEXT_INTEGRATION = "openclaw"; diff --git a/orbio-openclaw-plugin/src/formatters.ts b/orbio-openclaw-plugin/src/formatters.ts new file mode 100644 index 0000000..6bd2c52 --- /dev/null +++ b/orbio-openclaw-plugin/src/formatters.ts @@ -0,0 +1,162 @@ +import { OrbioApiError, PluginRateLimitError } from "./http"; +import type { + AccountSearchResponse, + ExportCreateResponse, + ExportStatusResponse, + JsonRecord, + ToolResult, +} from "./types"; + +const SAFE_DEFAULT_FIELDS = [ + "cnpj", + "legal_name", + "trade_name", + "uf", + "municipality_ibge", + "cnae_primary", + "company_size_code", + "registration_status", + "started_at", + "has_email", + "has_phone", +] as const; + +const CONTACT_FIELDS = [ + "email", + "phone1", + "area_code1", + "phone2", + "area_code2", + "street_type", + "street", + "street_number", + "address_complement", + "neighborhood", + "postal_code", +] as const; + +export function chooseOutputFields( + allowlist: string[], + withContact: boolean, +): { fields: string[]; contactGranted: boolean } { + const allowed = new Set(allowlist); + const safe = SAFE_DEFAULT_FIELDS.filter((field) => allowed.has(field)); + if (safe.length === 0) { + throw new Error("No safe output fields are allowed for this plan."); + } + + if (!withContact) { + return { fields: safe, contactGranted: false }; + } + + const contact = CONTACT_FIELDS.filter((field) => allowed.has(field)); + if (contact.length === 0) { + return { fields: safe, contactGranted: false }; + } + + return { fields: [...safe, ...contact], contactGranted: true }; +} + +function topAccounts(accounts: JsonRecord[], limit = 10): JsonRecord[] { + return accounts.slice(0, limit); +} + +export function renderSearchText( + payload: AccountSearchResponse, + opts: { withContactRequested: boolean; contactGranted: boolean; fields: string[] }, +): string { + const note = + opts.withContactRequested && !opts.contactGranted + ? "\nNote: contact fields are restricted by plan; returning masked fields only." + : ""; + + const body = { + request_id: payload.request_id, + snapshot: payload.snapshot, + snapshot_date: payload.snapshot_date, + result_count: payload.accounts.length, + has_more: payload.has_more, + next_cursor: payload.next_cursor, + fields: opts.fields, + accounts: topAccounts(payload.accounts), + }; + + return `Search completed.${note}\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; +} + +export function renderExportText( + payload: ExportCreateResponse, + opts: { withContactRequested: boolean; contactGranted: boolean; fields: string[] }, +): string { + const note = + opts.withContactRequested && !opts.contactGranted + ? "\nNote: contact fields are restricted by plan; export uses masked fields only." + : ""; + + const body = { + request_id: payload.request_id, + snapshot: payload.snapshot, + snapshot_date: payload.snapshot_date, + export: payload.export, + fields: opts.fields, + preview_accounts: topAccounts(payload.preview_accounts), + }; + + return `Export requested.${note}\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; +} + +export function renderExportStatusText(payload: ExportStatusResponse): string { + const body = { + export_id: payload.export_id, + status: payload.status, + format: payload.format, + row_count: payload.row_count, + size_bytes: payload.size_bytes, + expires_at: payload.expires_at, + download_url: payload.download_url, + }; + return `Export status:\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; +} + +export function errorText(error: unknown): string { + if (error instanceof PluginRateLimitError) { + return `Rate limited by plugin policy. Retry in ~${error.retryAfterSec}s.`; + } + + if (error instanceof OrbioApiError) { + const code = (error.code ?? "").toLowerCase(); + const requestIdSuffix = error.requestId ? ` (request_id=${error.requestId})` : ""; + + if (error.status === 429 || code === "rate_limit_exceeded") { + const retry = error.retryAfter ? ` Retry-After=${error.retryAfter}s.` : ""; + return `Orbio rate limit exceeded.${retry}${requestIdSuffix}`; + } + if (code === "quota_exceeded") { + return `Orbio quota exceeded for this API key/workspace.${requestIdSuffix}`; + } + if ( + code === "authentication_required" || + code === "authentication_invalid" || + code === "authentication_disabled" || + error.status === 401 + ) { + return `Orbio authentication failed. Check plugin apiKey.${requestIdSuffix}`; + } + if (code === "invalid_spec" || code === "query_too_broad" || error.status === 422) { + return `Query is invalid or too broad. Narrow filters and retry.${requestIdSuffix}`; + } + if (code === "dependency_unavailable" || error.status >= 500) { + return `Orbio dependency is temporarily unavailable. Retry shortly.${requestIdSuffix}`; + } + return `Orbio API error: ${error.detail}${requestIdSuffix}`; + } + + if (error instanceof Error) { + return `Unexpected error: ${error.message}`; + } + return "Unexpected unknown error."; +} + +export function result(text: string): ToolResult { + return { content: [{ type: "text", text }] }; +} diff --git a/orbio-openclaw-plugin/src/http.ts b/orbio-openclaw-plugin/src/http.ts new file mode 100644 index 0000000..75dcb33 --- /dev/null +++ b/orbio-openclaw-plugin/src/http.ts @@ -0,0 +1,211 @@ +import { randomUUID } from "node:crypto"; + +import { EXECUTION_CONTEXT_HEADER, EXECUTION_CONTEXT_INTEGRATION } from "./constants"; +import type { JsonRecord, OrbioPluginConfig } from "./types"; + +export class PluginRateLimitError extends Error { + public readonly retryAfterSec: number; + + constructor(retryAfterSec: number) { + super("plugin_rate_limited"); + this.retryAfterSec = retryAfterSec; + } +} + +export class OrbioApiError extends Error { + public readonly status: number; + public readonly code: string | null; + public readonly detail: string; + public readonly requestId: string | null; + public readonly retryAfter: string | null; + + constructor(params: { + status: number; + code: string | null; + detail: string; + requestId: string | null; + retryAfter: string | null; + }) { + super(params.detail || "orbio_api_error"); + this.status = params.status; + this.code = params.code; + this.detail = params.detail; + this.requestId = params.requestId; + this.retryAfter = params.retryAfter; + } +} + +export class MinuteWindowLimiter { + private readonly events = new Map(); + + check(key: string, limit: number): void { + const now = Date.now(); + const cutoff = now - 60_000; + const current = this.events.get(key) ?? []; + const kept = current.filter((ts) => ts >= cutoff); + + if (kept.length >= limit) { + const oldest = kept[0] ?? now; + const retryAfterMs = Math.max(1, 60_000 - (now - oldest)); + throw new PluginRateLimitError(Math.ceil(retryAfterMs / 1000)); + } + + kept.push(now); + this.events.set(key, kept); + } +} + +function parseProblem(payload: unknown): { code: string | null; detail: string } { + if (!payload || typeof payload !== "object") { + return { code: null, detail: "Orbio API returned an error." }; + } + const record = payload as JsonRecord; + + const directCode = typeof record.code === "string" ? record.code : null; + const directDetail = typeof record.detail === "string" ? record.detail : null; + + const nested = record.error; + if (nested && typeof nested === "object") { + const nestedRecord = nested as JsonRecord; + const nestedCode = typeof nestedRecord.code === "string" ? nestedRecord.code : null; + const nestedMessage = + typeof nestedRecord.message === "string" ? nestedRecord.message : directDetail; + return { + code: nestedCode ?? directCode, + detail: nestedMessage ?? "Orbio API returned an error.", + }; + } + + return { + code: directCode, + detail: directDetail ?? "Orbio API returned an error.", + }; +} + +function isNetworkError(error: unknown): boolean { + return error instanceof TypeError; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export class OrbioHttpClient { + private readonly cfg: OrbioPluginConfig; + + constructor(cfg: OrbioPluginConfig) { + this.cfg = cfg; + } + + async request( + method: "GET" | "POST", + path: string, + body?: unknown, + extraHeaders?: Record, + ): Promise { + const url = `${this.cfg.baseUrl}${path}`; + const requestId = randomUUID(); + + for (let attempt = 0; attempt <= this.cfg.retryCount; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.cfg.timeoutMs); + + try { + const response = await fetch(url, { + method, + headers: { + Accept: "application/json", + Authorization: `Bearer ${this.cfg.apiKey}`, + "Content-Type": "application/json", + "User-Agent": this.cfg.userAgent, + "X-Request-Id": requestId, + ...this.buildExecutionContextHeader(requestId), + ...(extraHeaders ?? {}), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: controller.signal, + }); + clearTimeout(timeout); + + if (response.ok) { + if (response.status === 204) { + return {} as T; + } + const payload = await this.parseJsonSafe(response); + return (payload ?? {}) as T; + } + + if (response.status >= 500 && attempt < this.cfg.retryCount) { + await sleep(this.cfg.retryBackoffMs * (attempt + 1)); + continue; + } + + const payload = await this.parseJsonSafe(response); + const { code, detail } = parseProblem(payload); + throw new OrbioApiError({ + status: response.status, + code, + detail, + requestId: response.headers.get("X-Request-Id"), + retryAfter: response.headers.get("Retry-After"), + }); + } catch (error) { + clearTimeout(timeout); + const isAbort = error instanceof Error && error.name === "AbortError"; + if ((isAbort || isNetworkError(error)) && attempt < this.cfg.retryCount) { + await sleep(this.cfg.retryBackoffMs * (attempt + 1)); + continue; + } + if (error instanceof OrbioApiError) { + throw error; + } + const detail = isAbort + ? `Request timed out after ${this.cfg.timeoutMs} ms.` + : "Network failure while calling Orbio API."; + throw new OrbioApiError({ + status: 0, + code: isAbort ? "TIMEOUT" : "NETWORK_ERROR", + detail, + requestId: null, + retryAfter: null, + }); + } + } + + throw new OrbioApiError({ + status: 0, + code: "RETRY_EXHAUSTED", + detail: "Transient retries exhausted.", + requestId: null, + retryAfter: null, + }); + } + + private async parseJsonSafe(response: Response): Promise { + const text = await response.text(); + if (!text.trim()) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } + } + + private buildExecutionContextHeader(requestId: string): Record { + if (!this.cfg.sendExecutionContext) { + return {}; + } + const payload = { + v: 1, + integration: EXECUTION_CONTEXT_INTEGRATION, + channel: this.cfg.channel, + workspace: this.cfg.workspaceId, + run_id: requestId, + }; + return { + [EXECUTION_CONTEXT_HEADER]: JSON.stringify(payload), + }; + } +} diff --git a/orbio-openclaw-plugin/src/index.ts b/orbio-openclaw-plugin/src/index.ts index 53be1ca..933b8d3 100644 --- a/orbio-openclaw-plugin/src/index.ts +++ b/orbio-openclaw-plugin/src/index.ts @@ -1,751 +1,36 @@ -import { createHash, randomUUID } from "node:crypto"; - -import { Type, type Static } from "@sinclair/typebox"; - -type JsonRecord = Record; - -const PLUGIN_ID = "orbio-openclaw"; -const PLUGIN_NAME = "Orbio (official)"; -const PLUGIN_VERSION = "0.1.0"; -const EXECUTION_CONTEXT_HEADER = "X-Orbio-Execution-Context"; -const EXECUTION_CONTEXT_INTEGRATION = "openclaw"; - -type OrbioPluginConfig = { - baseUrl: string; - apiKey: string; - workspaceId: string; - channel: string; - sendExecutionContext: boolean; - timeoutMs: number; - maxRequestsPerMinute: number; - retryCount: number; - retryBackoffMs: number; - capabilitiesTtlMs: number; - userAgent: string; -}; - -type ToolResult = { - content: Array<{ type: "text"; text: string }>; -}; - -type CapabilitiesResponse = { - current_snapshot: string; - snapshot_date: string; - plan_tier: string; - limits: JsonRecord; - broad_query_rules: { - require_cnae: boolean; - require_geo: string; - free_minimum: string; - }; - allowed_sort_fields: string[]; - field_allowlist: string[]; -}; - -type AccountSearchResponse = { - request_id: string; - snapshot: string; - snapshot_date: string; - spec?: JsonRecord; - accounts: JsonRecord[]; - has_more: boolean; - next_cursor: string | null; -}; - -type ExportCreateResponse = { - request_id: string; - snapshot: string; - snapshot_date: string; - spec?: JsonRecord; - preview_accounts: JsonRecord[]; - export: { - export_id: string; - status: string; - format: string; - row_count: number | null; - size_bytes: number | null; - expires_at: string | null; - download_url: string | null; - }; -}; - -type ExportStatusResponse = { - export_id: string; - status: string; - format: string; - snapshot?: string; - snapshot_date?: string; - row_count: number | null; - size_bytes: number | null; - object_key?: string | null; - expires_at: string | null; - download_url: string | null; -}; - -type SpecResponse = { - spec: JsonRecord; -}; - -type OutputSpec = { - format: "json" | "csv" | "html"; - include_explain: boolean; - fields: string[]; -}; - -const SAFE_DEFAULT_FIELDS = [ - "cnpj", - "legal_name", - "trade_name", - "uf", - "municipality_ibge", - "cnae_primary", - "company_size_code", - "registration_status", - "started_at", - "has_email", - "has_phone", -] as const; - -const CONTACT_FIELDS = [ - "email", - "phone1", - "area_code1", - "phone2", - "area_code2", - "street_type", - "street", - "street_number", - "address_complement", - "neighborhood", - "postal_code", -] as const; - -const SearchToolInput = Type.Object( - { - query_text: Type.String({ minLength: 1, maxLength: 500 }), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000 })), - with_contact: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -); - -type SearchToolInput = Static; - -const ExportToolInput = Type.Object( - { - query_text: Type.String({ minLength: 1, maxLength: 500 }), - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000 })), - format: Type.Optional(Type.Union([Type.Literal("csv"), Type.Literal("html")])), - with_contact: Type.Optional(Type.Boolean()), - }, - { additionalProperties: false }, -); - -type ExportToolInput = Static; - -const ExportStatusToolInput = Type.Object( - { - export_id: Type.String({ minLength: 1, maxLength: 128 }), - }, - { additionalProperties: false }, -); - -type ExportStatusToolInput = Static; - -const CommandToolInput = Type.Object( - { - command: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), - command_arg: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), - commandArg: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), - command_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), - commandName: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), - skill_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), - skillName: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), - }, - { additionalProperties: true }, -); - -type CommandToolInput = Static; - -class PluginRateLimitError extends Error { - public readonly retryAfterSec: number; - - constructor(retryAfterSec: number) { - super("plugin_rate_limited"); - this.retryAfterSec = retryAfterSec; - } -} - -class OrbioApiError extends Error { - public readonly status: number; - public readonly code: string | null; - public readonly detail: string; - public readonly requestId: string | null; - public readonly retryAfter: string | null; - - constructor(params: { - status: number; - code: string | null; - detail: string; - requestId: string | null; - retryAfter: string | null; - }) { - super(params.detail || "orbio_api_error"); - this.status = params.status; - this.code = params.code; - this.detail = params.detail; - this.requestId = params.requestId; - this.retryAfter = params.retryAfter; - } -} - -class MinuteWindowLimiter { - private readonly events = new Map(); - - check(key: string, limit: number): void { - const now = Date.now(); - const cutoff = now - 60_000; - const current = this.events.get(key) ?? []; - const kept = current.filter((ts) => ts >= cutoff); - - if (kept.length >= limit) { - const oldest = kept[0] ?? now; - const retryAfterMs = Math.max(1, 60_000 - (now - oldest)); - throw new PluginRateLimitError(Math.ceil(retryAfterMs / 1000)); - } - - kept.push(now); - this.events.set(key, kept); - } -} - -class OrbioHttpClient { - private readonly cfg: OrbioPluginConfig; - - constructor(cfg: OrbioPluginConfig) { - this.cfg = cfg; - } - - async request( - method: "GET" | "POST", - path: string, - body?: unknown, - extraHeaders?: Record, - ): Promise { - const url = `${this.cfg.baseUrl}${path}`; - const requestId = randomUUID(); - - for (let attempt = 0; attempt <= this.cfg.retryCount; attempt += 1) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), this.cfg.timeoutMs); - - try { - const response = await fetch(url, { - method, - headers: { - Accept: "application/json", - Authorization: `Bearer ${this.cfg.apiKey}`, - "Content-Type": "application/json", - "User-Agent": this.cfg.userAgent, - "X-Request-Id": requestId, - ...this.buildExecutionContextHeader(requestId), - ...(extraHeaders ?? {}), - }, - body: body === undefined ? undefined : JSON.stringify(body), - signal: controller.signal, - }); - clearTimeout(timeout); - - if (response.ok) { - if (response.status === 204) { - return {} as T; - } - const payload = await this.parseJsonSafe(response); - return (payload ?? {}) as T; - } - - if (response.status >= 500 && attempt < this.cfg.retryCount) { - await sleep(this.cfg.retryBackoffMs * (attempt + 1)); - continue; - } - - const payload = await this.parseJsonSafe(response); - const { code, detail } = parseProblem(payload); - throw new OrbioApiError({ - status: response.status, - code, - detail, - requestId: response.headers.get("X-Request-Id"), - retryAfter: response.headers.get("Retry-After"), - }); - } catch (error) { - clearTimeout(timeout); - const isAbort = error instanceof Error && error.name === "AbortError"; - if ((isAbort || isNetworkError(error)) && attempt < this.cfg.retryCount) { - await sleep(this.cfg.retryBackoffMs * (attempt + 1)); - continue; - } - if (error instanceof OrbioApiError) { - throw error; - } - const detail = isAbort - ? `Request timed out after ${this.cfg.timeoutMs} ms.` - : "Network failure while calling Orbio API."; - throw new OrbioApiError({ - status: 0, - code: isAbort ? "TIMEOUT" : "NETWORK_ERROR", - detail, - requestId: null, - retryAfter: null, - }); - } - } - - throw new OrbioApiError({ - status: 0, - code: "RETRY_EXHAUSTED", - detail: "Transient retries exhausted.", - requestId: null, - retryAfter: null, - }); - } - - private async parseJsonSafe(response: Response): Promise { - const text = await response.text(); - if (!text.trim()) { - return null; - } - try { - return JSON.parse(text); - } catch { - return null; - } - } - - private buildExecutionContextHeader(requestId: string): Record { - if (!this.cfg.sendExecutionContext) { - return {}; - } - const payload = { - v: 1, - integration: EXECUTION_CONTEXT_INTEGRATION, - channel: this.cfg.channel, - workspace: this.cfg.workspaceId, - run_id: requestId, - }; - return { - [EXECUTION_CONTEXT_HEADER]: JSON.stringify(payload), - }; - } -} - -function parseProblem(payload: unknown): { code: string | null; detail: string } { - if (!payload || typeof payload !== "object") { - return { code: null, detail: "Orbio API returned an error." }; - } - const record = payload as JsonRecord; - - const directCode = typeof record.code === "string" ? record.code : null; - const directDetail = typeof record.detail === "string" ? record.detail : null; - - const nested = record.error; - if (nested && typeof nested === "object") { - const nestedRecord = nested as JsonRecord; - const nestedCode = typeof nestedRecord.code === "string" ? nestedRecord.code : null; - const nestedMessage = - typeof nestedRecord.message === "string" ? nestedRecord.message : directDetail; - return { - code: nestedCode ?? directCode, - detail: nestedMessage ?? "Orbio API returned an error.", - }; - } - - return { - code: directCode, - detail: directDetail ?? "Orbio API returned an error.", - }; -} - -function parsePositiveInt(value: unknown, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { - return fallback; - } - return Math.floor(value); -} - -function parseNonNegativeInt(value: unknown, fallback: number): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { - return fallback; - } - return Math.floor(value); -} - -function toTrimmedString(value: unknown): string { - if (typeof value === "string") { - return value.trim(); - } - if (typeof value === "number" || typeof value === "boolean" || value == null) { - return String(value ?? "").trim(); - } - try { - return String(value).trim(); - } catch { - return ""; - } -} - -function asJsonRecord(value: unknown): JsonRecord | null { - return value && typeof value === "object" ? (value as JsonRecord) : null; -} - -function parseBoolean(value: unknown, fallback: boolean): boolean { - if (typeof value === "boolean") { - return value; - } - if (typeof value === "string" || value instanceof String) { - const normalized = toTrimmedString(value).toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) { - return true; - } - if (["0", "false", "no", "off"].includes(normalized)) { - return false; - } - } - return fallback; -} - -function normalizeChannel(value: unknown): string { - const raw = toTrimmedString(value).toLowerCase(); - if (!raw) { - return "chat"; - } - const normalized = raw.replaceAll(" ", "_").replace(/[^a-z0-9_-]/g, "").slice(0, 64); - return normalized || "chat"; -} - -function normalizeBaseUrl(value: string): string { - return value.endsWith("/") ? value.slice(0, -1) : value; -} - -function readConfig(api: unknown): OrbioPluginConfig { - const asRecord = (api ?? {}) as JsonRecord; - const pluginConfigEnvelope = asJsonRecord(asRecord.pluginConfig); - const pluginConfig = - asJsonRecord(pluginConfigEnvelope?.config) ?? - pluginConfigEnvelope; - const rootConfig = asJsonRecord(asRecord.config); - const rootPlugins = asJsonRecord(rootConfig?.plugins); - const rootPluginEntries = asJsonRecord(rootPlugins?.entries); - const rootPluginEntry = asJsonRecord(rootPluginEntries?.[PLUGIN_ID]); - const rootPluginConfig = - asJsonRecord(rootPluginEntry?.config) ?? - rootPluginEntry; - const legacyConfig = - rootConfig && - (Object.prototype.hasOwnProperty.call(rootConfig, "baseUrl") || - Object.prototype.hasOwnProperty.call(rootConfig, "apiKey")) - ? rootConfig - : null; - const rawConfig = - pluginConfig ?? - rootPluginConfig ?? - legacyConfig ?? - {}; - const envSource = asJsonRecord(asRecord.env); - const env = - ((envSource ?? process.env) as Record) ?? {}; - - const baseUrl = toTrimmedString(rawConfig.baseUrl ?? env.ORBIO_BASE_URL ?? ""); - const apiKey = toTrimmedString(rawConfig.apiKey ?? env.ORBIO_API_KEY ?? ""); - - if (!baseUrl) { - throw new Error("Missing plugin config: baseUrl"); - } - if (!apiKey) { - throw new Error("Missing plugin config: apiKey"); - } - - const timeoutMs = parsePositiveInt(rawConfig.timeoutMs, 20_000); - const maxRequestsPerMinute = parsePositiveInt(rawConfig.maxRequestsPerMinute, 30); - const retryCount = Math.min(3, parseNonNegativeInt(rawConfig.retryCount, 1)); - const retryBackoffMs = parsePositiveInt(rawConfig.retryBackoffMs, 300); - const capabilitiesTtlMs = parsePositiveInt(rawConfig.capabilitiesTtlMs, 60_000); - const workspaceId = toTrimmedString(rawConfig.workspaceId ?? env.ORBIO_WORKSPACE_ID ?? "default"); - const channel = normalizeChannel(rawConfig.channel ?? env.ORBIO_CHANNEL ?? "chat"); - const sendExecutionContext = parseBoolean( - rawConfig.sendExecutionContext ?? env.ORBIO_SEND_EXECUTION_CONTEXT, - true, - ); - - return { - baseUrl: normalizeBaseUrl(baseUrl), - apiKey, - workspaceId: workspaceId || "default", - channel, - sendExecutionContext, - timeoutMs, - maxRequestsPerMinute, - retryCount, - retryBackoffMs, - capabilitiesTtlMs, - userAgent: `${PLUGIN_ID}/${PLUGIN_VERSION}`, - }; -} - -function isNetworkError(error: unknown): boolean { - return error instanceof TypeError; -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -function clampLimit(raw: number | undefined): number { - const fallback = 20; - if (raw === undefined || raw === null || !Number.isFinite(raw)) { - return fallback; - } - return Math.min(50000, Math.max(1, Math.floor(raw))); -} - -function parseTokens(raw: string): string[] { - const out: string[] = []; - const regex = /"([^"]*)"|'([^']*)'|(\S+)/g; - let match: RegExpExecArray | null; - while ((match = regex.exec(raw)) !== null) { - const token = match[1] ?? match[2] ?? match[3] ?? ""; - if (token) { - out.push(token); - } - } - return out; -} - -type ParsedCommand = - | { - action: "search"; - queryText: string; - limit: number | undefined; - withContact: boolean; - } - | { - action: "export"; - queryText: string; - limit: number | undefined; - withContact: boolean; - format: "csv" | "html"; - } - | { - action: "export-status"; - exportId: string; - }; - -function parseCommand(raw: string): ParsedCommand | { error: string } { - const tokens = parseTokens(raw); - if (tokens.length === 0) { - return { error: usageText() }; - } - - const action = tokens[0]?.toLowerCase(); - const rest = tokens.slice(1); - - if (action === "search" || action === "export") { - let withContact = false; - let limit: number | undefined; - let format: "csv" | "html" = "csv"; - const queryParts: string[] = []; - - for (let idx = 0; idx < rest.length; idx += 1) { - const token = rest[idx] ?? ""; - if (token === "--with-contact") { - withContact = true; - continue; - } - if (token === "--limit") { - const rawLimit = rest[idx + 1]; - const parsed = rawLimit ? Number(rawLimit) : Number.NaN; - if (!Number.isFinite(parsed) || parsed <= 0) { - return { error: "Invalid --limit value. Use an integer >= 1." }; - } - limit = Math.floor(parsed); - idx += 1; - continue; - } - if (action === "export" && token === "--format") { - const rawFormat = String(rest[idx + 1] ?? "").toLowerCase(); - if (rawFormat !== "csv" && rawFormat !== "html") { - return { error: "Invalid --format value. Use csv or html." }; - } - format = rawFormat; - idx += 1; - continue; - } - queryParts.push(token); - } - - const queryText = queryParts.join(" ").trim(); - if (!queryText) { - return { error: `Missing query text.\n\n${usageText()}` }; - } - - if (action === "search") { - return { action: "search", queryText, limit, withContact }; - } - - return { action: "export", queryText, limit, withContact, format }; - } - - if (action === "export-status" || action === "status") { - const exportId = (rest[0] ?? "").trim(); - if (!exportId) { - return { error: "Missing export_id. Use: /orbio export-status " }; - } - return { action: "export-status", exportId }; - } - - return { error: `Unknown command: ${action}\n\n${usageText()}` }; -} - -function usageText(): string { - return [ - "Usage:", - "/orbio search [--limit N] [--with-contact]", - "/orbio export [--limit N] [--format csv|html] [--with-contact]", - "/orbio export-status ", - ].join("\n"); -} - -function buildIdempotencyKey(prefix: string, payload: unknown): string { - const digest = createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 24); - const suffix = randomUUID().replace(/-/g, "").slice(0, 12); - return `openclaw:${prefix}:${digest}:${suffix}`; -} - -function chooseOutputFields( - allowlist: string[], - withContact: boolean, -): { fields: string[]; contactGranted: boolean } { - const allowed = new Set(allowlist); - const safe = SAFE_DEFAULT_FIELDS.filter((field) => allowed.has(field)); - if (safe.length === 0) { - throw new Error("No safe output fields are allowed for this plan."); - } - - if (!withContact) { - return { fields: safe, contactGranted: false }; - } - - const contact = CONTACT_FIELDS.filter((field) => allowed.has(field)); - if (contact.length === 0) { - return { fields: safe, contactGranted: false }; - } - - return { fields: [...safe, ...contact], contactGranted: true }; -} - -function topAccounts(accounts: JsonRecord[], limit = 10): JsonRecord[] { - return accounts.slice(0, limit); -} - -function renderSearchText( - payload: AccountSearchResponse, - opts: { withContactRequested: boolean; contactGranted: boolean; fields: string[] }, -): string { - const note = - opts.withContactRequested && !opts.contactGranted - ? "\nNote: contact fields are restricted by plan; returning masked fields only." - : ""; - - const body = { - request_id: payload.request_id, - snapshot: payload.snapshot, - snapshot_date: payload.snapshot_date, - result_count: payload.accounts.length, - has_more: payload.has_more, - next_cursor: payload.next_cursor, - fields: opts.fields, - accounts: topAccounts(payload.accounts), - }; - - return `Search completed.${note}\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; -} - -function renderExportText( - payload: ExportCreateResponse, - opts: { withContactRequested: boolean; contactGranted: boolean; fields: string[] }, -): string { - const note = - opts.withContactRequested && !opts.contactGranted - ? "\nNote: contact fields are restricted by plan; export uses masked fields only." - : ""; - - const body = { - request_id: payload.request_id, - snapshot: payload.snapshot, - snapshot_date: payload.snapshot_date, - export: payload.export, - fields: opts.fields, - preview_accounts: topAccounts(payload.preview_accounts), - }; - - return `Export requested.${note}\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; -} - -function renderExportStatusText(payload: ExportStatusResponse): string { - const body = { - export_id: payload.export_id, - status: payload.status, - format: payload.format, - row_count: payload.row_count, - size_bytes: payload.size_bytes, - expires_at: payload.expires_at, - download_url: payload.download_url, - }; - return `Export status:\n\n\`\`\`json\n${JSON.stringify(body, null, 2)}\n\`\`\``; -} - -function errorText(error: unknown): string { - if (error instanceof PluginRateLimitError) { - return `Rate limited by plugin policy. Retry in ~${error.retryAfterSec}s.`; - } - - if (error instanceof OrbioApiError) { - const code = (error.code ?? "").toLowerCase(); - const requestIdSuffix = error.requestId ? ` (request_id=${error.requestId})` : ""; - - if (error.status === 429 || code === "rate_limit_exceeded") { - const retry = error.retryAfter ? ` Retry-After=${error.retryAfter}s.` : ""; - return `Orbio rate limit exceeded.${retry}${requestIdSuffix}`; - } - if (code === "quota_exceeded") { - return `Orbio quota exceeded for this API key/workspace.${requestIdSuffix}`; - } - if ( - code === "authentication_required" || - code === "authentication_invalid" || - code === "authentication_disabled" || - error.status === 401 - ) { - return `Orbio authentication failed. Check plugin apiKey.${requestIdSuffix}`; - } - if (code === "invalid_spec" || code === "query_too_broad" || error.status === 422) { - return `Query is invalid or too broad. Narrow filters and retry.${requestIdSuffix}`; - } - if (code === "dependency_unavailable" || error.status >= 500) { - return `Orbio dependency is temporarily unavailable. Retry shortly.${requestIdSuffix}`; - } - return `Orbio API error: ${error.detail}${requestIdSuffix}`; - } - - if (error instanceof Error) { - return `Unexpected error: ${error.message}`; - } - return "Unexpected unknown error."; -} - -function result(text: string): ToolResult { - return { content: [{ type: "text", text }] }; -} +import { PLUGIN_ID, PLUGIN_NAME } from "./constants"; +import { asJsonRecord, readConfig } from "./config"; +import { + buildIdempotencyKey, + clampLimit, + parseCommand, + resolveCommandRaw, +} from "./commands"; +import { + chooseOutputFields, + errorText, + renderExportStatusText, + renderExportText, + renderSearchText, + result, +} from "./formatters"; +import { MinuteWindowLimiter, OrbioHttpClient } from "./http"; +import { + CommandToolInput, + ExportStatusToolInput, + ExportToolInput, + SearchToolInput, +} from "./schemas"; +import type { + AccountSearchResponse, + CapabilitiesResponse, + ExportCreateResponse, + ExportStatusResponse, + JsonRecord, + OutputSpec, + SpecResponse, + ToolResult, +} from "./types"; export default function registerOrbioPlugin(api: unknown): unknown { const cfg = readConfig(api); @@ -867,20 +152,6 @@ export default function registerOrbioPlugin(api: unknown): unknown { return renderExportStatusText(payload); }; - const resolveCommandRaw = (args: CommandToolInput): string => { - const raw = args.command ?? args.command_arg ?? args.commandArg; - const commandName = args.command_name ?? args.commandName; - const rawText = toTrimmedString(raw); - if (rawText) { - return rawText; - } - const commandText = toTrimmedString(commandName); - if (commandText) { - return commandText; - } - return ""; - }; - const doCommand = async (args: CommandToolInput): Promise => { const raw = resolveCommandRaw(args); const parsed = parseCommand(raw); diff --git a/orbio-openclaw-plugin/src/schemas.ts b/orbio-openclaw-plugin/src/schemas.ts new file mode 100644 index 0000000..35f18e0 --- /dev/null +++ b/orbio-openclaw-plugin/src/schemas.ts @@ -0,0 +1,48 @@ +import { Type, type Static } from "@sinclair/typebox"; + +export const SearchToolInput = Type.Object( + { + query_text: Type.String({ minLength: 1, maxLength: 500 }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000 })), + with_contact: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export type SearchToolInput = Static; + +export const ExportToolInput = Type.Object( + { + query_text: Type.String({ minLength: 1, maxLength: 500 }), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 50000 })), + format: Type.Optional(Type.Union([Type.Literal("csv"), Type.Literal("html")])), + with_contact: Type.Optional(Type.Boolean()), + }, + { additionalProperties: false }, +); + +export type ExportToolInput = Static; + +export const ExportStatusToolInput = Type.Object( + { + export_id: Type.String({ minLength: 1, maxLength: 128 }), + }, + { additionalProperties: false }, +); + +export type ExportStatusToolInput = Static; + +export const CommandToolInput = Type.Object( + { + command: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), + command_arg: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), + commandArg: Type.Optional(Type.String({ minLength: 1, maxLength: 2000 })), + command_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + commandName: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + skill_name: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + skillName: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })), + }, + { additionalProperties: true }, +); + +export type CommandToolInput = Static; diff --git a/orbio-openclaw-plugin/src/types.ts b/orbio-openclaw-plugin/src/types.ts new file mode 100644 index 0000000..d3d398e --- /dev/null +++ b/orbio-openclaw-plugin/src/types.ts @@ -0,0 +1,83 @@ +export type JsonRecord = Record; + +export type OrbioPluginConfig = { + baseUrl: string; + apiKey: string; + workspaceId: string; + channel: string; + sendExecutionContext: boolean; + timeoutMs: number; + maxRequestsPerMinute: number; + retryCount: number; + retryBackoffMs: number; + capabilitiesTtlMs: number; + userAgent: string; +}; + +export type ToolResult = { + content: Array<{ type: "text"; text: string }>; +}; + +export type CapabilitiesResponse = { + current_snapshot: string; + snapshot_date: string; + plan_tier: string; + limits: JsonRecord; + broad_query_rules: { + require_cnae: boolean; + require_geo: string; + free_minimum: string; + }; + allowed_sort_fields: string[]; + field_allowlist: string[]; +}; + +export type AccountSearchResponse = { + request_id: string; + snapshot: string; + snapshot_date: string; + spec?: JsonRecord; + accounts: JsonRecord[]; + has_more: boolean; + next_cursor: string | null; +}; + +export type ExportCreateResponse = { + request_id: string; + snapshot: string; + snapshot_date: string; + spec?: JsonRecord; + preview_accounts: JsonRecord[]; + export: { + export_id: string; + status: string; + format: string; + row_count: number | null; + size_bytes: number | null; + expires_at: string | null; + download_url: string | null; + }; +}; + +export type ExportStatusResponse = { + export_id: string; + status: string; + format: string; + snapshot?: string; + snapshot_date?: string; + row_count: number | null; + size_bytes: number | null; + object_key?: string | null; + expires_at: string | null; + download_url: string | null; +}; + +export type SpecResponse = { + spec: JsonRecord; +}; + +export type OutputSpec = { + format: "json" | "csv" | "html"; + include_explain: boolean; + fields: string[]; +};