From d9990bbcbf02bc7b6697bdfa31f7510f363b739c Mon Sep 17 00:00:00 2001 From: phil-lipp <52623794+phil-lipp@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:17:59 +0200 Subject: [PATCH 1/8] fix(user-profile): scope cold-start buffer per user and persist learning_paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cold-start buffer was a single instance field on the profile-manager singleton, so items observed for one user during embedding warm-up drained into whichever user's mergeItems ran next — merging User A's preferences into User B's profile. Key the buffer by profileId (Map) and drain only the current profile's bucket; legacy unattributed buffer files are dropped on load. Separately, createProfile/updateProfile rebuilt cleanedData as only {preferences, patterns, workflows}, silently stripping learning_paths on every write and rendering the Learning Paths injection feature dead. Carry the field through. --- .../user-profile/user-profile-manager.ts | 110 +++++++++++++----- 1 file changed, 82 insertions(+), 28 deletions(-) diff --git a/src/services/user-profile/user-profile-manager.ts b/src/services/user-profile/user-profile-manager.ts index a5aff06..cf2b397 100644 --- a/src/services/user-profile/user-profile-manager.ts +++ b/src/services/user-profile/user-profile-manager.ts @@ -70,19 +70,23 @@ function normalizeDescription(text: string): string { } const USER_PROFILES_DB_NAME = "user-profiles.db"; +const COLD_BUFFER_DEFAULT_KEY = "__unattributed__"; export class UserProfileManager { private db: TursoDb | null = null; private dbPath: string; private initPromise: 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; } From 93b615d804c40a021eaa8ac42f902b5d2ef4f033 Mon Sep 17 00:00:00 2001 From: phil-lipp <52623794+phil-lipp@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:18:10 +0200 Subject: [PATCH 2/8] fix(user-profile): harden cleanup rebuild and injection against bad data rebuildProfileUsing dereferenced originalItem unconditionally in the merged-group branch, so a keeper id the model hallucinated (present only in its mapping, not in originalById) threw and aborted the entire cleanup run. Skip such groups, and normalize the AI-controlled mapping (kept/merged/removed) to well-formed arrays so a malformed response degrades to a no-op cleanup instead of throwing. getUserProfileContext parsed the stored profileData with no guard, so one corrupt row broke context injection for every request. Wrap in try/catch and return null. --- src/services/user-profile/ai-cleanup.ts | 32 ++++++++++++++++++-- src/services/user-profile/profile-context.ts | 9 +++++- 2 files changed, 38 insertions(+), 3 deletions(-) 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 Date: Thu, 13 Aug 2026 13:18:20 +0200 Subject: [PATCH 3/8] fix(user-memory-learning): await profile writes and evolve before serialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fire-and-forget/unawaited hazards in the learning cycle: - The retry-exhausted branch did not await markMultipleAsUserLearningCaptured, so finally cleared isLearningRunning while the write was in flight; the next cycle re-fetched and re-analyzed the same prompts (token burn), and the rejection was unhandled. Await it. - evolveAndUpdate mutates item.description/centroid in place but was called fire-and-forget, racing the JSON.stringify in updateProfile — the evolved description was included or lost nondeterministically. Make applyValidations async and await the evolve so mutation completes pre-serialization. - performUserProfileLearning had try/finally but no catch; JSON.parse of a corrupt profileData row rejected the promise (unhandled at the fire-and-forget site). Add a catch that logs and returns. --- src/services/user-memory-learning.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index eebbe62..f567fef 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,11 @@ 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. Log and exit cleanly. + log("user-profile-learning: aborted", { error: String(error) }); } finally { isLearningRunning = false; } @@ -549,12 +554,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 +616,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}`); From 715cef4926a9e3d5443b5bd4f1c8b042bf523008 Mon Sep 17 00:00:00 2001 From: phil-lipp <52623794+phil-lipp@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:18:37 +0200 Subject: [PATCH 4/8] fix(turso): safe shard creation, exact shard-path match, resilient lock cleanup - createShard committed the registry INSERT before initShardDb ran; if init threw (disk full, permissions) the row persisted pointing at an uninitialized file, so the next getWriteShard failed isShardValid and threw 'incompatible or corrupt', blocking all writes to that scope. Initialize the shard DB first (it is idempotent), then insert. - getShardByPath matched db_path with LIKE '%' || filename, so the underscores in shard names (user__shard_N.db) acted as single-char wildcards and could match the wrong row. Anchor on the '/' separator and escape LIKE metacharacters. - readLiveLock called unlinkSync outside its try/catch; a race (already removed) or a Windows open handle threw ENOENT/EPERM out of assertNoTursoMigrationInProgress and falsely blocked writes. Wrap it. --- src/services/turso/operation-lock.ts | 7 ++++++- src/services/turso/shard-manager.ts | 21 +++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) 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); } From e79af2441beda6cef23ce8e5c1cb8015d4766a5a Mon Sep 17 00:00:00 2001 From: phil-lipp <52623794+phil-lipp@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:18:48 +0200 Subject: [PATCH 5/8] test(user-profile): cover per-user cold-buffer isolation Asserts that items buffered for one profile during embedding cold-start never drain into another profile's merge, and that each bucket drains only for its own profile. --- ...user-profile-cold-buffer-isolation.test.ts | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 tests/user-profile-cold-buffer-isolation.test.ts 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"); + }); +}); From bdabf8c0cbc0b42479350b8d9badd5c375166bd5 Mon Sep 17 00:00:00 2001 From: Marc-oss-hub Date: Sun, 16 Aug 2026 21:19:09 +0800 Subject: [PATCH 6/8] feat: add named orcarouter memory provider mode Add an `orcarouter` memoryProvider mode backed by a dedicated OrcaRouterProvider that extends the OpenAI Chat Completions provider. OrcaRouter (https://www.orcarouter.ai) is an OpenAI-compatible model gateway that requires namespaced model IDs. When `memoryApiUrl` and `memoryModel` are omitted they default to the gateway endpoint (https://api.orcarouter.ai/v1) and the `orcarouter/auto` routing alias, so a minimal config only needs `memoryProvider` + `memoryApiKey`. A bare model name is rejected with a hint instead of surfacing a gateway-side model_not_found error. The provider is tagged `orcarouter` in the AI session store and diagnostics. The base OpenAIChatCompletionProvider gains protected sessionProviderTag / resolveEndpoint / resolveModel hooks so subclasses can override endpoint, model, and session tagging without duplicating request handling. Co-Authored-By: Claude --- README.md | 6 + src/config.ts | 29 ++- src/services/ai/ai-provider-factory.ts | 13 +- src/services/ai/provider-config.ts | 9 +- .../ai/providers/openai-chat-completion.ts | 28 ++- src/services/ai/providers/orcarouter.ts | 78 +++++++ src/services/ai/session/session-types.ts | 2 +- src/types/index.ts | 2 +- tests/ai-provider-config.test.ts | 23 ++ tests/orcarouter-provider.test.ts | 214 ++++++++++++++++++ 10 files changed, 390 insertions(+), 14 deletions(-) create mode 100644 src/services/ai/providers/orcarouter.ts create mode 100644 tests/orcarouter-provider.test.ts 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/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/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"); + }); +}); From 279a55762df0dd39a40659d2a8123e7ba9ecbe9c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 06:19:37 +0000 Subject: [PATCH 7/8] chore(deps): bump the minor-and-patch group across 2 directories with 9 updates Bumps the minor-and-patch group with 4 updates in the / directory: @opencode-ai/plugin, [@opencode-ai/sdk](https://github.com/sst/opencode-sdk-js), [hono](https://github.com/honojs/hono) and [@types/bun](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/bun). Bumps the minor-and-patch group with 5 updates in the /web directory: | Package | From | To | | --- | --- | --- | | [dompurify](https://github.com/cure53/DOMPurify) | `3.4.13` | `3.4.14` | | [marked](https://github.com/markedjs/marked) | `18.0.9` | `18.0.10` | | [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.5` | `6.1.0` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.31.0` | `1.33.0` | | [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.2.1` | `8.2.2` | Updates `@opencode-ai/plugin` from 1.18.18 to 1.18.19 Updates `@opencode-ai/sdk` from 1.18.18 to 1.18.19 - [Release notes](https://github.com/sst/opencode-sdk-js/releases) - [Changelog](https://github.com/anomalyco/opencode-sdk-js/blob/main/CHANGELOG.md) - [Commits](https://github.com/sst/opencode-sdk-js/commits) Updates `hono` from 4.13.2 to 4.13.3 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.13.2...v4.13.3) Updates `@types/bun` from 1.3.14 to 1.4.0 - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/bun) Updates `dompurify` from 3.4.13 to 3.4.14 - [Release notes](https://github.com/cure53/DOMPurify/releases) - [Commits](https://github.com/cure53/DOMPurify/compare/3.4.13...3.4.14) Updates `marked` from 18.0.9 to 18.0.10 - [Release notes](https://github.com/markedjs/marked/releases) - [Commits](https://github.com/markedjs/marked/compare/v18.0.9...v18.0.10) Updates `@vitejs/plugin-react` from 6.0.5 to 6.1.0 - [Release notes](https://github.com/vitejs/vite-plugin-react/releases) - [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.0/packages/plugin-react) Updates `lucide-react` from 1.31.0 to 1.33.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/lucide-react) Updates `vite` from 8.2.1 to 8.2.2 - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v8.2.2/packages/vite) --- updated-dependencies: - dependency-name: "@opencode-ai/plugin" dependency-version: 1.18.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@opencode-ai/sdk" dependency-version: 1.18.19 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: hono dependency-version: 4.13.3 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@types/bun" dependency-version: 1.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: dompurify dependency-version: 3.4.14 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: marked dependency-version: 18.0.10 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: minor-and-patch - dependency-name: "@vitejs/plugin-react" dependency-version: 6.1.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: lucide-react dependency-version: 1.33.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: minor-and-patch - dependency-name: vite dependency-version: 8.2.2 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: minor-and-patch ... Signed-off-by: dependabot[bot] --- bun.lock | 10 +++++----- web/bun.lock | 20 ++++++++++---------- web/package.json | 10 +++++----- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/bun.lock b/bun.lock index ece0b62..a7cff1f 100644 --- a/bun.lock +++ b/bun.lock @@ -130,9 +130,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 +156,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=="], @@ -202,7 +202,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], @@ -224,7 +224,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/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" } } From a98d368c2630d4cd7849c54c3220bd2c07048f08 Mon Sep 17 00:00:00 2001 From: Zhafron Date: Tue, 25 Aug 2026 11:40:28 +0700 Subject: [PATCH 8/8] fix: keep @types/bun 1.4.0 compatible and preserve #265 error propagation - pin bun-types to 1.3.14 via overrides: @types/bun 1.4.0 narrows the NodeJS.Process event surface (memoryPressure-only), which breaks process.on(SIGINT/SIGTERM/beforeExit/exit) typecheck on main - rethrow in performUserProfileLearning after logging so provider errors keep propagating per #267/#265 instead of being silently swallowed --- bun.lock | 5 +++-- package.json | 5 +++-- src/services/user-memory-learning.ts | 5 ++++- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index a7cff1f..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": { @@ -202,7 +203,7 @@ "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], 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/services/user-memory-learning.ts b/src/services/user-memory-learning.ts index 94ce469..205c50f 100644 --- a/src/services/user-memory-learning.ts +++ b/src/services/user-memory-learning.ts @@ -284,8 +284,11 @@ Rules: } 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. Log and exit cleanly. + // 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; }