diff --git a/package.json b/package.json index 9990241..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, @@ -145,6 +127,11 @@ "type": "number", "default": 0, "description": "Unix timestamp (ms) until which autocomplete is snoozed (0 disables snooze)" + }, + "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..ea9a948 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,14 +1,8 @@ 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 { isFileTooLarge, @@ -20,8 +14,6 @@ import { truncateRetrievalChunk, } from "./retrieval-chunks.ts"; import { - type AutocompleteMetricsRequest, - AutocompleteMetricsRequestSchema, type AutocompleteRequest, AutocompleteRequestSchema, type AutocompleteResponse, @@ -54,26 +46,16 @@ const MAX_CLIPBOARD_LINES = 20; const MAX_DIAGNOSTICS = 50; export class ApiClient { - private apiUrl: string; - private metricsUrl: string; - - constructor( - apiUrl: string = DEFAULT_API_ENDPOINT, - metricsUrl: string = DEFAULT_METRICS_ENDPOINT, - ) { - this.apiUrl = apiUrl; - this.metricsUrl = metricsUrl; + private localServer: LocalAutocompleteServer; + + constructor(localServer: LocalAutocompleteServer) { + this.localServer = localServer; } async getAutocomplete( input: AutocompleteInput, signal?: AbortSignal, ): Promise { - const apiKey = this.apiKey; - if (!apiKey) { - return null; - } - const documentText = input.document.getText(); if (isFileTooLarge(documentText) || isFileTooLarge(input.originalContent)) { console.log("[Sweep] Skipping autocomplete request: file too large", { @@ -94,20 +76,29 @@ export class ApiClient { return null; } - const compressed = await this.compress(JSON.stringify(parsedRequest.data)); let response: AutocompleteResponse; + 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.sendRequest( - compressed, - apiKey, + JSON.stringify(parsedRequest.data), + localUrl, AutocompleteResponseSchema, signal, ); + this.localServer.reportSuccess(); } catch (error) { if ((error as Error).name === "AbortError") { return null; } - console.error("[Sweep] API request failed:", error); + console.error("[Sweep] Local API request failed:", error); + this.localServer.reportFailure(); return null; } @@ -147,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 { @@ -213,7 +180,6 @@ export class ApiClient { editor_diagnostics: editorDiagnostics, recent_user_actions: userActions, use_bytes: true, - privacy_mode_enabled: config.privacyMode, }; } @@ -492,24 +458,9 @@ 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, + body: string, + url: string, schema: ZodType, signal?: AbortSignal, ): Promise { @@ -522,25 +473,19 @@ export class ApiClient { fn(); }; - const url = new URL(this.apiUrl); - const isHttps = url.protocol === "https:"; - const defaultPort = isHttps ? 443 : 80; - + const parsedUrl = new URL(url); const options: http.RequestOptions = { - hostname: url.hostname, - port: url.port || defaultPort, - path: `${url.pathname}${url.search}`, + hostname: parsedUrl.hostname, + port: parsedUrl.port || 80, + path: `${parsedUrl.pathname}${parsedUrl.search}`, method: "POST", headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - "Content-Encoding": "br", - "Content-Length": body.length, + "Content-Length": Buffer.byteLength(body), }, }; - const transport = isHttps ? https : http; - const req = transport.request(options, (res) => { + const req = http.request(options, (res) => { let data = ""; res.on("data", (chunk) => { data += chunk.toString(); @@ -548,11 +493,11 @@ export class ApiClient { res.on("end", () => { if (res.statusCode !== 200) { console.error( - `[Sweep] API request failed with status ${res.statusCode}: ${data}`, + `[Sweep] Local request failed with status ${res.statusCode}: ${data}`, ); finish(() => reject( - new Error(`API request failed with status ${res.statusCode}`), + new Error(`Local request failed with status ${res.statusCode}`), ), ); return; @@ -563,7 +508,7 @@ export class ApiClient { if (!parsed.success) { finish(() => reject( - new Error(`Invalid API response: ${parsed.error.message}`), + new Error(`Invalid local response: ${parsed.error.message}`), ), ); return; @@ -571,14 +516,16 @@ export class ApiClient { finish(() => resolve(parsed.data)); } catch { finish(() => - reject(new Error("Failed to parse API response JSON")), + reject(new Error("Failed to parse local response JSON")), ); } }); }); const onError = (error: Error) => { - finish(() => reject(new Error(`API request error: ${error.message}`))); + finish(() => + reject(new Error(`Local request error: ${error.message}`)), + ); }; const onAbort = () => { @@ -609,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 f9a7246..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,6 +29,10 @@ export class SweepConfig { return this.config.get("autocompleteSnoozeUntil", 0); } + get localPort(): number { + return this.config.get("localPort", 8081); + } + isAutocompleteSnoozed(now = Date.now()): boolean { const snoozeUntil = this.autocompleteSnoozeUntil; return snoozeUntil > now; @@ -68,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(), @@ -82,18 +71,18 @@ export class SweepConfig { return this.config.update("enabled", value, target); } - setPrivacyMode( - value: boolean, - target: vscode.ConfigurationTarget = this.getWorkspaceTarget(), + setAutocompleteSnoozeUntil( + value: number, + target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, ): Thenable { - return this.config.update("privacyMode", value, target); + return this.config.update("autocompleteSnoozeUntil", value, target); } - setAutocompleteSnoozeUntil( + setLocalPort( value: number, target: vscode.ConfigurationTarget = vscode.ConfigurationTarget.Global, ): Thenable { - return this.config.update("autocompleteSnoozeUntil", value, target); + return this.config.update("localPort", value, target); } private getWorkspaceTarget(): vscode.ConfigurationTarget { 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 68286a1..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 { @@ -12,28 +11,27 @@ import { registerStatusBarCommands, SweepStatusBar, } from "~/extension/status-bar.ts"; +import { LocalAutocompleteServer } from "~/services/local-server.ts"; import { type AutocompleteMetricsPayload, AutocompleteMetricsTracker, } 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; let statusBar: SweepStatusBar; let metricsTracker: AutocompleteMetricsTracker; +let localServer: LocalAutocompleteServer; export function activate(context: vscode.ExtensionContext) { - promptForApiKeyIfNeeded(context); - initSyntaxHighlighter(); tracker = new DocumentTracker(); - const apiClient = new ApiClient(); - metricsTracker = new AutocompleteMetricsTracker(apiClient); + localServer = new LocalAutocompleteServer(); + const apiClient = new ApiClient(localServer); + metricsTracker = new AutocompleteMetricsTracker(); jumpEditManager = new JumpEditManager(metricsTracker); provider = new InlineEditProvider( tracker, @@ -59,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(), @@ -94,7 +87,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) { @@ -155,7 +148,6 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push( providerDisposable, triggerCommand, - setApiKeyCommand, acceptJumpEditCommand, acceptInlineEditCommand, dismissJumpEditCommand, @@ -168,48 +160,14 @@ export function activate(context: vscode.ExtensionContext) { jumpEditManager, metricsTracker, statusBar, + localServer, ...statusBarCommands, ); -} - -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, + // Always auto-start the local server + localServer.ensureServerRunning().catch((error) => { + console.error("[Sweep] Failed to auto-start local server:", error); }); - - if (result !== undefined) { - await config.setApiKey(result, vscode.ConfigurationTarget.Global); - vscode.window.showInformationMessage( - result ? "Sweep API key saved!" : "Sweep API key cleared.", - ); - } } + +export function deactivate() {} diff --git a/src/extension/status-bar.ts b/src/extension/status-bar.ts index 3b9decc..269e4ef 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; @@ -18,7 +19,6 @@ export class SweepStatusBar implements vscode.Disposable { vscode.workspace.onDidChangeConfiguration((e) => { if ( e.affectsConfiguration("sweep.enabled") || - e.affectsConfiguration("sweep.privacyMode") || e.affectsConfiguration("sweep.autocompleteSnoozeUntil") ) { this.updateStatusBar(); @@ -31,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( @@ -50,18 +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 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}\n${snoozeLine}\n\nClick to open menu`; } dispose(): void { @@ -74,13 +64,13 @@ export class SweepStatusBar implements vscode.Disposable { export function registerStatusBarCommands( _context: vscode.ExtensionContext, + localServer?: LocalAutocompleteServer, ): vscode.Disposable[] { const disposables: vscode.Disposable[] = []; disposables.push( vscode.commands.registerCommand("sweep.showMenu", async () => { const isEnabled = config.enabled; - const privacyMode = config.privacyMode; const isSnoozed = config.isAutocompleteSnoozed(); interface MenuItem extends vscode.QuickPickItem { @@ -93,18 +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: "$(key) Set API Key", - description: "Configure your Sweep API key", - action: "setApiKey", - }, { label: isSnoozed ? "$(play-circle) Resume Autocomplete" @@ -115,9 +93,9 @@ export function registerStatusBarCommands( action: isSnoozed ? "resumeSnooze" : "snooze", }, { - label: "$(link-external) Open Sweep Dashboard", - description: "https://app.sweep.dev", - action: "openDashboard", + label: "$(server) Start Local Server", + description: "Manually start the local autocomplete server", + action: "startLocalServer", }, ]; @@ -131,23 +109,30 @@ export function registerStatusBarCommands( case "toggleEnabled": await vscode.commands.executeCommand("sweep.toggleEnabled"); break; - case "togglePrivacy": - await vscode.commands.executeCommand("sweep.togglePrivacyMode"); - 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; 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; } } }), @@ -176,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 new file mode 100644 index 0000000..d656b88 --- /dev/null +++ b/src/services/local-server.ts @@ -0,0 +1,261 @@ +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 { + this.stopServer(); + + 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)); +} 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" - > ->;