diff --git a/.github/release-notes/v1.2.4.md b/.github/release-notes/v1.2.4.md new file mode 100644 index 000000000..907796dfd --- /dev/null +++ b/.github/release-notes/v1.2.4.md @@ -0,0 +1,12 @@ +# Memmy v1.2.4 + +基于 v1.2.3 之后已合入并验证的改动,发布稳定版 v1.2.4;本版本集中改进模型配置、Agent 运行时、Memory 可靠性、浏览器与电脑历史采集,以及跨平台运行时分发。 + +- OpenCode V2 对话采集支持连续会话和真实扫描先到场景,补齐生命周期、去重和 L3 暂不可用时的可恢复行为;微信采集增加瞬态变化重试,并在退出时取消等待中的采集任务。 +- BYOK 与内置模型配置统一凭据来源和账号投影,连接测试隔离页面卸载后的响应,保留迁移后的模型模式;补充模型思考级别、token 预算、协议选择、视觉能力和 DeepSeek-V4.1-Flash-1017 能力登记。 +- Agent runtime 增强 Hook 生命周期、执行等待、OpenAI-compatible 请求、模型错误诊断和工具结果处理;显式停止取代整轮超时,恢复会话继续使用明确的模型选择。 +- Memory 改进跨账号候选去重、Embedding 输入校验与失败批次隔离、任务记忆删除、采集边界和运行时构建,并为各平台 runtime 产物生成校验和。 +- 桌面端补齐 slash command 与子命令建议、技能 Markdown 展示、社交登录入口、模型思考控制,以及浏览器和电脑历史授权与恢复路径;Windows History 与微信路径继续保留失败可见性和隐私边界。 +- 打包链路统一 Agent 与 Memory 多平台 runtime 构建、版本同步和依赖安装脚本,保持源码、安装包和运行时元数据一致;真实安装、覆盖升级和签名发布仍需按发版门禁分别验收。 + +本文件只记录 v1.2.4 的版本范围;Full Harness 回归、独立审查、Draft PR、合并、签名 tag、打包和发布仍是相互独立的门槛。 diff --git a/.github/workflows/memory-release.yml b/.github/workflows/memory-release.yml index 91dde3a24..da445a357 100644 --- a/.github/workflows/memory-release.yml +++ b/.github/workflows/memory-release.yml @@ -6,7 +6,7 @@ on: version: description: Memory version (X.Y.Z) required: true - default: 2.1.6 + default: 2.1.7 push: tags: - "memory-v*" diff --git a/.github/workflows/runtime-artifacts.yml b/.github/workflows/runtime-artifacts.yml new file mode 100644 index 000000000..7854d5629 --- /dev/null +++ b/.github/workflows/runtime-artifacts.yml @@ -0,0 +1,101 @@ +name: Agent and Memory runtime artifacts +run-name: runtime-artifacts-${{ github.ref_name }} + +on: + workflow_dispatch: + inputs: + runtime: + description: Runtime packages to build + type: choice + options: [both, memory] + default: both + push: + branches: [zy_memory_runtime_*] +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-14 + arch: arm64 + - target: darwin-x64 + runner: macos-15-intel + arch: x64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + - target: linux-x64 + runner: ubuntu-24.04 + arch: x64 + - target: windows-arm64 + runner: windows-11-arm + arch: arm64 + - target: windows-x64 + runner: windows-2025 + arch: x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + env: + COPYFILE_DISABLE: '1' + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 26.0.0 + architecture: ${{ matrix.arch }} + cache: npm + - name: Install build dependencies + run: npm ci --no-audit --no-fund + - name: Install Agent build dependencies + if: github.event_name == 'workflow_dispatch' && inputs.runtime != 'memory' + run: npm --prefix App/memmy-agent install --ignore-scripts --no-audit --no-fund + - name: Build Agent runtime + if: github.event_name == 'workflow_dispatch' && inputs.runtime != 'memory' + run: npm run agent:runtime:package -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" + - name: Build Memory runtime + run: npm run runtime:package -w @memmy/memory -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" + - name: Verify unpacked runtimes and native dependencies + run: node scripts/internal/shared/verify-runtime-artifacts.mjs "$RUNNER_TEMP/runtime-artifacts" "${{ matrix.target }}" "${{ inputs.runtime || 'memory' }}" + - uses: actions/upload-artifact@v4 + with: + name: runtimes-${{ matrix.target }} + path: ${{ runner.temp }}/runtime-artifacts/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + collect: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 26.0.0 + - uses: actions/download-artifact@v4 + with: + pattern: runtimes-* + path: runtime-input + - name: Collect runtime archives with checksums + run: node scripts/internal/shared/collect-runtime-artifacts.mjs runtime-input runtime-output "${{ inputs.runtime || 'memory' }}" + - uses: actions/upload-artifact@v4 + with: + name: all-platform-runtimes + path: runtime-output/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.gitignore b/.gitignore index fec3c3dd1..b390d647b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +App/memmy-agent/npm-packages/ release/ App/shell/desktop/release/ Mac软件打包/ diff --git a/AgentSourceCore/src/index.ts b/AgentSourceCore/src/index.ts index cd3020e3e..f432e22b5 100644 --- a/AgentSourceCore/src/index.ts +++ b/AgentSourceCore/src/index.ts @@ -4,6 +4,7 @@ export * from "./cursor-source-turn.js"; export * from "./claude-code-source-turn.js"; export * from "./openclaw-source-turn.js"; export * from "./opencode-source-turn.js"; +export * from "./opencode-database-source.js"; export * from "./hermes-source-turn.js"; export * from "./deepseek-source-turn.js"; export * from "./deepseek-session-files.js"; diff --git a/AgentSourceCore/src/opencode-database-source.ts b/AgentSourceCore/src/opencode-database-source.ts new file mode 100644 index 000000000..89949dd4d --- /dev/null +++ b/AgentSourceCore/src/opencode-database-source.ts @@ -0,0 +1,123 @@ +import type { OpencodeSource, OpencodeSession } from "./opencode-source-turn.js"; + +interface Database { + prepare(sql: string): { all(...parameters: string[]): unknown[]; get(...parameters: string[]): unknown }; +} +type Row = Record; +const record = (value: unknown): Row => value && typeof value === "object" && !Array.isArray(value) ? value as Row : {}; +const parse = (value: unknown): Row => { + if (typeof value !== "string") return record(value); + try { return record(JSON.parse(value)); } catch { return {}; } +}; + +/** Adapt both native database generations to the same turn parser. Only one + * session is materialised at a time; all callers open the database read-only. */ +export function createOpencodeDatabaseSource(db: Database, conversationId?: string): OpencodeSource | null { + const hasTable = (name: string) => Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)); + const v2 = hasTable("session_v2") && hasTable("session_message"); + if (!v2 && !(hasTable("session") && hasTable("message") && hasTable("part"))) return null; + const table = v2 ? "session_v2" : "session"; + const columns = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map(row => record(row).name)); + const sessions = db.prepare(`SELECT id, parent_id AS parentId, directory${columns.has("agent") ? ", agent" : ""}${columns.has("revert") ? ", revert" : ""} FROM ${table}${conversationId ? " WHERE id = ?" : ""} ORDER BY time_created ASC, id ASC`); + const messages = db.prepare(v2 + ? "SELECT id, type, data, time_created FROM session_message WHERE session_id = ? ORDER BY seq ASC, id ASC" + : "SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC"); + const parts = v2 ? null : db.prepare("SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC, id ASC"); + // Official V1 -> V2 migration retains the original tables. They are positive + // provenance for migrated messages, whose completion/parent/tool identity must + // remain identical to captures made before upgrading. Native V2 rows still + // require their own durable idle boundary. + const legacyMessages = v2 && hasTable("message") && hasTable("part") + ? db.prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC") : null; + const legacyParts = legacyMessages ? db.prepare("SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC, id ASC") : null; + const currentParts = new Map>(); + return { + sessions: () => sessions.all(...(conversationId ? [conversationId] : [])).map(value => { + const row = record(value); + const revert = parse(row.revert); + return { + id: String(row.id), parentId: row.parentId == null ? null : String(row.parentId), + directory: row.directory == null ? null : String(row.directory), + agent: typeof row.agent === "string" ? row.agent : null, + revertMessageId: typeof revert.messageID === "string" ? revert.messageID : null + } satisfies OpencodeSession; + }), + messages(sessionId) { + const rows = messages.all(sessionId).map(record); + if (!v2) return rows.map(row => ({ id: String(row.id), data: parse(row.data) })); + currentParts.clear(); + let parentID = ""; + let agent = ""; + let turnAssistants: Row[] = []; + const result: Array<{ id: string; data: Row }> = []; + const legacy = legacyMessages?.all(sessionId).map(record) || []; + const migratedIds = new Set(rows.map(row => String(row.id))); + const legacyIds = new Set(); + if (legacy.some(row => migratedIds.has(String(row.id)))) { + for (const row of legacy) { + const id = String(row.id); + legacyIds.add(id); + const data = parse(row.data); + const owner = data.role === "assistant" ? String(data.parentID || "") : id; + // V2 may have removed/reverted a migrated user. Keep its ordinal slot + // for existing source identities, but never resurrect deleted history. + const visible = migratedIds.has(owner); + result.push({ id, data: visible ? data : {} }); + if (!visible) continue; + currentParts.set(id, legacyParts!.all(id).map(value => { + const part = record(value); return { id: String(part.id), data: parse(part.data) }; + })); + } + } + for (const row of rows) { + const data = parse(row.data); + const type = data.type || row.type; + const id = String(row.id); + if (legacyIds.has(id)) continue; + if (type === "agent-switched") { agent = String(data.agent || ""); continue; } + if (type === "user") { + parentID = id; + turnAssistants = []; + result.push({ id, data: { role: "user", time: data.time || { created: row.time_created } } }); + currentParts.set(id, [{ id: id + ":text", data: { type: "text", text: data.text } }]); + } else if (type === "assistant" && parentID) { + const assistant = { ...data, nativeTurnPending: true, role: "assistant", parentID, agent: data.agent || agent }; + turnAssistants.push(assistant); + result.push({ id, data: assistant }); + const content = Array.isArray(data.content) ? data.content.map(record) : []; + currentParts.set(id, content.map((part, index) => { + if (part.type !== "tool") return { id: `${id}:${index}`, data: part }; + const state = record(part.state); + const output = Array.isArray(state.content) + ? state.content.map(record).filter(item => item.type === "text").map(item => item.text).join("\n") : ""; + return { id: `${id}:${index}`, data: { + type: "tool", callID: part.id, tool: part.name, + metadata: { providerExecuted: part.executed === true }, + state: { ...state, output: state.status === "error" ? record(state.error).message || output : output, time: part.time } + } }; + })); + } else if (type === "idle") { + // V2 persists an explicit terminal boundary, including tool-only and + // interrupted turns. Do not infer completion from an intermediate step. + // A final failed/empty assistant can be filtered by the shared parser. + // Close all steps so the last useful text/tool still has the terminal status. + for (const assistant of turnAssistants) { + assistant.nativeTurnCompleted = true; + assistant.nativeTurnPending = false; + if (data.outcome === "failed" || data.outcome === "interrupted") assistant.nativeStatus = "failed"; + if (data.outcome === "interrupted") assistant.error = { name: "MessageAbortedError" }; + assistant.time = { ...record(assistant.time), completed: record(data.time).created || row.time_created }; + } + // Freeze the completed turn. Synthetic continuations and independent + // compactions are not new user turns and cannot reopen this parent. + parentID = ""; + turnAssistants = []; + } + } + return result; + }, + parts: messageId => parts + ? parts.all(messageId).map(value => { const row = record(value); return { id: String(row.id), data: parse(row.data) }; }) + : currentParts.get(messageId) || [] + }; +} diff --git a/AgentSourceCore/src/opencode-source-turn.ts b/AgentSourceCore/src/opencode-source-turn.ts index 823228e9d..b40438225 100644 --- a/AgentSourceCore/src/opencode-source-turn.ts +++ b/AgentSourceCore/src/opencode-source-turn.ts @@ -59,6 +59,9 @@ interface OpencodeAssistant { pendingTool: boolean; hasOrdinaryTool: boolean; hasStructuredOutput: boolean; + nativeTurnCompleted: boolean; + nativeTurnPending: boolean; + nativeStatus?: "succeeded" | "failed"; } /** @@ -112,7 +115,10 @@ export async function* readOpencodeSession( toolResults: collected.toolResults, pendingTool: collected.pendingTool, hasOrdinaryTool: collected.hasOrdinaryTool, - hasStructuredOutput: data.structured !== undefined + hasStructuredOutput: data.structured !== undefined, + nativeTurnCompleted: data.nativeTurnCompleted === true, + nativeTurnPending: data.nativeTurnPending === true, + nativeStatus: data.nativeStatus === "failed" ? "failed" : undefined }; // An assistant with neither text nor a tool is idle bookkeeping (`step-finish` only). if (!assistant.assistantText && assistant.toolCalls.length === 0 && !assistant.pendingTool) continue; @@ -134,7 +140,7 @@ export async function* readOpencodeSession( const profileId = user.userAgent || assistantProfileId(assistants); let reason = ""; if (!user.query.trim()) reason = "turn_content_incomplete"; - else if (!last) reason = "turn_incomplete"; + else if (!last || last.nativeTurnPending) reason = "turn_incomplete"; else if (assistants.some((assistant) => assistant.pendingTool)) reason = "turn_incomplete"; else if (!last.aborted && opencodeAssistantStillOpen(last)) reason = "turn_incomplete"; // An interrupted turn still counts when it produced text or a finished tool. @@ -145,7 +151,7 @@ export async function* readOpencodeSession( source: OPENCODE_SOURCE_ID, conversationId: session.id, turnId: user.id, profileId, startedAt: user.startedAt, completedAt, sequence: user.ordinal, completionEvidence: last!.aborted ? `assistant_aborted:${last!.id}` : `assistant_completed:${last!.id}`, - query: redactSecrets(user.query), answer: redactSecrets(answer), status: "succeeded", + query: redactSecrets(user.query), answer: redactSecrets(answer), status: last!.nativeStatus || "succeeded", toolCalls: pairSourceToolCalls(toolCalls, toolResults).map(redactCall), toolResults: toolResults.map(redactResult), ...(session.directory ? { workspacePath: session.directory } : {}) }; @@ -229,6 +235,7 @@ function collectParts(source: OpencodeSource, messageId: string): { */ function opencodeAssistantStillOpen(last: OpencodeAssistant): boolean { if (!last.completedAt) return true; + if (last.nativeTurnCompleted) return false; if (last.hasStructuredOutput) return false; if (!last.finish || last.finish === "tool-calls" || last.finish === "unknown") return true; return last.hasOrdinaryTool; diff --git a/App/backend/local-api-contracts/package.json b/App/backend/local-api-contracts/package.json index 17a8c8cf8..d33a62b9f 100644 --- a/App/backend/local-api-contracts/package.json +++ b/App/backend/local-api-contracts/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json", - "test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts tests/language.test.ts", + "test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts tests/language.test.ts tests/model-catalog-resolver.test.ts tests/model-thinking-levels.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index a9bd7fe32..4fd760109 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -1,10 +1,12 @@ /** Memmy local API contract. */ import { z } from "zod"; +import { ModelThinkingConfigSchema } from "./model-thinking-levels.js"; import { normalizeOutputLanguage } from "./output-language.js"; export { normalizeOutputLanguage } from "./output-language.js"; export * from "./model-catalog-resolver.js"; +export * from "./model-thinking-levels.js"; export * from "./memory-runtime.js"; export * from "./memory-canonical-json.js"; @@ -745,6 +747,13 @@ export const ModelProviderSchema = z.enum([ ]); export type ModelProvider = z.infer; +/** Connection tests accept the catalog provider custom. Role model config stays on ModelProviderSchema. */ +export const ModelConfigTestProviderSchema = z.union([ + ModelProviderSchema, + z.literal("custom") +]); +export type ModelConfigTestProvider = z.infer; + export const CatalogProviderIdSchema = z.enum([ "openai", "anthropic", @@ -758,7 +767,9 @@ export const CatalogProviderIdSchema = z.enum([ "volcengine", "stepfun", "xiaomi_mimo", - "memmy_account" + "memmy_account", + "bedrock", + "custom" ]); export type CatalogProviderId = z.infer; @@ -800,7 +811,8 @@ export const ModelEndpointProtocolSchema = z.enum([ "dashscope-input-audio-chat", "openai-images", "dashscope-multimodal-generation", - "memmy-account" + "memmy-account", + "bedrock-converse" ]); export type ModelEndpointProtocol = z.infer; @@ -921,19 +933,26 @@ export const CatalogEndpointInputSchema = z.object({ protocol: ModelEndpointProtocolSchema, apiKey: z.string().optional(), extraHeaders: z.record(z.string(), z.string()).optional(), - extraBody: z.record(z.string(), z.unknown()).optional() + extraBody: z.record(z.string(), z.unknown()).optional(), + region: z.string().trim().min(1).optional() }); export type CatalogEndpointInput = z.infer; export const MODEL_NAME_MAX_LENGTH = 128; +export const ModelInputModalitySchema = z.enum(["text", "image", "video"]); +export type ModelInputModality = z.infer; + export const TextModelItemInputSchema = z.object({ presetId: z.string().trim().min(1).optional(), endpointId: z.string().trim().min(1), model: z.string().trim().min(1).max(MODEL_NAME_MAX_LENGTH), source: ModelSourceSchema, ownerAccountId: z.string().trim().min(1).optional(), - capabilities: z.array(ModelCapabilitySchema).min(1) + capabilities: z.array(ModelCapabilitySchema).min(1), + thinking: ModelThinkingConfigSchema.optional(), + custom: z.boolean().optional(), + inputModalities: z.array(ModelInputModalitySchema).optional() }); export type TextModelItemInput = z.infer; @@ -981,14 +1000,15 @@ export type ModelConfigInput = z.infer; /** Definition for model config test input. */ export const ModelConfigTestInputSchema = z.object({ - provider: ModelProviderSchema, + provider: ModelConfigTestProviderSchema, endpointId: z.string().trim().min(1), protocol: ModelEndpointProtocolSchema, apiBase: z.string().url(), modelId: z.string().min(1), apiKey: z.string().min(1).optional(), capability: ModelConfigTestCapabilitySchema.optional(), - secretTarget: ModelConfigTestSecretTargetSchema.optional() + secretTarget: ModelConfigTestSecretTargetSchema.optional(), + region: z.string().trim().min(1).optional() }); export type ModelConfigTestInput = z.infer; @@ -1094,7 +1114,8 @@ export const CatalogEndpointViewSchema = z.object({ protocol: ModelEndpointProtocolSchema, hasApiKey: z.boolean(), apiKeyMasked: z.string(), - apiKey: z.string().default("") + apiKey: z.string().default(""), + region: z.string().min(1).optional() }); export type CatalogEndpointView = z.infer; @@ -1107,7 +1128,10 @@ export const TextModelItemViewSchema = z.object({ source: ModelSourceSchema, ownerAccountId: z.string().min(1).optional(), capabilities: z.array(ModelCapabilitySchema).min(1), - available: z.boolean() + available: z.boolean(), + thinking: ModelThinkingConfigSchema.optional(), + custom: z.boolean().optional(), + inputModalities: z.array(ModelInputModalitySchema).optional() }); export type TextModelItemView = z.infer; @@ -1143,6 +1167,8 @@ export type MemoryRuntimeModelSettings = z.infer; extraBody?: Record; + region?: string; } export const BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID = "memmy-builtin-local-embedding"; @@ -30,6 +31,7 @@ export interface RuntimeCatalogPreset { source: ModelSource; ownerAccountId?: string; capabilities: ModelCapability[]; + inputModalities?: readonly ("text" | "image" | "video")[]; } export interface RuntimeModelAssignment { @@ -74,6 +76,7 @@ export interface ActualModelContext { ownerAccountId: string | null; capability: ModelCapability; capabilities: readonly ModelCapability[]; + inputModalities?: readonly ("text" | "image" | "video")[]; } export interface ResolvedProviderSnapshot { @@ -118,7 +121,8 @@ const CAPABILITIES = new Set([ const PROTOCOLS = new Set([ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "openai-embeddings", "dashscope-input-audio-chat", - "openai-images", "dashscope-multimodal-generation", "memmy-account" + "openai-images", "dashscope-multimodal-generation", "memmy-account", + "bedrock-converse" ]); /** Resolves one immutable current-catalog model assignment without guessing another preset or endpoint. */ @@ -171,7 +175,8 @@ export function resolveAssignedModel(input: ResolveAssignedModelInput): ModelSel source: preset.source, ownerAccountId: preset.ownerAccountId ?? null, capability: input.capability, - capabilities + capabilities, + ...(preset.inputModalities ? { inputModalities: Object.freeze([...preset.inputModalities]) } : {}) }); const providerSnapshot = Object.freeze({ provider: preset.provider, @@ -301,7 +306,12 @@ function isRuntimePreset(value: unknown): value is RuntimeCatalogPreset { && value.capabilities.length > 0 && value.capabilities.every((capability): capability is ModelCapability => ( typeof capability === "string" && CAPABILITIES.has(capability as ModelCapability) - )); + )) + && (value.inputModalities === undefined + || (Array.isArray(value.inputModalities) + && value.inputModalities.every((modality) => ( + modality === "text" || modality === "image" || modality === "video" + )))); } function isRuntimeProvider(value: unknown): value is RuntimeCatalogProvider { @@ -320,7 +330,8 @@ function isRuntimeEndpoint(value: unknown): value is RuntimeCatalogEndpoint { && PROTOCOLS.has(value.protocol as ModelEndpointProtocol) && (value.apiKey === undefined || typeof value.apiKey === "string") && validStringRecord(value.extraHeaders) - && validUnknownRecord(value.extraBody); + && validUnknownRecord(value.extraBody) + && (value.region === undefined || typeof value.region === "string"); } function protocolSupportsCapability( @@ -332,12 +343,15 @@ function protocolSupportsCapability( return protocol === "openai-chat-completions" || protocol === "openai-responses" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "memory_summary" || capability === "memory_evolution") { return protocol === "openai-chat-completions" + || protocol === "openai-responses" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "embedding") return protocol === "openai-embeddings"; if (capability === "asr") return protocol === "dashscope-input-audio-chat"; diff --git a/App/backend/local-api-contracts/src/model-thinking-levels.ts b/App/backend/local-api-contracts/src/model-thinking-levels.ts new file mode 100644 index 000000000..667f2f045 --- /dev/null +++ b/App/backend/local-api-contracts/src/model-thinking-levels.ts @@ -0,0 +1,283 @@ +/** + * 模型思考强度配置表。Reviewed: 2026-09-29 + * + * 三个字段决定前端渲染与新 session 默认状态: + * - switchable: true → 渲染思考开关(用户可关闭思考) + * false → 不渲染开关(thinking always-on) + * - defaultEnabled: 新 session 默认开关状态(仅 switchable:true 时有意义,当前恒为 true) + * - levels: 强度档位列表(不含表示关闭的 "off")。空数组 = 无档位选择器。 + * + * 档位命名(统一 i18n key):low=低, medium=中, high=高, xhigh=超高, max=最强 + */ +import { z } from "zod"; + +/** 思考强度档位。前端 i18n key 由 `home.thinking.levelValue.${level}` 拼出,须为字面量联合。 */ +export const THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +export interface ModelThinkingConfig { + switchable: boolean; + defaultEnabled: boolean; + levels: readonly ThinkingLevel[]; + defaultLevel?: ThinkingLevel; +} + +export const ModelThinkingConfigSchema = z.object({ + switchable: z.boolean(), + defaultEnabled: z.boolean(), + levels: z.array(z.enum(THINKING_LEVELS)), + defaultLevel: z.enum(THINKING_LEVELS).optional() +}); +export type ModelThinkingConfigDto = z.infer; + +export const MODEL_THINKING_REVIEWED_AT = "2026-09-29"; + +const ALWAYS_ON_FIVE: ModelThinkingConfig = { + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "high" +}; + +const SWITCHABLE_FIVE: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "high" +}; + +/** GPT-6 Sol / Luna: `none` turns reasoning off; omitted effort is `medium`. */ +const SWITCHABLE_FIVE_MEDIUM: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" +}; + +/** + * Thinking cannot be disabled. Five effort levels, default `medium`. + * GPT-6 Astra rejects `none`. Claude Opus 5.5 documents `medium` as its API default. + * OpenAI's reasoning guide does not publish a separate omit-effort default for Astra; + * `medium` is the default it documents for the other GPT-6 models and for most workloads. + */ +const ALWAYS_ON_FIVE_MEDIUM: ModelThinkingConfig = { + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" +}; + +const SWITCHABLE_FOUR: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh"], + defaultLevel: "medium" +}; + +const SWITCHABLE_THREE: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" +}; + +const SWITCHABLE_LOW_HIGH_MAX: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" +}; + +const SWITCHABLE_LOW_HIGH: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high"], + defaultLevel: "high" +}; + +const TOGGLE_ONLY: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: [] +}; + +export const MODEL_THINKING_CONFIGS: Readonly> = Object.freeze({ + // Anthropic. Reviewed 2026-09-10. + "claude-fable-5-1": ALWAYS_ON_FIVE, + "claude-fable-5": ALWAYS_ON_FIVE, + "claude-mythos-5-1": ALWAYS_ON_FIVE, + "claude-mythos-5": ALWAYS_ON_FIVE, + "claude-mythos-preview": ALWAYS_ON_FIVE, + "claude-opus-5-5": ALWAYS_ON_FIVE_MEDIUM, + "claude-opus-5": SWITCHABLE_FIVE, + "claude-sonnet-5": SWITCHABLE_FIVE, + "claude-opus-4-8": SWITCHABLE_FIVE, + "claude-opus-4-7": SWITCHABLE_FIVE, + "claude-opus-4-6": SWITCHABLE_FIVE, + "claude-sonnet-4-6": SWITCHABLE_FIVE, + "claude-opus-4-5-20251101": { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "max"], + defaultLevel: "high" + }, + "claude-haiku-4-5": TOGGLE_ONLY, + "claude-haiku-4-5-20251001": TOGGLE_ONLY, + "anthropic.claude-opus-5-5": ALWAYS_ON_FIVE_MEDIUM, + "anthropic.claude-opus-5": SWITCHABLE_FIVE, + "anthropic.claude-sonnet-5": SWITCHABLE_FIVE, + "anthropic.claude-opus-4-8": SWITCHABLE_FIVE, + "anthropic.claude-opus-4-7": SWITCHABLE_FIVE, + "anthropic.claude-haiku-4-5": TOGGLE_ONLY, + + // OpenAI. GPT-6 reviewed 2026-09-29; GPT-5.6 and earlier reviewed 2026-09-10. + "gpt-6-astra": ALWAYS_ON_FIVE_MEDIUM, + "gpt-6-sol": SWITCHABLE_FIVE_MEDIUM, + "gpt-6-luna": SWITCHABLE_FIVE_MEDIUM, + "gpt-5.6": SWITCHABLE_FIVE, + "gpt-5.6-sol": SWITCHABLE_FIVE, + "gpt-5.6-terra": SWITCHABLE_FIVE, + "gpt-5.6-luna": SWITCHABLE_FIVE, + "gpt-5.5": SWITCHABLE_FOUR, + "gpt-5.5-pro": SWITCHABLE_FOUR, + "gpt-5.4": SWITCHABLE_THREE, + "gpt-5.4-pro": SWITCHABLE_THREE, + "gpt-5.4-mini": SWITCHABLE_THREE, + "gpt-5.4-nano": SWITCHABLE_THREE, + "gpt-5.3-codex": SWITCHABLE_THREE, + "gpt-5.2": SWITCHABLE_THREE, + "gpt-5.2-pro": SWITCHABLE_THREE, + "gpt-5.2-codex": SWITCHABLE_THREE, + "gpt-5.1": SWITCHABLE_THREE, + "gpt-5.1-codex": SWITCHABLE_THREE, + + // Gemini. 3.8 Flash reviewed 2026-09-29; 2.5 reviewed 2026-09-10. + "gemini-3.8-flash": { + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" + }, + "gemini-2.5-flash": TOGGLE_ONLY, + "gemini-2.5-flash-lite": TOGGLE_ONLY, + + // DeepSeek. V4.1 Flash reviewed 2026-09-29; earlier V4 ids reviewed 2026-09-10. + "deepseek-flash": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4.1-flash": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-pro": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash-0731": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash-vision-exp": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v3.2": TOGGLE_ONLY, + + // Zhipu GLM. Reviewed 2026-09-10. + "glm-5.3": { + switchable: false, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" + }, + "glm-5.2": TOGGLE_ONLY, + "glm-5.1": TOGGLE_ONLY, + "glm-5": TOGGLE_ONLY, + "glm-5-turbo": TOGGLE_ONLY, + "glm-4.7": TOGGLE_ONLY, + + // Kimi / Moonshot. Reviewed 2026-09-10. + "kimi-k3": SWITCHABLE_LOW_HIGH_MAX, + "k3": SWITCHABLE_LOW_HIGH_MAX, + "k3-256k": SWITCHABLE_LOW_HIGH_MAX, + "kimi-k2.6": TOGGLE_ONLY, + "kimi-k2.5": TOGGLE_ONLY, + + // MiniMax. Reviewed 2026-09-10. + "MiniMax-M3": TOGGLE_ONLY, + + // Qwen. Reviewed 2026-09-10. + "qwen3.8-max": TOGGLE_ONLY, + "qwen3.8-max-preview": TOGGLE_ONLY, + "qwen3.7-max": TOGGLE_ONLY, + "qwen3.7-max-2026-05-20": TOGGLE_ONLY, + "qwen3.7-max-2026-06-08": TOGGLE_ONLY, + "qwen3.7-plus": TOGGLE_ONLY, + "qwen3.7-plus-2026-05-26": TOGGLE_ONLY, + "qwen3.7-flash": TOGGLE_ONLY, + "qwen3.7-flash-2026-07-15": TOGGLE_ONLY, + "qwen3.6-max-preview": TOGGLE_ONLY, + "qwen3.6-plus": TOGGLE_ONLY, + "qwen3.6-plus-2026-04-02": TOGGLE_ONLY, + "qwen3.6-flash": TOGGLE_ONLY, + "qwen3.6-flash-2026-04-16": TOGGLE_ONLY, + "qwen3.5-plus": TOGGLE_ONLY, + "qwen3.5-plus-2026-02-15": TOGGLE_ONLY, + "qwen3.5-flash": TOGGLE_ONLY, + "qwen3.5-flash-2026-02-23": TOGGLE_ONLY, + "qwen3-coder-plus": TOGGLE_ONLY, + "qwen3-coder-flash": TOGGLE_ONLY, + + // Doubao. Reviewed 2026-09-10. + "doubao-seed-evolving": TOGGLE_ONLY, + "doubao-seed-2-1-pro": TOGGLE_ONLY, + "doubao-seed-2-1-turbo": TOGGLE_ONLY, + "doubao-seed-2-0-pro-260215": TOGGLE_ONLY, + "doubao-seed-2-0-lite-260215": TOGGLE_ONLY, + "doubao-seed-2-0-mini-260215": TOGGLE_ONLY, + + // Xiaomi MiMo. Reviewed 2026-09-10. + "mimo-v2.5-pro": TOGGLE_ONLY, + "mimo-v2.5-pro-ultraspeed": TOGGLE_ONLY, + "mimo-v2.5": TOGGLE_ONLY, + + // StepFun. Reviewed 2026-09-10. + "step-3.7-flash": SWITCHABLE_THREE, + "step-3.5-flash": SWITCHABLE_LOW_HIGH, + "step-3.5-flash-2603": SWITCHABLE_LOW_HIGH, + + // Baidu ERNIE. Reviewed 2026-09-10. + "ernie-5.1": TOGGLE_ONLY, + "ernie-5.0": TOGGLE_ONLY, + "ernie-5.0-thinking-preview": TOGGLE_ONLY, + "ernie-5.0-thinking-latest": TOGGLE_ONLY, + "ernie-x1.1": TOGGLE_ONLY, + "ernie-x1.1-preview": TOGGLE_ONLY +}); + +export function getModelThinkingConfig(model: string | null | undefined): ModelThinkingConfig | null { + if (typeof model !== "string") return null; + return MODEL_THINKING_CONFIGS[model] ?? null; +} + +/** 是否只有开关无档位(情况2):可开关且无强度档位。 */ +export function isThinkingToggleOnly(config: ModelThinkingConfig | null): boolean { + if (!config) return false; + return config.switchable && config.levels.length === 0; +} + +/** 解析当前生效的思考档位:会话选择优先,否则回退表中默认档,再退首个档位。 */ +export function resolveThinkingLevel( + config: ModelThinkingConfig, + sessionLevel: string | null | undefined +): ThinkingLevel | null { + if (sessionLevel && config.levels.includes(sessionLevel as ThinkingLevel)) { + return sessionLevel as ThinkingLevel; + } + return config.defaultLevel ?? config.levels[0] ?? null; +} + +/** 解析当前生效的开关状态:always-on 恒 true,其余用会话选择,缺省取 defaultEnabled。 */ +export function resolveThinkingEnabled( + config: ModelThinkingConfig, + sessionEnabled: boolean | undefined +): boolean { + if (!config.switchable) return true; + return sessionEnabled ?? config.defaultEnabled; +} + +/** + * 从档位列表取默认档("中档"):优先 medium,否则取中间项(偶数个时偏下,避免默认落到最高档)。 + */ +export function defaultThinkingLevel(levels: readonly ThinkingLevel[]): ThinkingLevel | undefined { + if (levels.length === 0) return undefined; + if (levels.includes("medium")) return "medium"; + return levels[Math.floor((levels.length - 1) / 2)]; +} diff --git a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts index 7bc025940..2a4dc0c13 100644 --- a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts +++ b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts @@ -216,16 +216,64 @@ describe("resolveAssignedModel", () => { })).toEqual({ ok: false, code: "model_selection_unavailable" }); }); - it("rejects Responses presets for Memory capabilities until a Responses adapter exists", () => { + it("resolves Responses presets for memory capabilities", () => { const current = catalog() as any; current.providers.dashscope.endpoints.asr.protocol = "openai-responses"; current.modelPresets["byok-asr"].capabilities = ["memory_summary"]; current.modelAssignments.byok = { memorySummary: "byok-asr" }; - expect(resolveAssignedModel({ + const resolved = resolveAssignedModel({ catalog: current, mode: "byok", capability: "memory_summary" - })).toEqual({ ok: false, code: "model_selection_unavailable" }); + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.context.protocol).toBe("openai-responses"); + expect(resolved.context.capability).toBe("memory_summary"); + }); + + it("resolves bedrock-converse and declared input modalities", () => { + const current: RuntimeModelCatalog = { + providers: { + bedrock: { + endpoints: { + chat: { + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2" + } + } + } + }, + modelPresets: { + custom: { + provider: "bedrock", + endpoint: "chat", + model: "anthropic.claude-sonnet-5", + source: "byok", + capabilities: ["agent"], + inputModalities: ["text", "image"] + } + }, + modelAssignments: { + byok: { + agent: { candidates: ["custom"], default: "custom" } + } + } + }; + const resolved = resolveAssignedModel({ + catalog: current, + mode: "byok", + capability: "agent" + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.context.protocol).toBe("bedrock-converse"); + expect(resolved.context.provider).toBe("bedrock"); + expect(resolved.context.inputModalities).toEqual(["text", "image"]); + expect(resolved.provider.apiBase).toBe("https://bedrock-runtime.us-west-2.amazonaws.com"); }); }); diff --git a/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts b/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts new file mode 100644 index 000000000..650d6722e --- /dev/null +++ b/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + defaultThinkingLevel, + getModelThinkingConfig, + isThinkingToggleOnly, + resolveThinkingEnabled, + resolveThinkingLevel, +} from "../src/model-thinking-levels.js"; + +describe("model thinking levels", () => { + it("returns table config, toggle-only, and default middle level", () => { + const gpt = getModelThinkingConfig("gpt-5.4"); + expect(gpt).toMatchObject({ + switchable: true, + defaultEnabled: true, + defaultLevel: "medium", + }); + expect(isThinkingToggleOnly(getModelThinkingConfig("claude-haiku-4-5"))).toBe(true); + expect(getModelThinkingConfig("unlisted-model")).toBeNull(); + expect(defaultThinkingLevel(["low", "medium", "high"])).toBe("medium"); + expect(defaultThinkingLevel(["low", "high"])).toBe("low"); + expect(defaultThinkingLevel([])).toBeUndefined(); + }); + + it("resolves session override against table defaults", () => { + const gpt = getModelThinkingConfig("gpt-5.4")!; + expect(resolveThinkingEnabled(gpt, undefined)).toBe(true); + expect(resolveThinkingEnabled(gpt, false)).toBe(false); + expect(resolveThinkingLevel(gpt, undefined)).toBe("medium"); + expect(resolveThinkingLevel(gpt, "high")).toBe("high"); + expect(resolveThinkingLevel(gpt, "not-a-level")).toBe("medium"); + + const alwaysOn = getModelThinkingConfig("claude-fable-5")!; + expect(resolveThinkingEnabled(alwaysOn, false)).toBe(true); + }); + + it("matches vendor docs for models added on 2026-09-29", () => { + expect(getModelThinkingConfig("gpt-6-sol")).toEqual({ + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" + }); + expect(getModelThinkingConfig("gpt-6-luna")).toEqual(getModelThinkingConfig("gpt-6-sol")); + expect(getModelThinkingConfig("gpt-6-astra")).toEqual({ + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" + }); + expect(resolveThinkingEnabled(getModelThinkingConfig("gpt-6-astra")!, false)).toBe(true); + + expect(getModelThinkingConfig("claude-opus-5-5")).toEqual(getModelThinkingConfig("gpt-6-astra")); + expect(getModelThinkingConfig("anthropic.claude-opus-5-5")).toEqual(getModelThinkingConfig("claude-opus-5-5")); + + expect(getModelThinkingConfig("gemini-3.8-flash")).toEqual({ + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" + }); + + const deepseek = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" + }; + expect(getModelThinkingConfig("deepseek-flash")).toEqual(deepseek); + expect(getModelThinkingConfig("deepseek-v4.1-flash")).toEqual(deepseek); + }); +}); diff --git a/App/backend/src/adapters/outbound/agent-source/opencode/db-reader.ts b/App/backend/src/adapters/outbound/agent-source/opencode/db-reader.ts index 791a8748d..82c0d0e01 100644 --- a/App/backend/src/adapters/outbound/agent-source/opencode/db-reader.ts +++ b/App/backend/src/adapters/outbound/agent-source/opencode/db-reader.ts @@ -2,8 +2,7 @@ import { DatabaseSync } from "node:sqlite"; import { readOpencodeSessions, - type OpencodeSession, - type OpencodeSource, + createOpencodeDatabaseSource, type RawSourceMessage } from "@memmy/agent-source-core"; @@ -22,7 +21,7 @@ export interface RawOpencodeDatabaseMessage extends RawSourceMessage { export async function* readOpencodeDatabase(path: string, signal?: AbortSignal): AsyncIterable { const db = new DatabaseSync(path, { readOnly: true }); try { - const source = createSource(db); + const source = createOpencodeDatabaseSource(db); if (!source) return; const directories = new Map([...source.sessions()].map((session) => [session.id, session.directory ?? null])); for await (const message of readOpencodeSessions(source, signal)) { @@ -38,52 +37,3 @@ export async function* readOpencodeDatabase(path: string, signal?: AbortSignal): export async function* streamOpencodeDatabase(path: string, signal?: AbortSignal): AsyncIterable { yield* readOpencodeDatabase(path, signal); } - -/** A database without the conversation tables is not a conversation store. */ -function createSource(db: DatabaseSync): OpencodeSource | null { - if (!hasTable(db, "session") || !hasTable(db, "message") || !hasTable(db, "part")) return null; - const columns = tableColumns(db, "session"); - const sessions = db.prepare(`SELECT id, parent_id AS parentId, directory${columns.has("agent") ? ", agent" : ""}${columns.has("revert") ? ", revert" : ""} FROM session ORDER BY time_created ASC, id ASC`); - const messages = db.prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC"); - const parts = db.prepare("SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC, id ASC"); - return { - sessions: () => (sessions.all() as unknown as Array>).map(toOpencodeSession), - messages: (sessionId) => (messages.all(sessionId) as unknown as Array<{ id: string; data: string }>) - .map((row) => ({ id: row.id, data: parseJson(row.data) })), - parts: (messageId) => (parts.all(messageId) as unknown as Array<{ id: string; data: string }>) - .map((row) => ({ id: row.id, data: parseJson(row.data) })) - }; -} - -function toOpencodeSession(row: Record): OpencodeSession { - return { - id: String(row.id), - parentId: row.parentId == null ? null : String(row.parentId), - directory: row.directory == null ? null : String(row.directory), - agent: typeof row.agent === "string" ? row.agent : null, - revertMessageId: revertMessageId(row.revert) - }; -} - -function revertMessageId(value: unknown): string | null { - const parsed = typeof value === "string" ? parseJson(value) : value; - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null; - const messageId = (parsed as { messageID?: unknown }).messageID; - return typeof messageId === "string" && messageId ? messageId : null; -} - -function tableColumns(db: DatabaseSync, tableName: string): Set { - return new Set((db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>).map((row) => row.name)); -} - -function parseJson(value: string): unknown { - try { - return JSON.parse(value); - } catch { - return undefined; - } -} - -function hasTable(db: DatabaseSync, tableName: string): boolean { - return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName)); -} diff --git a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts index 6eb1c4e66..f9e5d0aed 100644 --- a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts +++ b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts @@ -68,6 +68,7 @@ import type { } from "./types.js"; const DEFAULT_TIMEOUT_MS = 5_000; +const CLOUD_SOCIAL_LOGIN_TIMEOUT_MS = 20_000; const CLOUD_COMPOSIO_ROUTER_TIMEOUT_MS = 60_000; const CLOUD_COMPOSIO_UNAVAILABLE_MESSAGE = "工具连接服务暂时不可用"; const CLOUD_COMPOSIO_SERVICE_UNAVAILABLE_CODE = 60020; @@ -147,7 +148,7 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} const data = await requestCloudData>( fetchImpl, baseUrl, - timeoutMs, + socialLoginTimeoutMs(timeoutMs), "/api/agentUser/oauth/start", { body: { @@ -169,7 +170,7 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} const data = await requestCloudData>( fetchImpl, baseUrl, - timeoutMs, + socialLoginTimeoutMs(timeoutMs), "/api/agentUser/oauth/status", { body: { flowId: input.flowId, pollToken: input.pollToken }, @@ -532,6 +533,10 @@ async function requestCloudData( return envelope.data as T; } +function socialLoginTimeoutMs(timeoutMs: number): number { + return Math.max(timeoutMs, CLOUD_SOCIAL_LOGIN_TIMEOUT_MS); +} + function normalizeAgentRegion(value: string | undefined): "cn" | "intl" { return value?.trim().toLowerCase() === "intl" ? "intl" : "cn"; } diff --git a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts index 498655008..ffea27e3f 100644 --- a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts +++ b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts @@ -222,6 +222,39 @@ describe("cloud client", () => { ]); }); + it("keeps waiting for Google login after the default cloud timeout", async () => { + server = createServer((_request, response) => { + setTimeout(() => { + sendJson(response, { + code: 0, + message: "ok", + data: { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=opaque-state", + expiresInSec: 600, + pollIntervalSec: 2 + } + }); + }, 1_500); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Mock cloud server did not bind"); + const client = createHttpCloudClient({ + baseUrl: `http://127.0.0.1:${address.port}`, + timeoutMs: 200 + }); + + const start = await client.startSocialLogin({ + provider: "google", + locale: "en", + loginSource: "Memmy" + }); + + expect(start.authorizationUrl).toContain("https://accounts.google.com/"); + }); + it("rejects a social-login authorization URL outside the provider allowlist", async () => { server = createServer((_request, response) => { sendJson(response, { diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts b/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts index a46425ca0..27edba3f5 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts @@ -1,6 +1,7 @@ /** Codex hook trust persistence through the Codex app-server protocol. */ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; import { basename, join, normalize } from "node:path"; const APP_SERVER_REQUEST_TIMEOUT_MS = 10_000; @@ -283,8 +284,17 @@ function handleResponseLine(line: string, pending: Map): function resolveCodexExecutable(codexHomeDirectory: string): string { const executableName = process.platform === "win32" ? "codex.exe" : "codex"; - const bundledExecutable = join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName); - return isExecutableFile(bundledExecutable) ? bundledExecutable : executableName; + const candidates = [join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName)]; + if (process.platform === "darwin") { + // The desktop process may not inherit the shell PATH where ChatGPT exposes Codex CLI. + const codexCliPath = join("Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"); + candidates.push( + join("/Applications", "ChatGPT.app", codexCliPath), + join(homedir(), "Applications", "ChatGPT.app", codexCliPath) + ); + } + + return candidates.find(isExecutableFile) ?? executableName; } function isExecutableFile(filePath: string): boolean { diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts index a292d4d33..2c0c65ab5 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts @@ -1,5 +1,5 @@ /** Target module. */ -import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { resolveCodexHomeDirectory } from "../../agent-paths.js"; @@ -118,18 +118,26 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }; - await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); - await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); - await writeFileAtomically( - join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), - await loadMemmyWorkspaceBridgeRuntimeAsset() - ); const hooksFilePath = join(root, HOOKS_FILE_NAME); + const bridgePath = join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME); const hookCommand = createNodeHookCommand(hookScriptPath); - await upsertCodexHookConfig(hooksFilePath, hookCommand); - await rm(join(root, HOOK_DIRECTORY_NAME, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); + await withCodexHookFileRollback( + [hookConfigPath, hookScriptPath, bridgePath, hooksFilePath], + async () => { + await mkdir(hookDirectory, { recursive: true }); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); + await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); + await writeFileAtomically(bridgePath, await loadMemmyWorkspaceBridgeRuntimeAsset()); + await upsertCodexHookConfig(hooksFilePath, hookCommand); + await trustHooks({ codexHomeDirectory: root, hooksFilePath, hookCommand }); + await writeFileAtomically( + hookConfigPath, + `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n` + ); + } + ); + await rm(join(hookDirectory, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); const manifest = renderMemmyPluginSkillManifest(_targetId); const filePath = join(root, TARGET_FILE_NAME); await writeFileAtomically( @@ -138,15 +146,6 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S ); await replaceMemmySkillDirectory(root, manifest); await replaceMemmyResumeSkillDirectory(root, CODEX_TARGET_ID); - await trustHooks({ - codexHomeDirectory: root, - hooksFilePath, - hookCommand - }); - await writeFileAtomically( - hookConfigPath, - `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n` - ); }, async uninstallPlugin(_targetId) { @@ -335,11 +334,73 @@ function createMarkerBlockPattern(startMarker: string, endMarker = END_MARKER): return new RegExp(`${escapeRegExp(startMarker)}\\n[\\s\\S]*?${escapeRegExp(endMarker)}\\n?`, "m"); } -async function writeFileAtomically(filePath: string, content: string): Promise { +async function writeFileAtomically(filePath: string, content: string | Buffer, mode?: number): Promise { await mkdir(dirname(filePath), { recursive: true }); const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`); - await writeFile(tempPath, content, "utf8"); - await rename(tempPath, filePath); + try { + await writeFile(tempPath, content, { encoding: "utf8", ...(mode === undefined ? {} : { mode }) }); + await rename(tempPath, filePath); + } finally { + await rm(tempPath, { force: true }); + } +} + +type CodexHookFileSnapshot = + | { path: string; kind: "missing" } + | { path: string; kind: "file"; content: Buffer; mode: number } + | { path: string; kind: "symlink"; target: string }; + +async function withCodexHookFileRollback(paths: string[], operation: () => Promise): Promise { + const snapshots = await Promise.all(paths.map(snapshotCodexHookFile)); + try { + return await operation(); + } catch (error) { + const rollbackResults = await Promise.allSettled(snapshots.map(restoreCodexHookFile)); + const rollbackErrors = rollbackResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + "Codex hook installation failed and its files could not be fully rolled back" + ); + } + + throw error; + } +} + +async function snapshotCodexHookFile(filePath: string): Promise { + try { + const metadata = await lstat(filePath); + if (metadata.isSymbolicLink()) { + return { path: filePath, kind: "symlink", target: await readlink(filePath) }; + } + if (!metadata.isFile()) { + throw new Error(`Codex hook path is not a regular file: ${filePath}`); + } + + return { path: filePath, kind: "file", content: await readFile(filePath), mode: metadata.mode & 0o777 }; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { path: filePath, kind: "missing" }; + } + + throw error; + } +} + +async function restoreCodexHookFile(snapshot: CodexHookFileSnapshot): Promise { + if (snapshot.kind === "missing") { + await rm(snapshot.path, { force: true }); + return; + } + + if (snapshot.kind === "symlink") { + await rm(snapshot.path, { force: true }); + await symlink(snapshot.target, snapshot.path); + return; + } + + await writeFileAtomically(snapshot.path, snapshot.content, snapshot.mode); } function escapeRegExp(input: string): string { diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 5a3e6855e..3db0efef8 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -1,5 +1,5 @@ /** Target tests. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; @@ -184,6 +184,47 @@ describe("codex skill target", () => { }); await expect(target.installPlugin?.("codex")).rejects.toThrow("trust failed"); + expect(existsSync(join(rootDirectory, "hooks.json"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-memory-config.json"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-workspace-bridge.mjs"))).toBe(false); + expect(existsSync(join(rootDirectory, "AGENTS.md"))).toBe(false); + expect(existsSync(join(rootDirectory, "skills", "memmy-memory"))).toBe(false); + }); + + it("restores existing hook files and links when Codex cannot persist hook trust", async () => { + const { rootDirectory, memmyConfigPath } = createFixture(); + const hookDirectory = join(rootDirectory, "hooks"); + mkdirSync(hookDirectory); + const hooksFilePath = join(rootDirectory, "hooks.json"); + const hookConfigPath = join(hookDirectory, "memmy-memory-config.json"); + const hookScriptPath = join(hookDirectory, "memmy-resume-hook.mjs"); + const bridgePath = join(hookDirectory, "memmy-workspace-bridge.mjs"); + const bridgeTargetPath = join(rootDirectory, "previous-workspace-bridge.mjs"); + const originalHooks = '{"hooks":{"Stop":[]}}\n'; + writeFileSync(hooksFilePath, originalHooks); + writeFileSync(hookConfigPath, "previous hook config\n"); + writeFileSync(hookScriptPath, "previous hook script\n"); + writeFileSync(bridgeTargetPath, "previous bridge\n"); + symlinkSync(bridgeTargetPath, bridgePath); + const target = createCodexSkillTarget({ + rootDirectory, + memmyConfigPath, + trustHooks: async () => { + throw new Error("trust failed"); + } + }); + + await expect(target.installPlugin?.("codex")).rejects.toThrow("trust failed"); + + expect(readFileSync(hooksFilePath, "utf8")).toBe(originalHooks); + expect(readFileSync(hookConfigPath, "utf8")).toBe("previous hook config\n"); + expect(readFileSync(hookScriptPath, "utf8")).toBe("previous hook script\n"); + expect(lstatSync(bridgePath).isSymbolicLink()).toBe(true); + expect(readlinkSync(bridgePath)).toBe(bridgeTargetPath); + expect(readFileSync(bridgePath, "utf8")).toBe("previous bridge\n"); + expect(existsSync(join(rootDirectory, "AGENTS.md"))).toBe(false); + expect(existsSync(join(rootDirectory, "skills", "memmy-memory"))).toBe(false); }); it("installs a UserPromptSubmit hook that blocks resume commands with top L1 candidates", async () => { diff --git a/App/backend/src/adapters/outbound/skill-writer/opencode/target.ts b/App/backend/src/adapters/outbound/skill-writer/opencode/target.ts index 11b4622ac..774af7d95 100644 --- a/App/backend/src/adapters/outbound/skill-writer/opencode/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/opencode/target.ts @@ -1,5 +1,6 @@ /** Target module. */ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { resolveOpencodeConfigDirectory } from "../../agent-paths.js"; @@ -18,6 +19,9 @@ const PLUGIN_DIRECTORY_NAME = "plugins"; const PLUGIN_FILE_NAME = "memmy-memory.js"; const PLUGIN_CONFIG_FILE_NAME = "memmy-memory-config.json"; const WORKSPACE_BRIDGE_FILE_NAME = "memmy-workspace-bridge.mjs"; +// Bump when the OpenCode bridge contract changes. Backend/Memory bundles have +// different bytes, so their valid installations must share a logical revision. +const OPENCODE_BRIDGE_REVISION = "v2-3"; const COMMAND_DIRECTORY_NAME = "commands"; const RESUME_COMMAND_FILE_NAME = "memmy-resume.md"; const START_MARKER = ""; @@ -70,25 +74,36 @@ export function createOpencodeSkillTarget(deps: CreateOpencodeSkillTargetDeps = return (await readTextFile(join(rootDirectory, TARGET_FILE_NAME))).includes(START_MARKER); }, + async isInstalledHookCurrent() { + const expected = await pluginAssets(); + const config = await readPluginConfig(join(rootDirectory, PLUGIN_DIRECTORY_NAME, PLUGIN_CONFIG_FILE_NAME)); + if (config.plugin_revision !== pluginRevision(expected)) return false; + const installed: Array<[string, string]> = []; + for (const [path, content] of expected) { + const actual = await readTextFile(join(rootDirectory, path)); + if (!actual || (basename(path) !== WORKSPACE_BRIDGE_FILE_NAME && actual !== content)) return false; + installed.push([path, actual]); + } + return config.plugin_integrity === assetRevision(installed); + }, + async installPlugin(_targetId) { await mkdir(rootDirectory, { recursive: true }); const pluginDirectory = join(rootDirectory, PLUGIN_DIRECTORY_NAME); const commandDirectory = join(rootDirectory, COMMAND_DIRECTORY_NAME); await mkdir(pluginDirectory, { recursive: true }); await mkdir(commandDirectory, { recursive: true }); - await writeFileAtomically( - join(pluginDirectory, PLUGIN_CONFIG_FILE_NAME), - `${JSON.stringify({ - memmy_config_path: memmyConfigPath, - ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) - }, null, 2)}\n` - ); - await writeFileAtomically(join(pluginDirectory, PLUGIN_FILE_NAME), renderMemmyOpencodePlugin()); - await writeFileAtomically( - join(pluginDirectory, WORKSPACE_BRIDGE_FILE_NAME), - await loadMemmyWorkspaceBridgeRuntimeAsset() - ); - await writeFileAtomically(join(commandDirectory, RESUME_COMMAND_FILE_NAME), renderMemmyOpencodeResumeCommand()); + const configPath = join(pluginDirectory, PLUGIN_CONFIG_FILE_NAME); + // Parse before writing anything. A malformed existing config must not be + // silently replaced with empty credentials or defaults. + const existingConfig = await readPluginConfig(configPath); + const config = { + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)), + ...existingConfig + }; + const assets = await pluginAssets(); + for (const [path, content] of assets) await writeFileAtomically(join(rootDirectory, path), content); const manifest = renderMemmyPluginSkillManifest(_targetId); const filePath = join(rootDirectory, TARGET_FILE_NAME); @@ -100,6 +115,10 @@ export function createOpencodeSkillTarget(deps: CreateOpencodeSkillTargetDeps = ) ); await replaceMemmySkillDirectory(rootDirectory, manifest); + // Publish the revision only after every managed file was installed. + await writeFileAtomically(configPath, `${JSON.stringify({ ...config, + plugin_revision: pluginRevision(assets), plugin_integrity: assetRevision(assets) + }, null, 2)}\n`); }, async uninstallPlugin(_targetId) { @@ -122,6 +141,30 @@ export function createOpencodeSkillTarget(deps: CreateOpencodeSkillTargetDeps = }; } +async function pluginAssets(): Promise> { + return [ + [join(PLUGIN_DIRECTORY_NAME, PLUGIN_FILE_NAME), renderMemmyOpencodePlugin()], + [join(PLUGIN_DIRECTORY_NAME, WORKSPACE_BRIDGE_FILE_NAME), await loadMemmyWorkspaceBridgeRuntimeAsset()], + [join(COMMAND_DIRECTORY_NAME, RESUME_COMMAND_FILE_NAME), renderMemmyOpencodeResumeCommand()] + ]; +} + +function assetRevision(assets: Array<[string, string]>): string { + return createHash("sha256").update(JSON.stringify(assets.map(([, content]) => content))).digest("hex"); +} + +function pluginRevision(assets: Array<[string, string]>): string { + return OPENCODE_BRIDGE_REVISION + ":" + assetRevision(assets.filter(([path]) => basename(path) !== WORKSPACE_BRIDGE_FILE_NAME)); +} + +async function readPluginConfig(path: string): Promise> { + const content = await readTextFile(path); + if (!content.trim()) return {}; + const value: unknown = JSON.parse(content); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Invalid Memmy OpenCode plugin config"); + return value as Record; +} + async function readTextFile(filePath: string): Promise { try { return await readFile(filePath, "utf8"); diff --git a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts index c22792ed1..8029122d8 100644 --- a/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/opencode/tests/target.test.ts @@ -98,8 +98,8 @@ describe("opencode skill target", () => { }); const bridgePath = join(rootDirectory, "plugins", "memmy-workspace-bridge.mjs"); expect(existsSync(bridgePath)).toBe(true); - expect(pluginSource).toContain('import { tool } from "@opencode-ai/plugin";'); - expect(pluginSource).toContain("export const MemmyMemoryPlugin"); + expect(pluginSource).not.toContain('from "@opencode-ai/plugin"'); + expect(pluginSource).toContain("export default"); expect(pluginSource).toContain('"chat.message"'); expect(pluginSource).toContain('"experimental.text.complete"'); expect(pluginSource).toContain('event: async ({ event }) =>'); @@ -368,24 +368,17 @@ interface OpencodePluginHooks { async function loadPluginHooks(rootDirectory: string): Promise { const pluginDirectory = join(rootDirectory, "plugins"); - const pluginSource = readFileSync(join(pluginDirectory, "memmy-memory.js"), "utf8").replace( - 'import { tool } from "@opencode-ai/plugin";', - [ - "const schemaValue = () => ({ optional() { return this; } });", - "const tool = (definition) => definition;", - "tool.schema = { string: schemaValue, enum: schemaValue, array: schemaValue };" - ].join("\n") - ); + const pluginSource = readFileSync(join(pluginDirectory, "memmy-memory.js"), "utf8"); const runtimePath = join(pluginDirectory, `memmy-memory-runtime-${Date.now()}-${Math.random().toString(36).slice(2)}.mjs`); writeFileSync(runtimePath, pluginSource, "utf8"); const module = await import(pathToFileURL(runtimePath).href) as { - MemmyMemoryPlugin(input: { + default: { server(input: { client: { app: { log(input: unknown): Promise } }; directory: string; worktree: string; - }): Promise; + }): Promise }; }; - return module.MemmyMemoryPlugin({ + return module.default.server({ client: { app: { async log() { return undefined; } } }, directory: rootDirectory, worktree: rootDirectory diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.ts b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.ts index 9ace951b2..7503dfc78 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/memmy-opencode-plugin.ts @@ -5,14 +5,17 @@ export function renderMemmyOpencodePlugin(): string { return String.raw`import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; -import { tool } from "@opencode-ai/plugin"; import { + opencodeTool as tool, + opencodeToolJsonSchema, closeRuntimeSession, completeSourceTurn, loadRuntimeL3, notifyRuntimeBoundary, openRuntimeSession, readOpencodeHookSourceTurn, + readOpencodeCompactionBoundary, + resolveOpencodeAgent, startRuntimeTurn } from "./memmy-workspace-bridge.mjs"; @@ -25,14 +28,121 @@ const DISPLAY_LIMIT = 5; const RESUME_STATE_TTL_MS = 10 * 60 * 1000; const RESUME_CONTEXT_MAX_CHARS = 24000; const TOOL_OUTPUT_MAX_CHARS = 12000; +const PLUGIN_REVISION = "opencode-v2-3"; + +// A single default export prevents V1 from registering the same plugin twice. +// The schema helper is bundled in our bridge; neither host needs the other SDK. +export default { + id: "memmy-memory", + async server(input) { + const hooks = await createMemmyHooks(input); + console.error("Memmy plugin loaded: revision=" + PLUGIN_REVISION + ", entry=v1"); + return hooks; + }, + async setup(ctx) { + const resolveAgent = async (sessionID) => { + const session = await ctx.session.get({ sessionID }); + return resolveOpencodeAgent(session.agent, undefined, session.agent ? undefined : await ctx.agent.list()); + }; + const hooks = await createMemmyHooks({ directory: ctx.location.directory, resolveAgent, modelContextL3: true }); + const controller = new AbortController(); + const registrations = []; + let events; + let cleanupPromise; + const cleanup = () => cleanupPromise || (cleanupPromise = (async () => { + controller.abort(); + await Promise.allSettled(registrations.map((registration) => registration.dispose())); + await events; + await hooks.dispose(); + })()); + try { + registrations.push(await ctx.tool.transform((editor) => { + for (const [name, definition] of Object.entries(hooks.tool)) { + editor.add({ + name, + description: definition.description, + input: opencodeToolJsonSchema(definition.args), + async execute(input, context) { + return { content: await definition.execute(input, context) }; + } + }); + } + })); + registrations.push(await ctx.session.hook("prompt", async (event) => { + if (controller.signal.aborted) return; + try { + const parts = [{ type: "text", text: event.prompt.text }]; + // Agent selection lives on the V2 session, not on SessionPrompt. + const session = await ctx.session.get({ sessionID: event.sessionID }); + if (session.parentID) return; + await hooks["chat.message"]( + { sessionID: event.sessionID, messageID: event.messageID, agent: await resolveAgent(event.sessionID) }, + { message: { id: event.messageID }, parts } + ); + event.prompt.text = extractUserText(parts); + } catch (error) { + console.warn("Memmy prompt recall skipped: " + formatError(error)); + } + })); + registrations.push(await ctx.session.hook("context", async (event) => { + if (controller.signal.aborted) return; + try { + const session = await ctx.session.get({ sessionID: event.sessionID }); + if (session.parentID) return; + const context = await hooks.l3Context(event.sessionID, event.agent); + // Context hooks receive a fresh model request, not a persisted user + // message. Inject one current L3 block even after automatic compaction. + if (context && !event.system.some(part => part.text === context)) event.system.push({ type: "text", text: context }); + } catch (error) { console.warn("Memmy L3 context skipped: " + formatError(error)); } + })); + registrations.push(await ctx.command.transform((editor) => editor.add({ + name: "memmy-resume", + description: "Search and resume a previous Memmy episode", + async execute({ sessionID, prompt, delivery }) { + await ctx.session.prompt({ ...prompt, sessionID, delivery, + text: "MEMMY_RESUME_COMMAND_ARGUMENTS: " + prompt.text + "\nMEMMY_RESUME_COMMAND_END" }); + } + }))); + events = (async () => { + try { + for await (const event of ctx.event.subscribe({ signal: controller.signal })) { + if (controller.signal.aborted) break; + const properties = event.data || {}; + if (properties.parentID) continue; + // V2 terminal events contain a session ID, not a user message ID. + // The pending native messages and durable DB boundaries select the turns. + const terminal = ["session.execution.succeeded", "session.execution.failed", "session.execution.interrupted"].includes(event.type); + // Shutdown preserves V2's execution claim: it is not a completed turn. + if (event.type === "session.execution.interrupted" && properties.reason === "shutdown") continue; + if (!terminal && !["session.compaction.ended", "session.deleted"].includes(event.type)) continue; + try { + await hooks.event({ event: { type: terminal ? "session.idle" : event.type === "session.compaction.ended" ? "session.compacted" : event.type, properties } }); + } catch (error) { + console.warn("Memmy event failed: " + formatError(error)); + } + } + } catch (error) { + if (!controller.signal.aborted) console.warn("Memmy event subscription failed: " + formatError(error)); + } + })(); + console.error("Memmy plugin loaded: revision=" + PLUGIN_REVISION + ", entry=v2"); + return cleanup; + } catch (error) { + await cleanup(); + throw error; + } + } +}; -export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { +const createMemmyHooks = async ({ client, directory, worktree, resolveAgent, modelContextL3 = false }) => { const sessionCache = new Map(); - const l3InjectOnce = new Map(); + const l3States = new Map(); + const sessionAgents = new Map(); const pendingTurns = new Map(); const pendingResumeSelections = new Map(); const latestRequests = new Map(); const captureJobs = new Set(); + const captureStop = new AbortController(); function log(level, message, extra = {}) { try { @@ -54,14 +164,26 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { } } + async function closeSession(sessionID) { + for (const [key, session] of sessionCache) { + if (!key.startsWith(sessionID + ":")) continue; + sessionCache.delete(key); + await Promise.resolve(session).then(closeRuntimeSession).catch(() => undefined); + } + l3States.delete(sessionID); + sessionAgents.delete(sessionID); + pendingResumeSelections.delete(sessionID); + latestRequests.delete(sessionID); + } + async function ensureSession(memmy, externalSessionId, agent) { - const profileId = normalizeText(agent) || "main"; + const profileId = resolveOpencodeAgent(agent); const cacheKey = externalSessionId + ":" + profileId; const cached = sessionCache.get(cacheKey); if (cached) { return cached; } - const opened = await openRuntimeSession({ + const opening = openRuntimeSession({ configUrl: CONFIG_URL, source: SOURCE, adapterId: "memmy-opencode-plugin", @@ -69,10 +191,39 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { sessionKey: "opencode-memory-" + externalSessionId, workspaceRoot: worktree || directory || null, transition: "allow_legacy_rollover" + }).then((opened) => { + if (!opened) throw new Error("Memmy session unavailable"); + return opened; + }).catch((error) => { sessionCache.delete(cacheKey); throw error; }); + sessionCache.set(cacheKey, opening); + return opening; + } + + async function l3Context(sessionID, agent) { + const profile = resolveOpencodeAgent(agent || sessionAgents.get(sessionID)); + sessionAgents.set(sessionID, profile); + let state = l3States.get(sessionID); + if (!state) { state = { initialized: false, context: "", boundary: "", job: Promise.resolve() }; l3States.set(sessionID, state); } + // Serialize initialization and compaction refresh per native session. + const job = state.job.catch(() => undefined).then(async () => { + const runtime = await ensureSession(null, sessionID, profile); + const boundary = await readOpencodeCompactionBoundary(sessionID).catch(() => null); + const changed = boundary && boundary !== state.boundary; + if (!state.initialized || changed) { + if (changed && state.initialized && state.notifiedBoundary !== boundary) { + await notifyRuntimeBoundary(runtime, "token_compaction"); + state.notifiedBoundary = boundary; + } + const loaded = await loadRuntimeL3(runtime); + state.context = loaded.additionalContext || ""; + state.initialized = true; + state.pendingInjection = true; + if (boundary !== null) state.boundary = boundary; + } + return state.context; }); - if (!opened) throw new Error("Memmy session unavailable"); - sessionCache.set(cacheKey, opened); - return opened; + state.job = job; + return job; } async function beginTurn(input, output, query, selectedContext = "") { @@ -85,7 +236,21 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { let recalledContext = ""; try { const memmy = await createMemmyClient(); + input = { ...input, agent: resolveOpencodeAgent(input.agent, output && output.message && output.message.agent) }; + sessionAgents.set(input.sessionID, input.agent); const runtimeSession = await ensureSession(memmy, input.sessionID, input.agent); + if (!modelContextL3) { + try { + const context = await l3Context(input.sessionID, input.agent); + const state = l3States.get(input.sessionID); + if (state.pendingInjection) { + selectedContext = [selectedContext, context].filter(Boolean).join("\n\n"); + state.pendingInjection = false; + } + } catch (error) { + log("warn", "Memmy L3 initialization deferred", { error: formatError(error), sessionID: input.sessionID }); + } + } const sessionId = runtimeSession.sessionId; const requestedTurnId = normalizeText(input.messageID) || normalizeText(output && output.message && output.message.id); const turn = await startRuntimeTurn( @@ -98,7 +263,11 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { input.sessionID, cleanQuery ].join("\u0000")); - pendingTurns.set(input.sessionID, { + // A new prompt must not replace a previous native user message. Retry older + // persisted turns even when the host missed an earlier completion event. + queueTurnCompletion(input.sessionID); + const key = JSON.stringify([input.sessionID, requestedTurnId || turnId]); + if (!pendingTurns.has(key)) pendingTurns.set(key, { externalSessionId: input.sessionID, sessionId, turnId, @@ -106,7 +275,7 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { sourceMemoryIds: Array.isArray(turn && turn.sourceMemoryIds) ? turn.sourceMemoryIds : undefined, query: cleanQuery, userMessageId: normalizeText(output && output.message && output.message.id) || requestedTurnId, - profileId: normalizeText(input.agent) || "main", + profileId: input.agent, answerParts: new Map(), toolCalls: [], toolResults: [], @@ -130,48 +299,82 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { } } + function latestPendingTurn(sessionID) { + let latest; + for (const pending of pendingTurns.values()) { + if (pending.externalSessionId === sessionID) latest = pending; + } + return latest; + } + function queueTurnCompletion(sessionID) { - const pending = pendingTurns.get(sessionID); - if (!pending) { - return; + for (const [key, pending] of pendingTurns) { + if (pending.externalSessionId !== sessionID || pending.capturing) continue; + pending.capturing = true; + const job = completeTurn(pending) + .then((done) => { if (done) pendingTurns.delete(key); }) + .catch((error) => { + log("warn", "Memmy turn capture failed", { error: formatError(error), sessionID }); + }) + .finally(() => { + pending.capturing = false; + captureJobs.delete(job); + }); + captureJobs.add(job); } - pendingTurns.delete(sessionID); - const job = completeTurn(pending) - .catch((error) => { - log("warn", "Memmy turn capture failed", { error: formatError(error), sessionID }); - }) - .finally(() => { - captureJobs.delete(job); - }); - captureJobs.add(job); } - // Capture reads the turn back from opencode.db so the plugin and the offline scan submit - // the same identity, text and tools. A turn that is not on disk yet is left for the scan. + function waitForCaptureRead(ms) { + if (captureStop.signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer); + captureStop.signal.removeEventListener("abort", done); + resolve(); + }; + const timer = setTimeout(done, ms); + captureStop.signal.addEventListener("abort", done, { once: true }); + }); + } + + // Event delivery can precede the database projection. Retry briefly without + // blocking prompts or the event subscription; retain unresolved records for the + // next terminal event / prompt / cleanup and leave durable recovery to scanning. async function completeTurn(pending) { const conversationId = normalizeText(pending.externalSessionId); const turnId = normalizeText(pending.userMessageId); if (!conversationId || !turnId) { log("warn", "Memmy turn capture skipped", { reason: "identity_unresolved", sessionID: pending.externalSessionId }); - return; + return true; + } + let parsed; + for (const wait of [0, 50, 100, 200, 400, 800, 1600]) { + if (wait && captureStop.signal.aborted) break; + if (wait) await waitForCaptureRead(wait); + parsed = await readOpencodeHookSourceTurn({ conversationId, turnId }); + if (parsed.turn) break; + if (!["source_store_unavailable", "turn_incomplete", "turn_content_incomplete", "identity_unresolved"].includes(parsed.reason)) break; } - const parsed = await readOpencodeHookSourceTurn({ conversationId, turnId }); if (!parsed.turn) { - log("warn", "Memmy turn capture skipped", { reason: parsed.reason || "identity_unresolved", sessionID: conversationId }); - return; + log("warn", "Memmy turn capture deferred", { reason: parsed.reason || "identity_unresolved", sessionID: conversationId, turnId }); + return false; } + // Preserve the Memory scope check. If execution selected another agent, + // resolve its scoped session instead of reusing the old agent's session ID. + const captureSession = parsed.turn.profileId === pending.profileId + ? pending.sessionId : (await ensureSession(null, conversationId, parsed.turn.profileId)).sessionId; const result = await completeSourceTurn({ configUrl: CONFIG_URL, turn: parsed.turn, - sessionId: normalizeText(pending.sessionId) || undefined, + sessionId: normalizeText(captureSession) || undefined, sourceMemoryIds: pending.sourceMemoryIds, - profileId: normalizeText(parsed.turn.profileId) || normalizeText(pending.profileId) || "main", + profileId: parsed.turn.profileId, adapterId: "memmy-opencode-plugin" }); const status = normalizeText(result && result.status); - if (status !== "stored" && status !== "existing" && status !== "rejected") { - log("warn", "Memmy turn capture failed", { reason: normalizeText(result && result.reason) || status || "unexpected_response", sessionID: conversationId }); - } + if (status === "stored" || status === "existing" || status === "rejected") return true; + log("warn", "Memmy turn capture failed", { reason: normalizeText(result && result.reason) || status || "unexpected_response", sessionID: conversationId, turnId }); + return false; } async function handleResumeSearch(sessionID, query, parts) { @@ -228,6 +431,7 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { } return { + l3Context, tool: { memmy_memory_search: tool({ description: "Search Memmy local memory for relevant facts, preferences, decisions, procedures, and prior tasks.", @@ -282,7 +486,7 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { throw new Error("Missing required parameter: content"); } const memmy = await createMemmyClient(); - const sessionId = (await ensureSession(memmy, context.sessionID, context.agent)).sessionId; + const sessionId = (await ensureSession(memmy, context.sessionID, context.agent || sessionAgents.get(context.sessionID) || (resolveAgent && await resolveAgent(context.sessionID)))).sessionId; const result = await memmy.post("/api/v1/memory/add", { content, title: normalizeText(args.title) || undefined, @@ -310,16 +514,14 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { await handleResumeSearch(input.sessionID, normalizeText(commandArguments), output.parts); return; } - const l3Context = l3InjectOnce.get(input.sessionID) || ""; - l3InjectOnce.delete(input.sessionID); - await beginTurn(input, output, rawPrompt, l3Context); + await beginTurn(input, output, rawPrompt); }, "tool.execute.before": async (input, output) => { if (isMemmyTool(input.tool)) { return; } - const pending = pendingTurns.get(input.sessionID); + const pending = latestPendingTurn(input.sessionID); if (!pending) { return; } @@ -338,7 +540,7 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { if (isMemmyTool(input.tool)) { return; } - const pending = pendingTurns.get(input.sessionID); + const pending = latestPendingTurn(input.sessionID); if (!pending) { return; } @@ -351,7 +553,7 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { }, "experimental.text.complete": async (input, output) => { - const pending = pendingTurns.get(input.sessionID); + const pending = latestPendingTurn(input.sessionID); if (!pending || normalizeText(input.messageID) === normalizeText(pending.userMessageId)) { return; } @@ -360,36 +562,21 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { event: async ({ event }) => { const properties = event && event.properties && typeof event.properties === "object" ? event.properties : {}; - if (event && event.type === "session.created") { - const info = properties.info && typeof properties.info === "object" ? properties.info : properties; - const sessionID = normalizeText(info.id || info.sessionID); - if (sessionID) { - const runtimeSession = await ensureSession(null, sessionID, "main"); - const loaded = await loadRuntimeL3(runtimeSession); - if (loaded.additionalContext) l3InjectOnce.set(sessionID, loaded.additionalContext); - } - return; - } if (event && event.type === "session.compacted") { const sessionID = normalizeText(properties.sessionID || properties.id); - const runtimeSession = sessionCache.get(sessionID) || await ensureSession(null, sessionID, "main"); - await notifyRuntimeBoundary(runtimeSession, "token_compaction"); - const loaded = await loadRuntimeL3(runtimeSession); - if (loaded.additionalContext) l3InjectOnce.set(sessionID, loaded.additionalContext); + const agent = sessionAgents.get(sessionID) || (resolveAgent && await resolveAgent(sessionID)); + if (agent) await l3Context(sessionID, agent); return; } if (event && event.type === "session.deleted") { const sessionID = normalizeText(properties.sessionID || properties.id); queueTurnCompletion(sessionID); - const runtimeSession = sessionCache.get(sessionID); - if (runtimeSession) await closeRuntimeSession(runtimeSession).catch(() => undefined); - sessionCache.delete(sessionID); - l3InjectOnce.delete(sessionID); + await closeSession(sessionID); return; } if (event && event.type === "message.part.updated") { const part = properties.part && typeof properties.part === "object" ? properties.part : {}; - const pending = pendingTurns.get(normalizeText(part.sessionID)); + const pending = latestPendingTurn(normalizeText(part.sessionID)); if ( pending && part.type === "text" && @@ -402,26 +589,35 @@ export const MemmyMemoryPlugin = async ({ client, directory, worktree }) => { // An interrupted turn is not dropped here: the database decides whether it produced // usable text or a finished tool, and the scan can still fill it in later. if (event && event.type === "session.error") { - const pending = pendingTurns.get(normalizeText(properties.sessionID)); + const pending = latestPendingTurn(normalizeText(properties.sessionID)); if (pending) { pending.status = "failed"; pending.error = errorText(properties.error); } return; } - if (event && event.type === "session.idle") { + if (event && (event.type === "session.idle" || (event.type === "session.status" && properties.status?.type === "idle"))) { queueTurnCompletion(normalizeText(properties.sessionID)); } }, dispose: async () => { - for (const sessionID of [...pendingTurns.keys()]) { + // Host shutdown has a short cleanup budget. Wake retries for one last read; + // never wait through the whole projection backoff while the host is exiting. + captureStop.abort(); + for (const sessionID of new Set([...pendingTurns.values()].map((pending) => pending.externalSessionId))) { queueTurnCompletion(sessionID); } await Promise.allSettled([...captureJobs]); - await Promise.allSettled([...sessionCache.values()].map((session) => closeRuntimeSession(session))); + await Promise.allSettled([...l3States.values()].map(state => state.job)); + await Promise.allSettled([...sessionCache.values()].map((session) => Promise.resolve(session).then(closeRuntimeSession))); + pendingTurns.clear(); sessionCache.clear(); - l3InjectOnce.clear(); + l3States.clear(); + sessionAgents.clear(); + pendingResumeSelections.clear(); + latestRequests.clear(); + console.error("Memmy plugin disposed: revision=" + PLUGIN_REVISION); } }; }; diff --git a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts index 32b881c67..db3305bc8 100644 --- a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts +++ b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts @@ -11,7 +11,7 @@ import { readOpencodeSourceTurn, type CursorVscdbSource, type OpenclawTranscriptSource, - type OpencodeSource, + createOpencodeDatabaseSource, type SourceTurn } from "@memmy/agent-source-core"; export { readClaudeCodeSourceTurn, readCodexSourceTurn } from "@memmy/agent-source-core"; @@ -28,6 +28,14 @@ import { homedir } from "node:os"; import { isAbsolute, parse, resolve } from "node:path"; import { pathToFileURL } from "node:url"; import YAML from "yaml"; +import { z } from "zod"; + +/** OpenCode V1 tools are plain definitions containing real Zod schemas. Bundle them + * locally so installing Memmy never depends on the host's SDK package resolution. */ +export const opencodeTool = Object.assign((definition: T): T => definition, { schema: z }); +export function opencodeToolJsonSchema(args: Record): Record { + return z.toJSONSchema(z.object(args)); +} import { normalizeWorkspaceUri, renderL3WorldModelContext, @@ -282,7 +290,7 @@ export async function completeSourceTurn(input: { }))); } -type NodeSqliteDatabase = import("node:sqlite").DatabaseSync; +type NodeSqliteDatabase = Pick; let nodeSqlite: Promise | undefined; @@ -307,10 +315,49 @@ function silenceSqliteExperimentalWarning(): void { } async function openReadOnlySqlite(path: string): Promise { + // Official V1 embeds Bun, which does not implement node:sqlite. Choose the + // host driver before opening; never retry an open error with a writable DB. + if ("Bun" in globalThis) { + const moduleName = "bun:sqlite"; + const { Database } = await import(moduleName); + return new Database(path, { readonly: true, create: false, strict: true }); + } const { DatabaseSync } = await loadNodeSqlite(); return new DatabaseSync(path, { readOnly: true }); } +/** Resolve only host-provided identities. V1 exposes the selected agent on + * output.message; V2 agent.list puts its configured effective default first. */ +export function resolveOpencodeAgent(selected: unknown, messageAgent?: unknown, agents?: unknown): string { + const explicit = text(selected) || text(messageAgent); + if (explicit) return explicit; + const list = Array.isArray(agents) ? agents : objectValue(agents).data; + if (Array.isArray(list)) { + const candidate = list.map(objectValue).find(agent => agent.mode !== "subagent" && !agent.hidden); + const id = text(candidate?.id) || text(candidate?.name); + if (id) return id; + } + throw new Error("OpenCode effective agent unavailable"); +} + +/** Durable success identity also covers a compaction event delivered before its + * projection, missed during restart, or repeated by the host. Failed rows are ignored. */ +export async function readOpencodeCompactionBoundary(conversationId: string): Promise { + const db = await openReadOnlySqlite(resolveOpencodeDatabasePath()); + try { + const v2 = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_v2'").get(); + const rows = db.prepare(v2 + ? "SELECT id, data FROM session_message WHERE session_id = ? AND type = 'compaction' ORDER BY seq DESC" + : "SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created DESC, id DESC").all(conversationId); + for (const value of rows) { + const row = objectValue(value); + const data = objectValue(JSON.parse(String(row.data))); + if (v2 ? data.status === "completed" : data.summary === true && objectValue(data.time).completed && !data.error) return text(row.id); + } + return ""; + } finally { db.close(); } +} + /** * Reads the turn Cursor just finished out of its own global `state.vscdb`, using the same * parser the offline scan uses. The hook only receives `generation_id`, which is the user @@ -412,37 +459,8 @@ export async function readOpencodeHookSourceTurn(input: { return { turn: null, reason: "source_store_unavailable" }; } try { - const columns = new Set((db.prepare("PRAGMA table_info(session)").all() as Array<{ name: string }>).map((row) => row.name)); - const sessions = db.prepare(`SELECT id, parent_id AS parentId, directory${columns.has("agent") ? ", agent" : ""}${columns.has("revert") ? ", revert" : ""} FROM session WHERE id = ?`); - const messages = db.prepare("SELECT id, data FROM message WHERE session_id = ? ORDER BY time_created ASC, id ASC"); - const parts = db.prepare("SELECT id, data FROM part WHERE message_id = ? ORDER BY time_created ASC, id ASC"); - const parse = (value: unknown): unknown => { - if (typeof value !== "string") return undefined; - try { - return JSON.parse(value); - } catch { - return undefined; - } - }; - const source: OpencodeSource = { - sessions: () => (sessions.all(input.conversationId) as unknown as Array>).map((row) => { - const revert = typeof row.revert === "string" ? parse(row.revert) : row.revert; - const messageId = revert && typeof revert === "object" && !Array.isArray(revert) - ? (revert as { messageID?: unknown }).messageID - : undefined; - return { - id: String(row.id), - parentId: row.parentId == null ? null : String(row.parentId), - directory: row.directory == null ? null : String(row.directory), - agent: typeof row.agent === "string" ? row.agent : null, - revertMessageId: typeof messageId === "string" && messageId ? messageId : null - }; - }), - messages: (sessionId) => (messages.all(sessionId) as unknown as Array<{ id: string; data: string }>) - .map((row) => ({ id: row.id, data: parse(row.data) })), - parts: (messageId) => (parts.all(messageId) as unknown as Array<{ id: string; data: string }>) - .map((row) => ({ id: row.id, data: parse(row.data) })) - }; + const source = createOpencodeDatabaseSource(db, input.conversationId); + if (!source) return { turn: null, reason: "source_store_unavailable" }; return await readOpencodeSourceTurn(source, input); } finally { db.close(); diff --git a/App/backend/src/infrastructure/agent-source-store/repository.ts b/App/backend/src/infrastructure/agent-source-store/repository.ts index 12a4fcf58..be0dbf7c0 100644 --- a/App/backend/src/infrastructure/agent-source-store/repository.ts +++ b/App/backend/src/infrastructure/agent-source-store/repository.ts @@ -60,6 +60,8 @@ export interface AgentSourceRepository { upsertSource(input: UpsertAgentSourceInput): void; removeSource(sourceId: string): void; setStatus(sourceId: string, status: AgentSourceStatus): void; + setConnectionState(sourceId: string, status: AgentSourceStatus, autoInjectSuppressed: boolean): void; + isAutoInjectSuppressed(sourceId: string): boolean; setLastScannedAt(sourceId: string, scannedAt: string): void; getScanWatermark(sourceId: string): AgentSourceScanWatermark | null; upsertScanWatermark(input: UpsertAgentSourceScanWatermarkInput): void; @@ -167,6 +169,25 @@ export function createAgentSourceRepository( ); }, + setConnectionState(sourceId, status, autoInjectSuppressed) { + db.prepare( + "UPDATE account_agent_sources SET status = ?, auto_inject_suppressed = ?, updated_at = ? WHERE uuid = ? AND source_id = ?" + ).run( + status, + autoInjectSuppressed ? 1 : 0, + new Date().toISOString(), + INSTALLATION_SCAN_SCOPE_UUID, + sourceId + ); + }, + + isAutoInjectSuppressed(sourceId) { + const row = db.prepare( + "SELECT auto_inject_suppressed FROM account_agent_sources WHERE uuid = ? AND source_id = ?" + ).get(INSTALLATION_SCAN_SCOPE_UUID, sourceId) as { auto_inject_suppressed: 0 | 1 } | undefined; + return row?.auto_inject_suppressed === 1; + }, + setLastScannedAt(sourceId, scannedAt) { db.prepare("UPDATE account_agent_sources SET last_scanned_at = ?, updated_at = ? WHERE uuid = ? AND source_id = ?").run( scannedAt, diff --git a/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts b/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts index 159ff5e98..7469b68c8 100644 --- a/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts +++ b/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts @@ -90,6 +90,24 @@ describe("agent source repository", () => { expect(repository.listSources()).toEqual([]); }); + it("persists auto-inject suppression across status-only updates", () => { + const repository = createRepository(); + repository.upsertSource({ + sourceId: "cursor", + displayName: "Cursor", + dataPath: "/Users/test/Cursor", + builtin: true + }); + + repository.setConnectionState("cursor", "not_connected", true); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); + repository.setStatus("cursor", "plugin_installed"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); + + repository.setConnectionState("cursor", "plugin_installed", false); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(false); + }); + it("tracks ingestion dedupe keys without double-counting repeated marks", () => { const repository = createRepository(); repository.upsertSource({ @@ -184,6 +202,7 @@ function createRepository(): AgentSourceRepository { data_path TEXT NOT NULL, builtin INTEGER NOT NULL CHECK(builtin IN (0,1)), status TEXT NOT NULL DEFAULT 'not_connected', + auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 CHECK(auto_inject_suppressed IN (0,1)), last_scanned_at TEXT, sync_recipe_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql b/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql new file mode 100644 index 000000000..55412239c --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql @@ -0,0 +1,3 @@ +ALTER TABLE account_agent_sources + ADD COLUMN auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 + CHECK(auto_inject_suppressed IN (0,1)); diff --git a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts index 9ffebda22..c46ac36c9 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts @@ -131,8 +131,8 @@ describe("app state store migrations", () => { expect(settings.menuBarIconEnabled).toBe(true); expect(settings.stopMemoryServiceOnExit).toBe(false); expect(agentSources).toEqual([]); - expect(firstMigrationCount).toBe(34); - expect(secondMigrationCount).toBe(34); + expect(firstMigrationCount).toBe(35); + expect(secondMigrationCount).toBe(35); }); it("preserves the authenticated account when upgrading the legacy 0007 database", () => { diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 6c0937c1c..d85c7e315 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -333,6 +333,20 @@ export async function readRuntimeMemmyConfigState( return deriveRuntimeMemmyConfigState(parsed, configPath, mode); } +/** Reads the persisted runtime user mode without deriving a model selection. */ +export async function readRuntimeMemmyConfigUserMode( + configPath = resolveDefaultMemmyConfigPath() +): Promise { + const content = await readMemmyConfigContent(configPath); + if (!content?.trim()) return undefined; + const parsed = YAML.parse(content) as unknown; + if (!isRecord(parsed)) return undefined; + const userMode = existingString(asRecord(parsed.app)?.userMode); + return userMode === "account" || userMode === "byok" || userMode === "unset" + ? userMode + : undefined; +} + /** Reads agents.defaults.timezone without inventing a configured value. */ export async function readConfiguredAgentTimeZone( configPath = resolveDefaultMemmyConfigPath() @@ -570,6 +584,11 @@ export async function writeAccountModelProjectionToMemmyConfig( appConfig, LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE ); + // Projection repair also runs during migration; preserve an explicit BYOK choice. + const userMode = existingString(appConfig.userMode); + if (!userMode || userMode === "unset") { + appConfig.userMode = "account"; + } if (normalizedCloudUuid) appConfig.cloudUuid = normalizedCloudUuid; if (normalizedUserId) appConfig.userId = normalizedUserId; delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index 5d6a8fea0..0e5f1657c 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -27,9 +27,9 @@ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; type ConfigRecord = Record; const CAPABILITY_PROTOCOLS: Readonly>> = { - agent: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account"]), - memory_summary: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account"]), - memory_evolution: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account"]), + agent: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_summary: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_evolution: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), embedding: new Set(["openai-embeddings", "memmy-account"]), asr: new Set(["dashscope-input-audio-chat", "memmy-account"]), image_generation: new Set(["openai-images", "dashscope-multimodal-generation", "memmy-account"]) @@ -64,7 +64,23 @@ export class InvalidModelConfigError extends Error { export async function readModelConfigCatalog(configPath: string): Promise { const target = resolve(configPath); const { content, config } = await readConfig(target); - return buildModelConfigView(config, revisionFor(config), await updatedAt(target, content)); + if (!relocateCustomPresets(config)) { + return buildModelConfigView(config, revisionFor(config), await updatedAt(target, content)); + } + try { + const result = await mutateRuntimeConfig(target, (current) => { + relocateCustomPresets(current); + return current; + }); + return buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()); + } catch (error) { + if (isErrorCode(error, "migration_lock_timeout")) { + throw Object.assign(new Error("Model configuration is busy; try again"), { + code: "config_write_busy" as const + }); + } + throw error; + } } export async function writeModelConfigCatalog( @@ -72,15 +88,19 @@ export async function writeModelConfigCatalog( input: ModelConfigInput ): Promise { const target = resolve(configPath); + const endpointAliases: NonNullable = {}; try { const result = await mutateRuntimeConfig(target, (current) => { if (input.configRevision !== revisionFor(current)) throw new ModelConfigChangedError(); - const next = mergeModelConfig(current, input); + const next = mergeModelConfig(current, input, endpointAliases); for (const key of Object.keys(current)) delete current[key]; Object.assign(current, next); return next; }); - return buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()); + return { + ...buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()), + ...(Object.keys(endpointAliases).length ? { endpointAliases } : {}) + }; } catch (error) { if (isErrorCode(error, "migration_lock_timeout")) { throw Object.assign(new Error("Model configuration is busy; try again"), { @@ -97,7 +117,9 @@ export function generateDesktopPresetName(provider: string, model: string): stri return `desktop-${readableIdPart(provider, 24) || "provider"}-${readableIdPart(model, 48) || "model"}-${hash}`; } -function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): ConfigRecord { +function mergeModelConfig( + config: ConfigRecord, input: ModelConfigInput, endpointAliases: NonNullable +): ConfigRecord { const existingProviders = record(config.providers); const existingPresets = record(config.modelPresets); const existingAssignments = normalizeStoredAssignments(config.modelAssignments); @@ -134,10 +156,10 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config if (providerInput.provider === ACCOUNT_PROVIDER) continue; const previousProvider = record(existingProviders[providerInput.provider]); const provider = mergeProvider(previousProvider, providerInput); + const modelInputs = canonicalizeEndpointDefinitions(providerInput, provider, previousProvider, endpointAliases); nextProviders[providerInput.provider] = provider; - validateEndpointDefinitions(providerInput, provider); - for (const modelInput of providerInput.models) { + for (const modelInput of modelInputs) { if (modelInput.source !== "byok" || modelInput.ownerAccountId) { throw new InvalidModelConfigError("Desktop model settings may only create ownerless BYOK presets"); } @@ -145,7 +167,7 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config validatePresetEndpoint(providerInput.provider, modelInput, endpoint); const presetId = resolvePresetId(modelInput.presetId, existingByokPresets, usedPresetIds); const previousPreset = record(existingByokPresets[presetId]); - nextPresets[presetId] = { + const nextPreset: ConfigRecord = { ...previousPreset, provider: providerInput.provider, endpoint: modelInput.endpointId, @@ -153,11 +175,19 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config source: "byok", capabilities: [...new Set(modelInput.capabilities)] }; - delete record(nextPresets[presetId]).label; - delete record(nextPresets[presetId]).ownerAccountId; + delete record(nextPreset).label; + delete record(nextPreset).ownerAccountId; + if (modelInput.thinking) nextPreset.thinking = modelInput.thinking; + else delete nextPreset.thinking; + if (modelInput.custom) nextPreset.custom = true; + else delete nextPreset.custom; + if (modelInput.inputModalities) nextPreset.inputModalities = [...modelInput.inputModalities]; + else delete nextPreset.inputModalities; + nextPresets[presetId] = nextPreset; } } + relocateCustomPresets({ providers: nextProviders, modelPresets: nextPresets }); validateUniqueModels(nextPresets); const modelAssignments = cloneAssignments(input.modelAssignments); validateAssignments(modelAssignments, nextPresets, existingAssignments); @@ -324,7 +354,7 @@ function memoryConnection(config: ConfigRecord, presetId: string): ConfigRecord const extraHeaders = { ...record(provider.extraHeaders), ...record(endpoint.extraHeaders) }; const extraBody = { ...record(provider.extraBody), ...record(endpoint.extraBody) }; return { - provider: memoryProvider(providerId), + provider: memoryProvider(providerId, stringValue(endpoint.protocol)), sourceProvider: providerId, endpoint: apiBase, model, @@ -334,12 +364,118 @@ function memoryConnection(config: ConfigRecord, presetId: string): ConfigRecord }; } -function memoryProvider(providerId: string): string { - if (providerId === "anthropic") return "anthropic"; - if (providerId === "gemini") return "gemini"; +function memoryProvider(providerId: string, protocol: string | undefined): string { + if (protocol === "bedrock-converse") return "bedrock"; + if (protocol === "openai-responses") return "openai_responses"; + if (protocol === "anthropic-messages" || providerId === "anthropic") return "anthropic"; + if (protocol === "gemini-generate-content" || providerId === "gemini") return "gemini"; return "openai_compatible"; } +const CUSTOM_PROVIDER_ID = "custom"; +const PROVIDER_CONNECTION_KEYS = new Set([ + "apiKey", "extraHeaders", "extraBody", "region", "profile", "endpoints", "apiBase", "apiType" +]); + +function relocateCustomPresets(config: ConfigRecord): boolean { + const providers = record(config.providers); + const presets = record(config.modelPresets); + const customProvider = record(providers[CUSTOM_PROVIDER_ID]); + const customEndpoints = record(customProvider.endpoints); + let changed = false; + + for (const presetValue of Object.values(presets)) { + const preset = record(presetValue); + if (preset.custom !== true) continue; + const providerId = stringValue(preset.provider); + const endpointId = stringValue(preset.endpoint); + if (!providerId || !endpointId || providerId === CUSTOM_PROVIDER_ID) continue; + const sourceProvider = record(providers[providerId]); + const sourceEndpoint = record(record(sourceProvider.endpoints)[endpointId]); + if (!stringValue(sourceEndpoint.apiBase)) continue; + const targetId = placeCustomEndpoint(customEndpoints, endpointId, movedEndpoint(sourceProvider, sourceEndpoint)); + preset.provider = CUSTOM_PROVIDER_ID; + preset.endpoint = targetId; + changed = true; + } + if (!changed) return false; + + customProvider.endpoints = customEndpoints; + providers[CUSTOM_PROVIDER_ID] = customProvider; + for (const [providerId, providerValue] of Object.entries(providers)) { + if (providerId === CUSTOM_PROVIDER_ID) continue; + const provider = record(providerValue); + const endpoints = record(provider.endpoints); + for (const endpointId of Object.keys(endpoints)) { + const referenced = Object.values(presets).some((presetValue) => { + const preset = record(presetValue); + return preset.provider === providerId && preset.endpoint === endpointId; + }); + if (!referenced) delete endpoints[endpointId]; + } + const stillReferenced = Object.values(presets).some((presetValue) => record(presetValue).provider === providerId); + const onlyConnectionFields = Object.keys(provider).every((key) => PROVIDER_CONNECTION_KEYS.has(key)); + if (!stillReferenced && !Object.keys(endpoints).length && onlyConnectionFields) { + delete providers[providerId]; + } + } + config.providers = providers; + config.modelPresets = presets; + return true; +} + +function movedEndpoint(provider: ConfigRecord, endpoint: ConfigRecord): ConfigRecord { + const next: ConfigRecord = { ...endpoint }; + const apiKey = stringValue(endpoint.apiKey) ?? stringValue(provider.apiKey); + if (apiKey) next.apiKey = apiKey; + const extraHeaders = { ...record(provider.extraHeaders), ...record(endpoint.extraHeaders) }; + if (Object.keys(extraHeaders).length) next.extraHeaders = extraHeaders; + const extraBody = { ...record(provider.extraBody), ...record(endpoint.extraBody) }; + if (Object.keys(extraBody).length) next.extraBody = extraBody; + const region = stringValue(endpoint.region) ?? stringValue(provider.region); + if (region) next.region = region; + const profile = stringValue(endpoint.profile) ?? stringValue(provider.profile); + if (profile) next.profile = profile; + return next; +} + +function placeCustomEndpoint(endpoints: ConfigRecord, preferredId: string, endpoint: ConfigRecord): string { + const existing = record(endpoints[preferredId]); + if (!Object.keys(existing).length) { + endpoints[preferredId] = endpoint; + return preferredId; + } + if (sameStoredEndpoint(existing, endpoint)) return preferredId; + let suffix = 2; + let id = `${preferredId}-custom`; + while (Object.keys(record(endpoints[id])).length && !sameStoredEndpoint(record(endpoints[id]), endpoint)) { + id = `${preferredId}-custom-${suffix}`; + suffix += 1; + } + endpoints[id] = endpoint; + return id; +} + +function sameStoredEndpoint(left: ConfigRecord, right: ConfigRecord): boolean { + return stableJson({ + protocol: left.protocol ?? null, + apiBase: normalizeApiBase(stringValue(left.apiBase) ?? ""), + apiKey: stringValue(left.apiKey) ?? null, + region: stringValue(left.region) ?? null, + profile: stringValue(left.profile) ?? null, + extraHeaders: left.extraHeaders ?? null, + extraBody: left.extraBody ?? null + }) === stableJson({ + protocol: right.protocol ?? null, + apiBase: normalizeApiBase(stringValue(right.apiBase) ?? ""), + apiKey: stringValue(right.apiKey) ?? null, + region: stringValue(right.region) ?? null, + profile: stringValue(right.profile) ?? null, + extraHeaders: right.extraHeaders ?? null, + extraBody: right.extraBody ?? null + }); +} + function mergeMemoryConnection(previous: ConfigRecord, connection: ConfigRecord): ConfigRecord { return { ...withoutMemoryConnection(previous), @@ -465,13 +601,24 @@ function mergeEndpoint(previous: ConfigRecord, input: CatalogEndpointInput): Con setOptionalSecret(next, "apiKey", input.apiKey, previous.apiKey); setOptionalRecord(next, "extraHeaders", input.extraHeaders, previous.extraHeaders); setOptionalRecord(next, "extraBody", input.extraBody, previous.extraBody); + if (input.region) next.region = input.region; + else delete next.region; return next; } -function validateEndpointDefinitions(input: TextModelProviderInput, provider: ConfigRecord): void { - const signatures = new Set(); - for (const endpoint of input.endpoints) { - const merged = record(record(provider.endpoints)[endpoint.endpointId]); +function canonicalizeEndpointDefinitions( + input: TextModelProviderInput, provider: ConfigRecord, previous: ConfigRecord, + aliases: NonNullable +): TextModelItemInput[] { + const canonicalIds = new Map(); + const redirects = new Map(); + const endpoints = record(provider.endpoints); + const previousEndpoints = record(previous.endpoints); + // Keep persisted connection identities even if a new duplicate is submitted first. + const ordered = [...input.endpoints].sort((left, right) => + Number(right.endpointId in previousEndpoints) - Number(left.endpointId in previousEndpoints)); + for (const endpoint of ordered) { + const merged = record(endpoints[endpoint.endpointId]); const signature = stableJson({ provider: input.provider, protocol: merged.protocol, @@ -480,11 +627,32 @@ function validateEndpointDefinitions(input: TextModelProviderInput, provider: Co extraHeaders: merged.extraHeaders ?? provider.extraHeaders ?? null, extraBody: merged.extraBody ?? provider.extraBody ?? null }); - if (signatures.has(signature)) { - throw new InvalidModelConfigError(`Duplicate endpoint definition for Provider ${input.provider}`); + const canonicalId = canonicalIds.get(signature); + if (canonicalId) { + redirects.set(endpoint.endpointId, canonicalId); + delete endpoints[endpoint.endpointId]; + } else { + canonicalIds.set(signature, endpoint.endpointId); } - signatures.add(signature); } + if (redirects.size) aliases[input.provider] = Object.fromEntries(redirects); + const models = input.models.map((model) => ({ + ...model, endpointId: redirects.get(model.endpointId) ?? model.endpointId + })); + // Only coalesce a new model made redundant by credential-based endpoint merging. + // Existing preset IDs and ordinary duplicate-model validation remain intact. + const removed = new Set(); + for (const [index, original] of input.models.entries()) { + if (original.presetId || original.ownerAccountId || original.source !== "byok" || !redirects.has(original.endpointId)) continue; + const duplicate = models.findIndex((model, candidate) => candidate !== index + && !removed.has(candidate) + && model.endpointId === models[index]!.endpointId + && model.model === original.model && model.source === original.source); + if (duplicate < 0) continue; + models[duplicate]!.capabilities = [...new Set([...models[duplicate]!.capabilities, ...models[index]!.capabilities])]; + removed.add(index); + } + return models.filter((_, index) => !removed.has(index)); } function validatePresetEndpoint(providerId: CatalogProviderId, model: TextModelItemInput, endpoint: ConfigRecord): void { @@ -673,7 +841,8 @@ function providerView( protocol, hasApiKey: Boolean(endpointApiKey), apiKeyMasked: maskSecret(endpointApiKey), - apiKey: "" + apiKey: "", + ...(stringValue(endpoint.region) ? { region: stringValue(endpoint.region)! } : {}) }]; }); return { @@ -714,7 +883,10 @@ function presetView( source, ...(stringValue(preset.ownerAccountId) ? { ownerAccountId: stringValue(preset.ownerAccountId)! } : {}), capabilities: arrayValue(preset.capabilities).filter(isModelCapability), - available: Boolean((hasCredential || API_KEY_OPTIONAL_PROVIDERS.has(providerId)) && ownerMatches) + available: Boolean((hasCredential || API_KEY_OPTIONAL_PROVIDERS.has(providerId)) && ownerMatches), + ...(preset.thinking && typeof preset.thinking === "object" ? { thinking: preset.thinking as TextModelItemView["thinking"] } : {}), + ...(preset.custom === true ? { custom: true } : {}), + ...(Array.isArray(preset.inputModalities) ? { inputModalities: preset.inputModalities as TextModelItemView["inputModalities"] } : {}) }; } diff --git a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 15e43fde3..ac3ae2914 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts @@ -86,6 +86,30 @@ function currentByokCatalog(): Record { } describe("account model projection current catalog", () => { + it.each([ + [undefined, "account"], + ["", "account"], + ["unset", "account"], + ["account", "account"], + ["byok", "byok"] + ])("preserves an explicit user mode when repairing the projection (%s -> %s)", async (userMode, expectedMode) => { + const initial = currentByokCatalog(); + const file = await configFile({ ...initial, app: { userMode } }); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "cloud-token", userId: "owner-a" }, file); + + const saved = await readConfig(file); + expect(saved.app.userMode).toBe(expectedMode); + expect(saved.providers.openai).toEqual((initial.providers as any).openai); + expect(saved.modelAssignments.byok).toEqual((initial.modelAssignments as any).byok); + expect(saved.providers.memmy_account.ownerAccountId).toBe("owner-a"); + expect(saved.modelAssignments.account.agent.candidates).toContain(accountId("owner-a", "agent")); + const snapshot = await readFile(file, "utf8"); + await expect(writeAccountModelProjectionToMemmyConfig({ + cloudUuid: "cloud-token", userId: "owner-a" + }, file)).resolves.toMatchObject({ changed: false }); + expect(await readFile(file, "utf8")).toBe(snapshot); + }); + it("creates an owner-scoped Provider, endpoint, six presets, and isolated assignment", async () => { const file = await configFile(currentByokCatalog()); const beforeByok = (await readConfig(file)).modelAssignments.byok; @@ -97,6 +121,7 @@ describe("account model projection current catalog", () => { expect(result).toEqual({ changed: true, memoryConfigAffected: true }); const saved = await readConfig(file); + expect(saved.app.userMode).toBe("account"); expect(saved.providers.memmy_account).toMatchObject({ ownerAccountId: "owner-a", apiKey: "cloud-token", diff --git a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts index 2c535c0fb..aa6d155b6 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts @@ -69,6 +69,143 @@ function openAiInput(revision: string, presetId?: string): ModelConfigInput { } describe("model config catalog", () => { + it("stores Custom beside OpenAI and moves an existing custom preset onto provider custom", async () => { + const file = fixture({ + providers: { + openai: { + apiKey: "sk-shared", + endpoints: { + chat: { + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions" + } + } + } + }, + modelPresets: { + "legacy-custom": { + provider: "openai", + endpoint: "chat", + model: "legacy-model", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"], + custom: true + } + }, + modelAssignments: emptyAssignments() + }); + + const read = await readModelConfigCatalog(file); + const migrated = YAML.parse(readFileSync(file, "utf8")) as any; + expect(migrated.modelPresets["legacy-custom"]).toMatchObject({ + provider: "custom", + endpoint: "chat", + custom: true + }); + expect(migrated.providers.custom.endpoints.chat).toMatchObject({ + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }); + expect(migrated.providers.openai).toBeUndefined(); + expect(read.providers.map((provider) => provider.provider)).toEqual(["custom"]); + + const legacy = read.providers[0]!.models[0]!; + const saved = await writeModelConfigCatalog(file, { + configRevision: read.configRevision, + providers: [ + { + provider: "custom", + endpoints: [{ + endpointId: legacy.endpointId, + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }, { + endpointId: "anthropic", + apiBase: "https://api.anthropic.com/v1", + protocol: "anthropic-messages", + apiKey: "sk-anthropic" + }], + models: [ + { + presetId: legacy.presetId, + endpointId: legacy.endpointId, + model: "legacy-model", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"], + custom: true + }, + { + endpointId: "anthropic", + model: "claude-sonnet", + source: "byok", + capabilities: ["memory_summary", "memory_evolution"], + custom: true + } + ] + }, + { + provider: "openai", + endpoints: [{ + endpointId: "chat", + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }], + models: [{ + endpointId: "chat", + model: "gpt-official", + source: "byok", + capabilities: ["agent"] + }] + } + ], + modelAssignments: emptyAssignments() + }); + expect(saved.providers.map((provider) => provider.provider).sort()).toEqual(["custom", "openai"]); + + const anthropicId = saved.providers + .find((provider) => provider.provider === "custom")! + .models.find((model) => model.model === "claude-sonnet")! + .presetId; + const officialId = saved.providers + .find((provider) => provider.provider === "openai")! + .models[0]!.presetId; + const assigned = await writeModelConfigCatalog(file, { + configRevision: saved.configRevision, + providers: saved.providers.map((provider) => ({ + provider: provider.provider, + endpoints: provider.endpoints.map((endpoint) => ({ + endpointId: endpoint.endpointId, + apiBase: endpoint.apiBase, + protocol: endpoint.protocol, + ...(provider.provider === "custom" && endpoint.protocol === "openai-chat-completions" ? { apiKey: "sk-shared" } : {}), + ...(endpoint.protocol === "anthropic-messages" ? { apiKey: "sk-anthropic" } : {}), + ...(provider.provider === "openai" ? { apiKey: "sk-shared" } : {}) + })), + models: provider.models.map((model) => ({ + presetId: model.presetId, + endpointId: model.endpointId, + model: model.model, + source: model.source, + capabilities: [...model.capabilities], + ...(model.custom ? { custom: true } : {}) + })) + })), + modelAssignments: { + ...emptyAssignments(), + byok: { + ...emptyAssignment(), + agent: { candidates: [officialId], default: officialId }, + memoryEvolution: anthropicId + } + } + }); + expect(assigned.providers.find((provider) => provider.provider === "custom")?.models).toHaveLength(2); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.evolution.provider).toBe("anthropic"); + }); + it("round-trips StepFun and Xiaomi MiMo connections back into the view", async () => { const file = fixture({ modelAssignments: emptyAssignments() }); const current = await readModelConfigCatalog(file); @@ -508,28 +645,156 @@ describe("model config catalog", () => { expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.roleRouting.summary).toBe("follow"); }); - it("rejects duplicate endpoint definitions, invalid protocol capabilities, and duplicate models", async () => { + it("coalesces identical endpoint definitions and redirects their model references", async () => { const file = fixture(); - const revision = (await readModelConfigCatalog(file)).configRevision; - const duplicateEndpoint = openAiInput(revision); - duplicateEndpoint.providers[0]!.endpoints.push({ + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints.push({ endpointId: "chat-copy", apiBase: "https://api.example.test/v1", protocol: "openai-chat-completions" }); - await expect(writeModelConfigCatalog(file, duplicateEndpoint)).rejects.toThrow(/Duplicate endpoint definition/); + input.providers[0]!.models.push({ + endpointId: "chat-copy", + model: "gpt-5-alias", + source: "byok", + capabilities: ["agent"] + }); + + const saved = await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["chat"]); + expect(Object.values(raw.modelPresets).map((preset: any) => preset.endpoint)).toEqual(["chat", "chat"]); + expect(saved.providers[0]?.endpoints).toHaveLength(1); + }); + + it("coalesces duplicate embedding endpoints created while adding models", async () => { + const file = fixture(); + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints = [ + { + endpointId: "embedding", + apiBase: "https://api.example.test/v1/", + protocol: "openai-embeddings" + }, + { + endpointId: "embedding-copy", + apiBase: "https://api.example.test/v1", + protocol: "openai-embeddings" + } + ]; + input.providers[0]!.models = [ + { + endpointId: "embedding", + model: "embedding-a", + source: "byok", + capabilities: ["embedding"] + }, + { + endpointId: "embedding-copy", + model: "embedding-b", + source: "byok", + capabilities: ["embedding"] + } + ]; + + const saved = await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["embedding"]); + expect(Object.values(raw.modelPresets).map((preset: any) => preset.endpoint)).toEqual(["embedding", "embedding"]); + expect(saved.providers[0]?.models.map((model) => model.model)).toEqual(["embedding-a", "embedding-b"]); + }); + + it.each([false, true])("coalesces a reentered key and identical model while retaining the saved preset (new endpoint first: %s)", async (newFirst) => { + const file = fixture(); + const first = await writeModelConfigCatalog(file, openAiInput((await readModelConfigCatalog(file)).configRevision)); + const originalId = first.providers[0]!.models[0]!.presetId; + const input = openAiInput(first.configRevision); + input.providers[0]!.models[0]!.presetId = originalId; + input.providers[0]!.models[0]!.capabilities = ["agent"]; + input.providers[0]!.endpoints.push({ ...input.providers[0]!.endpoints[0]!, endpointId: "chat-copy" }); + input.providers[0]!.models.push({ + ...input.providers[0]!.models[0]!, presetId: undefined, endpointId: "chat-copy", capabilities: ["memory_evolution"] + }); + if (newFirst) { + input.providers[0]!.endpoints.reverse(); + input.providers[0]!.models.reverse(); + } + const saved = await writeModelConfigCatalog(file, input); + expect(saved.endpointAliases).toEqual({ openai: { "chat-copy": "chat" } }); + expect(saved.providers[0]!.models).toHaveLength(1); + expect(saved.providers[0]!.models[0]).toMatchObject({ + presetId: originalId, endpointId: "chat", capabilities: ["agent", "memory_evolution"] + }); + // Aliases describe this write only; neither transient metadata nor plaintext is added to GET views. + expect((await readModelConfigCatalog(file)).endpointAliases).toBeUndefined(); + }); + + it("keeps endpoints with different credentials even when their route is the same", async () => { + const file = fixture(); + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints.push({ + endpointId: "chat-other-key", + apiBase: "https://api.example.test/v1", + protocol: "openai-chat-completions", + apiKey: "sk-another-secret" + }); + input.providers[0]!.models.push({ + endpointId: "chat-other-key", + model: "gpt-5-alias", + source: "byok", + capabilities: ["agent"] + }); + + await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["chat", "chat-other-key"]); + expect(raw.providers.openai.endpoints["chat-other-key"].apiKey).toBe("sk-another-secret"); + }); + it("rejects invalid protocol capabilities and duplicate models", async () => { + const file = fixture(); + const revision = (await readModelConfigCatalog(file)).configRevision; const wrongProtocol = openAiInput(revision); wrongProtocol.providers[0]!.models[0]!.capabilities = ["embedding"]; await expect(writeModelConfigCatalog(file, wrongProtocol)).rejects.toThrow(/does not support capability embedding/); - const unsupportedMemoryResponses = openAiInput(revision); - unsupportedMemoryResponses.providers[0]!.endpoints[0]!.protocol = "openai-responses"; - unsupportedMemoryResponses.providers[0]!.models[0]!.capabilities = ["memory_summary"]; - await expect(writeModelConfigCatalog(file, unsupportedMemoryResponses)) - .rejects.toThrow(/does not support capability memory_summary/); - - const duplicateModel = openAiInput(revision); + const responsesMemory = openAiInput(revision); + responsesMemory.providers[0]!.endpoints[0]!.protocol = "openai-responses"; + responsesMemory.providers[0]!.models[0]!.capabilities = ["agent", "memory_summary", "memory_evolution"]; + const savedResponses = await writeModelConfigCatalog(file, responsesMemory); + const responsesPresetId = savedResponses.providers[0]!.models[0]!.presetId!; + const assignedResponses = openAiInput(savedResponses.configRevision, responsesPresetId); + assignedResponses.providers[0]!.endpoints[0]!.protocol = "openai-responses"; + assignedResponses.providers[0]!.models[0]!.capabilities = ["agent", "memory_summary", "memory_evolution"]; + assignedResponses.modelAssignments.byok.memoryEvolution = responsesPresetId; + await writeModelConfigCatalog(file, assignedResponses); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.evolution.provider).toBe("openai_responses"); + + const bedrockMemory = openAiInput((await readModelConfigCatalog(file)).configRevision); + bedrockMemory.providers = [{ + provider: "bedrock", + endpoints: [{ + endpointId: "chat", + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2" + }], + models: [{ + endpointId: "chat", + model: "anthropic.claude-sonnet-5", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + }] + }]; + const savedBedrock = await writeModelConfigCatalog(file, bedrockMemory); + const bedrockPresetId = savedBedrock.providers[0]!.models[0]!.presetId!; + bedrockMemory.configRevision = savedBedrock.configRevision; + bedrockMemory.providers[0]!.models[0]!.presetId = bedrockPresetId; + bedrockMemory.modelAssignments.byok.memorySummary = bedrockPresetId; + await writeModelConfigCatalog(file, bedrockMemory); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.summary.provider).toBe("bedrock"); + + const duplicateModel = openAiInput((await readModelConfigCatalog(file)).configRevision); duplicateModel.providers[0]!.models.push({ endpointId: "chat", model: "gpt-5", diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index 11f9a99d3..6852134c5 100644 --- a/App/backend/src/project-version.ts +++ b/App/backend/src/project-version.ts @@ -1,2 +1,2 @@ /** Generated from the root package.json by scripts/sync-project-version.mjs. */ -export const MEMMY_VERSION = "1.2.3"; +export const MEMMY_VERSION = "1.2.4"; diff --git a/App/backend/src/services/agent-source-auto-inject-service.ts b/App/backend/src/services/agent-source-auto-inject-service.ts index c97eed893..389b4fb71 100644 --- a/App/backend/src/services/agent-source-auto-inject-service.ts +++ b/App/backend/src/services/agent-source-auto-inject-service.ts @@ -16,16 +16,18 @@ const AUTO_INJECT_AGENT_SOURCE_IDS = new Set([ "qwenwork" ]); const HOOK_OR_PLUGIN_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode", "openclaw", "hermes", "deepseek_harness"]); -const UPDATABLE_HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex"]); +const UPDATABLE_HOOK_AGENT_SOURCE_IDS = new Set(["cursor", "claude_code", "codex", "opencode"]); export interface AgentSourceAutoInjectService { runOnce(): Promise; } export interface CreateAgentSourceAutoInjectServiceOptions { - agentSources: Pick; + agentSources: Pick + & Partial>; permissionManager: Pick; getScanPreferences: () => ScanPreferences; + isAutoInjectSuppressed?: (sourceId: string) => boolean; } /** Creates create agent source auto inject service. */ @@ -54,6 +56,7 @@ export function createAgentSourceAutoInjectService( for (const source of sources) { if (!isUpdatableInstalledHook(source)) continue; + if (options.isAutoInjectSuppressed?.(source.sourceId)) continue; if (!(await options.permissionManager.canWriteAgentSkill({ agentSourceId: source.sourceId }))) { continue; } @@ -61,6 +64,11 @@ export function createAgentSourceAutoInjectService( try { if (await options.agentSources.isInstalledHookCurrent(source.sourceId)) continue; await options.agentSources.installPlugin(source.sourceId, { installType: "auto_inject" }); + if (options.isAutoInjectSuppressed?.(source.sourceId)) { + if (!options.agentSources.uninstallPlugin) throw new Error("Cannot clean up an auto-updated source removed during installation"); + await options.agentSources.uninstallPlugin(source.sourceId, { installType: "auto_inject" }); + continue; + } installed.push(source.sourceId); } catch (error) { failed.push({ @@ -85,6 +93,7 @@ export function createAgentSourceAutoInjectService( if (!AUTO_INJECT_AGENT_SOURCE_IDS.has(source.sourceId) || !source.builtin || !source.available || source.status !== "not_connected") { continue; } + if (options.isAutoInjectSuppressed?.(source.sourceId)) continue; if (!(await options.permissionManager.canWriteAgentSkill({ agentSourceId: source.sourceId }))) { continue; @@ -94,7 +103,17 @@ export function createAgentSourceAutoInjectService( if (HOOK_OR_PLUGIN_AGENT_SOURCE_IDS.has(source.sourceId)) { await options.agentSources.installPlugin(source.sourceId, { installType: "auto_inject" }); } else { - await options.agentSources.installSkill(source.sourceId); + await options.agentSources.installSkill(source.sourceId, { installType: "auto_inject" }); + } + if (options.isAutoInjectSuppressed?.(source.sourceId)) { + if (HOOK_OR_PLUGIN_AGENT_SOURCE_IDS.has(source.sourceId)) { + if (!options.agentSources.uninstallPlugin) throw new Error("Cannot clean up an auto-injected source removed during installation"); + await options.agentSources.uninstallPlugin(source.sourceId, { installType: "auto_inject" }); + } else { + if (!options.agentSources.uninstallSkill) throw new Error("Cannot clean up an auto-injected source removed during installation"); + await options.agentSources.uninstallSkill(source.sourceId); + } + continue; } installed.push(source.sourceId); } catch (error) { diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index a879722cd..63dd1b137 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -100,7 +100,7 @@ export interface AgentSourceService { syncManaged(sourceId: string): Promise; updateManaged(sourceId: string, input: ManagedAgentSourceUpdateInput): Promise; remove(sourceId: string): Promise; - installSkill(sourceId: string): Promise; + installSkill(sourceId: string, action?: Pick): Promise; uninstallSkill(sourceId: string): Promise; installPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; uninstallPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; @@ -353,8 +353,9 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption options.agentSourceRepository.removeSource(sourceId); }, - async installSkill(sourceId) { + async installSkill(sourceId, action = {}) { const startedAt = Date.now(); + const installType = normalizePluginInstallType(action.installType); const statusBefore = readSourceStatus(options, sourceId); const permission = await readScanPermission(options); const builtin = readSourceBuiltin(options, sourceId); @@ -362,7 +363,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption await ensureSourceAvailable(options, sourceId); await options.skillDistributionService.install(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "skill_installed"); + if (installType === "auto_inject") { + options.agentSourceRepository.setStatus(sourceId, "skill_installed"); + } else { + options.agentSourceRepository.setConnectionState(sourceId, "skill_installed", false); + } agentSourceAnalytics.trackSkillInstalled({ sourceId, builtin, @@ -395,7 +400,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption try { await options.skillDistributionService.uninstall(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "not_connected"); + options.agentSourceRepository.setConnectionState(sourceId, "not_connected", true); agentSourceAnalytics.trackSkillUninstalled({ sourceId, builtin, @@ -429,7 +434,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption await ensureSourceAvailable(options, sourceId); await options.skillDistributionService.installPlugin(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "plugin_installed"); + if (installType === "auto_inject") { + options.agentSourceRepository.setStatus(sourceId, "plugin_installed"); + } else { + options.agentSourceRepository.setConnectionState(sourceId, "plugin_installed", false); + } agentSourceAnalytics.trackPluginInstalled({ sourceId, permission, @@ -466,7 +475,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption try { await options.skillDistributionService.uninstallPlugin(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "not_connected"); + options.agentSourceRepository.setConnectionState(sourceId, "not_connected", true); agentSourceAnalytics.trackPluginUninstalled({ sourceId, permission, diff --git a/App/backend/src/services/app-config-service.ts b/App/backend/src/services/app-config-service.ts index c69a03335..ec2330727 100644 --- a/App/backend/src/services/app-config-service.ts +++ b/App/backend/src/services/app-config-service.ts @@ -115,6 +115,14 @@ export function createAppConfigService(options: CreateAppConfigServiceOptions): }); await options.memoryClient?.reloadConfig({ reason: "memory_token_budget_saved" }); } + if (input.userMode) { + try { + await options.memoryClient?.reloadConfig({ reason: "user_mode_saved" }); + } catch { + // Settings are saved; saving the mode again or restarting Memory can retry the reload. + console.warn("Memory config reload failed after user mode was saved"); + } + } preserveCompletedGuideWhenSwitchingToByok(previousOnboarding, options); return settings; }, diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 606e1ce35..65f47e091 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -216,6 +216,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba agentSourceAutoInject: createAgentSourceAutoInjectService({ agentSources, permissionManager: options.permissionManager, + isAutoInjectSuppressed: (sourceId) => options.appStateStore.repositories.agentSources.isAutoInjectSuppressed(sourceId), getScanPreferences: () => options.scanPreferencesStore?.getScanPreferences() ?? options.appStateStore.repositories.bootstrap.getScanPreferences() }), diff --git a/App/backend/src/services/model-config-tester.ts b/App/backend/src/services/model-config-tester.ts index 84aaf87f2..064522b23 100644 --- a/App/backend/src/services/model-config-tester.ts +++ b/App/backend/src/services/model-config-tester.ts @@ -2,8 +2,7 @@ import type { ModelConfigTestInput, ModelConfigTestResult, - ModelEndpointProtocol, - ModelProvider + ModelEndpointProtocol } from "@memmy/local-api-contracts"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -56,7 +55,7 @@ export function createHttpModelConfigTester(options: CreateHttpModelConfigTester return result( false, response.status === 404 - ? appendBaseUrlGuidance(errorMessage, input.provider) + ? appendBaseUrlGuidance(errorMessage, input.protocol) : errorMessage, now ); @@ -68,7 +67,7 @@ export function createHttpModelConfigTester(options: CreateHttpModelConfigTester return result(false, redactSecret(providerError, input.apiKey), now); } if (!probe.isValidBody(body)) { - return result(false, appendBaseUrlGuidance(INVALID_SUCCESS_BODY_MESSAGE, input.provider), now); + return result(false, appendBaseUrlGuidance(INVALID_SUCCESS_BODY_MESSAGE, input.protocol), now); } const modelListed = probe.listedModels(body).some((model) => model === input.modelId); @@ -156,16 +155,24 @@ function resourceUrl(apiBase: string, resource: string): string { return `${apiBase.replace(/\/+$/u, "")}/${resource}`; } -function baseUrlGuidance(provider: ModelProvider): string { - if (provider === "anthropic") { +function baseUrlGuidance(protocol: ModelEndpointProtocol): string { + if (protocol === "anthropic-messages") { return "Anthropic API 地址通常不包含 /v1,例如 https://api.anthropic.com"; } - if (provider === "google") return ""; - return "OpenAI 兼容 API 地址通常以 /v1 结尾,例如 https://api.openai.com/v1"; + if (protocol === "gemini-generate-content") return ""; + if ( + protocol === "openai-chat-completions" + || protocol === "openai-responses" + || protocol === "openai-embeddings" + || protocol === "openai-images" + ) { + return "OpenAI 兼容 API 地址通常以 /v1 结尾,例如 https://api.openai.com/v1"; + } + return ""; } -function appendBaseUrlGuidance(message: string, provider: ModelProvider): string { - const hint = baseUrlGuidance(provider); +function appendBaseUrlGuidance(message: string, protocol: ModelEndpointProtocol): string { + const hint = baseUrlGuidance(protocol); if (!hint) return message; return `${message.replace(/[。\.\s]+$/u, "")}。${hint}`; } diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index a5a80473d..d58357bb5 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -6,9 +6,11 @@ import { } from "../infrastructure/app-state-store/index.js"; import { clearAccountModelProjectionFromMemmyConfig, + readRuntimeMemmyConfigUserMode, readRuntimeMemmyConfigState, writeAccountModelProjectionToMemmyConfig, - type RuntimeMemmyConfigState + type RuntimeMemmyConfigState, + type RuntimeProjectionResult } from "../infrastructure/memmy-config/index.js"; export interface RuntimeConfigMigrationConsistency { @@ -52,6 +54,12 @@ type RuntimeConfigSyncErrorState = { reason: string; }; +interface RestoredAccountProjection { + cloudUuid: string; + userId: string; + result: RuntimeProjectionResult; +} + /** * Hydrate current AppState from config.yaml. Missing runtime config is left untouched: * importing SQLite model rows into YAML belongs exclusively to startup migrations. @@ -90,6 +98,8 @@ export async function syncRuntimeConfigWithAppState( } return accountChannelMismatchResult(options.appStateStore, wroteConfig); } + const restored = await restoreMissingActiveAccountProjection(options, state); + state = restored.state; switch (state.status) { case "valid_byok": { const clearedDormantProjection = await clearUntrustedAccountProjection(options, state.accountProjection); @@ -99,7 +109,7 @@ export async function syncRuntimeConfigWithAppState( : hydrated; } case "valid_account": - return hydrateAccountRuntimeConfig(options, state); + return hydrateAccountRuntimeConfig(options, state, restored.projection); case "missing": case "empty": return { @@ -135,6 +145,42 @@ export async function syncRuntimeConfigWithAppState( } } +async function restoreMissingActiveAccountProjection( + options: SyncRuntimeConfigWithAppStateOptions, + state: RuntimeMemmyConfigState +): Promise<{ + state: RuntimeMemmyConfigState; + projection?: RestoredAccountProjection; +}> { + if (state.status === "valid_account" || state.status === "invalid_yaml" || state.status === "conflict") { + return { state }; + } + + const session = options.appStateStore.repositories.accountSession.get(); + if (!session.authenticated) return { state }; + if (options.appStateStore.repositories.bootstrap.getAppSettings().userMode !== "account") { + return { state }; + } + + // An explicit BYOK selection remains authoritative even while the user is signed in. + // The repair is only for an account-mode database paired with an unset/incomplete runtime projection. + if (await readRuntimeMemmyConfigUserMode(options.memmyConfigPath) === "byok") { + return { state }; + } + + const cloudUuid = options.appStateStore.repositories.accountSession.getCloudUuid(); + if (!cloudUuid) return { state }; + const projection = await writeAccountModelProjectionToMemmyConfig({ + cloudUuid, + userId: session.profile.userId, + preserveAccountByokSelection: true + }, options.memmyConfigPath); + return { + state: await readRuntimeMemmyConfigState(options.memmyConfigPath), + projection: { cloudUuid, userId: session.profile.userId, result: projection } + }; +} + /** Handles sync runtime config for startup. */ export async function syncRuntimeConfigForStartup( options: SyncRuntimeConfigForStartupOptions @@ -247,7 +293,8 @@ function hydrateByokRuntimeConfig( async function hydrateAccountRuntimeConfig( options: SyncRuntimeConfigWithAppStateOptions, - state: Extract + state: Extract, + restoredProjection?: RestoredAccountProjection ): Promise { const { appStateStore } = options; const activated = appStateStore.repositories.accountSession.activateByCloudUuid( @@ -271,15 +318,21 @@ async function hydrateAccountRuntimeConfig( source: "none", mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: projection.changed, + wroteConfig: projection.changed || restoredProjection?.result.changed === true, reason: "account_projection_has_no_matching_local_session" }; } - const projection = await writeAccountModelProjectionToMemmyConfig({ - cloudUuid: state.cloudUuid, - userId: session.profile.userId, - preserveAccountByokSelection: true - }, options.memmyConfigPath); + // Reuse a completed repair only after validating the session and its account identity. + const restoredForCurrentAccount = restoredProjection?.cloudUuid === state.cloudUuid + && restoredProjection.userId === session.profile.userId; + const projection = restoredForCurrentAccount ? restoredProjection.result : await writeAccountModelProjectionToMemmyConfig( + { + cloudUuid: state.cloudUuid, + userId: session.profile.userId, + preserveAccountByokSelection: true + }, + options.memmyConfigPath + ); appStateStore.repositories.bootstrap.updateAppSettings({ userMode: "account" }); return { source: "runtime_config", @@ -287,10 +340,12 @@ async function hydrateAccountRuntimeConfig( provider: "memmy_account", model: "agent_chat", hydratedAppState: true, - wroteConfig: projection.changed, - reason: projection.changed - ? "refreshed_account_projection_and_hydrated_account" - : "hydrated_account_from_runtime_config" + wroteConfig: projection.changed || restoredProjection?.result.changed === true, + reason: restoredForCurrentAccount && restoredProjection.result.changed + ? "restored_account_projection_and_hydrated_account" + : projection.changed + ? "refreshed_account_projection_and_hydrated_account" + : "hydrated_account_from_runtime_config" }; } diff --git a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts index a79b18a5b..8c50ed1a4 100644 --- a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts +++ b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts @@ -53,6 +53,60 @@ describe("agent source auto inject service", () => { ]); }); + it("does not reinstall manually uninstalled sources", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [source("cursor", "not_connected", true)]; + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences, + isAutoInjectSuppressed: (sourceId) => sourceId === "cursor" + }); + + await expect(service.runOnce()).resolves.toMatchObject({ + ok: true, + skipped: false, + installed: [], + failed: [] + }); + expect(calls).toEqual([]); + }); + + it("removes an auto-injected hook when manual uninstallation races with installation", async () => { + const calls: string[] = []; + let suppressed = false; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [source("cursor", "not_connected", true)]; + }, + async installPlugin(sourceId) { + calls.push(`plugin:${sourceId}:auto_inject`); + suppressed = true; + }, + async uninstallPlugin(sourceId, action) { + calls.push(`uninstall:${sourceId}:${action?.installType ?? "manual"}`); + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences, + isAutoInjectSuppressed: () => suppressed + }); + + await expect(service.runOnce()).resolves.toMatchObject({ + ok: true, + skipped: false, + installed: [], + failed: [] + }); + expect(calls).toEqual(["plugin:cursor:auto_inject", "uninstall:cursor:auto_inject"]); + }); + it("skips unavailable builtin agents", async () => { const calls: string[] = []; const service = createAgentSourceAutoInjectService({ @@ -168,7 +222,7 @@ describe("agent source auto inject service", () => { ]); }); - it("does not refresh installed plugins that are not hooks", async () => { + it("refreshes outdated OpenCode plugins without refreshing unrelated plugins", async () => { const calls: string[] = []; const service = createAgentSourceAutoInjectService({ agentSources: { @@ -190,10 +244,10 @@ describe("agent source auto inject service", () => { await expect(service.runOnce()).resolves.toEqual({ ok: true, skipped: false, - installed: [], + installed: ["opencode"], failed: [] }); - expect(calls).toEqual([]); + expect(calls).toEqual(["plugin:opencode:auto_inject"]); }); it("skips an outdated hook when the agent is unavailable or not writable", async () => { diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index 69515fb4a..2289871ce 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -1312,10 +1312,12 @@ describe("agent source service", () => { }); await service.installSkill("cursor"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(false); await service.uninstallSkill("cursor"); expect(calls).toEqual(["install:cursor", "uninstall:cursor"]); expect(repository.listSources()[0]?.status).toBe("not_connected"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); }); it("delegates native plugin install then updates source status", async () => { @@ -1347,10 +1349,12 @@ describe("agent source service", () => { }); await service.installPlugin("openclaw"); + expect(repository.isAutoInjectSuppressed("openclaw")).toBe(false); await service.uninstallPlugin("openclaw"); expect(calls).toEqual(["plugin:openclaw", "unplugin:openclaw"]); expect(repository.listSources()[0]?.status).toBe("not_connected"); + expect(repository.isAutoInjectSuppressed("openclaw")).toBe(true); }); it("delegates memory plugin conflict detection to the skill distribution service", async () => { @@ -1785,6 +1789,7 @@ function createRepository(): AgentSourceRepository { data_path TEXT NOT NULL, builtin INTEGER NOT NULL CHECK(builtin IN (0,1)), status TEXT NOT NULL DEFAULT 'not_connected', + auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 CHECK(auto_inject_suppressed IN (0,1)), last_scanned_at TEXT, sync_recipe_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/App/backend/src/services/tests/app-config-service.test.ts b/App/backend/src/services/tests/app-config-service.test.ts index caf963eff..6b811adbf 100644 --- a/App/backend/src/services/tests/app-config-service.test.ts +++ b/App/backend/src/services/tests/app-config-service.test.ts @@ -1,9 +1,9 @@ /** App config service tests. */ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import YAML from "yaml"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createAppConfigService } from "../app-config-service.js"; import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; import { createMemmyConfigWriter } from "../../infrastructure/memmy-config/index.js"; @@ -16,6 +16,77 @@ import type { } from "@memmy/local-api-contracts"; describe("AppConfigService", () => { + it.each(["byok", "account"] as const)("reloads Memory after persisting %s mode to YAML and SQLite", async (userMode) => { + const root = mkdtempSync(join(tmpdir(), "memmy-mode-reload-")); + const configPath = join(root, "config.yaml"); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + try { + const observations: unknown[] = []; + const reloadConfig = vi.fn(async (input) => { + observations.push({ + input, + yamlMode: YAML.parse(readFileSync(configPath, "utf8")).app.userMode, + databaseMode: store.repositories.bootstrap.getAppSettings().userMode + }); + }); + const service = createAppConfigService({ + bootstrapRepository: store.repositories.bootstrap, + memmyConfigWriter: createMemmyConfigWriter({ configPath }), + memoryClient: { reloadConfig } + }); + await expect(service.updateSettings({ userMode })).resolves.toMatchObject({ userMode }); + expect(observations).toEqual([{ + input: { reason: "user_mode_saved" }, yamlMode: userMode, databaseMode: userMode + }]); + + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + reloadConfig.mockRejectedValueOnce(new Error("Memory unavailable")); + await expect(service.updateSettings({ userMode })).resolves.toMatchObject({ userMode }); + expect(warning).toHaveBeenCalledWith("Memory config reload failed after user mode was saved"); + expect(YAML.parse(readFileSync(configPath, "utf8")).app.userMode).toBe(userMode); + expect(store.repositories.bootstrap.getAppSettings().userMode).toBe(userMode); + // Saving the same mode again must retry the failed reload. + await service.updateSettings({ userMode }); + expect(reloadConfig).toHaveBeenCalledTimes(3); + expect(observations).toHaveLength(2); + } finally { + warning.mockRestore(); + } + } finally { + store.close(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("does not reload Memory when only appearance settings change", async () => { + const reloadConfig = vi.fn(); + const service = createAppConfigService({ + bootstrapRepository: { + ...createBootstrapRepositoryStub(), + updateAppSettings: (patch) => appSettings(patch) + }, + memoryClient: { reloadConfig } + }); + await service.updateSettings({ theme: "dark" }); + expect(reloadConfig).not.toHaveBeenCalled(); + }); + + it("does not update the database or reload Memory if saving the runtime mode fails", async () => { + const updateAppSettings = vi.fn(); + const reloadConfig = vi.fn(); + const writer = createMemmyConfigWriter(); + vi.spyOn(writer, "writeUserMode").mockRejectedValueOnce(new Error("Config not writable")); + const service = createAppConfigService({ + bootstrapRepository: { ...createBootstrapRepositoryStub(), updateAppSettings }, + memmyConfigWriter: writer, + memoryClient: { reloadConfig } + }); + await expect(service.updateSettings({ userMode: "account" })).rejects.toThrow("Config not writable"); + expect(updateAppSettings).not.toHaveBeenCalled(); + expect(reloadConfig).not.toHaveBeenCalled(); + }); + it("updates app settings through the bootstrap repository", async () => { const calls: unknown[] = []; const service = createAppConfigService({ diff --git a/App/backend/src/services/tests/model-config-tester.test.ts b/App/backend/src/services/tests/model-config-tester.test.ts index dcd087bf8..d4c0029bd 100644 --- a/App/backend/src/services/tests/model-config-tester.test.ts +++ b/App/backend/src/services/tests/model-config-tester.test.ts @@ -177,6 +177,111 @@ describe("model config tester", () => { expect(JSON.stringify(response)).not.toContain("sk-secret"); }); + it.each([ + ["openai-chat-completions", "https://api.example.test/v1/models", { Authorization: "Bearer sk-secret" }], + ["openai-responses", "https://api.example.test/v1/models", { Authorization: "Bearer sk-secret" }] + ] as Array<[ModelEndpointProtocol, string, Record]>)( + "probes custom %s with the OpenAI model list", + async (protocol, url, headers) => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ data: [{ id: "model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol, + apiBase: "https://api.example.test/v1" + })); + + expect(calls[0]).toMatchObject({ url, init: { method: "GET", headers } }); + } + ); + + it("probes custom Anthropic Messages with the Anthropic model list", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ data: [{ id: "model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com" + })); + + expect(calls[0]).toMatchObject({ + url: "https://api.anthropic.com/v1/models", + init: { + method: "GET", + headers: { + "x-api-key": "sk-secret", + "anthropic-version": "2023-06-01" + } + } + }); + }); + + it("probes custom Gemini with the Gemini model list", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ models: [{ name: "models/model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol: "gemini-generate-content", + apiBase: "https://generativelanguage.googleapis.com/v1beta" + })); + + expect(calls[0]).toMatchObject({ + url: "https://generativelanguage.googleapis.com/v1beta/models", + init: { method: "GET", headers: { "x-goog-api-key": "sk-secret" } } + }); + }); + + it("does not probe custom Bedrock Converse", async () => { + const fetch = vi.fn(); + const tester = createHttpModelConfigTester({ now: () => checkedAt, fetch }); + + await expect(tester.test(input({ + provider: "custom", + protocol: "bedrock-converse", + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com" + }))).resolves.toEqual({ + ok: false, + message: "当前 endpoint 协议不支持模型列表连接测试", + checkedAt + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses the selected protocol for custom failure hints", async () => { + const tester = createHttpModelConfigTester({ + now: () => checkedAt, + fetch: async () => json({ error: { message: "not found" } }, 404) + }); + + const openai = await tester.test(input({ provider: "custom", protocol: "openai-chat-completions" })); + const anthropic = await tester.test(input({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com" + })); + + expect(openai.message).toContain("OpenAI 兼容 API 地址通常以 /v1 结尾"); + expect(anthropic.message).toContain("Anthropic API 地址通常不包含 /v1"); + }); + it("keeps actionable Base URL guidance on 404", async () => { const tester = createHttpModelConfigTester({ now: () => checkedAt, diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index 2bc3c5828..f9d52f602 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -3,8 +3,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import YAML from "yaml"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import * as memmyConfig from "../../infrastructure/memmy-config/index.js"; import { createMemmyConfigWriter, writeAccountModelProjectionToMemmyConfig @@ -21,6 +22,7 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); store?.close(); store = undefined; if (tempDir) rmSync(tempDir, { recursive: true, force: true }); @@ -51,6 +53,147 @@ describe("syncRuntimeConfigWithAppState", () => { expect(context.store.db.prepare("SELECT * FROM account_model_config ORDER BY uuid").all()).toEqual(legacyBefore); }); + it("restores built-in models when an authenticated account has an unset runtime projection", async () => { + const context = createContext(); + seedAccountSession(context, "phone"); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + config.modelAssignments.account = { + ownerAccountId: "owner-a", + agent: { candidates: ["missing-account-preset"], default: "missing-account-preset" } + }; + context.writeConfig(config); + + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig"); + const activateSession = vi.spyOn(context.store.repositories.accountSession, "activateByCloudUuid"); + const updateSettings = vi.spyOn(context.store.repositories.bootstrap, "updateAppSettings"); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "account", + provider: "memmy_account", + model: "agent_chat", + hydratedAppState: true, + wroteConfig: true, + reason: "restored_account_projection_and_hydrated_account" + }); + expect(writeProjection).toHaveBeenCalledTimes(1); + expect(activateSession).toHaveBeenCalledWith("cloud-token-a", "phone"); + expect(updateSettings).toHaveBeenCalledWith({ userMode: "account" }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.userMode).toBe("account"); + expect(saved.providers.memmy_account).toMatchObject({ + ownerAccountId: "owner-a", + apiKey: "cloud-token-a" + }); + expect(saved.modelAssignments.account.agent.candidates).toContainEqual( + expect.stringMatching(/^memmy-account-.+-agent$/) + ); + + const repairedConfig = readFileSync(context.memmyConfigPath, "utf8"); + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + mode: "account", + hydratedAppState: true, + wroteConfig: false, + reason: "hydrated_account_from_runtime_config" + }); + // A normal restart still refreshes the projection, but makes no redundant changes. + expect(writeProjection).toHaveBeenCalledTimes(2); + expect(readFileSync(context.memmyConfigPath, "utf8")).toBe(repairedConfig); + }); + + it("validates the session after repairing a missing account projection", async () => { + const context = createContext(); + seedAccountSession(context); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + context.writeConfig(config); + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig"); + vi.spyOn(context.store.repositories.accountSession, "activateByCloudUuid").mockReturnValue(false); + const updateSettings = vi.spyOn(context.store.repositories.bootstrap, "updateAppSettings"); + + await expect(syncRuntimeConfigWithAppState({ ...context, accountChannel: "email" })) + .resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + wroteConfig: true, + reason: "account_projection_has_no_matching_local_session" + }); + expect(writeProjection).toHaveBeenCalledTimes(1); + expect(updateSettings).not.toHaveBeenCalled(); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + + it("refreshes a different account if its projection replaces the repaired config before hydration", async () => { + const context = createContext(); + seedAccountSession(context); + const session = context.store.repositories.accountSession.get(); + if (!session.authenticated) throw new Error("Expected the seeded account session"); + context.store.repositories.accountSession.upsert({ + profile: { ...session.profile, userId: "owner-b" }, + uuid: "account-b", + cloudUuid: "cloud-token-b", + authChannel: "email" + }); + context.store.repositories.accountSession.activateByCloudUuid("cloud-token-a", "email"); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + context.writeConfig(config); + const originalWrite = memmyConfig.writeAccountModelProjectionToMemmyConfig; + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig") + .mockImplementationOnce(async (...args) => { + const result = await originalWrite(...args); + // Model a concurrent account switch while the repaired config is being re-read. + context.writeRaw(YAML.stringify(currentAccountCatalog()) + .replaceAll("owner-a", "owner-b") + .replaceAll("cloud-token-a", "cloud-token-b")); + return result; + }); + + await expect(syncRuntimeConfigWithAppState({ ...context, accountChannel: "email" })) + .resolves.toMatchObject({ mode: "account", hydratedAppState: true, wroteConfig: true }); + expect(writeProjection).toHaveBeenCalledTimes(2); + expect(writeProjection).toHaveBeenLastCalledWith({ + cloudUuid: "cloud-token-b", userId: "owner-b", preserveAccountByokSelection: true + }, context.memmyConfigPath); + expect(context.store.repositories.accountSession.get()).toMatchObject({ + authenticated: true, profile: { userId: "owner-b" } + }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account.ownerAccountId).toBe("owner-b"); + expect(saved.modelAssignments.account.ownerAccountId).toBe("owner-b"); + expect(saved.modelPresets[saved.modelAssignments.account.memorySummary]).toMatchObject({ + source: "account", ownerAccountId: "owner-b", capabilities: ["memory_summary"] + }); + }); + + it("keeps an explicit BYOK runtime selection while the account remains authenticated", async () => { + const context = createContext(); + seedAccountSession(context); + context.writeConfig(currentByokCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "byok", + provider: "openai", + model: "gpt-5" + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + it("hydrates account mode only from a current owner-bound projection", async () => { const context = createContext(); context.store.repositories.accountSession.upsert({ @@ -469,6 +612,36 @@ describe("syncRuntimeConfigWithAppState", () => { expect(saved.modelAssignments.account.ownerAccountId).toBe("owner-a"); }); + it.each([false, true])("preserves explicit BYOK while repairing a migrated account projection (runtime migrated: %s)", async (runtimeSourceWasMigrated) => { + const context = createContext(); + seedAccountSession(context); + const byok = currentByokCatalog() as any; + context.writeConfig({ ...byok, app: { ...byok.app, userMode: "byok" } }); + + const options = { + ...context, + accountChannel: "email" as const, + migrationConsistency: { + accountSourceIsAuthoritative: true, + runtimeSourceWasMigrated, + categorySourcesShareGeneration: false + } + }; + await expect(syncRuntimeConfigWithAppState(options)).resolves.toMatchObject({ + mode: "byok", provider: "openai", model: "gpt-5", hydratedAppState: true + }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.userMode).toBe("byok"); + expect(saved.providers.openai).toEqual(byok.providers.openai); + expect(saved.modelAssignments.byok).toEqual(byok.modelAssignments.byok); + expect(saved.providers.memmy_account).toMatchObject({ ownerAccountId: "owner-a", apiKey: "cloud-token-a" }); + expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("byok"); + expect(context.store.repositories.accountSession.get()).toMatchObject({ authenticated: true }); + const snapshot = readFileSync(context.memmyConfigPath, "utf8"); + await expect(syncRuntimeConfigWithAppState(options)).resolves.toMatchObject({ mode: "byok", wroteConfig: false }); + expect(readFileSync(context.memmyConfigPath, "utf8")).toBe(snapshot); + }); + it("restores the active account after an upgrade left active_uuid empty", async () => { const context = createContext(); seedAccountSession(context); diff --git a/App/backend/src/tests/local-app-contracts.test.ts b/App/backend/src/tests/local-app-contracts.test.ts index 6a0623f62..69d779506 100644 --- a/App/backend/src/tests/local-app-contracts.test.ts +++ b/App/backend/src/tests/local-app-contracts.test.ts @@ -52,6 +52,7 @@ describe("local app contracts", () => { it("accepts StepFun and Xiaomi as text providers and canonicalises their catalog ids", () => { expect(ModelProviderSchema.safeParse("stepfun").success).toBe(true); expect(ModelProviderSchema.safeParse("xiaomi").success).toBe(true); + expect(ModelProviderSchema.safeParse("custom").success).toBe(false); expect(canonicalCatalogProviderId("stepfun")).toBe("stepfun"); expect(canonicalCatalogProviderId("xiaomi")).toBe("xiaomi_mimo"); @@ -409,6 +410,13 @@ describe("local app contracts", () => { expect(input.modelId).toBe("gpt-5.5"); expect(asrInput.capability).toBe("asr"); + expect(ModelConfigTestInputSchema.safeParse({ + provider: "custom", + endpointId: "chat", + protocol: "openai-chat-completions", + apiBase: "https://api.example.test/v1", + modelId: "gpt-6-sol" + }).success).toBe(true); const result = ModelConfigTestResultSchema.parse({ ok: false, diff --git a/App/frontend/desktop/src/api/config-client.ts b/App/frontend/desktop/src/api/config-client.ts index 00c147c71..f1795014f 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -55,6 +55,7 @@ export interface ModelProviderConfig { provider: string; endpointId?: string; protocol?: ModelEndpointProtocol; + region?: string; endpoint: string; model: string; apiKey: string; @@ -263,14 +264,15 @@ export function createHttpConfigClient(config: RuntimeConfig): ConfigClient { path: "/api/app/model-config/test", schema: ModelConfigTestResultSchema, body: ModelConfigTestInputSchema.parse({ - provider: toModelProvider(modelConfig.provider), + provider: modelConfig.provider === "custom" ? "custom" : toModelProvider(modelConfig.provider), endpointId: modelConfig.endpointId ?? `connection-test-${secretTarget ?? capability}`, protocol: modelConfig.protocol ?? testProtocolFor(modelConfig.provider, capability), apiBase: modelConfig.endpoint, modelId: modelConfig.model, apiKey: modelConfig.apiKey || undefined, capability, - secretTarget + secretTarget, + ...(modelConfig.region ? { region: modelConfig.region } : {}) }) }); }, @@ -646,12 +648,14 @@ function withoutPendingPresetAssignments(input: ModelConfigInput, pendingIds: Re function resolvePendingPresetIds(pending: PendingPreset[], view: ModelConfigView): Map { const mapping = new Map(); for (const item of pending) { + // Never infer credential equality from a mask. Only the backend can resolve aliases. + item.endpointId = view.endpointAliases?.[item.provider]?.[item.endpointId] ?? item.endpointId; const matches = view.providers.flatMap((provider) => provider.models).filter((model) => ( model.provider === item.provider && model.endpointId === item.endpointId && model.model === item.model && model.source === item.source - && sameStringSet(model.capabilities, item.capabilities) + && item.capabilities.every((capability) => model.capabilities.includes(capability)) )); if (matches.length !== 1) { throw new Error(`Unable to resolve server preset for ${item.provider}/${item.endpointId}/${item.model}`); @@ -710,17 +714,13 @@ function assertResolvedPresetsStillExist( || preset.endpointId !== item.endpointId || preset.model !== item.model || preset.source !== item.source - || !sameStringSet(preset.capabilities, item.capabilities) + || !item.capabilities.every((capability) => preset.capabilities.includes(capability)) ) { throw new Error(`Created model preset changed before assignment: ${item.provider}/${item.endpointId}/${item.model}`); } } } -function sameStringSet(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value) => right.includes(value)); -} - function isModelConfigChanged(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "model_config_changed"); } @@ -821,11 +821,32 @@ function fromOptionalPreset(view: ModelConfigView, preset: ModelConfigView["prov } function toModelProvider(provider: string): ModelProvider { - if (provider === "openai") { + if (provider === "openai" || provider === "openai_responses") { return "openai_compatible"; } + if (provider === "bedrock") { + return "anthropic"; + } + if (provider === "gemini") { + return "google"; + } + if (provider === "moonshot") { + return "kimi"; + } + if (provider === "dashscope") { + return "qwen"; + } + if (provider === "qianfan") { + return "baidu"; + } + if (provider === "volcengine") { + return "doubao"; + } + if (provider === "xiaomi_mimo") { + return "xiaomi"; + } - return provider === "gemini" ? "google" : (provider as ModelProvider); + return provider as ModelProvider; } function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfigInput { if (!("configured" in config)) { @@ -841,7 +862,8 @@ function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfig endpointId: endpoint.endpointId, apiBase: endpoint.apiBase, protocol: endpoint.protocol, - ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}) + ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}), + ...(endpoint.region ? { region: endpoint.region } : {}) })), models: provider.models.map((model) => ({ ...(model.presetId ? { presetId: model.presetId } : {}), @@ -849,7 +871,10 @@ function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfig model: model.model, source: model.source, ...(model.ownerAccountId ? { ownerAccountId: model.ownerAccountId } : {}), - capabilities: [...model.capabilities] + capabilities: [...model.capabilities], + ...(model.thinking ? { thinking: model.thinking } : {}), + ...(model.custom ? { custom: true } : {}), + ...(model.inputModalities ? { inputModalities: [...model.inputModalities] } : {}) })) })), modelAssignments: structuredClone(config.modelAssignments) diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index 597007a32..cd792bcbb 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -245,7 +245,9 @@ const SessionSummarySchema = z.object({ projectId: z.string().nullable(), cwd: z.string(), model_preset: z.string().nullable().optional(), - model_selection: ModelSelectionSchema.nullable().optional() + model_selection: ModelSelectionSchema.nullable().optional(), + thinking_enabled: z.boolean().nullable().optional(), + thinking_level: z.string().nullable().optional() }).passthrough(); const ProjectSchema = z.object({ @@ -370,18 +372,6 @@ const SlashCommandsResponseSchema = z.object({ commands: z.array(SlashCommandSchema) }); -const WEBUI_HIDDEN_SLASH_COMMANDS = new Set([ - "/stop", - "/restart", - "/dream", - "/dream-log", - "/dream-restore", - "/history", - "/pairing", - "/help", - "/model" -]); - const SidebarStateSchema = z.object({ schema_version: z.literal(1), pinned_keys: z.array(z.string()), @@ -656,6 +646,8 @@ export type MemmyAgentSendMessageInput = { language?: MemmyAgentUiLanguage; media?: MemmyAgentMediaInput[]; modelPreset?: string | null; + thinkingEnabled?: boolean; + thinkingLevel?: string | null; }; export interface MemmyAgentNewChatResult { @@ -1354,7 +1346,6 @@ class HttpMemmyAgentClient implements MemmyAgentClient { async listSlashCommands(): Promise { const response = await this.request("/api/commands", SlashCommandsResponseSchema); return response.commands - .filter((command) => !WEBUI_HIDDEN_SLASH_COMMANDS.has(command.command)) .map((command) => ({ command: command.command, title: command.title, @@ -2509,6 +2500,8 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { ...(input.target ? { target: input.target } : {}), ...(input.language ? { language: input.language } : {}), ...(input.modelPreset !== undefined ? { model_preset: input.modelPreset } : {}), + ...(input.thinkingEnabled !== undefined ? { thinking_enabled: input.thinkingEnabled } : {}), + ...(input.thinkingLevel ? { thinking_level: input.thinkingLevel } : {}), ...(input.media?.length ? { media_paths: input.media.map((item) => item.path) } : {}) }, expectedGeneration); this.knownChats.add(input.chatId); diff --git a/App/frontend/desktop/src/api/tests/config-client.test.ts b/App/frontend/desktop/src/api/tests/config-client.test.ts index eae3aa81a..98cb03b61 100644 --- a/App/frontend/desktop/src/api/tests/config-client.test.ts +++ b/App/frontend/desktop/src/api/tests/config-client.test.ts @@ -209,6 +209,122 @@ describe("config-client canonical model catalog", () => { expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); }); + it.each(["endpoint apiKey", "provider apiKey", "missing apiKey"] as const)( + "后端合并相同 endpoint 定义后可用 %s 解析新模型并完成 assignment", + async (keyLocation) => { + const bodies: any[] = []; + const serverPresetId = "5c8872cf-28c8-4cf3-bf51-9a3a2a0fb42a"; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + bodies.push(body); + const response = catalog(bodies.length === 1 ? "revision-2" : "revision-3"); + response.endpointAliases = { openai: { "chat-duplicate": "chat" } }; + response.providers[0]!.apiKeyMasked = "sk-••••1234"; + for (const endpoint of response.providers[0]!.endpoints) endpoint.apiKeyMasked = "sk-••••1234"; + response.providers[0]!.models.push({ + presetId: serverPresetId, + provider: "openai", + endpointId: "chat", + protocol: "openai-chat-completions", + model: "gpt-new", + source: "byok", + capabilities: ["agent"], + available: true + }); + if (bodies.length > 1) { + response.modelAssignments.byok.agent = { + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }; + } + return jsonResponse(response); + })); + const input = inputFromCatalog(catalog("revision-1")); + const clientPresetId = `${CLIENT_PRESET_ID_PREFIX}canonicalized-endpoint`; + input.providers[0]!.endpoints.push({ + endpointId: "chat-duplicate", + apiBase: "https://api.openai.com/v1/", + protocol: "openai-chat-completions", + ...(keyLocation === "endpoint apiKey" ? { apiKey: " fixture-live-key-1234 " } : {}) + }); + if (keyLocation === "provider apiKey") input.providers[0]!.apiKey = " fixture-live-key-1234 "; + input.providers[0]!.models.push({ + presetId: clientPresetId, + endpointId: "chat-duplicate", + model: "gpt-new", + source: "byok", + capabilities: ["agent"] + }); + input.modelAssignments.byok.agent = { + candidates: ["byok-agent", clientPresetId], + default: clientPresetId + }; + + const saved = await createHttpConfigClient(config).saveModelCatalog(input); + + expect(bodies).toHaveLength(2); + expect(bodies[0].providers[0].endpoints.some((endpoint: any) => endpoint.endpointId === "chat-duplicate")).toBe(true); + expect(bodies[1].providers[0].models.find((model: any) => model.model === "gpt-new")).toMatchObject({ + presetId: serverPresetId, + endpointId: "chat" + }); + expect(bodies[1].modelAssignments.byok.agent).toEqual({ + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }); + expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); + } + ); + + it.each(["missing alias", "endpoint changed after assignment"] as const)( + "%s: 相同地址和密钥掩码不能代替后端确认的连接身份", + async (scenario) => { + const bodies: ModelConfigInput[] = []; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as ModelConfigInput); + const response = catalog(`revision-${bodies.length + 1}`); + const provider = response.providers[0]!; + const endpoint = provider.endpoints[0]!; + endpoint.apiKeyMasked = "sk-••••1234"; + provider.endpoints.push({ ...endpoint, endpointId: "other-key" }); + if (scenario === "endpoint changed after assignment" && bodies.length === 1) { + response.endpointAliases = { openai: { "chat-copy": "chat" } }; + } + provider.models.push({ + ...provider.models[0]!, + presetId: "server-new-model", + endpointId: bodies.length === 1 ? "chat" : "other-key", + model: "gpt-new" + }); + return jsonResponse(response); + })); + const input = inputFromCatalog(catalog("revision-1")); + const clientId = `${CLIENT_PRESET_ID_PREFIX}identity-check`; + input.providers[0]!.endpoints.push({ + ...input.providers[0]!.endpoints[0]!, + endpointId: "chat-copy", + apiKey: "fixture-different-key-1234" + }); + input.providers[0]!.models.push({ + presetId: clientId, + endpointId: "chat-copy", + model: "gpt-new", + source: "byok", + capabilities: ["agent"] + }); + input.modelAssignments.byok.agent = { candidates: [clientId], default: clientId }; + + await expect(createHttpConfigClient(config).saveModelCatalog(input)).rejects.toThrow( + scenario === "missing alias" ? "Unable to resolve server preset" : "Created model preset changed before assignment" + ); + expect(bodies).toHaveLength(scenario === "missing alias" ? 1 : 2); + if (scenario === "endpoint changed after assignment") { + expect(bodies[1]!.modelAssignments.byok.agent.default).toBe("server-new-model"); + expect(bodies[1]!.providers[0]!.models.find((model) => model.presetId === "server-new-model")?.endpointId).toBe("chat"); + } + } + ); + it("首次成功 GET 后只清理旧 workspace cache", async () => { const removeItem = vi.fn(); vi.stubGlobal("window", { localStorage: { removeItem } }); @@ -249,6 +365,49 @@ describe("config-client canonical model catalog", () => { }); expect(result.ok).toBe(true); }); + + it("keeps custom as the test provider and probes by the selected protocol", async () => { + const bodies: any[] = []; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return jsonResponse({ ok: true, message: "ok", checkedAt: "2026-08-11T00:00:00.000Z" }); + })); + const client = createHttpConfigClient(config); + + await client.testModelConfig({ + provider: "custom", + endpointId: "chat", + protocol: "openai-chat-completions", + endpoint: "https://api.example.test/v1", + model: "gpt-6-sol", + apiKey: "sk-live", + apiKeyMasked: "", + configured: true + }); + await client.testModelConfig({ + provider: "custom", + endpointId: "messages", + protocol: "anthropic-messages", + endpoint: "https://api.anthropic.com", + model: "claude-sonnet", + apiKey: "sk-live", + apiKeyMasked: "", + configured: true + }); + + expect(bodies[0]).toMatchObject({ + provider: "custom", + protocol: "openai-chat-completions", + apiBase: "https://api.example.test/v1", + modelId: "gpt-6-sol" + }); + expect(bodies[1]).toMatchObject({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com", + modelId: "claude-sonnet" + }); + }); }); function catalog(configRevision: string): ModelConfigView { diff --git a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts index 45997bfcd..9c0e2a637 100644 --- a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts @@ -465,7 +465,7 @@ describe("memmy-agent client", () => { expect(paths).toEqual(["/webui/bootstrap"]); }); - it("lists slash commands with bearer token, camelCase mapping, goal exposure, and control-command filtering", async () => { + it("lists all server-supported slash commands with bearer token and camelCase mapping", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input)); if (url.pathname === "/webui/bootstrap") { @@ -495,9 +495,18 @@ describe("memmy-agent client", () => { const client = createMemmyAgentClient({ baseUrl: "http://127.0.0.1:18980", clientId: "frontend-test", fetchFn: fetchMock as typeof fetch }); await expect(client.listSlashCommands()).resolves.toEqual([ + { command: "/stop", title: "Stop", description: "Stop turn", icon: "square", argHint: "" }, + { command: "/restart", title: "Restart", description: "Restart agent", icon: "rotate-cw", argHint: "" }, + { command: "/dream", title: "Dream", description: "Run Dream", icon: "sparkles", argHint: "" }, + { command: "/dream-log", title: "Dream log", description: "Show Dream log", icon: "book-open", argHint: "" }, + { command: "/dream-restore", title: "Dream restore", description: "Restore Dream", icon: "undo-2", argHint: "" }, + { command: "/history", title: "History", description: "Show history", icon: "history", argHint: "[n]" }, { command: "/goal", title: "Goal", description: "Start goal", icon: "activity", argHint: "" }, + { command: "/pairing", title: "Pairing", description: "Manage pairing", icon: "shield", argHint: "" }, + { command: "/help", title: "Help", description: "Show help", icon: "circle-help", argHint: "" }, { command: "/status", title: "Status", description: "Show status", icon: "activity", argHint: "" }, - { command: "/new", title: "New", description: "New chat", icon: "square-pen", argHint: "" } + { command: "/new", title: "New", description: "New chat", icon: "square-pen", argHint: "" }, + { command: "/model", title: "Model", description: "Switch model", icon: "brain", argHint: "[preset]" } ]); }); diff --git a/App/frontend/desktop/src/assets/model-logos/custom.svg b/App/frontend/desktop/src/assets/model-logos/custom.svg new file mode 100644 index 000000000..5773776b0 --- /dev/null +++ b/App/frontend/desktop/src/assets/model-logos/custom.svg @@ -0,0 +1 @@ +Custom diff --git a/App/frontend/desktop/src/components/Select.tsx b/App/frontend/desktop/src/components/Select.tsx index 89cbc0dcf..92995123d 100644 --- a/App/frontend/desktop/src/components/Select.tsx +++ b/App/frontend/desktop/src/components/Select.tsx @@ -31,6 +31,8 @@ export interface SelectProps { placement?: "top" | "bottom"; /** Optional footer rendered below options; receives a close helper. */ menuFooter?: (api: { close: () => void }) => ReactNode; + /** Optional panel shown beside the menu while an option is hovered or focused. */ + optionAside?: (option: SelectOption) => ReactNode; } /** Handles select. */ @@ -40,15 +42,23 @@ export function Select(props: SelectProps) { const listboxId = `${controlId}-listbox`; const labelId = `${controlId}-label`; const rootRef = useRef(null); + const menuRef = useRef(null); const [open, setOpen] = useState(false); const [activeValue, setActiveValue] = useState(props.value); + const [hoveredValue, setHoveredValue] = useState(null); + const [hoveredTop, setHoveredTop] = useState(0); const enabledOptions = useMemo(() => props.options.filter((option) => !option.disabled), [props.options]); const selectedOption = props.options.find((option) => option.value === props.value); const activeOption = props.options.find((option) => option.value === activeValue && !option.disabled); + const hoveredOption = props.options.find((option) => option.value === hoveredValue && !option.disabled); + const aside = hoveredOption ? props.optionAside?.(hoveredOption) : null; useEffect(() => { - if (!open) return; + if (!open) { + setHoveredValue(null); + return; + } setActiveValue(props.value || enabledOptions[0]?.value || ""); @@ -62,6 +72,12 @@ export function Select(props: SelectProps) { return () => document.removeEventListener("mousedown", closeOnOutsideClick); }, [enabledOptions, open, props.value]); + function showOptionAside(option: SelectOption, element: HTMLButtonElement) { + const menuTop = menuRef.current?.getBoundingClientRect().top ?? 0; + setHoveredTop(element.getBoundingClientRect().top - menuTop); + setHoveredValue(option.value); + } + function moveActive(step: 1 | -1) { if (!enabledOptions.length) return; @@ -158,38 +174,51 @@ export function Select(props: SelectProps) {