Update CHANGELOG for version 0.0.2 and implement workspace-specific conversation storage

- Added per-workspace conversations with automatic migration from global conversations.
- Implemented auto-loading of GGUF models with a loading card in chat.
- Enhanced llama.cpp server to use random free ports with retry on bind failure.
- Improved composer dropdown positioning and shared model selection across composers.
- Fixed production error related to missing '@huggingface/hub' package.
- Removed MCP tool marketplace.
- Updated version in package.json to 0.0.2.
This commit is contained in:
Pawan Osman
2026-07-05 23:30:49 +03:00
parent 7bb5801a8c
commit f8ea587099
17 changed files with 283 additions and 306 deletions
+61 -18
View File
@@ -20,9 +20,10 @@ import * as fs from "fs/promises";
import { createWriteStream } from "fs";
import * as path from "path";
import * as os from "os";
import * as net from "net";
import { spawn, execFile } from "child_process";
import * as vscode from "vscode";
import { ensureRuntimeDeps } from "../runtimeDeps";
import { importRuntimeDep } from "../runtimeDeps";
/**
* llama-server launch configuration. Used both as the global default and as a
@@ -132,7 +133,18 @@ const LEGACY_BIN = "llama-server";
/** Resolved at install-check time: argv prefix to launch the server. */
let serverCmd: { bin: string; pre: string[] } = { bin: UNIFIED_BIN, pre: ["serve"] };
const MAX_LOG_LINES = 500;
let BASE_PORT = 8080;
/** Ask the OS for a free ephemeral port (bind :0, read the assigned port). */
function getFreePort(host = "127.0.0.1"): Promise<number> {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.once("error", reject);
srv.listen(0, host, () => {
const port = (srv.address() as net.AddressInfo).port;
srv.close(() => resolve(port));
});
});
}
let modelsDir: string | undefined;
let extCtx: vscode.ExtensionContext | undefined;
@@ -225,8 +237,7 @@ export async function installLlamacpp(): Promise<void> {
// ---- HF GGUF search ----
export async function searchGguf(query: string, limit = 20): Promise<HfGgufResult[]> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const hub = await import("@huggingface/hub");
const hub = await importRuntimeDep("@huggingface/hub");
const out: HfGgufResult[] = [];
for await (const m of hub.listModels({
search: { query, tags: ["gguf"] },
@@ -240,8 +251,7 @@ export async function searchGguf(query: string, limit = 20): Promise<HfGgufResul
/** List the .gguf files inside a repo so the user can pick a quantization. */
export async function listRepoGgufFiles(repo: string): Promise<HfGgufResult[]> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const hub = await import("@huggingface/hub");
const hub = await importRuntimeDep("@huggingface/hub");
const files: HfGgufResult[] = [];
for await (const f of hub.listFiles({ repo, recursive: true })) {
if (f.type === "file" && /\.gguf$/i.test(f.path)) {
@@ -297,7 +307,6 @@ export async function importGguf(srcPath: string): Promise<LlamacppModel> {
return makeModel({ file: base, filePath: dest, sizeBytes: stat.size, name: path.basename(base, ".gguf") });
}
let portCursor = 0;
function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes?: number; name: string }): LlamacppModel {
return {
id: modelId(p.repo, p.file),
@@ -306,7 +315,7 @@ function makeModel(p: { repo?: string; file: string; filePath: string; sizeBytes
repo: p.repo,
file: p.file,
sizeBytes: p.sizeBytes,
port: BASE_PORT + (portCursor++ % 100),
port: 0, // assigned per-load: a fresh random free port every time
autoLoad: false,
};
}
@@ -347,9 +356,11 @@ export function effectiveContextLength(m: LlamacppModel, globalCtx: number): num
return cfg.ctxSize ?? globalCtx;
}
/** Effective bind port: per-model config override (when set) else the model's assigned port. */
/** Effective bind port: the running server's port, else a per-model config override. */
function effectivePort(m: LlamacppModel, cfg: LlamacppServerConfig): number {
return m.useCustomConfig && cfg.port ? cfg.port : m.port;
const r = running.get(m.id);
if (r) return r.port;
return (m.useCustomConfig && cfg.port) || 0;
}
/** Base URL of a model's local OpenAI-compatible server (no trailing slash). */
@@ -360,8 +371,8 @@ export function serverUrlFor(m: LlamacppModel, globalCfg?: LlamacppServerConfig)
}
/** Build the llama-server argv from a model + effective config. */
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig): string[] {
const args: string[] = ["-m", m.filePath, "--port", String(effectivePort(m, cfg)), "--host", cfg.host || "127.0.0.1"];
function buildArgs(m: LlamacppModel, cfg: LlamacppServerConfig, port: number): string[] {
const args: string[] = ["-m", m.filePath, "--port", String(port), "--host", cfg.host || "127.0.0.1"];
if (cfg.ctxSize != null) args.push("--ctx-size", String(cfg.ctxSize));
args.push(cfg.jinja === false ? "--no-jinja" : "--jinja");
if (cfg.flashAttn) args.push("-fa", cfg.flashAttn);
@@ -390,19 +401,53 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
errors.delete(m.id);
logs.set(m.id, []); // fresh log per load
loading.set(m.id, true);
emit(); // surface loading state immediately
// Back-compat: callers used to pass a global context length number.
const gcfg: LlamacppServerConfig | undefined =
typeof globalCfg === "number" ? { ctxSize: globalCfg } : globalCfg;
const cfg = effectiveConfig(m, gcfg);
const port = effectivePort(m, cfg);
const host = cfg.host && cfg.host !== "0.0.0.0" ? cfg.host : "127.0.0.1";
const argv = [...serverCmd.pre, ...buildArgs(m, cfg)];
// Always launch on a fresh OS-assigned random port. If the server still
// fails to bind (TOCTOU race with another process), retry with a new one.
const MAX_BIND_TRIES = 3;
let lastErr: Error | null = null;
for (let attempt = 1; attempt <= MAX_BIND_TRIES; attempt++) {
let port: number;
try {
port = await getFreePort(cfg.host || "127.0.0.1");
} catch (e: any) {
lastErr = new Error(`could not find a free port: ${e?.message || e}`);
break;
}
try {
await spawnServer(m, cfg, host, port);
return; // loaded
} catch (e: any) {
lastErr = e instanceof Error ? e : new Error(String(e));
// Bind failure → retry on a new random port; anything else is fatal.
const bindFail = /couldn't bind|address already in use|EADDRINUSE|HTTP server error/i.test(lastErr.message) ||
(logs.get(m.id) || []).some((l) => /couldn't bind|address already in use/i.test(l));
if (!bindFail) break;
appendLog(m.id, `[retry] port ${port} unavailable, trying a new random port (${attempt}/${MAX_BIND_TRIES})`);
}
}
loading.delete(m.id);
const msg = lastErr?.message || "failed to start server";
errors.set(m.id, msg);
emit();
throw new Error(msg);
}
/** Spawn one llama-server on `port` and resolve when /health reports ready. */
function spawnServer(m: LlamacppModel, cfg: LlamacppServerConfig, host: string, port: number): Promise<void> {
const argv = [...serverCmd.pre, ...buildArgs(m, cfg, port)];
appendLog(m.id, `$ ${serverCmd.bin} ${argv.join(" ")}`);
const proc = spawn(serverCmd.bin, argv, { stdio: ["ignore", "pipe", "pipe"] });
running.set(m.id, { proc, port });
proc.stdout?.on("data", (b) => appendLog(m.id, b.toString()));
proc.stderr?.on("data", (b) => appendLog(m.id, b.toString()));
emit(); // surface loading state immediately
emit();
let resolved = false;
return new Promise<void>((resolve, reject) => {
@@ -410,8 +455,6 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
if (resolved) return;
resolved = true;
running.delete(m.id);
loading.delete(m.id);
errors.set(m.id, msg);
appendLog(m.id, `[error] ${msg}`);
emit();
reject(new Error(msg));
@@ -432,7 +475,7 @@ export async function loadModel(m: LlamacppModel, globalCfg?: LlamacppServerConf
if (r.ok) {
resolved = true;
loading.delete(m.id);
appendLog(m.id, "[ready] model loaded");
appendLog(m.id, `[ready] model loaded on port ${port}`);
emit();
resolve();
return;
+23 -1
View File
@@ -311,10 +311,32 @@ function parseTitle(content: string): string {
}
/** Auto mode judge: pick the best-suited model id from candidates for a task. */
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean): Promise<string> {
export async function pickModel(apiBaseUrl: string, apiKey: string, judge: string, candidates: string[], task: string, anthropic?: boolean, oauthKind?: OAuthKind): Promise<string> {
const useAnthropic = anthropic ?? isAnthropic(apiBaseUrl);
const sys = `You route a coding task to the best model. Available models: ${candidates.join(", ")}. Reply with EXACTLY one model id from the list, nothing else.`;
const prompt = task.slice(0, 2000);
if (oauthKind) {
// OAuth judges (Claude Code / Codex) have no raw HTTP endpoint; stream a tiny completion.
let text = "";
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 30_000);
try {
const gen = streamOAuthChat(oauthKind, {
model: judge,
messages: [{ role: "system", content: sys }, { role: "user", content: prompt }],
maxTokens: 64,
signal: ctrl.signal,
});
for await (const ev of gen) {
if (ev.type === "text-delta") text += ev.text;
}
} finally {
clearTimeout(timer);
}
// Reasoning models may emit <think> blocks; last non-empty line is the answer.
const lines = text.replace(/<think>[\s\S]*?<\/think>/gi, "").split("\n").map((l) => l.trim()).filter(Boolean);
return lines.length ? lines[lines.length - 1] : text.trim();
}
if (useAnthropic) {
const r = await fetch(`${apiBaseUrl}/messages`, {
method: "POST",
+2 -3
View File
@@ -21,7 +21,7 @@ import * as fs from "fs/promises";
import * as path from "path";
import * as crypto from "crypto";
import { walk } from "./tools/shared";
import { ensureRuntimeDeps } from "../runtimeDeps";
import { importRuntimeDep } from "../runtimeDeps";
// Selectable local embedding models. Add entries here to offer more choices.
export interface EmbedModel {
@@ -111,8 +111,7 @@ async function getExtractor(): Promise<any | null> {
if (!extractorP) {
const m = activeModel;
extractorP = (async () => {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
const t = await import("@huggingface/transformers");
const t = await importRuntimeDep("@huggingface/transformers");
t.env.allowRemoteModels = true;
t.env.cacheDir = path.join(storageDir!, "models");
return t.pipeline("feature-extraction", m.repo, { dtype: m.dtype });
-30
View File
@@ -295,33 +295,3 @@ export class McpManager {
}
export const mcpManager = new McpManager();
/** A server entry from the official MCP registry (registry.modelcontextprotocol.io). */
export interface RegistryServer {
name: string;
title?: string;
description?: string;
version?: string;
packages?: {
registryType?: string;
identifier?: string;
version?: string;
runtimeHint?: string;
transport?: { type?: string };
runtimeArguments?: { type?: string; name?: string; value?: string }[];
packageArguments?: { type?: string; name?: string; value?: string }[];
environmentVariables?: { name?: string; description?: string; isRequired?: boolean; isSecret?: boolean }[];
}[];
}
/**
* Search the official MCP registry. Runs in the extension host (not the webview)
* so it isn't blocked by the webview CSP. Empty query returns a default listing.
*/
export async function searchMcpRegistry(query: string, limit = 30): Promise<RegistryServer[]> {
const url = `https://registry.modelcontextprotocol.io/v0.1/servers?limit=${limit}${query.trim() ? `&search=${encodeURIComponent(query.trim())}` : ""}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`registry ${r.status}`);
const data = (await r.json()) as { servers?: { server: RegistryServer }[] };
return (data.servers ?? []).map((s) => s.server);
}
+27
View File
@@ -19,6 +19,7 @@ import * as fs from "fs";
import * as path from "path";
import * as zlib from "zlib";
import * as crypto from "crypto";
import { pathToFileURL } from "url";
const REGISTRY = "https://registry.npmjs.org";
@@ -252,6 +253,32 @@ export function ensureRuntimeDeps(): Promise<boolean> {
return readyP;
}
/** Resolve a package's ESM entry point from its package.json. */
function entryOf(pkg: any): string {
const pick = (v: any): string | undefined => {
if (typeof v === "string") return v;
if (v && typeof v === "object") return pick(v.import ?? v.node ?? v.default ?? v.require);
return undefined;
};
return pick(pkg.exports?.["."] ?? pkg.exports) || pkg.module || pkg.main || "index.js";
}
/**
* Import a runtime dep. In production the extension host's ESM resolver can't
* see runtime-deps/node_modules (the CJS require hook doesn't apply to
* import()), so we resolve the entry file ourselves and import it by file URL.
*/
export async function importRuntimeDep<T = any>(name: string): Promise<T> {
if (!(await ensureRuntimeDeps())) throw new Error("runtime deps unavailable");
try {
return await import(name); // dev: real node_modules next to us
} catch { /* fall back to runtime-deps dir */ }
if (!rootDir) throw new Error("runtime deps not initialized");
const dir = path.join(rootDir, "node_modules", ...name.split("/"));
const pkg = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
return await import(pathToFileURL(path.join(dir, entryOf(pkg))).href);
}
// Self-check: OPENCURSOR_SELFCHECK=1 node -e "require('./dist/extension.js')"
if (process.env.OPENCURSOR_SELFCHECK) {
const assert = (c: boolean, m: string) => { if (!c) throw new Error("selfcheck: " + m); };
+26 -5
View File
@@ -35,14 +35,35 @@ const KEY = "ocursor.conversations";
const ACTIVE_KEY = "ocursor.activeConversation";
export class ConversationStore {
constructor(private readonly context: vscode.ExtensionContext) {}
constructor(private readonly context: vscode.ExtensionContext) {
void this.migrateFromGlobal();
}
/** workspaceState = per-workspace storage; VS Code scopes it for us. */
private get state(): vscode.Memento {
return this.context.workspaceState;
}
/** One-time: move old globalState conversations into this workspace. */
private async migrateFromGlobal(): Promise<void> {
const old = this.context.globalState.get<Conversation[]>(KEY);
if (!old?.length || this.state.get<Conversation[]>(KEY)?.length) {
if (old) await this.context.globalState.update(KEY, undefined);
return;
}
await this.state.update(KEY, old);
const active = this.context.globalState.get<string>(ACTIVE_KEY);
if (active) await this.state.update(ACTIVE_KEY, active);
await this.context.globalState.update(KEY, undefined);
await this.context.globalState.update(ACTIVE_KEY, undefined);
}
private all(): Conversation[] {
return this.context.globalState.get<Conversation[]>(KEY, []);
return this.state.get<Conversation[]>(KEY, []);
}
private async persist(list: Conversation[]): Promise<void> {
await this.context.globalState.update(KEY, list);
await this.state.update(KEY, list);
}
list(): ConversationSummary[] {
@@ -57,11 +78,11 @@ export class ConversationStore {
}
getActiveId(): string | undefined {
return this.context.globalState.get<string>(ACTIVE_KEY);
return this.state.get<string>(ACTIVE_KEY);
}
async setActiveId(id: string | undefined): Promise<void> {
await this.context.globalState.update(ACTIVE_KEY, id);
await this.state.update(ACTIVE_KEY, id);
}
async create(personaId?: string): Promise<Conversation> {
+2 -1
View File
@@ -19,7 +19,8 @@ export interface Settings {
}
export const DEFAULT_SETTINGS: Settings = {
model: "auto",
// Auto hidden for now (bring back later): "" resolves to first enabled model.
model: "",
// 0 = don't send max_tokens; the model decides when to stop.
maxResponseLength: 0,
enableWorkspaceContext: true,
+1 -10
View File
@@ -13,7 +13,7 @@ import { listModels } from "../agent/provider";
import { renderWebviewHtml } from "./webviewHtml";
import { FeatureStore, MODEL_CATALOG } from "../stores/featureStore";
import { listRules, listSkills } from "../context/workspaceContext";
import { mcpManager, searchMcpRegistry } from "../integrations/mcpClient";
import { mcpManager } from "../integrations/mcpClient";
import { BUILTIN_PERSONAS } from "../agent/personas";
import { getStatus, onIndexStatus, buildIndex, deleteIndex, EMBED_MODELS } from "../agent/semanticIndex";
import { indexDocSource, deleteDocIndex, onDocsStatus, getDocsStatus, getDocLogs, type DocSource } from "../agent/docsIndex";
@@ -234,15 +234,6 @@ export class SettingsPanel {
case "resetStorage":
await this._resetStorage();
break;
case "mcpRegistrySearch": {
try {
const servers = await searchMcpRegistry(message.query || "");
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers });
} catch (err: any) {
this._panel.webview.postMessage({ type: "mcpRegistryResults", servers: [], error: String(err?.message || err) });
}
break;
}
case "saveProviderKey":
await this.settingsManager.setProviderKey(message.providerId, message.apiKey ?? "");
this.featureStore.notifyChanged();
+33 -17
View File
@@ -839,7 +839,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
/** Whether a model id maps to a managed local llama.cpp model. */
private _localModel(modelId: string) {
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId);
const bare = stripModelScope(modelId);
return this.featureStore.get().llamacppModels.find((m) => m.id === modelId || m.id === bare);
}
/**
@@ -1021,8 +1022,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const judge = features.autoJudgeModel || candidates[0];
try {
const jprov = await this._resolveProviderForModel(judge);
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, judge, candidates, task, jprov.anthropic);
// Local judge whose server isn't running would need a full model load just
// to route — not worth it; fall back to the first candidate instead.
const judgeIsLocal = !!this._localModel(judge);
if (judgeIsLocal && !isRunning(stripModelScope(judge))) return candidates[0];
if (!jprov.oauthKind && !jprov.baseUrl) return candidates[0]; // unroutable judge
const picked = await pickModel(jprov.baseUrl, jprov.apiKey, jprov.model, candidates, task, jprov.anthropic, jprov.oauthKind);
if (picked && candidates.includes(picked)) return picked;
// Judge may reply with a bare model id (no provider scope) — match it.
const scoped = candidates.find((c) => stripModelScope(c) === stripModelScope(picked));
if (scoped) return scoped;
} catch {
// fall through to default
}
@@ -1079,12 +1088,13 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
const allIds = fetched.flatMap((f) => f.ids);
const modelList = this._buildModelList(fetched);
// Selected model vanished (e.g. account disabled) -> fall back to auto.
// Selected model vanished (e.g. account disabled) or is "auto" (hidden for
// now) -> fall back to the first enabled model.
const settings = this.settingsManager.getSettings();
if (settings.model && settings.model !== "auto" && !modelList.some((m) => m.id === settings.model)) {
settings.model = "auto";
if (settings.model === "auto" || (settings.model && !modelList.some((m) => m.id === settings.model))) {
settings.model = modelList[0]?.id || "";
await this.settingsManager.saveSettings(settings);
this._view?.webview.postMessage({ type: "modelSelected", model: "auto" });
this._view?.webview.postMessage({ type: "modelSelected", model: settings.model });
}
this._view?.webview.postMessage({ type: "modelsFetched", models: allIds, modelList });
}
@@ -1165,10 +1175,12 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
if (edit?.mode) this._currentMode = edit.mode as Mode;
const settings = this.settingsManager.getSettings();
// Auto mode: let a judge model pick the best enabled model for this task.
let modelId = settings.model;
if (modelId === "auto") {
modelId = await this._resolveAutoModel(text);
// Auto mode is hidden for now; a lingering "auto" selection (or empty)
// resolves to the first enabled model. (Judge-based routing kept in
// _resolveAutoModel for when Auto returns.)
if (modelId === "auto" || !modelId) {
modelId = this._buildModelList([]).find((m) => m.id !== "auto")?.id || modelId;
}
// Resolve connection details without starting a local server yet — we want
// the chat UI to show a "loading model" state while it boots (below).
@@ -1286,15 +1298,19 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
// Local model not yet running → boot it now, showing a loading state in the
// chat (selecting a model never loads it; only sending a message does).
const local = this._localModel(modelId);
if (local && !isRunning(local.id)) {
emit({ type: "shell-notify", message: `Loading ${local.name}` });
try {
await ensureLoaded(local, features.llamacppConfig);
} catch (e: any) {
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
emit({ type: "run-status", status: "error" });
return; // `finally` clears the session + persists.
if (local) {
if (!isRunning(local.id)) {
emit({ type: "shell-notify", message: `Loading ${local.name}` });
try {
await ensureLoaded(local, features.llamacppConfig);
} catch (e: any) {
emit({ type: "error", message: `Failed to load ${local.name}: ${e?.message || e}` });
emit({ type: "run-status", status: "error" });
return; // `finally` clears the session + persists.
}
}
// The server binds a random port each load — resolve the URL only now.
prov.baseUrl = serverUrlFor(local, features.llamacppConfig);
}
// Generate a short AI title once the provider/server is ready (local