diff --git a/README.md b/README.md index 9483ddb..42b6ce2 100644 --- a/README.md +++ b/README.md @@ -415,6 +415,12 @@ Manual `memoryProvider` modes: - `openai-responses`: OpenAI Responses API with function-call output. - `anthropic`: Anthropic Messages API with tool use. - `minimax`: MiniMax Anthropic Messages-compatible endpoint. Set `memoryApiUrl` to the global endpoint (`https://api.minimax.io`) or the China endpoint (`https://api.minimaxi.com`); the `/anthropic/v1/messages` path and `x-api-key` header are applied automatically. MiniMax text models such as `MiniMax-M3` support the adaptive thinking modes used by this plugin via `memoryExtraParams`. +- `orcarouter`: OpenAI-compatible model gateway with namespaced model IDs. `memoryApiUrl` and `memoryModel` are optional — they default to `https://api.orcarouter.ai/v1` and `orcarouter/auto` (a routing alias that selects a capable model per request). If you set `memoryModel`, use a namespaced ID such as `openai/gpt-5.5` or `deepseek/deepseek-v4-flash`; OrcaRouter rejects bare model names. Example: + ```jsonc + "memoryProvider": "orcarouter", + "memoryApiKey": "", + ``` + [OrcaRouter](https://www.orcarouter.ai) also runs gateway-level, zero-trust security for AI agents on the same endpoint — screening every prompt/response and governing every tool call on a default-deny basis, with no application code changes. Troubleshooting: diff --git a/bun.lock b/bun.lock index ece0b62..6fbbc9f 100644 --- a/bun.lock +++ b/bun.lock @@ -16,7 +16,7 @@ "zod": "^4.4.3", }, "devDependencies": { - "@types/bun": "^1.3.14", + "@types/bun": "^1.4.0", "husky": "^9.1.7", "lint-staged": "^17.3.0", "prettier": "^3.9.6", @@ -25,6 +25,7 @@ }, }, "overrides": { + "bun-types": "1.3.14", "onnxruntime-node": "1.20.1", }, "packages": { @@ -130,9 +131,9 @@ "@neon-rs/load": ["@neon-rs/load@0.0.4", "", {}, "sha512-kTPhdZyTQxB+2wpiRcFWrDcejc4JI6tkPuS7UZCG4l6Zvc5kU/gGQ/ozvHTh1XR5tS+UlfAfGuPajjzQjCiHCw=="], - "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.18", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.18", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-vqQeqJtn9c+J+tIQDzYk88xip/NVNN1hym1ATmckxo6zINHAoXoul4Sw/jgnvL00rLsfAvhja28qax4h3g/5Jg=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.19", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.19", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-Z7MZALTNDg6WBNZVVGFhh2FwKd2dBDKmRqOFtXkJ1RDB2+PtqcrBz+3ZjpvgZDe2h9eW8N+1rxkIJEU5FnPU3g=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.19", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-AnszRg7cJ3PA6/06mkqdTJDKn9NJuV26AJMWbKEgRsznbJvrhf3PT8UhQQOhyKQYCygx9ZOxyKMIPVOQdMSS1A=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -156,7 +157,7 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], @@ -224,7 +225,7 @@ "guid-typescript": ["guid-typescript@1.0.9", "", {}, "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ=="], - "hono": ["hono@4.13.2", "", {}, "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA=="], + "hono": ["hono@4.13.3", "", {}, "sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw=="], "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], diff --git a/package.json b/package.json index 3f5a4ee..c812983 100644 --- a/package.json +++ b/package.json @@ -62,10 +62,11 @@ }, "//": "Pin onnxruntime-node@1.20.1 (direct + override): nested OpenCode installs ignore package overrides (#184); 1.21.0–1.23.2 can SIGILL on macOS process exit (#225); fixed releases still lack darwin/x64 (microsoft/onnxruntime#27961).", "overrides": { - "onnxruntime-node": "1.20.1" + "onnxruntime-node": "1.20.1", + "bun-types": "1.3.14" }, "devDependencies": { - "@types/bun": "^1.3.14", + "@types/bun": "^1.4.0", "husky": "^9.1.7", "lint-staged": "^17.3.0", "prettier": "^3.9.6", diff --git a/src/config.ts b/src/config.ts index d983d5b..b6784ad 100644 --- a/src/config.ts +++ b/src/config.ts @@ -44,7 +44,7 @@ interface OpenCodeMemConfig { autoCaptureMaxRetries?: number; autoCaptureMaxContextBytes?: number; autoCaptureLanguage?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -127,7 +127,7 @@ const DEFAULTS: Required< memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; - memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax"; + memoryProvider?: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter"; memoryTemperature?: number | false; memoryExtraParams?: Record; opencodeProvider?: string; @@ -349,7 +349,7 @@ const CONFIG_TEMPLATE = `{ "autoCaptureEnabled": true, - // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" + // Provider type: "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter" // Note: "openai-chat" is a generic OpenAI API-compatible mode. // Any service that follows the OpenAI Chat Completions API can use it via custom "memoryApiUrl". "memoryProvider": "openai-chat", @@ -402,6 +402,15 @@ const CONFIG_TEMPLATE = `{ // // Optional adaptive thinking for MiniMax-M3: // "memoryExtraParams": { "thinking": { "type": "adaptive" } } + // OrcaRouter (OpenAI-compatible gateway, namespaced model IDs, with session support): + // "memoryProvider": "orcarouter" + // "memoryApiKey": "" + // // memoryApiUrl and memoryModel are optional — they default to + // // https://api.orcarouter.ai/v1 and "orcarouter/auto" (a routing alias). + // // OrcaRouter rejects bare model names, so if you set memoryModel, use a + // // namespaced ID such as "openai/gpt-5.5" or "deepseek/deepseek-v4-flash". + // "memoryModel": "openai/gpt-5.5" + // Groq (OpenAI-compatible, use openai-chat provider): // "memoryProvider": "openai-chat" // "memoryModel": "llama-3.3-70b-versatile" @@ -641,7 +650,7 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { autoCaptureMaxContextBytes, autoCaptureLanguage: fileConfig.autoCaptureLanguage, memoryProvider: (fileConfig.memoryProvider ?? "openai-chat") as - "openai-chat" | "openai-responses" | "anthropic" | "minimax", + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "orcarouter", memoryModel: fileConfig.memoryModel, memoryApiUrl: fileConfig.memoryApiUrl, memoryApiKey, @@ -747,6 +756,7 @@ type RuntimeConfig = ReturnType; interface AutoCaptureProviderRuntimeConfig { opencodeProvider?: string; opencodeModel?: string; + memoryProvider?: string; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -779,6 +789,17 @@ export function getAutoCaptureProviderStatus( const hasMemoryApiKey = hasValue(config.memoryApiKey); const hasPlaceholderMemoryApiKey = isPlaceholderApiKey(config.memoryApiKey); + // The orcarouter provider presets its endpoint and default model, so only + // an API key is required for the manual fallback path. + if (config.memoryProvider === "orcarouter") { + if (!hasMemoryApiKey) issues.push("memoryApiKey is not configured"); + if (hasPlaceholderMemoryApiKey) issues.push("memoryApiKey contains a placeholder value"); + if (hasMemoryApiKey && !hasPlaceholderMemoryApiKey) { + return { ready: true, mode: "manual", issues: [] }; + } + return { ready: false, issues }; + } + if (!hasMemoryModel) issues.push("memoryModel is not configured"); if (!hasMemoryApiUrl) issues.push("memoryApiUrl is not configured"); if (!hasMemoryApiKey) issues.push("memoryApiKey is not configured"); diff --git a/src/services/ai/ai-provider-factory.ts b/src/services/ai/ai-provider-factory.ts index c3b5e08..cf8de0c 100644 --- a/src/services/ai/ai-provider-factory.ts +++ b/src/services/ai/ai-provider-factory.ts @@ -4,6 +4,7 @@ import { OpenAIResponsesProvider } from "./providers/openai-responses.js"; import { AnthropicMessagesProvider } from "./providers/anthropic-messages.js"; import { MiniMaxProvider } from "./providers/minimax.js"; import { GoogleGeminiProvider } from "./providers/google-gemini.js"; +import { OrcaRouterProvider } from "./providers/orcarouter.js"; import { aiSessionManager } from "./session/ai-session-manager.js"; import type { AIProviderType } from "./session/session-types.js"; @@ -25,13 +26,23 @@ export class AIProviderFactory { case "google-gemini": return new GoogleGeminiProvider(config, aiSessionManager); + case "orcarouter": + return new OrcaRouterProvider(config, aiSessionManager); + default: throw new Error(`Unknown provider type: ${providerType}`); } } static getSupportedProviders(): AIProviderType[] { - return ["openai-chat", "openai-responses", "anthropic", "minimax", "google-gemini"]; + return [ + "openai-chat", + "openai-responses", + "anthropic", + "minimax", + "google-gemini", + "orcarouter", + ]; } static async cleanupExpiredSessions(): Promise { diff --git a/src/services/ai/provider-config.ts b/src/services/ai/provider-config.ts index 7042eb2..6563e77 100644 --- a/src/services/ai/provider-config.ts +++ b/src/services/ai/provider-config.ts @@ -2,6 +2,7 @@ import type { ProviderConfig } from "./providers/base-provider.js"; import { isPlaceholderApiKey } from "./api-key-placeholder.js"; interface MemoryProviderRuntimeConfig { + memoryProvider?: string; memoryModel?: string; memoryApiUrl?: string; memoryApiKey?: string; @@ -25,8 +26,12 @@ export function buildMemoryProviderConfig( const memoryApiKey = config.memoryApiKey; const issues: string[] = []; - if (!memoryModel) issues.push("missing memoryModel"); - if (!memoryApiUrl) issues.push("missing memoryApiUrl"); + // The orcarouter provider presets its own endpoint and default model, so + // memoryModel / memoryApiUrl are optional there. An API key is always required. + const isOrcaRouter = config.memoryProvider === "orcarouter"; + + if (!memoryModel && !isOrcaRouter) issues.push("missing memoryModel"); + if (!memoryApiUrl && !isOrcaRouter) issues.push("missing memoryApiUrl"); if (!memoryApiKey) issues.push("missing memoryApiKey"); if (isPlaceholderApiKey(memoryApiKey)) issues.push("replace the placeholder memoryApiKey value"); diff --git a/src/services/ai/providers/openai-chat-completion.ts b/src/services/ai/providers/openai-chat-completion.ts index 48545da..9ae645a 100644 --- a/src/services/ai/providers/openai-chat-completion.ts +++ b/src/services/ai/providers/openai-chat-completion.ts @@ -5,7 +5,7 @@ import { applySafeExtraParams, } from "./base-provider.js"; import type { AISessionManager } from "../session/ai-session-manager.js"; -import type { AIMessage } from "../session/session-types.js"; +import type { AIMessage, AIProviderType } from "../session/session-types.js"; import type { ChatCompletionTool } from "../tools/tool-schema.js"; import { log } from "../../logger.js"; import { UserProfileValidator } from "../validators/user-profile-validator.js"; @@ -110,6 +110,24 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { return true; } + /** Provider tag used for AI session storage and diagnostics. */ + protected sessionProviderTag(): AIProviderType { + return "openai-chat"; + } + + /** + * Resolve the OpenAI-compatible API base URL. + * Trailing slashes are stripped so `${base}/chat/completions` is well-formed. + */ + protected resolveEndpoint(): string { + return (this.config.apiUrl || "").trim().replace(/\/+$/, ""); + } + + /** Resolve the model ID sent in the request body. */ + protected resolveModel(): string { + return this.config.model; + } + private async addToolResponse( sessionId: string, messages: APIMessage[], @@ -177,11 +195,11 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { toolSchema: ChatCompletionTool, sessionId: string ): Promise { - let session = await this.aiSessionManager.getSession(sessionId, "openai-chat"); + let session = await this.aiSessionManager.getSession(sessionId, this.sessionProviderTag()); if (!session) { session = await this.aiSessionManager.createSession({ - provider: "openai-chat", + provider: this.sessionProviderTag(), sessionId, }); } @@ -243,7 +261,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { try { const requestBody: RequestBody = { - model: this.config.model, + model: this.resolveModel(), messages, tools: [toolSchema], tool_choice: "auto", @@ -265,7 +283,7 @@ export class OpenAIChatCompletionProvider extends BaseAIProvider { headers.Authorization = `Bearer ${this.config.apiKey}`; } - const response = await fetch(`${this.config.apiUrl}/chat/completions`, { + const response = await fetch(`${this.resolveEndpoint()}/chat/completions`, { method: "POST", headers, body: JSON.stringify(requestBody), diff --git a/src/services/ai/providers/orcarouter.ts b/src/services/ai/providers/orcarouter.ts new file mode 100644 index 0000000..95056a9 --- /dev/null +++ b/src/services/ai/providers/orcarouter.ts @@ -0,0 +1,78 @@ +import { OpenAIChatCompletionProvider } from "./openai-chat-completion.js"; +import type { ProviderConfig } from "./base-provider.js"; +import type { AISessionManager } from "../session/ai-session-manager.js"; +import type { AIProviderType } from "../session/session-types.js"; + +/** OrcaRouter OpenAI-compatible endpoint used when `memoryApiUrl` is omitted. */ +export const ORCAROUTER_API_URL = "https://api.orcarouter.ai/v1"; + +/** + * Default model when `memoryModel` is omitted. `orcarouter/auto` is the + * gateway's routing alias — it picks a capable upstream model per request + * (including structured / tool-call output, which auto-capture relies on). + */ +export const ORCAROUTER_DEFAULT_MODEL = "orcarouter/auto"; + +/** + * OrcaRouter provider. + * + * [OrcaRouter](https://www.orcarouter.ai) is an OpenAI-compatible model + * gateway. It rejects bare model names, so the gateway requires namespaced + * model IDs such as `orcarouter/auto`, `deepseek/deepseek-v4-flash`, or + * `openai/gpt-5.5`. This provider reuses the OpenAI Chat Completions request + * handling and only overrides the resolved endpoint, the model resolution + * (validating the namespace), and the session provider tag, so OrcaRouter is + * distinguishable in the session store and diagnostics. + * + * Users configure it as: + * "memoryProvider": "orcarouter" + * "memoryApiKey": "" + * + * `memoryApiUrl` and `memoryModel` are optional — they default to the gateway + * endpoint and `orcarouter/auto` respectively. + */ +export class OrcaRouterProvider extends OpenAIChatCompletionProvider { + constructor(config: ProviderConfig, aiSessionManager: AISessionManager) { + super(config, aiSessionManager); + } + + override getProviderName(): string { + return "orcarouter"; + } + + protected override sessionProviderTag(): AIProviderType { + return "orcarouter"; + } + + /** + * Resolve the OpenAI-compatible endpoint. + * + * Defaults to the OrcaRouter gateway when `memoryApiUrl` is not configured, + * so a minimal config only needs `memoryProvider` + `memoryApiKey`. + */ + override resolveEndpoint(): string { + const base = (this.config.apiUrl || "").trim().replace(/\/+$/, ""); + return base || ORCAROUTER_API_URL; + } + + /** + * Resolve the model ID to send to the gateway. + * + * Defaults to the `orcarouter/auto` routing alias when `memoryModel` is not + * configured. OrcaRouter rejects bare model names (e.g. `gpt-4o-mini`), so a + * namespaced ID is required — fail with a helpful message instead of a + * gateway-side `model_not_found` error. + */ + override resolveModel(): string { + const model = (this.config.model || "").trim(); + if (!model) { + return ORCAROUTER_DEFAULT_MODEL; + } + if (!model.includes("/")) { + throw new Error( + `OrcaRouter requires a namespaced memoryModel (e.g. "orcarouter/auto", "openai/gpt-5.5", "deepseek/deepseek-v4-flash"). Got: ${model}` + ); + } + return model; + } +} diff --git a/src/services/ai/session/session-types.ts b/src/services/ai/session/session-types.ts index 6e162d0..db4676c 100644 --- a/src/services/ai/session/session-types.ts +++ b/src/services/ai/session/session-types.ts @@ -1,5 +1,5 @@ export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; export interface AIMessage { id?: number; diff --git a/src/services/turso/operation-lock.ts b/src/services/turso/operation-lock.ts index 1219f8b..35d8224 100644 --- a/src/services/turso/operation-lock.ts +++ b/src/services/turso/operation-lock.ts @@ -30,7 +30,12 @@ function readLiveLock(path: string): LockState | null { } catch { // Corrupt locks are stale and removed below. } - unlinkSync(path); + try { + unlinkSync(path); + } catch { + // Already removed (race) or held open (Windows) — either way the lock is + // gone or unowned; do not let cleanup failure block writes. + } return null; } diff --git a/src/services/turso/shard-manager.ts b/src/services/turso/shard-manager.ts index 867c9ff..14f79b0 100644 --- a/src/services/turso/shard-manager.ts +++ b/src/services/turso/shard-manager.ts @@ -180,6 +180,13 @@ export class TursoShardManager { const storedPath = join(`${scope}s`, basename(fullPath)).replace(/\\/g, "/"); const now = Date.now(); + // Initialize the shard file BEFORE inserting the registry row. If init throws + // (disk full, permissions), the registry stays free of an orphan row that points + // at an uninitialized file — such a row would later fail isShardValid on every + // getWriteShard and brick all writes to this scope. initShardDb is idempotent. + const shardDb = await tursoConnectionManager.getConnection(fullPath); + await this.initShardDb(shardDb); + let result; try { result = await metadataDb.execute( @@ -205,9 +212,6 @@ export class TursoShardManager { throw error; } - const shardDb = await tursoConnectionManager.getConnection(fullPath); - await this.initShardDb(shardDb); - return { id: Number(result.lastInsertRowid), scope, @@ -487,9 +491,14 @@ export class TursoShardManager { async getShardByPath(dbPath: string): Promise { const metadataDb = await this.ensureInitialized(); const fileName = basename(dbPath); - const row = await metadataDb.get(`SELECT * FROM shards WHERE db_path LIKE '%' || ?`, [ - fileName, - ]); + // Stored db_path is always `s/` (see createShard/registerExistingShard), + // so anchor on the "/" separator. Escape LIKE metacharacters in the filename — otherwise + // the "_" in shard names like `user__0.db` would match any character. + const escaped = fileName.replace(/[\\%_]/g, "\\$&"); + const row = await metadataDb.get( + `SELECT * FROM shards WHERE db_path LIKE '%/' || ? ESCAPE '\\'`, + [escaped] + ); if (!row) return null; return this.rowToShardInfo(row); } diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index df8c206..205c50f 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -215,7 +215,7 @@ Rules: updatedProfileData ); - const validationSummary = applyValidations( + const validationSummary = await applyValidations( updatedProfileData, llmResult, existingProfile.id, @@ -248,7 +248,7 @@ Rules: profileId: existingProfile?.id, userId, }); - userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id)); + await userPromptManager.markMultipleAsUserLearningCaptured(prompts.map((p) => p.id)); return; } @@ -281,6 +281,14 @@ Rules: }) .catch(() => {}); } + } catch (error) { + // Guard against corrupt stored profileData (JSON.parse throws) and any other + // fault: this runs fire-and-forget from the idle timer, so an uncaught rejection + // would surface as an unhandled promise rejection. The caller (src/index.ts idle + // timer) already wraps this call in its own try/catch, and issue #265 requires + // provider errors to propagate instead of being masked, so rethrow after logging. + log("user-profile-learning: aborted", { error: String(error) }); + throw error; } finally { isLearningRunning = false; } @@ -549,12 +557,12 @@ export function createUserProfileToolSchema(existingProfile: boolean) { type AnalysisResult = { raw: UserProfileData; merged: UserProfileData | null }; -function applyValidations( +async function applyValidations( profileData: UserProfileData, llmResult: UserProfileData, profileId: string, prefKeys?: string[] -): string | null { +): Promise { const validations = (llmResult as any).validations as | Array<{ index: number; @@ -611,7 +619,12 @@ function applyValidations( const evidence = (item as any).evidence; if (Array.isArray(evidence) && evidence.length >= 3) { const itemType = profileData.preferences.includes(item) ? "preference" : "pattern"; - userProfileManager.evolveAndUpdate(item, itemType, profileId).catch(() => {}); + // Await so the in-place description/centroid mutation completes before the + // caller serializes updatedProfileData — otherwise the evolved description is + // included or lost nondeterministically. Failures stay non-fatal. + try { + await userProfileManager.evolveAndUpdate(item, itemType, profileId); + } catch {} } } else { results.push(`no_evidence [${v.index}] ${v.reason}`); diff --git a/src/services/user-profile/ai-cleanup.ts b/src/services/user-profile/ai-cleanup.ts index 647f49a..5aaa5a0 100644 --- a/src/services/user-profile/ai-cleanup.ts +++ b/src/services/user-profile/ai-cleanup.ts @@ -131,6 +131,29 @@ interface AIMapping { removed: string[]; } +// The model controls this JSON; it may omit fields or return wrong types. Coerce to a +// well-formed AIMapping so rebuildProfileUsing/generateDiff never call .map/.filter/.includes +// on undefined. A missing or malformed mapping degrades to a no-op cleanup (all originals are +// preserved as "unmentioned") rather than aborting or corrupting the profile. +function normalizeAIMapping(raw: any): AIMapping { + if (!raw || typeof raw !== "object") { + log("AI cleanup: response missing valid mapping; treating as no-op", { + mappingType: typeof raw, + }); + return { kept: [], merged: [], removed: [] }; + } + const isStr = (x: any): x is string => typeof x === "string"; + const kept = Array.isArray(raw.kept) ? raw.kept.filter(isStr) : []; + const merged = Array.isArray(raw.merged) + ? raw.merged + .filter((g: any): g is any[] => Array.isArray(g)) + .map((g: any[]) => g.filter(isStr)) + .filter((g: string[]) => g.length > 0) + : []; + const removed = Array.isArray(raw.removed) ? raw.removed.filter(isStr) : []; + return { kept, merged, removed }; +} + function addIdsToProfile(profile: UserProfileData): IndexedProfile { const items = { preferences: profile.preferences.map((p, i) => ({ ...p, id: `pref_${i}` })), @@ -251,7 +274,7 @@ async function callViaExternalAPI( const parsed = JSON.parse(content); return { profile: parsed as IndexedProfile, - mapping: parsed.mapping as AIMapping, + mapping: normalizeAIMapping(parsed.mapping), }; } @@ -357,7 +380,7 @@ async function callViaOpencodeWithClient( const parsed = JSON.parse(jsonMatch[0]); return { profile: parsed as IndexedProfile, - mapping: parsed.mapping as AIMapping, + mapping: normalizeAIMapping(parsed.mapping), }; } finally { try { @@ -440,6 +463,11 @@ export function rebuildProfileUsing( } if (mergedGroups.some((g) => g[0] === id)) { + // The merge accumulation below dereferences originalItem unconditionally. If the + // model returned a keeper id that only exists in its cleaned output (a hallucinated + // id absent from originalById), skip the group instead of throwing and aborting the + // entire cleanup run. + if (!originalItem) continue; const group = mergedGroups.find((g) => g[0] === id)!; let bestFreq = (originalItem as any).frequency || 0; let bestCentroid = (originalItem as any).centroid; diff --git a/src/services/user-profile/profile-context.ts b/src/services/user-profile/profile-context.ts index fb1db64..2184e00 100644 --- a/src/services/user-profile/profile-context.ts +++ b/src/services/user-profile/profile-context.ts @@ -50,7 +50,14 @@ export async function getUserProfileContext(userId: string): Promise | null = null; - private coldBuffer: { preferences: any[]; patterns: any[]; workflows: any[] }; + // Cold-start buffers are keyed by profileId so items observed for one user never + // drain into another user's merge (cross-user contamination). The unattributed bucket + // (COLD_BUFFER_DEFAULT_KEY) only holds items from merges that ran without a profileId. + private coldBuffers: Map; private coldBufferPath: string; private dedupCheckedCache: Set = new Set(); constructor() { this.dbPath = join(CONFIG.storagePath || "", USER_PROFILES_DB_NAME); this.coldBufferPath = join(CONFIG.storagePath || "", "cold-buffer.json"); - this.coldBuffer = this.loadColdBuffer(); + this.coldBuffers = this.loadColdBuffers(); } reset(): void { @@ -128,35 +132,75 @@ export class UserProfileManager { return this.db; } - private loadColdBuffer(): { preferences: any[]; patterns: any[]; workflows: any[] } { + private emptyColdBuffer(): { preferences: any[]; patterns: any[]; workflows: any[] } { + return { preferences: [], patterns: [], workflows: [] }; + } + + private getColdBuffer(profileId?: string): { + preferences: any[]; + patterns: any[]; + workflows: any[]; + } { + const key = profileId || COLD_BUFFER_DEFAULT_KEY; + let buf = this.coldBuffers.get(key); + if (!buf) { + buf = this.emptyColdBuffer(); + this.coldBuffers.set(key, buf); + } + return buf; + } + + private loadColdBuffers(): Map< + string, + { preferences: any[]; patterns: any[]; workflows: any[] } + > { + const map = new Map(); try { if (existsSync(this.coldBufferPath)) { const raw = readFileSync(this.coldBufferPath, "utf-8"); const data = JSON.parse(raw); - if (data.preferences?.length || data.patterns?.length || data.workflows?.length) { - log("profile cold buffer: loaded from disk", { - prefs: data.preferences?.length || 0, - pats: data.patterns?.length || 0, - wfs: data.workflows?.length || 0, - }); + // Legacy flat format ({ preferences, patterns, workflows }) is cross-user + // contaminated and cannot be attributed to a profile, so it is dropped rather + // than replayed. New format is keyed by profileId. + const isLegacyFlat = + data && + typeof data === "object" && + !Array.isArray(data) && + ("preferences" in data || "patterns" in data || "workflows" in data); + if (data && typeof data === "object" && !Array.isArray(data) && !isLegacyFlat) { + let loaded = 0; + for (const [pid, v] of Object.entries(data)) { + map.set(pid, { + preferences: Array.isArray(v?.preferences) ? v.preferences : [], + patterns: Array.isArray(v?.patterns) ? v.patterns : [], + workflows: Array.isArray(v?.workflows) ? v.workflows : [], + }); + loaded++; + } + if (loaded > 0) { + log("profile cold buffer: loaded from disk", { profiles: loaded }); + } + } else if (isLegacyFlat) { + log("profile cold buffer: dropping legacy unattributed buffer"); } - return { - preferences: Array.isArray(data.preferences) ? data.preferences : [], - patterns: Array.isArray(data.patterns) ? data.patterns : [], - workflows: Array.isArray(data.workflows) ? data.workflows : [], - }; } } catch { - // 文件损坏或不存在,返回空缓冲 + // Corrupt or missing file — start with an empty buffer set. } - return { preferences: [], patterns: [], workflows: [] }; + return map; } - private saveColdBuffer(): void { + private saveColdBuffers(): void { try { - writeFileSync(this.coldBufferPath, JSON.stringify(this.coldBuffer), "utf-8"); + const obj: Record = {}; + for (const [pid, v] of this.coldBuffers.entries()) { + if (v.preferences.length || v.patterns.length || v.workflows.length) { + obj[pid] = v; + } + } + writeFileSync(this.coldBufferPath, JSON.stringify(obj), "utf-8"); } catch { - // 磁盘满或无权限时静默失败 + // Silently ignore disk-full / permission errors. } } @@ -237,6 +281,7 @@ export class UserProfileManager { preferences: safeArray(profileData.preferences), patterns: safeArray(profileData.patterns), workflows: safeArray(profileData.workflows), + ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}), }; await db.run( @@ -279,6 +324,7 @@ export class UserProfileManager { preferences: safeArray(profileData.preferences), patterns: safeArray(profileData.patterns), workflows: safeArray(profileData.workflows), + ...(profileData.learning_paths ? { learning_paths: profileData.learning_paths } : {}), }; const versionRow = await db.get(`SELECT version FROM user_profiles WHERE id = ?`, [profileId]); @@ -445,6 +491,9 @@ export class UserProfileManager { async deleteProfile(profileId: string): Promise { const db = await this.ready(); await db.run(`DELETE FROM user_profiles WHERE id = ?`, [profileId]); + if (this.coldBuffers.delete(profileId)) { + this.saveColdBuffers(); + } } async getProfileById(profileId: string): Promise { @@ -594,12 +643,16 @@ export class UserProfileManager { let matchCount = 0; let newCount = 0; - if (useEmbedding && (this.coldBuffer as any)[itemType + "s"].length > 0) { - const buffered = [...(this.coldBuffer as any)[itemType + "s"]]; - (this.coldBuffer as any)[itemType + "s"] = []; - this.saveColdBuffer(); + // Scope the cold buffer to this profile so we only drain items observed for the + // same user (see COLD_BUFFER_DEFAULT_KEY for the unattributed case). + const coldBuffer = this.getColdBuffer(profileId); + if (useEmbedding && (coldBuffer as any)[itemType + "s"].length > 0) { + const buffered = [...(coldBuffer as any)[itemType + "s"]]; + (coldBuffer as any)[itemType + "s"] = []; + this.saveColdBuffers(); log("profile cold start: draining buffer", { type: itemType, + profileId: profileId || COLD_BUFFER_DEFAULT_KEY, bufferSize: buffered.length, }); incoming = [...buffered, ...incoming]; @@ -1053,15 +1106,16 @@ export class UserProfileManager { (Array.isArray((newItem as any).evidence) && (newItem as any).evidence.includes("manual-write")); if (!isExplicit) { - (this.coldBuffer as any)[itemType + "s"].push(newItem); - if ((this.coldBuffer as any)[itemType + "s"].length > 50) { - (this.coldBuffer as any)[itemType + "s"].shift(); + (coldBuffer as any)[itemType + "s"].push(newItem); + if ((coldBuffer as any)[itemType + "s"].length > 50) { + (coldBuffer as any)[itemType + "s"].shift(); } - this.saveColdBuffer(); + this.saveColdBuffers(); log("profile cold start: buffered", { type: itemType, + profileId: profileId || COLD_BUFFER_DEFAULT_KEY, cat: newItem.category, - bufferSize: (this.coldBuffer as any)[itemType + "s"].length, + bufferSize: (coldBuffer as any)[itemType + "s"].length, }); continue; } diff --git a/src/types/index.ts b/src/types/index.ts index 5c6fc74..f6a0e82 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,4 +18,4 @@ export interface MemoryMetadata { } export type AIProviderType = - "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini"; + "openai-chat" | "openai-responses" | "anthropic" | "minimax" | "google-gemini" | "orcarouter"; diff --git a/tests/ai-provider-config.test.ts b/tests/ai-provider-config.test.ts index b866dd0..32f664d 100644 --- a/tests/ai-provider-config.test.ts +++ b/tests/ai-provider-config.test.ts @@ -127,6 +127,29 @@ describe("AI provider config", () => { ).toThrow("missing memoryApiKey"); }); + it("builds orcarouter config from only an API key, defaulting model and endpoint", () => { + const providerConfig = buildMemoryProviderConfig({ + memoryProvider: "orcarouter", + memoryApiKey: "sk-orca-test", + }); + + expect(providerConfig).toEqual({ + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + maxIterations: undefined, + iterationTimeout: undefined, + }); + }); + + it("still requires an API key for the orcarouter provider", () => { + expect(() => + buildMemoryProviderConfig({ + memoryProvider: "orcarouter", + }) + ).toThrow("missing memoryApiKey"); + }); + it("omits temperature for openai-chat when memoryTemperature is false", async () => { let capturedBody: Record | undefined; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { diff --git a/tests/orcarouter-provider.test.ts b/tests/orcarouter-provider.test.ts new file mode 100644 index 0000000..38de5bb --- /dev/null +++ b/tests/orcarouter-provider.test.ts @@ -0,0 +1,214 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { + OrcaRouterProvider, + ORCAROUTER_API_URL, + ORCAROUTER_DEFAULT_MODEL, +} from "../src/services/ai/providers/orcarouter.js"; +import { AIProviderFactory } from "../src/services/ai/ai-provider-factory.js"; +import type { ChatCompletionTool } from "../src/services/ai/tools/tool-schema.js"; + +const toolSchema: ChatCompletionTool = { + type: "function", + function: { + name: "save_memories", + description: "Save memories", + parameters: { + type: "object", + properties: {}, + required: [], + }, + }, +}; + +class FakeSessionManager { + private readonly session = { id: "session-1" }; + private readonly messages: any[] = []; + lastCreateSessionArgs: any; + + getSession(sessionId?: string, provider?: string): any { + void sessionId; + void provider; + return null; + } + + createSession(args: any): any { + this.lastCreateSessionArgs = args; + return this.session; + } + + getMessages(): any[] { + return this.messages; + } + + getLastSequence(): number { + return this.messages.length - 1; + } + + addMessage(message: any): void { + this.messages.push(message); + } +} + +function makeProvider( + overrides: Record = {}, + sessionManager = new FakeSessionManager() +) { + return { + provider: new OrcaRouterProvider( + { + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + ...overrides, + }, + sessionManager as any + ), + sessionManager, + }; +} + +describe("OrcaRouterProvider", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("reports the orcarouter provider name", () => { + const { provider } = makeProvider(); + expect(provider.getProviderName()).toBe("orcarouter"); + expect(provider.supportsSession()).toBe(true); + }); + + it("defaults to the OrcaRouter gateway endpoint when apiUrl is not configured", () => { + const { provider } = makeProvider(); + expect(provider.resolveEndpoint()).toBe(ORCAROUTER_API_URL); + }); + + it("strips a trailing slash from a configured apiUrl", () => { + const { provider } = makeProvider({ apiUrl: "https://proxy.example.com/v1/" }); + expect(provider.resolveEndpoint()).toBe("https://proxy.example.com/v1"); + }); + + it("defaults to the orcarouter/auto routing model", () => { + const { provider } = makeProvider(); + expect(provider.resolveModel()).toBe(ORCAROUTER_DEFAULT_MODEL); + }); + + it("returns a namespaced configured model unchanged", () => { + const { provider } = makeProvider({ model: "deepseek/deepseek-v4-flash" }); + expect(provider.resolveModel()).toBe("deepseek/deepseek-v4-flash"); + }); + + it("rejects a bare model name with a namespacing hint", () => { + const { provider } = makeProvider({ model: "gpt-4o-mini" }); + expect(() => provider.resolveModel()).toThrow(/namespaced memoryModel/); + }); + + it("records the orcarouter session provider tag", async () => { + globalThis.fetch = (async () => + ({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + }) as Response) as typeof fetch; + + const { provider, sessionManager } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(sessionManager.lastCreateSessionArgs?.provider).toBe("orcarouter"); + }); + + it("targets /chat/completions on the gateway and authenticates with Bearer", async () => { + let capturedUrl: string | undefined; + let capturedHeaders: Record | undefined; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedHeaders = init?.headers as Record; + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedUrl).toBe(`${ORCAROUTER_API_URL}/chat/completions`); + expect(capturedHeaders?.["Authorization"]).toBe("Bearer sk-orca-test"); + }); + + it("sends the resolved default model in the request body", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: false, + status: 401, + statusText: "Unauthorized", + text: async () => "login fail", + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.model).toBe(ORCAROUTER_DEFAULT_MODEL); + expect(capturedBody?.tool_choice).toBe("auto"); + expect(Array.isArray(capturedBody?.messages)).toBe(true); + expect(Array.isArray(capturedBody?.tools)).toBe(true); + }); + + it("extracts tool input from an OpenAI-format response", async () => { + let capturedBody: Record | undefined; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: true, + status: 200, + json: async () => ({ + choices: [ + { + message: { + content: "", + tool_calls: [ + { + id: "call_1", + type: "function", + function: { + name: "save_memories", + arguments: JSON.stringify({ memory: "captured fact" }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + }), + } as Response; + }) as typeof fetch; + + const { provider } = makeProvider(); + const result = await provider.executeToolCall("system", "user", toolSchema, "session-id"); + + expect(capturedBody?.model).toBe(ORCAROUTER_DEFAULT_MODEL); + expect(result.success).toBe(true); + expect((result.data as any).memory).toBe("captured fact"); + }); +}); + +describe("AIProviderFactory orcarouter wiring", () => { + it("creates an OrcaRouter provider and lists it as supported", () => { + const provider = AIProviderFactory.createProvider("orcarouter", { + model: "", + apiUrl: "", + apiKey: "sk-orca-test", + }); + expect(provider.getProviderName()).toBe("orcarouter"); + expect(AIProviderFactory.getSupportedProviders()).toContain("orcarouter"); + }); +}); diff --git a/tests/user-profile-cold-buffer-isolation.test.ts b/tests/user-profile-cold-buffer-isolation.test.ts new file mode 100644 index 0000000..0ae83c4 --- /dev/null +++ b/tests/user-profile-cold-buffer-isolation.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { tursoConnectionManager } from "../src/services/turso/connection-manager.js"; + +let tmpDir: string; + +async function makeManager() { + const { CONFIG } = await import("../src/config.js"); + CONFIG.storagePath = tmpDir; + CONFIG.userProfileEmbeddingMinDescriptionLength = 5; + const { UserProfileManager } = + await import("../src/services/user-profile/user-profile-manager.js"); + return { mgr: new UserProfileManager(), CONFIG }; +} + +// Embedding not warmed up → mergeItems buffers non-explicit items (cold start). +const coldEmbed = { isWarmedUp: false } as any; +// Warmed up → buffered items drain into the merge. embed() is only used to seed a +// centroid on append; existing is empty here so no cosine comparison runs. +const warmEmbed = { + isWarmedUp: true, + embed: async () => new Float32Array(8).fill(0.25), +} as any; + +const empty = () => ({ preferences: [], patterns: [], workflows: [] }); + +describe("cold buffer per-user isolation (correctness #1)", () => { + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-coldbuf-")); + }); + + afterEach(async () => { + await tursoConnectionManager.closeAll(); + await new Promise((r) => setTimeout(r, 50)); + try { + rmSync(tmpDir, { recursive: true, force: true }); + } catch {} + }); + + it("does not drain one user's buffered items into another user's merge", async () => { + const { mgr } = await makeManager(); + + // Cold start: each user's observation is buffered under its own profileId. + await mgr.mergeProfileData( + empty(), + { preferences: [{ category: "style", description: "A prefers tabs over spaces" }] }, + coldEmbed, + "profile_A" + ); + await mgr.mergeProfileData( + empty(), + { preferences: [{ category: "style", description: "B prefers spaces over tabs" }] }, + coldEmbed, + "profile_B" + ); + + // Warm merge for B must drain only B's bucket, never A's. + const mergedB = await mgr.mergeProfileData( + empty(), + { preferences: [] }, + warmEmbed, + "profile_B" + ); + const descsB = mergedB.preferences.map((p: any) => p.description); + expect(descsB).toContain("B prefers spaces over tabs"); + expect(descsB).not.toContain("A prefers tabs over spaces"); + + // A's bucket is untouched by B's drain and drains only for A. + const mergedA = await mgr.mergeProfileData( + empty(), + { preferences: [] }, + warmEmbed, + "profile_A" + ); + const descsA = mergedA.preferences.map((p: any) => p.description); + expect(descsA).toContain("A prefers tabs over spaces"); + expect(descsA).not.toContain("B prefers spaces over tabs"); + }); +}); diff --git a/web/bun.lock b/web/bun.lock index cc13693..ea74989 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,9 +5,9 @@ "": { "name": "web", "dependencies": { - "dompurify": "^3.4.13", + "dompurify": "^3.4.14", "jsonrepair": "^3.15.0", - "marked": "^18.0.9", + "marked": "^18.0.10", "zod": "^4.4.3", }, "devDependencies": { @@ -22,10 +22,10 @@ "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", + "@vitejs/plugin-react": "^6.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.31.0", + "lucide-react": "^1.33.0", "react": "^19.1.0", "react-dom": "^19.1.0", "sonner": "^2.0.8", @@ -33,7 +33,7 @@ "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "typescript": "~7.0.2", - "vite": "^8.2.1", + "vite": "^8.2.2", }, }, }, @@ -206,7 +206,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.5", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.0", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.145.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-qd2BzUBehkov86WFhg0JkEFEYyCLG9uPCe6qWTY/kRlss9OvJrOF2UbIWT7p+8IzZHkEu0DNGHc4HSv+JdDLsw=="], "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], @@ -220,7 +220,7 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="], + "dompurify": ["dompurify@3.4.14", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg=="], "enhanced-resolve": ["enhanced-resolve@5.24.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ=="], @@ -260,11 +260,11 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], - "lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="], + "lucide-react": ["lucide-react@1.33.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-MTRwMy0ZlL8Ur/vOAiJ9XGHE+kFPC7brq6MxAm0GiGXEBj0qy0jA/pG4N675oSzciO/UCdX8T+5yUQdmDeTLxg=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], - "marked": ["marked@18.0.9", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-/Sa4qiiHZxf0/FQdBBowr9q4r10krCwMvpK48FUBdXdUXScDxiQGR9zCPrFgRVR5LU3iySOiIjy09ZQvADir1w=="], + "marked": ["marked@18.0.10", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-FJeH4bRpYoXiggcgriCGItKCSv3xkngJc4QCZ/rkQCogU3VYaLxYJoZl8Nw/b4+x7iij/pd+09mZ6A1dXzpL0A=="], "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], @@ -312,7 +312,7 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - "vite": ["vite@8.2.1", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.25", "rolldown": "~1.2.1", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw=="], + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], diff --git a/web/package.json b/web/package.json index 7d5b815..32664c3 100644 --- a/web/package.json +++ b/web/package.json @@ -10,9 +10,9 @@ "check": "tsc -b --pretty false" }, "dependencies": { - "dompurify": "^3.4.13", + "dompurify": "^3.4.14", "jsonrepair": "^3.15.0", - "marked": "^18.0.9", + "marked": "^18.0.10", "zod": "^4.4.3" }, "devDependencies": { @@ -27,10 +27,10 @@ "@types/node": "^26.2.0", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.4", - "@vitejs/plugin-react": "^6.0.5", + "@vitejs/plugin-react": "^6.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.31.0", + "lucide-react": "^1.33.0", "react": "^19.1.0", "react-dom": "^19.1.0", "sonner": "^2.0.8", @@ -38,6 +38,6 @@ "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "typescript": "~7.0.2", - "vite": "^8.2.1" + "vite": "^8.2.2" } }