diff --git a/branding/apply.ts b/branding/apply.ts index f9d5f84660c..ac4616026b3 100644 --- a/branding/apply.ts +++ b/branding/apply.ts @@ -127,8 +127,9 @@ function buildReplacements(config: Branding): Replacement[] { // - @opencode-ai package names // - Directory paths like /opencode/ (but allow /bin/opencode at end of path) // - File extensions like opencode.json + // - Third-party packages like @gitlab/opencode-gitlab-auth replacements.push({ - search: /(? ${r.productName}`, }) @@ -220,33 +221,61 @@ interface FileTransform { } const FILE_TRANSFORMS: FileTransform[] = [ - // CLI UI logo with purple Q + // CLI logo.ts - update the logo export + { + pattern: "packages/opencode/src/cli/logo.ts", + transform: (content, config) => { + // Replace the logo export with qbraid logo + const leftStr = config.logo.tui.left.map((l) => `"${l}"`).join(", ") + const rightStr = config.logo.tui.right.map((l) => `"${l}"`).join(", ") + + return `export const logo = { + left: [${leftStr}], + right: [${rightStr}], +} + +export const marks = "_^~" +` + }, + }, + + // CLI UI logo() function - update to render Q in purple + // IMPORTANT: LOGO must be defined INSIDE the logo() function, not at module scope. + // The UI namespace compiles to an IIFE in the bundled binary, and module-scope + // const declarations are not accessible inside the IIFE. { pattern: "packages/opencode/src/cli/ui.ts", transform: (content, config) => { const logoStr = config.logo.cli.map((row) => ` [\`${row[0]}\`, \`${row[1]}\`],`).join("\n") - let result = content.replace(/const LOGO = \[\n[\s\S]*?\n \]/, `const LOGO = [\n${logoStr}\n ]`) - - // Replace the logo() function to render the Q in purple - // Purple ANSI: \x1b[35m (standard) or \x1b[38;2;147;112;219m (RGB for medium purple) - result = result.replace( - /export function logo\(pad\?: string\) \{[\s\S]*?\n \}/, + // Replace the logo() function with LOGO defined as a local constant inside it + const result = content.replace( + /export function logo\(pad\?: string\) \{[\s\S]*?return result\.join\(""\)\.trimEnd\(\)\n \}/, `export function logo(pad?: string) { - const PURPLE = "\\x1b[38;2;147;112;219m" // Medium purple RGB - const result = [] + const LOGO = [ +${logoStr} + ] + const result: string[] = [] + const reset = "\\x1b[0m" + const left = { + fg: Bun.color("gray", "ansi") ?? "", + shadow: "\\x1b[38;5;235m", + bg: "\\x1b[48;5;235m", + } + const PURPLE = "\\x1b[38;2;147;112;219m" // Medium purple RGB for Q + for (const row of LOGO) { if (pad) result.push(pad) - result.push(Bun.color("gray", "ansi")) + result.push(left.fg) result.push(row[0]) - result.push("\\x1b[0m") - result.push(PURPLE) // Purple tint for the Q + result.push(reset) + result.push(PURPLE) // Purple for the Q result.push(row[1]) - result.push("\\x1b[0m") + result.push(reset) result.push(EOL) } return result.join("").trimEnd() - }`, + }` ) return result @@ -313,14 +342,14 @@ const PURPLE = RGBA.fromHex("#9370DB")`, }, }, - // Model provider configuration (remove Zen, add qBraid) - // This replaces the entire models.ts to use embedded models + // Model provider configuration + // When exclusive=true: replace get() to return only embedded models (original behavior) + // When default=true, exclusive=false: prepend branded models to the models.dev response { pattern: "packages/opencode/src/provider/models.ts", transform: async (content, config) => { - if (!config.models?.exclusive || !config.models?.source) return content + if (!config.models?.source) return content - // Read the models JSON const modelsPath = path.join(BRAND_DIR, config.models.source.replace("./", "")) const modelsFile = Bun.file(modelsPath) if (!(await modelsFile.exists())) { @@ -329,18 +358,68 @@ const PURPLE = RGBA.fromHex("#9370DB")`, } const modelsJson = await modelsFile.json() - // Remove schema and comment keys delete modelsJson.$schema delete modelsJson._comment - // Replace the get() function with one that returns embedded models directly - return content.replace( + if (config.models.exclusive) { + // Exclusive mode: only branded models, no models.dev fetch + const result = content.replace( + /export async function get\(\) \{[\s\S]*?\n \}/, + `export async function get() { + // Branding: embedded models (exclusive mode, no external fetch) + return ${JSON.stringify(modelsJson)} as Record + }`, + ) + if (result === content) { + throw new Error("models.ts branding transform failed: get() regex did not match (exclusive mode)") + } + return result + } + + // Default mode: prepend branded models, then merge models.dev data + // This ensures qBraid models appear first and are the defaults, + // while all upstream providers (Anthropic, OpenAI, Copilot, Codex, etc.) + // remain available. + // + // The replacement body references variables from the original models.ts scope: + // - filepath (const, path to cache file) + // - data (Bun macro import for bundled snapshot) + // - refresh() (background fetch to update cache) + const brandedModelsStr = JSON.stringify(modelsJson) + const result = content.replace( /export async function get\(\) \{[\s\S]*?\n \}/, `export async function get() { - // Branding: embedded models (no external fetch) - return ${JSON.stringify(modelsJson)} as Record + // Branding: qBraid models prepended as defaults + const branded = ${brandedModelsStr} as Record + + // Kick off background cache refresh + refresh() + + // Try cached models first, then macro bundle, then live fetch + let upstream: Record = {} + try { + const file = Bun.file(filepath) + const cached = await file.json().catch(() => undefined) + if (cached) { + upstream = cached as Record + } else if (typeof data === "function") { + upstream = JSON.parse(await data()) as Record + } else { + const json = await fetch("https://models.dev/api.json").then((x) => x.text()) + upstream = JSON.parse(json) as Record + } + } catch { + // All upstream sources failed — branded models only + } + + // Merge: branded providers win on conflict + return { ...upstream, ...branded } }`, ) + if (result === content) { + throw new Error("models.ts branding transform failed: get() regex did not match. Has the upstream function signature changed?") + } + return result }, }, @@ -357,38 +436,43 @@ const PURPLE = RGBA.fromHex("#9370DB")`, }, }, - // Remove builtin plugins (they don't exist for qBraid branding) + // Remove builtin plugins only in exclusive mode. + // In default mode (exclusive=false), keep plugins so Anthropic auth, + // Codex OAuth, Copilot device code, etc. continue to work. { pattern: "packages/opencode/src/plugin/index.ts", transform: (content, config) => { if (!config.models?.exclusive) return content - // Clear the BUILTIN array - these npm packages don't exist for branded versions - // Match the array with its contents across potential newlines - return content.replace( + const result = content.replace( /const BUILTIN = \["[^"]*"(?:,\s*"[^"]*")*\]/, "const BUILTIN: string[] = [] // Cleared by branding - no external plugins", ) + if (result === content) { + throw new Error("plugin/index.ts branding transform failed: BUILTIN regex did not match") + } + return result }, }, - // Remove custom loaders for providers that don't exist in exclusive models + // Remove custom loaders only in exclusive mode. + // In default mode, keep all loaders — they're needed for native provider support + // (Anthropic, OpenAI, Bedrock, Copilot, etc.). { pattern: "packages/opencode/src/provider/provider.ts", transform: (content, config) => { if (!config.models?.exclusive) return content - // Comment out all custom loaders when in exclusive mode - // This prevents "Provider does not exist in model list" errors - // Match the CUSTOM_LOADERS object definition and replace with empty object - // The object starts at "const CUSTOM_LOADERS: Record = {" - // and ends with " }" before "export const Model" - return content.replace( + const result = content.replace( /const CUSTOM_LOADERS: Record = \{[\s\S]*?\n \}(?=\n\n export const Model)/, `const CUSTOM_LOADERS: Record = { // All custom loaders removed by branding (exclusive mode) }`, ) + if (result === content) { + throw new Error("provider.ts branding transform failed: CUSTOM_LOADERS regex did not match") + } + return result }, }, diff --git a/branding/qbraid/brand.json b/branding/qbraid/brand.json index d99336614e0..31745d1a96a 100644 --- a/branding/qbraid/brand.json +++ b/branding/qbraid/brand.json @@ -29,7 +29,7 @@ } }, "models": { - "exclusive": true, + "exclusive": false, "removeProviders": ["opencode"], "source": "./models.json" }, diff --git a/branding/schema.ts b/branding/schema.ts index abfac367857..47dcd6f5842 100644 --- a/branding/schema.ts +++ b/branding/schema.ts @@ -45,7 +45,8 @@ export const ModelsSchema = z.object({ .optional(), /** Provider IDs to completely remove */ removeProviders: z.array(z.string()).optional(), - /** If true, only use the providers defined in this config */ + /** If true, only use the providers defined in this config (locks out all others). + * When false/unset, branded models are prepended as defaults but upstream providers remain available. */ exclusive: z.boolean().optional(), }) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7442037604b..f3fc7392b8f 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -36,6 +36,8 @@ import { ArgsProvider, useArgs, type Args } from "./context/args" import open from "open" import { writeHeapSnapshot } from "v8" import { PromptRefProvider, usePromptRef } from "./context/prompt" +import { DialogTelemetryConsent, KV_TELEMETRY_CONSENT_SHOWN, KV_TELEMETRY_ENABLED } from "@tui/component/dialog-telemetry-consent" +import { Telemetry } from "@/telemetry" async function getTerminalBackgroundColor(): Promise<"dark" | "light"> { // can't set raw mode if not a TTY @@ -272,6 +274,32 @@ function App() { } }) + // --- First-run telemetry consent dialog --- + // Fires once when sync is complete and user hasn't seen the consent dialog yet. + // Must fire *before* the provider connect dialog so consent is captured first. + let consentShown = false + createEffect( + on( + () => sync.status === "complete" && kv.get(KV_TELEMETRY_CONSENT_SHOWN) === undefined, + (needsConsent, prev) => { + if (!needsConsent || prev || consentShown) return + consentShown = true + + // Load any existing consent value into the telemetry module + const existing = kv.get(KV_TELEMETRY_ENABLED) + if (existing !== undefined) { + Telemetry.loadConsent(existing === true) + kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) + return + } + + // Default to "paid" tier (gives genuine opt-out) until we can + // determine the actual tier from the auth/consent service. + DialogTelemetryConsent.show(dialog, "paid") + }, + ), + ) + createEffect( on( () => sync.status === "complete" && sync.data.provider.length === 0, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx new file mode 100644 index 00000000000..7f585a05e3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-telemetry-consent.tsx @@ -0,0 +1,141 @@ +/** + * First-run telemetry consent dialog. + * + * - Free-tier users: informational only — telemetry is required, single "I Understand" button. + * - Paid/unknown users: genuine opt-in with "Enable" / "No Thanks" buttons. + * + * The consent choice is persisted to the KV store and loaded into the + * Telemetry consent module on startup. + */ + +import { TextAttributes } from "@opentui/core" +import { useTheme } from "@tui/context/theme" +import { useDialog, type DialogContext } from "@tui/ui/dialog" +import { useKV } from "@tui/context/kv" +import { createStore } from "solid-js/store" +import { For, Show } from "solid-js" +import { useKeyboard } from "@opentui/solid" +import { Telemetry } from "@/telemetry" + +/** KV keys used by the consent dialog */ +export const KV_TELEMETRY_CONSENT_SHOWN = "telemetry_consent_shown" +export const KV_TELEMETRY_ENABLED = "telemetry_enabled" + +type Tier = "free" | "paid" + +export type DialogTelemetryConsentProps = { + tier: Tier + onResult: (accepted: boolean) => void +} + +export function DialogTelemetryConsent(props: DialogTelemetryConsentProps) { + const dialog = useDialog() + const { theme } = useTheme() + const kv = useKV() + + const isFree = () => props.tier === "free" + + const title = () => isFree() ? "Usage Data Collection" : "Enable Usage Telemetry?" + + const message = () => + isFree() + ? "CodeQ collects anonymous usage telemetry to improve the product.\n" + + "This includes session metrics (token counts, tool usage, latency)\n" + + "and is required for free-tier accounts. No source code or secrets\n" + + "are collected. You can review our privacy policy at qbraid.com/privacy." + : "CodeQ can collect anonymous usage telemetry to help us improve\n" + + "the product. This includes session metrics like token counts,\n" + + "tool usage, and latency. No source code or secrets are collected.\n\n" + + "You can change this anytime in your config:\n" + + ' qbraid.telemetry.enabled: true | false' + + // Free tier: single button. Paid tier: two-button confirm/decline. + const buttons = () => + isFree() ? ["understand"] as const : ["decline", "enable"] as const + + const [store, setStore] = createStore({ + active: isFree() ? "understand" : "enable", + }) + + const labels: Record = { + understand: "I Understand", + enable: "Enable", + decline: "No Thanks", + } + + let handled = false + const handleSelect = (key: string) => { + if (handled) return + handled = true + const accepted = key === "understand" || key === "enable" + kv.set(KV_TELEMETRY_CONSENT_SHOWN, true) + kv.set(KV_TELEMETRY_ENABLED, accepted) + Telemetry.setConsent(accepted) + props.onResult(accepted) + dialog.clear() + } + + useKeyboard((evt) => { + if (evt.name === "return") { + handleSelect(store.active) + return + } + + if (!isFree() && (evt.name === "left" || evt.name === "right")) { + setStore("active", store.active === "enable" ? "decline" : "enable") + } + }) + + return ( + + + + {title()} + + + esc + + + + {message()} + + + + {(key) => ( + handleSelect(key)} + > + + {labels[key]} + + + )} + + + + ) +} + +/** + * Show the consent dialog and return a promise that resolves with the user's choice. + */ +DialogTelemetryConsent.show = ( + dialog: DialogContext, + tier: Tier, +): Promise => { + return new Promise((resolve) => { + dialog.replace( + () => ( + resolve(accepted)} + /> + ), + // Esc handler: free tier = accept (required), paid tier = decline + () => resolve(tier === "free"), + ) + }) +} diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 6dd0592d51e..c05bf410a51 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -77,18 +77,18 @@ export namespace Config { for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${key}/.well-known/opencode` }) - const response = await fetch(`${key}/.well-known/opencode`) + log.debug("fetching remote config", { url: `${key}/.well-known/codeq` }) + const response = await fetch(`${key}/.well-known/codeq`) if (!response.ok) { throw new Error(`failed to fetch remote config from ${key}: ${response.status}`) } const wellknown = (await response.json()) as any const remoteConfig = wellknown.config ?? {} // Add $schema to prevent load() from trying to write back to a non-existent file - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" + if (!remoteConfig.$schema) remoteConfig.$schema = "https://codeq.ai/config.json" result = mergeConfigConcatArrays( result, - await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`), + await load(JSON.stringify(remoteConfig), `${key}/.well-known/codeq`), ) log.debug("loaded remote config from well-known", { url: key }) } @@ -132,7 +132,7 @@ export namespace Config { // Always scan ~/.opencode/ (user home directory) ...(await Array.fromAsync( Filesystem.up({ - targets: [".opencode"], + targets: [".codeq"], start: Global.Path.home, stop: Global.Path.home, }), @@ -148,7 +148,7 @@ export namespace Config { const deps = [] for (const dir of unique(directories)) { - if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { + if (dir.endsWith(".codeq") || dir === Flag.OPENCODE_CONFIG_DIR) { for (const file of ["opencode.jsonc", "opencode.json"]) { log.debug(`loading config from ${path.join(dir, file)}`) result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file))) @@ -473,7 +473,7 @@ export namespace Config { * * @example * getPluginName("file:///path/to/plugin/foo.js") // "foo" - * getPluginName("oh-my-opencode@2.4.3") // "oh-my-opencode" + * getPluginName("oh-my-codeq@2.4.3") // "oh-my-codeq" * getPluginName("@scope/pkg@1.0.0") // "@scope/pkg" */ export function getPluginName(plugin: string): string { @@ -500,11 +500,11 @@ export namespace Config { */ export function deduplicatePlugins(plugins: string[]): string[] { // seenNames: canonical plugin names for duplicate detection - // e.g., "oh-my-opencode", "@scope/pkg" + // e.g., "oh-my-codeq", "@scope/pkg" const seenNames = new Set() // uniqueSpecifiers: full plugin specifiers to return - // e.g., "oh-my-opencode@2.4.3", "file:///path/to/plugin.js" + // e.g., "oh-my-codeq@2.4.3", "file:///path/to/plugin.js" const uniqueSpecifiers: string[] = [] for (const specifier of plugins.toReversed()) { @@ -1006,7 +1006,7 @@ export namespace Config { keybinds: Keybinds.optional().describe("Custom keybind configurations"), logLevel: Log.Level.optional().describe("Log level"), tui: TUI.optional().describe("TUI specific settings"), - server: Server.optional().describe("Server configuration for opencode serve and web commands"), + server: Server.optional().describe("Server configuration for codeq serve and web commands"), command: z .record(z.string(), Command) .optional() @@ -1078,7 +1078,7 @@ export namespace Config { }) .catchall(Agent) .optional() - .describe("Agent configuration, see https://opencode.ai/docs/agents"), + .describe("Agent configuration, see https://codeq.ai/docs/agents"), provider: z .record(z.string(), Provider) .optional() @@ -1183,6 +1183,54 @@ export namespace Config { .describe("Timeout in milliseconds for model context protocol (MCP) requests"), }) .optional(), + // qBraid-specific configuration (CodeQ customizations) + // This section is ignored by upstream codeq and contains qBraid-specific features + qbraid: z + .object({ + telemetry: z + .object({ + enabled: z + .union([z.boolean(), z.literal("tier-default")]) + .optional() + .describe( + "Enable telemetry collection. 'tier-default' uses tier-based defaults (free=enabled, paid=disabled). Default: 'tier-default'", + ), + endpoint: z + .string() + .url() + .optional() + .describe("Telemetry service endpoint. Default: https://telemetry.qbraid.com"), + dataLevel: z + .enum(["full", "metrics-only"]) + .optional() + .describe( + "Level of data to collect. 'full' includes message content, 'metrics-only' only collects usage stats. Default: 'full'", + ), + excludePatterns: z + .array(z.string()) + .optional() + .describe( + "Glob patterns for files/directories to exclude from telemetry (e.g., ['**/secrets/**', '**/.env*'])", + ), + batchSize: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Number of turns to batch before uploading. Default: 5"), + flushIntervalMs: z + .number() + .int() + .min(1000) + .optional() + .describe("Maximum time (ms) to wait before flushing buffered data. Default: 30000"), + }) + .optional() + .describe("Telemetry settings for CodeQ session data collection"), + }) + .optional() + .describe("qBraid-specific configuration for CodeQ"), }) .strict() .meta({ @@ -1302,9 +1350,9 @@ export namespace Config { const parsed = Info.safeParse(data) if (parsed.success) { if (!parsed.data.$schema) { - parsed.data.$schema = "https://opencode.ai/config.json" + parsed.data.$schema = "https://codeq.ai/config.json" // Write the $schema to the original text to preserve variables like {env:VAR} - const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://codeq.ai/config.json",') await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b11058b3405..33c6cad2373 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -51,6 +51,9 @@ export namespace Flag { export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"] export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"] + // qBraid / CodeQ flags + export const CODEQ_DISABLE_TELEMETRY = truthy("CODEQ_DISABLE_TELEMETRY") + function number(key: string) { const value = process.env[key] if (!value) return undefined diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts index efdcaba9909..f88a4e8fc13 100644 --- a/packages/opencode/src/project/bootstrap.ts +++ b/packages/opencode/src/project/bootstrap.ts @@ -13,6 +13,7 @@ import { Log } from "@/util/log" import { ShareNext } from "@/share/share-next" import { Snapshot } from "../snapshot" import { Truncate } from "../tool/truncation" +import { Telemetry } from "@/telemetry" export async function InstanceBootstrap() { Log.Default.info("bootstrapping", { directory: Instance.directory }) @@ -27,6 +28,12 @@ export async function InstanceBootstrap() { Snapshot.init() Truncate.init() + // Initialize qBraid telemetry (CodeQ-specific) + // This is a no-op if telemetry is disabled by consent or config + await Telemetry.initIntegration().catch((error) => { + Log.Default.warn("telemetry initialization failed", { error }) + }) + Bus.subscribe(Command.Event.Executed, async (payload) => { if (payload.properties.name === Command.Default.INIT) { await Project.setInitialized(Instance.project.id) diff --git a/packages/opencode/src/quantum/client.ts b/packages/opencode/src/quantum/client.ts new file mode 100644 index 00000000000..2be977dcb4b --- /dev/null +++ b/packages/opencode/src/quantum/client.ts @@ -0,0 +1,301 @@ +/** + * qBraid Quantum API Client + * + * TypeScript HTTP client for the qBraid quantum runtime API. + * Replaces the Python SDK dependency for device listing, job submission, + * and result retrieval — keeping everything in-process. + */ + +import { Log } from "../util/log" +import { Auth } from "../auth" +import z from "zod" +import path from "path" +import os from "os" +import fs from "fs/promises" + +const log = Log.create({ service: "quantum:client" }) + +const DEFAULT_API_URL = "https://api-v2.qbraid.com/api/v1" +const MAX_ERROR_BODY = 500 + +// --- Zod schemas for API response validation --- + +const QuantumDeviceSchema = z.object({ + id: z.string(), + name: z.string(), + vendor: z.string(), + provider: z.string(), + type: z.string().default("unknown"), + status: z.string(), + qubits: z.number().default(0), + paradigm: z.string().default("unknown"), + pricing: z.object({ + perShot: z.number().optional(), + perTask: z.number().optional(), + perMinute: z.number().optional(), + }).optional(), +}) + +const QuantumJobSchema = z.object({ + id: z.string(), + device: z.string(), + status: z.string(), + shots: z.number(), + createdAt: z.string(), + endedAt: z.string().optional(), + cost: z.number().optional(), +}) + +const JobResultSchema = z.object({ + jobId: z.string().optional(), + status: z.string().optional(), + measurements: z.record(z.string(), z.number()).optional(), + success: z.boolean().optional(), +}) + +export type QuantumDevice = z.infer +export type QuantumJob = z.infer +export type JobResult = z.infer + +export interface CostEstimate { + deviceId: string + shots: number + estimatedCredits: number + pricingAvailable: boolean + breakdown: { + perShot: number + perTask: number + } +} + +// --- Auth resolution with short-lived cache --- + +let cachedAuth: { apiKey: string; baseUrl: string; expiry: number } | null = null + +/** + * Resolve the qBraid API key and base URL. + * Priority: env var > config provider > ~/.qbraid/qbraidrc + * Cached for 5 seconds to avoid repeated disk reads within a single tool call. + */ +async function resolveAuth(): Promise<{ apiKey: string; baseUrl: string } | null> { + if (cachedAuth && Date.now() < cachedAuth.expiry) { + return { apiKey: cachedAuth.apiKey, baseUrl: cachedAuth.baseUrl } + } + + let apiKey: string | undefined + let baseUrl = DEFAULT_API_URL + + // 1. Environment variable + if (process.env.QBRAID_API_KEY) { + apiKey = process.env.QBRAID_API_KEY + } + + // 2. CodeQ auth store + if (!apiKey) { + try { + const authData = await Auth.all() + for (const [key, value] of Object.entries(authData)) { + if (key.includes("qbraid")) { + if (value.type === "wellknown" && value.token) { + apiKey = value.token + break + } + if (value.type === "api" && value.key) { + apiKey = value.key + break + } + } + } + } catch { + // auth not available + } + } + + // 3. ~/.qbraid/qbraidrc + if (!apiKey) { + try { + const rcPath = path.join(os.homedir(), ".qbraid", "qbraidrc") + const content = await fs.readFile(rcPath, "utf-8") + for (const line of content.split("\n")) { + const keyMatch = line.trim().match(/^api-key\s*=\s*(.+)/) + if (keyMatch) { + apiKey = keyMatch[1].trim() + break + } + const urlMatch = line.trim().match(/^url\s*=\s*(.+)/) + if (urlMatch) baseUrl = urlMatch[1].trim() + } + } catch { + // qbraidrc not available + } + } + + if (process.env.QBRAID_API_BASE_URL) { + baseUrl = process.env.QBRAID_API_BASE_URL + } + + if (!apiKey) return null + + cachedAuth = { apiKey, baseUrl, expiry: Date.now() + 5_000 } + return { apiKey, baseUrl } +} + +// --- HTTP request helper --- + +async function request( + method: string, + endpoint: string, + body?: unknown, + signal?: AbortSignal, +): Promise { + const auth = await resolveAuth() + if (!auth) throw new Error("No qBraid API key found. Run `codeq /connect` to set up qBraid.") + + const url = `${auth.baseUrl}${endpoint}` + log.debug("quantum api request", { method, url }) + + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + "api-key": auth.apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + signal: signal ?? AbortSignal.timeout(30_000), + }) + + if (!response.ok) { + const text = (await response.text().catch(() => "")).slice(0, MAX_ERROR_BODY) + throw new Error(`qBraid API ${method} ${endpoint} failed (${response.status}): ${text}`) + } + + return response.json() as Promise +} + +// --- API functions --- + +/** + * List available quantum devices with optional filters. + */ +export async function listDevices( + filters?: { status?: string; provider?: string }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams() + if (filters?.status) params.set("status", filters.status) + if (filters?.provider) params.set("provider", filters.provider) + + const query = params.toString() + const endpoint = `/quantum/devices${query ? `?${query}` : ""}` + const data = await request("GET", endpoint, undefined, signal) + + const arr = Array.isArray(data) + ? data + : (data as { devices?: unknown[] }).devices ?? [] + + return arr.map((d: unknown) => QuantumDeviceSchema.parse(d)) +} + +/** + * Get details for a specific device. + */ +export async function getDevice(deviceId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/devices/${encodeURIComponent(deviceId)}`, undefined, signal) + return QuantumDeviceSchema.parse(data) +} + +/** + * Estimate the cost of running a job on a device. + * NOTE: This is a client-side estimate based on device pricing metadata. + * If pricing is unavailable the estimate is 0 — check `pricingAvailable`. + */ +export async function estimateCost(deviceId: string, shots: number, signal?: AbortSignal): Promise { + const device = await getDevice(deviceId, signal) + const pricingAvailable = device.pricing != null + const perShot = device.pricing?.perShot ?? 0 + const perTask = device.pricing?.perTask ?? 0 + const estimatedCredits = perShot * shots + perTask + + return { + deviceId, + shots, + estimatedCredits, + pricingAvailable, + breakdown: { perShot: perShot * shots, perTask }, + } +} + +/** + * Submit a QASM circuit to a device. + */ +export async function submitJob( + params: { deviceId: string; qasm: string; shots: number }, + signal?: AbortSignal, +): Promise { + const data = await request("POST", "/quantum/jobs", { + device: params.deviceId, + openQasm: params.qasm, + shots: params.shots, + }, signal) + return QuantumJobSchema.parse(data) +} + +/** + * Get the status and metadata of a job. + */ +export async function getJob(jobId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}`, undefined, signal) + return QuantumJobSchema.parse(data) +} + +/** + * Get the results of a completed job. + */ +export async function getResult(jobId: string, signal?: AbortSignal): Promise { + const data = await request("GET", `/quantum/jobs/${encodeURIComponent(jobId)}/result`, undefined, signal) + return JobResultSchema.parse(data) +} + +/** + * Cancel a running or queued job. + */ +export async function cancelJob(jobId: string, signal?: AbortSignal): Promise<{ success: boolean }> { + return request<{ success: boolean }>("POST", `/quantum/jobs/${encodeURIComponent(jobId)}/cancel`, undefined, signal) +} + +/** + * List recent jobs with optional filters. + */ +export async function listJobs( + filters?: { status?: string; limit?: number }, + signal?: AbortSignal, +): Promise { + const params = new URLSearchParams() + if (filters?.status) params.set("status", filters.status) + if (filters?.limit) params.set("limit", String(filters.limit)) + + const query = params.toString() + const endpoint = `/quantum/jobs${query ? `?${query}` : ""}` + const data = await request("GET", endpoint, undefined, signal) + + const arr = Array.isArray(data) + ? data + : (data as { jobs?: unknown[] }).jobs ?? [] + + return arr.map((j: unknown) => QuantumJobSchema.parse(j)) +} + +/** + * Get account credit balance. + */ +export async function getCredits(signal?: AbortSignal): Promise<{ balance: number }> { + return request<{ balance: number }>("GET", "/user/credits", undefined, signal) +} + +/** + * Check if qBraid API access is configured. + */ +export async function isConfigured(): Promise { + const auth = await resolveAuth() + return auth !== null +} diff --git a/packages/opencode/src/quantum/index.ts b/packages/opencode/src/quantum/index.ts new file mode 100644 index 00000000000..6c94f21d3fe --- /dev/null +++ b/packages/opencode/src/quantum/index.ts @@ -0,0 +1,13 @@ +/** + * Quantum Module + * + * Native quantum computing integration for CodeQ. + * Provides in-process tools for device management, job submission, + * cost estimation, and result retrieval via the qBraid API. + * + * This replaces the pod_mcp MCP server for core quantum workflows, + * giving CodeQ tight integration with auth, permissions, and telemetry. + */ + +export { QUANTUM_TOOLS } from "./tools" +export * as QuantumClient from "./client" diff --git a/packages/opencode/src/quantum/tools.ts b/packages/opencode/src/quantum/tools.ts new file mode 100644 index 00000000000..42894e7db96 --- /dev/null +++ b/packages/opencode/src/quantum/tools.ts @@ -0,0 +1,326 @@ +/** + * Quantum Tools + * + * Native CodeQ tool definitions for quantum computing operations. + * These replace the pod_mcp MCP server for core quantum workflows, + * running in-process with access to CodeQ's auth, permissions, and telemetry. + */ + +import z from "zod" +import { Tool } from "../tool/tool" +import * as client from "./client" + +// ============================================================================ +// quantum_devices — List available quantum devices +// ============================================================================ + +export const QuantumDevicesTool = Tool.define("quantum_devices", { + description: [ + "List available quantum computing devices (QPUs and simulators) from qBraid.", + "Returns device ID, name, vendor, status, qubit count, and pricing.", + "Use the status and provider filters to narrow results.", + "Device IDs from this list are needed for job submission and cost estimation.", + ].join("\n"), + parameters: z.object({ + status: z.enum(["online", "offline", "all"]).optional() + .describe("Filter devices by status. Defaults to all."), + provider: z.string().optional() + .describe("Filter by provider (e.g., 'ibm', 'aws', 'ionq', 'rigetti')."), + }), + async execute(params, ctx) { + const devices = await client.listDevices({ + status: params.status === "all" ? undefined : params.status, + provider: params.provider, + }, ctx.abort) + + if (devices.length === 0) { + return { + title: "No devices found", + metadata: { count: 0 }, + output: "No quantum devices match the given filters.", + } + } + + const lines = devices.map((d) => { + const pricing = d.pricing + ? `${d.pricing.perShot ?? 0}/shot + ${d.pricing.perTask ?? 0}/task credits` + : "N/A" + return `${d.id} | ${d.name} | ${d.vendor} | ${d.status} | ${d.qubits}q | ${d.paradigm} | ${pricing}` + }) + + const header = "ID | Name | Vendor | Status | Qubits | Paradigm | Pricing" + const separator = "-".repeat(80) + const output = [header, separator, ...lines].join("\n") + + return { + title: `${devices.length} quantum devices`, + metadata: { count: devices.length }, + output, + } + }, +}) + +// ============================================================================ +// quantum_estimate_cost — Estimate cost before submitting a job +// ============================================================================ + +export const QuantumEstimateCostTool = Tool.define("quantum_estimate_cost", { + description: [ + "Estimate the cost in qBraid credits for running a quantum job on a specific device.", + "Use this BEFORE submitting a job to check the cost and user's credit balance.", + "Returns the estimated cost breakdown and the user's current credit balance.", + ].join("\n"), + parameters: z.object({ + device_id: z.string().describe("The quantum device ID to estimate cost for."), + shots: z.number().int().min(1).default(1024) + .describe("Number of measurement shots."), + }), + async execute(params, ctx) { + const [estimate, credits] = await Promise.all([ + client.estimateCost(params.device_id, params.shots, ctx.abort), + client.getCredits(ctx.abort).catch(() => ({ balance: -1 })), + ]) + + const balanceStr = credits.balance >= 0 ? `${credits.balance}` : "unknown" + const sufficient = credits.balance >= 0 + ? (credits.balance >= estimate.estimatedCredits ? "Yes" : "NO — insufficient credits") + : "unknown" + + const pricingNote = estimate.pricingAvailable + ? "" + : "\nWARNING: Pricing data unavailable for this device. Actual cost may differ." + + const output = [ + `Device: ${params.device_id}`, + `Shots: ${params.shots}`, + `Estimated cost: ${estimate.estimatedCredits.toFixed(4)} credits`, + ` Per-shot: ${estimate.breakdown.perShot.toFixed(4)}`, + ` Per-task: ${estimate.breakdown.perTask.toFixed(4)}`, + `Current balance: ${balanceStr} credits`, + `Sufficient funds: ${sufficient}`, + pricingNote, + ].filter(Boolean).join("\n") + + return { + title: `Cost estimate: ${estimate.estimatedCredits.toFixed(4)} credits`, + metadata: { cost: estimate.estimatedCredits, balance: credits.balance, pricingAvailable: estimate.pricingAvailable }, + output, + } + }, +}) + +// ============================================================================ +// quantum_submit_job — Submit a QASM circuit with cost approval +// ============================================================================ + +export const QuantumSubmitJobTool = Tool.define("quantum_submit_job", { + description: [ + "Submit a quantum circuit (OpenQASM format) to a device for execution.", + "IMPORTANT: This tool uses the native permission system to get user approval", + "for the estimated cost before submitting. The user will see a cost estimate", + "and must explicitly approve the submission.", + "Use quantum_estimate_cost first to check costs, then call this to submit.", + ].join("\n"), + parameters: z.object({ + device_id: z.string().describe("The quantum device ID to run on."), + qasm: z.string().describe("The OpenQASM 2.0 or 3.0 circuit code."), + shots: z.number().int().min(1).default(1024).describe("Number of measurement shots."), + }), + async execute(params, ctx) { + // Estimate cost first + const estimate = await client.estimateCost(params.device_id, params.shots, ctx.abort) + + const costNote = estimate.pricingAvailable + ? `~${estimate.estimatedCredits.toFixed(4)} credits` + : "unknown (pricing unavailable)" + + // Use CodeQ's native permission system for cost approval + await ctx.ask({ + permission: "quantum_submit", + patterns: [params.device_id], + always: [], + metadata: { + device: params.device_id, + shots: params.shots, + cost: estimate.estimatedCredits, + pricingAvailable: estimate.pricingAvailable, + summary: `Submit quantum job to ${params.device_id} (${params.shots} shots, ${costNote})`, + }, + }) + + const job = await client.submitJob({ + deviceId: params.device_id, + qasm: params.qasm, + shots: params.shots, + }, ctx.abort) + + return { + title: `Job submitted: ${job.id}`, + metadata: { jobId: job.id, device: params.device_id }, + output: [ + `Job ID: ${job.id}`, + `Device: ${job.device}`, + `Status: ${job.status}`, + `Shots: ${job.shots}`, + `Created: ${job.createdAt}`, + ``, + `Use quantum_get_result with this job ID to retrieve results when complete.`, + ].join("\n"), + } + }, +}) + +// ============================================================================ +// quantum_get_result — Retrieve results from a submitted job +// ============================================================================ + +export const QuantumGetResultTool = Tool.define("quantum_get_result", { + description: [ + "Retrieve the measurement results from a previously submitted quantum job.", + "If the job is still running, returns the current status.", + "Measurement results are returned as a dictionary of bitstring counts.", + ].join("\n"), + parameters: z.object({ + job_id: z.string().describe("The quantum job ID to retrieve results for."), + }), + async execute(params, ctx) { + const job = await client.getJob(params.job_id, ctx.abort) + const status = job.status.toUpperCase() + + if (status !== "COMPLETED") { + return { + title: `Job ${params.job_id}: ${job.status}`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + status === "QUEUED" || status === "RUNNING" + ? "The job is still processing. Try again in a moment." + : `The job ended with status: ${job.status}`, + ].join("\n"), + } + } + + let result: client.JobResult + try { + result = await client.getResult(params.job_id, ctx.abort) + } catch (error) { + return { + title: `Job ${params.job_id}: completed (results unavailable)`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + `Error retrieving results: ${error instanceof Error ? error.message : String(error)}`, + ].join("\n"), + } + } + + const measurements = result.measurements + ? Object.entries(result.measurements) + .sort(([, a], [, b]) => b - a) + .map(([state, count]) => ` ${state}: ${count}`) + .join("\n") + : "No measurement data available" + + return { + title: `Results: ${params.job_id}`, + metadata: { jobId: params.job_id, status: job.status }, + output: [ + `Job ID: ${params.job_id}`, + `Status: ${job.status}`, + `Device: ${job.device}`, + `Cost: ${job.cost ?? "N/A"} credits`, + ``, + `Measurement results:`, + measurements, + ].join("\n"), + } + }, +}) + +// ============================================================================ +// quantum_cancel_job — Cancel a running or queued job +// ============================================================================ + +export const QuantumCancelJobTool = Tool.define("quantum_cancel_job", { + description: "Cancel a queued or running quantum job. Requires user confirmation. Returns whether the cancellation succeeded.", + parameters: z.object({ + job_id: z.string().describe("The quantum job ID to cancel."), + }), + async execute(params, ctx) { + // Cancellation is destructive — require user approval + await ctx.ask({ + permission: "quantum_cancel", + patterns: [params.job_id], + always: [], + metadata: { + jobId: params.job_id, + summary: `Cancel quantum job ${params.job_id}`, + }, + }) + + const result = await client.cancelJob(params.job_id, ctx.abort) + + return { + title: result.success ? `Cancelled: ${params.job_id}` : `Cancel failed: ${params.job_id}`, + metadata: { success: result.success }, + output: result.success + ? `Successfully cancelled job ${params.job_id}.` + : `Failed to cancel job ${params.job_id}. It may have already completed or been cancelled.`, + } + }, +}) + +// ============================================================================ +// quantum_list_jobs — List recent quantum jobs +// ============================================================================ + +export const QuantumListJobsTool = Tool.define("quantum_list_jobs", { + description: "List recent quantum jobs with optional status filter. Shows job IDs, devices, status, and costs.", + parameters: z.object({ + status: z.string().optional().describe("Filter by job status (e.g., 'COMPLETED', 'RUNNING', 'QUEUED', 'FAILED')."), + limit: z.number().int().min(1).max(100).default(10).describe("Maximum number of jobs to return."), + }), + async execute(params, ctx) { + const jobs = await client.listJobs({ + status: params.status, + limit: params.limit, + }, ctx.abort) + + if (jobs.length === 0) { + return { + title: "No jobs found", + metadata: { count: 0 }, + output: "No quantum jobs match the given filters.", + } + } + + const lines = jobs.map((j) => + `${j.id} | ${j.device} | ${j.status} | ${j.shots} shots | ${j.cost ?? "N/A"} credits | ${j.createdAt}`, + ) + + const header = "ID | Device | Status | Shots | Cost | Created" + const output = [header, "-".repeat(80), ...lines].join("\n") + + return { + title: `${jobs.length} quantum jobs`, + metadata: { count: jobs.length }, + output, + } + }, +}) + +/** + * All quantum tools for registration in the tool registry. + */ +export const QUANTUM_TOOLS = [ + QuantumDevicesTool, + QuantumEstimateCostTool, + QuantumSubmitJobTool, + QuantumGetResultTool, + QuantumCancelJobTool, + QuantumListJobsTool, +] diff --git a/packages/opencode/src/telemetry/collector.ts b/packages/opencode/src/telemetry/collector.ts new file mode 100644 index 00000000000..d374b480838 --- /dev/null +++ b/packages/opencode/src/telemetry/collector.ts @@ -0,0 +1,383 @@ +/** + * Telemetry Collector + * + * Collects session telemetry per-instance. Uses Instance.state so each project + * context gets its own collector that is disposed when the instance shuts down. + */ + +import { Log } from "../util/log" +import { Config } from "../config/config" +import { Instance } from "../project/instance" +import { createSanitizer } from "./sanitizer" +import { createSignalTracker, type SignalTracker } from "./signals" +import { createUploader, type TelemetryUploader } from "./uploader" +import { getConsentStatus, getTelemetryEndpoint } from "./consent" +import type { + Environment, + FileChangeData, + ModelUsage, + SessionMetrics, + TelemetrySession, + TelemetryTurn, + ToolCallData, + UserTier, +} from "./types" + +const log = Log.create({ service: "telemetry:collector" }) + +const CODEQ_VERSION = process.env.npm_package_version ?? "0.0.0" + +/** + * State for tracking the current session + */ +interface SessionState { + sessionId: string + startedAt: Date + userId: string + organizationId: string + environment: Environment + metrics: SessionMetrics + modelUsage: ModelUsage + currentTurnIndex: number + currentTurn: Partial | null + /** Tracks which assistant messageIDs have already been finalized */ + finalizedMessages: Set +} + +/** + * Telemetry collector — one per Instance (project context) + */ +export class TelemetryCollector { + private uploader: TelemetryUploader | null = null + private signalTracker: SignalTracker + private sanitizer: ReturnType + private sessionState: SessionState | null = null + private isEnabled = false + private authToken: string | null = null + private dataLevel: "full" | "metrics-only" = "full" + private consentTier: UserTier = "free" + + constructor() { + this.signalTracker = createSignalTracker() + this.sanitizer = createSanitizer() + } + + async initialize(authToken?: string): Promise { + this.authToken = authToken ?? null + + const consent = await getConsentStatus(authToken) + + if (!consent.telemetryEnabled) { + log.info("telemetry disabled by consent", { tier: consent.tier }) + this.isEnabled = false + return + } + + this.dataLevel = consent.dataLevel + this.consentTier = consent.tier + + const config = await Config.get() + const telemetryConfig = config.qbraid?.telemetry + + if (telemetryConfig?.excludePatterns) { + this.sanitizer = createSanitizer({ + excludePatterns: telemetryConfig.excludePatterns, + }) + } + + const endpoint = telemetryConfig?.endpoint ?? getTelemetryEndpoint() + + if (authToken) { + this.uploader = createUploader({ + endpoint, + authToken, + batchSize: telemetryConfig?.batchSize, + flushIntervalMs: telemetryConfig?.flushIntervalMs, + }) + } + + this.isEnabled = true + log.info("telemetry initialized", { endpoint, dataLevel: consent.dataLevel }) + } + + async startSession(sessionId: string, userId: string, organizationId: string): Promise { + if (!this.isEnabled) return + + this.sessionState = { + sessionId, + startedAt: new Date(), + userId, + organizationId, + environment: this.detectEnvironment(), + metrics: { + turnCount: 0, + totalInputTokens: 0, + totalOutputTokens: 0, + totalCost: 0, + toolCallCount: 0, + toolErrorCount: 0, + filesModified: 0, + linesAdded: 0, + linesDeleted: 0, + }, + modelUsage: {}, + currentTurnIndex: 0, + currentTurn: null, + finalizedMessages: new Set(), + } + + this.signalTracker.reset() + + if (this.uploader) { + const session: TelemetrySession = { + userId, + organizationId, + sessionId, + codeqVersion: CODEQ_VERSION, + environment: this.sessionState.environment, + startedAt: this.sessionState.startedAt.toISOString(), + durationSeconds: 0, + consentTier: this.consentTier, + dataLevel: this.dataLevel, + metrics: this.sessionState.metrics, + signals: this.signalTracker.getSignals(false), + modelUsage: {}, + } + + await this.uploader.createSession(session) + } + + log.debug("session started", { sessionId }) + } + + async endSession(wasExplicitlyEnded = true): Promise { + if (!this.isEnabled || !this.sessionState) return + + if (this.sessionState.currentTurn) { + this.finalizeTurn() + } + + const durationSeconds = Math.floor((Date.now() - this.sessionState.startedAt.getTime()) / 1000) + + if (this.uploader) { + await this.uploader.updateSession({ + endedAt: new Date().toISOString(), + durationSeconds, + metrics: this.sessionState.metrics, + signals: this.signalTracker.getSignals(wasExplicitlyEnded), + modelUsage: this.sessionState.modelUsage, + }) + + await this.uploader.shutdown() + } + + log.debug("session ended", { + sessionId: this.sessionState.sessionId, + duration: durationSeconds, + turns: this.sessionState.metrics.turnCount, + }) + + this.sessionState = null + } + + recordUserMessage(content: string, hasImages = false, hasFiles = false): void { + if (!this.isEnabled || !this.sessionState) return + + this.signalTracker.startTurn() + + // Respect dataLevel: metrics-only skips message content + const sanitizedContent = this.dataLevel === "full" + ? this.sanitizer.sanitizeContent(content) + : "" + + this.sessionState.currentTurn = { + turnIndex: this.sessionState.currentTurnIndex, + createdAt: new Date().toISOString(), + userMessage: { + content: sanitizedContent, + contentLength: content.length, + hasImages, + hasFiles, + }, + toolCalls: [], + wasRetried: false, + } + } + + recordAssistantMessage( + content: string, + modelId: string, + inputTokens: number, + outputTokens: number, + latencyMs: number, + ): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + const sanitizedContent = this.dataLevel === "full" + ? this.sanitizer.sanitizeContent(content) + : "" + + this.sessionState.currentTurn.assistantMessage = { + content: sanitizedContent, + contentLength: content.length, + modelId, + inputTokens, + outputTokens, + latencyMs, + } + + if (!this.sessionState.modelUsage[modelId]) { + this.sessionState.modelUsage[modelId] = { + turns: 0, + inputTokens: 0, + outputTokens: 0, + } + } + this.sessionState.modelUsage[modelId].turns++ + this.sessionState.modelUsage[modelId].inputTokens += inputTokens + this.sessionState.modelUsage[modelId].outputTokens += outputTokens + + this.sessionState.metrics.totalInputTokens += inputTokens + this.sessionState.metrics.totalOutputTokens += outputTokens + } + + recordToolCall( + name: string, + status: "success" | "error", + durationMs: number, + inputSize?: number, + outputSize?: number, + errorType?: string, + ): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + const toolCall: ToolCallData = { + name, + status, + durationMs, + inputSizeBytes: inputSize, + outputSizeBytes: outputSize, + errorType, + } + + this.sessionState.currentTurn.toolCalls?.push(toolCall) + + this.sessionState.metrics.toolCallCount++ + if (status === "error") { + this.sessionState.metrics.toolErrorCount++ + if (errorType) { + this.signalTracker.recordError(errorType) + } + } + } + + recordFileChange(filePath: string, additions: number, deletions: number): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + if (this.sanitizer.isSensitiveFile(filePath)) return + + const fileChange: FileChangeData = { + pathHash: this.sanitizer.hashFilePath(filePath), + extension: this.sanitizer.getFileExtension(filePath), + additions, + deletions, + } + + if (!this.sessionState.currentTurn.fileChanges) { + this.sessionState.currentTurn.fileChanges = [] + } + this.sessionState.currentTurn.fileChanges.push(fileChange) + + this.sessionState.metrics.filesModified++ + this.sessionState.metrics.linesAdded += additions + this.sessionState.metrics.linesDeleted += deletions + } + + recordRetry(): void { + if (!this.isEnabled || !this.sessionState || !this.sessionState.currentTurn) return + + this.sessionState.currentTurn.wasRetried = true + this.signalTracker.recordRetry() + } + + recordCompaction(): void { + if (!this.isEnabled) return + this.signalTracker.recordCompaction() + } + + /** + * Check if an assistant message has already been finalized (prevents duplicates + * from multiple step-finish events in multi-step tool-call loops). + */ + hasFinalized(messageId: string): boolean { + return this.sessionState?.finalizedMessages.has(messageId) ?? false + } + + /** + * Finalize the current turn and queue for upload. + * Returns false if the turn was incomplete and skipped. + */ + finalizeTurn(messageId?: string): boolean { + if (!this.sessionState?.currentTurn) return false + + const turn = this.sessionState.currentTurn as TelemetryTurn + + if (!turn.userMessage || !turn.assistantMessage) { + log.warn("incomplete turn, skipping", { turnIndex: turn.turnIndex }) + this.sessionState.currentTurn = null + return false + } + + if (messageId) { + this.sessionState.finalizedMessages.add(messageId) + } + + if (this.uploader) { + this.uploader.addTurn(turn) + } + + this.sessionState.metrics.turnCount++ + this.sessionState.currentTurnIndex++ + this.sessionState.currentTurn = null + + this.signalTracker.endTurn() + return true + } + + private detectEnvironment(): Environment { + if (process.env.QBRAID_LAB || process.env.JUPYTERHUB_USER) return "lab" + return "local" + } + + async shutdown(): Promise { + await this.endSession(false) + } +} + +/** + * Instance-scoped collector state. Each project directory gets its own collector + * that is automatically disposed when Instance.dispose() is called. + */ +const getCollectorState = Instance.state<{ collector: TelemetryCollector }>( + () => ({ collector: new TelemetryCollector() }), + async (state) => { + await state.collector.shutdown() + }, +) + +/** + * Get the collector for the current Instance context. + */ +export function getCollector(): TelemetryCollector { + return getCollectorState().collector +} + +export async function initializeTelemetry(authToken?: string): Promise { + const collector = getCollector() + await collector.initialize(authToken) +} + +export async function shutdownTelemetry(): Promise { + const collector = getCollector() + await collector.shutdown() +} diff --git a/packages/opencode/src/telemetry/consent.ts b/packages/opencode/src/telemetry/consent.ts new file mode 100644 index 00000000000..e15aeafb87b --- /dev/null +++ b/packages/opencode/src/telemetry/consent.ts @@ -0,0 +1,150 @@ +/** + * Telemetry Consent + * + * Manages user consent for telemetry based on tier, local preferences, and + * the remote consent service. Defaults to OFF unless explicitly enabled by + * the user through the first-run dialog or config. + */ + +import { Log } from "../util/log" +import { Config } from "../config/config" +import { Flag } from "../flag/flag" +import type { ConsentStatus, DataLevel, UserTier } from "./types" + +const log = Log.create({ service: "telemetry:consent" }) + +const DEFAULT_TELEMETRY_ENDPOINT = "https://qbraid-telemetry-314301605548.us-central1.run.app" + +let cachedConsent: ConsentStatus | null = null +let cacheExpiry: number = 0 +const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes + +export function getTelemetryEndpoint(): string { + return DEFAULT_TELEMETRY_ENDPOINT +} + +async function fetchConsentFromService( + endpoint: string, + authToken: string, +): Promise { + try { + const response = await fetch(`${endpoint}/api/v1/consent`, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + }) + + if (!response.ok) { + log.warn("failed to fetch consent status", { status: response.status }) + return null + } + + return (await response.json()) as ConsentStatus + } catch (error) { + log.error("error fetching consent status", { error }) + return null + } +} + +/** + * Local consent value read from the KV store file. + * Set by the first-run dialog or the `Telemetry.setLocalConsent()` API. + * `null` means no local decision has been recorded yet. + */ +let localConsent: boolean | null = null + +/** + * Set the local consent value (called by the TUI consent dialog). + */ +export function setLocalConsent(enabled: boolean): void { + localConsent = enabled +} + +/** + * Load local consent from the KV store file if available. + * This is called once during initialization. + */ +export function loadLocalConsent(value: boolean | null): void { + localConsent = value +} + +/** + * Get the current consent status. + * + * Priority order: + * 1. CODEQ_DISABLE_TELEMETRY env var — always wins + * 2. Config `qbraid.telemetry.enabled` — explicit config override + * 3. Remote consent service (for authenticated users) + * 4. Local consent from first-run dialog (KV store) + * 5. Default: OFF (telemetry is opt-in until the user makes a choice) + */ +export async function getConsentStatus(authToken?: string): Promise { + const config = await Config.get() + const qbraidConfig = config.qbraid?.telemetry + const userId = "unknown" + + // 1. Env var kill switch + if (Flag.CODEQ_DISABLE_TELEMETRY) { + return { userId, tier: "standard", telemetryEnabled: false, dataLevel: "metrics-only" } + } + + // 2. Explicit config override + if (qbraidConfig?.enabled === false) { + return { userId, tier: "standard", telemetryEnabled: false, dataLevel: "metrics-only" } + } + + if (qbraidConfig?.enabled === true) { + return { + userId, + tier: "standard", + telemetryEnabled: true, + dataLevel: qbraidConfig.dataLevel ?? "full", + } + } + + // 3. Remote consent service (authenticated users) + if (authToken) { + if (cachedConsent && Date.now() < cacheExpiry) return cachedConsent + + const endpoint = qbraidConfig?.endpoint ?? getTelemetryEndpoint() + const serviceConsent = await fetchConsentFromService(endpoint, authToken) + + if (serviceConsent) { + if (qbraidConfig?.dataLevel) serviceConsent.dataLevel = qbraidConfig.dataLevel + + cachedConsent = serviceConsent + cacheExpiry = Date.now() + CACHE_TTL_MS + return serviceConsent + } + } + + // 4. Local consent from first-run dialog + if (localConsent !== null) { + return { + userId, + tier: "free", + telemetryEnabled: localConsent, + dataLevel: qbraidConfig?.dataLevel ?? "full", + } + } + + // 5. Default: OFF until user makes a choice + return { userId, tier: "free", telemetryEnabled: false, dataLevel: "metrics-only" } +} + +export async function isTelemetryEnabled(authToken?: string): Promise { + const consent = await getConsentStatus(authToken) + return consent.telemetryEnabled +} + +export async function getDataLevel(authToken?: string): Promise { + const consent = await getConsentStatus(authToken) + return consent.dataLevel +} + +export function clearConsentCache(): void { + cachedConsent = null + cacheExpiry = 0 +} diff --git a/packages/opencode/src/telemetry/index.ts b/packages/opencode/src/telemetry/index.ts new file mode 100644 index 00000000000..ed13fe07aa5 --- /dev/null +++ b/packages/opencode/src/telemetry/index.ts @@ -0,0 +1,258 @@ +/** + * CodeQ Telemetry Module + * + * Collects session telemetry for analysis and model improvement. + * This module is qBraid-specific and not part of upstream codeq. + * + * Usage: + * import { Telemetry } from "./telemetry" + * + * // Initialize at startup (with Event Bus integration) + * await Telemetry.initIntegration() + * + * // Or initialize manually without Event Bus + * await Telemetry.initialize(authToken) + * + * // Start a session + * await Telemetry.startSession(sessionId, userId, orgId) + * + * // Record events during the session + * Telemetry.recordUserMessage(content) + * Telemetry.recordAssistantMessage(content, model, tokens, latency) + * Telemetry.recordToolCall(name, status, duration) + * Telemetry.recordFileChange(path, additions, deletions) + * + * // End the session + * await Telemetry.endSession() + * + * // Shutdown on exit + * await Telemetry.shutdown() + */ + +import { + getCollector, + initializeTelemetry, + shutdownTelemetry, + type TelemetryCollector, +} from "./collector" +import { getConsentStatus, isTelemetryEnabled, clearConsentCache, setLocalConsent, loadLocalConsent } from "./consent" +import { + initTelemetryIntegration, + shutdownTelemetryIntegration, + finalizeTurn, + recordUserTurn, +} from "./integration" +import type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" + +export namespace Telemetry { + /** + * Initialize the telemetry system with Event Bus integration + * + * This is the recommended way to initialize telemetry. It: + * - Checks consent based on user tier + * - Subscribes to relevant Event Bus events + * - Automatically tracks sessions, messages, tool calls, and file changes + */ + export async function initIntegration(): Promise { + await initTelemetryIntegration() + } + + /** + * Shutdown the telemetry system with Event Bus integration + * + * Unsubscribes from events and flushes pending data. + * Should be called on application exit. + */ + export async function shutdownIntegration(): Promise { + await shutdownTelemetryIntegration() + } + + /** + * Initialize the telemetry system (manual mode, no Event Bus) + * + * Use this if you want to manually control telemetry collection + * without automatic Event Bus integration. + * + * @param authToken - Optional qBraid auth token for consent lookup + */ + export async function initialize(authToken?: string): Promise { + await initializeTelemetry(authToken) + } + + /** + * Shutdown the telemetry system (manual mode) + * + * Flushes any pending data and cleans up resources. + * Should be called on application exit. + */ + export async function shutdown(): Promise { + await shutdownTelemetry() + } + + /** + * Finalize a turn when assistant response is complete + * + * Called to record the assistant's response and complete the turn. + * This should be called after the LLM streaming is complete. + */ + export const completeTurn = finalizeTurn + + /** + * Record a user message (start of a turn) + * + * Use this for manual recording when not using Event Bus integration. + */ + export const userMessage = recordUserTurn + + /** + * Start collecting for a new session + * + * @param sessionId - CodeQ session ID + * @param userId - qBraid user ID + * @param organizationId - Organization ID + */ + export async function startSession( + sessionId: string, + userId: string, + organizationId: string, + ): Promise { + const collector = getCollector() + await collector.startSession(sessionId, userId, organizationId) + } + + /** + * End the current session + * + * @param wasExplicitlyEnded - Whether the user explicitly ended the session + */ + export async function endSession(wasExplicitlyEnded = true): Promise { + const collector = getCollector() + await collector.endSession(wasExplicitlyEnded) + } + + /** + * Record a user message (start of a turn) + * + * @param content - Message content + * @param hasImages - Whether the message includes images + * @param hasFiles - Whether the message includes file attachments + */ + export function recordUserMessage(content: string, hasImages = false, hasFiles = false): void { + const collector = getCollector() + collector.recordUserMessage(content, hasImages, hasFiles) + } + + /** + * Record an assistant response (end of a turn) + * + * @param content - Response content + * @param modelId - Model used for generation + * @param inputTokens - Number of input tokens + * @param outputTokens - Number of output tokens + * @param latencyMs - Response latency in milliseconds + */ + export function recordAssistantMessage( + content: string, + modelId: string, + inputTokens: number, + outputTokens: number, + latencyMs: number, + ): void { + const collector = getCollector() + collector.recordAssistantMessage(content, modelId, inputTokens, outputTokens, latencyMs) + } + + /** + * Record a tool call + * + * @param name - Tool name + * @param status - Execution status + * @param durationMs - Execution duration in milliseconds + * @param inputSize - Size of input in bytes + * @param outputSize - Size of output in bytes + * @param errorType - Error type if status is "error" + */ + export function recordToolCall( + name: string, + status: "success" | "error", + durationMs: number, + inputSize?: number, + outputSize?: number, + errorType?: string, + ): void { + const collector = getCollector() + collector.recordToolCall(name, status, durationMs, inputSize, outputSize, errorType) + } + + /** + * Record a file change + * + * @param filePath - Path to the modified file + * @param additions - Lines added + * @param deletions - Lines deleted + */ + export function recordFileChange(filePath: string, additions: number, deletions: number): void { + const collector = getCollector() + collector.recordFileChange(filePath, additions, deletions) + } + + /** + * Record that the current turn was retried + */ + export function recordRetry(): void { + const collector = getCollector() + collector.recordRetry() + } + + /** + * Record a compaction event + */ + export function recordCompaction(): void { + const collector = getCollector() + collector.recordCompaction() + } + + /** + * Check if telemetry is currently enabled + * + * @param authToken - Optional auth token for consent lookup + */ + export async function isEnabled(authToken?: string): Promise { + return isTelemetryEnabled(authToken) + } + + /** + * Get the current consent status + * + * @param authToken - Optional auth token for consent lookup + */ + export async function getConsent(authToken?: string): Promise { + return getConsentStatus(authToken) + } + + /** + * Clear cached consent (useful when user changes settings) + */ + export function clearCache(): void { + clearConsentCache() + } + + /** + * Set local consent from the TUI first-run dialog. + * This persists via the KV store and takes effect immediately. + */ + export function setConsent(enabled: boolean): void { + setLocalConsent(enabled) + } + + /** + * Load previously-stored local consent value from KV store. + * Called during TUI initialization. + */ + export function loadConsent(value: boolean | null): void { + loadLocalConsent(value) + } +} + +// Re-export types for convenience +export type { ConsentStatus, TelemetrySession, TelemetryTurn } from "./types" diff --git a/packages/opencode/src/telemetry/integration.ts b/packages/opencode/src/telemetry/integration.ts new file mode 100644 index 00000000000..fc9edb885c1 --- /dev/null +++ b/packages/opencode/src/telemetry/integration.ts @@ -0,0 +1,390 @@ +/** + * Telemetry Integration + * + * Subscribes to the Event Bus to automatically feed data to the collector. + * Uses Instance.state for cleanup and per-instance isolation. + */ + +import { Bus } from "../bus" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { SessionCompaction } from "../session/compaction" +import { File } from "../file" +import { Log } from "../util/log" +import { Auth } from "../auth" +import { Instance } from "../project/instance" +import { getCollector, initializeTelemetry, shutdownTelemetry } from "./collector" +import path from "path" +import os from "os" +import fs from "fs/promises" + +const log = Log.create({ service: "telemetry:integration" }) + +/** + * Per-instance telemetry tracking state + */ +interface TelemetryState { + activeSessions: Map + /** Maps user messageID -> timestamp for latency calculation */ + turnStartTimes: Map + /** Tracks which user messages have been recorded (cleared per session) */ + recordedUserMessages: Set + unsubscribers: (() => void)[] + initialized: boolean +} + +const getTelemetryState = Instance.state( + () => ({ + activeSessions: new Map(), + turnStartTimes: new Map(), + recordedUserMessages: new Set(), + unsubscribers: [], + initialized: false, + }), + async (state) => { + log.info("disposing telemetry state") + for (const unsub of state.unsubscribers) unsub() + await shutdownTelemetry() + state.activeSessions.clear() + state.turnStartTimes.clear() + state.recordedUserMessages.clear() + state.unsubscribers = [] + log.info("telemetry disposed") + }, +) + +// Cached user info from consent endpoint — shared across instances because +// the same qBraid account is used regardless of which project is open. +let cachedUserInfo: { userId: string; organizationId?: string } | null = null + +/** + * Read qBraid API key from env, config, or ~/.qbraid/qbraidrc + */ +async function getQBraidApiKey(): Promise { + if (process.env.QBRAID_API_KEY) return process.env.QBRAID_API_KEY + + try { + const { Config } = await import("../config/config") + const config = await Config.get() + const apiKey = config.provider?.qbraid?.options?.apiKey + if (apiKey && typeof apiKey === "string") return apiKey + } catch { + log.debug("could not read qbraid api key from config") + } + + try { + const qbraidrcPath = path.join(os.homedir(), ".qbraid", "qbraidrc") + const content = await fs.readFile(qbraidrcPath, "utf-8") + for (const line of content.split("\n")) { + const match = line.trim().match(/^api-key\s*=\s*(.+)/) + if (match) return match[1].trim() + } + } catch { + log.debug("no qbraidrc file found") + } + + return undefined +} + +/** + * Initialize telemetry and subscribe to events + */ +export async function initTelemetryIntegration(): Promise { + const state = getTelemetryState() + if (state.initialized) { + log.debug("telemetry already initialized") + return + } + + let authToken: string | undefined + + // Try CodeQ auth system first + try { + const authData = await Auth.all() + for (const [key, value] of Object.entries(authData)) { + if (key.includes("qbraid") && value.type === "wellknown" && value.token) { + authToken = value.token + break + } + } + } catch { + log.debug("no auth token in codeq auth system") + } + + // Fall back to qBraid API key + if (!authToken) { + authToken = await getQBraidApiKey() + if (authToken) log.debug("using qbraid api key for telemetry") + } + + // Fetch user info from consent endpoint + if (authToken) { + try { + const { Config } = await import("../config/config") + const config = await Config.get() + const endpoint = config.qbraid?.telemetry?.endpoint ?? "https://qbraid-telemetry-314301605548.us-central1.run.app" + + const response = await fetch(`${endpoint}/api/v1/consent`, { + method: "GET", + headers: { + Authorization: `Bearer ${authToken}`, + "Content-Type": "application/json", + }, + }) + + if (response.ok) { + const data = await response.json() as { userId: string; organizationId?: string } + cachedUserInfo = { userId: data.userId, organizationId: data.organizationId } + log.debug("fetched user info for telemetry", { userId: data.userId }) + } + } catch (error) { + log.warn("failed to fetch user info for telemetry", { error }) + } + } + + await initializeTelemetry(authToken) + subscribeToEvents(state) + state.initialized = true + + // Note: flush on exit is handled by Instance.state disposal (getTelemetryState) + // which calls shutdownTelemetry(). We do NOT register process.once handlers + // here because they would run outside any Instance context and crash. + + log.info("telemetry integration initialized") +} + +/** + * Subscribe to Bus events and feed data to the collector. + */ +function subscribeToEvents(state: TelemetryState): void { + const collector = getCollector() + + // --- Session lifecycle --- + + state.unsubscribers.push( + Bus.subscribe(Session.Event.Created, async (event) => { + const { info } = event.properties + const userId = cachedUserInfo?.userId ?? "unknown" + const orgId = cachedUserInfo?.organizationId ?? "unknown" + + state.activeSessions.set(info.id, { startTime: Date.now(), userId, orgId }) + state.recordedUserMessages.clear() + state.turnStartTimes.clear() + await collector.startSession(info.id, userId, orgId) + log.debug("session tracking started", { sessionId: info.id }) + }), + ) + + state.unsubscribers.push( + Bus.subscribe(Session.Event.Deleted, async (event) => { + const { info } = event.properties + if (state.activeSessions.has(info.id)) { + await collector.endSession(true) + state.activeSessions.delete(info.id) + } + }), + ) + + // --- Tool calls via PartUpdated --- + + state.unsubscribers.push( + Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { + const { part } = event.properties + + // Handle completed tool calls + if (part.type === "tool" && part.state.status === "completed") { + const duration = part.state.time.end - part.state.time.start + collector.recordToolCall( + part.tool, + "success", + duration, + JSON.stringify(part.state.input).length, + part.state.output.length, + undefined, + ) + } else if (part.type === "tool" && part.state.status === "error") { + const duration = part.state.time.end - part.state.time.start + collector.recordToolCall( + part.tool, + "error", + duration, + JSON.stringify(part.state.input).length, + undefined, + part.state.error, + ) + } + }), + ) + + // --- Message updated: captures user messages and assistant completion --- + + state.unsubscribers.push( + Bus.subscribe(MessageV2.Event.Updated, async (event) => { + const { info } = event.properties + + if (info.role === "user") { + // Record the turn start time + state.turnStartTimes.set(info.id, Date.now()) + + // Only record content once per message + if (state.recordedUserMessages.has(info.id)) return + state.recordedUserMessages.add(info.id) + + // Read parts — by the time Updated fires for a user message on subsequent + // updates (e.g. when the assistant starts), parts should be available. + // We retry once after a short delay as a safety net. + let content = "" + let hasFiles = false + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + content = textParts.map((p) => p.text).join("\n") + hasFiles = parts.some((p) => p.type === "file") + } catch { + // Parts may not be written yet on the very first Updated event. + // Re-try after a short delay. + await new Promise((r) => setTimeout(r, 50)) + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + content = textParts.map((p) => p.text).join("\n") + hasFiles = parts.some((p) => p.type === "file") + } catch (error) { + log.warn("failed to get user message parts after retry", { error }) + } + } + + if (content) { + collector.recordUserMessage(content, false, hasFiles) + log.debug("recorded user message", { messageId: info.id, len: content.length }) + } + } + + // Assistant message with time.completed set means the processor is done + // with this message. This fires exactly once per full response cycle. + if (info.role === "assistant" && info.time?.completed) { + // Guard against duplicate finalization + if (collector.hasFinalized(info.id)) return + + try { + const parts = await MessageV2.parts(info.id) + const textParts = parts.filter((p): p is MessageV2.TextPart => p.type === "text") + const content = textParts.map((p) => p.text).join("\n") + + // Find the user message that started this turn via parentID. + // Assistant messages reference their parent user message. + const parentId = info.parentID + let startTime = Date.now() + if (parentId && state.turnStartTimes.has(parentId)) { + startTime = state.turnStartTimes.get(parentId)! + state.turnStartTimes.delete(parentId) + } + + const latencyMs = Date.now() - startTime + const modelId = info.modelID ?? "unknown" + const inputTokens = info.tokens?.input ?? 0 + const outputTokens = info.tokens?.output ?? 0 + + collector.recordAssistantMessage(content, modelId, inputTokens, outputTokens, latencyMs) + collector.finalizeTurn(info.id) + + log.debug("finalized turn", { + messageId: info.id, + modelId, + inputTokens, + outputTokens, + latencyMs, + }) + } catch (error) { + log.warn("failed to finalize turn", { error }) + } + } + }), + ) + + // --- Compaction --- + + state.unsubscribers.push( + Bus.subscribe(SessionCompaction.Event.Compacted, () => { + collector.recordCompaction() + }), + ) + + // --- File edits --- + + state.unsubscribers.push( + Bus.subscribe(File.Event.Edited, (event) => { + collector.recordFileChange(event.properties.file, 0, 0) + }), + ) + + // --- Session diff (for line-level change data) --- + + state.unsubscribers.push( + Bus.subscribe(Session.Event.Diff, (event) => { + for (const diff of event.properties.diff) { + if (diff.additions > 0 || diff.deletions > 0) { + collector.recordFileChange(diff.file, diff.additions, diff.deletions) + } + } + }), + ) + + // --- Session errors --- + + state.unsubscribers.push( + Bus.subscribe(Session.Event.Error, (event) => { + if (event.properties.error) { + log.debug("session error", { error: event.properties.error.name }) + } + }), + ) + + log.debug("subscribed to telemetry events") +} + +/** + * Finalize a turn manually (for non-Event-Bus callers). + * Records the assistant message and queues the turn for upload. + */ +export function finalizeTurn( + _sessionId: string, + assistantContent: string, + modelId: string, + tokens: { input: number; output: number }, + startTime?: number, +): void { + const collector = getCollector() + const latencyMs = startTime ? Date.now() - startTime : 0 + collector.recordAssistantMessage(assistantContent, modelId, tokens.input, tokens.output, latencyMs) + collector.finalizeTurn() +} + +export function recordUserTurn(content: string, hasImages = false, hasFiles = false): void { + getCollector().recordUserMessage(content, hasImages, hasFiles) +} + +export function recordRetry(): void { + getCollector().recordRetry() +} + +/** + * Shutdown telemetry integration explicitly. + * Normally handled automatically by Instance.dispose() via state disposal. + */ +export async function shutdownTelemetryIntegration(): Promise { + const state = getTelemetryState() + if (!state.initialized) return + + for (const unsub of state.unsubscribers) unsub() + state.unsubscribers = [] + + await shutdownTelemetry() + + state.activeSessions.clear() + state.turnStartTimes.clear() + state.recordedUserMessages.clear() + state.initialized = false + + log.info("telemetry integration shutdown") +} diff --git a/packages/opencode/src/telemetry/sanitizer.ts b/packages/opencode/src/telemetry/sanitizer.ts new file mode 100644 index 00000000000..cf8e76cd925 --- /dev/null +++ b/packages/opencode/src/telemetry/sanitizer.ts @@ -0,0 +1,205 @@ +/** + * Telemetry Sanitizer + * + * Sanitizes telemetry data to remove sensitive information before upload. + * Critical for user privacy and security. + */ + +import crypto from "crypto" + +// Patterns that indicate sensitive environment variables +const SENSITIVE_ENV_PATTERNS = [ + /key/i, + /secret/i, + /token/i, + /password/i, + /credential/i, + /auth/i, + /private/i, + /api_?key/i, + /access_?key/i, +] + +// File patterns that should never have content included +const SENSITIVE_FILE_PATTERNS = [ + /\.env($|\.)/i, + /\.pem$/i, + /\.key$/i, + /\.p12$/i, + /\.pfx$/i, + /credentials?\.(json|yaml|yml|toml)$/i, + /secrets?\.(json|yaml|yml|toml)$/i, + /service[-_]?account.*\.json$/i, + /id_rsa/i, + /id_ed25519/i, + /\.ssh\//i, +] + +// Common secret value patterns +const SECRET_VALUE_PATTERNS = [ + // API keys (various formats) + /\b[A-Za-z0-9_-]{32,}\b/g, // Generic long alphanumeric + /\bsk[-_][A-Za-z0-9]{20,}\b/g, // Stripe-style keys + /\bghp_[A-Za-z0-9]{36}\b/g, // GitHub personal access tokens + /\bgho_[A-Za-z0-9]{36}\b/g, // GitHub OAuth tokens + /\bAKIA[A-Z0-9]{16}\b/g, // AWS access key IDs + /\bey[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\b/g, // JWTs + /\bqbr_[A-Za-z0-9]{32,}\b/g, // qBraid API keys +] + +// Maximum content length before truncation +const MAX_CONTENT_LENGTH = 50000 // 50KB + +/** + * Hash a file path for privacy while maintaining ability to deduplicate + */ +export function hashFilePath(path: string): string { + return crypto.createHash("sha256").update(path).digest("hex").substring(0, 16) +} + +/** + * Check if a file path matches sensitive patterns + */ +export function isSensitiveFile(path: string, additionalPatterns: string[] = []): boolean { + const patterns = [...SENSITIVE_FILE_PATTERNS, ...additionalPatterns.map((p) => new RegExp(p))] + return patterns.some((pattern) => pattern.test(path)) +} + +/** + * Check if an environment variable name is sensitive + */ +export function isSensitiveEnvVar(name: string): boolean { + return SENSITIVE_ENV_PATTERNS.some((pattern) => pattern.test(name)) +} + +/** + * Redact potential secrets from text content + */ +export function redactSecrets(content: string): string { + let result = content + + // Redact environment variable assignments + result = result.replace(/^(\s*[A-Z_][A-Z0-9_]*\s*=\s*)(["']?)(.+?)\2$/gm, (match, prefix, quote, value) => { + const varName = prefix.split("=")[0].trim() + if (isSensitiveEnvVar(varName)) { + return `${prefix}${quote}[REDACTED]${quote}` + } + return match + }) + + // Redact common secret patterns + for (const pattern of SECRET_VALUE_PATTERNS) { + result = result.replace(pattern, "[REDACTED]") + } + + // Redact Bearer tokens in headers + result = result.replace(/(Authorization:\s*Bearer\s+)([^\s]+)/gi, "$1[REDACTED]") + + // Redact password-like fields in JSON + result = result.replace( + /("(?:password|secret|token|key|credential|auth)[^"]*"\s*:\s*)"([^"]+)"/gi, + '$1"[REDACTED]"', + ) + + return result +} + +/** + * Truncate content if it exceeds the maximum length + */ +export function truncateContent(content: string, maxLength: number = MAX_CONTENT_LENGTH): string { + if (content.length <= maxLength) { + return content + } + + const truncated = content.substring(0, maxLength) + const hash = crypto.createHash("sha256").update(content).digest("hex").substring(0, 8) + + return `${truncated}\n\n[TRUNCATED - Original length: ${content.length} bytes, hash: ${hash}]` +} + +/** + * Sanitize message content for telemetry + */ +export function sanitizeContent(content: string, excludePatterns: string[] = []): string { + // Check if content contains file paths that should be excluded + const allPatterns = [...SENSITIVE_FILE_PATTERNS, ...excludePatterns.map((p) => new RegExp(p))] + + let sanitized = content + + // Redact secrets + sanitized = redactSecrets(sanitized) + + // Truncate if too long + sanitized = truncateContent(sanitized) + + return sanitized +} + +/** + * Sanitize a tool call input/output + */ +export function sanitizeToolData( + toolName: string, + data: unknown, + excludePatterns: string[] = [], +): string | undefined { + if (data === undefined || data === null) { + return undefined + } + + // For file-related tools, check if the path is sensitive + if (typeof data === "object" && data !== null) { + const obj = data as Record + if (typeof obj.path === "string" || typeof obj.filePath === "string") { + const path = (obj.path || obj.filePath) as string + if (isSensitiveFile(path, excludePatterns)) { + return "[REDACTED - Sensitive file]" + } + } + } + + const content = typeof data === "string" ? data : JSON.stringify(data, null, 2) + return sanitizeContent(content, excludePatterns) +} + +/** + * Extract file extension from a path + */ +export function getFileExtension(path: string): string { + const lastDot = path.lastIndexOf(".") + const lastSlash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + + if (lastDot > lastSlash && lastDot < path.length - 1) { + return path.substring(lastDot).toLowerCase() + } + + return "" +} + +/** + * Configuration for the sanitizer + */ +export interface SanitizerConfig { + excludePatterns: string[] + maxContentLength: number +} + +/** + * Create a sanitizer with custom configuration + */ +export function createSanitizer(config: Partial = {}) { + const finalConfig: SanitizerConfig = { + excludePatterns: config.excludePatterns ?? [], + maxContentLength: config.maxContentLength ?? MAX_CONTENT_LENGTH, + } + + return { + sanitizeContent: (content: string) => sanitizeContent(content, finalConfig.excludePatterns), + sanitizeToolData: (toolName: string, data: unknown) => + sanitizeToolData(toolName, data, finalConfig.excludePatterns), + hashFilePath, + isSensitiveFile: (path: string) => isSensitiveFile(path, finalConfig.excludePatterns), + getFileExtension, + } +} diff --git a/packages/opencode/src/telemetry/signals.ts b/packages/opencode/src/telemetry/signals.ts new file mode 100644 index 00000000000..461af6f9ac0 --- /dev/null +++ b/packages/opencode/src/telemetry/signals.ts @@ -0,0 +1,128 @@ +/** + * Telemetry Signals + * + * Tracks implicit feedback signals from user behavior during sessions. + * These signals help understand session quality without requiring explicit ratings. + */ + +import type { SessionSignals, SessionState } from "./types" + +/** + * Tracker for implicit feedback signals within a session + */ +export class SignalTracker { + private retryCount = 0 + private compactionCount = 0 + private errorTypes = new Set() + private turnStartTime: number | null = null + private lastActivityTime: number = Date.now() + private inProgressTurn = false + + /** + * Record that a turn was retried + */ + recordRetry(): void { + this.retryCount++ + } + + /** + * Record that a compaction occurred + */ + recordCompaction(): void { + this.compactionCount++ + } + + /** + * Record an error that occurred during the session + */ + recordError(errorType: string): void { + this.errorTypes.add(errorType) + } + + /** + * Mark the start of a new turn + */ + startTurn(): void { + this.turnStartTime = Date.now() + this.inProgressTurn = true + this.updateActivity() + } + + /** + * Mark the end of the current turn + */ + endTurn(): void { + this.turnStartTime = null + this.inProgressTurn = false + this.updateActivity() + } + + /** + * Update the last activity timestamp + */ + updateActivity(): void { + this.lastActivityTime = Date.now() + } + + /** + * Get whether the session was abandoned mid-turn + * (i.e., user closed while assistant was responding) + */ + isAbandonedMidTurn(): boolean { + return this.inProgressTurn + } + + /** + * Determine the final state of the session + */ + determineFinalState(hasErrors: boolean, wasExplicitlyEnded: boolean): SessionState { + if (hasErrors || this.errorTypes.size > 0) { + return "error" + } + + if (this.isAbandonedMidTurn() || !wasExplicitlyEnded) { + return "abandoned" + } + + return "completed" + } + + /** + * Get the aggregated signals for the session + */ + getSignals(wasExplicitlyEnded: boolean): SessionSignals { + return { + retryCount: this.retryCount, + compactionCount: this.compactionCount, + abandonedMidTurn: this.isAbandonedMidTurn(), + finalState: this.determineFinalState(false, wasExplicitlyEnded), + errorTypes: this.errorTypes.size > 0 ? Array.from(this.errorTypes) : undefined, + } + } + + /** + * Get the time since last activity (for idle detection) + */ + getIdleTimeMs(): number { + return Date.now() - this.lastActivityTime + } + + /** + * Reset the tracker (for testing or session restart) + */ + reset(): void { + this.retryCount = 0 + this.compactionCount = 0 + this.errorTypes.clear() + this.turnStartTime = null + this.lastActivityTime = Date.now() + this.inProgressTurn = false + } +} + +/** + * Create a new signal tracker instance + */ +export function createSignalTracker(): SignalTracker { + return new SignalTracker() +} diff --git a/packages/opencode/src/telemetry/types.ts b/packages/opencode/src/telemetry/types.ts new file mode 100644 index 00000000000..51f72806dbd --- /dev/null +++ b/packages/opencode/src/telemetry/types.ts @@ -0,0 +1,188 @@ +/** + * Telemetry Types + * + * Type definitions for CodeQ telemetry data. + * These match the schema expected by the qbraid-telemetry microservice. + */ + +/** + * User tier for consent-based telemetry + */ +export type UserTier = "free" | "standard" | "pro" + +/** + * Data collection level + */ +export type DataLevel = "full" | "metrics-only" + +/** + * Environment where CodeQ is running + */ +export type Environment = "local" | "lab" + +/** + * Session state for implicit feedback + */ +export type SessionState = "completed" | "abandoned" | "error" + +/** + * Consent status from the telemetry service + */ +export interface ConsentStatus { + userId: string + tier: UserTier + telemetryEnabled: boolean + dataLevel: DataLevel +} + +/** + * Session metrics aggregated across all turns + */ +export interface SessionMetrics { + turnCount: number + totalInputTokens: number + totalOutputTokens: number + totalCost: number + toolCallCount: number + toolErrorCount: number + filesModified: number + linesAdded: number + linesDeleted: number +} + +/** + * Implicit feedback signals derived from session behavior + */ +export interface SessionSignals { + retryCount: number + compactionCount: number + abandonedMidTurn: boolean + finalState: SessionState + errorTypes?: string[] +} + +/** + * Model usage breakdown + */ +export interface ModelUsage { + [modelId: string]: { + turns: number + inputTokens: number + outputTokens: number + } +} + +/** + * CodeQ Session telemetry payload + */ +export interface TelemetrySession { + // Identity + userId: string + organizationId: string + + // Session metadata + sessionId: string + codeqVersion: string + environment: Environment + projectHash?: string + + // Timing + startedAt: string // ISO 8601 + endedAt?: string // ISO 8601 + durationSeconds: number + + // Consent + consentTier: UserTier + dataLevel: DataLevel + + // Aggregated data + metrics: SessionMetrics + signals: SessionSignals + modelUsage: ModelUsage +} + +/** + * Tool call metadata for a turn + */ +export interface ToolCallData { + name: string + status: "success" | "error" + durationMs: number + inputSizeBytes?: number + outputSizeBytes?: number + errorType?: string +} + +/** + * File change metadata for a turn + */ +export interface FileChangeData { + pathHash: string // SHA-256 of relative path + extension: string + additions: number + deletions: number +} + +/** + * User message data for a turn + */ +export interface UserMessageData { + content: string + contentLength: number + hasImages: boolean + hasFiles: boolean +} + +/** + * Assistant message data for a turn + */ +export interface AssistantMessageData { + content: string + contentLength: number + modelId: string + inputTokens: number + outputTokens: number + latencyMs: number +} + +/** + * A single turn (user message + assistant response) in a session + */ +export interface TelemetryTurn { + turnIndex: number + createdAt: string // ISO 8601 + + userMessage: UserMessageData + assistantMessage: AssistantMessageData + + toolCalls: ToolCallData[] + fileChanges?: FileChangeData[] + + wasRetried: boolean + userEditedAfter?: boolean +} + +/** + * Request payload for creating/updating a session + */ +export interface CreateSessionRequest { + session: TelemetrySession + turns?: TelemetryTurn[] +} + +/** + * Request payload for adding turns to a session + */ +export interface AddTurnsRequest { + turns: TelemetryTurn[] +} + +/** + * Response from session creation + */ +export interface SessionResponse { + id: string + sessionId: string + created: boolean + turnsAdded: number +} diff --git a/packages/opencode/src/telemetry/uploader.ts b/packages/opencode/src/telemetry/uploader.ts new file mode 100644 index 00000000000..b6b0e2157ee --- /dev/null +++ b/packages/opencode/src/telemetry/uploader.ts @@ -0,0 +1,257 @@ +/** + * Telemetry Uploader + * + * Handles batching and uploading telemetry data to the qBraid telemetry service. + * Includes retry logic, offline handling, and graceful degradation. + */ + +import { Log } from "../util/log" +import type { + AddTurnsRequest, + CreateSessionRequest, + SessionResponse, + TelemetrySession, + TelemetryTurn, +} from "./types" + +const log = Log.create({ service: "telemetry:uploader" }) + +// Default configuration +const DEFAULT_BATCH_SIZE = 5 +const DEFAULT_FLUSH_INTERVAL_MS = 30000 // 30 seconds +const MAX_RETRY_ATTEMPTS = 3 +const RETRY_BACKOFF_MS = 1000 + +/** + * Configuration for the uploader + */ +export interface UploaderConfig { + endpoint: string + authToken: string + batchSize: number + flushIntervalMs: number +} + +/** + * Telemetry uploader for sending session data to the service + */ +export class TelemetryUploader { + private config: UploaderConfig + private pendingTurns: TelemetryTurn[] = [] + private sessionDocId: string | null = null + private flushTimer: ReturnType | null = null + private isOnline = true + private offlineQueue: TelemetryTurn[] = [] + + constructor(config: Partial & { endpoint: string; authToken: string }) { + this.config = { + endpoint: config.endpoint, + authToken: config.authToken, + batchSize: config.batchSize ?? DEFAULT_BATCH_SIZE, + flushIntervalMs: config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS, + } + } + + /** + * Create or update a session on the telemetry service + */ + async createSession(session: TelemetrySession, initialTurns?: TelemetryTurn[]): Promise { + const request: CreateSessionRequest = { + session, + turns: initialTurns, + } + + try { + const response = await this.makeRequest("POST", "/api/v1/sessions", request) + + if (response) { + this.sessionDocId = response.id + log.info("session created", { id: response.id, created: response.created }) + return response.id + } + } catch (error) { + log.error("failed to create session", { error }) + } + + return null + } + + /** + * Add a turn to the pending batch + */ + addTurn(turn: TelemetryTurn): void { + this.pendingTurns.push(turn) + + // Check if we should flush + if (this.pendingTurns.length >= this.config.batchSize) { + this.flush().catch((error) => log.error("flush failed", { error })) + } else { + // Start flush timer if not already running + this.startFlushTimer() + } + } + + /** + * Flush pending turns to the service + */ + async flush(): Promise { + this.stopFlushTimer() + + if (this.pendingTurns.length === 0) { + return + } + + if (!this.sessionDocId) { + log.warn("cannot flush turns: no session created") + return + } + + if (!this.isOnline) { + // Queue for later when back online + this.offlineQueue.push(...this.pendingTurns) + this.pendingTurns = [] + log.debug("queued turns for offline", { count: this.offlineQueue.length }) + return + } + + const turnsToSend = [...this.pendingTurns] + this.pendingTurns = [] + + const request: AddTurnsRequest = { + turns: turnsToSend, + } + + try { + await this.makeRequest("POST", `/api/v1/sessions/${this.sessionDocId}/turns`, request) + log.debug("turns uploaded", { count: turnsToSend.length }) + } catch (error) { + // Put turns back in queue + this.pendingTurns = [...turnsToSend, ...this.pendingTurns] + log.error("failed to upload turns", { error, count: turnsToSend.length }) + } + } + + /** + * Update the session (e.g., when it ends) + */ + async updateSession(updates: Partial): Promise { + if (!this.sessionDocId) { + log.warn("cannot update session: no session created") + return + } + + try { + await this.makeRequest("PATCH", `/api/v1/sessions/${this.sessionDocId}`, updates) + log.debug("session updated", { id: this.sessionDocId }) + } catch (error) { + log.error("failed to update session", { error }) + } + } + + /** + * Graceful shutdown - flush all pending data + */ + async shutdown(): Promise { + this.stopFlushTimer() + + // Flush any remaining turns + if (this.pendingTurns.length > 0) { + await this.flush() + } + + // Try to send offline queue + if (this.offlineQueue.length > 0 && this.isOnline) { + const offlineTurns = [...this.offlineQueue] + this.offlineQueue = [] + this.pendingTurns = offlineTurns + await this.flush() + } + } + + /** + * Set online status + */ + setOnline(online: boolean): void { + const wasOffline = !this.isOnline + this.isOnline = online + + if (online && wasOffline && this.offlineQueue.length > 0) { + // Try to send queued data + log.info("back online, flushing offline queue", { count: this.offlineQueue.length }) + const offlineTurns = [...this.offlineQueue] + this.offlineQueue = [] + this.pendingTurns = [...this.pendingTurns, ...offlineTurns] + this.flush().catch((error) => log.error("offline flush failed", { error })) + } + } + + /** + * Make an HTTP request with retry logic + */ + private async makeRequest(method: string, path: string, body?: unknown): Promise { + const url = `${this.config.endpoint}${path}` + + for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) { + try { + const response = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.config.authToken}`, + }, + body: body ? JSON.stringify(body) : undefined, + }) + + if (response.ok) { + return (await response.json()) as T + } + + // Don't retry client errors (4xx) + if (response.status >= 400 && response.status < 500) { + const error = await response.text() + log.warn("client error", { status: response.status, error }) + return null + } + + // Retry server errors (5xx) + log.warn("server error, retrying", { status: response.status, attempt }) + } catch (error) { + log.warn("request failed, retrying", { error, attempt }) + + // Check if we're offline + if (error instanceof TypeError && error.message.includes("fetch")) { + this.setOnline(false) + } + } + + // Wait before retry with exponential backoff + if (attempt < MAX_RETRY_ATTEMPTS - 1) { + await new Promise((resolve) => setTimeout(resolve, RETRY_BACKOFF_MS * Math.pow(2, attempt))) + } + } + + return null + } + + private startFlushTimer(): void { + if (this.flushTimer) return + + this.flushTimer = setTimeout(() => { + this.flush().catch((error) => log.error("timer flush failed", { error })) + }, this.config.flushIntervalMs) + } + + private stopFlushTimer(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + } +} + +/** + * Create a new telemetry uploader + */ +export function createUploader(config: Partial & { endpoint: string; authToken: string }): TelemetryUploader { + return new TelemetryUploader(config) +} diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5ed5a879b48..6ccf8b84ffc 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -27,6 +27,7 @@ import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" import { ApplyPatchTool } from "./apply_patch" +import { QUANTUM_TOOLS } from "../quantum" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -115,6 +116,7 @@ export namespace ToolRegistry { ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), + ...QUANTUM_TOOLS, ...custom, ] }