From a46675791ed6c3a6123d4f11a58e2ce6f1589987 Mon Sep 17 00:00:00 2001 From: wwzeng1 <44910023+wwzeng1@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:36:37 -0800 Subject: [PATCH 1/2] feat: Add local mode for autocomplete via uvx sweep-autocomplete Ports the JetBrains local mode feature to VSCode, allowing users to run autocomplete inference locally instead of through the cloud API. --- package.json | 10 ++ src/api/client.ts | 157 +++++++++++++++++++-- src/core/config.ts | 22 +++ src/extension/activate.ts | 15 +- src/extension/status-bar.ts | 47 ++++++- src/services/local-server.ts | 259 +++++++++++++++++++++++++++++++++++ 6 files changed, 495 insertions(+), 15 deletions(-) create mode 100644 src/services/local-server.ts diff --git a/package.json b/package.json index 9990241..2bd24a5 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,16 @@ "type": "number", "default": 0, "description": "Unix timestamp (ms) until which autocomplete is snoozed (0 disables snooze)" + }, + "sweep.localMode": { + "type": "boolean", + "default": false, + "description": "Enable local autocomplete server (routes requests through a local uvx sweep-autocomplete instance)" + }, + "sweep.localPort": { + "type": "number", + "default": 8081, + "description": "Port for the local autocomplete server" } } } diff --git a/src/api/client.ts b/src/api/client.ts index 107778b..b8ca4a1 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,6 +9,7 @@ import { DEFAULT_API_ENDPOINT, DEFAULT_METRICS_ENDPOINT, } from "~/core/constants.ts"; +import type { LocalAutocompleteServer } from "~/services/local-server.ts"; import { toUnixPath } from "~/utils/path.ts"; import { isFileTooLarge, @@ -56,21 +57,25 @@ const MAX_DIAGNOSTICS = 50; export class ApiClient { private apiUrl: string; private metricsUrl: string; + private localServer: LocalAutocompleteServer | null; constructor( apiUrl: string = DEFAULT_API_ENDPOINT, metricsUrl: string = DEFAULT_METRICS_ENDPOINT, + localServer: LocalAutocompleteServer | null = null, ) { this.apiUrl = apiUrl; this.metricsUrl = metricsUrl; + this.localServer = localServer; } async getAutocomplete( input: AutocompleteInput, signal?: AbortSignal, ): Promise { + const isLocal = config.localMode && this.localServer; const apiKey = this.apiKey; - if (!apiKey) { + if (!isLocal && !apiKey) { return null; } @@ -94,20 +99,51 @@ export class ApiClient { return null; } - const compressed = await this.compress(JSON.stringify(parsedRequest.data)); let response: AutocompleteResponse; - try { - response = await this.sendRequest( - compressed, - apiKey, - AutocompleteResponseSchema, - signal, + if (isLocal) { + try { + await this.localServer?.ensureServerRunning(); + } catch (error) { + console.error("[Sweep] Failed to start local server:", error); + return null; + } + + const localUrl = `${this.localServer?.getServerUrl()}/backend/next_edit_autocomplete`; + try { + response = await this.sendLocalRequest( + JSON.stringify(parsedRequest.data), + localUrl, + AutocompleteResponseSchema, + signal, + ); + this.localServer?.reportSuccess(); + } catch (error) { + if ((error as Error).name === "AbortError") { + return null; + } + console.error("[Sweep] Local API request failed:", error); + this.localServer?.reportFailure(); + return null; + } + } else if (apiKey) { + const compressed = await this.compress( + JSON.stringify(parsedRequest.data), ); - } catch (error) { - if ((error as Error).name === "AbortError") { + try { + response = await this.sendRequest( + compressed, + apiKey, + AutocompleteResponseSchema, + signal, + ); + } catch (error) { + if ((error as Error).name === "AbortError") { + return null; + } + console.error("[Sweep] API request failed:", error); return null; } - console.error("[Sweep] API request failed:", error); + } else { return null; } @@ -610,6 +646,105 @@ export class ApiClient { }); } + private sendLocalRequest( + body: string, + url: string, + schema: ZodType, + signal?: AbortSignal, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: () => void) => { + if (settled) return; + settled = true; + cleanup(); + fn(); + }; + + const parsedUrl = new URL(url); + const options: http.RequestOptions = { + hostname: parsedUrl.hostname, + port: parsedUrl.port || 80, + path: `${parsedUrl.pathname}${parsedUrl.search}`, + method: "POST", + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }, + }; + + const req = http.request(options, (res) => { + let data = ""; + res.on("data", (chunk) => { + data += chunk.toString(); + }); + res.on("end", () => { + if (res.statusCode !== 200) { + console.error( + `[Sweep] Local request failed with status ${res.statusCode}: ${data}`, + ); + finish(() => + reject( + new Error(`Local request failed with status ${res.statusCode}`), + ), + ); + return; + } + try { + const parsedJson: unknown = JSON.parse(data); + const parsed = schema.safeParse(parsedJson); + if (!parsed.success) { + finish(() => + reject( + new Error(`Invalid local response: ${parsed.error.message}`), + ), + ); + return; + } + finish(() => resolve(parsed.data)); + } catch { + finish(() => + reject(new Error("Failed to parse local response JSON")), + ); + } + }); + }); + + const onError = (error: Error) => { + finish(() => + reject(new Error(`Local request error: ${error.message}`)), + ); + }; + + const onAbort = () => { + const abortError = new Error("Request aborted"); + abortError.name = "AbortError"; + req.destroy(abortError); + finish(() => reject(abortError)); + }; + + const cleanup = () => { + req.off("error", onError); + if (signal) { + signal.removeEventListener("abort", onAbort); + } + }; + + req.on("error", onError); + + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort); + } + + req.write(body); + req.end(); + }); + } + private sendMetricsRequest(body: string, apiKey: string): Promise { return new Promise((resolve, reject) => { const url = new URL(this.metricsUrl); diff --git a/src/core/config.ts b/src/core/config.ts index f9a7246..97562f2 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -37,6 +37,14 @@ export class SweepConfig { return this.config.get("autocompleteSnoozeUntil", 0); } + get localMode(): boolean { + return this.config.get("localMode", false); + } + + get localPort(): number { + return this.config.get("localPort", 8081); + } + isAutocompleteSnoozed(now = Date.now()): boolean { const snoozeUntil = this.autocompleteSnoozeUntil; return snoozeUntil > now; @@ -96,6 +104,20 @@ export class SweepConfig { return this.config.update("autocompleteSnoozeUntil", value, target); } + setLocalMode( + value: boolean, + target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, + ): Thenable { + return this.config.update("localMode", value, target); + } + + setLocalPort( + value: number, + target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, + ): Thenable { + return this.config.update("localPort", value, target); + } + private getWorkspaceTarget(): vscode.ConfigurationTarget { return vscode.workspace.workspaceFolders ? vscode.ConfigurationTarget.Workspace diff --git a/src/extension/activate.ts b/src/extension/activate.ts index 68286a1..3feac7b 100644 --- a/src/extension/activate.ts +++ b/src/extension/activate.ts @@ -12,6 +12,7 @@ import { registerStatusBarCommands, SweepStatusBar, } from "~/extension/status-bar.ts"; +import { LocalAutocompleteServer } from "~/services/local-server.ts"; import { type AutocompleteMetricsPayload, AutocompleteMetricsTracker, @@ -25,6 +26,7 @@ let jumpEditManager: JumpEditManager; let provider: InlineEditProvider; let statusBar: SweepStatusBar; let metricsTracker: AutocompleteMetricsTracker; +let localServer: LocalAutocompleteServer; export function activate(context: vscode.ExtensionContext) { promptForApiKeyIfNeeded(context); @@ -32,7 +34,8 @@ export function activate(context: vscode.ExtensionContext) { initSyntaxHighlighter(); tracker = new DocumentTracker(); - const apiClient = new ApiClient(); + localServer = new LocalAutocompleteServer(); + const apiClient = new ApiClient(undefined, undefined, localServer); metricsTracker = new AutocompleteMetricsTracker(apiClient); jumpEditManager = new JumpEditManager(metricsTracker); provider = new InlineEditProvider( @@ -94,7 +97,7 @@ export function activate(context: vscode.ExtensionContext) { ); statusBar = new SweepStatusBar(context); - const statusBarCommands = registerStatusBarCommands(context); + const statusBarCommands = registerStatusBarCommands(context, localServer); const changeListener = vscode.workspace.onDidChangeTextDocument((event) => { if (event.document === vscode.window.activeTextEditor?.document) { @@ -168,8 +171,16 @@ export function activate(context: vscode.ExtensionContext) { jumpEditManager, metricsTracker, statusBar, + localServer, ...statusBarCommands, ); + + // Auto-start local server if local mode is enabled + if (config.localMode) { + localServer.ensureServerRunning().catch((error) => { + console.error("[Sweep] Failed to auto-start local server:", error); + }); + } } export function deactivate() {} diff --git a/src/extension/status-bar.ts b/src/extension/status-bar.ts index 3b9decc..5049d5c 100644 --- a/src/extension/status-bar.ts +++ b/src/extension/status-bar.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { config } from "~/core/config"; +import type { LocalAutocompleteServer } from "~/services/local-server.ts"; export class SweepStatusBar implements vscode.Disposable { private statusBarItem: vscode.StatusBarItem; @@ -19,7 +20,8 @@ export class SweepStatusBar implements vscode.Disposable { if ( e.affectsConfiguration("sweep.enabled") || e.affectsConfiguration("sweep.privacyMode") || - e.affectsConfiguration("sweep.autocompleteSnoozeUntil") + e.affectsConfiguration("sweep.autocompleteSnoozeUntil") || + e.affectsConfiguration("sweep.localMode") ) { this.updateStatusBar(); } @@ -57,11 +59,12 @@ export class SweepStatusBar implements vscode.Disposable { ): string { const status = isEnabled ? "Enabled" : "Disabled"; const privacy = privacyMode ? "On" : "Off"; + const localMode = config.localMode ? "On" : "Off"; const snoozeUntil = config.autocompleteSnoozeUntil; const snoozeLine = isSnoozed ? `Snoozed Until: ${formatSnoozeTime(snoozeUntil)}` : "Snoozed: Off"; - return `Sweep Next Edit\nStatus: ${status}\nPrivacy Mode: ${privacy}\n${snoozeLine}\n\nClick to open menu`; + return `Sweep Next Edit\nStatus: ${status}\nPrivacy Mode: ${privacy}\nLocal Mode: ${localMode}\n${snoozeLine}\n\nClick to open menu`; } dispose(): void { @@ -74,6 +77,7 @@ export class SweepStatusBar implements vscode.Disposable { export function registerStatusBarCommands( _context: vscode.ExtensionContext, + localServer?: LocalAutocompleteServer, ): vscode.Disposable[] { const disposables: vscode.Disposable[] = []; @@ -81,6 +85,7 @@ export function registerStatusBarCommands( vscode.commands.registerCommand("sweep.showMenu", async () => { const isEnabled = config.enabled; const privacyMode = config.privacyMode; + const localMode = config.localMode; const isSnoozed = config.isAutocompleteSnoozed(); interface MenuItem extends vscode.QuickPickItem { @@ -100,6 +105,13 @@ export function registerStatusBarCommands( : "Completions may be used for training", action: "togglePrivacy", }, + { + label: `$(${localMode ? "check" : "circle-outline"}) Local Mode`, + description: localMode + ? "Using local autocomplete server" + : "Using cloud API", + action: "toggleLocalMode", + }, { label: "$(key) Set API Key", description: "Configure your Sweep API key", @@ -114,6 +126,11 @@ export function registerStatusBarCommands( : "Pause suggestions temporarily", action: isSnoozed ? "resumeSnooze" : "snooze", }, + { + label: "$(server) Start Local Server", + description: "Manually start the local autocomplete server", + action: "startLocalServer", + }, { label: "$(link-external) Open Sweep Dashboard", description: "https://app.sweep.dev", @@ -134,6 +151,14 @@ export function registerStatusBarCommands( case "togglePrivacy": await vscode.commands.executeCommand("sweep.togglePrivacyMode"); break; + case "toggleLocalMode": { + const current = config.localMode; + await config.setLocalMode(!current); + vscode.window.showInformationMessage( + `Sweep local mode ${!current ? "enabled" : "disabled"}`, + ); + break; + } case "setApiKey": await vscode.commands.executeCommand("sweep.setApiKey"); break; @@ -148,6 +173,24 @@ export function registerStatusBarCommands( case "resumeSnooze": await handleResumeSnooze(); break; + case "startLocalServer": + if (localServer) { + try { + await localServer.startServer(); + vscode.window.showInformationMessage( + "Sweep local server started.", + ); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to start local server: ${(error as Error).message}`, + ); + } + } else { + vscode.window.showWarningMessage( + "Local server is not available.", + ); + } + break; } } }), diff --git a/src/services/local-server.ts b/src/services/local-server.ts new file mode 100644 index 0000000..50dc611 --- /dev/null +++ b/src/services/local-server.ts @@ -0,0 +1,259 @@ +import * as child_process from "node:child_process"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +import { config } from "~/core/config.ts"; + +const HEALTH_CHECK_TIMEOUT_MS = 2_000; +const SERVER_START_TIMEOUT_MS = 30_000; +const HEALTH_POLL_INTERVAL_MS = 500; +const MAX_CONSECUTIVE_FAILURES = 3; +const RESTART_COOLDOWN_MS = 60_000; + +export class LocalAutocompleteServer implements vscode.Disposable { + private process: child_process.ChildProcess | null = null; + private starting = false; + private consecutiveFailures = 0; + private lastRestartTime = 0; + + async ensureServerRunning(): Promise { + if (this.starting) return; + if (await this.isServerHealthy()) return; + await this.startServer(); + } + + async isServerHealthy(): Promise { + const port = config.localPort; + return new Promise((resolve) => { + const req = http.get( + `http://localhost:${port}`, + { timeout: HEALTH_CHECK_TIMEOUT_MS }, + (res) => { + res.resume(); + // Any response (2xx-4xx) means the server is running + resolve(res.statusCode !== undefined && res.statusCode < 500); + }, + ); + req.on("error", () => resolve(false)); + req.on("timeout", () => { + req.destroy(); + resolve(false); + }); + }); + } + + async startServer(): Promise { + if (this.starting) return; + this.starting = true; + + try { + const uvxPath = await this.resolveUvx(); + if (!uvxPath) { + const install = await vscode.window.showWarningMessage( + "Sweep Local Mode requires 'uvx' (from uv) but it was not found on your system.", + "Install uv", + "Cancel", + ); + if (install === "Install uv") { + await this.installUv(); + const retryPath = await this.resolveUvx(); + if (!retryPath) { + vscode.window.showErrorMessage( + "Failed to find uvx after installing uv. Please restart your terminal and try again.", + ); + return; + } + await this.spawnServer(retryPath); + } + return; + } + + await this.spawnServer(uvxPath); + } finally { + this.starting = false; + } + } + + stopServer(): void { + if (this.process) { + this.process.kill(); + this.process = null; + } + } + + reportSuccess(): void { + this.consecutiveFailures = 0; + } + + reportFailure(): void { + this.consecutiveFailures++; + if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + const now = Date.now(); + if (now - this.lastRestartTime > RESTART_COOLDOWN_MS) { + this.lastRestartTime = now; + this.consecutiveFailures = 0; + console.log( + "[Sweep] Local server: too many consecutive failures, restarting...", + ); + this.stopServer(); + this.ensureServerRunning(); + } + } + } + + getServerUrl(): string { + return `http://localhost:${config.localPort}`; + } + + dispose(): void { + this.stopServer(); + } + + private async spawnServer(uvxPath: string): Promise { + const port = config.localPort; + console.log( + `[Sweep] Starting local autocomplete server on port ${port}...`, + ); + + this.process = child_process.spawn( + uvxPath, + ["sweep-autocomplete", "--port", String(port)], + { + stdio: ["ignore", "pipe", "pipe"], + detached: false, + }, + ); + + this.process.stderr?.on("data", (data: Buffer) => { + console.log(`[Sweep] Local server stderr: ${data.toString().trim()}`); + }); + + this.process.stdout?.on("data", (data: Buffer) => { + console.log(`[Sweep] Local server stdout: ${data.toString().trim()}`); + }); + + this.process.on("exit", (code) => { + console.log(`[Sweep] Local server exited with code ${code}`); + this.process = null; + }); + + // Poll for health until ready + const deadline = Date.now() + SERVER_START_TIMEOUT_MS; + while (Date.now() < deadline) { + await sleep(HEALTH_POLL_INTERVAL_MS); + if (await this.isServerHealthy()) { + console.log("[Sweep] Local autocomplete server is ready."); + return; + } + // Process may have exited + if (this.process === null) { + throw new Error("Local server process exited before becoming healthy"); + } + } + + throw new Error( + `Local server did not become healthy within ${SERVER_START_TIMEOUT_MS / 1000}s`, + ); + } + + private async resolveUvx(): Promise { + // Check PATH first + const pathResult = this.whichSync("uvx"); + if (pathResult) return pathResult; + + // Check common installation locations + const home = os.homedir(); + const candidates = [ + path.join(home, ".local", "bin", "uvx"), + path.join(home, ".cargo", "bin", "uvx"), + ]; + + if (process.platform === "win32") { + candidates.push( + path.join( + // biome-ignore lint/complexity/useLiteralKeys: tsgo requires bracket notation for index signatures + process.env["LOCALAPPDATA"] || path.join(home, "AppData", "Local"), + "uv", + "bin", + "uvx.exe", + ), + ); + } + + for (const candidate of candidates) { + try { + await fs.promises.access(candidate, fs.constants.X_OK); + return candidate; + } catch { + // not found here + } + } + + return null; + } + + private whichSync(command: string): string | null { + try { + const result = child_process.execSync( + process.platform === "win32" ? `where ${command}` : `which ${command}`, + { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }, + ); + const firstLine = result.trim().split("\n")[0]; + return firstLine || null; + } catch { + return null; + } + } + + private async installUv(): Promise { + return new Promise((resolve, reject) => { + const isWindows = process.platform === "win32"; + + let proc: child_process.ChildProcess; + if (isWindows) { + proc = child_process.spawn( + "powershell", + [ + "-ExecutionPolicy", + "ByPass", + "-c", + "irm https://astral.sh/uv/install.ps1 | iex", + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + } else { + proc = child_process.spawn( + "sh", + ["-c", "curl -LsSf https://astral.sh/uv/install.sh | sh"], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + } + + let stderr = ""; + proc.stderr?.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + proc.on("exit", (code) => { + if (code === 0) { + console.log("[Sweep] uv installed successfully."); + resolve(); + } else { + console.error(`[Sweep] uv installation failed: ${stderr}`); + reject(new Error(`uv installation failed with code ${code}`)); + } + }); + + proc.on("error", (err) => { + reject(new Error(`Failed to start uv installer: ${err.message}`)); + }); + }); + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} From e1eb7ab95b1eef5332424b9e0ac51f8fa4e93de2 Mon Sep 17 00:00:00 2001 From: wwzeng1 <44910023+wwzeng1@users.noreply.github.com> Date: Fri, 13 Feb 2026 13:07:16 -0800 Subject: [PATCH 2/2] refactor: Remove cloud mode, privacy mode, and API key management Local mode is now the only mode. This removes: - Cloud API endpoints, brotli compression, and HTTPS transport - API key settings, prompts, and configuration - Privacy mode toggle and training opt-out flag - Local mode toggle (always on now) - Cloud metrics reporting (trackAutocompleteMetrics) - Edit tracking timers/anchors/snapshots (only fed cloud endpoint) - Dashboard link and stale menu items Also fixes server restart by killing existing process before rebinding port. --- package.json | 23 --- src/api/client.ts | 280 ++------------------------ src/api/schemas.ts | 2 - src/core/config.ts | 33 --- src/core/constants.ts | 4 - src/editor/inline-edit-provider.ts | 20 +- src/editor/jump-edit-manager.ts | 6 +- src/extension/activate.ts | 65 +----- src/extension/status-bar.ts | 81 +------- src/services/local-server.ts | 2 + src/telemetry/autocomplete-metrics.ts | 259 +----------------------- 11 files changed, 38 insertions(+), 737 deletions(-) diff --git a/package.json b/package.json index 2bd24a5..6116ffa 100644 --- a/package.json +++ b/package.json @@ -35,14 +35,6 @@ "command": "sweep.triggerNextEdit", "title": "Sweep: Trigger Next Edit Suggestion" }, - { - "command": "sweep.setApiKey", - "title": "Sweep: Set API Key" - }, - { - "command": "sweep.togglePrivacyMode", - "title": "Sweep: Toggle Privacy Mode" - }, { "command": "sweep.toggleEnabled", "title": "Sweep: Toggle Enabled" @@ -104,22 +96,12 @@ "configuration": { "title": "Sweep Next Edit", "properties": { - "sweep.apiKey": { - "type": "string", - "default": "", - "description": "API key for authentication" - }, "sweep.enabled": { "type": "boolean", "default": true, "scope": "window", "description": "Enable or disable Sweep Next Edit suggestions" }, - "sweep.privacyMode": { - "type": "boolean", - "default": false, - "description": "When enabled completions will not be trained on" - }, "sweep.maxContextFiles": { "type": "number", "default": 5, @@ -146,11 +128,6 @@ "default": 0, "description": "Unix timestamp (ms) until which autocomplete is snoozed (0 disables snooze)" }, - "sweep.localMode": { - "type": "boolean", - "default": false, - "description": "Enable local autocomplete server (routes requests through a local uvx sweep-autocomplete instance)" - }, "sweep.localPort": { "type": "number", "default": 8081, diff --git a/src/api/client.ts b/src/api/client.ts index b8ca4a1..ea9a948 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,14 +1,7 @@ import * as http from "node:http"; -import * as https from "node:https"; import * as os from "node:os"; -import * as zlib from "node:zlib"; import * as vscode from "vscode"; import type { ZodType } from "zod"; -import { config } from "~/core/config.ts"; -import { - DEFAULT_API_ENDPOINT, - DEFAULT_METRICS_ENDPOINT, -} from "~/core/constants.ts"; import type { LocalAutocompleteServer } from "~/services/local-server.ts"; import { toUnixPath } from "~/utils/path.ts"; import { @@ -21,8 +14,6 @@ import { truncateRetrievalChunk, } from "./retrieval-chunks.ts"; import { - type AutocompleteMetricsRequest, - AutocompleteMetricsRequestSchema, type AutocompleteRequest, AutocompleteRequestSchema, type AutocompleteResponse, @@ -55,17 +46,9 @@ const MAX_CLIPBOARD_LINES = 20; const MAX_DIAGNOSTICS = 50; export class ApiClient { - private apiUrl: string; - private metricsUrl: string; - private localServer: LocalAutocompleteServer | null; - - constructor( - apiUrl: string = DEFAULT_API_ENDPOINT, - metricsUrl: string = DEFAULT_METRICS_ENDPOINT, - localServer: LocalAutocompleteServer | null = null, - ) { - this.apiUrl = apiUrl; - this.metricsUrl = metricsUrl; + private localServer: LocalAutocompleteServer; + + constructor(localServer: LocalAutocompleteServer) { this.localServer = localServer; } @@ -73,12 +56,6 @@ export class ApiClient { input: AutocompleteInput, signal?: AbortSignal, ): Promise { - const isLocal = config.localMode && this.localServer; - const apiKey = this.apiKey; - if (!isLocal && !apiKey) { - return null; - } - const documentText = input.document.getText(); if (isFileTooLarge(documentText) || isFileTooLarge(input.originalContent)) { console.log("[Sweep] Skipping autocomplete request: file too large", { @@ -100,50 +77,28 @@ export class ApiClient { } let response: AutocompleteResponse; - if (isLocal) { - try { - await this.localServer?.ensureServerRunning(); - } catch (error) { - console.error("[Sweep] Failed to start local server:", error); - return null; - } + try { + await this.localServer.ensureServerRunning(); + } catch (error) { + console.error("[Sweep] Failed to start local server:", error); + return null; + } - const localUrl = `${this.localServer?.getServerUrl()}/backend/next_edit_autocomplete`; - try { - response = await this.sendLocalRequest( - JSON.stringify(parsedRequest.data), - localUrl, - AutocompleteResponseSchema, - signal, - ); - this.localServer?.reportSuccess(); - } catch (error) { - if ((error as Error).name === "AbortError") { - return null; - } - console.error("[Sweep] Local API request failed:", error); - this.localServer?.reportFailure(); - return null; - } - } else if (apiKey) { - const compressed = await this.compress( + const localUrl = `${this.localServer.getServerUrl()}/backend/next_edit_autocomplete`; + try { + response = await this.sendRequest( JSON.stringify(parsedRequest.data), + localUrl, + AutocompleteResponseSchema, + signal, ); - try { - response = await this.sendRequest( - compressed, - apiKey, - AutocompleteResponseSchema, - signal, - ); - } catch (error) { - if ((error as Error).name === "AbortError") { - return null; - } - console.error("[Sweep] API request failed:", error); + this.localServer.reportSuccess(); + } catch (error) { + if ((error as Error).name === "AbortError") { return null; } - } else { + console.error("[Sweep] Local API request failed:", error); + this.localServer.reportFailure(); return null; } @@ -183,30 +138,6 @@ export class ApiClient { return results; } - async trackAutocompleteMetrics( - request: AutocompleteMetricsRequest, - ): Promise { - const apiKey = this.apiKey; - if (!apiKey) { - return; - } - - const parsedRequest = AutocompleteMetricsRequestSchema.safeParse(request); - if (!parsedRequest.success) { - console.error( - "[Sweep] Invalid metrics data:", - parsedRequest.error.message, - ); - return; - } - - await this.sendMetricsRequest(JSON.stringify(parsedRequest.data), apiKey); - } - - get apiKey(): string | null { - return config.apiKey; - } - private async buildRequest( input: AutocompleteInput, ): Promise { @@ -249,7 +180,6 @@ export class ApiClient { editor_diagnostics: editorDiagnostics, recent_user_actions: userActions, use_bytes: true, - privacy_mode_enabled: config.privacyMode, }; } @@ -528,125 +458,7 @@ export class ApiClient { ); } - private compress(data: string): Promise { - return new Promise((resolve, reject) => { - zlib.brotliCompress( - Buffer.from(data, "utf-8"), - { - params: { - [zlib.constants.BROTLI_PARAM_QUALITY]: 11, - [zlib.constants.BROTLI_PARAM_LGWIN]: 22, - }, - }, - (error, result) => (error ? reject(error) : resolve(result)), - ); - }); - } - private sendRequest( - body: Buffer, - apiKey: string, - schema: ZodType, - signal?: AbortSignal, - ): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const finish = (fn: () => void) => { - if (settled) return; - settled = true; - cleanup(); - fn(); - }; - - const url = new URL(this.apiUrl); - const isHttps = url.protocol === "https:"; - const defaultPort = isHttps ? 443 : 80; - - const options: http.RequestOptions = { - hostname: url.hostname, - port: url.port || defaultPort, - path: `${url.pathname}${url.search}`, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "Content-Encoding": "br", - "Content-Length": body.length, - }, - }; - - const transport = isHttps ? https : http; - const req = transport.request(options, (res) => { - let data = ""; - res.on("data", (chunk) => { - data += chunk.toString(); - }); - res.on("end", () => { - if (res.statusCode !== 200) { - console.error( - `[Sweep] API request failed with status ${res.statusCode}: ${data}`, - ); - finish(() => - reject( - new Error(`API request failed with status ${res.statusCode}`), - ), - ); - return; - } - try { - const parsedJson: unknown = JSON.parse(data); - const parsed = schema.safeParse(parsedJson); - if (!parsed.success) { - finish(() => - reject( - new Error(`Invalid API response: ${parsed.error.message}`), - ), - ); - return; - } - finish(() => resolve(parsed.data)); - } catch { - finish(() => - reject(new Error("Failed to parse API response JSON")), - ); - } - }); - }); - - const onError = (error: Error) => { - finish(() => reject(new Error(`API request error: ${error.message}`))); - }; - - const onAbort = () => { - const abortError = new Error("Request aborted"); - abortError.name = "AbortError"; - req.destroy(abortError); - finish(() => reject(abortError)); - }; - - const cleanup = () => { - req.off("error", onError); - if (signal) { - signal.removeEventListener("abort", onAbort); - } - }; - - req.on("error", onError); - - if (signal) { - if (signal.aborted) { - onAbort(); - return; - } - signal.addEventListener("abort", onAbort); - } - - req.write(body); - req.end(); - }); - } - - private sendLocalRequest( body: string, url: string, schema: ZodType, @@ -744,56 +556,4 @@ export class ApiClient { req.end(); }); } - - private sendMetricsRequest(body: string, apiKey: string): Promise { - return new Promise((resolve, reject) => { - const url = new URL(this.metricsUrl); - const isHttps = url.protocol === "https:"; - const defaultPort = isHttps ? 443 : 80; - - const options: http.RequestOptions = { - hostname: url.hostname, - port: url.port || defaultPort, - path: url.pathname, - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "Content-Length": Buffer.byteLength(body), - }, - }; - - const transport = isHttps ? https : http; - const req = transport.request(options, (res) => { - let data = ""; - res.on("data", (chunk) => { - data += chunk.toString(); - }); - res.on("end", () => { - if ( - !res.statusCode || - res.statusCode < 200 || - res.statusCode >= 300 - ) { - console.error( - `[Sweep] Metrics request failed with status ${res.statusCode}: ${data}`, - ); - reject( - new Error( - `Metrics request failed with status ${res.statusCode}: ${data}`, - ), - ); - return; - } - resolve(); - }); - }); - - req.on("error", (error) => - reject(new Error(`Metrics request error: ${error.message}`)), - ); - req.write(body); - req.end(); - }); - } } diff --git a/src/api/schemas.ts b/src/api/schemas.ts index d255f43..fb18bdb 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -49,7 +49,6 @@ export const AutocompleteRequestSchema = z.object({ editor_diagnostics: z.array(EditorDiagnosticSchema), recent_user_actions: z.array(UserActionSchema), use_bytes: z.boolean(), - privacy_mode_enabled: z.boolean(), }); export const AutocompleteResponseSchema = z.object({ @@ -103,7 +102,6 @@ export const AutocompleteMetricsRequestSchema = z.object({ lifespan: z.number().optional(), debug_info: z.string(), device_id: z.string(), - privacy_mode_enabled: z.boolean(), num_definitions_retrieved: z.number().optional(), num_usages_retrieved: z.number().optional(), }); diff --git a/src/core/config.ts b/src/core/config.ts index 97562f2..e77433f 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -10,18 +10,10 @@ export class SweepConfig { return vscode.workspace.getConfiguration(SWEEP_CONFIG_SECTION); } - get apiKey(): string | null { - return this.config.get("apiKey", null); - } - get enabled(): boolean { return this.config.get("enabled", true); } - get privacyMode(): boolean { - return this.config.get("privacyMode", false); - } - get maxContextFiles(): number { return this.config.get( "maxContextFiles", @@ -37,10 +29,6 @@ export class SweepConfig { return this.config.get("autocompleteSnoozeUntil", 0); } - get localMode(): boolean { - return this.config.get("localMode", false); - } - get localPort(): number { return this.config.get("localPort", 8081); } @@ -76,13 +64,6 @@ export class SweepConfig { return this.config.inspect(key); } - setApiKey( - value: string | null, - target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, - ): Thenable { - return this.config.update("apiKey", value, target); - } - setEnabled( value: boolean, target: vscode.ConfigurationTarget = this.getWorkspaceTarget(), @@ -90,13 +71,6 @@ export class SweepConfig { return this.config.update("enabled", value, target); } - setPrivacyMode( - value: boolean, - target: vscode.ConfigurationTarget = this.getWorkspaceTarget(), - ): Thenable { - return this.config.update("privacyMode", value, target); - } - setAutocompleteSnoozeUntil( value: number, target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, @@ -104,13 +78,6 @@ export class SweepConfig { return this.config.update("autocompleteSnoozeUntil", value, target); } - setLocalMode( - value: boolean, - target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, - ): Thenable { - return this.config.update("localMode", value, target); - } - setLocalPort( value: number, target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, diff --git a/src/core/constants.ts b/src/core/constants.ts index d0b3acd..2ca27ae 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -3,10 +3,6 @@ export const SWEEP_FILE_SEP_TOKEN = "<|file_sep|>"; export const STOP_TOKENS = ["<|file_sep|>", ""]; // Default configuration -export const DEFAULT_API_ENDPOINT = - "https://autocomplete.sweep.dev/backend/next_edit_autocomplete"; -export const DEFAULT_METRICS_ENDPOINT = - "https://backend.app.sweep.dev/backend/track_autocomplete_metrics"; export const DEFAULT_MAX_CONTEXT_FILES = 5; // Model parameters diff --git a/src/editor/inline-edit-provider.ts b/src/editor/inline-edit-provider.ts index ed02f85..31ba51e 100644 --- a/src/editor/inline-edit-provider.ts +++ b/src/editor/inline-edit-provider.ts @@ -12,7 +12,6 @@ import type { DocumentTracker } from "~/telemetry/document-tracker.ts"; import { toUnixPath } from "~/utils/path.ts"; import { isFileTooLarge, utf8ByteOffsetAt } from "~/utils/text.ts"; -const API_KEY_PROMPT_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes const INLINE_REQUEST_DEBOUNCE_MS = 300; const MAX_FILE_CHUNK_LINES = 60; const BULK_CHANGE_LOOKBACK_MS = 1500; @@ -37,7 +36,6 @@ export class InlineEditProvider implements vscode.InlineCompletionItemProvider { private jumpEditManager: JumpEditManager; private api: ApiClient; private metricsTracker: AutocompleteMetricsTracker; - private lastApiKeyPrompt = 0; private lastInlineEdit: { uri: string; line: number; @@ -81,11 +79,6 @@ export class InlineEditProvider implements vscode.InlineCompletionItemProvider { if (!config.enabled) return undefined; if (config.isAutocompleteSnoozed()) return undefined; - if (!this.api.apiKey) { - this.promptForApiKey(); - return undefined; - } - const suppressionReason = await this.getSuppressionReason(document); if (suppressionReason) { console.log("[Sweep] Suppressing inline edit:", suppressionReason); @@ -288,13 +281,6 @@ export class InlineEditProvider implements vscode.InlineCompletionItemProvider { } } - private promptForApiKey(): void { - const now = Date.now(); - if (now - this.lastApiKeyPrompt < API_KEY_PROMPT_INTERVAL_MS) return; - this.lastApiKeyPrompt = now; - vscode.commands.executeCommand("sweep.setApiKey"); - } - private cancelInFlightRequest(reason: string): void { if (!this.inFlightRequest) return; console.log("[Sweep] Cancelling in-flight inline edit request:", reason); @@ -465,11 +451,7 @@ export class InlineEditProvider implements vscode.InlineCompletionItemProvider { version: document.version, payload: metricsPayload, }; - this.metricsTracker.trackShown(metricsPayload, { - uri: document.uri, - startOffset: result.startIndex, - endOffset: result.endIndex, - }); + this.metricsTracker.trackShown(metricsPayload); return { items: [item] }; } diff --git a/src/editor/jump-edit-manager.ts b/src/editor/jump-edit-manager.ts index edbb9cc..ac255a8 100644 --- a/src/editor/jump-edit-manager.ts +++ b/src/editor/jump-edit-manager.ts @@ -171,11 +171,7 @@ export class JumpEditManager implements vscode.Disposable { }), }; - this.metricsTracker.trackShown(this.pendingJumpEdit.metricsPayload, { - uri: document.uri, - startOffset: result.startIndex, - endOffset: result.endIndex, - }); + this.metricsTracker.trackShown(this.pendingJumpEdit.metricsPayload); this.applyDecorations(editor, document); vscode.commands.executeCommand("setContext", "sweep.hasJumpEdit", true); } diff --git a/src/extension/activate.ts b/src/extension/activate.ts index 3feac7b..877f7e3 100644 --- a/src/extension/activate.ts +++ b/src/extension/activate.ts @@ -1,7 +1,6 @@ import * as vscode from "vscode"; import { ApiClient } from "~/api/client.ts"; -import { config } from "~/core/config"; import { InlineEditProvider } from "~/editor/inline-edit-provider.ts"; import { JumpEditManager } from "~/editor/jump-edit-manager.ts"; import { @@ -19,8 +18,6 @@ import { } from "~/telemetry/autocomplete-metrics.ts"; import { DocumentTracker } from "~/telemetry/document-tracker.ts"; -const API_KEY_PROMPT_SHOWN = "sweep.apiKeyPromptShown"; - let tracker: DocumentTracker; let jumpEditManager: JumpEditManager; let provider: InlineEditProvider; @@ -29,14 +26,12 @@ let metricsTracker: AutocompleteMetricsTracker; let localServer: LocalAutocompleteServer; export function activate(context: vscode.ExtensionContext) { - promptForApiKeyIfNeeded(context); - initSyntaxHighlighter(); tracker = new DocumentTracker(); localServer = new LocalAutocompleteServer(); - const apiClient = new ApiClient(undefined, undefined, localServer); - metricsTracker = new AutocompleteMetricsTracker(apiClient); + const apiClient = new ApiClient(localServer); + metricsTracker = new AutocompleteMetricsTracker(); jumpEditManager = new JumpEditManager(metricsTracker); provider = new InlineEditProvider( tracker, @@ -62,11 +57,6 @@ export function activate(context: vscode.ExtensionContext) { }, ); - const setApiKeyCommand = vscode.commands.registerCommand( - "sweep.setApiKey", - promptSetApiKey, - ); - const acceptJumpEditCommand = vscode.commands.registerCommand( "sweep.acceptJumpEdit", () => jumpEditManager.acceptJumpEdit(), @@ -158,7 +148,6 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( providerDisposable, triggerCommand, - setApiKeyCommand, acceptJumpEditCommand, acceptInlineEditCommand, dismissJumpEditCommand, @@ -175,52 +164,10 @@ export function activate(context: vscode.ExtensionContext) { ...statusBarCommands, ); - // Auto-start local server if local mode is enabled - if (config.localMode) { - localServer.ensureServerRunning().catch((error) => { - console.error("[Sweep] Failed to auto-start local server:", error); - }); - } + // Always auto-start the local server + localServer.ensureServerRunning().catch((error) => { + console.error("[Sweep] Failed to auto-start local server:", error); + }); } export function deactivate() {} - -async function promptForApiKeyIfNeeded( - context: vscode.ExtensionContext, -): Promise { - const apiKey = config.apiKey; - - if (apiKey) return; - - const hasPrompted = context.globalState.get( - API_KEY_PROMPT_SHOWN, - false, - ); - if (hasPrompted) return; - - await promptSetApiKey(); - - await context.globalState.update(API_KEY_PROMPT_SHOWN, true); -} - -async function promptSetApiKey(): Promise { - const currentKey = config.apiKey ?? ""; - - if (!currentKey) { - vscode.env.openExternal(vscode.Uri.parse("https://app.sweep.dev/")); - } - - const result = await vscode.window.showInputBox({ - prompt: "Enter your Sweep API key", - placeHolder: currentKey ? `${currentKey.slice(0, 6)}...` : "sk-...", - ignoreFocusOut: true, - password: true, - }); - - if (result !== undefined) { - await config.setApiKey(result, vscode.ConfigurationTarget.Global); - vscode.window.showInformationMessage( - result ? "Sweep API key saved!" : "Sweep API key cleared.", - ); - } -} diff --git a/src/extension/status-bar.ts b/src/extension/status-bar.ts index 5049d5c..269e4ef 100644 --- a/src/extension/status-bar.ts +++ b/src/extension/status-bar.ts @@ -19,9 +19,7 @@ export class SweepStatusBar implements vscode.Disposable { vscode.workspace.onDidChangeConfiguration((e) => { if ( e.affectsConfiguration("sweep.enabled") || - e.affectsConfiguration("sweep.privacyMode") || - e.affectsConfiguration("sweep.autocompleteSnoozeUntil") || - e.affectsConfiguration("sweep.localMode") + e.affectsConfiguration("sweep.autocompleteSnoozeUntil") ) { this.updateStatusBar(); } @@ -33,15 +31,10 @@ export class SweepStatusBar implements vscode.Disposable { private updateStatusBar(): void { const isEnabled = config.enabled; - const privacyMode = config.privacyMode; const isSnoozed = config.isAutocompleteSnoozed(); this.statusBarItem.text = "$(sweep-icon) Sweep"; - this.statusBarItem.tooltip = this.buildTooltip( - isEnabled, - privacyMode, - isSnoozed, - ); + this.statusBarItem.tooltip = this.buildTooltip(isEnabled, isSnoozed); if (!isEnabled || isSnoozed) { this.statusBarItem.backgroundColor = new vscode.ThemeColor( @@ -52,19 +45,13 @@ export class SweepStatusBar implements vscode.Disposable { } } - private buildTooltip( - isEnabled: boolean, - privacyMode: boolean, - isSnoozed: boolean, - ): string { + private buildTooltip(isEnabled: boolean, isSnoozed: boolean): string { const status = isEnabled ? "Enabled" : "Disabled"; - const privacy = privacyMode ? "On" : "Off"; - const localMode = config.localMode ? "On" : "Off"; const snoozeUntil = config.autocompleteSnoozeUntil; const snoozeLine = isSnoozed ? `Snoozed Until: ${formatSnoozeTime(snoozeUntil)}` : "Snoozed: Off"; - return `Sweep Next Edit\nStatus: ${status}\nPrivacy Mode: ${privacy}\nLocal Mode: ${localMode}\n${snoozeLine}\n\nClick to open menu`; + return `Sweep Next Edit\nStatus: ${status}\n${snoozeLine}\n\nClick to open menu`; } dispose(): void { @@ -84,8 +71,6 @@ export function registerStatusBarCommands( disposables.push( vscode.commands.registerCommand("sweep.showMenu", async () => { const isEnabled = config.enabled; - const privacyMode = config.privacyMode; - const localMode = config.localMode; const isSnoozed = config.isAutocompleteSnoozed(); interface MenuItem extends vscode.QuickPickItem { @@ -98,25 +83,6 @@ export function registerStatusBarCommands( description: isEnabled ? "Enabled" : "Disabled", action: "toggleEnabled", }, - { - label: `$(${privacyMode ? "check" : "circle-outline"}) Privacy Mode`, - description: privacyMode - ? "Completions not used for training" - : "Completions may be used for training", - action: "togglePrivacy", - }, - { - label: `$(${localMode ? "check" : "circle-outline"}) Local Mode`, - description: localMode - ? "Using local autocomplete server" - : "Using cloud API", - action: "toggleLocalMode", - }, - { - label: "$(key) Set API Key", - description: "Configure your Sweep API key", - action: "setApiKey", - }, { label: isSnoozed ? "$(play-circle) Resume Autocomplete" @@ -131,11 +97,6 @@ export function registerStatusBarCommands( description: "Manually start the local autocomplete server", action: "startLocalServer", }, - { - label: "$(link-external) Open Sweep Dashboard", - description: "https://app.sweep.dev", - action: "openDashboard", - }, ]; const selection = await vscode.window.showQuickPick(items, { @@ -148,25 +109,6 @@ export function registerStatusBarCommands( case "toggleEnabled": await vscode.commands.executeCommand("sweep.toggleEnabled"); break; - case "togglePrivacy": - await vscode.commands.executeCommand("sweep.togglePrivacyMode"); - break; - case "toggleLocalMode": { - const current = config.localMode; - await config.setLocalMode(!current); - vscode.window.showInformationMessage( - `Sweep local mode ${!current ? "enabled" : "disabled"}`, - ); - break; - } - case "setApiKey": - await vscode.commands.executeCommand("sweep.setApiKey"); - break; - case "openDashboard": - await vscode.env.openExternal( - vscode.Uri.parse("https://app.sweep.dev"), - ); - break; case "snooze": await handleSnooze(); break; @@ -219,21 +161,6 @@ export function registerStatusBarCommands( }), ); - disposables.push( - vscode.commands.registerCommand("sweep.togglePrivacyMode", async () => { - const inspection = config.inspect("privacyMode"); - const current = - inspection?.workspaceValue ?? - inspection?.globalValue ?? - inspection?.defaultValue ?? - false; - await config.setPrivacyMode(!current); - vscode.window.showInformationMessage( - `Privacy mode ${!current ? "enabled" : "disabled"}`, - ); - }), - ); - return disposables; } diff --git a/src/services/local-server.ts b/src/services/local-server.ts index 50dc611..d656b88 100644 --- a/src/services/local-server.ts +++ b/src/services/local-server.ts @@ -113,6 +113,8 @@ export class LocalAutocompleteServer implements vscode.Disposable { } private async spawnServer(uvxPath: string): Promise { + this.stopServer(); + const port = config.localPort; console.log( `[Sweep] Starting local autocomplete server on port ${port}...`, diff --git a/src/telemetry/autocomplete-metrics.ts b/src/telemetry/autocomplete-metrics.ts index ffa63d1..387b528 100644 --- a/src/telemetry/autocomplete-metrics.ts +++ b/src/telemetry/autocomplete-metrics.ts @@ -1,15 +1,6 @@ import * as vscode from "vscode"; -import type { ApiClient } from "~/api/client.ts"; -import type { - AutocompleteEventType, - AutocompleteMetricsRequest, - AutocompleteResult, - SuggestionType, -} from "~/api/schemas.ts"; -import { config } from "~/core/config"; -import { applyContentChangeToTrackedOffsets } from "~/telemetry/edit-tracking-anchor.ts"; -import { toUnixPath } from "~/utils/path.ts"; +import type { AutocompleteResult, SuggestionType } from "~/api/schemas.ts"; export interface AutocompleteMetricsPayload { id: string; @@ -20,248 +11,20 @@ export interface AutocompleteMetricsPayload { numUsagesRetrieved?: number; } -const EVENT_SHOWN: AutocompleteEventType = "autocomplete_suggestion_shown"; -const EVENT_ACCEPTED: AutocompleteEventType = - "autocomplete_suggestion_accepted"; -const EVENT_DISPOSED: AutocompleteEventType = - "autocomplete_suggestion_disposed"; -const EVENT_EDIT_TRACKING: AutocompleteEventType = "autocomplete_edit_tracking"; -const MAX_SHOWN_IDS = 1000; -const EDIT_TRACKING_INTERVALS_SECONDS = [15, 30, 60, 120, 300]; - -interface EditTrackingContext { - uri: vscode.Uri; - startOffset: number; - endOffset: number; -} - -interface EditTrackingAnchor { - uri: string; - startOffset: number; - endOffset: number; -} - export class AutocompleteMetricsTracker implements vscode.Disposable { - private api: ApiClient; private shownIds = new Set(); - private shownAt = new Map(); - private editTrackingTimers = new Map(); - private editTrackingAnchors = new Map(); - private disposables: vscode.Disposable[] = []; - - constructor(api: ApiClient) { - this.api = api; - this.disposables.push( - vscode.workspace.onDidChangeTextDocument((event) => { - this.handleDocumentChange(event); - }), - ); - } dispose(): void { this.shownIds.clear(); - this.shownAt.clear(); - for (const timers of this.editTrackingTimers.values()) { - for (const timer of timers) { - clearTimeout(timer); - } - } - this.editTrackingTimers.clear(); - this.editTrackingAnchors.clear(); - for (const disposable of this.disposables) { - disposable.dispose(); - } - this.disposables = []; } - trackShown( - payload: AutocompleteMetricsPayload, - context?: EditTrackingContext, - ): void { - if (this.shownIds.has(payload.id)) { - return; - } + trackShown(payload: AutocompleteMetricsPayload): void { this.shownIds.add(payload.id); - this.shownAt.set(payload.id, Date.now()); - if (this.shownIds.size > MAX_SHOWN_IDS) { - const iter = this.shownIds.values().next(); - if (!iter.done) { - const oldestId = iter.value; - this.shownIds.delete(oldestId); - this.shownAt.delete(oldestId); - this.clearEditTrackingTimers(oldestId); - } - } - if (context) { - this.editTrackingAnchors.set(payload.id, { - uri: context.uri.toString(), - startOffset: context.startOffset, - endOffset: Math.max(context.startOffset, context.endOffset), - }); - } - this.trackEvent(EVENT_SHOWN, payload); - this.scheduleEditTracking(payload, context); } - trackAccepted(payload: AutocompleteMetricsPayload): void { - this.trackEvent(EVENT_ACCEPTED, payload); - } + trackAccepted(_payload: AutocompleteMetricsPayload): void {} - trackDisposed(payload: AutocompleteMetricsPayload): void { - const shownTime = this.shownAt.get(payload.id); - const lifespan = shownTime ? Date.now() - shownTime : null; - this.clearEditTrackingTimers(payload.id); - const extras: AutocompleteMetricsExtras = {}; - if (lifespan !== null) { - extras.lifespan = lifespan; - } - this.trackEvent(EVENT_DISPOSED, payload, extras); - } - - private trackEvent( - eventType: AutocompleteEventType, - payload: AutocompleteMetricsPayload, - extra?: AutocompleteMetricsExtras, - ) { - if (!payload.id) { - return; - } - - if (!this.api.apiKey) { - return; - } - - const privacyModeEnabled = config.privacyMode; - - const numDefinitionsRetrieved = payload.numDefinitionsRetrieved ?? -1; - const numUsagesRetrieved = payload.numUsagesRetrieved ?? -1; - - void this.api - .trackAutocompleteMetrics({ - event_type: eventType, - suggestion_type: payload.suggestionType, - additions: payload.additions, - deletions: payload.deletions, - autocomplete_id: payload.id, - ...extra, - debug_info: this.api.getDebugInfo(), - device_id: vscode.env.machineId, - privacy_mode_enabled: privacyModeEnabled, - num_definitions_retrieved: numDefinitionsRetrieved, - num_usages_retrieved: numUsagesRetrieved, - }) - .catch((error) => { - console.error("[Sweep] Metrics tracking failed:", error); - }); - } - - private scheduleEditTracking( - payload: AutocompleteMetricsPayload, - context?: EditTrackingContext, - ): void { - if (!context) return; - - const privacyModeEnabled = config.privacyMode; - if (privacyModeEnabled) return; - - const timers = EDIT_TRACKING_INTERVALS_SECONDS.map((intervalSeconds) => - setTimeout(() => { - void this.captureEditTrackingSnapshot( - payload, - context, - intervalSeconds, - ); - }, intervalSeconds * 1000), - ); - this.editTrackingTimers.set(payload.id, timers); - } - - private clearEditTrackingTimers(autocompleteId: string): void { - const timers = this.editTrackingTimers.get(autocompleteId); - if (!timers) return; - for (const timer of timers) { - clearTimeout(timer); - } - this.editTrackingTimers.delete(autocompleteId); - this.editTrackingAnchors.delete(autocompleteId); - } - - private async captureEditTrackingSnapshot( - payload: AutocompleteMetricsPayload, - context: EditTrackingContext, - intervalSeconds: number, - ): Promise { - try { - const document = await vscode.workspace.openTextDocument(context.uri); - const text = document.getText(); - const anchor = this.editTrackingAnchors.get(payload.id); - const editTrackingLine = this.buildEditTrackingLine( - document, - anchor?.startOffset ?? context.startOffset, - anchor?.endOffset ?? context.endOffset, - ); - const snapshotPayload: AutocompleteMetricsExtras = {}; - if (intervalSeconds === 30) snapshotPayload.edit_tracking = text; - if (intervalSeconds === 15) snapshotPayload.edit_tracking_15 = text; - if (intervalSeconds === 30) snapshotPayload.edit_tracking_30 = text; - if (intervalSeconds === 60) snapshotPayload.edit_tracking_60 = text; - if (intervalSeconds === 120) snapshotPayload.edit_tracking_120 = text; - if (intervalSeconds === 300) snapshotPayload.edit_tracking_300 = text; - if (editTrackingLine) { - snapshotPayload.edit_tracking_line = editTrackingLine; - } - - this.trackEvent(EVENT_EDIT_TRACKING, payload, snapshotPayload); - } catch (error) { - console.error("[Sweep] Edit tracking snapshot failed:", error); - } - } - - private buildEditTrackingLine( - document: vscode.TextDocument, - startOffset: number, - endOffset: number, - ): AutocompleteMetricsExtras["edit_tracking_line"] | null { - const documentLength = document.getText().length; - const safeStartOffset = Math.max(0, Math.min(startOffset, documentLength)); - const safeEndOffset = Math.max( - safeStartOffset, - Math.min(endOffset, documentLength), - ); - const startPos = document.positionAt(safeStartOffset); - const endPos = document.positionAt(safeEndOffset); - const content = document.getText(new vscode.Range(startPos, endPos)); - return { - file_path: toUnixPath(document.uri.fsPath), - start_line: startPos.line, - end_line: endPos.line, - content, - }; - } - - private handleDocumentChange(event: vscode.TextDocumentChangeEvent): void { - if (event.contentChanges.length === 0) return; - const uri = event.document.uri.toString(); - for (const [id, anchor] of this.editTrackingAnchors.entries()) { - if (anchor.uri !== uri) continue; - let nextOffsets = { - startOffset: anchor.startOffset, - endOffset: anchor.endOffset, - }; - for (const change of event.contentChanges) { - nextOffsets = applyContentChangeToTrackedOffsets(nextOffsets, { - rangeOffset: change.rangeOffset, - rangeLength: change.rangeLength, - text: change.text, - }); - } - this.editTrackingAnchors.set(id, { - uri: anchor.uri, - startOffset: nextOffsets.startOffset, - endOffset: nextOffsets.endOffset, - }); - } - } + trackDisposed(_payload: AutocompleteMetricsPayload): void {} } export function buildMetricsPayload( @@ -292,17 +55,3 @@ export function computeAdditionsDeletions( const additions = Math.max(result.completion.split("\n").length, 1); return { additions, deletions }; } - -type AutocompleteMetricsExtras = Partial< - Pick< - AutocompleteMetricsRequest, - | "edit_tracking" - | "edit_tracking_15" - | "edit_tracking_30" - | "edit_tracking_60" - | "edit_tracking_120" - | "edit_tracking_300" - | "edit_tracking_line" - | "lifespan" - > ->;