diff --git a/.github/release-notes/v1.0.9.md b/.github/release-notes/v1.0.9.md new file mode 100644 index 000000000..2ee86cd72 --- /dev/null +++ b/.github/release-notes/v1.0.9.md @@ -0,0 +1,27 @@ +# Memmy v1.0.9 + +## Highlights + +- Added a first-class Git workspace view to Agent conversations. You can inspect repository and branch state, review file changes and rendered diffs, see Goal evidence, and switch or create local branches without leaving Memmy. +- Introduced a dedicated User Memory layer for facts, preferences, and explicit directives, together with per-turn evidence showing which memories supported an Agent response. +- Added an in-app desktop update experience with update notifications, download progress, restart-to-install controls, and background preparation for managed updates on macOS and Windows. + +## Agent and workspace improvements + +- Improved image handling by checking the selected model's input capabilities before sending and using the account image-to-text fallback only when appropriate. +- TUI follow-ups can now steer an active Goal turn instead of waiting behind it. +- Moved conversation-source scanning into an isolated process, improving resilience when importing compressed Agent histories. + +## Memory improvements + +- User Memory now captures and consolidates user facts, preferences, and directives independently from raw traces and Agent experience, with searchable list, detail, and deletion views. +- Agent responses can show recalled memories with their layer, recall score, source conversation or Skill, and delete/undo controls. +- Hardened the layered Memory lifecycle so compatible Skills are consolidated, outdated downstream knowledge follows changes in its source evidence, and Skills discovered from connected Agents can be recalled across Agents. + +## Desktop and setup reliability + +- BYOK setup now surfaces save failures, disables actions while saving, supports retries, and reuses existing endpoint identities to avoid duplicates after API-key re-entry. +- Existing installations with legacy model configuration are repaired during upgrade, while Agent Gateway startup failures no longer force the desktop app to exit and are retried in the background. +- Windows configuration repair now resolves ACL tools from verified system locations, including installations under paths containing spaces. +- Kept China phone-login and International email-login sessions isolated so credentials cannot cross desktop editions. +- Refined the Settings token-usage layout so the complete usage breakdown remains ahead of the invitation card. diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index f82e33b82..a5342a146 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -214,9 +214,13 @@ export const ByokTokenUsageSummarySchema = z.object({ }); export type ByokTokenUsageSummary = z.infer; +export const AgentGatewayStartupIssueSchema = z.enum(["model_config_invalid"]); +export type AgentGatewayStartupIssue = z.infer; + export const AgentGatewayRuntimeConfigSchema = z.object({ baseUrl: z.string().url(), - bootstrapSecret: z.string().min(1).optional() + bootstrapSecret: z.string().min(1).optional(), + startupIssue: AgentGatewayStartupIssueSchema.optional() }); export type AgentGatewayRuntimeConfig = z.infer; diff --git a/App/backend/local-api-contracts/src/memory-runtime.ts b/App/backend/local-api-contracts/src/memory-runtime.ts index 8a5450625..6ca34668d 100644 --- a/App/backend/local-api-contracts/src/memory-runtime.ts +++ b/App/backend/local-api-contracts/src/memory-runtime.ts @@ -10,12 +10,14 @@ export const CursorSchema = z.string(); export type Cursor = z.infer; /** Schema for memory kind. */ -export const MemoryKindSchema = z.enum(["trace", "span", "policy", "world_model", "skill"]); +export const MemoryKindSchema = z.enum(["user_memory", "trace", "span", "policy", "world_model", "skill"]); export type MemoryKind = z.infer; /** Schema for memory layer. */ export const MemoryLayerSchema = z.enum(["L1", "L2", "L3", "Skill"]); export type MemoryLayer = z.infer; +export const RecallMemoryLayerSchema = z.enum(["UserMemory", "L1", "L2", "L3", "Skill"]); +export type RecallMemoryLayer = z.infer; /** Schema for memory status. */ export const MemoryStatusSchema = z.enum(["activated", "resolving", "archived", "deleted"]); @@ -29,6 +31,7 @@ export type JobStatus = z.infer; export const JobTypeSchema = z.enum([ "episode_idle_close", "trace_summary", + "user_memory_embedding", "import_summary", "reflection", "embedding", @@ -49,7 +52,7 @@ export const InjectedContextSectionSchema = z.object({ id: NonEmptyStringSchema, title: NonEmptyStringSchema, kind: MemoryKindSchema, - memoryLayer: MemoryLayerSchema, + memoryLayer: RecallMemoryLayerSchema, memoryIds: z.array(NonEmptyStringSchema), content: z.string(), tokenEstimate: z.number().int().nonnegative().optional() @@ -67,17 +70,45 @@ export type InjectedContext = z.infer; export const RecallHitSchema = z.object({ id: NonEmptyStringSchema, kind: MemoryKindSchema, - memoryLayer: MemoryLayerSchema, + memoryLayer: RecallMemoryLayerSchema, status: MemoryStatusSchema, title: z.string().optional(), snippet: z.string(), score: z.number(), tags: z.array(z.string()), + createdAt: IsoTimeSchema.optional(), updatedAt: IsoTimeSchema.optional(), - source: z.enum(["search", "episode", "rule", "skill"]) + source: z.enum(["search", "episode", "rule", "skill"]), + sourceTurnId: z.string().optional(), + memberMemoryIds: z.array(NonEmptyStringSchema).optional(), + retrievalRoutes: z.array(z.enum(["user_memory", "l1", "agent_memory"])).optional(), + sourceAgentId: z.string().optional(), + sourceSkillId: z.string().optional(), + sourceSkillVersion: z.string().optional(), + readOnly: z.boolean().optional(), + members: z.array(z.object({ + id: NonEmptyStringSchema, + kind: MemoryKindSchema, + memoryLayer: RecallMemoryLayerSchema, + status: z.union([MemoryStatusSchema, z.enum(["active", "archived", "deleted"])]), + content: z.string(), + createdAt: IsoTimeSchema, + updatedAt: IsoTimeSchema, + retrievalRoute: z.enum(["user_memory", "l1", "agent_memory"]) + })).optional() }); export type RecallHit = z.infer; +export const RecallEvidenceOutputSchema = z.object({ + recallEventId: NonEmptyStringSchema, + queryId: NonEmptyStringSchema, + query: z.string(), + hits: z.array(RecallHitSchema), + createdAt: IsoTimeSchema, + serverTime: IsoTimeSchema +}); +export type RecallEvidenceOutput = z.infer; + /** Schema for memory metrics. */ export const MemoryMetricsSchema = z.object({ value: z.number().optional(), @@ -116,7 +147,7 @@ export type MemoryProcessingRecord = z.infer; @@ -334,6 +369,8 @@ export const CompleteTurnOutputSchema = z.object({ sessionId: NonEmptyStringSchema, episodeId: NonEmptyStringSchema, rawTurnId: NonEmptyStringSchema, + userMemoryId: z.string().optional(), + userMemoryIds: z.array(NonEmptyStringSchema).optional(), l1MemoryId: z.string(), l1MemoryIds: z.array(NonEmptyStringSchema), closedEpisodeIds: z.array(NonEmptyStringSchema), @@ -388,7 +425,12 @@ export const AddMemoryInputSchema = RuntimeRequestFieldsSchema.extend({ sessionId: z.string().optional(), turnId: z.string().optional(), createdAt: IsoTimeSchema.optional(), - deferProcessing: z.boolean().optional() + deferProcessing: z.boolean().optional(), + sourceAgentId: z.string().optional(), + sourceSkillId: z.string().optional(), + sourceSkillPath: z.string().optional(), + sourceSkillVersion: z.string().optional(), + sourceContentHash: z.string().optional() }); export type AddMemoryInput = z.infer; @@ -547,7 +589,7 @@ export type RetryMemoryProcessingOutput = z.infer; export const PanelOverviewOutputSchema = z.object({ counts: z.object({ memories: z.number().int().nonnegative(), + userMemories: z.number().int().nonnegative().default(0), skills: z.number().int().nonnegative(), experiences: z.number().int().nonnegative(), worldModels: z.number().int().nonnegative() diff --git a/App/backend/package.json b/App/backend/package.json index 41bba574b..d447e6c9d 100644 --- a/App/backend/package.json +++ b/App/backend/package.json @@ -25,6 +25,7 @@ "@modelcontextprotocol/sdk": "^1.29.0", "dotenv": "^16.6.1", "fastify": "^5.8.5", + "fzstd": "^0.1.1", "sqlite-vec": "0.1.9", "yaml": "^2.9.0", "zod": "^4.4.3" diff --git a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts index 401c66e82..f4165830e 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/agent-runtime/memory.ts @@ -15,7 +15,23 @@ const MemoryParamsSchema = z.object({ id: z.string().min(1) }); +const RecallParamsSchema = z.object({ + queryId: z.string().min(1) +}); + export function registerMemoryRoutes(app: FastifyInstance, deps: AgentRuntimeRouteDeps): void { + app.get( + "/api/v1/memory/recalls/:queryId", + { preHandler: deps.authenticateRuntimeToken }, + withErrorEnvelope(async (request, reply) => { + const params = RecallParamsSchema.parse(request.params); + return reply.send(await deps.services.memoryDetail.recallEvidence( + params.queryId, + runtimeContextFromRequest(request, deps.timeZone) + )); + }) + ); + app.post( "/api/v1/memory/add", { preHandler: deps.authenticateRuntimeToken }, diff --git a/App/backend/src/adapters/inbound/local-api/routes/agent-sources.ts b/App/backend/src/adapters/inbound/local-api/routes/agent-sources.ts index c3f4c8c79..781e2342c 100644 --- a/App/backend/src/adapters/inbound/local-api/routes/agent-sources.ts +++ b/App/backend/src/adapters/inbound/local-api/routes/agent-sources.ts @@ -15,8 +15,9 @@ import { OkResponseSchema } from "@memmy/local-api-contracts"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import { fork, type ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; -import { Worker } from "node:worker_threads"; +import { fileURLToPath } from "node:url"; import type { PermissionManager } from "../../../../permission/index.js"; import { withErrorEnvelope } from "../../../../services/error-envelope.js"; import type { AgentSourceAutoInjectService } from "../../../../services/agent-source-auto-inject-service.js"; @@ -28,9 +29,9 @@ import { import type { ProgressBus } from "../../../../services/progress-bus.js"; import { type AgentSourceScanJobState, - type AgentSourceScanWorkerCommand, - type AgentSourceScanWorkerData, - type AgentSourceScanWorkerMessage, + type AgentSourceScanProcessCommand, + type AgentSourceScanProcessData, + type AgentSourceScanProcessMessage, isScanResumeStateReference, type PipelineProgress, progressForResume, @@ -46,7 +47,7 @@ export interface RegisterAgentSourceRoutesOptions { progressBus: ProgressBus; permissionManager: Pick; authenticateRuntimeToken: (request: FastifyRequest, reply: FastifyReply) => Promise; - scanWorker?: { + scanProcess?: { databasePath: string; }; } @@ -55,7 +56,7 @@ export interface RegisterAgentSourceRoutesOptions { export function registerAgentSourceRoutes(app: FastifyInstance, options: RegisterAgentSourceRoutesOptions): void { type ActiveScanJob = Omit & { resume: RouteScanResumeState | null; - worker?: Worker; + process?: ChildProcess; }; let activeScanJob: ActiveScanJob | null = null; @@ -66,7 +67,7 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe lastProgress: PipelineProgress & { jobId: string }; resume: RouteScanResumeState | null; }; - const restoredScanJob = toPausedScanJob(readLatestPersistedScanResume(options.scanWorker?.databasePath)); + const restoredScanJob = toPausedScanJob(readLatestPersistedScanResume(options.scanProcess?.databasePath)); let pausedScanJob: PausedScanJob | null = restoredScanJob; let lastScanProgress: (PipelineProgress & { jobId: string }) | null = restoredScanJob?.lastProgress ?? null; let lastScanCompletion: { @@ -85,7 +86,7 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe activeScanJob = null; abortScanJob(closingJob); pausedScanJob = null; - await closingJob.worker?.terminate().catch(() => undefined); + closingJob.process?.kill(); }); app.get("/api/agent-sources", { preHandler: options.authenticateRuntimeToken }, async (_request, reply) => { @@ -324,7 +325,7 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe function startScanJob(scanJob: ActiveScanJob): void { if (scanJob.controller.signal.aborted) { - if (!options.scanWorker) { + if (!options.scanProcess) { startInlineScanJob(scanJob); } return; @@ -334,8 +335,8 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe return; } - if (options.scanWorker) { - startWorkerScanJob(scanJob); + if (options.scanProcess) { + startProcessScanJob(scanJob); return; } @@ -344,7 +345,7 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe function startInlineScanJob(scanJob: ActiveScanJob): void { if (scanJob.resume && isScanResumeStateReference(scanJob.resume)) { - handleWorkerFailure(scanJob.jobId, new Error("SQLite-backed scan resume requires the scan worker")); + handleProcessFailure(scanJob.jobId, new Error("SQLite-backed scan resume requires the scan process")); return; } @@ -383,13 +384,13 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe }); } - function startWorkerScanJob(scanJob: ActiveScanJob): void { - if (!options.scanWorker) { + function startProcessScanJob(scanJob: ActiveScanJob): void { + if (!options.scanProcess) { return; } - const workerData: AgentSourceScanWorkerData = { - databasePath: options.scanWorker.databasePath, + const processData: AgentSourceScanProcessData = { + databasePath: options.scanProcess.databasePath, job: { jobId: scanJob.jobId, sourceId: scanJob.sourceId, @@ -398,31 +399,41 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe resume: scanJob.resume } }; - let worker: Worker; + let child: ChildProcess; try { - worker = new Worker(resolveAgentSourceScanWorkerUrl(), { workerData }); + child = fork(fileURLToPath(resolveAgentSourceScanProcessUrl()), [], { + env: createScanProcessEnvironment(process.env) + }); } catch (error) { - handleWorkerFailure(scanJob.jobId, error instanceof Error ? error : new Error("Agent source scan worker failed to start")); + handleProcessFailure(scanJob.jobId, error instanceof Error ? error : new Error("Agent source scan process failed to start")); return; } - scanJob.worker = worker; + scanJob.process = child; - worker.on("message", (message: AgentSourceScanWorkerMessage) => { - handleWorkerMessage(scanJob.jobId, message); + child.on("message", (message) => { + handleProcessMessage(scanJob.jobId, message as AgentSourceScanProcessMessage); }); - worker.on("error", (error) => { - handleWorkerFailure(scanJob.jobId, error instanceof Error ? error : new Error("Agent source scan worker failed")); + child.on("error", (error) => { + handleProcessFailure(scanJob.jobId, error instanceof Error ? error : new Error("Agent source scan process failed")); }); - worker.on("exit", (code) => { + child.on("exit", (code, signal) => { if (code !== 0 && activeScanJob?.jobId === scanJob.jobId && !scanJob.controller.signal.aborted) { - handleWorkerFailure(scanJob.jobId, new Error(`Agent source scan worker exited with code ${code}`)); + handleProcessFailure( + scanJob.jobId, + new Error(`Agent source scan process exited with ${signal ? `signal ${signal}` : `code ${code}`}`) + ); return; } finishActiveScanJob(scanJob.jobId); }); + + const startCommand: AgentSourceScanProcessCommand = { type: "start", data: processData }; + child.send(startCommand, (error) => { + if (error) handleProcessFailure(scanJob.jobId, error); + }); } - function handleWorkerMessage(jobId: string, message: AgentSourceScanWorkerMessage): void { + function handleProcessMessage(jobId: string, message: AgentSourceScanProcessMessage): void { if (activeScanJob?.jobId !== jobId && pausedScanJob?.jobId === jobId && message.type === "resume") { pausedScanJob.resume = message.resume; return; @@ -457,10 +468,10 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe return; } - handleWorkerFailure(jobId, new Error(message.message)); + handleProcessFailure(jobId, new Error(message.message)); } - function handleWorkerFailure(jobId: string, error: Error): void { + function handleProcessFailure(jobId: string, error: Error): void { if (activeScanJob?.jobId !== jobId) { return; } @@ -521,11 +532,11 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe function abortScanJob(job: ActiveScanJob): void { job.controller.abort(); - const command: AgentSourceScanWorkerCommand = { type: "abort" }; + const command: AgentSourceScanProcessCommand = { type: "abort" }; try { - job.worker?.postMessage(command); + if (job.process?.connected) job.process.send(command); } catch { - // The worker may already have exited while the HTTP stop/cancel request is being handled. + // The process may already have exited while the HTTP stop/cancel request is being handled. } } @@ -534,7 +545,7 @@ export function registerAgentSourceRoutes(app: FastifyInstance, options: Registe return; } - deletePersistedScanResume(options.scanWorker?.databasePath, resume.jobId); + deletePersistedScanResume(options.scanProcess?.databasePath, resume.jobId); } } @@ -587,7 +598,13 @@ function recentScanCompletion(completion: T | return Date.now() - Date.parse(completion.completedAt) <= 60_000 ? completion : null; } -function resolveAgentSourceScanWorkerUrl(): URL { +function resolveAgentSourceScanProcessUrl(): URL { const extension = import.meta.url.endsWith(".ts") ? "ts" : "js"; - return new URL(`../../../../services/agent-source-scan-worker.${extension}`, import.meta.url); + return new URL(`../../../../services/agent-source-scan-process.${extension}`, import.meta.url); +} + +function createScanProcessEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + return process.versions.electron + ? { ...env, ELECTRON_RUN_AS_NODE: "1" } + : env; } diff --git a/App/backend/src/adapters/inbound/local-api/server.ts b/App/backend/src/adapters/inbound/local-api/server.ts index 123637f95..b9606a75d 100644 --- a/App/backend/src/adapters/inbound/local-api/server.ts +++ b/App/backend/src/adapters/inbound/local-api/server.ts @@ -37,7 +37,7 @@ export interface CreateLocalApiServerOptions { composioMcpToken: string; heartbeatIntervalMs?: number; allowedOrigins?: readonly string[]; - scanWorker?: { + scanProcess?: { databasePath: string; }; } @@ -88,7 +88,7 @@ export function createLocalApiServer(options: CreateLocalApiServerOptions): Fast progressBus: options.services.progressBus, permissionManager: options.permissionManager, authenticateRuntimeToken, - scanWorker: options.scanWorker + scanProcess: options.scanProcess }); registerAppConfigRoutes(app, { appConfig: options.services.appConfig, diff --git a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts index cd6bae3af..7495a5b93 100644 --- a/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts +++ b/App/backend/src/adapters/inbound/local-api/tests/agent-runtime-routes.test.ts @@ -30,6 +30,7 @@ describe("agent runtime local api routes", () => { { method: "POST", url: "/api/v1/memory/memory-1/processing/retry", payload: {} }, { method: "GET", url: "/api/v1/memory/memory-1" }, { method: "DELETE", url: "/api/v1/memory/memory-1" }, + { method: "GET", url: "/api/v1/memory/recalls/turn-1" }, { method: "GET", url: "/api/v1/memory/logs?tools=memory_add,memory_search&limit=20&offset=0" }, { method: "GET", url: "/api/v1/panel/overview" }, { method: "GET", url: "/api/v1/panel/analysis" }, @@ -405,7 +406,17 @@ function createServer(overrides: Record = {}, timeZone?: string memoryDetail: { async add() { return addMemoryOutput(); }, async getById() { return getMemoryOutput(); }, - async delete() { return deleteMemoryOutput(); } + async delete() { return deleteMemoryOutput(); }, + async recallEvidence() { + return { + recallEventId: "recall-1", + queryId: "turn-1", + query: "question", + hits: [], + createdAt: now(), + serverTime: now() + }; + } }, panel: { async overview() { return panelOverviewOutput(); }, @@ -574,7 +585,7 @@ function deleteMemoryOutput() { function panelOverviewOutput() { return { - counts: { memories: 0, skills: 0, experiences: 0, worldModels: 0 }, + counts: { memories: 0, userMemories: 0, skills: 0, experiences: 0, worldModels: 0 }, dailyActivity: panelDays(), sourceDistribution: [] }; diff --git a/App/backend/src/adapters/outbound/agent-source/deepseek-harness/session-reader.ts b/App/backend/src/adapters/outbound/agent-source/deepseek-harness/session-reader.ts index f480c8a0e..63efdcba2 100644 --- a/App/backend/src/adapters/outbound/agent-source/deepseek-harness/session-reader.ts +++ b/App/backend/src/adapters/outbound/agent-source/deepseek-harness/session-reader.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import { basename } from "node:path"; -import { zstdDecompressSync } from "node:zlib"; +import { decompress, ZstdErrorCode } from "fzstd"; const ZSTD_FRAME_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]); @@ -26,18 +26,25 @@ export async function readDeepseekHarnessSession( } function decompressFrames(bytes: Buffer): string { - const offsets: number[] = []; - for (let offset = 0; offset <= bytes.length - ZSTD_FRAME_MAGIC.length; offset += 1) { - if (bytes.subarray(offset, offset + ZSTD_FRAME_MAGIC.length).equals(ZSTD_FRAME_MAGIC)) { - offsets.push(offset); - } - } - if (offsets.length === 0 || offsets[0] !== 0) { + if (!bytes.subarray(0, ZSTD_FRAME_MAGIC.length).equals(ZSTD_FRAME_MAGIC)) { throw new Error("DeepSeek Harness session has no Zstandard frame header"); } - return offsets.map((offset, index) => - zstdDecompressSync(bytes.subarray(offset, offsets[index + 1] ?? bytes.length)).toString("utf8") - ).join(""); + + try { + return Buffer.from(decompress(bytes)).toString("utf8"); + } catch (error) { + if (!isUnexpectedEndOfFile(error)) throw error; + const trailingFrameOffset = bytes.lastIndexOf(ZSTD_FRAME_MAGIC); + if (trailingFrameOffset <= 0) throw error; + return Buffer.from(decompress(bytes.subarray(0, trailingFrameOffset))).toString("utf8"); + } +} + +function isUnexpectedEndOfFile(error: unknown): boolean { + return typeof error === "object" + && error !== null + && "code" in error + && error.code === ZstdErrorCode.UnexpectedEOF; } function parseSessionRows( diff --git a/App/backend/src/adapters/outbound/agent-source/deepseek-harness/tests/adapter.test.ts b/App/backend/src/adapters/outbound/agent-source/deepseek-harness/tests/adapter.test.ts index 4ad421812..a4c8c75ae 100644 --- a/App/backend/src/adapters/outbound/agent-source/deepseek-harness/tests/adapter.test.ts +++ b/App/backend/src/adapters/outbound/agent-source/deepseek-harness/tests/adapter.test.ts @@ -1,10 +1,12 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { zstdCompressSync } from "node:zlib"; import { afterEach, describe, expect, it } from "vitest"; import { createDeepseekHarnessSourceAdapter, readDeepseekHarnessSession } from "../index.js"; +const openAiKeyFixture = ["sk-", "abcdefghijklmnopqrstuvwxyz", "ABCDEFGHIJKLMN"].join(""); +const secretMessageFixture = `Remember OPENAI_API_KEY=${openAiKeyFixture}`; let tempDir: string | undefined; afterEach(() => { @@ -25,7 +27,7 @@ describe("DeepSeek Harness source adapter", () => { messageId: "user-1", conversationId: "dsh-session-1", role: "user", - content: "Remember OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN", + content: secretMessageFixture, workspacePath: fixture.workspacePath }), expect.objectContaining({ @@ -38,6 +40,26 @@ describe("DeepSeek Harness source adapter", () => { ]); }); + it("ignores an incomplete trailing Zstandard frame", async () => { + const fixture = createFixture("session.jsonl.zstd"); + const incompleteFrame = zstdCompressSync(Buffer.from(JSON.stringify({ + type: "user/message", + seq: 4, + time: 1780404003000, + data: { + id: "incomplete-user", + role: "user", + source: { kind: "user" }, + content: [{ type: "text", text: "This frame is still being written" }] + } + }) + "\n")); + appendFileSync(fixture.sessionFilePath, incompleteFrame.subarray(0, incompleteFrame.length - 8)); + + const messages = await readDeepseekHarnessSession(fixture.sessionFilePath); + + expect(messages.map((message) => message.messageId)).toEqual(["user-1", "assistant-1"]); + }); + it("discovers sessions and redacts secrets during source scans", async () => { const fixture = createFixture("session.jsonl"); const adapter = createDeepseekHarnessSourceAdapter({ rootDirectory: fixture.rootDirectory }); @@ -105,7 +127,7 @@ function createFixture(fileName: "session.jsonl" | "session.jsonl.zstd"): { id: "user-1", role: "user", source: { kind: "user" }, - content: [{ type: "text", text: "Remember OPENAI_API_KEY=sk-abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN" }] + content: [{ type: "text", text: secretMessageFixture }] } }, { diff --git a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts index a76132981..c718bdb6a 100644 --- a/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/http-memory-client.ts @@ -12,6 +12,7 @@ import { MemoryHealthSnapshotSchema, MemoryProcessingStatusOutputSchema, MemoryReloadConfigOutputSchema, + RecallEvidenceOutputSchema, PanelAnalysisOutputSchema, PanelItemsOutputSchema, PanelOverviewOutputSchema, @@ -176,6 +177,13 @@ export function createHttpMemoryClient( }); }, + async recallEvidence(queryId, context) { + return request("GET", "recallEvidence", RecallEvidenceOutputSchema, { + params: { queryId }, + context + }); + }, + async enqueueImportSummaries(memoryIds) { return request("POST", "enqueueImportSummaries", EnqueueImportSummariesOutputSchema, { body: memoryIds ? { memoryIds } : {} diff --git a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts index 5bcb246a2..cc5876099 100644 --- a/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts +++ b/App/backend/src/adapters/outbound/memory-client/memory-layer-endpoints.ts @@ -11,6 +11,7 @@ export const MEMORY_LAYER_PATHS = Object.freeze({ addMemory: "/api/v1/memory/add", getMemory: "/api/v1/memory/:id", deleteMemory: "/api/v1/memory/:id", + recallEvidence: "/api/v1/memory/recalls/:queryId", runWorker: "/api/v1/worker/run", enqueueImportSummaries: "/api/v1/worker/import-summaries/enqueue", memoryProcessingStatus: "/api/v1/memory/processing/status", diff --git a/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts b/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts index 6c93f9b5a..e03923144 100644 --- a/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts +++ b/App/backend/src/adapters/outbound/memory-client/memos-sqlite-memory-client.ts @@ -30,6 +30,7 @@ import type { PanelTasksInput, PanelTasksOutput, RecallHit, + RecallEvidenceOutput, StartTurnInput, StartTurnOutput, SearchOutput @@ -97,6 +98,21 @@ interface LocalRawTurnRow { created_at: string; } +interface LocalUserMemoryRow { + id: string; + source_turn_id: string; + user_id: string; + memory_types_json: string; + content: string; + source_turn_refs_json: string; + status: "active" | "archived" | "deleted"; + archived_at: string | null; + archive_reason: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + interface LocalEpisodeRow { id: string; session_id: string; @@ -285,6 +301,19 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl }, async deleteMemory(input): Promise { + const userMemory = findWritableUserMemoryRow(sources, input.memoryId); + if (userMemory) { + const deleted = softDeleteUserMemoryRow(userMemory, now()); + return { + ok: true, + id: encodeId(userMemory.source, userMemory.row.id), + kind: "user_memory", + status: "deleted", + changeSeq: deleted.changeSeq, + syncCursor: deleted.syncCursor, + serverTime: deleted.serverTime + }; + } const target = findWritableMemoryRow(sources, input.memoryId); if (!target) { throw new MemoryLayerError("not_found", 404, `memory not found: ${input.memoryId}`); @@ -304,6 +333,10 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl }; }, + async recallEvidence(queryId): Promise { + throw new MemoryLayerError("not_found", 404, `recall event not found: ${queryId}`); + }, + async enqueueImportSummaries() { return readOnlyOperationUnavailable(); }, @@ -327,6 +360,7 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl return { counts: { memories: rows.filter((item) => item.row.memory_layer === "L1").length, + userMemories: 0, skills: rows.filter((item) => item.row.memory_layer === "Skill").length, experiences: rows.filter((item) => item.row.memory_layer === "L2").length, worldModels: rows.filter((item) => item.row.memory_layer === "L3").length @@ -365,8 +399,10 @@ export function createMemosSqliteMemoryClient(options: CreateMemosSqliteMemoryCl async panelItems(input: PanelItemsInput): Promise { const pageSize = 20; - const filtered = listMemoryRows(sources) - .map((row) => ({ item: toListItem(row), sourceAgent: sourceAgentForRow(row) })) + const rows = input.layer === "UserMemory" + ? listUserMemoryRows(sources).map((row) => ({ item: toUserMemoryListItem(row), sourceAgent: undefined })) + : listMemoryRows(sources).map((row) => ({ item: toListItem(row), sourceAgent: sourceAgentForRow(row) })); + const filtered = rows .filter(({ item, sourceAgent }) => itemMatchesPanelInput(item, input, sourceAgent)) .map(({ item }) => item) .sort((a, b) => @@ -479,6 +515,17 @@ function listMemoryRows(sources: readonly MemosSqliteSource[]): MemoryRow[] { })); } +type UserMemoryRow = { source: MemosSqliteSource; row: LocalUserMemoryRow }; + +function listUserMemoryRows(sources: readonly MemosSqliteSource[]): UserMemoryRow[] { + return sources.flatMap((source) => withDb(source, (db) => { + if (!tableExists(db, "user_memories")) return []; + return db.prepare( + "select * from user_memories where deleted_at is null and status != 'deleted'" + ).all().map((row) => ({ source, row: row as unknown as LocalUserMemoryRow })); + })); +} + /** * Reads Memory API log rows from local SQLite data sources. * @@ -868,6 +915,54 @@ function findWritableMemoryRow(sources: readonly MemosSqliteSource[], encodedId: return null; } +function findWritableUserMemoryRow( + sources: readonly MemosSqliteSource[], + encodedId: string +): UserMemoryRow | null { + const decoded = decodeId(encodedId); + const candidates = decoded.sourceId ? sources.filter((source) => source.id === decoded.sourceId) : sources; + for (const source of candidates) { + const row = withDb(source, (db) => { + if (!tableExists(db, "user_memories")) return undefined; + return db.prepare( + "select * from user_memories where id = ? and deleted_at is null and status != 'deleted' limit 1" + ).get(decoded.rawId) as unknown as LocalUserMemoryRow | undefined; + }); + if (row) return { source, row }; + } + return null; +} + +function softDeleteUserMemoryRow(target: UserMemoryRow, serverTime: string): LocalDeleteResult { + return withWritableDb(target.source, (db) => { + db.exec("BEGIN IMMEDIATE"); + try { + if (tableExists(db, "user_memories_fts")) { + db.prepare("delete from user_memories_fts where id = ?").run(target.row.id); + } + const result = db.prepare( + `update user_memories + set memory_types_json = '[]', content = '[DELETED]', source_turn_refs_json = '[]', + status = 'deleted', embedding_json = null, embedding_model = null, + embedding_provider = null, updated_at = ?, deleted_at = ? + where id = ? and deleted_at is null and status != 'deleted'` + ).run(serverTime, serverTime, target.row.id) as { changes?: number | bigint }; + if (Number(result.changes ?? 0) !== 1) { + throw new MemoryLayerError("not_found", 404, `memory not found: ${encodeId(target.source, target.row.id)}`); + } + db.exec("COMMIT"); + return { + changeSeq: 0, + syncCursor: `sqlite-delete:${target.source.id}:0`, + serverTime + }; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } + }); +} + function hardDeleteMemoryRow(target: MemoryRow, serverTime: string): LocalDeleteResult { return withWritableDb(target.source, (db) => { db.exec("BEGIN IMMEDIATE"); @@ -968,6 +1063,31 @@ function toListItem(row: MemoryRow): MemoryListItem { }; } +function toUserMemoryListItem(row: UserMemoryRow): MemoryListItem { + const memoryTypes = readJsonArray(row.row.memory_types_json); + const sourceTurnRefs = readJsonArray(row.row.source_turn_refs_json); + return { + id: encodeId(row.source, row.row.id), + kind: "user_memory", + memoryLayer: "UserMemory", + status: row.row.status === "active" ? "activated" : row.row.status, + title: truncate(firstLine(row.row.content) || row.row.id, 80), + summary: row.row.content, + tags: memoryTypes, + metadata: { + source: row.source.label, + sourceTurnId: row.row.source_turn_id, + sourceTurnRefs, + memoryTypes, + archivedAt: row.row.archived_at, + archiveReason: row.row.archive_reason + }, + createdAt: normalizeIsoTime(row.row.created_at), + updatedAt: normalizeIsoTime(row.row.updated_at), + version: Math.max(1, sourceTurnRefs.length) + }; +} + function spanGoalFromParsed(parsed: ParsedRow): string | undefined { const internalInfo = objectAt(parsed.properties, ["internal_info"]); if (stringValue(internalInfo.memory_kind) !== "span") return undefined; diff --git a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts index f2a15b6d3..80974aa59 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/http-memory-client.test.ts @@ -24,6 +24,7 @@ describe("HttpMemoryClient", () => { "/api/v1/memory/add", "/api/v1/memory/:id", "/api/v1/memory/:id", + "/api/v1/memory/recalls/:queryId", "/api/v1/worker/run", "/api/v1/worker/import-summaries/enqueue", "/api/v1/memory/processing/status", @@ -85,6 +86,7 @@ describe("HttpMemoryClient", () => { await expect(client.addMemory(addMemoryInput())).resolves.toMatchObject({ id: "memory-1" }); await expect(client.getMemory({ memoryId: "memory-1" })).resolves.toMatchObject({ item: { id: "memory-1" } }); await expect(client.deleteMemory({ memoryId: "memory-1", source: "codex" })).resolves.toMatchObject({ status: "deleted" }); + await expect(client.recallEvidence("turn-1")).resolves.toMatchObject({ queryId: "turn-1", hits: [] }); await expect( client.memoryApiLogs({ tools: ["memory_add", "memory_search"], limit: 20, offset: 0 }) ).resolves.toMatchObject({ logs: [] }); @@ -106,6 +108,7 @@ describe("HttpMemoryClient", () => { "POST /api/v1/memory/add", "GET /api/v1/memory/memory-1", "DELETE /api/v1/memory/memory-1", + "GET /api/v1/memory/recalls/turn-1", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", @@ -402,6 +405,16 @@ function fixtureFor(method: string, path: string, body: unknown): unknown { if (method === "POST" && path === "/api/v1/memory/add") return addMemoryOutput(body); if (method === "GET" && path === "/api/v1/memory/memory-1") return getMemoryOutput(); if (method === "DELETE" && path === "/api/v1/memory/memory-1") return deleteMemoryOutput(); + if (method === "GET" && path === "/api/v1/memory/recalls/turn-1") { + return { + recallEventId: "recall-1", + queryId: "turn-1", + query: "remember", + hits: [], + createdAt: now(), + serverTime: now() + }; + } if (method === "GET" && path === "/api/v1/memory/logs") return memoryApiLogsOutput(); if (method === "GET" && path === "/api/v1/panel/overview") return panelOverviewOutput(); if (method === "GET" && path === "/api/v1/panel/analysis") return panelAnalysisOutput(); @@ -551,7 +564,7 @@ function memoryApiLogsOutput() { } function panelOverviewOutput() { - return { counts: { memories: 0, skills: 0, experiences: 0, worldModels: 0 }, dailyActivity: panelDays(), sourceDistribution: [] }; + return { counts: { memories: 0, userMemories: 0, skills: 0, experiences: 0, worldModels: 0 }, dailyActivity: panelDays(), sourceDistribution: [] }; } function panelAnalysisOutput() { diff --git a/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts b/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts index 51e3e964e..e87d3d77b 100644 --- a/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts +++ b/App/backend/src/adapters/outbound/memory-client/tests/memos-sqlite-memory-client.test.ts @@ -49,6 +49,37 @@ describe("createMemosSqliteMemoryClient", () => { }); }); + it("lists and deletes User Memory through the sqlite fallback", async () => { + const dbPath = createMemoryDatabase({ + id: "trace_user_memory_seed", + sessionId: "codex-user-memory", + agentId: "codex", + tagsJson: "[]", + infoJson: "{}", + propertiesJson: JSON.stringify({ internal_info: { memory_layer: "L1" } }) + }); + insertUserMemory(dbPath, "user_memory_sqlite_1", "我最喜欢的水果是苹果"); + const client = createMemosSqliteMemoryClient({ + sources: [{ id: "memmy-memory", label: "memmy", dbPath }], + now: () => NOW + }); + + await expect(client.panelItems({ layer: "UserMemory", q: "苹果", page: 1 })).resolves.toMatchObject({ + total: 1, + items: [{ + id: "memmy-memory::user_memory_sqlite_1", + kind: "user_memory", + memoryLayer: "UserMemory", + tags: ["User Preference"] + }] + }); + await expect(client.deleteMemory({ memoryId: "memmy-memory::user_memory_sqlite_1" })).resolves.toMatchObject({ + kind: "user_memory", + status: "deleted" + }); + await expect(client.panelItems({ layer: "UserMemory", page: 1 })).resolves.toMatchObject({ total: 0, items: [] }); + }); + it("exposes only the span's raw-turn tool-call range in detail metadata", async () => { const dbPath = createMemoryDatabase({ id: "span_sqlite_steps", @@ -700,6 +731,43 @@ function readMemoryRowCount(dbPath: string, memoryId: string): number { } } +function insertUserMemory(dbPath: string, id: string, content: string): void { + const db = new DatabaseSync(dbPath); + try { + db.exec(` + CREATE TABLE user_memories ( + id TEXT PRIMARY KEY, + source_turn_id TEXT NOT NULL, + user_id TEXT NOT NULL, + memory_types_json TEXT NOT NULL, + content TEXT NOT NULL, + normalized_user_text_hash TEXT NOT NULL, + source_turn_refs_json TEXT NOT NULL, + status TEXT NOT NULL, + replaces_memory_id TEXT, + replaced_by_memory_id TEXT, + archived_at TEXT, + archive_reason TEXT, + embedding_json TEXT, + embedding_model TEXT, + embedding_provider TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ) + `); + db.prepare(` + INSERT INTO user_memories ( + id, source_turn_id, user_id, memory_types_json, content, + normalized_user_text_hash, source_turn_refs_json, status, + created_at, updated_at + ) VALUES (?, 'turn-user-memory', 'local-user', '["User Preference"]', ?, 'hash', '["turn-user-memory"]', 'active', ?, ?) + `).run(id, content, NOW, NOW); + } finally { + db.close(); + } +} + function seedApiLogs(dbPath: string): void { const db = new DatabaseSync(dbPath); try { diff --git a/App/backend/src/adapters/outbound/memory-client/types.ts b/App/backend/src/adapters/outbound/memory-client/types.ts index 6eac68d07..adf138b1b 100644 --- a/App/backend/src/adapters/outbound/memory-client/types.ts +++ b/App/backend/src/adapters/outbound/memory-client/types.ts @@ -17,6 +17,7 @@ import type { MemoryProcessingStatusOutput, MemoryReloadConfigInput, MemoryReloadConfigOutput, + RecallEvidenceOutput, PanelAnalysisOutput, PanelItemsInput, PanelItemsOutput, @@ -52,6 +53,7 @@ export interface MemoryClient { addMemory(input: AddMemoryInput, context?: MemoryRequestContext): Promise; getMemory(input: { memoryId: string }, context?: MemoryRequestContext): Promise; deleteMemory(input: DeleteMemoryInput & { memoryId: string }, context?: MemoryRequestContext): Promise; + recallEvidence(queryId: string, context?: MemoryRequestContext): Promise; enqueueImportSummaries(memoryIds?: string[]): Promise; getMemoryProcessingStatus(memoryIds: string[]): Promise; diff --git a/App/backend/src/analytics/agent-source-analytics.ts b/App/backend/src/analytics/agent-source-analytics.ts index e584cf965..e8a48c17e 100644 --- a/App/backend/src/analytics/agent-source-analytics.ts +++ b/App/backend/src/analytics/agent-source-analytics.ts @@ -136,6 +136,7 @@ export function buildAgentSourceConflictParams(input: AgentSourceConflictInput): export function createAgentSourceLifecycleAnalytics(options: { getClientId?: () => string | null | undefined; + getInstallationId?: () => string | null | undefined; getUserId?: () => string | null | undefined; getUserMode?: () => string | null | undefined; appEnv?: AnalyticsAppEnv | null; @@ -147,6 +148,7 @@ export function createAgentSourceLifecycleAnalytics(options: { const queued = createQueuedAnalytics({ source: AGENT_SOURCE_ANALYTICS_SOURCE, getClientId: options.getClientId ?? (() => readAnalyticsClientId()), + getInstallationId: options.getInstallationId, getUserId: options.getUserId, getUserMode: options.getUserMode, appEnv: options.appEnv, diff --git a/App/backend/src/analytics/analytics-transport.ts b/App/backend/src/analytics/analytics-transport.ts index b9827eb31..115c00387 100644 --- a/App/backend/src/analytics/analytics-transport.ts +++ b/App/backend/src/analytics/analytics-transport.ts @@ -1,23 +1,32 @@ -import { existsSync as nodeExistsSync, readFileSync as nodeReadFileSyncFs } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { + existsSync as nodeExistsSync, + mkdirSync as nodeMkdirSync, + readFileSync as nodeReadFileSyncFs, + writeFileSync as nodeWriteFileSync, +} from "node:fs"; import { homedir as nodeHomedir } from "node:os"; import { join as nodeJoin } from "node:path"; +import { MEMMY_VERSION } from "../project-version.js"; export type AnalyticsAppEnv = "dev" | "prod"; export type AnalyticsAppEdition = "cn" | "intl"; -export type AnalyticsUserMode = "account" | "byok"; +export type AnalyticsUserMode = "account" | "account_byok" | "byok"; export type AnalyticsParams = Record; type AnalyticsEventInput = { eventName: string; params?: AnalyticsParams; eventTimeMillis?: number; + source?: string; }; type PostAnalyticsEventsInput = { events: AnalyticsEventInput[]; + installationId?: string | null; clientId?: string | null; userId?: string | null; - /** account | byok; unset/unknown omitted from params. */ + /** account | account_byok | byok; unset/unknown omitted. */ userMode?: string | null; appEnv?: AnalyticsAppEnv | null; appEdition?: AnalyticsAppEdition | null; @@ -36,6 +45,7 @@ export type QueuedAnalytics = { const DEFAULT_ENGAGEMENT_TIME_MSEC = 100; const ANALYTICS_PATH = "/api/analytics/events"; const CLIENT_ID_FILENAME = "analytics-client-id"; +const INSTALLATION_ID_FILENAME = "installation-id"; export function resolveAnalyticsBaseUrl(env: NodeJS.ProcessEnv = process.env): string | null { const raw = env.MEMMY_CLOUD_SERVICE?.trim(); @@ -128,6 +138,30 @@ export function readAnalyticsClientId(options: { } } +export function getOrCreateInstallationId(options: { + env?: NodeJS.ProcessEnv; + homeDir?: string; +} = {}): string { + const homeDir = options.homeDir ?? nodeHomedir(); + const memmyHome = (options.env?.MEMMY_HOME?.trim() || nodeJoin(homeDir, ".memmy")).replace( + /^~(?=$|[/\\])/, + homeDir, + ); + const filePath = nodeJoin(memmyHome, INSTALLATION_ID_FILENAME); + try { + if (nodeExistsSync(filePath)) { + const existing = nodeReadFileSyncFs(filePath, "utf8").trim(); + if (existing) return existing; + } + } catch { + // Recreate an unreadable or empty identifier below. + } + const installationId = randomUUID(); + nodeMkdirSync(memmyHome, { recursive: true }); + nodeWriteFileSync(filePath, `${installationId}\n`, "utf8"); + return installationId; +} + export function errorCodeFromUnknown(error: unknown): string { if (error && typeof error === "object" && "status" in error) { const status = (error as { status?: unknown }).status; @@ -151,14 +185,16 @@ export function normalizeAnalyticsUserId(userId: string | null | undefined): str export function resolveAnalyticsUserMode( mode: string | null | undefined, ): AnalyticsUserMode | null { - return mode === "account" || mode === "byok" ? mode : null; + return mode === "account" || mode === "account_byok" || mode === "byok" ? mode : null; } export function resolveAnalyticsUserModeParams( mode: string | null | undefined, + userId?: string | null, ): AnalyticsParams { const resolved = resolveAnalyticsUserMode(mode); - return resolved ? { user_mode: resolved } : {}; + if (!userId) return { user_mode: "byok" }; + return { user_mode: resolved === "byok" || resolved === "account_byok" ? "account_byok" : "account" }; } function toTimestampMicros(eventTimeMillis: number): number { @@ -167,14 +203,15 @@ function toTimestampMicros(eventTimeMillis: number): number { export function postAnalyticsEvents(input: PostAnalyticsEventsInput): Promise { const clientId = input.clientId?.trim() || null; + const installationId = input.installationId?.trim() || getOrCreateInstallationId({ env: input.env }); const userId = normalizeAnalyticsUserId(input.userId); - const userModeParams = resolveAnalyticsUserModeParams(input.userMode); + const userModeParams = resolveAnalyticsUserModeParams(input.userMode, userId); const env = input.env ?? process.env; const resolvedBase = input.baseUrl !== undefined ? input.baseUrl : resolveAnalyticsBaseUrl(env); const baseUrl = resolvedBase?.replace(/\/+$/, "") || null; const events = Array.isArray(input.events) ? input.events : []; - if (!baseUrl || !clientId || events.length === 0) { + if (!baseUrl || !installationId || events.length === 0) { return Promise.resolve(); } @@ -186,18 +223,21 @@ export function postAnalyticsEvents(input: PostAnalyticsEventsInput): Promise { const eventTimeMillis = event.eventTimeMillis ?? Date.now(); return { eventName: event.eventName, params: compactAnalyticsParams({ engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_MSEC, + source: event.source ?? "memmy-backend", ...userModeParams, ...(event.params ?? {}), ...(userId ? { user_id: userId } : {}), ...envParams, + app_version: MEMMY_VERSION, timestamp_micros: toTimestampMicros(eventTimeMillis), }), }; @@ -222,6 +262,7 @@ export function trackAnalyticsEvent(input: { eventName: string; params?: AnalyticsParams; clientId?: string | null; + installationId?: string | null; userId?: string | null; userMode?: string | null; appEnv?: AnalyticsAppEnv | null; @@ -240,6 +281,7 @@ export function trackAnalyticsEvent(input: { }, ], clientId: input.clientId, + installationId: input.installationId, userId: input.userId, userMode: input.userMode, appEnv: input.appEnv, @@ -252,6 +294,7 @@ export function trackAnalyticsEvent(input: { export function createQueuedAnalytics(options: { getClientId?: () => string | null | undefined; + getInstallationId?: () => string | null | undefined; getUserId?: () => string | null | undefined; getUserMode?: () => string | null | undefined; source?: string; @@ -264,6 +307,7 @@ export function createQueuedAnalytics(options: { } = {}): QueuedAnalytics { const source = options.source; const getClientId = options.getClientId ?? (() => readAnalyticsClientId({ env: options.env })); + const getInstallationId = options.getInstallationId ?? (() => getOrCreateInstallationId({ env: options.env })); const getUserId = options.getUserId ?? (() => null); const getUserMode = options.getUserMode ?? (() => null); let pending: AnalyticsEventInput[] = []; @@ -276,7 +320,8 @@ export function createQueuedAnalytics(options: { lastEventTimeMillis = eventTimeMillis; pending.push({ eventName, - params: source ? { source, ...params } : { ...params }, + params: { ...params }, + source, eventTimeMillis, }); }; @@ -290,6 +335,7 @@ export function createQueuedAnalytics(options: { postAnalyticsEvents({ events: batch, clientId: getClientId(), + installationId: getInstallationId(), userId: getUserId(), userMode: getUserMode(), appEnv: options.appEnv, diff --git a/App/backend/src/analytics/memory-add-analytics.ts b/App/backend/src/analytics/memory-add-analytics.ts index 5e1c9828d..5cd6a62be 100644 --- a/App/backend/src/analytics/memory-add-analytics.ts +++ b/App/backend/src/analytics/memory-add-analytics.ts @@ -69,6 +69,7 @@ export function buildMemoryDesktopScanAddParams(input: MemoryDesktopScanAddBaseI export function createMemoryDesktopAddAnalytics(options: { getClientId?: () => string | null | undefined; + getInstallationId?: () => string | null | undefined; getUserId?: () => string | null | undefined; getUserMode?: () => string | null | undefined; appEnv?: AnalyticsAppEnv | null; @@ -80,6 +81,7 @@ export function createMemoryDesktopAddAnalytics(options: { const queued = createQueuedAnalytics({ source: MEMORY_ADD_ANALYTICS_SOURCE, getClientId: options.getClientId ?? (() => readAnalyticsClientId()), + getInstallationId: options.getInstallationId, getUserId: options.getUserId, getUserMode: options.getUserMode, appEnv: options.appEnv, diff --git a/App/backend/src/analytics/tests/memory-add-analytics.test.ts b/App/backend/src/analytics/tests/memory-add-analytics.test.ts index a3ca0872a..e4505c826 100644 --- a/App/backend/src/analytics/tests/memory-add-analytics.test.ts +++ b/App/backend/src/analytics/tests/memory-add-analytics.test.ts @@ -31,6 +31,7 @@ describe("memory-add-analytics", () => { const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); const analytics = createMemoryDesktopAddAnalytics({ getClientId: () => "client-1", + getInstallationId: () => "install-1", getUserId: () => "user-1", getUserMode: () => "account", appEnv: "dev", @@ -66,6 +67,7 @@ describe("memory-add-analytics", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ clientId: "client-1", userId: "user-1", installationId: "install-1" }); const names = body.events.map((event: { eventName: string }) => event.eventName); expect(names).toEqual([ MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addStarted, @@ -73,10 +75,12 @@ describe("memory-add-analytics", () => { MEMORY_DESKTOP_ADD_ANALYTICS_EVENTS.addFailed, ]); expect(body.events[0]?.params).toMatchObject({ + user_id: "user-1", + user_mode: "account", + source: "memmy-agent", mode: MEMORY_DESKTOP_ADD_MODE_AGENT_SOURCE_SCAN, scan_mode: "full", adapter_id: "agent-source:cursor", - source: "memmy-agent", }); expect(body.events[1]?.params).toMatchObject({ success: true, diff --git a/App/backend/src/analytics/tests/tool-connection-analytics.test.ts b/App/backend/src/analytics/tests/tool-connection-analytics.test.ts index c4d201a88..c9e490aa4 100644 --- a/App/backend/src/analytics/tests/tool-connection-analytics.test.ts +++ b/App/backend/src/analytics/tests/tool-connection-analytics.test.ts @@ -54,6 +54,7 @@ describe("tool-connection-analytics", () => { const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); const analytics = createToolConnectionAnalytics({ getClientId: () => "client-1", + getInstallationId: () => "install-1", getUserId: () => "user-1", getUserMode: () => "account", appEnv: "dev", @@ -84,19 +85,28 @@ describe("tool-connection-analytics", () => { expect(fetchImpl).toHaveBeenCalledTimes(1); const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); - expect(body.clientId).toBe("client-1"); - expect(body.userId).toBe("user-1"); + expect(body).toMatchObject({ + clientId: "client-1", + userId: "user-1", + installationId: "install-1", + }); expect(body.events).toHaveLength(2); expect(body.events.map((event: { eventName: string }) => event.eventName)).toEqual([ TOOL_CONNECTION_ANALYTICS_EVENTS.connection, TOOL_CONNECTION_ANALYTICS_EVENTS.connection, ]); expect(body.events[0]?.params).toMatchObject({ + engagement_time_msec: 100, + user_id: "user-1", + user_mode: "account", source: "memmy-backend", + app_env: "dev", + app_edition: "cn", surface: "channel", toolkit: "wechat", event: "connected", occurred_at_ms: 100, + timestamp_micros: expect.any(Number), }); expect(body.events[1]?.params).toMatchObject({ surface: "integration", diff --git a/App/backend/src/analytics/tool-connection-analytics.ts b/App/backend/src/analytics/tool-connection-analytics.ts index 51616a2e8..2b3a1dcae 100644 --- a/App/backend/src/analytics/tool-connection-analytics.ts +++ b/App/backend/src/analytics/tool-connection-analytics.ts @@ -55,6 +55,7 @@ export function buildToolConnectionParams(input: ToolConnectionTrackInput): Anal export function createToolConnectionAnalytics(options: { getClientId?: () => string | null | undefined; + getInstallationId?: () => string | null | undefined; getUserId?: () => string | null | undefined; getUserMode?: () => string | null | undefined; appEnv?: AnalyticsAppEnv | null; @@ -66,6 +67,7 @@ export function createToolConnectionAnalytics(options: { const queued = createQueuedAnalytics({ source: TOOL_CONNECTION_ANALYTICS_SOURCE, getClientId: options.getClientId ?? (() => readAnalyticsClientId()), + getInstallationId: options.getInstallationId, getUserId: options.getUserId, getUserMode: options.getUserMode, appEnv: options.appEnv, diff --git a/App/backend/src/index.ts b/App/backend/src/index.ts index ec4a505a4..7317420cd 100644 --- a/App/backend/src/index.ts +++ b/App/backend/src/index.ts @@ -1,5 +1,5 @@ /** Src module. */ -import { RuntimeConfigSchema, type AppSettingsDto, type LastLaunchMode, type RuntimeConfig } from "@memmy/local-api-contracts"; +import { RuntimeConfigSchema, type AccountChannel, type AppSettingsDto, type LastLaunchMode, type RuntimeConfig } from "@memmy/local-api-contracts"; import { randomBytes } from "node:crypto"; import type { AddressInfo } from "node:net"; import { createDefaultAgentAdapterRegistry, type AgentAdapterRegistry } from "./adapters/outbound/agent-adapter/index.js"; @@ -28,11 +28,15 @@ import { } from "./services/agent-source-auto-scan-service.js"; import { resolveCloudClientConfig, type CloudClientConfig } from "./config/service-urls.js"; import { resetAccountRuntimeForDesktopInstallChange } from "./services/desktop-install-state-service.js"; -import { syncRuntimeConfigWithAppState } from "./services/runtime-config-sync-service.js"; +import { + syncRuntimeConfigForStartup, + syncRuntimeConfigWithAppState +} from "./services/runtime-config-sync-service.js"; import { loadCloudServiceEnv } from "./load-env.js"; export type { BootstrapScenario }; export { loadCloudServiceEnv }; +export { syncRuntimeConfigForStartup }; export { trackAnalyticsEvent } from "./analytics/analytics-transport.js"; const DEFAULT_MEMORY_LAYER_TIMEOUT_MS = 20_000; @@ -53,6 +57,8 @@ export interface CreateLocalBackendOptions { memoryBaseUrl?: string; /** Desktop install fingerprint. */ desktopInstallFingerprint?: string; + /** Login channel supported by the current desktop package. */ + accountChannel?: AccountChannel; /** Agent source auto scan interval in ms. Defaults to one hour. */ agentSourceAutoScanIntervalMs?: number; /** Agent source startup scan delay in ms. Defaults to five minutes. */ @@ -89,7 +95,8 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr } await syncRuntimeConfigWithAppState({ appStateStore, - memmyConfigPath + memmyConfigPath, + accountChannel: options.accountChannel }); const permissionManager = createPermissionManager({ @@ -98,7 +105,7 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr }); const memoryClient = options.memoryClient ?? createDefaultMemoryClient(process.env); await memoryClient.reloadConfig({ reason: "desktop_startup" }); - const scanWorker = options.memoryClient ? undefined : { databasePath: appStateStore.databasePath }; + const scanProcess = options.memoryClient ? undefined : { databasePath: appStateStore.databasePath }; const cloudConfig = resolveCloudClientConfig(process.env); const cloudClient = options.cloudClient ?? createDefaultCloudClient( cloudConfig, @@ -120,6 +127,7 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr bootstrapScenario: options.bootstrapScenario, memmyConfigWriter, memmyConfigPath, + accountChannel: options.accountChannel, memmyAgentAdminBootstrapSecret: await readAgentGatewayBootstrapSecret(memmyConfigPath) }); const localToken = await permissionManager.getRuntimeToken(); @@ -130,7 +138,7 @@ export async function createLocalBackend(options: CreateLocalBackendOptions): Pr composioMcpToken, timeZone: configuredTimeZone, heartbeatIntervalMs: options.heartbeatIntervalMs, - scanWorker + scanProcess }); await server.listen({ host: "127.0.0.1", port: 0 }); diff --git a/App/backend/src/infrastructure/app-state-store/local-data-store.ts b/App/backend/src/infrastructure/app-state-store/local-data-store.ts index ebed75e3b..a05a4bcf2 100644 --- a/App/backend/src/infrastructure/app-state-store/local-data-store.ts +++ b/App/backend/src/infrastructure/app-state-store/local-data-store.ts @@ -30,7 +30,9 @@ export interface CreateFilesystemLocalDataStoreOptions { const DEFAULT_MEMORY_HOME = join(homedir(), ".memmy"); const MEMORY_DATA_TABLES = [ "memories_fts", + "user_memories_fts", "memory_vector_entries", + "memory_processing_state", "trace_policy_links", "skill_trials", "feedback", @@ -47,6 +49,7 @@ const MEMORY_DATA_TABLES = [ "api_logs", "memory_change_log", "idempotency_keys", + "user_memories", "memories" ] as const; diff --git a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts index cd83a65d6..e99e0107f 100644 --- a/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts +++ b/App/backend/src/infrastructure/app-state-store/repositories/account-session-repo.ts @@ -3,6 +3,7 @@ import { createHash } from "node:crypto"; import { AccountProfileViewSchema, AccountSessionViewSchema, + type AccountChannel, type AccountProfileView, type AccountSessionView } from "@memmy/local-api-contracts"; @@ -12,12 +13,16 @@ import type { SecretStore } from "../secret-store.js"; export interface AccountSessionRepository { get(): AccountSessionView; + /** Reads the explicit channel used to create the active login session. */ + getAuthChannel(): AccountChannel | null; + /** Reads the channel bound to a persisted cloud credential without activating it. */ + getAuthChannelByCloudUuid(cloudUuid: string): AccountChannel | null; /** Reads get cloud uuid. */ getCloudUuid(): string | null; /** Reads get latest cloud uuid. */ getLatestCloudUuid(): string | null; /** Handles activate by cloud uuid. */ - activateByCloudUuid(cloudUuid: string): boolean; + activateByCloudUuid(cloudUuid: string, accountChannel?: AccountChannel): boolean; upsert(input: UpsertAccountSessionInput): AccountSessionView; clear(): void; getLastCodeSentAt(key: string): string | null; @@ -36,6 +41,8 @@ export interface UpsertAccountSessionInput { cloudUuid?: string; /** Is new user. */ isNewUser?: boolean | null; + /** Verification channel used by this login. */ + authChannel?: AccountChannel; } interface AccountSessionRow { @@ -57,6 +64,8 @@ interface VerificationThrottleRow { last_code_sent_at: string; } +const LOCAL_AUTH_CHANNEL_FIELD = "_memmyAuthChannel"; + /** Creates create account session repository. */ export function createAccountSessionRepository(db: DatabaseSync, secretStore: SecretStore): AccountSessionRepository { return { @@ -73,6 +82,19 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se }); }, + getAuthChannel() { + return resolveAccountAuthChannel(getActiveAccountRow(db)); + }, + + getAuthChannelByCloudUuid(cloudUuid) { + const normalizedCloudUuid = cloudUuid.trim(); + if (!normalizedCloudUuid) return null; + const row = listRowsWithCloudUuidRef(db).find( + (candidate) => getCloudUuidFromRow(secretStore, candidate) === normalizedCloudUuid + ); + return resolveAccountAuthChannel(row ?? null); + }, + getCloudUuid() { return getCloudUuidFromRow(secretStore, getActiveAccountRow(db)); }, @@ -81,7 +103,7 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se return findLatestCloudUuid(db, secretStore); }, - activateByCloudUuid(cloudUuid) { + activateByCloudUuid(cloudUuid, accountChannel) { const normalizedCloudUuid = cloudUuid.trim(); if (!normalizedCloudUuid) { setActiveAccountUuid(db, null); @@ -90,6 +112,9 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se const rows = listRowsWithCloudUuidRef(db); for (const row of rows) { + if (accountChannel && resolveAccountAuthChannel(row) !== accountChannel) { + continue; + } if (getCloudUuidFromRow(secretStore, row) !== normalizedCloudUuid) { continue; } @@ -99,7 +124,6 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se return true; } - setActiveAccountUuid(db, null); return false; }, @@ -158,7 +182,7 @@ export function createAccountSessionRepository(db: DatabaseSync, secretStore: Se toNullableInteger(input.profile.hasFinishedGuide), input.profile.region, registeredAt, - JSON.stringify(stripCloudCredential(input.profile.rawProfile)), + JSON.stringify(buildPersistedRawProfile(previous, input.profile.rawProfile, input.authChannel)), previous?.cloud_uuid_ref ?? null, now, now, @@ -333,6 +357,50 @@ function parseRawProfile(rawProfileJson: string): Record | null } } +function buildPersistedRawProfile( + previous: AccountSessionRow | null, + rawProfile: Record, + authChannel: AccountChannel | undefined +): Record { + const result = stripCloudCredential(rawProfile); + delete result[LOCAL_AUTH_CHANNEL_FIELD]; + const persistedChannel = authChannel ?? resolveAccountAuthChannel(previous); + if (persistedChannel) result[LOCAL_AUTH_CHANNEL_FIELD] = persistedChannel; + return result; +} + +function resolveAccountAuthChannel(row: AccountSessionRow | null): AccountChannel | null { + return resolveExplicitAccountAuthChannel(row) ?? resolveLegacyAccountAuthChannel(row); +} + +function resolveExplicitAccountAuthChannel(row: AccountSessionRow | null): AccountChannel | null { + if (!row?.raw_profile_json) return null; + const value = parseRawProfile(row.raw_profile_json)?.[LOCAL_AUTH_CHANNEL_FIELD]; + return value === "email" || value === "phone" ? value : null; +} + +/** + * Infers the login channel for sessions written before `_memmyAuthChannel` existed. + * + * Only a single bound contact is trusted: an account containing both email and phone remains + * ambiguous and must still be rejected when a package requires a specific login channel. + * + * @param row the persisted account row. + * @returns the unambiguous legacy channel, or null when it cannot be inferred safely. + */ +function resolveLegacyAccountAuthChannel(row: AccountSessionRow | null): AccountChannel | null { + if (!row) return null; + const rawProfile = row.raw_profile_json ? parseRawProfile(row.raw_profile_json) : null; + const hasEmail = Boolean(row.email?.trim() || readRawProfileString(rawProfile?.email)); + const hasPhone = Boolean( + row.phone?.trim() + || readRawProfileString(rawProfile?.phoneNumber) + || readRawProfileString(rawProfile?.phone) + ); + if (hasEmail === hasPhone) return null; + return hasEmail ? "email" : "phone"; +} + /** * Reads a string from a raw profile field. * diff --git a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts index 436864801..e8eec9def 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/account-session-repo.test.ts @@ -265,10 +265,81 @@ describe("account session repository", () => { userId: "user-1" } }); - expect(activeMissing.active_uuid).toBeNull(); + expect(activeMissing.active_uuid).toBe("cloud-account-a"); expect(fabricatedAccount).toBeUndefined(); }); + it("infers a legacy login channel only from one unambiguous bound contact", () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); + const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); + const createProfile = (input: { userId: string; email: string | null; phoneNumber: string | null }) => ({ + ...input, + nickname: input.userId, + avatarUrl: null, + planType: "free", + hasFinishedGuide: false, + region: null, + registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { + id: input.userId, + ...(input.email ? { email: input.email } : {}), + ...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}) + } + }); + + store.repositories.accountSession.upsert({ + profile: createProfile({ userId: "legacy-email", email: "legacy@example.com", phoneNumber: null }), + uuid: "legacy-email", + cloudUuid: "legacy.email.credential" + }); + expect(store.repositories.accountSession.getAuthChannel()).toBe("email"); + + store.repositories.accountSession.upsert({ + profile: createProfile({ userId: "legacy-phone", email: null, phoneNumber: "13800138000" }), + uuid: "legacy-phone", + cloudUuid: "legacy.phone.credential" + }); + expect(store.repositories.accountSession.getAuthChannel()).toBe("phone"); + + store.repositories.accountSession.upsert({ + profile: createProfile({ userId: "legacy-ambiguous", email: "both@example.com", phoneNumber: "13800138000" }), + uuid: "legacy-ambiguous", + cloudUuid: "legacy.ambiguous.credential" + }); + expect(store.repositories.accountSession.getAuthChannel()).toBeNull(); + store.close(); + }); + + it("uses the persisted login channel instead of inferring from bound contact fields", () => { + tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); + const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); + + store.repositories.accountSession.upsert({ + profile: { + userId: "user-both", + email: "both@example.com", + phoneNumber: "13800138000", + nickname: "both", + avatarUrl: null, + planType: "free", + hasFinishedGuide: false, + region: null, + registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "user-both", email: "both@example.com", phoneNumber: "13800138000" } + }, + uuid: "cloud-account-both", + cloudUuid: "cloud.login.uuid.both", + authChannel: "email" + }); + + expect(store.repositories.accountSession.getAuthChannel()).toBe("email"); + store.repositories.accountSession.clear(); + expect(store.repositories.accountSession.activateByCloudUuid("cloud.login.uuid.both", "phone")).toBe(false); + expect(store.repositories.accountSession.get()).toEqual({ authenticated: false }); + expect(store.repositories.accountSession.activateByCloudUuid("cloud.login.uuid.both", "email")).toBe(true); + store.close(); + }); + it("repairs missing phone column from raw cloud profile", () => { tempDir = mkdtempSync(join(tmpdir(), "memmy-account-session-")); const store = createAppStateStore({ databasePath: join(tempDir, "app.sqlite") }); diff --git a/App/backend/src/infrastructure/app-state-store/tests/local-data-store.test.ts b/App/backend/src/infrastructure/app-state-store/tests/local-data-store.test.ts index 56449b996..f9e5d465c 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/local-data-store.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/local-data-store.test.ts @@ -109,6 +109,9 @@ describe("filesystem local data store", () => { const memoryDb = new DatabaseSync(memoryDatabasePath, { readOnly: true, allowExtension: true }); memoryDb.loadExtension(getSqliteVecLoadablePath()); const memoryCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM memories").get() as { count: number }; + const userMemoryCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM user_memories").get() as { count: number }; + const userMemoryFtsCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM user_memories_fts").get() as { count: number }; + const processingCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM memory_processing_state").get() as { count: number }; const vectorCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM memory_vec_3").get() as { count: number }; const apiLogCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM api_logs").get() as { count: number }; const migrationCount = memoryDb.prepare("SELECT COUNT(*) AS count FROM schema_migrations").get() as { count: number }; @@ -128,6 +131,9 @@ describe("filesystem local data store", () => { expect(seenCount.count).toBe(0); expect(watermarkCount.count).toBe(0); expect(memoryCount.count).toBe(0); + expect(userMemoryCount.count).toBe(0); + expect(userMemoryFtsCount.count).toBe(0); + expect(processingCount.count).toBe(0); expect(vectorCount.count).toBe(0); expect(apiLogCount.count).toBe(0); expect(migrationCount.count).toBe(1); @@ -140,6 +146,9 @@ function createMemoryDatabase(databasePath: string): void { db.exec(` CREATE TABLE schema_migrations (id TEXT PRIMARY KEY); CREATE TABLE memories (id TEXT PRIMARY KEY, memory_value TEXT NOT NULL); + CREATE TABLE user_memories (id TEXT PRIMARY KEY, content TEXT NOT NULL); + CREATE VIRTUAL TABLE user_memories_fts USING fts5(id, content); + CREATE TABLE memory_processing_state (memory_id TEXT PRIMARY KEY, state TEXT NOT NULL); CREATE TABLE memory_vector_entries ( id INTEGER PRIMARY KEY, memory_id TEXT NOT NULL, @@ -151,6 +160,9 @@ function createMemoryDatabase(databasePath: string): void { CREATE TABLE api_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, tool_name TEXT NOT NULL); INSERT INTO schema_migrations (id) VALUES ('001_runtime_schema'); INSERT INTO memories (id, memory_value) VALUES ('memory-1', 'remember this'); + INSERT INTO user_memories (id, content) VALUES ('user-memory-1', 'prefers concise code'); + INSERT INTO user_memories_fts (id, content) VALUES ('user-memory-1', 'prefers concise code'); + INSERT INTO memory_processing_state (memory_id, state) VALUES ('memory-1', 'summarizing'); INSERT INTO memory_vector_entries VALUES (1, 'memory-1', 'vec_summary', 3, '2026-01-01'); INSERT INTO api_logs (tool_name) VALUES ('memory_add'); `); diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 1ea3559e8..8cc5b5495 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -77,6 +77,10 @@ export type RuntimeMemmyConfigState = status: "invalid_yaml" | "no_model_config" | "conflict"; configPath: string; reason: string; + accountProjection?: { + cloudUuid: string; + userId?: string; + }; } | { status: "valid_account"; @@ -89,6 +93,10 @@ export type RuntimeMemmyConfigState = configPath: string; context: Readonly; provider: Readonly; + accountProjection?: { + cloudUuid: string; + userId?: string; + }; }; export interface RuntimeProjectionResult { @@ -122,7 +130,7 @@ export interface MemmyConfigWriter { /** * Clear the account-mode runtime login projection. */ - clearAccountModelProjection?(input?: { ownerAccountId?: string }): Promise; + clearAccountModelProjection?(input?: { ownerAccountId?: string; force?: boolean }): Promise; /** * Patch a single memmy-agent channel config. @@ -377,24 +385,51 @@ function deriveRuntimeMemmyConfigState( configPath: string, mode?: UserMode ): RuntimeMemmyConfigState { + const appCloudUuid = existingString(asRecord(config.app)?.cloudUuid); + const providerCloudUuid = existingString( + asRecord(asRecord(config.providers)?.[MEMMY_ACCOUNT_PROVIDER])?.apiKey + ); + if (appCloudUuid && providerCloudUuid && appCloudUuid !== providerCloudUuid) { + return runtimeConfigProblem( + "conflict", + configPath, + "account_runtime_credentials_conflict", + { cloudUuid: appCloudUuid } + ); + } const userMode = mode ?? existingString(asRecord(config.app)?.userMode); const byok = resolveRuntimeAgentSelection(config, "byok"); if (userMode === "byok") { return byok.ok - ? deriveByokRuntimeConfigState(configPath, byok) - : runtimeConfigProblem("no_model_config", configPath, "Active BYOK assignment is not locally usable"); + ? deriveByokRuntimeConfigState(config, configPath, byok) + : runtimeConfigProblem( + "no_model_config", + configPath, + "Active BYOK assignment is not locally usable", + readAccountProjection(config) + ); } if (userMode === "account") { return hasAccountProjection(config) ? deriveAccountRuntimeConfigState(config, configPath) - : runtimeConfigProblem("no_model_config", configPath, "Active account assignment is not locally usable"); + : runtimeConfigProblem( + "no_model_config", + configPath, + "Active account assignment is not locally usable", + readAccountProjection(config) + ); } if (hasAccountProjection(config)) return deriveAccountRuntimeConfigState(config, configPath); if (byok.ok) { - return deriveByokRuntimeConfigState(configPath, byok); + return deriveByokRuntimeConfigState(config, configPath, byok); } - return runtimeConfigProblem("no_model_config", configPath, "Missing a locally usable default text model"); + return runtimeConfigProblem( + "no_model_config", + configPath, + "Missing a locally usable default text model", + readAccountProjection(config) + ); } function hasAccountProjection(config: Record): boolean { @@ -441,15 +476,28 @@ function deriveAccountRuntimeConfigState( } function deriveByokRuntimeConfigState( + config: Record, configPath: string, resolved: Extract ): RuntimeMemmyConfigState { - return { + return omitUndefined({ status: "valid_byok", configPath, context: resolved.context, - provider: resolved.provider - }; + provider: resolved.provider, + accountProjection: readAccountProjection(config) + }) as RuntimeMemmyConfigState; +} + +function readAccountProjection( + config: Record +): { cloudUuid: string; userId?: string } | undefined { + const cloudUuid = existingString(asRecord(config.app)?.cloudUuid) + ?? existingString(asRecord(asRecord(config.providers)?.[MEMMY_ACCOUNT_PROVIDER])?.apiKey); + if (!cloudUuid) return undefined; + const userId = existingString(asRecord(config.app)?.userId) + ?? existingString(asRecord(asRecord(config.providers)?.[MEMMY_ACCOUNT_PROVIDER])?.ownerAccountId); + return userId ? { cloudUuid, userId } : { cloudUuid }; } /** @@ -546,13 +594,32 @@ export async function writeAccountModelProjectionToMemmyConfig( */ export async function clearAccountModelProjectionFromMemmyConfig( configPath = resolveDefaultMemmyConfigPath(), - input: { ownerAccountId?: string } = {} + input: { ownerAccountId?: string; force?: boolean } = {} ): Promise { const requestedOwnerAccountId = input.ownerAccountId?.trim(); const result = await mutateRuntimeConfig(configPath, (config) => { const appConfig = isRecord(config.app) ? { ...config.app } : {}; const providers = isRecord(config.providers) ? { ...config.providers } : {}; const accountProvider = asRecord(providers[MEMMY_ACCOUNT_PROVIDER]); + if (input.force) { + delete appConfig.cloudUuid; + delete appConfig.userId; + setAppConfig(config, appConfig); + delete config.uuid; + delete config.identity; + delete providers[MEMMY_ACCOUNT_PROVIDER]; + config.providers = providers; + const presets = isRecord(config.modelPresets) ? { ...config.modelPresets } : {}; + for (const [presetId, value] of Object.entries(presets)) { + if (isRecord(value) && value.source === "account") delete presets[presetId]; + } + config.modelPresets = presets; + replaceRemovedAccountDefault(config, presets); + const assignments = isRecord(config.modelAssignments) ? { ...config.modelAssignments } : {}; + delete assignments.account; + config.modelAssignments = assignments; + return { memoryConfigAffected: false }; + } const ownerAccountId = requestedOwnerAccountId ?? existingString(appConfig.userId) ?? existingString(accountProvider?.ownerAccountId); @@ -725,13 +792,15 @@ function replaceRemovedAccountDefault( function runtimeConfigProblem( status: Extract, configPath: string, - reason: string + reason: string, + accountProjection?: { cloudUuid: string; userId?: string } ): RuntimeMemmyConfigState { - return { + return omitUndefined({ status, configPath, - reason - }; + reason, + accountProjection + }) as RuntimeMemmyConfigState; } async function readMemmyConfigContent(configPath: string): Promise { diff --git a/App/backend/src/project-version.ts b/App/backend/src/project-version.ts index d71913810..775b1c825 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.0.8"; +export const MEMMY_VERSION = "1.0.9"; diff --git a/App/backend/src/services/account-service.ts b/App/backend/src/services/account-service.ts index 893d43885..61d8f4bf2 100644 --- a/App/backend/src/services/account-service.ts +++ b/App/backend/src/services/account-service.ts @@ -5,6 +5,7 @@ import { AccountProfileViewSchema, AccountSessionViewSchema, SendCodeResponseSchema, + type AccountChannel, type AccountInvitationView, type AccountLoginResultView, type AccountProfileView, @@ -46,6 +47,8 @@ export interface CreateAccountServiceOptions { memoryClient?: Pick; /** Now. */ now?: () => Date; + /** Verification channel supported by the current desktop package. */ + accountChannel?: AccountChannel; } /** Creates create account service. */ @@ -54,6 +57,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco return { async sendCode(input) { + assertExpectedAccountChannel(input.channel, options.accountChannel); const key = toCodeKey(input); const sentAt = options.accountSessionRepository.getLastCodeSentAt(key); const remaining = getRemainingResendSeconds(sentAt, now()); @@ -79,6 +83,7 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco }, async verifyCode(input) { + assertExpectedAccountChannel(input.channel, options.accountChannel); const loginResult = await options.cloudClient.login({ ...(input.email ? { email: input.email } : {}), ...(input.phoneNumber ? { phoneNumber: input.phoneNumber } : {}), @@ -100,7 +105,8 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco profile: toSessionProfileInput(loginResult.profile), uuid: loginResult.accountUuid, cloudUuid: loginResult.uuid, - isNewUser: loginResult.isNewUser + isNewUser: loginResult.isNewUser, + authChannel: input.channel }) ); @@ -199,6 +205,16 @@ export function createAccountService(options: CreateAccountServiceOptions): Acco }; } +function assertExpectedAccountChannel( + actualChannel: AccountChannel, + expectedChannel: AccountChannel | undefined +): void { + if (!expectedChannel || actualChannel === expectedChannel) return; + throw Object.assign(new Error(`Account channel ${actualChannel} is not supported by this desktop package`), { + code: "invalid_argument" as const + }); +} + async function reloadMemoryConfigIfNeeded( projection: RuntimeProjectionResult | undefined, options: CreateAccountServiceOptions diff --git a/App/backend/src/services/agent-source-scan-worker.ts b/App/backend/src/services/agent-source-scan-process.ts similarity index 76% rename from App/backend/src/services/agent-source-scan-worker.ts rename to App/backend/src/services/agent-source-scan-process.ts index 0af7f2556..f5a9570f2 100644 --- a/App/backend/src/services/agent-source-scan-worker.ts +++ b/App/backend/src/services/agent-source-scan-process.ts @@ -1,4 +1,3 @@ -import { parentPort, workerData } from "node:worker_threads"; import { createHttpMemoryClient, createMemosSqliteMemoryClient, @@ -6,21 +5,22 @@ import { type MemoryClient, type MemoryLayerConfig } from "../adapters/outbound/memory-client/index.js"; -import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; -import { createAgentSourceScanJournal, type AgentSourceScanJournal } from "../infrastructure/agent-source-scan-journal/index.js"; import { createAgentSourceLifecycleAnalytics, resolveLoggedInAnalyticsUserId, } from "../analytics/agent-source-analytics.js"; import { createMemoryDesktopAddAnalytics } from "../analytics/memory-add-analytics.js"; +import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; +import { createAgentSourceScanJournal, type AgentSourceScanJournal } from "../infrastructure/agent-source-scan-journal/index.js"; import { createAgentSourceService } from "./agent-source-service.js"; import { createBuiltinAgentSourceRegistry } from "./builtin-agent-source-registry.js"; +import { createBuiltinSkillTargetRegistry } from "./builtin-skill-target-registry.js"; import { createIngestionService } from "./ingestion-service.js"; -import type { SkillDistributionService } from "./skill-distribution-service.js"; +import { createSkillDistributionService } from "./skill-distribution-service.js"; import { - type AgentSourceScanWorkerCommand, - type AgentSourceScanWorkerData, - type AgentSourceScanWorkerMessage, + type AgentSourceScanProcessCommand, + type AgentSourceScanProcessData, + type AgentSourceScanProcessMessage, isScanResumeStateReference, type ScanResumeState, type ScanResumeStateReference, @@ -29,30 +29,38 @@ import { const DEFAULT_MEMORY_LAYER_TIMEOUT_MS = 20_000; -if (!parentPort) { - throw new Error("Agent source scan worker requires a parent port"); +if (!process.send) { + throw new Error("Agent source scan process requires an IPC channel"); } const controller = new AbortController(); -parentPort.on("message", (message: AgentSourceScanWorkerCommand) => { +let started = false; + +process.on("message", (message: AgentSourceScanProcessCommand) => { if (message.type === "abort") { controller.abort(); + return; } + + if (started) return; + started = true; + void runProcess(message.data).catch((error: unknown) => { + if (!controller.signal.aborted) { + postProcessMessage({ + type: "failed", + message: error instanceof Error ? error.message : "Agent source scan process failed" + }); + } + }).finally(() => { + if (process.connected) process.disconnect?.(); + }); }); -void runWorker().catch((error: unknown) => { - if (!controller.signal.aborted) { - postWorkerMessage({ - type: "failed", - message: error instanceof Error ? error.message : "Agent source scan worker failed" - }); - } -}).finally(() => { - parentPort?.close(); +process.once("disconnect", () => { + controller.abort(); }); -async function runWorker(): Promise { - const data = workerData as AgentSourceScanWorkerData; +async function runProcess(data: AgentSourceScanProcessData): Promise { let appStateStore: AppStateStore | null = null; try { appStateStore = createAppStateStore({ databasePath: data.databasePath }); @@ -68,13 +76,13 @@ async function runWorker(): Promise { agentSources, { onProgress(progress) { - postWorkerMessage({ type: "progress", progress }); + postProcessMessage({ type: "progress", progress }); }, onResumeChanged(resume) { - postWorkerMessage({ type: "resume", resume: resume ? writeResumeState(scanJournal, data, resume) : null }); + postProcessMessage({ type: "resume", resume: resume ? writeResumeState(scanJournal, data, resume) : null }); }, onCompleted(results) { - postWorkerMessage({ type: "completed", results }); + postProcessMessage({ type: "completed", results }); } } ); @@ -112,7 +120,9 @@ function createAgentSources(appStateStore: AppStateStore, memoryClient: MemoryCl agentSourceRepository: appStateStore.repositories.agentSources, ingestionService, memoryClient, - skillDistributionService: createUnavailableSkillDistributionService(), + skillDistributionService: createSkillDistributionService({ + targetRegistry: createBuiltinSkillTargetRegistry() + }), agentSourceAnalytics: createAgentSourceLifecycleAnalytics({ getUserId: resolveAnalyticsUserId, getUserMode: resolveAnalyticsUserMode, @@ -150,20 +160,7 @@ function readMemoryLayerConfig(env: NodeJS.ProcessEnv): MemoryLayerConfig | null }; } -function createUnavailableSkillDistributionService(): SkillDistributionService { - const unavailable = async () => { - throw new Error("Skill distribution is not available in agent source scan worker"); - }; - - return { - install: unavailable, - uninstall: unavailable, - installPlugin: unavailable, - uninstallPlugin: unavailable - }; -} - -function readResumeState(scanJournal: AgentSourceScanJournal, resume: AgentSourceScanWorkerData["job"]["resume"]): ScanResumeState | null { +function readResumeState(scanJournal: AgentSourceScanJournal, resume: AgentSourceScanProcessData["job"]["resume"]): ScanResumeState | null { if (!resume) { return null; } @@ -177,7 +174,7 @@ function readResumeState(scanJournal: AgentSourceScanJournal, resume: AgentSourc function writeResumeState( scanJournal: AgentSourceScanJournal, - data: AgentSourceScanWorkerData, + data: AgentSourceScanProcessData, resume: ScanResumeState ): ScanResumeStateReference { scanJournal.writeResume({ @@ -207,6 +204,6 @@ function writeResumeState( }; } -function postWorkerMessage(message: AgentSourceScanWorkerMessage): void { - parentPort?.postMessage(message); +function postProcessMessage(message: AgentSourceScanProcessMessage): void { + if (process.connected) process.send?.(message); } diff --git a/App/backend/src/services/agent-source-scan-runner.ts b/App/backend/src/services/agent-source-scan-runner.ts index 94556a209..e5fc95c7b 100644 --- a/App/backend/src/services/agent-source-scan-runner.ts +++ b/App/backend/src/services/agent-source-scan-runner.ts @@ -60,18 +60,23 @@ export interface AgentSourceScanJobState { resume: ScanResumeState | null; } -export interface AgentSourceScanWorkerData { +export interface AgentSourceScanProcessData { databasePath: string; job: Omit & { resume: RouteScanResumeState | null; }; } -export type AgentSourceScanWorkerCommand = { - type: "abort"; -}; +export type AgentSourceScanProcessCommand = + | { + type: "start"; + data: AgentSourceScanProcessData; + } + | { + type: "abort"; + }; -export type AgentSourceScanWorkerMessage = +export type AgentSourceScanProcessMessage = | { type: "progress"; progress: PipelineProgress; diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index fefcdfb60..c7aecfdeb 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -99,7 +99,7 @@ export interface CreateAgentSourceServiceOptions { sourceRegistry: SourceRegistry; agentSourceRepository: AgentSourceRepository; ingestionService: IngestionService; - memoryClient: Pick; + memoryClient: Pick; skillDistributionService: SkillDistributionService; agentSourceAnalytics?: AgentSourceLifecycleAnalytics; getScanPermission?: () => Promise; @@ -688,6 +688,7 @@ async function ingestCollectedSource( ) { updateScanWatermark(options, collected, scanOptions, scannedAt); } + errors.push(...await ingestSourceSkills(options, collected.sourceId, scanOptions)); return { sourceId: collected.sourceId, discoveredConversations: collected.conversationIds.length, @@ -698,6 +699,53 @@ async function ingestCollectedSource( }; } +async function ingestSourceSkills( + options: CreateAgentSourceServiceOptions, + sourceId: string, + scanOptions: AgentSourceScanOptions +): Promise> { + if (!options.skillDistributionService.listSkills) return []; + + const errors: Array<{ conversationId: string; reason: string }> = []; + let skills; + try { + skills = await options.skillDistributionService.listSkills(sourceId); + } catch (error) { + return [{ + conversationId: "skills", + reason: error instanceof Error ? error.message : "Agent Skill scan failed" + }]; + } + + for (const skill of skills) { + scanOptions.signal?.throwIfAborted(); + try { + await options.memoryClient.addMemory({ + requestId: `agent-source-skill:${sourceId}:${skill.sourceSkillId}:${skill.sourceContentHash}`, + adapterId: `agent-source:${sourceId}`, + content: skill.content, + layer: "Skill", + title: skill.title, + tags: ["agent-source", "cross-agent-skill", sourceId], + source: sourceId, + turnId: `skill:${skill.sourceSkillId}:${skill.sourceSkillVersion}`, + createdAt: skill.updatedAt, + sourceAgentId: sourceId, + sourceSkillId: skill.sourceSkillId, + sourceSkillPath: skill.sourceSkillPath, + sourceSkillVersion: skill.sourceSkillVersion, + sourceContentHash: skill.sourceContentHash + }); + } catch (error) { + errors.push({ + conversationId: `skill:${skill.sourceSkillId}`, + reason: error instanceof Error ? error.message : "Agent Skill import failed" + }); + } + } + return errors; +} + function filterCheckpointedConversations( options: CreateAgentSourceServiceOptions, collected: CollectedSourceScan diff --git a/App/backend/src/services/builtin-skill-target-registry.ts b/App/backend/src/services/builtin-skill-target-registry.ts new file mode 100644 index 000000000..856c16eac --- /dev/null +++ b/App/backend/src/services/builtin-skill-target-registry.ts @@ -0,0 +1,26 @@ +import { createClaudeCodeSkillTarget } from "../adapters/outbound/skill-writer/claude-code/index.js"; +import { createCodexSkillTarget } from "../adapters/outbound/skill-writer/codex/index.js"; +import { createCursorSkillTarget } from "../adapters/outbound/skill-writer/cursor/index.js"; +import { createDeepseekHarnessSkillTarget } from "../adapters/outbound/skill-writer/deepseek-harness/index.js"; +import { createHermesSkillTarget } from "../adapters/outbound/skill-writer/hermes/index.js"; +import { createOpenclawSkillTarget } from "../adapters/outbound/skill-writer/openclaw/index.js"; +import { createOpencodeSkillTarget } from "../adapters/outbound/skill-writer/opencode/index.js"; +import { createPiSkillTarget } from "../adapters/outbound/skill-writer/pi/index.js"; +import { createQwenworkSkillTarget } from "../adapters/outbound/skill-writer/qwenwork/index.js"; +import { createWorkbuddySkillTarget } from "../adapters/outbound/skill-writer/workbuddy/index.js"; +import { createSkillTargetRegistry, type SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; + +export function createBuiltinSkillTargetRegistry(memmyConfigPath?: string): SkillTargetRegistry { + return createSkillTargetRegistry([ + createCursorSkillTarget({ memmyConfigPath }), + createClaudeCodeSkillTarget({ memmyConfigPath }), + createCodexSkillTarget({ memmyConfigPath }), + createOpencodeSkillTarget(), + createOpenclawSkillTarget({ memmyConfigPath }), + createHermesSkillTarget({ memmyConfigPath }), + createDeepseekHarnessSkillTarget({ memmyConfigPath }), + createWorkbuddySkillTarget(), + createPiSkillTarget(), + createQwenworkSkillTarget() + ]); +} diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 8afd598d0..aea89ed62 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -1,3 +1,4 @@ +import type { AccountChannel } from "@memmy/local-api-contracts"; import type { AppStateStore } from "../infrastructure/app-state-store/index.js"; import { type MemmyConfigWriter } from "../infrastructure/memmy-config/index.js"; import type { AgentAdapterRegistry } from "../adapters/outbound/agent-adapter/index.js"; @@ -8,17 +9,7 @@ import { import type { SourceRegistry } from "../adapters/outbound/agent-source/source-registry.js"; import { createHttpMemmyAgentAdminClient } from "../adapters/outbound/memmy-agent-admin-client/http-memmy-agent-admin-client.js"; import type { MemmyAgentAdminClient } from "../adapters/outbound/memmy-agent-admin-client/index.js"; -import { createClaudeCodeSkillTarget } from "../adapters/outbound/skill-writer/claude-code/index.js"; -import { createCodexSkillTarget } from "../adapters/outbound/skill-writer/codex/index.js"; -import { createCursorSkillTarget } from "../adapters/outbound/skill-writer/cursor/index.js"; -import { createHermesSkillTarget } from "../adapters/outbound/skill-writer/hermes/index.js"; -import { createDeepseekHarnessSkillTarget } from "../adapters/outbound/skill-writer/deepseek-harness/index.js"; -import { createOpenclawSkillTarget } from "../adapters/outbound/skill-writer/openclaw/index.js"; -import { createOpencodeSkillTarget } from "../adapters/outbound/skill-writer/opencode/index.js"; -import { createPiSkillTarget } from "../adapters/outbound/skill-writer/pi/index.js"; -import { createQwenworkSkillTarget } from "../adapters/outbound/skill-writer/qwenwork/index.js"; -import { createWorkbuddySkillTarget } from "../adapters/outbound/skill-writer/workbuddy/index.js"; -import { createSkillTargetRegistry, type SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; +import type { SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; import type { CloudClient } from "../adapters/outbound/cloud-client/index.js"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; import type { PermissionManager } from "../permission/index.js"; @@ -31,6 +22,7 @@ import { createToolConnectionAnalytics } from "../analytics/tool-connection-anal import { createAgentSourceService, type AgentSourceService } from "./agent-source-service.js"; import { createAgentSourceAutoInjectService, type AgentSourceAutoInjectService } from "./agent-source-auto-inject-service.js"; import { createBuiltinAgentSourceRegistry } from "./builtin-agent-source-registry.js"; +import { createBuiltinSkillTargetRegistry } from "./builtin-skill-target-registry.js"; import { createAppConfigService, type AppConfigService } from "./app-config-service.js"; import { createAccountService, type AccountService } from "./account-service.js"; import { createAsrService, type AsrService } from "./asr-service.js"; @@ -112,6 +104,8 @@ export interface CreateBackendServicesOptions { memmyAgentAdminClient?: MemmyAgentAdminClient; /** Memmy agent admin bootstrap secret. */ memmyAgentAdminBootstrapSecret?: string | null; + /** Verification channel supported by the current desktop package. */ + accountChannel?: AccountChannel; } export function createBackendServices(options: CreateBackendServicesOptions): BackendServices { @@ -121,18 +115,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba createBuiltinAgentSourceRegistry(); const skillTargetRegistry = options.skillTargetRegistry ?? - createSkillTargetRegistry([ - createCursorSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createClaudeCodeSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createCodexSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createOpencodeSkillTarget(), - createOpenclawSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createHermesSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createDeepseekHarnessSkillTarget({ memmyConfigPath: options.memmyConfigPath }), - createWorkbuddySkillTarget(), - createPiSkillTarget(), - createQwenworkSkillTarget() - ]); + createBuiltinSkillTargetRegistry(options.memmyConfigPath); const skillDistributionService = options.skillDistributionService ?? createSkillDistributionService({ @@ -197,7 +180,8 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba cloudClient: options.cloudClient, accountSessionRepository: options.appStateStore.repositories.accountSession, memmyConfigWriter: options.memmyConfigWriter, - memoryClient: options.memoryClient + memoryClient: options.memoryClient, + accountChannel: options.accountChannel }), integrations: createIntegrationService({ cloudClient: options.cloudClient, diff --git a/App/backend/src/services/memory-detail-service.ts b/App/backend/src/services/memory-detail-service.ts index bb821ce99..570301b27 100644 --- a/App/backend/src/services/memory-detail-service.ts +++ b/App/backend/src/services/memory-detail-service.ts @@ -4,7 +4,8 @@ import type { AddMemoryOutput, GetMemoryOutput, DeleteMemoryInput, - DeleteMemoryOutput + DeleteMemoryOutput, + RecallEvidenceOutput } from "@memmy/local-api-contracts"; import type { MemoryClient } from "../adapters/outbound/memory-client/index.js"; import type { RuntimeContext } from "./runtime-context.js"; @@ -13,6 +14,7 @@ export interface MemoryDetailService { add(input: AddMemoryInput, ctx: RuntimeContext): Promise; getById(id: string, ctx: RuntimeContext): Promise; delete(id: string, input: DeleteMemoryInput, ctx: RuntimeContext): Promise; + recallEvidence(queryId: string, ctx: RuntimeContext): Promise; } export function createMemoryDetailService(deps: { @@ -29,6 +31,10 @@ export function createMemoryDetailService(deps: { async delete(id, input, ctx) { return deps.memoryClient.deleteMemory({ ...input, memoryId: id }, ctx); + }, + + async recallEvidence(queryId, ctx) { + return deps.memoryClient.recallEvidence(queryId, ctx); } }; } diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index 0478e9e22..13a1d7f6a 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -1,10 +1,11 @@ /** Runtime config sync service module. */ -import type { UserMode } from "@memmy/local-api-contracts"; +import type { AccountChannel, UserMode } from "@memmy/local-api-contracts"; import { createAppStateStore, type AppStateStore } from "../infrastructure/app-state-store/index.js"; import { + clearAccountModelProjectionFromMemmyConfig, readRuntimeMemmyConfigState, type RuntimeMemmyConfigState } from "../infrastructure/memmy-config/index.js"; @@ -12,11 +13,15 @@ import { export interface SyncRuntimeConfigWithAppStateOptions { appStateStore: AppStateStore; memmyConfigPath: string; + /** Login channel supported by the current desktop package. */ + accountChannel?: AccountChannel; } export interface SyncRuntimeConfigForStartupOptions { databasePath: string; memmyConfigPath: string; + /** Login channel supported by the current desktop package. */ + accountChannel?: AccountChannel; } export interface RuntimeConfigSyncResult { @@ -43,11 +48,43 @@ export async function syncRuntimeConfigWithAppState( options: SyncRuntimeConfigWithAppStateOptions ): Promise { const state = await readRuntimeMemmyConfigState(options.memmyConfigPath); + const activeChannelMismatch = await clearMismatchedActiveSession(options, state); + if (activeChannelMismatch) { + const clearedUntrustedProjection = await clearUntrustedAccountProjection( + options, + accountProjectionFromState(state) + ); + const wroteConfig = activeChannelMismatch.wroteConfig || clearedUntrustedProjection; + if (state.status === "valid_byok") { + const hydrated = hydrateByokRuntimeConfig(options.appStateStore, state); + return { + ...hydrated, + wroteConfig, + reason: "cleared_mismatched_account_session_and_hydrated_byok" + }; + } + if (state.status === "conflict" && state.reason === "account_runtime_credentials_conflict") { + return { + ...await syncRuntimeConfigWithAppState(options), + wroteConfig: true, + reason: "cleared_conflicting_account_credentials" + }; + } + if (state.status === "invalid_yaml" || state.status === "conflict") { + throw createRuntimeConfigSyncError(state); + } + return accountChannelMismatchResult(options.appStateStore, wroteConfig); + } switch (state.status) { - case "valid_byok": - return hydrateByokRuntimeConfig(options.appStateStore, state); + case "valid_byok": { + const clearedDormantProjection = await clearUntrustedAccountProjection(options, state.accountProjection); + const hydrated = hydrateByokRuntimeConfig(options.appStateStore, state); + return clearedDormantProjection + ? { ...hydrated, wroteConfig: true, reason: "cleared_untrusted_account_projection_and_hydrated_byok" } + : hydrated; + } case "valid_account": - return hydrateAccountRuntimeConfig(options.appStateStore, state); + return hydrateAccountRuntimeConfig(options, state); case "missing": case "empty": return { @@ -57,17 +94,29 @@ export async function syncRuntimeConfigWithAppState( wroteConfig: false, reason: `${state.status}_runtime_config_requires_startup_migration` }; - case "no_model_config": + case "no_model_config": { + const clearedProjection = await clearUntrustedAccountProjection(options, state.accountProjection); return { source: "none", mode: options.appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: false, - reason: state.reason + wroteConfig: clearedProjection, + reason: clearedProjection ? "cleared_untrusted_account_projection" : state.reason }; + } case "invalid_yaml": - case "conflict": throw createRuntimeConfigSyncError(state); + case "conflict": + if (state.reason !== "account_runtime_credentials_conflict") { + throw createRuntimeConfigSyncError(state); + } + options.appStateStore.repositories.accountSession.clear(); + await clearAccountModelProjectionFromMemmyConfig(options.memmyConfigPath, { force: true }); + return { + ...await syncRuntimeConfigWithAppState(options), + wroteConfig: true, + reason: "cleared_conflicting_account_credentials" + }; } } @@ -79,7 +128,8 @@ export async function syncRuntimeConfigForStartup( try { return await syncRuntimeConfigWithAppState({ appStateStore, - memmyConfigPath: options.memmyConfigPath + memmyConfigPath: options.memmyConfigPath, + accountChannel: options.accountChannel }); } finally { appStateStore.close(); @@ -102,19 +152,33 @@ function hydrateByokRuntimeConfig( }; } -function hydrateAccountRuntimeConfig( - appStateStore: AppStateStore, +async function hydrateAccountRuntimeConfig( + options: SyncRuntimeConfigWithAppStateOptions, state: Extract -): RuntimeConfigSyncResult { - const activated = appStateStore.repositories.accountSession.activateByCloudUuid(state.cloudUuid); +): Promise { + const { appStateStore } = options; + const activated = appStateStore.repositories.accountSession.activateByCloudUuid( + state.cloudUuid, + options.accountChannel + ); const session = appStateStore.repositories.accountSession.get(); - if (!activated || !session.authenticated || (state.userId && session.profile.userId !== state.userId)) { - if (activated) appStateStore.repositories.accountSession.activateByCloudUuid(""); + const sessionChannel = appStateStore.repositories.accountSession.getAuthChannel(); + if ( + !activated + || !session.authenticated + || (options.accountChannel && sessionChannel !== options.accountChannel) + || (state.userId && session.profile.userId !== state.userId) + ) { + if (activated) appStateStore.repositories.accountSession.clear(); + const projection = await clearAccountModelProjectionFromMemmyConfig(options.memmyConfigPath, { + ownerAccountId: state.userId ?? (session.authenticated ? session.profile.userId : undefined), + force: true + }); return { source: "none", mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: false, + wroteConfig: projection.changed, reason: "account_projection_has_no_matching_local_session" }; } @@ -130,6 +194,70 @@ function hydrateAccountRuntimeConfig( }; } +async function clearMismatchedActiveSession( + options: SyncRuntimeConfigWithAppStateOptions, + state: RuntimeMemmyConfigState +): Promise<{ wroteConfig: boolean } | null> { + if (!options.accountChannel) return null; + const session = options.appStateStore.repositories.accountSession.get(); + if (!session.authenticated) return null; + if (options.appStateStore.repositories.accountSession.getAuthChannel() === options.accountChannel) { + return null; + } + + options.appStateStore.repositories.accountSession.clear(); + if ( + state.status === "missing" + || state.status === "empty" + || state.status === "invalid_yaml" + || state.status === "conflict" + ) { + return { wroteConfig: false }; + } + const projection = await clearAccountModelProjectionFromMemmyConfig(options.memmyConfigPath, { + ownerAccountId: session.profile.userId + }); + return { wroteConfig: projection.changed }; +} + +async function clearUntrustedAccountProjection( + options: SyncRuntimeConfigWithAppStateOptions, + accountProjection: { cloudUuid: string; userId?: string } | undefined +): Promise { + if (!options.accountChannel || !accountProjection) return false; + const storedChannel = options.appStateStore.repositories.accountSession.getAuthChannelByCloudUuid( + accountProjection.cloudUuid + ); + if (storedChannel === options.accountChannel) return false; + const projection = await clearAccountModelProjectionFromMemmyConfig(options.memmyConfigPath, { + ownerAccountId: accountProjection.userId, + force: true + }); + return projection.changed; +} + +function accountProjectionFromState( + state: RuntimeMemmyConfigState +): { cloudUuid: string; userId?: string } | undefined { + if (state.status === "valid_account") { + return state.userId ? { cloudUuid: state.cloudUuid, userId: state.userId } : { cloudUuid: state.cloudUuid }; + } + return "accountProjection" in state ? state.accountProjection : undefined; +} + +function accountChannelMismatchResult( + appStateStore: AppStateStore, + wroteConfig: boolean +): RuntimeConfigSyncResult { + return { + source: "none", + mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, + hydratedAppState: false, + wroteConfig, + reason: "account_session_channel_mismatch" + }; +} + function createRuntimeConfigSyncError(state: RuntimeConfigSyncErrorState): Error { return Object.assign(new Error(`Invalid Memmy runtime config: ${state.reason}`), { code: "invalid_runtime_config" as const, diff --git a/App/backend/src/services/skill-distribution-service.ts b/App/backend/src/services/skill-distribution-service.ts index e5e2c7e9d..aca5a4b9e 100644 --- a/App/backend/src/services/skill-distribution-service.ts +++ b/App/backend/src/services/skill-distribution-service.ts @@ -1,11 +1,26 @@ /** Skill distribution service module. */ +import { createHash } from "node:crypto"; +import { readdir, readFile, stat } from "node:fs/promises"; +import { join, relative } from "node:path"; import { renderMemmyDefaultSkillManifest } from "../adapters/outbound/skill-writer/templates/memmy-default.js"; import type { MemoryPluginConflict } from "../adapters/outbound/skill-writer/types.js"; import type { SkillTargetRegistry } from "../adapters/outbound/skill-writer/target-registry.js"; import { AgentSourceUnavailableError } from "./runtime-errors.js"; +export interface ScannedAgentSkill { + sourceAgentId: string; + sourceSkillId: string; + sourceSkillPath: string; + sourceSkillVersion: string; + sourceContentHash: string; + title: string; + content: string; + updatedAt: string; +} + /** Contract for skill distribution service. */ export interface SkillDistributionService { + listSkills?(sourceId: string): Promise; install(sourceId: string): Promise; uninstall(sourceId: string): Promise; installPlugin(sourceId: string): Promise; @@ -23,6 +38,33 @@ export function createSkillDistributionService( options: CreateSkillDistributionServiceOptions ): SkillDistributionService { return { + async listSkills(sourceId) { + const target = options.targetRegistry.get(sourceId); + const root = await target?.resolveRootDirectory(); + if (!root) return []; + + const skillsRoot = join(root, "skills"); + const skillFiles = await findSkillFiles(skillsRoot); + return await Promise.all(skillFiles.map(async (filePath) => { + const content = await readFile(filePath, "utf8"); + const contentHash = createHash("sha256").update(content).digest("hex"); + const sourceSkillId = relative(skillsRoot, filePath) + .replace(/[/\\]SKILL\.md$/i, "") + .replaceAll("\\", "/"); + const fileStat = await stat(filePath); + return { + sourceAgentId: sourceId, + sourceSkillId, + sourceSkillPath: filePath, + sourceSkillVersion: frontmatterValue(content, "version") ?? contentHash, + sourceContentHash: contentHash, + title: frontmatterValue(content, "name") ?? sourceSkillId, + content, + updatedAt: fileStat.mtime.toISOString() + }; + })); + }, + async install(sourceId) { const target = options.targetRegistry.require(sourceId); if (!(await target.resolveRootDirectory())) { @@ -71,3 +113,42 @@ export function createSkillDistributionService( } }; } + +async function findSkillFiles(skillsRoot: string): Promise { + const files: string[] = []; + await visit(skillsRoot, 0); + return files.sort(); + + async function visit(directory: string, depth: number): Promise { + let entries; + try { + entries = await readdir(directory, { withFileTypes: true }); + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") return; + throw error; + } + + for (const entry of entries) { + if (entry.name === "memmy-memory" || entry.name === "node_modules" || entry.name === ".git") continue; + const entryPath = join(directory, entry.name); + if (entry.isFile() && entry.name.toLowerCase() === "skill.md") { + files.push(entryPath); + } else if (depth < 2 && (entry.isDirectory() || entry.isSymbolicLink())) { + const entryStat = await stat(entryPath); + if (entryStat.isDirectory()) await visit(entryPath, depth + 1); + } + } + } +} + +function frontmatterValue(content: string, key: string): string | undefined { + if (!content.startsWith("---")) return undefined; + const end = content.indexOf("\n---", 3); + if (end < 0) return undefined; + const match = content.slice(3, end).match(new RegExp(`^${key}:\\s*["']?([^\\n"']+)["']?\\s*$`, "im")); + return match?.[1]?.trim(); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && "code" in error; +} diff --git a/App/backend/src/services/tests/account-service.test.ts b/App/backend/src/services/tests/account-service.test.ts index 808ffd888..3465bf814 100644 --- a/App/backend/src/services/tests/account-service.test.ts +++ b/App/backend/src/services/tests/account-service.test.ts @@ -3,6 +3,37 @@ import { describe, expect, it } from "vitest"; import { createAccountService } from "../account-service.js"; describe("AccountService", () => { + it("rejects verification channels that are not supported by the desktop package", async () => { + let cloudCalls = 0; + const service = createAccountService({ + accountChannel: "phone", + cloudClient: { + ...createCloudClientStub(), + async sendEmailCode() { + cloudCalls += 1; + }, + async login() { + cloudCalls += 1; + throw new Error("unexpected cloud login"); + } + }, + accountSessionRepository: createAccountSessionRepositoryStub() + }); + + await expect(service.sendCode({ + channel: "email", + email: "hello@example.com", + locale: "zh" + })).rejects.toMatchObject({ code: "invalid_argument" }); + await expect(service.verifyCode({ + channel: "email", + email: "hello@example.com", + verificationCode: "123456", + loginSource: "Memmy" + })).rejects.toMatchObject({ code: "invalid_argument" }); + expect(cloudCalls).toBe(0); + }); + it("sends verification codes through cloud-client and rate-limits by channel address", async () => { const calls: string[] = []; let lastCodeSentAt: string | null = null; @@ -161,7 +192,8 @@ describe("AccountService", () => { profile: cloudProfile(), isNewUser: true, uuid: "cloud-account-user-1", - cloudUuid: "cloud.login.uuid" + cloudUuid: "cloud.login.uuid", + authChannel: "email" } }, { 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 88cbc9a1e..a1c09dff3 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -128,6 +128,53 @@ describe("agent source service", () => { }); }); + it("imports scanned Agent skills with immutable source provenance", async () => { + const added: Parameters[0][] = []; + const memoryClient = createMockMemoryClient(); + const service = createService({ + adapters: [createFakeAdapter("cursor", createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"))], + memoryClient: { + ...memoryClient, + async addMemory(input, context) { + added.push(input); + return memoryClient.addMemory(input, context); + } + }, + skillDistributionService: { + async listSkills() { + return [{ + sourceAgentId: "cursor", + sourceSkillId: "review-code", + sourceSkillPath: "/tmp/cursor/skills/review-code/SKILL.md", + sourceSkillVersion: "v2", + sourceContentHash: "hash-v2", + title: "review-code", + content: "Review changed code.", + updatedAt: "2026-05-28T09:00:00.000Z" + }]; + }, + async install() {}, + async uninstall() {}, + async installPlugin() {}, + async uninstallPlugin() {} + } + }); + + await service.scanOne("cursor"); + + expect(added).toEqual([ + expect.objectContaining({ + layer: "Skill", + sourceAgentId: "cursor", + sourceSkillId: "review-code", + sourceSkillPath: "/tmp/cursor/skills/review-code/SKILL.md", + sourceSkillVersion: "v2", + sourceContentHash: "hash-v2", + tags: ["agent-source", "cross-agent-skill", "cursor"] + }) + ]); + }); + it("completes the scan and advances checkpoints when every memory is skipped", async () => { const repository = createRepository(); const messages = createCompleteMemoryMessages("cursor", 1, "2026-05-28T10:00:00.000Z"); diff --git a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts index 792376ed9..de6168a9e 100644 --- a/App/backend/src/services/tests/builtin-agent-source-registry.test.ts +++ b/App/backend/src/services/tests/builtin-agent-source-registry.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createBuiltinAgentSourceRegistry } from "../builtin-agent-source-registry.js"; describe("built-in agent source registry", () => { - it("keeps every built-in source available to both the main service and scan worker", () => { + it("keeps every built-in source available to both the main service and scan process", () => { const registry = createBuiltinAgentSourceRegistry(); expect(registry.list().map((adapter) => adapter.descriptor.sourceId)).toEqual([ 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 0aa31d305..67062eca4 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 @@ -26,7 +26,10 @@ describe("syncRuntimeConfigWithAppState", () => { context.store.repositories.bootstrap.updateAppSettings({ userMode: "account" }); const legacyBefore = context.store.db.prepare("SELECT * FROM account_model_config ORDER BY uuid").all(); - await expect(syncRuntimeConfigWithAppState(context)).resolves.toMatchObject({ + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ source: "runtime_config", mode: "byok", provider: "openai", @@ -47,7 +50,8 @@ describe("syncRuntimeConfigWithAppState", () => { rawProfile: { id: "owner-a", email: "a@example.test", userName: "a" } }, uuid: "account-a", - cloudUuid: "cloud-token-a" + cloudUuid: "cloud-token-a", + authChannel: "email" }); context.store.repositories.accountSession.upsert({ profile: { @@ -56,11 +60,15 @@ describe("syncRuntimeConfigWithAppState", () => { rawProfile: { id: "user-b", email: "b@example.test", userName: "b" } }, uuid: "account-b", - cloudUuid: "cloud-token-b" + cloudUuid: "cloud-token-b", + authChannel: "email" }); context.writeConfig(currentAccountCatalog()); - await expect(syncRuntimeConfigWithAppState(context)).resolves.toMatchObject({ + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false }); expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("account"); @@ -71,7 +79,113 @@ describe("syncRuntimeConfigWithAppState", () => { expect(context.store.db.prepare("SELECT uuid FROM cloud_accounts WHERE uuid = ?").get("cloud-token-a")).toBeUndefined(); }); - it("keeps BYOK active when a dormant account projection is also present", async () => { + it("keeps an unmarked legacy email session when the INTL package starts", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: "a@example.test", phoneNumber: null, nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", email: "a@example.test", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a" + }); + context.writeConfig(currentAccountCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "runtime_config", mode: "account", hydratedAppState: true, wroteConfig: false + }); + expect(context.store.repositories.accountSession.get()).toMatchObject({ + authenticated: true, + profile: { userId: "owner-a" } + }); + }); + + it("clears an email session before CN startup hydrates the shared account projection", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: "a@example.test", phoneNumber: null, nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", email: "a@example.test", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a", + authChannel: "email" + }); + context.writeConfig(currentAccountCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + reason: "account_session_channel_mismatch" + }); + + expect(context.store.repositories.accountSession.get()).toEqual({ authenticated: false }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.cloudUuid).toBeUndefined(); + expect(saved.app.userId).toBeUndefined(); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + + it("clears a phone session before INTL startup hydrates the shared account projection", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: null, phoneNumber: "13800138000", nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", phoneNumber: "13800138000", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a", + authChannel: "phone" + }); + context.writeConfig(currentAccountCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + reason: "account_session_channel_mismatch" + }); + + expect(context.store.repositories.accountSession.get()).toEqual({ authenticated: false }); + }); + + it("clears a mismatched active session even when the account projection is already incomplete", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: "a@example.test", phoneNumber: null, nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", email: "a@example.test", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a" + }); + context.writeConfig({ app: { userMode: "account" } }); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + reason: "account_session_channel_mismatch" + }); + + expect(context.store.repositories.accountSession.get()).toEqual({ authenticated: false }); + }); + + it("keeps BYOK active while clearing an untrusted dormant account projection", async () => { const context = createContext(); const byok = currentByokCatalog() as any; const account = currentAccountCatalog() as any; @@ -86,13 +200,100 @@ describe("syncRuntimeConfigWithAppState", () => { } }); - await expect(syncRuntimeConfigWithAppState(context)).resolves.toMatchObject({ + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ source: "runtime_config", mode: "byok", provider: "openai", model: "gpt-5" }); expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("byok"); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + + it("preserves a dormant account projection owned by the current package channel", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: "a@example.test", phoneNumber: "13800138000", nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", email: "a@example.test", phoneNumber: "13800138000", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a", + authChannel: "email" + }); + const byok = currentByokCatalog() as any; + const account = currentAccountCatalog() as any; + context.writeConfig({ + ...byok, + app: { ...account.app, userMode: "byok" }, + providers: { ...byok.providers, ...account.providers }, + modelPresets: { ...byok.modelPresets, ...account.modelPresets }, + modelAssignments: { byok: byok.modelAssignments.byok, account: account.modelAssignments.account } + }); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ mode: "byok", wroteConfig: false }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account.apiKey).toBe("cloud-token-a"); + }); + + it("clears an account projection that has no matching local credential", async () => { + const context = createContext(); + context.writeConfig(currentAccountCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + reason: "account_projection_has_no_matching_local_session", + wroteConfig: true + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.cloudUuid).toBeUndefined(); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + + it("clears conflicting app and provider account credentials before startup", async () => { + const context = createContext(); + context.store.repositories.accountSession.upsert({ + profile: { + userId: "owner-a", email: "a@example.test", phoneNumber: null, nickname: "a", avatarUrl: null, + planType: "free", hasFinishedGuide: false, region: null, registeredAt: "2026-06-02T10:00:00.000Z", + rawProfile: { id: "owner-a", email: "a@example.test", userName: "a" } + }, + uuid: "account-a", + cloudUuid: "cloud-token-a", + authChannel: "email" + }); + const config = currentAccountCatalog() as any; + config.providers.memmy_account.apiKey = "foreign-cloud-token"; + context.writeConfig(config); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + wroteConfig: true, + reason: "cleared_conflicting_account_credentials" + }); + + expect(context.store.repositories.accountSession.get()).toEqual({ authenticated: false }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.cloudUuid).toBeUndefined(); + expect(saved.providers.memmy_account).toBeUndefined(); }); it("persists account/BYOK mode switches through settings and honors them after restart", async () => { diff --git a/App/backend/src/services/tests/skill-distribution-service.test.ts b/App/backend/src/services/tests/skill-distribution-service.test.ts index 39076d0ba..2ddaf12e0 100644 --- a/App/backend/src/services/tests/skill-distribution-service.test.ts +++ b/App/backend/src/services/tests/skill-distribution-service.test.ts @@ -1,10 +1,45 @@ /** Skill distribution service tests. */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { createSkillTargetRegistry } from "../../adapters/outbound/skill-writer/target-registry.js"; import type { MemoryPluginConflict, SkillManifest, SkillTarget } from "../../adapters/outbound/skill-writer/types.js"; import { createSkillDistributionService } from "../skill-distribution-service.js"; describe("skill distribution service", () => { + it("scans other Agent skills as source-attributed immutable versions", async () => { + const rootDirectory = mkdtempSync(join(tmpdir(), "memmy-agent-skills-")); + try { + mkdirSync(join(rootDirectory, "skills", "review-code"), { recursive: true }); + mkdirSync(join(rootDirectory, "skills", "memmy-memory"), { recursive: true }); + writeFileSync( + join(rootDirectory, "skills", "review-code", "SKILL.md"), + "---\nname: review-code\nversion: 2\n---\nReview changed code.\n", + "utf8" + ); + writeFileSync(join(rootDirectory, "skills", "memmy-memory", "SKILL.md"), "internal", "utf8"); + const service = createSkillDistributionService({ + targetRegistry: createSkillTargetRegistry([ + createFakeTarget({ resolveRootDirectory: () => rootDirectory }) + ]) + }); + + await expect(service.listSkills?.("cursor")).resolves.toEqual([ + expect.objectContaining({ + sourceAgentId: "cursor", + sourceSkillId: "review-code", + sourceSkillVersion: "2", + title: "review-code", + content: expect.stringContaining("Review changed code."), + sourceContentHash: expect.stringMatching(/^[a-f0-9]{64}$/) + }) + ]); + } finally { + rmSync(rootDirectory, { recursive: true, force: true }); + } + }); + it("renders and installs the fixed Memmy skill manifest", async () => { let installed: SkillManifest | undefined; const service = createSkillDistributionService({ diff --git a/App/backend/src/tests/memory-runtime-contracts.test.ts b/App/backend/src/tests/memory-runtime-contracts.test.ts index c8e20ee13..1a3dec23b 100644 --- a/App/backend/src/tests/memory-runtime-contracts.test.ts +++ b/App/backend/src/tests/memory-runtime-contracts.test.ts @@ -26,6 +26,7 @@ import { PanelOverviewOutputSchema, RawTurnSummarySchema, RecallHitSchema, + RecallEvidenceOutputSchema, SearchInputSchema, SearchOutputSchema, StartTurnInputSchema, @@ -48,9 +49,18 @@ describe("memory runtime contracts", () => { })).not.toThrow(); }); + it("accepts User Memory as a panel-only memory layer", () => { + expect(() => PanelItemsInputSchema.parse({ layer: "UserMemory", page: 1 })).not.toThrow(); + expect(() => MemoryListItemSchema.parse(memoryListItem({ + kind: "user_memory", + memoryLayer: "UserMemory" + }))).not.toThrow(); + }); + const outputCases: Array<{ name: string; schema: ZodType; valid: unknown; invalid: unknown }> = [ { name: "InjectedContext", schema: InjectedContextSchema, valid: injectedContext(), invalid: { markdown: "", sections: [{ id: "sec-1", kind: "bad" }] } }, { name: "RecallHit", schema: RecallHitSchema, valid: recallHit(), invalid: { ...recallHit(), memoryLayer: "L4" } }, + { name: "RecallEvidenceOutput", schema: RecallEvidenceOutputSchema, valid: { recallEventId: "recall-1", queryId: "turn-1", query: "remember", hits: [recallHit()], createdAt: ISO, serverTime: ISO }, invalid: { queryId: "", hits: [] } }, { name: "MemoryListItem", schema: MemoryListItemSchema, valid: memoryListItem(), invalid: { ...memoryListItem(), status: "draft" } }, { name: "MemoryDetailItem", schema: MemoryDetailItemSchema, valid: memoryDetailItem(), invalid: { ...memoryDetailItem(), createdAt: "not-a-date" } }, { name: "RawTurnSummary", schema: RawTurnSummarySchema, valid: rawTurnSummary(), invalid: { ...rawTurnSummary(), rawTurnId: "" } }, @@ -266,7 +276,7 @@ function deleteMemoryOutput() { function panelOverviewOutput() { return { - counts: { memories: 1, skills: 4, experiences: 2, worldModels: 3 }, + counts: { memories: 1, userMemories: 5, skills: 4, experiences: 2, worldModels: 3 }, dailyActivity: panelDays(), sourceDistribution: [{ source: "codex", count: 10, percentage: 100 }] }; diff --git a/App/backend/src/tests/support/mock-memory-client.ts b/App/backend/src/tests/support/mock-memory-client.ts index 459187fde..b65f84c68 100644 --- a/App/backend/src/tests/support/mock-memory-client.ts +++ b/App/backend/src/tests/support/mock-memory-client.ts @@ -256,7 +256,7 @@ export function createMockMemoryClient(options: CreateMockMemoryClientOptions = async panelOverview() { failIfNeeded(); return { - counts: { memories: 0, skills: 0, experiences: 0, worldModels: 0 }, + counts: { memories: 0, userMemories: 0, skills: 0, experiences: 0, worldModels: 0 }, dailyActivity: emptyPanelDays(now()), sourceDistribution: [] }; diff --git a/App/frontend/desktop/eslint.config.mjs b/App/frontend/desktop/eslint.config.mjs index 1c6502ece..c4b6d7af3 100644 --- a/App/frontend/desktop/eslint.config.mjs +++ b/App/frontend/desktop/eslint.config.mjs @@ -25,7 +25,14 @@ export default tseslint.config( }, }, { - files: ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.spec.ts", "src/**/*.spec.tsx"], + files: [ + "src/**/*.test.ts", + "src/**/*.test.tsx", + "src/**/*.spec.ts", + "src/**/*.spec.tsx", + "src/**/tests/**/*.ts", + "src/**/tests/**/*.tsx", + ], languageOptions: { parserOptions: { projectService: false, diff --git a/App/frontend/desktop/src/analytics/analytics-context.ts b/App/frontend/desktop/src/analytics/analytics-context.ts index 4b02e9aed..cb96c42d9 100644 --- a/App/frontend/desktop/src/analytics/analytics-context.ts +++ b/App/frontend/desktop/src/analytics/analytics-context.ts @@ -1,6 +1,9 @@ -export type AnalyticsUserMode = "account" | "byok" | "unset"; +export type AnalyticsUserMode = "account" | "account_byok" | "byok" | "unset"; +export type AnalyticsModelSource = "platform" | "byok"; let currentUserMode: AnalyticsUserMode = "unset"; +let currentUserId: string | null = null; +let currentModelSource: AnalyticsModelSource | null = null; export function setAnalyticsUserMode(mode: AnalyticsUserMode): void { currentUserMode = mode; @@ -10,11 +13,33 @@ export function getAnalyticsUserMode(): AnalyticsUserMode { return currentUserMode; } -export function resolveAnalyticsUserModeParams(): { user_mode?: "account" | "byok" } { - if (currentUserMode === "account" || currentUserMode === "byok") { - return { user_mode: currentUserMode }; +/** Mirrors the backend transport: the BYOK placeholder is not a cloud identity. */ +export function normalizeAnalyticsUserId(userId: string | null | undefined): string | null { + const trimmed = userId?.trim() || null; + if (!trimmed || trimmed === "local-user") return null; + return trimmed; +} + +export function setAnalyticsUserId(userId: string | null | undefined): void { + currentUserId = normalizeAnalyticsUserId(userId); +} + +export function getAnalyticsUserId(): string | null { + return currentUserId; +} + +export function setAnalyticsModelSource(source: AnalyticsModelSource | null): void { + currentModelSource = source; +} + +export function resolveAnalyticsUserModeParams(): { user_mode: "account" | "account_byok" | "byok" } { + if (!currentUserId) return { user_mode: "byok" }; + if (currentModelSource === "byok") return { user_mode: "account_byok" }; + if (currentModelSource === "platform") return { user_mode: "account" }; + if (currentUserMode === "byok" || currentUserMode === "account_byok") { + return { user_mode: "account_byok" }; } - return {}; + return { user_mode: "account" }; } export function mergeAnalyticsEventParams( @@ -26,4 +51,6 @@ export function mergeAnalyticsEventParams( /** Test helper to reset module state between cases. */ export function resetAnalyticsContextForTests(): void { currentUserMode = "unset"; + currentUserId = null; + currentModelSource = null; } diff --git a/App/frontend/desktop/src/analytics/cloud-analytics.ts b/App/frontend/desktop/src/analytics/cloud-analytics.ts index 43f4c1a0e..b40f65a9b 100644 --- a/App/frontend/desktop/src/analytics/cloud-analytics.ts +++ b/App/frontend/desktop/src/analytics/cloud-analytics.ts @@ -1,4 +1,4 @@ -import { mergeAnalyticsEventParams } from "./analytics-context.js"; +import { getAnalyticsUserId, mergeAnalyticsEventParams } from "./analytics-context.js"; import { resolveAnalyticsAppEdition, resolveAnalyticsAppEnv, @@ -19,6 +19,9 @@ const DESKTOP_ANALYTICS_SOURCE = "memmy-desktop"; /** Session-scoped gtag client_id. Never read ~/.memmy/analytics-client-id here. */ let sessionClientId: string | null = null; +let installationId: string | null = null; +let appVersion: string | null = null; +let platform: string | null = null; let pending: PendingCloudEvent[] = []; let lastEventTimeMillis = 0; let inflight: Promise = Promise.resolve(); @@ -48,6 +51,17 @@ export function getDesktopAnalyticsClientId(): string | null { return sessionClientId; } +export function setDesktopAnalyticsContext(input: { + installationId: string; + appVersion?: string | null; + platform?: string | null; +}): void { + installationId = input.installationId.trim() || null; + appVersion = input.appVersion?.trim() || null; + platform = normalizePlatform(input.platform); + if (installationId) scheduleFlush(); +} + export function trackCloudAnalyticsEvent( eventName: string, params?: CloudAnalyticsParams @@ -59,7 +73,7 @@ export function trackCloudAnalyticsEvent( lastEventTimeMillis = eventTimeMillis; pending.push({ eventName: name, - params: mergeAnalyticsEventParams(params), + params: { ...(params ?? {}) }, eventTimeMillis }); @@ -77,6 +91,9 @@ export function resetDesktopCloudAnalyticsForTests(options?: { fetchImpl?: typeof fetch; }): void { sessionClientId = null; + installationId = null; + appVersion = null; + platform = null; pending = []; lastEventTimeMillis = 0; inflight = Promise.resolve(); @@ -98,10 +115,11 @@ function flushNow(): Promise { pending = []; if (batch.length === 0) return inflight; - const clientId = sessionClientId; + const currentInstallationId = installationId; + const currentClientId = sessionClientId; const baseUrl = resolveDesktopAnalyticsBaseUrl(); - if (!clientId || !baseUrl) { - // Keep waiting for client_id; drop only when base URL is missing. + if (!currentInstallationId || !currentClientId || !baseUrl) { + // Keep waiting for both analytics identifiers; drop only when the base URL is missing. if (!baseUrl) { console.log("[analytics] cloud flush dropped (MEMMY_CLOUD_SERVICE unset):", batch.length); return inflight; @@ -110,7 +128,14 @@ function flushNow(): Promise { return inflight; } - const run = () => postCloudAnalyticsEvents({ baseUrl, clientId, events: batch }); + const run = () => postCloudAnalyticsEvents({ + baseUrl, + installationId: currentInstallationId, + analyticsClientId: currentClientId, + appVersion, + platform, + events: batch, + }); inflight = inflight.then(run, run).then( () => undefined, () => undefined @@ -126,25 +151,35 @@ function compactParams(params: CloudAnalyticsParams): CloudAnalyticsParams { async function postCloudAnalyticsEvents(input: { baseUrl: string; - clientId: string; + installationId: string; + analyticsClientId: string; + appVersion: string | null; + platform: string | null; events: PendingCloudEvent[]; }): Promise { const appEnv = resolveAnalyticsAppEnv(); const appEdition = resolveAnalyticsAppEdition(); const debugMode = resolveGtagDebugMode(); + const userId = getAnalyticsUserId(); + const commonParams = mergeAnalyticsEventParams(); const body = { - clientId: input.clientId, + clientId: input.analyticsClientId, + ...(userId ? { userId } : {}), + installationId: input.installationId, events: input.events.map((event) => ({ eventName: event.eventName, params: compactParams({ engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_MSEC, source: DESKTOP_ANALYTICS_SOURCE, + ...commonParams, ...event.params, + ...(userId ? { user_id: userId } : {}), app_env: appEnv, app_edition: appEdition, + ...(input.appVersion ? { app_version: input.appVersion } : {}), ...(debugMode ? { debug_mode: 1 } : {}), - timestamp_micros: Math.max(0, Math.trunc(event.eventTimeMillis)) * 1000 + timestamp_micros: Math.max(0, Math.trunc(event.eventTimeMillis)) * 1000, }) })) }; @@ -152,8 +187,8 @@ async function postCloudAnalyticsEvents(input: { console.log( "[analytics] cloud post:", input.events.map((event) => event.eventName), - "clientId=", - input.clientId + "installationId=", + input.installationId ); try { @@ -170,3 +205,10 @@ async function postCloudAnalyticsEvents(input: { // Match backend transport: swallow network errors. } } + +function normalizePlatform(value: string | null | undefined): string | null { + if (value === "darwin" || value === "macos") return "macos"; + if (value === "win32" || value === "windows") return "windows"; + if (value === "linux") return "linux"; + return value?.trim() || null; +} diff --git a/App/frontend/desktop/src/analytics/gtag-init.ts b/App/frontend/desktop/src/analytics/gtag-init.ts index cae901aea..630cbbc23 100644 --- a/App/frontend/desktop/src/analytics/gtag-init.ts +++ b/App/frontend/desktop/src/analytics/gtag-init.ts @@ -1,4 +1,4 @@ -import { setDesktopAnalyticsClientId, trackCloudAnalyticsEvent } from "./cloud-analytics.js"; +import { setDesktopAnalyticsClientId, setDesktopAnalyticsContext, trackCloudAnalyticsEvent } from "./cloud-analytics.js"; import { resolveAnalyticsAppEdition, resolveAnalyticsAppEnv, @@ -18,6 +18,7 @@ let initialized = false; export function initGtag(): void { if (initialized) return; + void initializeDesktopAnalyticsContext(); if (!MEASUREMENT_ID) { console.log("[analytics] initGtag skipped: MEMMY_GA4_MEASUREMENT_ID not set"); return; @@ -66,6 +67,24 @@ export function initGtag(): void { }; } +async function initializeDesktopAnalyticsContext(): Promise { + const memmy = window.memmy; + if (!memmy) return; + try { + const [installationId, appInfo] = await Promise.all([ + memmy.getInstallationId(), + memmy.getAppInfo(), + ]); + setDesktopAnalyticsContext({ + installationId, + appVersion: appInfo.version, + platform: appInfo.platform, + }); + } catch (error) { + console.warn("[analytics] failed to initialize desktop analytics context:", error); + } +} + /** * Desktop UI events go through cloud `/api/analytics/events`. * Kept as `gtagEvent` for call-site compatibility; only `app_launch` uses gtag directly. diff --git a/App/frontend/desktop/src/analytics/page-view.ts b/App/frontend/desktop/src/analytics/page-view.ts index 9bebe1750..b9d398794 100644 --- a/App/frontend/desktop/src/analytics/page-view.ts +++ b/App/frontend/desktop/src/analytics/page-view.ts @@ -22,6 +22,7 @@ const ROUTE_PAGE_TITLES: Record = { const MEMORY_SUB_PAGE_TITLES: Record = { overview: "Overview", memories: "Memories", + "user-memories": "User Memories", tasks: "Tasks", policies: "Policies", "world-model": "World Model", diff --git a/App/frontend/desktop/src/analytics/tests/cloud-analytics.test.ts b/App/frontend/desktop/src/analytics/tests/cloud-analytics.test.ts index 23665d69f..fb17e7178 100644 --- a/App/frontend/desktop/src/analytics/tests/cloud-analytics.test.ts +++ b/App/frontend/desktop/src/analytics/tests/cloud-analytics.test.ts @@ -1,22 +1,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { resetAnalyticsContextForTests, setAnalyticsUserMode } from "../analytics-context.js"; +import { + resetAnalyticsContextForTests, + setAnalyticsModelSource, + setAnalyticsUserId, + setAnalyticsUserMode +} from "../analytics-context.js"; import { flushDesktopCloudAnalytics, getDesktopAnalyticsClientId, resetDesktopCloudAnalyticsForTests, resolveDesktopAnalyticsBaseUrl, + setDesktopAnalyticsContext, setDesktopAnalyticsClientId, trackCloudAnalyticsEvent } from "../cloud-analytics.js"; describe("cloud-analytics", () => { - const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); + const fetchMock = vi.fn(async () => new Response(null, { status: 204 })); beforeEach(() => { vi.stubEnv("MEMMY_CLOUD_SERVICE", "https://cloud.example.com/"); vi.stubEnv("MEMMY_APP_EDITION", "cn"); resetAnalyticsContextForTests(); - resetDesktopCloudAnalyticsForTests({ fetchImpl: fetchMock as unknown as typeof fetch }); + resetDesktopCloudAnalyticsForTests({ fetchImpl: fetchMock }); + setDesktopAnalyticsContext({ + installationId: "install-1", + appVersion: "1.0.5", + platform: "macos" + }); fetchMock.mockClear(); }); @@ -35,6 +46,7 @@ describe("cloud-analytics", () => { it("queues events until session client_id is set, then posts once", async () => { setAnalyticsUserMode("account"); + setAnalyticsUserId("user-1"); trackCloudAnalyticsEvent("welcome_viewed", { step: 1 }); expect(fetchMock).not.toHaveBeenCalled(); expect(getDesktopAnalyticsClientId()).toBeNull(); @@ -47,24 +59,47 @@ describe("cloud-analytics", () => { expect(url).toBe("https://cloud.example.com/api/analytics/events"); expect(init?.method).toBe("POST"); const body = JSON.parse(String(init?.body)) as { - clientId: string; + clientId?: string; + userId?: string; + installationId: string; events: Array<{ eventName: string; params: Record }>; }; - expect(body.clientId).toBe("cid-from-gtag"); + expect(body).toMatchObject({ + clientId: "cid-from-gtag", + userId: "user-1", + installationId: "install-1" + }); expect(body.events).toHaveLength(1); expect(body.events[0]?.eventName).toBe("welcome_viewed"); expect(body.events[0]?.params).toMatchObject({ + engagement_time_msec: 100, step: 1, user_mode: "account", source: "memmy-desktop", + user_id: "user-1", app_env: "dev", app_edition: "cn", - engagement_time_msec: 100 + app_version: "1.0.5", + debug_mode: 1, + timestamp_micros: expect.any(Number) }); - expect(body.events[0]?.params.timestamp_micros).toEqual(expect.any(Number)); }); - it("does not use a client_id until setDesktopAnalyticsClientId runs", async () => { + it("always supplies the required byok mode for a signed-out event", async () => { + setDesktopAnalyticsClientId("cid-1"); + trackCloudAnalyticsEvent("welcome_viewed"); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: { user_mode?: string; user_id?: string } }>; + }; + expect(body.userId).toBeUndefined(); + expect(body.events[0]?.params).toMatchObject({ user_mode: "byok" }); + expect(body.events[0]?.params.user_id).toBeUndefined(); + }); + + it("waits for both the GA client id and installation id before sending", async () => { trackCloudAnalyticsEvent("page_view", { page_path: "/welcome" }); await flushDesktopCloudAnalytics(); expect(fetchMock).not.toHaveBeenCalled(); @@ -72,7 +107,9 @@ describe("cloud-analytics", () => { setDesktopAnalyticsClientId(" fresh-id "); await flushDesktopCloudAnalytics(); expect(fetchMock).toHaveBeenCalledTimes(1); - const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { clientId: string }; + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + clientId?: string; + }; expect(body.clientId).toBe("fresh-id"); }); @@ -83,4 +120,115 @@ describe("cloud-analytics", () => { await flushDesktopCloudAnalytics(); expect(fetchMock).not.toHaveBeenCalled(); }); + + it("sends the original account id on the event row", async () => { + setAnalyticsUserId("cloud-uuid-1"); + trackCloudAnalyticsEvent("memory_desktop_search_succeeded", { page_path: "/memory" }); + setDesktopAnalyticsClientId("cid-1"); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: Record }>; + }; + expect(body.userId).toBe("cloud-uuid-1"); + expect(body.events[0]?.params.user_id).toBe("cloud-uuid-1"); + }); + + it("reports account_byok when a signed-in user is using BYOK", async () => { + setAnalyticsUserId("user-1"); + setAnalyticsUserMode("account"); + setAnalyticsModelSource("byok"); + setDesktopAnalyticsClientId("cid-1"); + trackCloudAnalyticsEvent("provider_selected", { provider: "openai" }); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: { user_mode: string; user_id?: string } }>; + }; + expect(body.userId).toBe("user-1"); + expect(body.events[0]?.params).toMatchObject({ + user_mode: "account_byok", + user_id: "user-1", + }); + }); + + it("reports account again after a signed-in user switches back to a platform model", async () => { + setAnalyticsUserId("user-1"); + setAnalyticsUserMode("account"); + setAnalyticsModelSource("byok"); + setAnalyticsModelSource("platform"); + setDesktopAnalyticsClientId("cid-1"); + trackCloudAnalyticsEvent("agent_send_message", { page_path: "/main" }); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + events: Array<{ params: { user_mode: string } }>; + }; + expect(body.events[0]?.params.user_mode).toBe("account"); + }); + + it("reports byok after logout even when the selected model source is byok", async () => { + setAnalyticsUserId("user-1"); + setAnalyticsUserMode("account"); + setAnalyticsModelSource("byok"); + setAnalyticsUserId(null); + setDesktopAnalyticsClientId("cid-1"); + trackCloudAnalyticsEvent("page_view", { page_path: "/welcome" }); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: { user_mode: string; user_id?: string } }>; + }; + expect(body.userId).toBeUndefined(); + expect(body.events[0]?.params).toMatchObject({ user_mode: "byok" }); + expect(body.events[0]?.params.user_id).toBeUndefined(); + }); + + it("omits the user id in BYOK / signed-out mode", async () => { + setAnalyticsUserMode("byok"); + trackCloudAnalyticsEvent("memory_desktop_search_succeeded", { page_path: "/memory" }); + setDesktopAnalyticsClientId("cid-1"); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: Record }>; + }; + expect(body.userId).toBeUndefined(); + expect(body.events[0]?.params).not.toHaveProperty("user_id"); + }); + + it("treats the local-user placeholder as no cloud identity", async () => { + setAnalyticsUserId(" local-user "); + trackCloudAnalyticsEvent("page_view", { page_path: "/main" }); + setDesktopAnalyticsClientId("cid-1"); + await flushDesktopCloudAnalytics(); + + const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + userId?: string; + events: Array<{ params: Record }>; + }; + expect(body.userId).toBeUndefined(); + expect(body.events[0]?.params).not.toHaveProperty("user_id"); + }); + + it("stops sending the user id after logout clears it", async () => { + setAnalyticsUserId("cloud-uuid-1"); + setDesktopAnalyticsClientId("cid-1"); + trackCloudAnalyticsEvent("account_logout", { page_path: "/settings" }); + await flushDesktopCloudAnalytics(); + + setAnalyticsUserId(null); + trackCloudAnalyticsEvent("page_view", { page_path: "/welcome" }); + await flushDesktopCloudAnalytics(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const logoutBody = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { userId?: string }; + const afterBody = JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)) as { userId?: string }; + expect(logoutBody.userId).toBe("cloud-uuid-1"); + expect(afterBody.userId).toBeUndefined(); + }); }); diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index d761617ed..f6def0b23 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -174,6 +174,87 @@ const SessionSnapshotSchema = z.object({ sessions: z.array(SessionSummarySchema) }); +const WorkspaceEnvironmentFileSchema = z.object({ + path: z.string(), + status: z.string(), + staged: z.boolean(), + unstaged: z.boolean(), + untracked: z.boolean(), + conflict: z.boolean(), + additions: z.number().nullable(), + deletions: z.number().nullable(), + attribution: z.union([ + z.literal("goal"), + z.literal("preexisting"), + z.literal("uncertain"), + z.literal("unattributed") + ]) +}); + +const WorkspaceEnvironmentSnapshotSchema = z.object({ + scope_kind: z.union([z.literal("session"), z.literal("project")]), + scope_key: z.string(), + cwd: z.string(), + status: z.union([ + z.literal("ready"), + z.literal("not_git"), + z.literal("workspace_unavailable"), + z.literal("error") + ]), + revision: z.string(), + captured_at: z.string(), + repository: z.object({ + display_name: z.string(), + root: z.string(), + head_sha: z.string(), + branch: z.string().nullable(), + detached: z.boolean(), + upstream: z.string().nullable(), + ahead: z.number(), + behind: z.number(), + worktree: z.union([z.literal("clean"), z.literal("dirty")]) + }).nullable(), + changes: z.object({ + file_count: z.number(), + additions: z.number().nullable(), + deletions: z.number().nullable(), + conflicts: z.number(), + staged: z.number(), + unstaged: z.number(), + untracked: z.number() + }).nullable(), + goal: z.object({ + goal_id: z.string(), + base_head: z.string().nullable(), + base_branch: z.string().nullable(), + goal_files: z.number(), + preexisting_files: z.number(), + uncertain_files: z.number(), + verification: z.union([ + z.literal("not_run"), + z.literal("running"), + z.literal("passed"), + z.literal("failed"), + z.literal("stale") + ]), + completion_audit: z.union([z.literal("pending"), z.literal("risk"), z.literal("satisfied")]), + baseline_status: z.union([z.literal("captured"), z.literal("unavailable")]) + }).nullable() +}); + +const WorkspaceEnvironmentStateSchema = z.object({ + snapshot: WorkspaceEnvironmentSnapshotSchema, + files: z.array(WorkspaceEnvironmentFileSchema), + branches: z.array(z.string()) +}); + +const WorkspaceEnvironmentDiffSchema = z.object({ + path: z.string(), + diff: z.string(), + truncated: z.boolean(), + unavailable_reason: z.string().nullable() +}); + const ProjectMutationResponseSchema = z.object({ project: ProjectSchema, snapshot: SessionSnapshotSchema @@ -346,6 +427,11 @@ export type MemmyAgentBootstrap = z.infer; export type ChatModelPreset = z.infer; export type MemmyAgentSettings = z.infer; export type MemmyAgentSessionSummary = z.infer; +export type WorkspaceEnvironmentSnapshot = z.infer; +export type WorkspaceEnvironmentFile = z.infer; +export type WorkspaceEnvironmentState = z.infer; +export type WorkspaceEnvironmentDiff = z.infer; +export type WorkspaceEnvironmentScope = { kind: "session" | "project"; key: string }; export type MemmyAgentProject = z.infer; export type MemmyAgentSessionSnapshot = z.infer; export type MemmyAgentSidebarState = z.infer; @@ -555,6 +641,18 @@ export interface MemmyAgentClient { getSettings(): Promise; getSessionSnapshot(options?: MemmyAgentRequestOptions): Promise; listSessions(): Promise; + readWorkspaceEnvironment(scope: WorkspaceEnvironmentScope): Promise; + readWorkspaceEnvironmentDiff(scope: WorkspaceEnvironmentScope, path: string): Promise; + switchWorkspaceEnvironmentBranch( + scope: WorkspaceEnvironmentScope, + branch: string, + expectedRevision: string + ): Promise; + createOrCheckoutWorkspaceEnvironmentBranch( + scope: WorkspaceEnvironmentScope, + branch: string, + expectedRevision: string + ): Promise; listSlashCommands(): Promise; readSidebarState(): Promise; writeSidebarState( @@ -884,6 +982,49 @@ class HttpMemmyAgentClient implements MemmyAgentClient { return (await this.getSessionSnapshot()).sessions; } + async readWorkspaceEnvironment(scope: WorkspaceEnvironmentScope): Promise { + const collection = scope.kind === "session" ? "sessions" : "projects"; + return this.request( + `/api/${collection}/${encodeURIComponent(scope.key)}/environment`, + WorkspaceEnvironmentStateSchema + ); + } + + async readWorkspaceEnvironmentDiff(scope: WorkspaceEnvironmentScope, path: string): Promise { + const collection = scope.kind === "session" ? "sessions" : "projects"; + const query = new URLSearchParams({ path }); + return this.request( + `/api/${collection}/${encodeURIComponent(scope.key)}/environment/diff?${query.toString()}`, + WorkspaceEnvironmentDiffSchema + ); + } + + async switchWorkspaceEnvironmentBranch( + scope: WorkspaceEnvironmentScope, + branch: string, + expectedRevision: string + ): Promise { + const collection = scope.kind === "session" ? "sessions" : "projects"; + return this.request( + `/api/${collection}/${encodeURIComponent(scope.key)}/environment/branch`, + WorkspaceEnvironmentStateSchema, + { method: "POST", body: { branch, expected_revision: expectedRevision } } + ); + } + + async createOrCheckoutWorkspaceEnvironmentBranch( + scope: WorkspaceEnvironmentScope, + branch: string, + expectedRevision: string + ): Promise { + const collection = scope.kind === "session" ? "sessions" : "projects"; + return this.request( + `/api/${collection}/${encodeURIComponent(scope.key)}/environment/branch`, + WorkspaceEnvironmentStateSchema, + { method: "POST", body: { branch, expected_revision: expectedRevision, create: true } } + ); + } + async listSlashCommands(): Promise { const response = await this.request("/api/commands", SlashCommandsResponseSchema); return response.commands diff --git a/App/frontend/desktop/src/api/memory-runtime-client.ts b/App/frontend/desktop/src/api/memory-runtime-client.ts index cc80b2fbc..573ee2a6a 100644 --- a/App/frontend/desktop/src/api/memory-runtime-client.ts +++ b/App/frontend/desktop/src/api/memory-runtime-client.ts @@ -15,6 +15,7 @@ import { MemoryProcessingStatusOutputSchema, MemoryReloadConfigInputSchema, MemoryReloadConfigOutputSchema, + RecallEvidenceOutputSchema, OpenSessionInputSchema, OpenSessionOutputSchema, PanelAnalysisOutputSchema, @@ -43,6 +44,7 @@ import { type MemoryProcessingStatusOutput, type MemoryReloadConfigInput, type MemoryReloadConfigOutput, + type RecallEvidenceOutput, type OpenSessionInput, type OpenSessionOutput, type PanelAnalysisOutput, @@ -73,6 +75,7 @@ export const MEMORY_RUNTIME_ENDPOINTS = [ "POST /api/v1/memory/:id/processing/retry", "GET /api/v1/memory/:id", "DELETE /api/v1/memory/:id", + "GET /api/v1/memory/recalls/:queryId", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", @@ -92,6 +95,7 @@ export interface MemoryRuntimeClient { addMemory(input: AddMemoryInput): Promise; getMemory(id: string): Promise; deleteMemory(id: string): Promise; + recallEvidence(queryId: string): Promise; getMemoryProcessingStatus(memoryIds: string[]): Promise; retryMemoryProcessing(id: string): Promise; listMemoryLogs(input: MemoryApiLogsInput): Promise; @@ -169,6 +173,14 @@ export function createHttpMemoryRuntimeClient(config: RuntimeConfig): MemoryRunt }); }, + async recallEvidence(queryId) { + return requestJson({ + config, + path: `/api/v1/memory/recalls/${encodeURIComponent(queryId)}`, + schema: RecallEvidenceOutputSchema + }); + }, + async getMemoryProcessingStatus(memoryIds) { return requestJson({ config, @@ -278,6 +290,9 @@ export function createUnavailableMemoryRuntimeClient(): MemoryRuntimeClient { async deleteMemory() { throw unavailable(); }, + async recallEvidence() { + throw unavailable(); + }, async getMemoryProcessingStatus() { throw unavailable(); }, 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 22b5be79f..f6aff2536 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 @@ -81,6 +81,105 @@ afterEach(() => { }); describe("memmy-agent client", () => { + it("reads the workspace snapshot, changed files, and a selected diff", async () => { + const calls: string[] = []; + const workspaceState = (project: boolean, branch = "zy_git_v1.0.7") => ({ + snapshot: { + scope_kind: project ? "project" : "session", + scope_key: project ? "project-1" : "websocket:chat-1", + cwd: "/workspace", + status: "ready", + revision: `rev-${branch}`, + captured_at: "2026-08-11T08:00:00.000Z", + repository: { + display_name: "memmy-agent", + root: "/workspace", + head_sha: "84d10f8", + branch, + detached: false, + upstream: null, + ahead: 0, + behind: 0, + worktree: "dirty" + }, + changes: { file_count: 1, additions: 8, deletions: 1, conflicts: 0, staged: 0, unstaged: 1, untracked: 0 }, + goal: null + }, + files: [{ + path: "src/panel.tsx", + status: ".M", + staged: false, + unstaged: true, + untracked: false, + conflict: false, + additions: 8, + deletions: 1, + attribution: "goal" + }], + branches: ["zy_git_v1.0.7", "main"] + }); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = new URL(String(input)); + calls.push(`${url.pathname}${url.search}`); + if (url.pathname === "/webui/bootstrap") return json(bootstrap); + if (url.pathname.endsWith("/environment/branch")) { + expect(init?.method).toBe("POST"); + const body = JSON.parse(String(init?.body)); + if (body.create === true) { + expect(body).toEqual({ branch: "feature/new", expected_revision: "rev-main", create: true }); + return json(workspaceState(url.pathname.includes("/projects/"), "feature/new")); + } + expect(body).toEqual({ branch: "main", expected_revision: "rev-zy_git_v1.0.7" }); + return json(workspaceState(url.pathname.includes("/projects/"), "main")); + } + if (url.pathname.endsWith("/environment/diff")) { + expect(url.searchParams.get("path")).toBe("src/panel.tsx"); + return json({ path: "src/panel.tsx", diff: "+panel", truncated: false, unavailable_reason: null }); + } + if (url.pathname.endsWith("/environment")) { + return json(workspaceState(url.pathname.includes("/projects/"))); + } + return json({ error: "not found" }, 404); + }); + const client = createMemmyAgentClient({ + baseUrl: "http://127.0.0.1:18980", + fetchFn: fetchMock as typeof fetch + }); + + await expect(client.readWorkspaceEnvironment({ kind: "session", key: "websocket:chat-1" })).resolves.toMatchObject({ + snapshot: { + repository: { branch: "zy_git_v1.0.7" }, + changes: { file_count: 1 } + }, + files: [{ path: "src/panel.tsx", attribution: "goal" }] + }); + await expect(client.readWorkspaceEnvironmentDiff({ kind: "session", key: "websocket:chat-1" }, "src/panel.tsx")).resolves.toMatchObject({ + diff: "+panel" + }); + await expect(client.readWorkspaceEnvironment({ kind: "project", key: "project-1" })).resolves.toMatchObject({ + snapshot: { repository: { branch: "zy_git_v1.0.7" } }, + files: [{ path: "src/panel.tsx" }], + branches: ["zy_git_v1.0.7", "main"] + }); + await expect(client.readWorkspaceEnvironmentDiff({ kind: "project", key: "project-1" }, "src/panel.tsx")).resolves.toMatchObject({ + diff: "+panel" + }); + await expect(client.switchWorkspaceEnvironmentBranch( + { kind: "project", key: "project-1" }, + "main", + "rev-zy_git_v1.0.7" + )).resolves.toMatchObject({ snapshot: { repository: { branch: "main" } } }); + await expect(client.createOrCheckoutWorkspaceEnvironmentBranch( + { kind: "project", key: "project-1" }, + "feature/new", + "rev-main" + )).resolves.toMatchObject({ snapshot: { repository: { branch: "feature/new" } } }); + expect(calls).toContain("/api/sessions/websocket%3Achat-1/environment/diff?path=src%2Fpanel.tsx"); + expect(calls).toContain("/api/projects/project-1/environment"); + expect(calls).toContain("/api/projects/project-1/environment/diff?path=src%2Fpanel.tsx"); + expect(calls).toContain("/api/projects/project-1/environment/branch"); + }); + it("prefers env override, then current origin, then local gateway default for base URL", () => { vi.stubEnv("VITE_MEMMY_AGENT_WEBUI_URL", "http://127.0.0.1:19000"); expect(defaultMemmyAgentBaseUrl()).toBe("http://127.0.0.1:19000"); diff --git a/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts b/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts index a10303624..e8d80fede 100644 --- a/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memory-runtime-client.test.ts @@ -12,7 +12,7 @@ describe("memory runtime client", () => { }); it("declares the memory runtime endpoints exposed under /api/v1", () => { - expect(MEMORY_RUNTIME_ENDPOINTS).toHaveLength(18); + expect(MEMORY_RUNTIME_ENDPOINTS).toHaveLength(19); expect(MEMORY_RUNTIME_ENDPOINTS).toEqual([ "GET /api/v1/health", "POST /api/v1/admin/reload-config", @@ -26,6 +26,7 @@ describe("memory runtime client", () => { "POST /api/v1/memory/:id/processing/retry", "GET /api/v1/memory/:id", "DELETE /api/v1/memory/:id", + "GET /api/v1/memory/recalls/:queryId", "GET /api/v1/memory/logs", "GET /api/v1/panel/overview", "GET /api/v1/panel/analysis", diff --git a/App/frontend/desktop/src/app.tsx b/App/frontend/desktop/src/app.tsx index d98b662da..4a51acae6 100644 --- a/App/frontend/desktop/src/app.tsx +++ b/App/frontend/desktop/src/app.tsx @@ -1,7 +1,7 @@ /** App module. */ import { SseEventSchema, type AccountSessionView, type SseEvent } from "@memmy/local-api-contracts"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { setAnalyticsUserMode } from "./analytics/analytics-context.js"; +import { setAnalyticsUserId, setAnalyticsUserMode } from "./analytics/analytics-context.js"; import { trackCloudAnalyticsEvent } from "./analytics/cloud-analytics.js"; import { trackAgentSourceScanOutcome } from "./analytics/memory-ui-analytics.js"; import { useAnalytics } from "./analytics/use-analytics.js"; @@ -88,6 +88,10 @@ function RuntimeApp() { setAnalyticsUserMode(state.bootstrap?.app.userMode ?? "unset"); }, [state.bootstrap?.app.userMode]); + useEffect(() => { + setAnalyticsUserId(state.account.userId); + }, [state.account.userId]); + useEffect(() => () => taskStateCoordinator?.dispose(), [taskStateCoordinator]); useEffect(() => { @@ -191,6 +195,7 @@ function RuntimeApp() { setClients(clients); setAnalyticsUserMode(effectiveBootstrap.app.userMode); + setAnalyticsUserId(accountSession.authenticated ? accountSession.profile.userId : null); dispatch(appActions.bootstrapLoaded(effectiveBootstrap, initialPath)); if (bootstrap.tokenUsage.totalTokens > 0) { const u = bootstrap.tokenUsage; @@ -212,6 +217,7 @@ function RuntimeApp() { } if (accountSession.authenticated) { dispatch(appActions.accountUpdated({ + userId: accountSession.profile.userId, email: accountSession.profile.email ?? "", phoneNumber: accountSession.profile.phoneNumber, nickname: accountSession.profile.nickname, diff --git a/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx b/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx index 72d12e347..3e471a665 100644 --- a/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx +++ b/App/frontend/desktop/src/app/tests/update-coordinator.test.tsx @@ -106,6 +106,67 @@ describe("UpdateCoordinatorProvider", () => { expect(downloadUpdate).toHaveBeenCalledTimes(1); }); + it("downloads from the inline account action without opening the installer dialog", async () => { + let resolveDownload!: (result: DesktopUpdateInstallResult) => void; + const downloadPromise = new Promise((resolve) => { + resolveDownload = resolve; + }); + const checkForUpdates = vi.fn(async () => ({ + status: "available" as const, + currentVersion: "2.1.0", + latestVersion: "2.2.0", + downloadUrl: "https://updates.example.com/Memmy.dmg" + })); + const downloadUpdate = vi.fn(() => downloadPromise); + setDesktopBridge({ + platform: "darwin", + getAppInfo: vi.fn(async () => ({ + name: "Memmy", + version: "2.1.0", + platform: "darwin", + arch: "arm64" + })), + checkForUpdates, + downloadUpdate + }); + + await act(async () => { + root.render( + + + + + + + + ); + }); + + await act(async () => { + getButtonByLabel("update-action").click(); + await Promise.resolve(); + }); + act(() => getButtonByText("稍后再说").click()); + expect(readOutput("phase")).toBe("available"); + + await act(async () => { + getButtonByLabel("inline-update-action").click(); + await Promise.resolve(); + }); + expect(readOutput("phase")).toBe("downloading"); + expect(downloadUpdate).toHaveBeenCalledWith( + expect.objectContaining({ latestVersion: "2.2.0" }), + { openInstaller: false } + ); + + await act(async () => { + resolveDownload({ filePath: "/tmp/Memmy-2.2.0.dmg", opened: false }); + await downloadPromise; + }); + expect(readOutput("phase")).toBe("prepared"); + expect(container.textContent).not.toContain("安装包已准备好,是否重启并安装更新?"); + }); + it("keeps the prepared installer path when launching the installer fails", async () => { const checkForUpdates = vi.fn(async () => ({ status: "available" as const, @@ -249,13 +310,22 @@ function UpdateHarness() { Toggle route {routeContentVisible && ( - + <> + + + )} {update.phase} {update.preparedUpdatePath ?? ""} diff --git a/App/frontend/desktop/src/app/update-coordinator.tsx b/App/frontend/desktop/src/app/update-coordinator.tsx index f185b49d0..ae7bf34c9 100644 --- a/App/frontend/desktop/src/app/update-coordinator.tsx +++ b/App/frontend/desktop/src/app/update-coordinator.tsx @@ -39,11 +39,16 @@ export interface UpdateCoordinatorValue { preparedUpdatePath: string | null; downloadProgress: DesktopUpdateDownloadProgress | null; feedback: UpdateFeedback | null; + requestInlineAction(): Promise; requestPrimaryAction(): Promise; } type UpdateDialogKind = "download-confirm" | "install-confirm" | null; +interface DownloadUpdateOptions { + showInstallDialog?: boolean; +} + interface UpdateCoordinatorState { phase: UpdatePhase; result: DesktopUpdateCheckResult | null; @@ -60,7 +65,7 @@ interface UpdateCoordinatorContextValue extends UpdateCoordinatorValue { confirmDialog(): Promise; } -const UPDATE_NOTIFICATION_FIRST_CHECK_DELAY_MS = 60_000; +const UPDATE_NOTIFICATION_FIRST_CHECK_DELAY_MS = 5_000; const UPDATE_NOTIFICATION_INTERVAL_MS = 60 * 60 * 1000; const INITIAL_UPDATE_STATE: UpdateCoordinatorState = { @@ -179,7 +184,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { return request; }, [appVersion]); - const downloadUpdate = useCallback(async (update: DesktopUpdateCheckResult): Promise => { + const downloadUpdate = useCallback(async (update: DesktopUpdateCheckResult, options: DownloadUpdateOptions = {}): Promise => { if (downloadInFlightRef.current) { await downloadInFlightRef.current; return; @@ -237,7 +242,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { preparedUpdatePath: installResult.filePath, downloadProgress: null, feedback: { key: "settings.about.silentReady", values: { version } }, - dialog: "install-confirm" + dialog: options.showInstallDialog === false ? null : "install-confirm" })); } catch (error) { if (!mountedRef.current) { @@ -421,6 +426,23 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { await checkManually(); }, [checkManually, commitUpdateState]); + const requestInlineAction = useCallback(async (): Promise => { + const current = updateStateRef.current; + if (isUpdateBusy(current.phase)) { + return; + } + if (current.phase === "prepared" && current.result && current.preparedUpdatePath) { + await installPreparedUpdate(); + return; + } + if (current.phase === "available" && current.result?.downloadUrl) { + await downloadUpdate(current.result, { showInstallDialog: false }); + return; + } + + await checkManually(); + }, [checkManually, downloadUpdate, installPreparedUpdate]); + const dismissDialog = useCallback(() => { commitUpdateState((state) => ({ ...state, dialog: null })); }, [commitUpdateState]); @@ -436,10 +458,39 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { } }, [downloadUpdate, installPreparedUpdate]); + const commitPassiveAvailableUpdate = useCallback((result: DesktopUpdateCheckResult): void => { + const version = result.latestVersion ?? result.currentVersion; + commitUpdateState((current) => { + if (isForegroundUpdateFlow(current)) { + return current; + } + if (result.preparedUpdatePath) { + return { + phase: "prepared", + result, + preparedUpdatePath: result.preparedUpdatePath ?? null, + downloadProgress: null, + feedback: { key: "settings.about.silentReady", values: { version } }, + dialog: null + }; + } + return { + phase: "available", + result, + preparedUpdatePath: null, + downloadProgress: null, + feedback: result.downloadUrl + ? { key: "settings.about.updateReady", values: { version } } + : { key: "settings.about.updateAvailableNoLink", values: { version } }, + dialog: null + }; + }); + }, [commitUpdateState]); + const startupReady = appState.startup.status === "ready"; useEffect(() => { const bridge = typeof window === "undefined" ? undefined : window.memmy; - if (!startupReady || !bridge?.checkForUpdates || !bridge.notifyUpdateAvailable) { + if (!startupReady || !bridge?.checkForUpdates) { return; } @@ -454,6 +505,9 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { if (disposed) { return; } + if (result.status === "available") { + commitPassiveAvailableUpdate(result); + } const notificationContext = notificationContextRef.current; const plan = decideUpdateNotification({ enabled: notificationContext.enabled, @@ -466,6 +520,9 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { return; } + if (!bridge.notifyUpdateAvailable) { + return; + } lastNotifiedUpdateVersionRef.current = plan.version; void bridge.notifyUpdateAvailable({ title: notificationContext.translate("notification.update.title"), @@ -489,7 +546,7 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { clearTimeout(firstCheckTimer); clearInterval(intervalTimer); }; - }, [requestUpdateResult, startupReady]); + }, [commitPassiveAvailableUpdate, requestUpdateResult, startupReady]); const value = useMemo(() => ({ appVersion, @@ -499,10 +556,11 @@ export function UpdateCoordinatorProvider(props: { children: ReactNode }) { feedback: updateState.feedback, result: updateState.result, dialog: updateState.dialog, + requestInlineAction, requestPrimaryAction, dismissDialog, confirmDialog - }), [appVersion, confirmDialog, dismissDialog, requestPrimaryAction, updateState]); + }), [appVersion, confirmDialog, dismissDialog, requestInlineAction, requestPrimaryAction, updateState]); return ( @@ -516,6 +574,11 @@ export function useUpdateCoordinator(): UpdateCoordinatorValue { return useUpdateCoordinatorContext(); } +/** Reads the app-level update state when a provider is present. */ +export function useOptionalUpdateCoordinator(): UpdateCoordinatorValue | null { + return useContext(UpdateCoordinatorContext); +} + /** Renders the update dialog above route-specific pages. */ export function GlobalUpdateDialog(props: { suspended?: boolean }) { const update = useUpdateCoordinatorContext(); diff --git a/App/frontend/desktop/src/global.d.ts b/App/frontend/desktop/src/global.d.ts index 01a013b51..3a99b3323 100644 --- a/App/frontend/desktop/src/global.d.ts +++ b/App/frontend/desktop/src/global.d.ts @@ -29,6 +29,7 @@ declare global { platform: string; getRuntimeConfig(): Promise; getAppInfo(): Promise; + getInstallationId(): Promise; checkForUpdates(): Promise; downloadUpdate(update: DesktopUpdateCheckResult, options?: import("@memmy/desktop-interface").DesktopUpdateDownloadOptions): Promise; onUpdateDownloadProgress(callback: (progress: DesktopUpdateDownloadProgress) => void): () => void; diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 4721e8da3..9afa19f8b 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -92,6 +92,16 @@ export const zhCNMessages = { "appFrame.accountMetaFallback": "未绑定手机号或邮箱", "appFrame.unsetName": "未选择模式", "appFrame.unsetMeta": "重新选择登录方式", + "appFrame.update.available": "更新", + "appFrame.update.availableAria": "下载 Memmy 更新", + "appFrame.update.downloading": "下载中", + "appFrame.update.downloadingAria": "Memmy 更新下载中", + "appFrame.update.progress": "{percent}%", + "appFrame.update.progressAria": "Memmy 更新已下载 {percent}%", + "appFrame.update.installing": "正在安装", + "appFrame.update.installingAria": "Memmy 更新正在安装", + "appFrame.update.restart": "重启", + "appFrame.update.restartAria": "重启 Memmy 安装更新", "appFrame.renameTaskPrompt": "重命名任务", "appFrame.renameTaskPlaceholder": "输入新的任务名称", "appFrame.deleteArchivedTitle": "删除已归档对话?", @@ -547,11 +557,73 @@ export const zhCNMessages = { "home.project.standalone": "不使用项目", "home.project.clear": "清除项目", "home.project.desktopRequired": "请在 Memmy 桌面端打开本地文件夹", + "home.environment.title": "环境信息", + "home.environment.refresh": "刷新环境信息", + "home.environment.refreshShort": "刷新", + "home.environment.close": "关闭环境信息", + "home.environment.closeShort": "关闭", + "home.environment.gitWorkspace": "Git 工作区信息", + "home.environment.local": "本地", + "home.environment.detachedHead": "分离 HEAD", + "home.environment.notGit": "当前目录不是 Git 仓库", + "home.environment.workspaceUnavailable": "工作区不可用", + "home.environment.readFailed": "读取 Git 状态失败", + "home.environment.changes": "变更", + "home.environment.attribution.goal": "目标", + "home.environment.attribution.preexisting": "原有", + "home.environment.attribution.uncertain": "待确认", + "home.environment.diff.untrackedUnavailable": "未跟踪文件暂不展示 diff", + "home.environment.diff.empty": "暂无可展示的 diff", + "home.environment.diff.loading": "正在加载 Diff…", + "home.environment.diff.label": "{{path}} 的代码改动", + "home.environment.diff.truncated": "Diff 已截断", + "home.environment.clean": "工作区干净", + "home.environment.goal.title": "Goal 证据", + "home.environment.goal.risk": "存在风险", + "home.environment.goal.satisfied": "审计通过", + "home.environment.goal.baseline": "基线", + "home.environment.goal.files": "目标新增", + "home.environment.goal.preexisting": "原有变更", + "home.environment.goal.uncertain": "待确认", + "home.environment.goal.verification": "验证", + "home.environment.goal.notRun": "未运行", + "home.environment.mode.title": "工作模式", + "home.environment.mode.newWorktree": "新工作树", + "home.environment.mode.newWorktreeUnavailable": "当前版本暂不支持创建新工作树", + "home.environment.branch.label": "分支 {branch}", + "home.environment.branch.search": "搜索 {repository} 分支", + "home.environment.branch.searchAria": "搜索分支", + "home.environment.branch.title": "分支", + "home.environment.branch.localList": "本地分支", + "home.environment.branch.empty": "没有匹配的分支", + "home.environment.branch.repositoryFallback": "仓库", + "home.environment.branch.dirtyConfirm": "当前有未提交变更,切换分支可能会把这些变更带到目标分支。仍要继续吗?", + "home.environment.branch.createOrCheckout": "创建或检出新分支", + "home.environment.branch.newNameAria": "新分支名称", + "home.environment.branch.newNamePlaceholder": "输入新分支名称", + "home.environment.branch.cancelCreate": "取消", + "home.environment.branch.confirmCreate": "创建并检出", "home.agent.restart": "重启 Agent", "home.agent.restarting": "正在重启 Agent", "home.agent.restartCompleted": "Agent 重启完成", "home.agent.restartFailed": "Agent 重启失败", "home.agent.platformApiFallback": "抱歉,刚刚没有拿到有效回复,请稍后再试一次。", + "home.memoryEvidence.show": "查看本轮记忆依据", + "home.memoryEvidence.hide": "收起记忆依据", + "home.memoryEvidence.loading": "正在读取本轮注入的记忆…", + "home.memoryEvidence.empty": "本轮没有注入长期记忆", + "home.memoryEvidence.unavailable": "暂时无法读取本轮记忆依据", + "home.memoryEvidence.deleteFailed": "部分记忆删除失败,请重试", + "home.memoryEvidence.delete": "删除", + "home.memoryEvidence.undo": "撤销", + "home.memoryEvidence.recallScore": "召回分 {score}", + "home.memoryEvidence.sourceConversation": "来源对话", + "home.memoryEvidence.updatedAt": "更新于", + "home.memoryEvidence.layer.userMemory": "用户记忆", + "home.memoryEvidence.layer.l1": "L1 记忆", + "home.memoryEvidence.layer.agentMemory": "Agent 记忆", + "home.memoryEvidence.sourceTurn": "来源 Turn", + "home.memoryEvidence.sourceSkill": "来源 Skill", "home.media.encoding": "正在处理图片", "home.media.normalized": "已压缩", "home.media.error.unsupported": "仅支持 PNG、JPG/JPEG、WebP、GIF 图片,以及 PDF、DOCX、XLSX、PPTX 或文本文件", @@ -799,6 +871,7 @@ export const zhCNMessages = { "memory.nav.system": "系统", "memory.nav.overview": "概览", "memory.nav.memory": "记忆", + "memory.nav.userMemories": "用户记忆", "memory.nav.tasks": "任务", "memory.nav.policies": "经验", "memory.nav.worldModel": "场域认知", @@ -812,6 +885,8 @@ export const zhCNMessages = { "memory.overview.total": "记忆总数", "memory.overview.memories": "记忆数量", "memory.overview.memoriesHint": "L1 原始执行与对话记忆", + "memory.overview.userMemories": "用户记忆数量", + "memory.overview.userMemoriesHint": "用户事实、偏好和明确指令", "memory.overview.skills": "技能数量", "memory.overview.skillsHint": "可重复调用的沉淀能力", "memory.overview.policies": "经验数量", @@ -844,6 +919,22 @@ export const zhCNMessages = { "memory.overview.serverTime": "服务时间", "memory.memories.title": "记忆", "memory.memories.description": "Agent 每步的执行与反思痕迹。", + "memory.userMemories.title": "用户记忆", + "memory.userMemories.description": "用户事实、偏好和明确指令;独立于 Agent 经验记忆。", + "memory.userMemories.searchPlaceholder": "搜索用户记忆", + "memory.userMemories.loading": "正在加载用户记忆…", + "memory.userMemories.empty": "暂无用户记忆", + "memory.userMemories.detailTitle": "用户记忆详情", + "memory.userMemories.content": "内容", + "memory.userMemories.type": "类型", + "memory.userMemories.type.fact": "用户事实", + "memory.userMemories.type.preference": "用户偏好", + "memory.userMemories.type.directive": "用户指令", + "memory.userMemories.status.active": "有效", + "memory.userMemories.status.archived": "已归档", + "memory.userMemories.status.deleted": "已删除", + "memory.userMemories.sourceTurn": "来源 Turn", + "memory.userMemories.expressionCount": "重复表达次数", "memory.memories.loading": "正在加载记忆列表", "memory.memories.empty": "暂无记忆", "memory.memories.searchPlaceholder": "搜索记忆...", @@ -1625,6 +1716,16 @@ export const enUSMessages: Record = { "appFrame.accountMetaFallback": "No phone or email linked", "appFrame.unsetName": "No mode selected", "appFrame.unsetMeta": "Choose a sign-in method again", + "appFrame.update.available": "Update", + "appFrame.update.availableAria": "Download Memmy update", + "appFrame.update.downloading": "Downloading", + "appFrame.update.downloadingAria": "Memmy update is downloading", + "appFrame.update.progress": "{percent}%", + "appFrame.update.progressAria": "Memmy update is {percent}% downloaded", + "appFrame.update.installing": "Installing", + "appFrame.update.installingAria": "Memmy update is installing", + "appFrame.update.restart": "Restart", + "appFrame.update.restartAria": "Restart Memmy to install the update", "appFrame.renameTaskPrompt": "Rename task", "appFrame.renameTaskPlaceholder": "Enter a new task name", "appFrame.deleteArchivedTitle": "Delete archived conversation?", @@ -2080,11 +2181,73 @@ export const enUSMessages: Record = { "home.project.standalone": "No project", "home.project.clear": "Clear project", "home.project.desktopRequired": "Open a local folder in the Memmy desktop app", + "home.environment.title": "Environment information", + "home.environment.refresh": "Refresh environment information", + "home.environment.refreshShort": "Refresh", + "home.environment.close": "Close environment information", + "home.environment.closeShort": "Close", + "home.environment.gitWorkspace": "Git workspace information", + "home.environment.local": "Local", + "home.environment.detachedHead": "Detached HEAD", + "home.environment.notGit": "Not a Git repository", + "home.environment.workspaceUnavailable": "Workspace unavailable", + "home.environment.readFailed": "Failed to read Git status", + "home.environment.changes": "Changes", + "home.environment.attribution.goal": "Goal", + "home.environment.attribution.preexisting": "Existing", + "home.environment.attribution.uncertain": "Uncertain", + "home.environment.diff.untrackedUnavailable": "Diff is unavailable for untracked files", + "home.environment.diff.empty": "No diff available", + "home.environment.diff.loading": "Loading diff…", + "home.environment.diff.label": "Code changes for {{path}}", + "home.environment.diff.truncated": "Diff truncated", + "home.environment.clean": "Working tree clean", + "home.environment.goal.title": "Goal evidence", + "home.environment.goal.risk": "Risk", + "home.environment.goal.satisfied": "Audit satisfied", + "home.environment.goal.baseline": "Baseline", + "home.environment.goal.files": "Goal files", + "home.environment.goal.preexisting": "Pre-existing", + "home.environment.goal.uncertain": "Uncertain", + "home.environment.goal.verification": "Verification", + "home.environment.goal.notRun": "Not run", + "home.environment.mode.title": "Work mode", + "home.environment.mode.newWorktree": "New worktree", + "home.environment.mode.newWorktreeUnavailable": "Creating a worktree is not supported yet", + "home.environment.branch.label": "Branch {branch}", + "home.environment.branch.search": "Search {repository} branches", + "home.environment.branch.searchAria": "Search branches", + "home.environment.branch.title": "Branches", + "home.environment.branch.localList": "Local branches", + "home.environment.branch.empty": "No matching branches", + "home.environment.branch.repositoryFallback": "repository", + "home.environment.branch.dirtyConfirm": "There are uncommitted changes. Switching may carry them to the target branch. Continue?", + "home.environment.branch.createOrCheckout": "Create or checkout new branch", + "home.environment.branch.newNameAria": "New branch name", + "home.environment.branch.newNamePlaceholder": "Enter a new branch name", + "home.environment.branch.cancelCreate": "Cancel", + "home.environment.branch.confirmCreate": "Create and checkout", "home.agent.restart": "Restart Agent", "home.agent.restarting": "Restarting Agent", "home.agent.restartCompleted": "Agent restarted", "home.agent.restartFailed": "Agent restart failed", "home.agent.platformApiFallback": "Sorry, I couldn't get a valid response. Please try again in a moment.", + "home.memoryEvidence.show": "View memory used", + "home.memoryEvidence.hide": "Hide memory used", + "home.memoryEvidence.loading": "Loading memories injected for this turn…", + "home.memoryEvidence.empty": "No long-term memory was injected for this turn", + "home.memoryEvidence.unavailable": "Memory evidence is currently unavailable", + "home.memoryEvidence.deleteFailed": "Some memories could not be deleted. Please try again.", + "home.memoryEvidence.delete": "Delete", + "home.memoryEvidence.undo": "Undo", + "home.memoryEvidence.recallScore": "Recall {score}", + "home.memoryEvidence.sourceConversation": "Source conversation", + "home.memoryEvidence.updatedAt": "Updated", + "home.memoryEvidence.layer.userMemory": "User Memory", + "home.memoryEvidence.layer.l1": "L1 Memory", + "home.memoryEvidence.layer.agentMemory": "Agent Memory", + "home.memoryEvidence.sourceTurn": "Source turn", + "home.memoryEvidence.sourceSkill": "Source Skill", "home.media.encoding": "Processing image", "home.media.normalized": "compressed", "home.media.error.unsupported": "Only PNG, JPG/JPEG, WebP, GIF images, PDF, DOCX, XLSX, PPTX, or text files are supported", @@ -2331,6 +2494,7 @@ export const enUSMessages: Record = { "memory.nav.system": "System", "memory.nav.overview": "Overview", "memory.nav.memory": "Memories", + "memory.nav.userMemories": "User Memory", "memory.nav.tasks": "Tasks", "memory.nav.policies": "Experiences", "memory.nav.worldModel": "World models", @@ -2344,6 +2508,8 @@ export const enUSMessages: Record = { "memory.overview.total": "Total memories", "memory.overview.memories": "Memories", "memory.overview.memoriesHint": "L1 execution and conversation memories", + "memory.overview.userMemories": "User Memory", + "memory.overview.userMemoriesHint": "User facts, preferences, and explicit directives", "memory.overview.skills": "Skills", "memory.overview.skillsHint": "Reusable crystallized capabilities", "memory.overview.policies": "Experiences", @@ -2376,6 +2542,22 @@ export const enUSMessages: Record = { "memory.overview.serverTime": "Server time", "memory.memories.title": "Memories", "memory.memories.description": "Execution and reflection traces from each agent step.", + "memory.userMemories.title": "User Memory", + "memory.userMemories.description": "User facts, preferences, and explicit directives, kept separate from agent experience.", + "memory.userMemories.searchPlaceholder": "Search user memory", + "memory.userMemories.loading": "Loading user memory…", + "memory.userMemories.empty": "No user memory yet", + "memory.userMemories.detailTitle": "User memory details", + "memory.userMemories.content": "Content", + "memory.userMemories.type": "Type", + "memory.userMemories.type.fact": "User fact", + "memory.userMemories.type.preference": "User preference", + "memory.userMemories.type.directive": "User directive", + "memory.userMemories.status.active": "Active", + "memory.userMemories.status.archived": "Archived", + "memory.userMemories.status.deleted": "Deleted", + "memory.userMemories.sourceTurn": "Source turn", + "memory.userMemories.expressionCount": "Expression count", "memory.memories.loading": "Loading memories", "memory.memories.empty": "No memories", "memory.memories.searchPlaceholder": "Search memories...", diff --git a/App/frontend/desktop/src/pages/agent-environment-panel.tsx b/App/frontend/desktop/src/pages/agent-environment-panel.tsx new file mode 100644 index 000000000..7af1611a2 --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-environment-panel.tsx @@ -0,0 +1,250 @@ +import { useEffect, useId, useRef, useState } from "react"; +import { + AlertTriangle, + CheckCircle2, + ChevronDown, + ChevronRight, + FileCode2, + FolderGit2, + GitBranch, + RefreshCw, + X, +} from "lucide-react"; +import type { + MemmyAgentClient, + WorkspaceEnvironmentDiff, + WorkspaceEnvironmentFile, + WorkspaceEnvironmentState, +} from "../api/memmy-agent-client.js"; +import { useTranslation } from "../i18n/use-translation.js"; +import { WorkspaceDiffView } from "./workspace-diff-view.js"; + +type AgentEnvironmentPanelProps = { + client: MemmyAgentClient | null; + scope: "session" | "project"; + scopeKey: string; + environment: WorkspaceEnvironmentState | null; + loading: boolean; + error: string | null; + onRefresh: () => Promise; + onClose: () => void; +}; + +function shortSha(value: string): string { + return value ? value.slice(0, 7) : "—"; +} + +function countLabel(value: number | null, prefix: "+" | "-"): string { + return value == null ? "?" : `${prefix}${value.toLocaleString()}`; +} + +export function AgentEnvironmentPanel({ + client, + scope, + scopeKey, + environment, + loading, + error, + onRefresh, + onClose, +}: AgentEnvironmentPanelProps) { + const { t } = useTranslation(); + const titleId = useId(); + const panelRef = useRef(null); + const [diff, setDiff] = useState(null); + const [selectedPath, setSelectedPath] = useState(null); + const [filesOpen, setFilesOpen] = useState(true); + const diffRequestIdRef = useRef(0); + const snapshot = environment?.snapshot ?? null; + const files = environment?.files ?? []; + + useEffect(() => { + diffRequestIdRef.current += 1; + setDiff(null); + setSelectedPath(null); + return () => { + diffRequestIdRef.current += 1; + }; + }, [scope, scopeKey, snapshot?.revision]); + + useEffect(() => { + function closeOnEscape(event: globalThis.KeyboardEvent) { + if (event.key === "Escape") onClose(); + } + function closeOnOutsidePointer(event: globalThis.PointerEvent) { + const target = event.target instanceof Node ? event.target : null; + const toggle = target instanceof Element + ? target.closest("[data-agent-environment-toggle]") + : null; + if ( + !panelRef.current?.contains(target) + && !toggle + ) { + onClose(); + } + } + window.addEventListener("keydown", closeOnEscape); + document.addEventListener("pointerdown", closeOnOutsidePointer); + return () => { + window.removeEventListener("keydown", closeOnEscape); + document.removeEventListener("pointerdown", closeOnOutsidePointer); + }; + }, [onClose]); + + async function selectFile(file: WorkspaceEnvironmentFile) { + if (!client) return; + if (selectedPath === file.path) { + diffRequestIdRef.current += 1; + setSelectedPath(null); + setDiff(null); + return; + } + const requestId = ++diffRequestIdRef.current; + setSelectedPath(file.path); + setDiff(null); + try { + const next = await client.readWorkspaceEnvironmentDiff({ kind: scope, key: scopeKey }, file.path); + if (requestId === diffRequestIdRef.current) setDiff(next); + } catch (cause) { + if (requestId === diffRequestIdRef.current) { + setDiff({ + path: file.path, + diff: "", + truncated: false, + unavailable_reason: cause instanceof Error ? cause.message : "diff_unavailable", + }); + } + } + } + + async function refresh() { + diffRequestIdRef.current += 1; + setDiff(null); + setSelectedPath(null); + await onRefresh(); + } + + const statusLabel = snapshot?.status === "not_git" + ? t("home.environment.notGit") + : snapshot?.status === "workspace_unavailable" + ? t("home.environment.workspaceUnavailable") + : snapshot?.status === "error" + ? t("home.environment.readFailed") + : null; + + return ( + + ); +} diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index cb63af516..6907a89f3 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -24,6 +24,7 @@ import { Search, Terminal, Trash2, + Undo2, Wand2, X, } from "lucide-react"; @@ -44,6 +45,8 @@ import { useTranslation } from "../i18n/use-translation.js"; import type { MessageKey, MessageValues, ResolvedLanguage } from "../i18n/messages.js"; import { WINDOW_CONTROLS_OVERLAY_SAFE_TOP_STYLE } from "../theme/window-controls-overlay.js"; import type { AgentChatMediaAttachment, AgentChatMessage, AgentCompactionStatus, AgentRetryWaitStatus } from "../state/agent-chat-slice.js"; +import type { RecallEvidenceOutput, RecallHit } from "@memmy/local-api-contracts"; +import type { MemoryRuntimeClient } from "../api/memory-runtime-client.js"; import { formatToolCallTrace, summarizeToolCall, @@ -73,6 +76,7 @@ interface AgentThreadMessagesProps { historyVersion?: number; isSending?: boolean; sanitizePlatformApiErrors?: boolean; + memoryRuntimeClient?: Pick | null; } export type AgentDisplayUnit = @@ -133,6 +137,10 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen [props.chatScopeKey, props.messages, props.retryWaitStatus] ); const finalAssistantAnswerIndex = useMemo(() => findFinalAssistantAnswerUnitIndex(units, { isSending: props.isSending }), [props.isSending, units]); + const recallEvidenceAnchor = useMemo( + () => findRecallEvidenceUserAnchor(units, finalAssistantAnswerIndex), + [finalAssistantAnswerIndex, units] + ); const [manualOpenByActivityKey, setManualOpenByActivityKey] = useState>({}); const previousRunningByActivityKey = useRef>({}); const activityRunningByKey = useMemo(() => { @@ -214,6 +222,8 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen deferContentRender={shouldDeferAgentMessageContent(unit, index, units.length)} deferredRevealDelayMs={deferredAgentMessageRevealDelay(index, units.length)} sanitizePlatformApiErrors={props.sanitizePlatformApiErrors === true} + memoryRuntimeClient={props.memoryRuntimeClient} + recallEvidenceTurnId={index === recallEvidenceAnchor?.unitIndex ? recallEvidenceAnchor.turnId : undefined} /> {unit.message.id === props.afterMessageId ? props.afterMessageContent : null} @@ -236,7 +246,8 @@ function areAgentThreadMessagesPropsEqual(previous: AgentThreadMessagesProps, ne && previous.historyVersion === next.historyVersion && previous.isSending === next.isSending && previous.retryWaitStatus === next.retryWaitStatus - && previous.sanitizePlatformApiErrors === next.sanitizePlatformApiErrors; + && previous.sanitizePlatformApiErrors === next.sanitizePlatformApiErrors + && previous.memoryRuntimeClient === next.memoryRuntimeClient; } export function buildAgentDisplayUnits(messages: AgentChatMessage[], options: { chatScopeKey: string; retryWaitStatus?: AgentRetryWaitStatus | null }): AgentDisplayUnit[] { @@ -383,6 +394,22 @@ function findLastUserUnitIndex(units: AgentDisplayUnit[]): number { return -1; } +function findRecallEvidenceUserAnchor( + units: AgentDisplayUnit[], + finalAssistantAnswerIndex: number +): { unitIndex: number; turnId: string } | null { + if (finalAssistantAnswerIndex < 0) return null; + const answer = units[finalAssistantAnswerIndex]; + if (answer?.type !== "single" || answer.message.role !== "assistant") return null; + for (let index = finalAssistantAnswerIndex - 1; index >= 0; index -= 1) { + const unit = units[index]; + if (unit?.type !== "single" || unit.message.role !== "user") continue; + const turnId = answer.message.turnId ?? unit.message.turnId; + return turnId ? { unitIndex: index, turnId } : null; + } + return null; +} + interface SingleMessageProps { message: AgentChatMessage; artifactClient?: AgentArtifactClient | null; @@ -393,6 +420,8 @@ interface SingleMessageProps { deferContentRender?: boolean; deferredRevealDelayMs?: number; sanitizePlatformApiErrors?: boolean; + memoryRuntimeClient?: Pick | null; + recallEvidenceTurnId?: string; } const SingleMessage = memo(function SingleMessage(props: SingleMessageProps) { @@ -405,18 +434,25 @@ const SingleMessage = memo(function SingleMessage(props: SingleMessageProps) { if (message.role === "user") { const hasContent = message.content.trim().length > 0; const timestamp = messageTimestamp(message.createdAt, language, t); + const copyAction = ; return (
-
+
{message.media?.length ? ( ) : null} {hasContent ? ( -
+
{message.content}
- + {props.recallEvidenceTurnId && props.memoryRuntimeClient ? ( + + ) : copyAction}
) : null}
@@ -502,6 +538,268 @@ const SingleMessage = memo(function SingleMessage(props: SingleMessageProps) { ); }, areSingleMessagePropsEqual); +type RecallMember = NonNullable[number]; + +function TurnRecallEvidence(props: { + turnId: string; + client: Pick; + trailingAction?: ReactNode; +}) { + const { language, t } = useTranslation(); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(false); + const [evidence, setEvidence] = useState(null); + const [loadError, setLoadError] = useState(false); + const [deleteError, setDeleteError] = useState(false); + const [deletedIds, setDeletedIds] = useState>(() => new Set()); + const [pendingDeleteHitIds, setPendingDeleteHitIds] = useState>(() => new Set()); + const [committingDeleteHitIds, setCommittingDeleteHitIds] = useState>(() => new Set()); + const pendingDeleteMembersRef = useRef(new Map()); + const mountedRef = useRef(true); + const lifecycleGenerationRef = useRef(0); + + useEffect(() => { + const generation = lifecycleGenerationRef.current + 1; + lifecycleGenerationRef.current = generation; + mountedRef.current = true; + return () => { + if (lifecycleGenerationRef.current === generation) mountedRef.current = false; + const members = uniqueRecallMembers([...pendingDeleteMembersRef.current.values()].flat()); + pendingDeleteMembersRef.current.clear(); + if (members.length > 0) { + void Promise.allSettled(members.map((member) => props.client.deleteMemory(member.id))); + } + }; + }, [props.client, props.turnId]); + + const commitDelete = async (entries: Array<[string, RecallMember[]]>) => { + const generation = lifecycleGenerationRef.current; + const hitIds = entries.map(([hitId]) => hitId); + const uniqueMembers = uniqueRecallMembers(entries.flatMap(([, members]) => members)); + const results = await Promise.allSettled( + uniqueMembers.map((member) => props.client.deleteMemory(member.id)) + ); + if (!mountedRef.current || lifecycleGenerationRef.current !== generation) return; + const deletedMemberIds = uniqueMembers + .filter((_, index) => { + const result = results[index]; + return result?.status === "fulfilled" + || (result?.status === "rejected" && isAlreadyDeletedError(result.reason)); + }) + .map((member) => member.id); + setDeletedIds((current) => new Set([...current, ...deletedMemberIds])); + setCommittingDeleteHitIds((current) => { + const next = new Set(current); + hitIds.forEach((hitId) => next.delete(hitId)); + return next; + }); + setDeleteError(results.some((result) => + result.status === "rejected" && !isAlreadyDeletedError(result.reason) + )); + }; + + const flushPendingDeletes = () => { + const entries = [...pendingDeleteMembersRef.current.entries()]; + if (entries.length === 0) return; + pendingDeleteMembersRef.current.clear(); + setPendingDeleteHitIds(new Set()); + setCommittingDeleteHitIds((current) => new Set([ + ...current, + ...entries.map(([hitId]) => hitId) + ])); + void commitDelete(entries); + }; + + const toggle = async () => { + if (open) { + setOpen(false); + flushPendingDeletes(); + return; + } + setOpen(true); + if (evidence || loading) return; + setLoading(true); + setLoadError(false); + try { + setEvidence(await props.client.recallEvidence(props.turnId)); + } catch { + setLoadError(true); + } finally { + setLoading(false); + } + }; + + const stageDelete = (hit: RecallHit) => { + if (pendingDeleteMembersRef.current.has(hit.id)) return; + const members = recallMembers(hit).filter((member) => !deletedIds.has(member.id)); + if (members.length === 0) return; + setDeleteError(false); + pendingDeleteMembersRef.current.set(hit.id, members); + setPendingDeleteHitIds((current) => new Set([...current, hit.id])); + }; + + const undoDelete = (hitId: string) => { + if (!pendingDeleteMembersRef.current.delete(hitId)) return; + setPendingDeleteHitIds((current) => { + const next = new Set(current); + next.delete(hitId); + return next; + }); + }; + + return ( +
+
+ + {props.trailingAction} +
+ {open ? ( +
+ {loading ? ( +

+ {t("home.memoryEvidence.loading")} +

+ ) : null} + {loadError ? ( +

+ {t("home.memoryEvidence.unavailable")} +

+ ) : null} + {deleteError ? ( +

+ {t("home.memoryEvidence.deleteFailed")} +

+ ) : null} + {!loading && !loadError && evidence?.hits.length === 0 ? ( +

+ {t("home.memoryEvidence.empty")} +

+ ) : null} + {evidence?.hits.map((hit) => { + const members = recallMembers(hit); + const deleted = members.length > 0 && members.every((member) => deletedIds.has(member.id)); + const pendingDelete = pendingDeleteHitIds.has(hit.id); + if (committingDeleteHitIds.has(hit.id) || deleted) return null; + const layers = recallHitLayers(hit, members); + const updatedAt = hit.updatedAt ?? members[0]?.updatedAt; + const traceId = recallHitTraceId(hit, members); + return ( +
+
+
+

+ {traceId} +

+ {layers.map((layer) => ( + + {recallLayerLabel(layer, t)} + + ))} + + {t("home.memoryEvidence.recallScore", { score: hit.score.toFixed(2) })} + +
+ +
+ {hit.sourceAgentId ? ( +

+ {t("home.memoryEvidence.sourceSkill")}: {hit.sourceAgentId} + {hit.sourceSkillId ? ` / ${hit.sourceSkillId}` : ""} + {hit.sourceSkillVersion ? ` @ ${hit.sourceSkillVersion}` : ""} +

+ ) : null} +

+ {hit.snippet} +

+
+ {hit.sourceTurnId ? {t("home.memoryEvidence.sourceConversation")} : null} + {updatedAt ? ( + {t("home.memoryEvidence.updatedAt")} {formatRecallMemberTime(updatedAt, language)} + ) : null} +
+
+ ); + })} +
+ ) : null} +
+ ); +} + +function recallMembers(hit: RecallHit): RecallMember[] { + if (hit.members?.length) return hit.members; + const createdAt = hit.createdAt ?? hit.updatedAt; + const updatedAt = hit.updatedAt ?? hit.createdAt; + if (!createdAt || !updatedAt) return []; + return [{ + id: hit.id, + kind: hit.kind, + memoryLayer: hit.memoryLayer, + status: hit.status, + content: hit.snippet, + createdAt, + updatedAt, + retrievalRoute: hit.memoryLayer === "UserMemory" + ? "user_memory" + : hit.memoryLayer === "L1" + ? "l1" + : "agent_memory" + }]; +} + +function uniqueRecallMembers(members: RecallMember[]): RecallMember[] { + return [...new Map(members.map((member) => [member.id, member])).values()]; +} + +function isAlreadyDeletedError(error: unknown): boolean { + return isRecord(error) && error.status === 404; +} + +function recallHitTraceId(hit: RecallHit, members: RecallMember[]): string { + return members.find((member) => member.memoryLayer === "L1")?.id + ?? members[0]?.id + ?? hit.id; +} + +function recallHitLayers(hit: RecallHit, members: RecallMember[]): RecallHit["memoryLayer"][] { + return [...new Set(members.length > 0 + ? members.map((member) => member.memoryLayer) + : [hit.memoryLayer])]; +} + +function recallLayerLabel(layer: RecallHit["memoryLayer"], t: (key: MessageKey) => string): string { + if (layer === "UserMemory") return t("home.memoryEvidence.layer.userMemory"); + if (layer === "L1") return t("home.memoryEvidence.layer.l1"); + return t("home.memoryEvidence.layer.agentMemory"); +} + +function formatRecallMemberTime(value: string, language: ResolvedLanguage): string { + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) ? parsed.toLocaleString(language) : value; +} + function contextCompactionFallbackText(status: AgentCompactionStatus): string { if (status === "running") return "Summarizing chat context"; if (status === "error") return "Context summary failed"; @@ -645,6 +943,8 @@ function areSingleMessagePropsEqual(previous: SingleMessageProps, next: SingleMe && previous.unitIndex === next.unitIndex && previous.isFinalAssistantAnswer === next.isFinalAssistantAnswer && previous.forceMessageActions === next.forceMessageActions + && previous.memoryRuntimeClient === next.memoryRuntimeClient + && previous.recallEvidenceTurnId === next.recallEvidenceTurnId && previous.deferContentRender === next.deferContentRender && previous.deferredRevealDelayMs === next.deferredRevealDelayMs && previous.sanitizePlatformApiErrors === next.sanitizePlatformApiErrors; diff --git a/App/frontend/desktop/src/pages/agent-workspace-context.tsx b/App/frontend/desktop/src/pages/agent-workspace-context.tsx new file mode 100644 index 000000000..e18167f4b --- /dev/null +++ b/App/frontend/desktop/src/pages/agent-workspace-context.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from "react"; +import type { CSSProperties } from "react"; +import { + Check, + GitBranch, + GitFork, + Laptop, + Plus, + Search, +} from "lucide-react"; +import type { + WorkspaceEnvironmentSnapshot, +} from "../api/memmy-agent-client.js"; +import { useTranslation } from "../i18n/use-translation.js"; + +export type AgentWorkspaceContextProps = { + snapshot: WorkspaceEnvironmentSnapshot | null; + branches: string[]; + loading: boolean; + error: string | null; + onSwitchBranch: (branch: string) => Promise; + onCreateOrCheckoutBranch: (branch: string) => Promise; +}; + +const DEFAULT_VISIBLE_BRANCH_COUNT = 5; + +export function AgentWorkspaceContext({ + snapshot, + branches, + loading, + error, + onSwitchBranch, + onCreateOrCheckoutBranch, +}: AgentWorkspaceContextProps) { + const { t } = useTranslation(); + const [openMenu, setOpenMenu] = useState<"mode" | "branch" | null>(null); + const [query, setQuery] = useState(""); + const [createBranchOpen, setCreateBranchOpen] = useState(false); + const [newBranchName, setNewBranchName] = useState(""); + const rootRef = useRef(null); + const branchSearchRef = useRef(null); + const newBranchRef = useRef(null); + const repository = snapshot?.status === "ready" ? snapshot.repository : null; + const revision = repository?.branch + ?? (repository?.head_sha ? `HEAD ${repository.head_sha.slice(0, 7)}` : null); + const currentBranch = snapshot?.repository?.branch ?? null; + const filteredBranches = branches.filter((branch) => branch.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())); + + useEffect(() => { + if (!openMenu) return; + function closeOnOutsidePointer(event: PointerEvent) { + if (!rootRef.current?.contains(event.target as Node)) setOpenMenu(null); + } + function closeOnEscape(event: KeyboardEvent) { + if (event.key === "Escape") setOpenMenu(null); + } + document.addEventListener("pointerdown", closeOnOutsidePointer); + document.addEventListener("keydown", closeOnEscape); + return () => { + document.removeEventListener("pointerdown", closeOnOutsidePointer); + document.removeEventListener("keydown", closeOnEscape); + }; + }, [openMenu]); + + useEffect(() => { + if (openMenu !== "branch") return; + setQuery(""); + setCreateBranchOpen(false); + setNewBranchName(""); + branchSearchRef.current?.focus(); + }, [openMenu]); + + useEffect(() => { + if (createBranchOpen) newBranchRef.current?.focus(); + }, [createBranchOpen]); + + if (!revision) return null; + + const localLabel = t("home.environment.local"); + const branchLabel = currentBranch + ? t("home.environment.branch.label", { branch: revision }) + : t("home.environment.detachedHead"); + + function confirmDirtyWorkspace(): boolean { + return snapshot?.repository?.worktree !== "dirty" + || window.confirm(t("home.environment.branch.dirtyConfirm")); + } + + async function selectBranch(branch: string) { + if (branch === currentBranch) { + setOpenMenu(null); + return; + } + if (!confirmDirtyWorkspace()) return; + if (await onSwitchBranch(branch)) setOpenMenu(null); + } + + async function createOrCheckoutBranch() { + const branch = newBranchName.trim(); + if (!branch || !confirmDirtyWorkspace()) return; + if (await onCreateOrCheckoutBranch(branch)) { + setCreateBranchOpen(false); + setNewBranchName(""); + setOpenMenu(null); + } + } + + return ( +
+
+ + {openMenu === "mode" ? ( +
+

{t("home.environment.mode.title")}

+ + +
+ ) : null} +
+ +
+ + {openMenu === "branch" ? ( +
+ +

{t("home.environment.branch.title")}

+
+ {filteredBranches.length ? filteredBranches.map((branch) => ( + + )) :

{t("home.environment.branch.empty")}

} +
+ {error ?

{error}

: null} + {createBranchOpen ? ( +
{ + event.preventDefault(); + void createOrCheckoutBranch(); + }} + > + setNewBranchName(event.target.value)} + /> +
+ + +
+
+ ) : null} +
+ +
+
+ ) : null} +
+
+ ); +} diff --git a/App/frontend/desktop/src/pages/api-key-optional-page.tsx b/App/frontend/desktop/src/pages/api-key-optional-page.tsx index d131e34a4..c8dda7386 100644 --- a/App/frontend/desktop/src/pages/api-key-optional-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-optional-page.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { ChevronLeft, Image as ImageIcon, Mic } from "lucide-react"; import { useAnalytics } from "../analytics/use-analytics.js"; import { persistLoginModeSelection } from "../app/login-mode.js"; @@ -50,6 +50,11 @@ import { type ImageProtocol } from "./model-config.js"; +interface SavedEndpointIdentity { + endpointId: string; + credentialSignature: string; +} + export function ApiKeyOptionalPage() { const { state, dispatch } = useAppState(); const { clients } = useApiClients(); @@ -81,9 +86,43 @@ export function ApiKeyOptionalPage() { const isImageGenUsable = canSaveModelConfig(imageGenFormValues, imageGenValidation); const imageGenTestKey = createModelConfigValidationKey(imageGenFormValues); const isImageGenTestStale = Boolean(imageGenValidation.testedKey && imageGenValidation.testedKey !== imageGenTestKey); + const imageGenEndpointProtocol = imageGenProtocol === "qwen" + ? "dashscope-multimodal-generation" + : "openai-images"; + const asrCredentialSignature = endpointCredentialSignature( + "qwen", + "dashscope-input-audio-chat", + asrEndpoint, + asrApiKey, + asrApiKeyMasked + ); + const imageGenCredentialSignature = endpointCredentialSignature( + imageGenProtocol, + imageGenEndpointProtocol, + imageGenEndpoint, + imageGenApiKey, + imageGenApiKeyMasked + ); + const saveSignature = JSON.stringify({ + asr: isAsrUsable ? asrTestKey : null, + imageGeneration: isImageGenUsable ? imageGenTestKey : null + }); + const savedCatalogSignatureRef = useRef(null); + const initialWorkspace = createModelWorkspace(state.modelConfig); + const initialAsrEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "asr"); + const initialImageEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "image_generation"); + const savedEndpointIdentitiesRef = useRef>>({ + ...(initialAsrEndpointId + ? { asr: { endpointId: initialAsrEndpointId, credentialSignature: asrCredentialSignature } } + : {}), + ...(initialImageEndpointId + ? { imageGeneration: { endpointId: initialImageEndpointId, credentialSignature: imageGenCredentialSignature } } + : {}) + }); const [optionalModelMissingWarning, setOptionalModelMissingWarning] = useState(null); const [modePersistencePending, setModePersistencePending] = useState(false); const [nextValidationError, setNextValidationError] = useState<"noInput" | "testRequired" | null>(null); + const [saveError, setSaveError] = useState(null); function changeImageGenProtocol(nextProtocol: string) { const next = (IMAGE_PROTOCOL_OPTIONS.find((option) => option.value === nextProtocol)?.value ?? "openai") as ImageProtocol; @@ -134,45 +173,65 @@ export function ApiKeyOptionalPage() { return; } + setSaveError(null); try { setModePersistencePending(true); - const latest = await clients.config.getModelConfig(); - let workspace = createModelWorkspace(latest); - if (isAsrUsable) { - const assignedAsrEndpointId = assignedCatalogEndpointId(workspace, "byok", "asr"); - const asr = upsertByokPreset(workspace, { - provider: "qwen", - ...(asrApiKeyMasked && assignedAsrEndpointId ? { endpointId: assignedAsrEndpointId } : {}), - endpoint: asrEndpoint, - protocol: "dashscope-input-audio-chat", - ...(asrApiKey.trim() ? { apiKey: asrApiKey.trim() } : {}), - ...(asrApiKeyMasked ? { apiKeyMasked: asrApiKeyMasked } : {}), - model: asrModel || ASR_MODEL_ID, - capabilities: ["asr"] - }); - workspace = assignCatalogPreset(asr.workspace, "byok", "asr", asr.presetId); + if (savedCatalogSignatureRef.current !== saveSignature) { + const latest = await clients.config.getModelConfig(); + let workspace = createModelWorkspace(latest); + if (isAsrUsable) { + const savedAsrIdentity = savedEndpointIdentitiesRef.current.asr; + const asrEndpointId = savedAsrIdentity?.credentialSignature === asrCredentialSignature + ? savedAsrIdentity.endpointId + : undefined; + const asr = upsertByokPreset(workspace, { + provider: "qwen", + ...(asrEndpointId ? { endpointId: asrEndpointId } : {}), + endpoint: asrEndpoint, + protocol: "dashscope-input-audio-chat", + ...(asrApiKey.trim() ? { apiKey: asrApiKey.trim() } : {}), + ...(asrApiKeyMasked ? { apiKeyMasked: asrApiKeyMasked } : {}), + model: asrModel || ASR_MODEL_ID, + capabilities: ["asr"] + }); + workspace = assignCatalogPreset(asr.workspace, "byok", "asr", asr.presetId); + } + if (isImageGenUsable) { + const savedImageIdentity = savedEndpointIdentitiesRef.current.imageGeneration; + const imageEndpointId = savedImageIdentity?.credentialSignature === imageGenCredentialSignature + ? savedImageIdentity.endpointId + : undefined; + const image = upsertByokPreset(workspace, { + provider: imageGenProtocol, + ...(imageEndpointId ? { endpointId: imageEndpointId } : {}), + endpoint: imageGenEndpoint, + protocol: imageGenEndpointProtocol, + ...(imageGenApiKey.trim() ? { apiKey: imageGenApiKey.trim() } : {}), + ...(imageGenApiKeyMasked ? { apiKeyMasked: imageGenApiKeyMasked } : {}), + model: imageGenModel, + capabilities: ["image_generation"] + }); + workspace = assignCatalogPreset(image.workspace, "byok", "image_generation", image.presetId); + } + const savedConfig = await clients.config.saveModelCatalog(modelConfigInput(workspace)); + if (!savedConfig.catalog?.modelAssignments.byok.agent.candidates.length) { + throw new Error("persisted BYOK Agent assignment is empty"); + } + dispatch(appActions.modelConfigUpdated(savedConfig)); + const savedWorkspace = createModelWorkspace(savedConfig); + const savedAsrEndpointId = assignedCatalogEndpointId(savedWorkspace, "byok", "asr"); + const savedImageEndpointId = assignedCatalogEndpointId(savedWorkspace, "byok", "image_generation"); + savedEndpointIdentitiesRef.current = { + ...savedEndpointIdentitiesRef.current, + ...(isAsrUsable && savedAsrEndpointId + ? { asr: { endpointId: savedAsrEndpointId, credentialSignature: asrCredentialSignature } } + : {}), + ...(isImageGenUsable && savedImageEndpointId + ? { imageGeneration: { endpointId: savedImageEndpointId, credentialSignature: imageGenCredentialSignature } } + : {}) + }; + savedCatalogSignatureRef.current = saveSignature; } - if (isImageGenUsable) { - const assignedImageEndpointId = assignedCatalogEndpointId(workspace, "byok", "image_generation"); - const image = upsertByokPreset(workspace, { - provider: imageGenProtocol, - ...(imageGenApiKeyMasked && assignedImageEndpointId ? { endpointId: assignedImageEndpointId } : {}), - endpoint: imageGenEndpoint, - protocol: imageGenProtocol === "qwen" - ? "dashscope-multimodal-generation" - : "openai-images", - ...(imageGenApiKey.trim() ? { apiKey: imageGenApiKey.trim() } : {}), - ...(imageGenApiKeyMasked ? { apiKeyMasked: imageGenApiKeyMasked } : {}), - model: imageGenModel, - capabilities: ["image_generation"] - }); - workspace = assignCatalogPreset(image.workspace, "byok", "image_generation", image.presetId); - } - const savedConfig = await clients.config.saveModelCatalog(modelConfigInput(workspace)); - if (!savedConfig.catalog?.modelAssignments.byok.agent.candidates.length) { - throw new Error("persisted BYOK Agent assignment is empty"); - } - dispatch(appActions.modelConfigUpdated(savedConfig)); const byokCompletion = resolveByokModelCompletion({ onboarding: state.bootstrap?.onboarding ?? buildByokOnboardingGuidePatch() }); @@ -186,6 +245,7 @@ export function ApiKeyOptionalPage() { track({ name: "byok_completed", params: { user_mode: "byok" }, consentTier: "basic" }); } catch (error) { console.error("save byok optional model config failed", error); + setSaveError(optionalStepSaveErrorText(error, t)); } finally { setModePersistencePending(false); } @@ -196,6 +256,7 @@ export function ApiKeyOptionalPage() { return; } + setSaveError(null); setNextValidationError(null); const nextWarning = resolveOptionalModelMissingWarning({ @@ -215,6 +276,7 @@ export function ApiKeyOptionalPage() { return; } + setSaveError(null); setNextValidationError(null); const asrHasInput = Boolean(asrApiKey.trim()); @@ -358,6 +420,14 @@ export function ApiKeyOptionalPage() {
)} + {saveError ? ( +
+
+

{saveError}

+
+
+ ) : null} +
+ {saveError ?

{saveError}

: null}
); } +function endpointCredentialSignature( + provider: string, + protocol: string, + endpoint: string, + apiKey: string, + apiKeyMasked: string +): string { + const normalizedApiKey = apiKey.trim(); + const normalizedMaskedApiKey = apiKeyMasked.trim(); + return JSON.stringify({ + provider: provider.trim().toLowerCase(), + protocol, + endpoint: endpoint.trim().replace(/\/+$/, ""), + credential: normalizedMaskedApiKey ? `masked:${normalizedMaskedApiKey}` : `raw:${normalizedApiKey}` + }); +} + +function firstStepSaveErrorText( + error: unknown, + t: ReturnType["t"] +): string { + const code = error && typeof error === "object" && "code" in error ? error.code : null; + if (code === "model_config_changed") return t("settings.model.configChanged"); + if (code === "config_write_busy") return t("settings.modelWorkspace.saveBusy"); + return error instanceof Error && error.message + ? error.message + : t("settings.modelWorkspace.saveFailed"); +} + function chatProtocol(provider: string) { if (provider === "anthropic") return "anthropic-messages" as const; if (provider === "gemini") return "gemini-generate-content" as const; diff --git a/App/frontend/desktop/src/pages/app-frame.tsx b/App/frontend/desktop/src/pages/app-frame.tsx index 77a312c22..e1f4a6fa3 100644 --- a/App/frontend/desktop/src/pages/app-frame.tsx +++ b/App/frontend/desktop/src/pages/app-frame.tsx @@ -20,8 +20,9 @@ import { MemmyAgentRequestError } from "../api/memmy-agent-client.js"; import { communityLinks } from "../community/community-links.js"; import { ConfirmDialog } from "../components/confirm-dialog.js"; import { Tooltip } from "../components/tooltip.js"; -import type { MessageKey } from "../i18n/messages.js"; +import type { MessageKey, MessageValues } from "../i18n/messages.js"; import { useTranslation } from "../i18n/use-translation.js"; +import { useOptionalUpdateCoordinator, type UpdateCoordinatorValue } from "../app/update-coordinator.js"; import type { MemmyAgentProject, WebuiSessionTarget } from "../api/memmy-agent-client.js"; import { getLegalLinkUrl } from "../legal/legal-links.js"; import { useTaskBus } from "../lib/task-bus.js"; @@ -62,7 +63,7 @@ import { Wand2 } from "./memory/memory-prototype-icons.js"; import { SETTINGS_NAV_ITEMS, type SettingsTabId } from "./settings-nav.js"; -import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Folder, FolderOpen, FolderPlus, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; +import { Check, CheckCheck, ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, Download, Folder, FolderOpen, FolderPlus, ListFilter, MoreHorizontal, Plus, RotateCcw } from "lucide-react"; export interface SettingsSidebarNav { activeTab: SettingsTabId; @@ -179,6 +180,16 @@ export interface AccountDisplayText { truncated: boolean; } +type AppFrameTranslate = (key: MessageKey, values?: MessageValues) => string; + +interface SidebarUpdateActionView { + kind: "available" | "downloading" | "installing" | "prepared"; + label: string; + ariaLabel: string; + title: string; + disabled: boolean; +} + const navItems: NavItem[] = [ { path: "/main", icon: }, { action: "search", icon: , labelKey: "appFrame.search" }, @@ -261,6 +272,7 @@ export function AppFrame(props: AppFrameProps) { const { state, dispatch } = useAppState(); const { clients } = useOptionalApiClients(); const { t, language } = useTranslation(); + const update = useOptionalUpdateCoordinator(); const { track } = useAnalytics(); const taskStateCoordinator = useOptionalAgentRuntimeBridge()?.taskStateCoordinator ?? standaloneRenderTaskStateCoordinator; @@ -301,6 +313,7 @@ export function AppFrame(props: AppFrameProps) { }); const accountNameLine = truncateAccountDisplayText(accountSummary.name, SIDEBAR_PROFILE_NAME_MAX_VISUAL_WIDTH); const accountMetaLine = truncateAccountDisplayText(accountSummary.meta, SIDEBAR_PROFILE_META_MAX_VISUAL_WIDTH); + const sidebarUpdateAction = resolveSidebarUpdateAction(update, t); const visibleTasks = state.agent.tasks; const projectTree = useMemo( () => deriveSidebarPlacement(visibleTasks, state.agent.projects), @@ -1437,7 +1450,63 @@ export function AppFrame(props: AppFrameProps) {
)} - {props.settingsNav ? null : ( + {props.settingsNav ? null : sidebarUpdateAction ? ( +
+ + + +
+ ) : ( + ) : null} - topBarBorder={hasActiveConversation} + topBarBorder={Boolean(hasActiveConversation || environmentScope)} > - {!hasActiveConversation ? ( +
+ {!hasActiveConversation ? (
@@ -2717,6 +2768,14 @@ export function HomePage() { onSelect={selectDraftTarget} onChooseOther={() => void selectOtherProjectFolder()} /> +
@@ -2726,7 +2785,7 @@ export function HomePage() { void selectMedia(event)} />
- ) : ( + ) : (
-
+
{displayConnectionStatus !== "connected" && (
- {agentStatusText(displayConnectionStatus, state.agent.modelName, t)} + {statusText}
)} @@ -2755,6 +2814,7 @@ export function HomePage() { isSending={state.agent.isSending} sanitizePlatformApiErrors={sanitizePlatformApiErrors} artifactClient={sessionArtifactClient} + memoryRuntimeClient={clients?.memoryRuntime ?? null} />
@@ -2770,7 +2830,7 @@ export function HomePage() { ) : null}
-
+
{slashMenuOpen && (
@@ -2864,7 +2924,7 @@ export function HomePage() { }} onKeyDown={handleComposerKeyDown} onPaste={handleComposerPaste} - className={`${isComposerSingleLine ? "agent-composer-input--single " : ""}${selectedComposerCommand ? "agent-composer-input--command-selected " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40`} + className={`${isComposerSingleLine ? "agent-composer-input--single " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40`} />
@@ -2921,7 +2981,9 @@ export function HomePage() {
- )} + )} + {environmentPanel} +
); } @@ -3399,12 +3461,19 @@ const AGENT_OPERATION_ERROR_MESSAGE_KEYS: Record = { attachment_read_failed: "home.media.error.sendReadFailed" }; -export function agentStatusText(status: string, modelName: string | null, t: HomeTranslate): string | null { +export function agentStatusText( + status: string, + modelName: string | null, + t: HomeTranslate, + context: { startupIssue?: AgentGatewayStartupIssue; hasConnected?: boolean } = {} +): string | null { if (status === "connected") { return null; } if (status === "error") { - return t("home.agent.failed"); + return t(context.startupIssue === "model_config_invalid" && !context.hasConnected + ? "home.modelSelector.unavailable" + : "home.agent.failed"); } if (status === "reconnecting") { return t("home.agent.reconnecting"); diff --git a/App/frontend/desktop/src/pages/login-page.tsx b/App/frontend/desktop/src/pages/login-page.tsx index de354ae44..069cb74b2 100644 --- a/App/frontend/desktop/src/pages/login-page.tsx +++ b/App/frontend/desktop/src/pages/login-page.tsx @@ -7,6 +7,7 @@ import { resolveInvitationToastKind } from "../app/invitation-result.js"; import { persistLoginModeSelection } from "../app/login-mode.js"; import { useApiClients } from "../app/providers.js"; import { buildAccountOnboardingStartPatch, resolvePostLoginRoute } from "../app/routes.js"; +import { setAnalyticsUserId } from "../analytics/analytics-context.js"; import { useAnalytics } from "../analytics/use-analytics.js"; import { AuthCodeForm } from "../components/auth-code-form.js"; import { LanguageToggleButton } from "../components/language-toggle-button.js"; @@ -66,6 +67,8 @@ export function LoginPage() { return; } const session = loginResult.session; + // Set before the signup event so it is attributed to the cloud account. + setAnalyticsUserId(session.profile.userId); const invitationToastKind = resolveInvitationToastKind(loginResult.invitationResult); if (invitationToastKind) { dispatch(appActions.showInvitationToast(invitationToastKind)); @@ -78,6 +81,7 @@ export function LoginPage() { })); dispatch(appActions.accountUpdated({ + userId: session.profile.userId, email: session.profile.email ?? "", phoneNumber: session.profile.phoneNumber, nickname: session.profile.nickname, diff --git a/App/frontend/desktop/src/pages/memory-page.tsx b/App/frontend/desktop/src/pages/memory-page.tsx index badb70670..bd6bbe777 100644 --- a/App/frontend/desktop/src/pages/memory-page.tsx +++ b/App/frontend/desktop/src/pages/memory-page.tsx @@ -28,6 +28,7 @@ import { PoliciesSubPage } from "./memory/policies-sub-page.js"; import { SkillsSubPage } from "./memory/skills-sub-page.js"; import { SourcesSubPage } from "./memory/sources-sub-page.js"; import { TasksSubPage } from "./memory/tasks-sub-page.js"; +import { UserMemoriesSubPage } from "./memory/user-memories-sub-page.js"; import { WorldModelSubPage } from "./memory/world-model-sub-page.js"; import { ArrowLeft, @@ -41,12 +42,14 @@ import { PanelLeftCollapsed, ScrollText, Sparkles, + UserRound, Wand2 } from "./memory/memory-prototype-icons.js"; export type MemorySubPageId = | "overview" | "memories" + | "user-memories" | "tasks" | "policies" | "world-model" @@ -75,7 +78,8 @@ const memoryNavSections: MemoryNavSection[] = [ { id: "tasks", labelKey: "memory.nav.tasks", icon: }, { id: "policies", labelKey: "memory.nav.policies", icon: }, { id: "world-model", labelKey: "memory.nav.worldModel", icon: }, - { id: "skills", labelKey: "memory.nav.skills", icon: } + { id: "skills", labelKey: "memory.nav.skills", icon: }, + { id: "user-memories", labelKey: "memory.nav.userMemories", icon: } ] }, { @@ -146,6 +150,7 @@ export function MemoryPage(props: MemoryPageProps) { }} /> ), + "user-memories": , tasks: , policies: ( string): Record{t("memory.overview.total")}, memories:
{t("memory.memories.title")}
, + "user-memories":
{t("memory.userMemories.title")}
, tasks:
{t("memory.tasks.title")}
, policies:
{t("memory.policies.title")}
, "world-model":
{t("memory.worldModel.title")}
, diff --git a/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx b/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx index a76001a3f..a391abcd2 100644 --- a/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx +++ b/App/frontend/desktop/src/pages/memory/memory-prototype-icons.tsx @@ -62,6 +62,15 @@ export function BrainCircuit(props: MemoryIconProps) { ); } +export function UserRound(props: MemoryIconProps) { + return ( + + + + + ); +} + export function ListChecks(props: MemoryIconProps) { return ( diff --git a/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx b/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx index 3babcc554..0e2ea902b 100644 --- a/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/overview-sub-page.tsx @@ -5,7 +5,7 @@ import type { MemoryRuntimeClient } from "../../api/memory-runtime-client.js"; import { Tooltip } from "../../components/tooltip.js"; import type { MessageKey } from "../../i18n/messages.js"; import { useTranslation } from "../../i18n/use-translation.js"; -import { BarChart3, BrainCircuit, Globe2, Layers, Sparkles, Wand2 } from "./memory-prototype-icons.js"; +import { BarChart3, BrainCircuit, Globe2, Layers, Sparkles, UserRound, Wand2 } from "./memory-prototype-icons.js"; import { memoryPanelCacheKey, readMemoryPanelCache, @@ -14,7 +14,7 @@ import { import { type RemoteData, toErrorMessage } from "./remote-state.js"; interface OverviewCountCard { - id: "memories" | "skills" | "experiences" | "worldModels"; + id: "memories" | "userMemories" | "skills" | "experiences" | "worldModels"; targetPage: OverviewCountTargetPage; labelKey: MessageKey; value: number; @@ -22,7 +22,7 @@ interface OverviewCountCard { icon: ReactNode; } -type OverviewCountTargetPage = "memories" | "policies" | "world-model" | "skills"; +type OverviewCountTargetPage = "memories" | "policies" | "world-model" | "skills" | "user-memories"; interface DailyActivityCell { date: string; @@ -148,7 +148,8 @@ function buildCountCards(data: PanelOverviewOutput): OverviewCountCard[] { { id: "memories", targetPage: "memories", labelKey: "memory.overview.memories", value: data.counts.memories, hintKey: "memory.overview.memoriesHint", icon: }, { id: "experiences", targetPage: "policies", labelKey: "memory.overview.policies", value: data.counts.experiences, hintKey: "memory.overview.policiesHint", icon: }, { id: "worldModels", targetPage: "world-model", labelKey: "memory.overview.worldModels", value: data.counts.worldModels, hintKey: "memory.overview.worldModelsHint", icon: }, - { id: "skills", targetPage: "skills", labelKey: "memory.overview.skills", value: data.counts.skills, hintKey: "memory.overview.skillsHint", icon: } + { id: "skills", targetPage: "skills", labelKey: "memory.overview.skills", value: data.counts.skills, hintKey: "memory.overview.skillsHint", icon: }, + { id: "userMemories", targetPage: "user-memories", labelKey: "memory.overview.userMemories", value: data.counts.userMemories, hintKey: "memory.overview.userMemoriesHint", icon: } ]; } diff --git a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts index d0bca1c47..5d5fac48e 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts +++ b/App/frontend/desktop/src/pages/memory/tests/memory-runtime-fixtures.ts @@ -210,6 +210,7 @@ export const mockMemoryDetails: Record = { export const mockPanelOverview: PanelOverviewOutput = { counts: { memories: 9, + userMemories: 5, skills: 2, experiences: 4, worldModels: 3 diff --git a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx index b713dc373..483c5fc91 100644 --- a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.interaction.test.tsx @@ -21,7 +21,7 @@ describe("OverviewSubPage interaction", () => { container.remove(); }); - it.each(["memories", "policies", "world-model", "skills"] as const)( + it.each(["memories", "policies", "world-model", "skills", "user-memories"] as const)( "点击数量卡片后跳转到 %s 页面", (targetPage) => { const onNavigate = vi.fn(); diff --git a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx index 4839a5449..b7e28160d 100644 --- a/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/overview-sub-page.test.tsx @@ -23,12 +23,14 @@ describe("OverviewSubPage", () => { expect(html).toContain('data-icon="layers"'); expect(html).toContain("记忆数量"); + expect(html).toContain("用户记忆数量"); expect(html).toContain("技能数量"); expect(html).toContain("经验数量"); expect(html).toContain("场域认知数量"); expect(html.indexOf("记忆数量")).toBeLessThan(html.indexOf("经验数量")); expect(html.indexOf("经验数量")).toBeLessThan(html.indexOf("场域认知数量")); expect(html.indexOf("场域认知数量")).toBeLessThan(html.indexOf("技能数量")); + expect(html.indexOf("技能数量")).toBeLessThan(html.indexOf("用户记忆数量")); expect(html).toContain("每日统计"); expect(html).toContain("按创建日期统计最近一年的新增记忆。"); expect(html).toContain('data-daily-activity-card="true"'); diff --git a/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx b/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx index 91ff3c690..b68e95917 100644 --- a/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/sub-page-cache-hydration.test.tsx @@ -10,6 +10,7 @@ import { OverviewSubPage } from "../overview-sub-page.js"; import { PoliciesSubPage } from "../policies-sub-page.js"; import { SkillsSubPage } from "../skills-sub-page.js"; import { TasksSubPage } from "../tasks-sub-page.js"; +import { UserMemoriesSubPage } from "../user-memories-sub-page.js"; import { WorldModelSubPage } from "../world-model-sub-page.js"; describe("memory sub page cache hydration", () => { @@ -20,6 +21,7 @@ describe("memory sub page cache hydration", () => { it.each([ ["overview", () => undefined} />], ["memories", () => ], + ["user-memories", () => ], ["tasks", () => ], ["policies", () => undefined} />], ["world-model", () => undefined} />], diff --git a/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx new file mode 100644 index 000000000..c27b822cb --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.interaction.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../../i18n/i18n-provider.js"; +import { UserMemoriesSubPage } from "../user-memories-sub-page.js"; +import { createMemoryRuntimeClientStub, panelItemsOutput } from "./fixtures.js"; + +describe("UserMemoriesSubPage interaction", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("loads the UserMemory layer and deletes the selected record", async () => { + const item = { + id: "user_memory_1", + kind: "user_memory" as const, + memoryLayer: "UserMemory" as const, + status: "activated" as const, + title: "我最喜欢的水果是苹果", + summary: "我最喜欢的水果是苹果", + tags: ["User Preference"], + metadata: { + memoryTypes: ["User Preference"], + sourceTurnId: "turn-1", + sourceTurnRefs: ["turn-1"] + }, + createdAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + version: 1 + }; + const listPanelItems = vi.fn(async () => panelItemsOutput([item])); + const deleteMemory = vi.fn(async () => ({ + ok: true as const, + id: item.id, + kind: item.kind, + status: "deleted" as const, + changeSeq: 1, + syncCursor: "cursor-1", + serverTime: "2026-08-17T00:00:00.000Z" + })); + const client = createMemoryRuntimeClientStub({ listPanelItems, deleteMemory }); + + await act(async () => { + root.render( + + + + ); + }); + + expect(listPanelItems).toHaveBeenCalledWith({ layer: "UserMemory", page: 1 }); + act(() => container.querySelector(".memory-card")?.click()); + const deleteButton = container.querySelector(".memory-delete-button"); + act(() => deleteButton?.click()); + await act(async () => deleteButton?.click()); + + expect(deleteMemory).toHaveBeenCalledWith(item.id); + }); +}); diff --git a/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.test.tsx new file mode 100644 index 000000000..1cf3caf95 --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/tests/user-memories-sub-page.test.tsx @@ -0,0 +1,29 @@ +import { renderToString } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { I18nProvider } from "../../../i18n/i18n-provider.js"; +import { + buildUserMemoryPanelItemsInput, + UserMemoriesSubPage +} from "../user-memories-sub-page.js"; + +describe("UserMemoriesSubPage", () => { + it("uses the independent UserMemory panel layer", () => { + expect(buildUserMemoryPanelItemsInput(" 苹果 ", 2)).toEqual({ + layer: "UserMemory", + q: "苹果", + page: 2 + }); + }); + + it("renders a separate User Memory management section", () => { + const html = renderToString( + + + + ); + expect(html).toContain("用户记忆"); + expect(html).toContain('data-icon="user-round"'); + expect(html).toContain("用户事实、偏好和明确指令"); + expect(html).toContain("搜索用户记忆"); + }); +}); diff --git a/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx new file mode 100644 index 000000000..baa52f77a --- /dev/null +++ b/App/frontend/desktop/src/pages/memory/user-memories-sub-page.tsx @@ -0,0 +1,214 @@ +import { useEffect, useRef, useState } from "react"; +import type { MemoryListItem, PanelItemsInput, PanelItemsOutput } from "@memmy/local-api-contracts"; +import type { MemoryRuntimeClient } from "../../api/memory-runtime-client.js"; +import type { MessageKey } from "../../i18n/messages.js"; +import { formatUserDateTime } from "../../lib/user-time-zone.js"; +import { useTranslation } from "../../i18n/use-translation.js"; +import { MemoryDrawerDeleteAction } from "./memory-delete-action.js"; +import { MemoryPagination, normalizePage } from "./memory-pagination.js"; +import { ChevronRight, Search, UserRound, X } from "./memory-prototype-icons.js"; +import { MemoryRefreshButton } from "./memory-refresh-button.js"; +import { MemoryStateBox } from "./memory-state-box.js"; +import type { RemoteData } from "./remote-state.js"; +import { toErrorMessage } from "./remote-state.js"; + +export interface UserMemoriesSubPageProps { + client: MemoryRuntimeClient | null; +} + +export function buildUserMemoryPanelItemsInput(query: string, page: number): PanelItemsInput { + const input: PanelItemsInput = { layer: "UserMemory", page: normalizePage(page) }; + const normalizedQuery = query.trim(); + if (normalizedQuery) input.q = normalizedQuery; + return input; +} + +export function UserMemoriesSubPage(props: UserMemoriesSubPageProps) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [page, setPage] = useState(1); + const [state, setState] = useState>({ status: "loading" }); + const [selected, setSelected] = useState(null); + const requestIdRef = useRef(0); + + function refresh(nextPage = page): Promise { + if (!props.client) { + const message = t("memory.clientNotReady"); + setState({ status: "error", message }); + return Promise.reject(new Error(message)); + } + const requestId = ++requestIdRef.current; + setState((current) => current.status === "ready" ? current : { status: "loading" }); + return props.client.listPanelItems(buildUserMemoryPanelItemsInput(query, nextPage)) + .then((data) => { + if (requestId === requestIdRef.current) { + setPage(data.page); + setState({ status: "ready", data }); + } + return data; + }) + .catch((error) => { + if (requestId === requestIdRef.current) { + setState({ status: "error", message: toErrorMessage(error) }); + } + return undefined; + }); + } + + useEffect(() => { + void refresh(1); + return () => { + requestIdRef.current += 1; + }; + }, [props.client]); + + async function deleteSelected(): Promise { + if (!props.client || !selected) return; + await props.client.deleteMemory(selected.id); + setSelected(null); + await refresh(page); + } + + return ( +
+
+
+

+ + {t("memory.userMemories.title")} +

+

{t("memory.userMemories.description")}

+
+ void refresh()} /> +
+ +
+ +
+ + {state.status === "loading" ? : null} + {state.status === "error" ? : null} + {state.status === "ready" ? ( + <> +
+ {state.data.items.length === 0 ? : null} + {state.data.items.map((item) => ( + + ))} +
+ void refresh(nextPage)} + /> + + ) : null} + + {selected ? ( +
setSelected(null)}> + + +
+
+
{t("memory.userMemories.content")}
+

{selected.summary}

+
+
+
{t("memory.memories.meta")}
+
+
{t("memory.userMemories.type")}
+
{userMemoryTypeLabel(selected, t)}
+
{t("memory.memories.status")}
+
{userMemoryStatusLabel(selected.status, t)}
+
{t("memory.memories.createdAt")}
+
{formatUserDateTime(selected.createdAt)}
+
{t("memory.memories.updatedAt")}
+
{formatUserDateTime(selected.updatedAt)}
+
{t("memory.userMemories.sourceTurn")}
+
{metadataText(selected, "sourceTurnId")}
+
{t("memory.userMemories.expressionCount")}
+
{metadataArray(selected, "sourceTurnRefs").length}
+
+
+
+ + +
+ ) : null} +
+ ); +} + +function userMemoryTypeLabel(item: MemoryListItem, t: (key: MessageKey) => string): string { + const types = metadataArray(item, "memoryTypes"); + const values = types.length > 0 ? types : item.tags; + return values.map((type) => { + if (type === "User Fact") return t("memory.userMemories.type.fact"); + if (type === "User Preference") return t("memory.userMemories.type.preference"); + if (type === "User Directive") return t("memory.userMemories.type.directive"); + return type; + }).join(" · "); +} + +function userMemoryStatusLabel(status: MemoryListItem["status"], t: (key: MessageKey) => string): string { + if (status === "activated") return t("memory.userMemories.status.active"); + if (status === "archived") return t("memory.userMemories.status.archived"); + return t("memory.userMemories.status.deleted"); +} + +function metadataArray(item: MemoryListItem, key: string): string[] { + const value = item.metadata?.[key]; + return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : []; +} + +function metadataText(item: MemoryListItem, key: string): string { + const value = item.metadata?.[key]; + return typeof value === "string" ? value : "—"; +} diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index 8ae116281..a0332c64f 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -1263,7 +1263,7 @@ function ProviderModelList(props: { {props.items.map((item, index) => (
0 ? "border-t border-border-stone/30" : "" }`} > diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx index 410da4cc0..da76e0384 100644 --- a/App/frontend/desktop/src/pages/settings-page.tsx +++ b/App/frontend/desktop/src/pages/settings-page.tsx @@ -699,7 +699,6 @@ export function SettingsPageView(props: SettingsPageViewProps) { document.getElementById("pet-avatar")?.scrollIntoView({ behavior: "smooth", block: "start" }); }, 0); // Mount-only deep-link sync; activeTabProp is read once for controlled vs local. - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional }, []); useEffect(() => { @@ -1336,9 +1335,52 @@ export function SettingsPageView(props: SettingsPageViewProps) { aria-labelledby="settings-tab-tokens" hidden={activeTab !== "tokens"} > +
+
+
+ +

{t("settings.tokens")}

+
+ +
+ + {(isTokenLow || quotaEligibilityText) && showGiftQuota && ( +
+ +

+ {quotaEligibilityText ?? t("settings.token.lowHint")} +

+ {quotaRequestPending ? ( + + ) : canApplyMoreByPromotion && isTokenLow && (!quotaEligibility || quotaEligibility.state === "available") ? ( + + ) : null} +
+ )} + + +
+ {isAccountMode && showGiftQuota && showInvitationBanner ? (
button { + grid-column: 2; + justify-self: start; + } + + .invitationActions { + flex-wrap: wrap; + } + .sectionHead { display: block; } diff --git a/App/frontend/desktop/src/pages/tests/agent-environment-panel.test.tsx b/App/frontend/desktop/src/pages/tests/agent-environment-panel.test.tsx new file mode 100644 index 000000000..d4f4d915b --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-environment-panel.test.tsx @@ -0,0 +1,244 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MemmyAgentClient, WorkspaceEnvironmentSnapshot } from "../../api/memmy-agent-client.js"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { AgentEnvironmentPanel } from "../agent-environment-panel.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const snapshot: WorkspaceEnvironmentSnapshot = { + scope_kind: "session", + scope_key: "websocket:chat-1", + cwd: "/workspace/memmy-agent", + status: "ready", + revision: "rev-1", + captured_at: "2026-08-11T08:00:00.000Z", + repository: { + display_name: "memmy-agent", + root: "/workspace/memmy-agent", + head_sha: "84d10f8f00", + branch: "zy_git_v1.0.7", + detached: false, + upstream: "origin/zy_git_v1.0.7", + ahead: 1, + behind: 0, + worktree: "dirty", + }, + changes: { file_count: 1, additions: 8, deletions: 1, conflicts: 0, staged: 0, unstaged: 1, untracked: 0 }, + goal: { + goal_id: "8f59f58a-7295-4c34-8e03-55e7035a5a8d", + base_head: "1111111111", + base_branch: "main", + goal_files: 1, + preexisting_files: 0, + uncertain_files: 0, + verification: "not_run", + completion_audit: "pending", + baseline_status: "captured", + }, +}; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +describe("AgentEnvironmentPanel", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it("renders repository and Goal evidence and loads a selected diff", async () => { + const onClose = vi.fn(); + const onRefresh = vi.fn(async () => undefined); + const files = [{ + path: "src/panel.tsx", + status: ".M", + staged: false, + unstaged: true, + untracked: false, + conflict: false, + additions: 8, + deletions: 1, + attribution: "goal" as const, + }]; + const client = { + readWorkspaceEnvironmentDiff: vi.fn(async () => ({ + path: "src/panel.tsx", + diff: "+export function Panel() {}", + truncated: false, + unavailable_reason: null, + })), + } as unknown as MemmyAgentClient; + + await act(async () => { + root.render( + + + + ); + }); + + expect(container.textContent).toContain("zy_git_v1.0.7"); + expect(container.textContent).toContain("Goal 证据"); + expect(container.textContent).toContain("src/panel.tsx"); + expect(container.querySelector('[role="dialog"]')?.getAttribute("aria-modal")).toBe("false"); + + const fileButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("src/panel.tsx")); + expect(fileButton).toBeTruthy(); + await act(async () => fileButton!.click()); + + expect(client.readWorkspaceEnvironmentDiff).toHaveBeenCalledWith( + { kind: "session", key: "websocket:chat-1" }, + "src/panel.tsx", + ); + expect(container.textContent).toContain("export function Panel() {}"); + expect(container.querySelector('[data-kind="addition"]')).toBeTruthy(); + + act(() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("closes on an outside pointer but ignores its environment toggle", async () => { + const onClose = vi.fn(); + const toggle = document.createElement("button"); + toggle.dataset.agentEnvironmentToggle = ""; + document.body.append(toggle); + + await act(async () => { + root.render( + + undefined)} + onClose={onClose} + /> + + ); + }); + + act(() => toggle.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + expect(onClose).not.toHaveBeenCalled(); + + act(() => document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }))); + expect(onClose).toHaveBeenCalledTimes(1); + toggle.remove(); + }); + + it("loads project environment before a Session exists", async () => { + const projectSnapshot = { ...snapshot, scope_kind: "project" as const, scope_key: "project-1", goal: null }; + const client = {} as MemmyAgentClient; + + await act(async () => { + root.render( + + undefined)} + onClose={vi.fn()} + /> + + ); + }); + + expect(container.textContent).toContain("zy_git_v1.0.7"); + expect(container.textContent).not.toContain("Goal 证据"); + }); + + it("ignores an older diff response after another file is selected", async () => { + const first = deferred<{ + path: string; + diff: string; + truncated: boolean; + unavailable_reason: null; + }>(); + const second = deferred<{ + path: string; + diff: string; + truncated: boolean; + unavailable_reason: null; + }>(); + const client = { + readWorkspaceEnvironmentDiff: vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise), + } as unknown as MemmyAgentClient; + const files = ["src/a.ts", "src/b.ts"].map((path) => ({ + path, + status: ".M", + staged: false, + unstaged: true, + untracked: false, + conflict: false, + additions: 1, + deletions: 0, + attribution: "goal" as const, + })); + + await act(async () => { + root.render( + + undefined)} + onClose={vi.fn()} + /> + + ); + }); + + const buttons = [...container.querySelectorAll("button")]; + const firstButton = buttons.find((button) => button.textContent?.includes("src/a.ts")); + const secondButton = buttons.find((button) => button.textContent?.includes("src/b.ts")); + await act(async () => firstButton!.click()); + expect(container.textContent).toContain("正在加载 Diff…"); + await act(async () => secondButton!.click()); + await act(async () => second.resolve({ path: "src/b.ts", diff: "+new-b", truncated: false, unavailable_reason: null })); + expect(container.textContent).toContain("new-b"); + + await act(async () => first.resolve({ path: "src/a.ts", diff: "+stale-a", truncated: false, unavailable_reason: null })); + expect(container.textContent).toContain("new-b"); + expect(container.textContent).not.toContain("stale-a"); + }); +}); diff --git a/App/frontend/desktop/src/pages/tests/agent-thread-memory-evidence.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-thread-memory-evidence.interaction.test.tsx new file mode 100644 index 000000000..a0dfa8cc2 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-thread-memory-evidence.interaction.test.tsx @@ -0,0 +1,205 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { AgentThreadMessages } from "../agent-thread-messages.js"; + +describe("AgentThreadMessages memory evidence interaction", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("[BC-27 chat] deletes same-turn L1 and User Memory members together", async () => { + const deleteMemory = vi.fn(async (id: string) => { + if (id === "trace-1") throw Object.assign(new Error("already deleted"), { status: 404 }); + return { + ok: true as const, + id, + kind: "trace" as const, + status: "deleted" as const, + changeSeq: 1, + syncCursor: "cursor", + serverTime: "2026-08-17T00:00:00.000Z" + }; + }); + const recallEvidence = vi.fn(async () => ({ + recallEventId: "recall-1", + queryId: "turn-1", + query: "简洁代码", + createdAt: "2026-08-17T00:00:00.000Z", + serverTime: "2026-08-17T00:00:00.000Z", + hits: [ + { + id: "turn:turn-source", + kind: "trace" as const, + memoryLayer: "L1" as const, + status: "activated" as const, + title: "memory.add:agent-source:workbuddy:turn:workbuddy:406eaf47addb146159e05da3", + snippet: "保持代码简洁", + score: 0.9, + tags: [], + source: "search" as const, + sourceTurnId: "turn-source", + memberMemoryIds: ["trace-1", "user-memory-1"], + members: [ + { + id: "trace-1", + kind: "trace" as const, + memoryLayer: "L1" as const, + status: "activated" as const, + content: "任务反馈:保持代码简洁", + createdAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + retrievalRoute: "l1" as const + }, + { + id: "user-memory-1", + kind: "user_memory" as const, + memoryLayer: "UserMemory" as const, + status: "active" as const, + content: "我喜欢简洁代码", + createdAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + retrievalRoute: "user_memory" as const + } + ] + }, + { + id: "turn:turn-source-2", + kind: "trace" as const, + memoryLayer: "L1" as const, + status: "activated" as const, + snippet: "第二条记忆", + score: 0.6, + tags: [], + source: "search" as const, + sourceTurnId: "turn-source-2", + memberMemoryIds: ["trace-2"], + members: [{ + id: "trace-2", + kind: "trace" as const, + memoryLayer: "L1" as const, + status: "activated" as const, + content: "第二条记忆", + createdAt: "2026-08-17T00:00:00.000Z", + updatedAt: "2026-08-17T00:00:00.000Z", + retrievalRoute: "l1" as const + }] + } + ] + })); + + await act(async () => { + root.render( + + + + ); + }); + + const showButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("查看本轮记忆依据")); + expect(showButton?.classList.contains("agent-memory-evidence-toggle")).toBe(true); + const userTurn = container.querySelector(".agent-user-turn"); + const userBubble = userTurn?.querySelector(".agent-chat-bubble--user"); + expect(userTurn?.contains(showButton ?? null)).toBe(true); + expect(Boolean(userBubble && showButton && (userBubble.compareDocumentPosition(showButton) & Node.DOCUMENT_POSITION_FOLLOWING))).toBe(true); + await act(async () => showButton?.click()); + + expect(container.textContent).toContain("trace-1"); + expect(container.textContent).toContain("L1 记忆"); + expect(container.textContent).toContain("用户记忆"); + expect(container.textContent).toContain("召回分 0.90"); + expect(container.textContent).not.toContain("memory.add:agent-source"); + expect(container.textContent).not.toContain("user_memory"); + expect(container.innerHTML).not.toContain("turn-source"); + const evidenceCard = container.querySelector("article"); + expect(evidenceCard?.className).toContain("bg-canvas-oat/35"); + expect(evidenceCard?.className.split(/\s+/)).not.toContain("border"); + expect(evidenceCard?.firstElementChild?.textContent).toContain("trace-1L1 记忆用户记忆召回分 0.90"); + + const header = container.querySelector("[data-memory-evidence-header]"); + const headerInfo = header?.firstElementChild; + expect(headerInfo?.textContent).toBe("trace-1L1 记忆用户记忆召回分 0.90"); + + const deleteButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "删除"); + expect(header?.lastElementChild?.contains(deleteButton ?? null)).toBe(true); + expect(deleteButton?.className).toContain("agent-memory-evidence-delete"); + expect(document.querySelector('[role="tooltip"]')).toBeNull(); + await act(async () => deleteButton?.click()); + + expect(container.textContent).toContain("trace-1"); + expect(deleteButton?.textContent?.trim()).toBe("撤销"); + expect(deleteButton?.className).toContain("agent-memory-evidence-undo"); + expect(deleteMemory).not.toHaveBeenCalled(); + + await act(async () => deleteButton?.click()); + expect(deleteButton?.textContent?.trim()).toBe("删除"); + expect(deleteMemory).not.toHaveBeenCalled(); + + await act(async () => deleteButton?.click()); + expect(deleteButton?.textContent?.trim()).toBe("撤销"); + + const hideButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("收起记忆依据")); + await act(async () => hideButton?.click()); + + expect(deleteMemory).toHaveBeenCalledTimes(2); + expect(deleteMemory).toHaveBeenCalledWith("trace-1"); + expect(deleteMemory).toHaveBeenCalledWith("user-memory-1"); + + const reopenButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes("查看本轮记忆依据")); + await act(async () => reopenButton?.click()); + expect(container.textContent).not.toContain("暂时无法读取本轮记忆依据"); + expect(container.textContent).not.toContain("部分记忆删除失败"); + expect(container.textContent).not.toContain("trace-1"); + const remainingDeleteButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent?.trim() === "删除"); + await act(async () => remainingDeleteButton?.click()); + expect(remainingDeleteButton?.textContent?.trim()).toBe("撤销"); + expect(deleteMemory).toHaveBeenCalledTimes(2); + + await act(async () => { + root.render( + + + + ); + }); + expect(deleteMemory).toHaveBeenCalledTimes(3); + expect(deleteMemory).toHaveBeenCalledWith("trace-2"); + }); +}); diff --git a/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx b/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx index 12e0f0a2d..cfb7f4fad 100644 --- a/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-thread-messages.test.tsx @@ -12,6 +12,38 @@ const stylesSourceUrl = new URL("../../styles.css", import.meta.url); const WINDOWS_COMMAND_ERROR = "'node' 不是内部或外部命令,也不是可运行的程序\r\n或批处理文件。"; describe("AgentThreadMessages", () => { + it("shows a per-answer control for inspecting injected memories", () => { + const html = renderToString( + + + + ); + + expect(html).toContain("查看本轮记忆依据"); + expect(html).toContain('aria-expanded="false"'); + expect(html).toContain("agent-memory-evidence-toggle"); + expect(html.indexOf("查看本轮记忆依据")).toBeLessThan(html.indexOf("已完成。")); + }); + it("keeps memo boundaries around chat history rendering", () => { const threadSource = readFileSync(agentThreadMessagesSourceUrl, "utf8"); const contentSource = readFileSync(agentMessageContentSourceUrl, "utf8"); diff --git a/App/frontend/desktop/src/pages/tests/agent-workspace-context.test.tsx b/App/frontend/desktop/src/pages/tests/agent-workspace-context.test.tsx new file mode 100644 index 000000000..3e455e169 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-workspace-context.test.tsx @@ -0,0 +1,169 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { WorkspaceEnvironmentSnapshot } from "../../api/memmy-agent-client.js"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import { AgentWorkspaceContext } from "../agent-workspace-context.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function environment(branch: string | null = "zy_git_v1.0.7"): WorkspaceEnvironmentSnapshot { + return { + scope_kind: "project", + scope_key: "project-1", + cwd: "/workspace/memmy-agent", + status: "ready", + revision: `revision-${branch ?? "detached"}`, + captured_at: "2026-08-11T08:00:00.000Z", + repository: { + display_name: "memmy-agent", + root: "/workspace/memmy-agent", + head_sha: "84d10f8f00", + branch, + detached: branch == null, + upstream: null, + ahead: 0, + behind: 0, + worktree: "clean", + }, + changes: { file_count: 0, additions: 0, deletions: 0, conflicts: 0, staged: 0, unstaged: 0, untracked: 0 }, + goal: null, + }; +} + +describe("AgentWorkspaceContext", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + function renderContext( + snapshot = environment(), + onSwitchBranch = vi.fn(async () => true), + onCreateOrCheckoutBranch = vi.fn(async () => true), + ) { + const branches = ["main", "zy_git_v1.0.7", "zy_v1.0.6", "release/1", "release/2", "release/3", "release/4", "release/5"]; + act(() => { + root.render( + + + + ); + }); + return { branches, onSwitchBranch, onCreateOrCheckoutBranch }; + } + + it("shows the local mode and current branch from the shared snapshot", () => { + renderContext(); + + expect(container.textContent).toContain("本地"); + expect(container.textContent).toContain("zy_git_v1.0.7"); + }); + + it("renders nothing when the shared snapshot is not a Git repository", () => { + act(() => { + root.render( + + true)} + onCreateOrCheckoutBranch={vi.fn(async () => true)} + /> + + ); + }); + + expect(container.innerHTML).toBe(""); + }); + + it("opens the work mode menu without presenting unsupported modes as active actions", () => { + renderContext(); + const modeButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "本地"); + + act(() => modeButton!.click()); + + expect(container.textContent).toContain("工作模式"); + expect([...container.querySelectorAll("button:disabled")].map((button) => button.textContent)).toEqual([ + "新工作树", + ]); + }); + + it("searches local branches and switches the selected branch", async () => { + const { onSwitchBranch } = renderContext(); + const branchButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "zy_git_v1.0.7"); + act(() => branchButton!.click()); + + expect((container.querySelector('[role="listbox"]') as HTMLElement).style.getPropertyValue("--visible-branch-count")).toBe("5"); + const search = container.querySelector('input[aria-label="搜索分支"]') as HTMLInputElement; + act(() => { + search.value = "main"; + search.dispatchEvent(new Event("input", { bubbles: true })); + }); + const mainOption = [...container.querySelectorAll('[role="option"]')].find((option) => option.textContent?.includes("main")) as HTMLButtonElement; + await act(async () => mainOption.click()); + + expect(onSwitchBranch).toHaveBeenCalledWith("main"); + }); + + it("keeps the create action outside the five-row scroller and creates a new branch", async () => { + const { onCreateOrCheckoutBranch } = renderContext(); + const branchButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "zy_git_v1.0.7"); + act(() => branchButton!.click()); + + const listbox = container.querySelector('[role="listbox"]') as HTMLElement; + const createButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent === "创建或检出新分支") as HTMLButtonElement; + expect(listbox.contains(createButton)).toBe(false); + + act(() => createButton.click()); + const input = container.querySelector('input[aria-label="新分支名称"]') as HTMLInputElement; + act(() => { + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + setInputValue?.call(input, "feature/new-branch"); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + const confirmButton = [...container.querySelectorAll("button")] + .find((button) => button.textContent === "创建并检出") as HTMLButtonElement; + await act(async () => confirmButton.click()); + + expect(onCreateOrCheckoutBranch).toHaveBeenCalledWith("feature/new-branch"); + }); + + it("requires confirmation before switching a dirty workspace", async () => { + const confirm = vi.fn(() => false); + vi.stubGlobal("confirm", confirm); + const dirty = environment(); + dirty.repository = dirty.repository ? { ...dirty.repository, worktree: "dirty" } : null; + const { onSwitchBranch } = renderContext(dirty); + const branchButton = [...container.querySelectorAll("button")].find((button) => button.textContent === "zy_git_v1.0.7"); + act(() => branchButton!.click()); + const mainOption = [...container.querySelectorAll('[role="option"]')].find((option) => option.textContent?.includes("main")) as HTMLButtonElement; + await act(async () => mainOption.click()); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(onSwitchBranch).not.toHaveBeenCalled(); + }); +}); diff --git a/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts index c0cfa6a56..b092e1cff 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts @@ -23,4 +23,25 @@ describe("ApiKeyOptionalPage catalog source", () => { expect(source).not.toContain("maxTokens"); expect(source).not.toContain("dailyTokenLimit"); }); + + it("保存失败时展示可重试的持久化错误", () => { + const source = readFileSync(sourcePath, "utf8"); + + expect(source).toContain("const [saveError, setSaveError] = useState(null)"); + expect(source).toContain("setSaveError(null)"); + expect(source).toContain("setSaveError(optionalStepSaveErrorText(error, t))"); + expect(source).toContain("optionalStepSaveErrorText"); + expect(source).toContain("const savedCatalogSignatureRef = useRef(null)"); + expect(source).toContain("savedCatalogSignatureRef.current !== saveSignature"); + expect(source).toContain("savedCatalogSignatureRef.current = saveSignature"); + expect(source).toContain("const savedEndpointIdentitiesRef = useRef"); + expect(source).toContain("savedAsrIdentity?.credentialSignature === asrCredentialSignature"); + expect(source).toContain("savedImageIdentity?.credentialSignature === imageGenCredentialSignature"); + expect(source).toContain("endpointCredentialSignature("); + expect(source).toContain("provider: provider.trim().toLowerCase()"); + expect(source).toContain('endpoint: endpoint.trim().replace(/\\/+$/, "")'); + expect(source).toContain("credential: normalizedMaskedApiKey ?"); + expect(source).toContain('role="alert"'); + expect(source).toContain("disabled={modePersistencePending}"); + }); }); diff --git a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts index 026621c4a..193d7e8e6 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts @@ -66,6 +66,23 @@ describe("ApiKeyPage source", () => { expect(stateIndex).toBeGreaterThan(saveIndex); expect(persistIndex).toBeGreaterThan(stateIndex); expect(navigateIndex).toBeGreaterThan(persistIndex); + expect(source).toContain("const [savePending, setSavePending] = useState(false)"); + expect(source).toContain("const [saveError, setSaveError] = useState(null)"); + expect(source).toContain("if (!canSave || !clients?.config || savePending)"); + expect(source).toContain("disabled={!canSave || !clients?.config || savePending}"); + expect(source).toContain("setSaveError(firstStepSaveErrorText(error, t))"); + expect(source).toContain("const savedCatalogSignatureRef = useRef(null)"); + expect(source).toContain("savedCatalogSignatureRef.current !== saveSignature"); + expect(source).toContain("savedCatalogSignatureRef.current = saveSignature"); + expect(source).toContain("const savedEndpointIdentitiesRef = useRef"); + expect(source).toContain("savedAgentIdentity?.credentialSignature === agentCredentialSignature"); + expect(source).toContain("savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature"); + expect(source).toContain("endpointCredentialSignature("); + expect(source).toContain("provider: provider.trim().toLowerCase()"); + expect(source).toContain('endpoint: endpoint.trim().replace(/\\/+$/, "")'); + expect(source).toContain("credential: normalizedMaskedApiKey ?"); + expect(source).toContain('role="alert"'); + expect(source).toContain("} finally {"); }); it("重新进入 BYOK 第一步时从已保存模型配置 hydrate 主模型与 Embedding", () => { diff --git a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx index 4c79c0d02..5deb2cc63 100644 --- a/App/frontend/desktop/src/pages/tests/app-frame.test.tsx +++ b/App/frontend/desktop/src/pages/tests/app-frame.test.tsx @@ -4,14 +4,15 @@ import { resolve } from "node:path"; import { renderToString } from "react-dom/server"; import { describe, expect, it } from "vitest"; import { AppProviders } from "../../app/providers.js"; +import type { UpdateCoordinatorValue } from "../../app/update-coordinator.js"; import type { MemmyAgentProject } from "../../api/memmy-agent-client.js"; import { I18nProvider } from "../../i18n/i18n-provider.js"; -import { enUSMessages, zhCNMessages, type MessageKey } from "../../i18n/messages.js"; +import { enUSMessages, formatMessage, zhCNMessages, type MessageKey, type MessageValues } from "../../i18n/messages.js"; import { appActions } from "../../state/app-actions.js"; import { appReducer, createInitialAppState } from "../../state/app-reducer.js"; import type { AgentTaskView } from "../../state/agent-chat-slice.js"; import { mockBootstrap } from "./fixtures/bootstrap.js"; -import { AppFrame, TaskArchiveInlineAction, TaskRow, countProjectTasksToArchive, deriveSidebarPlacement, deriveVisibleSidebarPlacement, groupAgentTasks, groupTasksByTime, resolveSidebarAccountSummary, resolveSidebarContextMenuPlacement, resolveSidebarMenuOverlayStyle, resolveTaskAncestorGroupKeys, shouldCreateNewAgentDraft, truncateAccountDisplayText } from "../app-frame.js"; +import { AppFrame, TaskArchiveInlineAction, TaskRow, countProjectTasksToArchive, deriveSidebarPlacement, deriveVisibleSidebarPlacement, groupAgentTasks, groupTasksByTime, resolveSidebarAccountSummary, resolveSidebarContextMenuPlacement, resolveSidebarMenuOverlayStyle, resolveSidebarUpdateAction, resolveTaskAncestorGroupKeys, shouldCreateNewAgentDraft, truncateAccountDisplayText } from "../app-frame.js"; describe("AppFrame", () => { it("使用原型 MainLayout 的侧栏图标与导航文案", () => { @@ -528,6 +529,42 @@ describe("AppFrame", () => { expect(source).not.toContain('className={`app-frame-profile-settings shrink-0 inline-flex items-center justify-center transition-colors cursor-pointer'); }); + it("resolves account footer update states for the inline update button", () => { + const t = zhTestTranslate; + + expect(resolveSidebarUpdateAction(null, t)).toBeNull(); + expect(resolveSidebarUpdateAction(updateViewModel({ phase: "idle" }), t)).toBeNull(); + expect(resolveSidebarUpdateAction(updateViewModel({ phase: "available" }), t)).toMatchObject({ + kind: "available", + label: "更新", + disabled: false + }); + expect(resolveSidebarUpdateAction(updateViewModel({ + phase: "downloading", + downloadProgress: { + downloadUrl: "https://updates.example.com/Memmy.dmg", + filePath: "/tmp/Memmy.dmg", + transferredBytes: 12, + totalBytes: 100, + percent: 12.3 + } + }), t)).toMatchObject({ + kind: "downloading", + label: "12%", + disabled: true + }); + expect(resolveSidebarUpdateAction(updateViewModel({ phase: "installing" }), t)).toMatchObject({ + kind: "installing", + label: "正在安装", + disabled: true + }); + expect(resolveSidebarUpdateAction(updateViewModel({ phase: "prepared" }), t)).toMatchObject({ + kind: "prepared", + label: "重启", + disabled: false + }); + }); + it("settingsNav replaces the main sidebar with settings section links", () => { const html = renderToString( @@ -1175,6 +1212,23 @@ function sidebarLabels() { }; } +function zhTestTranslate(key: MessageKey, values?: MessageValues): string { + return formatMessage(zhCNMessages[key], values); +} + +function updateViewModel(overrides: Partial = {}): UpdateCoordinatorValue { + return { + appVersion: "1.0.6", + phase: "idle", + preparedUpdatePath: null, + downloadProgress: null, + feedback: null, + requestInlineAction: async () => undefined, + requestPrimaryAction: async () => undefined, + ...overrides + }; +} + function task(chatId: string, overrides: Partial> = {}) { return { ...baseTask(chatId), ...overrides }; } diff --git a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx new file mode 100644 index 000000000..b882fe04c --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx @@ -0,0 +1,633 @@ +// @vitest-environment happy-dom + +import type { ModelConfigInput, ModelConfigView } from "@memmy/local-api-contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppClients } from "../../api/client-types.js"; +import type { ModelProviderConfig } from "../../api/config-client.js"; +import { appActions } from "../../state/app-actions.js"; +import { createInitialAppState, type AppState } from "../../state/app-reducer.js"; +import { + assignedCatalogEndpointId, + assignCatalogPreset, + createModelWorkspace, + maskApiKey, + modelConfigInput, + upsertByokPreset +} from "../../state/model-workspace.js"; +import { ApiKeyOptionalPage } from "../api-key-optional-page.js"; +import { ApiKeyPage } from "../api-key-page.js"; +import { ModelPage } from "../model-page.js"; + +const mocks = vi.hoisted(() => ({ + clients: null as AppClients | null, + state: null as AppState | null, + dispatch: vi.fn(), + track: vi.fn() +})); + +vi.mock("../../app/providers.js", () => ({ + useApiClients: () => ({ clients: mocks.clients, setClients: vi.fn() }) +})); + +vi.mock("../../state/app-state.js", () => ({ + useAppState: () => ({ state: mocks.state, dispatch: mocks.dispatch }) +})); + +vi.mock("../../analytics/use-analytics.js", () => ({ + useAnalytics: () => ({ track: mocks.track, ready: true }) +})); + +vi.mock("../../i18n/use-translation.js", () => ({ + useTranslation: () => ({ t: (key: string) => key, language: "zh-CN" }) +})); + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("BYOK setup save feedback", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig() }; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + document.body.replaceChildren(); + mocks.clients = null; + mocks.state = null; + vi.restoreAllMocks(); + }); + + it("advances from the first step after persistence succeeds", async () => { + mocks.clients = createClients(vi.fn(async () => savedModelConfig())); + await render(); + + await click(button("apiKey.next")); + + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + + it("shows a first-step conflict, stays put, and allows a successful retry", async () => { + const firstSave = deferred(); + const saveModelCatalog = vi.fn() + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(savedModelConfig()); + mocks.clients = createClients(saveModelCatalog); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + expect(nextButton.disabled).toBe(true); + + await reject(firstSave, Object.assign(new Error("stale revision"), { code: "model_config_changed" })); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("settings.model.configChanged"); + expect(nextButton.disabled).toBe(false); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + + await click(nextButton); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + }); + + it("does not save the first-step catalog again when mode persistence is retried", async () => { + const saveModelCatalog = vi.fn(async () => savedModelConfig()); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.clients = createClients(saveModelCatalog, { updateSettings }); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("settings offline"); + expect(saveModelCatalog).toHaveBeenCalledTimes(1); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + + await click(nextButton); + + expect(saveModelCatalog).toHaveBeenCalledTimes(1); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + }); + + it("reuses first-step endpoint identities when only the model changes after partial success", async () => { + const server = createCatalogServer(); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + const firstCatalog = server.catalog(); + const firstAgentEndpointId = assignedCatalogEndpointId(createModelWorkspace(firstCatalog), "byok", "agent"); + expect(endpointCount(firstCatalog)).toBe(2); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + + await changeField("apiKey.model", "gpt-4.1"); + await click(button("apiKey.test")); + await vi.waitFor(() => expect(nextButton.disabled).toBe(false)); + await click(nextButton); + + const retriedCatalog = server.catalog(); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(endpointCount(retriedCatalog)).toBe(2); + expect(assignedCatalogEndpointId(createModelWorkspace(retriedCatalog), "byok", "agent")) + .toBe(firstAgentEndpointId); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); + }); + + it("reuses existing first-step endpoints when saved keys are entered again", async () => { + const server = createCatalogServer(configuredCatalog(false)); + const initialAgentEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "agent"); + const initialEmbeddingEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "embedding"); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: vi.fn(async () => maskedSavedModelConfig(server.catalog())) + }); + await render(); + + await changeField("apiKey.key", "sk-primary"); + await click(testButton()); + await changeField("apiKey.embeddingKey", "sk-embedding"); + await click(testButton(1)); + await vi.waitFor(() => expect(button("apiKey.next").disabled).toBe(false)); + await click(button("apiKey.next")); + + const savedCatalog = server.catalog(); + expect(endpointCount(savedCatalog)).toBe(2); + expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "agent")) + .toBe(initialAgentEndpointId); + expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "embedding")) + .toBe(initialEmbeddingEndpointId); + }); + + it("invalidates only the changed first-step credential identity", async () => { + const server = createCatalogServer(); + const updateSettings = vi.fn(async (settings: unknown) => settings) + .mockRejectedValueOnce(new Error("settings offline")); + mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateSettings + }); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + const firstCatalog = server.catalog(); + const firstAgentEndpointId = assignedCatalogEndpointId(createModelWorkspace(firstCatalog), "byok", "agent"); + + await changeField("apiKey.key", "sk-primary-changed"); + await click(button("apiKey.test")); + await vi.waitFor(() => expect(nextButton.disabled).toBe(false)); + await click(nextButton); + + const retriedCatalog = server.catalog(); + expect(endpointCount(retriedCatalog)).toBe(3); + expect(assignedCatalogEndpointId(createModelWorkspace(retriedCatalog), "byok", "agent")) + .not.toBe(firstAgentEndpointId); + }); + + it("shows a busy error after Skip, stays put, and allows a successful retry", async () => { + const firstSave = deferred(); + const saveModelCatalog = vi.fn() + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(savedModelConfig()); + mocks.clients = createClients(saveModelCatalog); + await render(); + const skipButton = button("apiKey.optionalPage.skip"); + const nextButton = button("apiKey.next"); + + await click(skipButton); + expect(skipButton.disabled).toBe(true); + expect(nextButton.disabled).toBe(true); + + await reject(firstSave, Object.assign(new Error("busy"), { code: "config_write_busy" })); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("settings.modelWorkspace.saveBusy"); + expect(skipButton.disabled).toBe(false); + expect(nextButton.disabled).toBe(false); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/onboarding")); + + await click(skipButton); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/onboarding")); + }); + + it("shows a raw save error after Next, stays put, and allows a successful retry", async () => { + const firstSave = deferred(); + const saveModelCatalog = vi.fn() + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(savedModelConfig()); + mocks.clients = createClients(saveModelCatalog); + await render(); + const skipButton = button("apiKey.optionalPage.skip"); + const nextButton = button("apiKey.next"); + + await click(nextButton); + await reject(firstSave, new Error("catalog offline")); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("catalog offline"); + expect(skipButton.disabled).toBe(false); + expect(nextButton.disabled).toBe(false); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/onboarding")); + + await click(nextButton); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/onboarding")); + }); + + it("does not save the optional catalog again when onboarding persistence is retried", async () => { + const saveModelCatalog = vi.fn(async () => savedModelConfig()); + const updateOnboarding = vi.fn(async (onboarding: unknown) => onboarding) + .mockRejectedValueOnce(new Error("onboarding offline")); + mocks.clients = createClients(saveModelCatalog, { updateOnboarding }); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain("onboarding offline"); + expect(saveModelCatalog).toHaveBeenCalledTimes(1); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/onboarding")); + + await click(nextButton); + + expect(saveModelCatalog).toHaveBeenCalledTimes(1); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/onboarding")); + }); + + it("reuses optional endpoint identities when only the image model changes after partial success", async () => { + const server = createCatalogServer(configuredCatalog(false)); + const updateOnboarding = vi.fn(async (onboarding: unknown) => onboarding) + .mockRejectedValueOnce(new Error("onboarding offline")); + mocks.state = { ...createInitialAppState(), modelConfig: savedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: server.getModelConfig, + updateOnboarding + }); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + const firstCatalog = server.catalog(); + const firstImageEndpointId = assignedCatalogEndpointId(createModelWorkspace(firstCatalog), "byok", "image_generation"); + expect(endpointCount(firstCatalog)).toBe(4); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/onboarding")); + + await changeField("apiKey.imageGenModel", "gpt-image-2"); + await click(button("apiKey.test", 1)); + await vi.waitFor(() => expect(nextButton.disabled).toBe(false)); + await click(nextButton); + + const retriedCatalog = server.catalog(); + expect(server.saveModelCatalog).toHaveBeenCalledTimes(2); + expect(endpointCount(retriedCatalog)).toBe(4); + expect(assignedCatalogEndpointId(createModelWorkspace(retriedCatalog), "byok", "image_generation")) + .toBe(firstImageEndpointId); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/onboarding")); + }); + + it("reuses existing optional endpoints when saved keys are entered again", async () => { + const server = createCatalogServer(configuredCatalog()); + const initialAsrEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "asr"); + const initialImageEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "image_generation"); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; + mocks.clients = createClients(server.saveModelCatalog, { + getModelConfig: vi.fn(async () => maskedSavedModelConfig(server.catalog())) + }); + await render(); + + await changeField("apiKey.asrKey", "sk-asr"); + await click(testButton()); + await changeField("apiKey.imageGenKey", "sk-image"); + await click(testButton(1)); + await vi.waitFor(() => expect(button("apiKey.next").disabled).toBe(false)); + await click(button("apiKey.next")); + + const savedCatalog = server.catalog(); + expect(endpointCount(savedCatalog)).toBe(4); + expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "asr")) + .toBe(initialAsrEndpointId); + expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "image_generation")) + .toBe(initialImageEndpointId); + }); + + it("keeps the middle step pending and retryable when its catalog save fails", async () => { + const firstSave = deferred(); + const saveModelCatalog = vi.fn() + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValueOnce(savedModelConfig()); + mocks.clients = createClients(saveModelCatalog); + await render(); + const nextButton = button("apiKey.next"); + + await click(nextButton); + expect(nextButton.disabled).toBe(true); + + await reject(firstSave, new Error("middle step offline")); + + expect(container.textContent).toContain("middle step offline"); + expect(nextButton.disabled).toBe(false); + expect(mocks.dispatch).not.toHaveBeenCalledWith(appActions.navigate("/api-key-optional")); + + await click(nextButton); + expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-optional")); + }); + + async function render(page: React.ReactNode) { + await act(async () => root.render(page)); + } + + async function click(target: HTMLButtonElement) { + await act(async () => target.click()); + } + + function button(label: string, index = 0): HTMLButtonElement { + const target = [...container.querySelectorAll("button")] + .filter((candidate) => candidate.textContent === label)[index]; + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`button not found: ${label}`); + } + return target; + } + + function testButton(index = 0): HTMLButtonElement { + const target = [...container.querySelectorAll("button")] + .filter((candidate) => candidate.textContent?.startsWith("apiKey.test"))[index]; + if (!(target instanceof HTMLButtonElement)) { + throw new Error(`test button not found: ${index}`); + } + return target; + } + + async function changeField(labelText: string, value: string) { + const label = [...container.querySelectorAll("label")] + .find((candidate) => candidate.textContent === labelText); + const input = label?.parentElement?.querySelector("input"); + if (!(input instanceof HTMLInputElement)) { + throw new Error(`input not found: ${labelText}`); + } + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } +}); + +function createClients( + saveModelCatalog: ReturnType, + overrides: { + getModelConfig?: ReturnType; + testModelConfig?: ReturnType; + updateSettings?: ReturnType; + updateOnboarding?: ReturnType; + } = {} +): AppClients { + return { + config: { + getModelConfig: vi.fn(async () => mocks.state!.modelConfig), + saveModelCatalog, + testModelConfig: vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-08-19T00:00:00.000Z" + })), + updateSettings: vi.fn(async (settings) => settings), + updateOnboarding: vi.fn(async (onboarding) => onboarding), + ...overrides + } + } as unknown as AppClients; +} + +function savedModelConfig(catalog = configuredCatalog()): ModelProviderConfig { + return { + provider: "openai", + endpoint: "https://api.openai.com/v1", + model: "gpt-4o", + apiKey: "sk-primary", + apiKeyMasked: "", + configured: true, + embedding: { + mode: "custom", + endpoint: "https://api.openai.com/v1", + model: "text-embedding-3-small", + apiKey: "sk-embedding", + apiKeyMasked: "", + configured: true + }, + asr: { + provider: "qwen", + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1", + model: "qwen3-asr-flash", + apiKey: "sk-asr", + apiKeyMasked: "", + configured: true + }, + imageGen: { + provider: "openai", + endpoint: "https://api.openai.com/v1", + model: "gpt-image-1", + apiKey: "sk-image", + apiKeyMasked: "", + configured: true + }, + catalog + }; +} + +function maskedSavedModelConfig(catalog = configuredCatalog()): ModelProviderConfig { + const saved = savedModelConfig(catalog); + return { + ...saved, + apiKey: "", + apiKeyMasked: maskApiKey("sk-primary"), + embedding: saved.embedding ? { + ...saved.embedding, + apiKey: "", + apiKeyMasked: maskApiKey("sk-embedding") + } : undefined, + asr: saved.asr ? { + ...saved.asr, + apiKey: "", + apiKeyMasked: maskApiKey("sk-asr") + } : undefined, + imageGen: saved.imageGen ? { + ...saved.imageGen, + apiKey: "", + apiKeyMasked: maskApiKey("sk-image") + } : undefined + }; +} + +function configuredCatalog(includeOptional = true): ModelConfigView { + const empty = emptyCatalog(); + let workspace = createModelWorkspace(empty); + const agent = upsertByokPreset(workspace, { + provider: "openai", + endpoint: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-primary", + model: "gpt-4o", + capabilities: ["agent"] + }); + workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId); + const embedding = upsertByokPreset(workspace, { + provider: "openai", + endpoint: "https://api.openai.com/v1", + protocol: "openai-embeddings", + apiKey: "sk-embedding", + model: "text-embedding-3-small", + capabilities: ["embedding"] + }); + workspace = assignCatalogPreset(embedding.workspace, "byok", "embedding", embedding.presetId); + if (!includeOptional) { + return catalogFromInput(modelConfigInput(workspace), empty, 1); + } + const asr = upsertByokPreset(workspace, { + provider: "qwen", + endpoint: "https://dashscope.aliyuncs.com/compatible-mode/v1", + protocol: "dashscope-input-audio-chat", + apiKey: "sk-asr", + model: "qwen3-asr-flash", + capabilities: ["asr"] + }); + workspace = assignCatalogPreset(asr.workspace, "byok", "asr", asr.presetId); + const image = upsertByokPreset(workspace, { + provider: "openai", + endpoint: "https://api.openai.com/v1", + protocol: "openai-images", + apiKey: "sk-image", + model: "gpt-image-1", + capabilities: ["image_generation"] + }); + workspace = assignCatalogPreset(image.workspace, "byok", "image_generation", image.presetId); + return catalogFromInput(modelConfigInput(workspace), empty, 1); +} + +function endpointCount(catalog: ModelConfigView): number { + return catalog.providers.reduce((total, provider) => total + provider.endpoints.length, 0); +} + +function emptyCatalog(): ModelConfigView { + const assignment = { + agent: { candidates: [], default: null }, + memorySummary: null, + memoryEvolution: null, + embedding: null, + asr: null, + imageGeneration: null + }; + return { + configRevision: "revision-0", + providers: [], + modelAssignments: { + byok: structuredClone(assignment), + account: structuredClone(assignment) + }, + effectiveCandidates: { byok: [], account: [] }, + configured: false, + updatedAt: "2026-08-19T00:00:00.000Z" + }; +} + +function createCatalogServer(initialCatalog = emptyCatalog()) { + let catalog = structuredClone(initialCatalog); + let revision = 0; + return { + getModelConfig: vi.fn(async () => savedModelConfig(catalog)), + saveModelCatalog: vi.fn(async (input: ModelConfigInput | ModelConfigView) => { + const writable = "configured" in input + ? modelConfigInput(createModelWorkspace(input)) + : input; + catalog = catalogFromInput(writable, catalog, ++revision); + return savedModelConfig(catalog); + }), + catalog: () => structuredClone(catalog) + }; +} + +function catalogFromInput(input: ModelConfigInput, previous: ModelConfigView, revision: number): ModelConfigView { + const providers: ModelConfigView["providers"] = input.providers.map((provider) => { + const previousProvider = previous.providers.find((candidate) => candidate.provider === provider.provider); + const endpoints = provider.endpoints.map((endpoint) => { + const previousEndpoint = previousProvider?.endpoints.find((candidate) => candidate.endpointId === endpoint.endpointId); + const rawApiKey = endpoint.apiKey?.trim() ?? ""; + const apiKeyMasked = rawApiKey ? maskApiKey(rawApiKey) : previousEndpoint?.apiKeyMasked ?? ""; + return { + endpointId: endpoint.endpointId, + apiBase: endpoint.apiBase, + protocol: endpoint.protocol, + hasApiKey: Boolean(rawApiKey || apiKeyMasked), + apiKeyMasked, + apiKey: "" + }; + }); + const models = provider.models.map((model) => { + const endpoint = endpoints.find((candidate) => candidate.endpointId === model.endpointId)!; + return { + ...model, + provider: provider.provider, + protocol: endpoint.protocol, + available: true + }; + }); + const apiKeyMasked = provider.apiKey + ? maskApiKey(provider.apiKey) + : previousProvider?.apiKeyMasked ?? ""; + return { + provider: provider.provider, + configured: endpoints.some((endpoint) => endpoint.hasApiKey), + hasApiKey: Boolean(apiKeyMasked) || endpoints.some((endpoint) => endpoint.hasApiKey), + apiKeyMasked, + apiKey: "", + endpoints, + accountManaged: false, + editable: true, + models + }; + }); + const models = providers.flatMap((provider) => provider.models); + const byokIds = new Set(input.modelAssignments.byok.agent.candidates); + const accountIds = new Set(input.modelAssignments.account.agent.candidates); + return { + configRevision: `revision-${revision}`, + providers, + modelAssignments: structuredClone(input.modelAssignments), + effectiveCandidates: { + byok: models.filter((model) => byokIds.has(model.presetId)), + account: models.filter((model) => accountIds.has(model.presetId)) + }, + configured: byokIds.size > 0 || accountIds.size > 0, + updatedAt: "2026-08-19T00:00:00.000Z" + }; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((next, fail) => { + resolve = next; + reject = fail; + }); + return { promise, resolve, reject }; +} + +async function reject(pending: ReturnType>, error: unknown) { + await act(async () => { + pending.reject(error); + await Promise.resolve(); + }); +} diff --git a/App/frontend/desktop/src/pages/tests/home-page.test.tsx b/App/frontend/desktop/src/pages/tests/home-page.test.tsx index c1cb92348..1da77e585 100644 --- a/App/frontend/desktop/src/pages/tests/home-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/home-page.test.tsx @@ -141,6 +141,8 @@ describe("HomePage", () => { expect(chipStyles).toContain("opacity: 0;"); expect(chipStyles).toContain("opacity: 1;"); expect(chipStyles).toContain("font-weight: 500;"); + expect(chipStyles).toMatch(/\.composer-command-chip__icon\s*{[^}]*position:\s*absolute;/s); + expect(chipStyles).toMatch(/\.composer-command-chip__leading\s*{[^}]*display:\s*inline-flex;/s); expect(source.match(/ { expect(agentStatusText("connecting", null, (key) => key)).toBe("home.agent.connecting"); expect(agentStatusText("reconnecting", null, (key) => key)).toBe("home.agent.reconnecting"); expect(agentStatusText("error", null, (key) => key)).toBe("home.agent.failed"); + expect(agentStatusText("error", null, (key) => key, { + startupIssue: "model_config_invalid" + })).toBe("home.modelSelector.unavailable"); + expect(agentStatusText("error", null, (key) => key, { + startupIssue: "model_config_invalid", + hasConnected: true + })).toBe("home.agent.failed"); }); it("shows the specific queue steer failure messages", () => { @@ -277,12 +286,12 @@ describe("HomePage", () => { expect(source).toContain("const activeConversationTitle = state.agent.currentSessionKey"); expect(source).toContain("const activeImTitleDisplay = imChannelTitleDisplay(activeConversationTitle);"); expect(source).toContain("formatConversationTitleForDisplay(activeImTitleDisplay?.title ?? activeConversationTitle)"); - expect(source).toContain("topBar={hasActiveConversation ? ("); - expect(source).toContain('

'); - expect(source).toContain('{activeConversationTitleDisplay}'); - expect(source).toContain(''); - expect(source).toContain("{activeConversationTitleDisplay}"); - expect(source).toContain("topBarBorder={hasActiveConversation}"); + expect(source).toContain("topBar={hasActiveConversation || environmentScope ? ("); + expect(source).toContain('
'); + expect(source).toContain('title={hasActiveConversation ? activeConversationTitle : selectedDraftProject?.name}'); + expect(source).toContain("{hasActiveConversation ? activeConversationTitleDisplay : selectedDraftProject?.name}"); + expect(source).toContain('{hasActiveConversation && activeImTitleDisplay ? : null}'); + expect(source).toContain("topBarBorder={Boolean(hasActiveConversation || environmentScope)}"); expect(source).not.toContain("agent-conversation-titlebar"); expect(source).toContain("app-frame-page-content agent-conversation-scroll flex-1 overflow-y-auto"); expect(source).toContain("onScroll={handleAgentConversationScroll}"); @@ -578,14 +587,34 @@ describe("HomePage", () => { it("keeps the conversation composer on the same expanded two-row layout as a new chat", () => { const source = readFileSync(homePageSourcePath, "utf8"); + const styles = readFileSync(stylesSourcePath, "utf8"); - expect(source).toContain('${isComposerSingleLine ? "agent-composer-input--single " : ""}${selectedComposerCommand ? "agent-composer-input--command-selected " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40'); + expect(source).toContain('${isComposerSingleLine ? "agent-composer-input--single " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40'); + expect(source).not.toContain("agent-composer-input--command-selected"); + expect(styles).not.toContain("padding-left: 86px;"); expect(source).toContain('className="relative agent-composer-shell agent-composer-shell--expanded rounded-card-lg"'); expect(source).toContain('className="agent-composer-toolbar"'); - expect(source).toContain('
'); + expect(source).toContain('
'); + expect(styles).toMatch(/\.agent-composer-toolbar\s*{[^}]*display:\s*flex;/s); + expect(styles).toMatch(/\.agent-composer-toolbar \.composer-actions\s*{[^}]*margin-left:\s*auto;/s); expect(source).toContain("COMPOSER_SINGLE_LINE_HEIGHT_PX = 52"); }); + it("shifts the conversation without resizing it when the environment panel has room", () => { + const source = readFileSync(homePageSourcePath, "utf8"); + const styles = readFileSync(stylesSourcePath, "utf8"); + + expect(source).toContain('agent-workspace-layout${environmentPanelOpen ? " agent-workspace-layout--environment-open" : ""}'); + expect(source).toContain('className="agent-conversation-content max-w-3xl mx-auto space-y-3"'); + expect(source).toContain('className="agent-conversation-content agent-conversation-content--composer max-w-2xl mx-auto"'); + expect(styles).toContain("container-name: agent-workspace;"); + expect(styles).toContain("@container agent-workspace (min-width: 1240px)"); + expect(styles).toContain(".agent-workspace-layout--environment-open .agent-conversation-content"); + expect(styles).toMatch(/--agent-conversation-shift:\s*\d+px;/); + expect(styles).toContain("transform: translateX(calc(0px - var(--agent-conversation-shift)));"); + expect(styles).toMatch(/\.agent-environment-panel\s*{[^}]*position:\s*absolute;/s); + }); + it("lets expanded conversation text use the full width above the action footer", () => { const source = readFileSync(homePageSourcePath, "utf8"); const composerStart = source.indexOf('className="agent-conversation-composer"'); diff --git a/App/frontend/desktop/src/pages/tests/memory-page.test.tsx b/App/frontend/desktop/src/pages/tests/memory-page.test.tsx index 1a286ff44..5ea33cdba 100644 --- a/App/frontend/desktop/src/pages/tests/memory-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/memory-page.test.tsx @@ -17,10 +17,12 @@ describe("MemoryPageView", () => { expect(html).toContain('data-tour-anchor="product-tour-memory-nav"'); expect(html).toContain('data-icon="layers"'); expect(html).toContain('data-icon="brain-circuit"'); + expect(html).toContain('data-icon="user-round"'); expect(html).toContain("M12 5a3 3 0 1 0-5.997.125"); expect(html).toContain('data-icon="link-2"'); expect(html).toContain("概览"); expect(html).toContain("记忆"); + expect(html).toContain("用户记忆"); expect(html).toContain("任务"); expect(html).toContain("技能"); expect(html).toContain("分析"); @@ -63,6 +65,9 @@ describe("MemoryPageView", () => { writeMemorySubPage(storage, "memories"); expect(readMemorySubPage(storage)).toBe("memories"); + writeMemorySubPage(storage, "user-memories"); + expect(readMemorySubPage(storage)).toBe("user-memories"); + storage.setItem("memmy.memorySubPage", "unknown"); expect(readMemorySubPage(storage)).toBeNull(); }); diff --git a/App/frontend/desktop/src/pages/tests/model-page-source.test.ts b/App/frontend/desktop/src/pages/tests/model-page-source.test.ts index 395eb2fee..e03408d33 100644 --- a/App/frontend/desktop/src/pages/tests/model-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-page-source.test.ts @@ -63,6 +63,10 @@ describe("ModelPage source", () => { expect(pageSource).not.toContain("function simulateTest"); expect(pageSource).not.toContain("window.setTimeout"); expect(pageSource).toContain("modelPageSaveErrorText(error, t)"); + expect(pageSource).toContain("const [savePending, setSavePending] = useState(false)"); + expect(pageSource).toContain("disabled={!canContinue || savePending}"); + expect(pageSource).toContain("const [saveFeedback, setSaveFeedback]"); + expect(pageSource).toContain("{saveFeedback.text}"); expect(pageSource).not.toContain('t("login.error.modePersistenceFailed")'); expect(pageSource).not.toContain("MessageToast"); expect(pageSource).toContain('text-red-500">{saveFeedback.text}

'); diff --git a/App/frontend/desktop/src/pages/tests/model-workspace-long-model-name.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/model-workspace-long-model-name.interaction.test.tsx index 97b26eb6a..3d7cbf715 100644 --- a/App/frontend/desktop/src/pages/tests/model-workspace-long-model-name.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/model-workspace-long-model-name.interaction.test.tsx @@ -47,7 +47,12 @@ describe("模型工作区长模型名称", () => { const stylesSource = readFileSync(stylesPath, "utf8"); const modelListRule = stylesSource.match(/\.provider-model-list\s*\{[^}]*\}/s)?.[0] ?? ""; expect(modelListRule).toContain("max-width: 100%;"); + expect(modelListRule).toContain("--provider-model-visible-rows: 6;"); + expect(modelListRule).toContain("max-height: calc(var(--provider-model-row-height) * var(--provider-model-visible-rows));"); expect(modelListRule).toContain("overflow-x: hidden;"); + + const rowRule = stylesSource.match(/\.provider-model-list__row\s*\{[^}]*\}/s)?.[0] ?? ""; + expect(rowRule).toContain("height: var(--provider-model-row-height);"); }); it("限制编辑器中的模型名称输入长度", () => { diff --git a/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx b/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx index 33a29cb73..956512114 100644 --- a/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-invitation-banner.test.tsx @@ -96,6 +96,7 @@ describe("SettingsPage invitation banner", () => { preparedUpdatePath: null, downloadProgress: null, feedback: null, + requestInlineAction: vi.fn(async () => undefined), requestPrimaryAction: vi.fn(async () => undefined) }} /> @@ -114,7 +115,7 @@ describe("SettingsPage invitation banner", () => { .find((element) => element.textContent === "邀请好友,享更多额度"); const invitationBanner = inviteTitle?.parentElement?.parentElement; const tokenUsageSection = container.querySelector("#token-usage"); - expect(invitationBanner?.nextElementSibling).toBe(tokenUsageSection); + expect(tokenUsageSection?.nextElementSibling).toBe(invitationBanner); }); }); diff --git a/App/frontend/desktop/src/pages/tests/settings-page-token-usage.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page-token-usage.interaction.test.tsx index dda36151a..c91e7de7a 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page-token-usage.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page-token-usage.interaction.test.tsx @@ -89,6 +89,7 @@ describe("SettingsPage platform scene quota details", () => { preparedUpdatePath: null, downloadProgress: null, feedback: null, + requestInlineAction: vi.fn(async () => undefined), requestPrimaryAction: vi.fn(async () => undefined) }} /> @@ -208,6 +209,7 @@ describe("SettingsPage platform scene quota details", () => { preparedUpdatePath: null, downloadProgress: null, feedback: null, + requestInlineAction: vi.fn(async () => undefined), requestPrimaryAction: vi.fn(async () => undefined) }} /> diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index e9596154b..2582d6e84 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -638,7 +638,12 @@ describe("SettingsPageView", () => { expect(source).toContain("requestAccountInvitation(accountClient, accountKey)"); expect(source).not.toContain("resolveDisplayInviteCode"); expect(source).toContain('t("settings.token.invite.title")'); - expect(source).toContain("mb-6 flex items-center gap-3"); + expect(source).toContain("usageStyles.invitationCard"); + expect(styles).toContain(".invitationCard"); + const compactStyles = styles.slice(styles.indexOf("@media (max-width: 640px)")); + expect(compactStyles).toContain("grid-template-columns: auto minmax(0, 1fr)"); + expect(compactStyles).toContain("grid-column: 2"); + expect(compactStyles).toContain("flex-wrap: wrap"); expect(source).toContain("byokTokenUsageClient.getSummary"); expect(source).toContain("EMPTY_BYOK_TOKEN_USAGE"); expect(source).not.toContain("function ChannelStat"); @@ -1027,6 +1032,7 @@ function createUpdateViewModel( preparedUpdatePath: null, downloadProgress: null, feedback: null, + requestInlineAction: vi.fn(async () => undefined), requestPrimaryAction: vi.fn(async () => undefined), ...overrides }; diff --git a/App/frontend/desktop/src/pages/tests/use-workspace-environment.test.tsx b/App/frontend/desktop/src/pages/tests/use-workspace-environment.test.tsx new file mode 100644 index 000000000..0fb9117f9 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/use-workspace-environment.test.tsx @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { + MemmyAgentClient, + WorkspaceEnvironmentScope, + WorkspaceEnvironmentState, +} from "../../api/memmy-agent-client.js"; +import { useWorkspaceEnvironment } from "../use-workspace-environment.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((next) => { + resolve = next; + }); + return { promise, resolve }; +} + +function environment(scope: WorkspaceEnvironmentScope): WorkspaceEnvironmentState { + return { + snapshot: { + scope_kind: scope.kind, + scope_key: scope.key, + cwd: `/workspace/${scope.key}`, + status: "not_git", + revision: `revision-${scope.key}`, + captured_at: "2026-08-11T08:00:00.000Z", + repository: null, + changes: null, + goal: null, + }, + files: [], + branches: [scope.key], + }; +} + +function Probe({ client, scope }: { client: MemmyAgentClient; scope: WorkspaceEnvironmentScope }) { + const query = useWorkspaceEnvironment(client, scope, false); + return {query.data?.snapshot.scope_key ?? (query.loading ? "loading" : query.error ?? "empty")}; +} + +describe("useWorkspaceEnvironment", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + }); + + it("ignores a stale response after switching workspace scope", async () => { + const first = deferred(); + const second = deferred(); + const readWorkspaceEnvironment = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + const client = { readWorkspaceEnvironment } as unknown as MemmyAgentClient; + + await act(async () => { + root.render(); + }); + await act(async () => { + root.render(); + }); + await act(async () => second.resolve(environment({ kind: "project", key: "project-2" }))); + expect(container.textContent).toBe("project-2"); + + await act(async () => first.resolve(environment({ kind: "project", key: "project-1" }))); + expect(container.textContent).toBe("project-2"); + }); +}); diff --git a/App/frontend/desktop/src/pages/tests/workspace-diff-view.test.tsx b/App/frontend/desktop/src/pages/tests/workspace-diff-view.test.tsx new file mode 100644 index 000000000..c4ec2e07a --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/workspace-diff-view.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { parseWorkspaceDiff, WorkspaceDiffView, workspaceDiffLanguage } from "../workspace-diff-view.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +describe("WorkspaceDiffView", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it("parses hunk line numbers and change kinds", () => { + const lines = parseWorkspaceDiff([ + "diff --git a/src/panel.tsx b/src/panel.tsx", + "--- a/src/panel.tsx", + "+++ b/src/panel.tsx", + "@@ -65,3 +65,4 @@", + " context", + "-old value", + "+new value", + "+extra value", + "+++counter;", + "---counter;", + ].join("\n")); + + expect(lines).toEqual([ + { kind: "context", lineNumber: 65, content: "context" }, + { kind: "deletion", lineNumber: 66, content: "old value" }, + { kind: "addition", lineNumber: 66, content: "new value" }, + { kind: "addition", lineNumber: 67, content: "extra value" }, + { kind: "addition", lineNumber: 68, content: "++counter;" }, + { kind: "deletion", lineNumber: 67, content: "--counter;" }, + ]); + }); + + it("renders highlighted rows without raw diff prefixes", () => { + act(() => { + root.render( + + ); + }); + + const additions = container.querySelectorAll('[data-kind="addition"]'); + expect(additions).toHaveLength(1); + expect(additions[0]?.textContent).toContain("9const next = 2;"); + expect(container.textContent).not.toContain("+const next"); + expect(container.querySelector('[role="region"]')?.getAttribute("aria-label")).toBe("src/panel.tsx 的代码改动"); + }); + + it("maps common file extensions to Prism languages", () => { + expect(workspaceDiffLanguage("src/panel.tsx")).toBe("tsx"); + expect(workspaceDiffLanguage("scripts/release.sh")).toBe("bash"); + expect(workspaceDiffLanguage("NOTICE")).toBeNull(); + }); + + it("keeps separate rows for files without a known language", () => { + act(() => { + root.render(); + }); + + expect(container.querySelectorAll('[data-kind="addition"]')).toHaveLength(2); + }); +}); diff --git a/App/frontend/desktop/src/pages/token-detail-page.tsx b/App/frontend/desktop/src/pages/token-detail-page.tsx index 9fd34c906..10a654558 100644 --- a/App/frontend/desktop/src/pages/token-detail-page.tsx +++ b/App/frontend/desktop/src/pages/token-detail-page.tsx @@ -8,6 +8,7 @@ import { resolveInvitationToastKind } from "../app/invitation-result.js"; import { persistLoginModeSelection } from "../app/login-mode.js"; import { useApiClients } from "../app/providers.js"; import { buildAccountOnboardingStartPatch, resolvePostLoginRoute } from "../app/routes.js"; +import { setAnalyticsUserId } from "../analytics/analytics-context.js"; import { useAnalytics } from "../analytics/use-analytics.js"; import { AuthCodeForm } from "../components/auth-code-form.js"; import { LanguageToggleButton, PAGE_CORNER_ACTION_CONTAINER_STYLE, PageCornerActionButton } from "../components/language-toggle-button.js"; @@ -67,6 +68,8 @@ export function TokenDetailPage() { return; } const session = loginResult.session; + // Set before the signup event so it is attributed to the cloud account. + setAnalyticsUserId(session.profile.userId); const invitationToastKind = resolveInvitationToastKind(loginResult.invitationResult); if (invitationToastKind) { dispatch(appActions.showInvitationToast(invitationToastKind)); @@ -79,6 +82,7 @@ export function TokenDetailPage() { })); dispatch(appActions.accountUpdated({ + userId: session.profile.userId, email: session.profile.email ?? "", phoneNumber: session.profile.phoneNumber, nickname: session.profile.nickname, diff --git a/App/frontend/desktop/src/pages/use-workspace-environment.ts b/App/frontend/desktop/src/pages/use-workspace-environment.ts new file mode 100644 index 000000000..3c51e563a --- /dev/null +++ b/App/frontend/desktop/src/pages/use-workspace-environment.ts @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + MemmyAgentClient, + WorkspaceEnvironmentScope, + WorkspaceEnvironmentState, +} from "../api/memmy-agent-client.js"; + +export type WorkspaceEnvironmentQuery = { + data: WorkspaceEnvironmentState | null; + loading: boolean; + error: string | null; + refresh: () => Promise; + switchBranch: (branch: string) => Promise; + createOrCheckoutBranch: (branch: string) => Promise; +}; + +export function resolveWorkspaceEnvironmentScope( + sessionKey: string | null, + projectId: string | null, +): WorkspaceEnvironmentScope | null { + if (sessionKey) return { kind: "session", key: sessionKey }; + if (projectId) return { kind: "project", key: projectId }; + return null; +} + +export function useWorkspaceEnvironment( + client: MemmyAgentClient | null, + scope: WorkspaceEnvironmentScope | null, + refreshSignal: boolean, +): WorkspaceEnvironmentQuery { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestIdRef = useRef(0); + const previousRefreshSignalRef = useRef(refreshSignal); + const scopeKind = scope?.kind ?? null; + const scopeKey = scope?.key ?? null; + + const refresh = useCallback(async () => { + const requestId = ++requestIdRef.current; + if (!client || !scopeKind || !scopeKey) { + setData(null); + setLoading(false); + setError(null); + return; + } + + setLoading(true); + setError(null); + try { + const next = await client.readWorkspaceEnvironment({ kind: scopeKind, key: scopeKey }); + if (requestId === requestIdRef.current) setData(next); + } catch (cause) { + if (requestId === requestIdRef.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, [client, scopeKey, scopeKind]); + + const mutateBranch = useCallback(async ( + branch: string, + operation: "switch" | "create-or-checkout", + ): Promise => { + if (!client || !scopeKind || !scopeKey || !data) return false; + const requestId = ++requestIdRef.current; + setLoading(true); + setError(null); + try { + const next = operation === "create-or-checkout" + ? await client.createOrCheckoutWorkspaceEnvironmentBranch( + { kind: scopeKind, key: scopeKey }, + branch, + data.snapshot.revision, + ) + : await client.switchWorkspaceEnvironmentBranch( + { kind: scopeKind, key: scopeKey }, + branch, + data.snapshot.revision, + ); + if (requestId !== requestIdRef.current) return false; + setData(next); + return true; + } catch (cause) { + if (requestId === requestIdRef.current) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + return false; + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, [client, data, scopeKey, scopeKind]); + + const switchBranch = useCallback( + (branch: string) => mutateBranch(branch, "switch"), + [mutateBranch], + ); + const createOrCheckoutBranch = useCallback( + (branch: string) => mutateBranch(branch, "create-or-checkout"), + [mutateBranch], + ); + + useEffect(() => { + setData(null); + void refresh(); + return () => { + requestIdRef.current += 1; + }; + }, [refresh]); + + useEffect(() => { + if (previousRefreshSignalRef.current !== refreshSignal) { + previousRefreshSignalRef.current = refreshSignal; + void refresh(); + } + }, [refresh, refreshSignal]); + + useEffect(() => { + function refreshOnFocus() { + void refresh(); + } + window.addEventListener("focus", refreshOnFocus); + return () => window.removeEventListener("focus", refreshOnFocus); + }, [refresh]); + + return { data, loading, error, refresh, switchBranch, createOrCheckoutBranch }; +} diff --git a/App/frontend/desktop/src/pages/welcome-page.tsx b/App/frontend/desktop/src/pages/welcome-page.tsx index 766e3a6fb..b9a4c6cc4 100644 --- a/App/frontend/desktop/src/pages/welcome-page.tsx +++ b/App/frontend/desktop/src/pages/welcome-page.tsx @@ -12,6 +12,7 @@ import { AuthCodeForm } from "../components/auth-code-form.js"; import { LanguageToggleButton } from "../components/language-toggle-button.js"; import { Memmy } from "../components/mascot/memmy.js"; import { useVerificationCodeAuth } from "../components/use-verification-code-auth.js"; +import { setAnalyticsUserId } from "../analytics/analytics-context.js"; import { useAnalytics } from "../analytics/use-analytics.js"; import { getLegalLinkUrl } from "../legal/legal-links.js"; import { openExternalUrl } from "../utils/open-url.js"; @@ -74,6 +75,8 @@ export function WelcomePage() { return; } const session = loginResult.session; + // Set before the signup event so it is attributed to the cloud account. + setAnalyticsUserId(session.profile.userId); const invitationToastKind = resolveInvitationToastKind(loginResult.invitationResult); if (invitationToastKind) { dispatch(appActions.showInvitationToast(invitationToastKind)); @@ -86,6 +89,7 @@ export function WelcomePage() { })); dispatch(appActions.accountUpdated({ + userId: session.profile.userId, email: session.profile.email ?? "", phoneNumber: session.profile.phoneNumber, nickname: session.profile.nickname, diff --git a/App/frontend/desktop/src/pages/workspace-diff-view.tsx b/App/frontend/desktop/src/pages/workspace-diff-view.tsx new file mode 100644 index 000000000..f920eafec --- /dev/null +++ b/App/frontend/desktop/src/pages/workspace-diff-view.tsx @@ -0,0 +1,146 @@ +import { memo, useMemo } from "react"; +import { Prism as SyntaxHighlighter, createElement, type SyntaxHighlighterProps } from "react-syntax-highlighter"; +import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; + +export type WorkspaceDiffLineKind = "context" | "addition" | "deletion" | "notice"; + +export type WorkspaceDiffLine = { + kind: WorkspaceDiffLineKind; + lineNumber: number | null; + content: string; +}; + +type WorkspaceDiffViewProps = { + diff: string; + path: string; + ariaLabel: string; +}; + +const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/; +const MAX_HIGHLIGHT_CHARACTERS = 100_000; +const CODE_SELECTOR = 'code[class*="language-"]' as const; +const PRE_SELECTOR = 'pre[class*="language-"]' as const; +const DIFF_CODE_THEME = { + ...oneLight, + [CODE_SELECTOR]: { ...oneLight[CODE_SELECTOR], background: "transparent" }, + [PRE_SELECTOR]: { ...oneLight[PRE_SELECTOR], background: "transparent" }, +} satisfies NonNullable; + +export function parseWorkspaceDiff(diff: string): WorkspaceDiffLine[] { + const parsed: WorkspaceDiffLine[] = []; + let oldLine = 1; + let newLine = 1; + let insideHunk = false; + + for (const rawLine of diff.split(/\r?\n/)) { + if (rawLine.startsWith("diff --git ")) { + insideHunk = false; + continue; + } + const hunk = HUNK_HEADER.exec(rawLine); + if (hunk) { + oldLine = Number(hunk[1]); + newLine = Number(hunk[2]); + insideHunk = true; + continue; + } + if (!insideHunk && isDiffHeader(rawLine)) continue; + if (rawLine.startsWith("\\ No newline at end of file")) continue; + + if (rawLine.startsWith("+")) { + parsed.push({ kind: "addition", lineNumber: newLine, content: rawLine.slice(1) }); + newLine += 1; + continue; + } + if (rawLine.startsWith("-")) { + parsed.push({ kind: "deletion", lineNumber: oldLine, content: rawLine.slice(1) }); + oldLine += 1; + continue; + } + if (insideHunk && rawLine.startsWith(" ")) { + parsed.push({ kind: "context", lineNumber: newLine, content: rawLine.slice(1) }); + oldLine += 1; + newLine += 1; + continue; + } + if (insideHunk && rawLine === "") continue; + if (rawLine) parsed.push({ kind: "notice", lineNumber: null, content: rawLine }); + } + + return parsed; +} + +export function workspaceDiffLanguage(path: string): string | null { + const extension = path.split(".").pop()?.toLocaleLowerCase() ?? ""; + return { + bash: "bash", + css: "css", + go: "go", + html: "markup", + java: "java", + js: "javascript", + jsx: "jsx", + json: "json", + kt: "kotlin", + md: "markdown", + mjs: "javascript", + py: "python", + rs: "rust", + scss: "scss", + sh: "bash", + sql: "sql", + ts: "typescript", + tsx: "tsx", + xml: "markup", + yaml: "yaml", + yml: "yaml", + }[extension] ?? null; +} + +export const WorkspaceDiffView = memo(function WorkspaceDiffView({ diff, path, ariaLabel }: WorkspaceDiffViewProps) { + const lines = useMemo(() => parseWorkspaceDiff(diff), [diff]); + const language = useMemo(() => workspaceDiffLanguage(path), [path]); + const source = useMemo(() => lines.map((line) => line.content).join("\n"), [lines]); + const highlightLanguage = source.length <= MAX_HIGHLIGHT_CHARACTERS ? language : null; + + return ( +
+ lines.map((line, index) => ( +
+ + + {line.kind !== "notice" && rows[index] + ? createElement({ + node: rows[index], + stylesheet, + useInlineStyles, + key: index, + }) + : line.content || " "} + +
+ ))} + > + {source} +
+
+ ); +}); + +function isDiffHeader(line: string): boolean { + return line.startsWith("index ") + || line.startsWith("--- ") + || line.startsWith("+++ "); +} diff --git a/App/frontend/desktop/src/state/agent-chat-slice.ts b/App/frontend/desktop/src/state/agent-chat-slice.ts index 4be338ca0..5925c36ea 100644 --- a/App/frontend/desktop/src/state/agent-chat-slice.ts +++ b/App/frontend/desktop/src/state/agent-chat-slice.ts @@ -174,6 +174,7 @@ export interface AgentState { operationErrorNotice: AgentOperationError | null; operationErrorsBySurface: Record; connectionGeneration: number; + hasConnectedSinceStartup: boolean; recoveringGeneration: number | null; recoveringChatId: string | null; recoveringChatSelectionEpoch: number | null; @@ -338,6 +339,7 @@ export const initialAgentState: AgentState = { operationErrorNotice: null, operationErrorsBySurface: { chat: null, sidebar: null }, connectionGeneration: 0, + hasConnectedSinceStartup: false, recoveringGeneration: null, recoveringChatId: null, recoveringChatSelectionEpoch: null, @@ -2519,7 +2521,8 @@ function reduceWsEvent(state: AgentState, event: MemmyAgentWsEvent): AgentState ...base, connectionStatus: "connected", connectionError: null, - connectionGeneration: readyGeneration + connectionGeneration: readyGeneration, + hasConnectedSinceStartup: true }); } const pendingCanonicalHydrateByChatId = { ...base.pendingCanonicalHydrateByChatId }; @@ -2534,6 +2537,7 @@ function reduceWsEvent(state: AgentState, event: MemmyAgentWsEvent): AgentState connectionStatus: "connected", connectionError: null, connectionGeneration: readyGeneration, + hasConnectedSinceStartup: true, recoveringGeneration: readyGeneration, recoveringChatId: base.currentChatId, recoveringChatSelectionEpoch: base.currentChatId ? base.chatSelectionEpoch : null, diff --git a/App/frontend/desktop/src/state/app-actions.ts b/App/frontend/desktop/src/state/app-actions.ts index 076b0929f..3de3f5dcd 100644 --- a/App/frontend/desktop/src/state/app-actions.ts +++ b/App/frontend/desktop/src/state/app-actions.ts @@ -100,7 +100,7 @@ export type AppAction = | { type: "agentSources/scanCompletionExpired"; jobId: string } | { type: "scanPreferences/updated"; preferences: Partial } | { type: "preferredMode/updated"; preferredMode: PreferredMode } - | { type: "account/updated"; email?: string; phoneNumber?: string | null; nickname?: string; registeredAt?: string | null } + | { type: "account/updated"; userId?: string; email?: string; phoneNumber?: string | null; nickname?: string; registeredAt?: string | null } | { type: "account/cleared" } | { type: "modelConfig/updated"; config: Partial } | { type: "modal/changed"; modal: "nickname" | "scanPermission" | "improvement" | "modelConfig" | "manualSource"; open: boolean }; @@ -210,7 +210,7 @@ export const appActions = { }, /** Handles account updated. */ - accountUpdated(input: { email?: string; phoneNumber?: string | null; nickname?: string; registeredAt?: string | null }): AppAction { + accountUpdated(input: { userId?: string; email?: string; phoneNumber?: string | null; nickname?: string; registeredAt?: string | null }): AppAction { return { type: "account/updated", ...input }; }, diff --git a/App/frontend/desktop/src/state/app-reducer.ts b/App/frontend/desktop/src/state/app-reducer.ts index 9b6954848..1a49301b6 100644 --- a/App/frontend/desktop/src/state/app-reducer.ts +++ b/App/frontend/desktop/src/state/app-reducer.ts @@ -37,6 +37,8 @@ export interface AgentSourcesState { /** Contract for account state. */ export interface AccountState { + /** Cloud account id; null in BYOK / signed-out mode. */ + userId: string | null; email: string; phoneNumber: string | null; nickname: string; @@ -129,6 +131,7 @@ export function createInitialAppState(): AppState { scanPreferences: defaultScanPreferences }, account: { + userId: null, email: "", phoneNumber: null, nickname: "", @@ -334,6 +337,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { return { ...state, account: { + userId: action.userId ?? state.account.userId, email: action.email ?? state.account.email, phoneNumber: Object.prototype.hasOwnProperty.call(action, "phoneNumber") ? (action.phoneNumber ?? null) : state.account.phoneNumber, nickname: action.nickname ?? state.account.nickname, @@ -344,6 +348,7 @@ export function appReducer(state: AppState, action: AppAction): AppState { return { ...state, account: { + userId: null, email: "", phoneNumber: null, nickname: "", diff --git a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts index 60bb24a09..05c826f3a 100644 --- a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts +++ b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts @@ -4966,6 +4966,19 @@ describe("agent chat slice", () => { expect(state.currentChatId).toBe("chat-1"); }); + it("remembers a successful connection after the Agent bridge is disposed", () => { + let state = agentReducer(initialAgentState, { + type: "agent/wsEvent", + event: { event: "ready", chat_id: "chat-1", connection_generation: 1 } + }); + expect(state.hasConnectedSinceStartup).toBe(true); + + state = agentReducer(state, { type: "agent/connectionDisposed" }); + + expect(state.connectionGeneration).toBe(0); + expect(state.hasConnectedSinceStartup).toBe(true); + }); + it("does not treat attached as an application-ready connection", () => { const state = agentReducer({ ...initialAgentState, connectionStatus: "connecting" }, { type: "agent/wsEvent", diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 516575ac6..1b08d74d6 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -219,7 +219,7 @@ body.memmy-window-fullscreen { } .agent-conversation-composer textarea { - max-height: 400px; + max-height: 300px; } /* Home empty-state composer ONLY: an opaque strip under the bottom action @@ -336,6 +336,282 @@ body.memmy-window-fullscreen { background: transparent; } +.home-workspace-context { + position: relative; + display: inline-flex; + min-width: 0; + align-items: center; + gap: 8px; + color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); + font-family: var(--font-sans); + font-size: var(--codex-text-sm); + line-height: 1; +} + +.home-workspace-context__control { + position: relative; + min-width: 0; +} + +.home-workspace-context__trigger { + display: inline-flex; + min-width: 0; + height: 28px; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + background: transparent; + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + white-space: nowrap; + cursor: pointer; +} + +.home-workspace-context__trigger:hover, +.home-workspace-context__trigger--open { + color: var(--color-text-ink); +} + +.home-workspace-context__trigger:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-brand-primary) 55%, transparent); + outline-offset: 2px; +} + +.home-workspace-context__trigger:disabled { + cursor: default; + opacity: 0.56; +} + +.home-workspace-context__mode { + flex: 0 0 auto; + padding: 0 8px; + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-text-ink) 5%, transparent); +} + +.home-workspace-context__branch { + max-width: min(14rem, 30vw); + flex: 0 1 auto; +} + +.home-workspace-context__branch > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.home-workspace-context svg { + flex: 0 0 auto; +} + +.home-workspace-menu { + position: absolute; + top: calc(100% + 10px); + left: 0; + z-index: 100; + padding: 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--color-background-paper) 98%, var(--color-canvas-oat)); + box-shadow: 0 16px 42px rgb(17 29 28 / 0.16); + color: var(--color-text-ink); +} + +.home-workspace-menu--mode { + width: 230px; +} + +.home-workspace-menu--branch { + width: min(350px, calc(100vw - 32px)); +} + +.home-workspace-menu__heading { + margin: 2px 8px 7px; + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + font-size: 12px; + font-weight: 650; +} + +.home-workspace-menu__item { + display: grid; + width: 100%; + min-width: 0; + height: 42px; + grid-template-columns: 18px minmax(0, 1fr) 18px; + align-items: center; + gap: 9px; + padding: 0 10px; + border: 0; + border-radius: 10px; + background: transparent; + color: inherit; + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + text-align: left; + cursor: pointer; +} + +.home-workspace-menu__item:hover, +.home-workspace-menu__item[aria-selected="true"], +.home-workspace-menu__item[aria-checked="true"] { + background: color-mix(in srgb, var(--color-text-ink) 6%, transparent); +} + +.home-workspace-menu__item:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-brand-primary) 55%, transparent); + outline-offset: -2px; +} + +.home-workspace-menu__item:disabled { + color: color-mix(in srgb, var(--color-text-ink) 34%, transparent); + cursor: not-allowed; +} + +.home-workspace-menu__item > span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.home-workspace-menu__search { + display: flex; + height: 38px; + align-items: center; + gap: 8px; + margin-bottom: 10px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 62%, transparent); + border-radius: 10px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.home-workspace-menu__search:focus-within { + border-color: color-mix(in srgb, var(--color-brand-primary) 55%, var(--color-border-stone)); +} + +.home-workspace-menu__search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + color: var(--color-text-ink); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; +} + +.home-workspace-menu__branch-list { + max-height: calc(42px * var(--visible-branch-count, 5)); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.home-workspace-menu__branch-create-form { + display: grid; + gap: 8px; + margin-top: 8px; + padding: 10px 8px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); +} + +.home-workspace-menu__branch-create-form input { + width: 100%; + height: 36px; + padding: 0 9px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 62%, transparent); + border-radius: 8px; + background: transparent; + color: var(--color-text-ink); + font-family: inherit; + font-size: 12px; +} + +.home-workspace-menu__branch-create-form input:focus { + border-color: color-mix(in srgb, var(--color-brand-primary) 55%, var(--color-border-stone)); +} + +.home-workspace-menu__branch-create-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.home-workspace-menu__branch-create-actions button { + min-height: 28px; + padding: 0 9px; + border-radius: 7px; + color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); + font-size: 11px; +} + +.home-workspace-menu__branch-create-actions button:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-text-ink) 6%, transparent); +} + +.home-workspace-menu__branch-create-actions button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.home-workspace-menu__branch-footer { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); +} + +.home-workspace-menu__branch-create { + display: flex; + width: 100%; + height: 42px; + align-items: center; + gap: 9px; + padding: 0 10px; + border-radius: 10px; + color: var(--color-text-ink); + font-family: inherit; + font-size: inherit; + text-align: left; +} + +.home-workspace-menu__branch-create:hover, +.home-workspace-menu__branch-create[aria-expanded="true"] { + background: color-mix(in srgb, var(--color-text-ink) 6%, transparent); +} + +.home-workspace-menu__empty { + margin: 14px 10px; + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); + font-size: 12px; +} + +.home-workspace-menu__error { + margin: 8px 8px 2px; + color: var(--color-danger, #b84c43); + font-size: 11px; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + .home-workspace-context { + gap: 6px; + } + + .home-workspace-context__branch { + max-width: 28vw; + } +} + .composer-command-chip-slot { position: absolute; z-index: 10; @@ -348,17 +624,20 @@ body.memmy-window-fullscreen { left: 16px; } +.composer-command-chip-slot--conversation { + left: 10px; +} + .composer-command-chip { display: inline-flex; - height: 32px; + height: 30px; align-items: center; - gap: 6px; - padding: 0 7px 0 9px; - border: 0; - border-radius: 9px; - background: color-mix(in srgb, var(--color-canvas-oat) 48%, var(--color-background-paper)); + gap: 5px; + padding: 0 7px; + border-radius: 10px; + background: transparent; color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); - font-size: 12px; + font-size: 11px; font-weight: 500; line-height: 1; transition: background 140ms ease, color 140ms ease; @@ -366,7 +645,7 @@ body.memmy-window-fullscreen { .composer-command-chip:hover, .composer-command-chip:focus-within { - background: color-mix(in srgb, var(--color-canvas-oat) 78%, var(--color-background-paper)); + background: color-mix(in srgb, var(--color-canvas-oat) 62%, var(--color-background-paper)); color: var(--color-text-ink); } @@ -1305,2620 +1584,2920 @@ body:has(.memory-drawer) .window-drag-region { margin-left: 6px; } -.agent-conversation-scroll { - padding-top: 12px; - padding-bottom: calc(var(--agent-composer-overlay-height, 120px) + 8px); +.agent-conversation-topbar { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + gap: 10px; } -.agent-streaming-cursor { - display: inline-block; - width: 2px; - height: 1.1em; - margin-left: 1px; - vertical-align: text-bottom; - background: var(--color-action-sky); - border-radius: 1px; - animation: agent-cursor-blink 0.8s ease-in-out infinite; +.agent-environment-toggle, +.agent-environment-panel__actions button { + display: inline-flex; + width: 30px; + height: 30px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + transition: background-color 120ms ease, color 120ms ease, border-color 120ms ease; } -@keyframes agent-cursor-blink { - 0%, - 100% { - opacity: 1; - } - - 50% { - opacity: 0; - } +.agent-environment-toggle:hover, +.agent-environment-toggle--active, +.agent-environment-panel__actions button:hover { + border-color: color-mix(in srgb, var(--color-border-stone) 46%, transparent); + background: color-mix(in srgb, var(--color-text-ink) 6%, transparent); + color: var(--color-text-ink); } -.agent-message-content--streaming { - position: relative; +.agent-environment-toggle:focus-visible, +.agent-environment-panel button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 48%, transparent); + outline-offset: 2px; } -.agent-message-content--streaming > .agent-message-content__p:has(+ .agent-streaming-cursor) { - display: inline; - margin-bottom: 0; -} +.agent-workspace-layout { + --agent-environment-panel-width: 300px; + --agent-environment-panel-gutter: 24px; + --agent-conversation-shift: 112px; -.agent-thinking-pulse { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--color-action-sky); - animation: agent-thinking-pulse-anim 1.2s ease-in-out infinite; - vertical-align: middle; + position: relative; + display: flex; + width: 100%; + height: 100%; + min-width: 0; + overflow: hidden; + container-name: agent-workspace; + container-type: inline-size; } -@keyframes agent-thinking-pulse-anim { - 0%, - 100% { - opacity: 0.3; - transform: scale(0.8); - } - - 50% { - opacity: 1; - transform: scale(1.2); - } +.agent-workspace-layout > .agent-conversation-panel { + min-width: 0; + flex: 1 1 auto; } -.agent-thinking-placeholder { - padding: 2px 0; - color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +.agent-workspace-layout > .home-empty-screen { + min-width: 0; + flex: 1 1 auto; } -.agent-reasoning-panel { - overflow: hidden; +.agent-conversation-content { + transition: transform 160ms ease; } -.agent-reasoning-panel--live { - /* no live-only visual affordance in Cursor-style rendering */ -} +/* Shift only when the message column and environment panel can coexist. */ +@container agent-workspace (min-width: 1240px) { + .agent-workspace-layout--environment-open .agent-conversation-content { + transform: translateX(calc(0px - var(--agent-conversation-shift))); + } -.agent-reasoning-panel__toggle { - transition: color 120ms ease; + .agent-workspace-layout--environment-open .agent-scroll-to-bottom-fab { + right: calc(50% + var(--agent-conversation-shift)); + } } -.agent-reasoning-panel__toggle:hover { - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +.agent-environment-panel { + position: absolute; + top: 12px; + right: 12px; + z-index: 1000; + display: flex; + width: min(var(--agent-environment-panel-width), calc(100% - var(--agent-environment-panel-gutter))); + max-height: calc(100% - 24px); + min-width: 0; + flex-direction: column; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 46%, transparent); + border-radius: 20px; + background: color-mix(in srgb, var(--color-background-paper) 98%, var(--color-content-base)); + box-shadow: 0 18px 48px color-mix(in srgb, var(--color-text-ink) 16%, transparent); + color: var(--color-text-ink); + font-family: var(--font-sans); } -/* The thinking-chain rail container: holds offset but no border — the - border lives on the inner content element so it hugs the text exactly. */ -.agent-reasoning-panel__body, -.agent-activity-segment--thought .agent-activity-segment__body { - margin-left: 1px; - border: 0; +.agent-environment-panel__header { + display: flex; + min-height: 54px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px 9px 16px; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 34%, transparent); } -.agent-reasoning-panel__text { - font-family: inherit; - white-space: normal; +.agent-environment-panel__header h2 { margin: 0; + max-width: 260px; + overflow: hidden; + font-size: 15px; + font-weight: 650; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-activity-cluster--running .agent-activity-cluster__toggle { - color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); +.agent-environment-panel__actions { + display: flex; + gap: 2px; } -/* Micro-motion, mascot-free at icon scale: the running label breathes softly - and the waiting mascot bobs. Both stop for prefers-reduced-motion, and - neither moves layout — pure opacity/transform. */ -@media (prefers-reduced-motion: no-preference) { - .agent-activity-cluster--running .agent-activity-cluster__toggle > span { - animation: agent-working-breathe 2.4s ease-in-out infinite; - } - - .agent-thinking-placeholder__mascot { - animation: agent-mascot-bob 1.8s ease-in-out infinite; - } +.agent-environment-panel__actions button:disabled { + cursor: default; + opacity: 0.5; } -@keyframes agent-working-breathe { - 0%, - 100% { - opacity: 0.55; - } +.agent-environment-panel__body { + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; +} - 50% { - opacity: 1; - } +.agent-environment-panel__notice, +.agent-environment-panel__empty { + margin: 0; + padding: 14px 16px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 12px; + line-height: 18px; } -@keyframes agent-mascot-bob { - 0%, - 100% { - transform: translateY(0); - } +.agent-environment-panel__notice--error { + color: var(--color-status-error); +} - 50% { - transform: translateY(-2px); - } +.agent-environment-section { + padding: 10px 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); } -.agent-activity-cluster__toggle { - transition: color 120ms ease; +.agent-environment-row { + display: flex; + min-height: 42px; + align-items: center; + gap: 10px; + padding: 5px 16px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); } -.agent-activity-cluster__toggle:hover { - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +.agent-environment-row__content { + display: flex; + min-width: 0; + flex: 1 1 auto; + flex-direction: column; } -.agent-activity-cluster__body { - /* Expanded activity flows inline with the rest of the message, exactly - like Cursor's expanded "Worked for": a FLAT chronological list, flush - with the answer column — no rail, no indent, no inner scrollbox. Flush - alignment also means a draft paragraph occupies the exact same position - whether it is still streaming (outside the cluster) or already adopted - into the timeline, so nothing ever visibly moves. */ - margin: 4px 0 2px; +.agent-environment-row__content span { + font-size: 13px; + font-weight: 600; + line-height: 18px; } -.agent-activity-timeline-item { - min-width: 0; +.agent-environment-row__content small { + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + font-family: var(--font-mono); + font-size: 10px; + line-height: 15px; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-activity-timeline-item--thought { - margin: 0; - padding: 4px 0; - border-radius: 0; - background: transparent; +.agent-environment-row__meta { + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + font-family: var(--font-mono); + font-size: 10px; } -.agent-activity-timeline__body { - margin: 0; - color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +.agent-environment-section__toggle, +.agent-environment-section__heading { + display: flex; + width: 100%; + min-height: 34px; + align-items: center; + gap: 7px; + padding: 4px 16px; + border: 0; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); font-size: 13px; - font-weight: 400; - line-height: 1.5; - white-space: pre-wrap; - font-family: inherit; + font-weight: 650; + text-align: left; } -/* Chronological thought/tool-group segments inside one activity cluster body. - Cursor renders these as a continuous flowing sequence (no per-segment - folding) so the natural alternation reads like a transcript, not a stack of - disconnected toggles. */ -.agent-activity-segment + .agent-activity-segment { - margin-top: 6px; +.agent-environment-section__toggle:hover { + background: color-mix(in srgb, var(--color-text-ink) 4%, transparent); } -.agent-activity-tool-group-details { - display: block; +.agent-environment-section__count { + margin-right: auto; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 11px; + font-weight: 500; } -/* Mid-turn drafts keep their exact streamed look: answer typography, full - opacity, no movement. Paragraph-scale margins give the 14px prose room to - breathe between the 13px meta rows, and keep the adoption moment (streamed - text folding into the timeline) visually near-identical to the unit gap it - had while streaming. Long cumulative reports are the one exception — they - collapse in place behind a one-line preview row. */ -.agent-activity-segment--narration { - margin: 8px 0; +.agent-environment-section__lines { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 650; } -.agent-activity-segment + .agent-activity-segment--narration { - margin-top: 8px; +.agent-environment-section__lines--add { + color: var(--color-status-success); } -/* Conversation rhythm: process blocks sit tight (12px via the container's - space-y-3) so a turn reads as one continuous piece of work, while each new - user turn opens a clear chapter break. Unlayered rule intentionally beats - the utility's margin-top. */ -.agent-conversation-panel .agent-user-turn:not(:first-child) { - margin-top: 26px; +.agent-environment-section__lines--delete { + color: var(--color-status-error); } -/* The fold row keeps real text in both states (preview when closed, a meta - label when open) so a bare chevron never floats alone; chevron sits to the - right of the label, matching every other collapse affordance. */ -.agent-narration-block__toggle { - display: inline-flex; - max-width: 100%; - min-width: 0; +.agent-environment-file { + display: flex; + width: 100%; + min-height: 34px; align-items: center; - gap: 6px; - /* Same 2px vertical padding as tool rows so all timeline rows share one - baseline grid. */ - padding: 2px 0; + gap: 7px; + padding: 5px 16px 5px 38px; border: 0; background: transparent; - cursor: pointer; + color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); text-align: left; } -.agent-narration-block__preview, -.agent-narration-block__label { +.agent-environment-file:hover, +.agent-environment-file--selected { + background: color-mix(in srgb, var(--color-text-ink) 5%, transparent); + color: var(--color-text-ink); +} + +.agent-environment-file__path { min-width: 0; + flex: 1 1 auto; overflow: hidden; + font-family: var(--font-mono); + font-size: 10px; text-overflow: ellipsis; white-space: nowrap; - color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); - font-size: 13px; - line-height: 1.55; } -.agent-narration-block__label { - font-weight: 500; +.agent-environment-file__attribution { + flex: 0 0 auto; + padding: 1px 5px; + border-radius: 5px; + background: color-mix(in srgb, var(--color-text-ink) 5%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 9px; + line-height: 14px; } -.agent-narration-block__toggle:hover .agent-narration-block__preview, -.agent-narration-block__toggle:hover .agent-narration-block__label { - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +.agent-environment-file__attribution--goal { + background: color-mix(in srgb, var(--color-status-success) 10%, transparent); + color: color-mix(in srgb, var(--color-status-success) 76%, var(--color-text-ink)); } -.agent-narration-block__chevron { - flex-shrink: 0; - color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); +.agent-environment-file__attribution--uncertain { + background: color-mix(in srgb, var(--color-status-warning) 12%, transparent); + color: color-mix(in srgb, var(--color-status-warning) 72%, var(--color-text-ink)); } -/* Collapse affordances stay quiet until pointed at: every expand/collapse - chevron in the conversation is invisible by default and fades in when its - own row is hovered (or keyboard-focused). Purely visual — rows keep their - hit area, ARIA state, and toggle logic. */ -.agent-activity-cluster__toggle svg, -.agent-reasoning-panel__toggle svg, -.agent-activity-tool-group-details__chevron, -.agent-activity-tool-details__chevron, -.agent-narration-block__chevron { - opacity: 0; - transition: opacity 120ms ease; +.agent-environment-diff { + margin: 0 12px 8px 38px; + max-height: 320px; + overflow: auto; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); + border-radius: 7px; + background: var(--color-background-paper); } -.agent-activity-cluster__toggle:hover svg, -.agent-activity-cluster__toggle:focus-visible svg, -.agent-reasoning-panel__toggle:hover svg, -.agent-reasoning-panel__toggle:focus-visible svg, -.agent-activity-tool-group-details__summary:hover .agent-activity-tool-group-details__chevron, -.agent-activity-tool-group-details__summary:focus-visible .agent-activity-tool-group-details__chevron, -.agent-activity-tool-details__summary:hover .agent-activity-tool-details__chevron, -.agent-activity-tool-details__summary:focus-visible .agent-activity-tool-details__chevron, -.agent-narration-block__toggle:hover .agent-narration-block__chevron, -.agent-narration-block__toggle:focus-visible .agent-narration-block__chevron { - opacity: 1; +.workspace-diff-view { + min-width: max-content; + font-family: var(--font-mono); + font-size: 10px; + line-height: 18px; + tab-size: 2; } -.agent-activity-tool-group-details__summary { - display: inline-flex; - max-width: 100%; - min-width: 0; - align-items: center; - gap: 6px; - cursor: pointer; - list-style: none; +.workspace-diff-view__line { + display: grid; + min-height: 18px; + grid-template-columns: 42px minmax(max-content, 1fr); } -/* Inside a collapsible tool row the one-line summary hugs its text, so the - trailing chevron lands right after the label instead of the far edge. */ -.agent-activity-tool-details__summary .agent-activity-timeline-item__body { - flex: 0 1 auto; +.workspace-diff-view__line--addition { + background: color-mix(in srgb, var(--color-status-success) 11%, transparent); } -/* Group labels sit vertically centered next to their chevron inside the - summary row — the standalone 2px bottom margin would skew the baseline. */ -.agent-activity-tool-group-details__summary .agent-activity-segment__label { - margin: 0; +.workspace-diff-view__line--deletion { + background: color-mix(in srgb, var(--color-status-error) 12%, transparent); } -.agent-activity-tool-group-details__summary::-webkit-details-marker { - display: none; +.workspace-diff-view__line--notice { + background: color-mix(in srgb, var(--color-text-ink) 3%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } -.agent-activity-tool-group-details__chevron { - flex-shrink: 0; - color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); - transition: transform 120ms ease; +.workspace-diff-view__line-number { + position: sticky; + left: 0; + z-index: 1; + padding: 0 8px 0 4px; + border-right: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: color-mix(in srgb, var(--color-background-paper) 96%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-variant-numeric: tabular-nums; + text-align: right; + user-select: none; } -.agent-activity-tool-group-details[open] .agent-activity-tool-group-details__chevron { - transform: rotate(90deg); +.workspace-diff-view__line--addition .workspace-diff-view__line-number { + background: color-mix(in srgb, var(--color-status-success) 15%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-status-success) 80%, var(--color-text-ink)); } -/* Group items sit flush under their summary row — the fold hierarchy is - already conveyed by the header, so indentation would only waste width. */ -.agent-activity-tool-group-details__body { - margin: 2px 0 4px; - padding-left: 0; +.workspace-diff-view__line--deletion .workspace-diff-view__line-number { + background: color-mix(in srgb, var(--color-status-error) 16%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-status-error) 82%, var(--color-text-ink)); } -.agent-activity-segment__label { - display: inline-flex; - align-items: center; - gap: 6px; - margin: 0 0 2px; - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); - font-size: 13px; - font-weight: 500; - line-height: 1.55; +.workspace-diff-view__code { + display: block; + min-width: 0; + padding: 0 10px; + white-space: pre; } -.agent-activity-segment__label-icon { - flex-shrink: 0; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +.agent-environment-diff p, +.agent-environment-diff small { + display: block; + margin: 0; + padding: 9px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 10px; + line-height: 15px; } -.agent-activity-segment--thought .agent-activity-segment__label-icon { - color: color-mix(in srgb, var(--color-action-sky) 62%, transparent); +.agent-environment-goal dl { + margin: 2px 0 0; + padding: 0 16px; } -.agent-activity-segment--tool-group .agent-activity-segment__label-icon { - color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +.agent-environment-goal dl > div { + display: flex; + min-height: 28px; + align-items: baseline; + justify-content: space-between; + gap: 12px; + font-size: 11px; } -.agent-activity-segment__body { - margin: 0; +.agent-environment-goal dt { + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } -.agent-activity-segment__items > * + * { - margin-top: 0; +.agent-environment-goal dd { + margin: 0; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-family: var(--font-mono); + text-align: right; + text-overflow: ellipsis; + white-space: nowrap; } -/* - * Activity item variants — thought vs. tool vs. file edit. - * - * Cursor renders each row with its own icon and typography so a collapsed - * "Worked for" cluster still tells you at a glance what the model actually - * did. All rows share the same 13px muted baseline (to fit inside a compact - * activity trace), but the verb, icon, and category-specific accents let the - * user distinguish "read source" from "ran a shell command" from "edited a - * file" without having to expand any nested chrome. - */ -.agent-activity-timeline-item--tool, -.agent-activity-timeline-item--file-edit { - display: flex; - align-items: flex-start; - gap: 8px; - /* Rows sit on the same 13px/1.55 grid as surrounding activity prose — - no taller than a text line plus breathing room. */ - padding: 2px 0; +.agent-environment-panel__spin { + animation: agent-environment-spin 900ms linear infinite; } -.agent-activity-timeline-item__icon { - margin-top: 3px; - flex-shrink: 0; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); +@keyframes agent-environment-spin { + to { transform: rotate(360deg); } } -.agent-activity-timeline-item--shell .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); +@media (max-width: 959px) { + .agent-environment-panel { + width: min(400px, calc(100% - 24px)); + } } -.agent-activity-timeline-item--read .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--notebook .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-action-sky) 68%, transparent); +.agent-conversation-scroll { + padding-top: 12px; + padding-bottom: calc(var(--agent-composer-overlay-height, 120px) + 8px); } -.agent-activity-timeline-item--grep .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--search .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--glob .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--list .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); +.agent-streaming-cursor { + display: inline-block; + width: 2px; + height: 1.1em; + margin-left: 1px; + vertical-align: text-bottom; + background: var(--color-action-sky); + border-radius: 1px; + animation: agent-cursor-blink 0.8s ease-in-out infinite; } -.agent-activity-timeline-item--edit .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--write .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-status-success) 68%, var(--color-text-ink)); -} +@keyframes agent-cursor-blink { + 0%, + 100% { + opacity: 1; + } -.agent-activity-timeline-item--delete .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-status-error) 65%, var(--color-text-ink)); + 50% { + opacity: 0; + } } -.agent-activity-timeline-item--web .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--browser .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-action-sky) 70%, transparent); +.agent-message-content--streaming { + position: relative; } -.agent-activity-timeline-item--mcp .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--image .agent-activity-timeline-item__icon, -.agent-activity-timeline-item--task .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); +.agent-message-content--streaming > .agent-message-content__p:has(+ .agent-streaming-cursor) { + display: inline; + margin-bottom: 0; } -.agent-activity-timeline-item--error .agent-activity-timeline-item__icon { - color: color-mix(in srgb, var(--color-status-error) 82%, transparent); +.agent-thinking-pulse { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-action-sky); + animation: agent-thinking-pulse-anim 1.2s ease-in-out infinite; + vertical-align: middle; } -.agent-activity-timeline-item__body { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; -} +@keyframes agent-thinking-pulse-anim { + 0%, + 100% { + opacity: 0.3; + transform: scale(0.8); + } -.agent-activity-timeline-item__line { - display: flex; - /* Verb (13px sans) and detail (12.5px mono) share one text baseline — - stretch alignment let the mono span ride visibly higher. */ - align-items: baseline; - min-width: 0; - max-width: 100%; - margin: 0; - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - font-size: 13px; - font-weight: 400; - line-height: 1.55; - overflow: hidden; - white-space: nowrap; + 50% { + opacity: 1; + transform: scale(1.2); + } } -.agent-activity-timeline-item__verb { - flex: 0 0 auto; - color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); - font-weight: 500; +.agent-thinking-placeholder { + padding: 2px 0; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); } -.agent-activity-timeline-item__detail { - min-width: 0; - flex: 1 1 auto; - margin-left: 6px; +.agent-reasoning-panel { overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); - font-size: 12.5px; - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); } -.agent-activity-timeline-item--error .agent-activity-timeline-item__line, -.agent-activity-timeline-item--error .agent-activity-timeline-item__verb { - color: color-mix(in srgb, var(--color-status-error) 80%, var(--color-text-ink)); +.agent-reasoning-panel--live { + /* no live-only visual affordance in Cursor-style rendering */ } -.agent-activity-timeline-item__error { - margin: 2px 0 0; - color: color-mix(in srgb, var(--color-status-error) 80%, var(--color-text-ink)); - font-size: 12.5px; - line-height: 1.45; - overflow-wrap: anywhere; +.agent-reasoning-panel__toggle { + transition: color 120ms ease; } -.agent-activity-tool-details { - display: block; - padding: 0; -} - -.agent-activity-tool-details__summary { - display: flex; - min-width: 0; - max-width: 100%; - align-items: flex-start; - gap: 8px; - padding: 3px 0; - overflow: hidden; - cursor: pointer; - list-style: none; +.agent-reasoning-panel__toggle:hover { + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); } -.agent-activity-tool-details__summary::-webkit-details-marker { - display: none; +/* The thinking-chain rail container: holds offset but no border — the + border lives on the inner content element so it hugs the text exactly. */ +.agent-reasoning-panel__body, +.agent-activity-segment--thought .agent-activity-segment__body { + margin-left: 1px; + border: 0; } -.agent-activity-tool-details__chevron { - margin-top: 3px; - flex-shrink: 0; - color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); - transition: transform 120ms ease; +.agent-reasoning-panel__text { + font-family: inherit; + white-space: normal; + margin: 0; } -.agent-activity-tool-details[open] .agent-activity-tool-details__chevron { - transform: rotate(90deg); +.agent-activity-cluster--running .agent-activity-cluster__toggle { + color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); } -/* Expanded tool payload = ONE quiet code card (Cursor-style). The clickable - row above is already the human summary, so the card only carries raw - input/output: no ALL-CAPS form labels, sections separated by hairlines, - errors tinted, and long payloads scroll inside the card instead of - stretching the whole conversation. One generic shape fits every tool. */ -.agent-activity-tool-card { - margin: 4px 0 6px; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-background-paper) 62%, transparent); - overflow: hidden; -} +/* Micro-motion, mascot-free at icon scale: the running label breathes softly + and the waiting mascot bobs. Both stop for prefers-reduced-motion, and + neither moves layout — pure opacity/transform. */ +@media (prefers-reduced-motion: no-preference) { + .agent-activity-cluster--running .agent-activity-cluster__toggle > span { + animation: agent-working-breathe 2.4s ease-in-out infinite; + } -.agent-activity-tool-card__section { - padding: 7px 12px; + .agent-thinking-placeholder__mascot { + animation: agent-mascot-bob 1.8s ease-in-out infinite; + } } -.agent-activity-tool-card__section + .agent-activity-tool-card__section { - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); -} +@keyframes agent-working-breathe { + 0%, + 100% { + opacity: 0.55; + } -/* Input (arguments) reads dimmer than output so the two are tellable apart - without labels — the same quiet hierarchy a terminal has ($ cmd vs output). */ -.agent-activity-tool-card__section[data-detail="arguments"] .agent-activity-tool-card__value { - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + 50% { + opacity: 1; + } } -.agent-activity-tool-card__value { - max-width: 100%; - max-height: 260px; - margin: 0; - overflow: auto; - white-space: pre-wrap; - overflow-wrap: anywhere; - color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); - font-family: var(--font-mono); - font-size: 11.5px; - line-height: 1.55; -} +@keyframes agent-mascot-bob { + 0%, + 100% { + transform: translateY(0); + } -.agent-activity-tool-card__section--error .agent-activity-tool-card__value { - color: color-mix(in srgb, var(--color-status-error) 78%, var(--color-text-ink)); + 50% { + transform: translateY(-2px); + } } -/* Reasoning body uses the shared markdown renderer but stays in the compact - 13px muted style instead of the assistant answer's larger typography. */ -.agent-message-content.agent-message-content--reasoning { - font-size: 13px; - line-height: 1.55; - color: color-mix(in srgb, var(--color-text-ink) 54%, transparent); - font-weight: 400; +.agent-activity-cluster__toggle { + transition: color 120ms ease; } -.agent-message-content.agent-message-content--reasoning :where(h1, h2, h3, h4, h5, h6) { - margin: 7px 0 3px; - font-size: 13px; - font-weight: 500; - color: inherit; - letter-spacing: 0; +.agent-activity-cluster__toggle:hover { + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); } -.agent-message-content.agent-message-content--reasoning .agent-message-content__p { - margin: 6px 0; - color: inherit; - font-size: inherit; - font-weight: inherit; - line-height: inherit; +.agent-activity-cluster__body { + /* Expanded activity flows inline with the rest of the message, exactly + like Cursor's expanded "Worked for": a FLAT chronological list, flush + with the answer column — no rail, no indent, no inner scrollbox. Flush + alignment also means a draft paragraph occupies the exact same position + whether it is still streaming (outside the cluster) or already adopted + into the timeline, so nothing ever visibly moves. */ + margin: 4px 0 2px; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__list { - margin: 6px 0; - padding-left: 18px; - color: inherit; - font-size: inherit; - font-weight: inherit; - line-height: inherit; +.agent-activity-timeline-item { + min-width: 0; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__list li { - margin: 2px 0; +.agent-activity-timeline-item--thought { + margin: 0; + padding: 4px 0; + border-radius: 0; + background: transparent; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__list p { +.agent-activity-timeline__body { margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.5; + white-space: pre-wrap; + font-family: inherit; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__inline-code { - font-size: 12.5px; - color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); - background: color-mix(in srgb, var(--color-text-ink) 5%, transparent); +/* Chronological thought/tool-group segments inside one activity cluster body. + Cursor renders these as a continuous flowing sequence (no per-segment + folding) so the natural alternation reads like a transcript, not a stack of + disconnected toggles. */ +.agent-activity-segment + .agent-activity-segment { + margin-top: 6px; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__code-block { - margin: 6px 0; - background: color-mix(in srgb, var(--color-canvas-oat) 28%, transparent); +.agent-activity-tool-group-details { + display: block; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__quote { - margin: 4px 0; - padding-left: 8px; - color: inherit; - border-left-color: color-mix(in srgb, var(--color-border-stone) 42%, transparent); +/* Mid-turn drafts keep their exact streamed look: answer typography, full + opacity, no movement. Paragraph-scale margins give the 14px prose room to + breathe between the 13px meta rows, and keep the adoption moment (streamed + text folding into the timeline) visually near-identical to the unit gap it + had while streaming. Long cumulative reports are the one exception — they + collapse in place behind a one-line preview row. */ +.agent-activity-segment--narration { + margin: 8px 0; } -.agent-message-content.agent-message-content--reasoning :where(strong, b) { - color: inherit; - font-weight: 500; +.agent-activity-segment + .agent-activity-segment--narration { + margin-top: 8px; } -.agent-message-content.agent-message-content--reasoning :where(em, i) { - color: inherit; +/* Conversation rhythm: process blocks sit tight (12px via the container's + space-y-3) so a turn reads as one continuous piece of work, while each new + user turn opens a clear chapter break. Unlayered rule intentionally beats + the utility's margin-top. */ +.agent-conversation-panel .agent-user-turn:not(:first-child) { + margin-top: 26px; } -.agent-message-content.agent-message-content--reasoning .agent-message-content__link { - color: color-mix(in srgb, var(--color-action-sky) 58%, var(--color-text-ink)); +/* The fold row keeps real text in both states (preview when closed, a meta + label when open) so a bare chevron never floats alone; chevron sits to the + right of the label, matching every other collapse affordance. */ +.agent-narration-block__toggle { + display: inline-flex; + max-width: 100%; + min-width: 0; + align-items: center; + gap: 6px; + /* Same 2px vertical padding as tool rows so all timeline rows share one + baseline grid. */ + padding: 2px 0; + border: 0; + background: transparent; + cursor: pointer; + text-align: left; } -/* Thinking-chain rail: drawn as a pseudo-element on the inner content - element, inset 4px top/bottom. A plain border would span the full line - boxes, and 13px text in a 1.55 line box leaves ~3.6px of half-leading - above the first line and below the last — the inset makes the rail's ends - sit flush with the GLYPHS of the first and last lines, not the box. */ -.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning, -.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning { - position: relative; - padding-left: 12px; +.agent-narration-block__preview, +.agent-narration-block__label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); + font-size: 13px; + line-height: 1.55; } -.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning::before, -.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning::before { - content: ""; - position: absolute; - left: 0; - top: 4px; - bottom: 4px; - width: 1px; - border-radius: 0.5px; - background: color-mix(in srgb, var(--color-text-ink) 14%, transparent); +.agent-narration-block__label { + font-weight: 500; } -.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning > :first-child, -.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning > :first-child { - margin-top: 0; +.agent-narration-block__toggle:hover .agent-narration-block__preview, +.agent-narration-block__toggle:hover .agent-narration-block__label { + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); } -.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning > :last-child, -.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning > :last-child { - margin-bottom: 0; +.agent-narration-block__chevron { + flex-shrink: 0; + color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); } -.agent-scroll-to-bottom-fab { - position: absolute; - right: 50%; - bottom: calc(var(--agent-composer-overlay-height, 120px) + 8px); - z-index: 3; +/* Collapse affordances stay quiet until pointed at: every expand/collapse + chevron in the conversation is invisible by default and fades in when its + own row is hovered (or keyboard-focused). Purely visual — rows keep their + hit area, ARIA state, and toggle logic. */ +.agent-activity-cluster__toggle svg, +.agent-reasoning-panel__toggle svg, +.agent-activity-tool-group-details__chevron, +.agent-activity-tool-details__chevron, +.agent-narration-block__chevron { + opacity: 0; + transition: opacity 120ms ease; +} + +.agent-activity-cluster__toggle:hover svg, +.agent-activity-cluster__toggle:focus-visible svg, +.agent-reasoning-panel__toggle:hover svg, +.agent-reasoning-panel__toggle:focus-visible svg, +.agent-activity-tool-group-details__summary:hover .agent-activity-tool-group-details__chevron, +.agent-activity-tool-group-details__summary:focus-visible .agent-activity-tool-group-details__chevron, +.agent-activity-tool-details__summary:hover .agent-activity-tool-details__chevron, +.agent-activity-tool-details__summary:focus-visible .agent-activity-tool-details__chevron, +.agent-narration-block__toggle:hover .agent-narration-block__chevron, +.agent-narration-block__toggle:focus-visible .agent-narration-block__chevron { + opacity: 1; +} + +.agent-activity-tool-group-details__summary { display: inline-flex; - width: 36px; - height: 36px; + max-width: 100%; + min-width: 0; align-items: center; - justify-content: center; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); - border-radius: 9999px; - background: var(--color-background-paper); - color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); - box-shadow: 0 8px 20px rgba(17, 29, 28, 0.08); + gap: 6px; cursor: pointer; - transform: translateX(50%); - transition: - background 140ms ease, - color 140ms ease, - box-shadow 140ms ease; + list-style: none; } -.agent-scroll-to-bottom-fab:hover { - background: var(--color-canvas-oat); - color: var(--color-text-ink); +/* Inside a collapsible tool row the one-line summary hugs its text, so the + trailing chevron lands right after the label instead of the far edge. */ +.agent-activity-tool-details__summary .agent-activity-timeline-item__body { + flex: 0 1 auto; } -.agent-scroll-to-bottom-fab:focus-visible { - outline: none; - box-shadow: - 0 8px 20px rgba(17, 29, 28, 0.08), - 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +/* Group labels sit vertically centered next to their chevron inside the + summary row — the standalone 2px bottom margin would skew the baseline. */ +.agent-activity-tool-group-details__summary .agent-activity-segment__label { + margin: 0; } -.agent-conversation-composer { - position: absolute; - right: 0; - bottom: 0; - left: 0; - z-index: 2; - padding: 40px 24px 12px; - pointer-events: none; +.agent-activity-tool-group-details__summary::-webkit-details-marker { + display: none; } -.agent-conversation-composer::before { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient( - to bottom, - transparent 0, - var(--color-content-base) 40px, - var(--color-content-base) 100% - ); - pointer-events: none; +.agent-activity-tool-group-details__chevron { + flex-shrink: 0; + color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); + transition: transform 120ms ease; } -.agent-conversation-composer > * { - position: relative; - z-index: 1; - pointer-events: auto; +.agent-activity-tool-group-details[open] .agent-activity-tool-group-details__chevron { + transform: rotate(90deg); } -.agent-composer-flow, -.agent-composer-stack { - position: relative; +/* Group items sit flush under their summary row — the fold hierarchy is + already conveyed by the header, so indentation would only waste width. */ +.agent-activity-tool-group-details__body { + margin: 2px 0 4px; + padding-left: 0; } -.agent-composer-stack { - display: flex; - flex-direction: column; - gap: 8px; +.agent-activity-segment__label { + display: inline-flex; + align-items: center; + gap: 6px; + margin: 0 0 2px; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + font-size: 13px; + font-weight: 500; + line-height: 1.55; } -.agent-composer-popover { - max-height: min(432px, calc(100dvh - var(--agent-composer-overlay-height, 120px) + 16px)); - overflow-y: auto; - overscroll-behavior: contain; +.agent-activity-segment__label-icon { + flex-shrink: 0; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); } -.agent-queue-panel { - max-height: min(200px, 32dvh); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); - border-radius: var(--radius-composer); - background: var(--color-background-paper); - box-shadow: 0 8px 20px rgba(17, 29, 28, 0.04); +.agent-activity-segment--thought .agent-activity-segment__label-icon { + color: color-mix(in srgb, var(--color-action-sky) 62%, transparent); } -.agent-queue-list { - max-height: min(200px, 32dvh); - overflow-y: auto; - overscroll-behavior: contain; +.agent-activity-segment--tool-group .agent-activity-segment__label-icon { + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); } -.agent-queue-item { - display: flex; - min-height: 40px; - align-items: center; - gap: 8px; - padding: 0 10px; +.agent-activity-segment__body { + margin: 0; } -.agent-queue-item + .agent-queue-item { - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); +.agent-activity-segment__items > * + * { + margin-top: 0; } -.agent-queue-item__icon { - flex: 0 0 auto; - color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +/* + * Activity item variants — thought vs. tool vs. file edit. + * + * Cursor renders each row with its own icon and typography so a collapsed + * "Worked for" cluster still tells you at a glance what the model actually + * did. All rows share the same 13px muted baseline (to fit inside a compact + * activity trace), but the verb, icon, and category-specific accents let the + * user distinguish "read source" from "ran a shell command" from "edited a + * file" without having to expand any nested chrome. + */ +.agent-activity-timeline-item--tool, +.agent-activity-timeline-item--file-edit { + display: flex; + align-items: flex-start; + gap: 8px; + /* Rows sit on the same 13px/1.55 grid as surrounding activity prose — + no taller than a text line plus breathing room. */ + padding: 2px 0; } -.agent-queue-item__source { - display: inline-flex; - width: 18px; - height: 18px; - flex: 0 0 18px; - align-items: center; - justify-content: center; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); +.agent-activity-timeline-item__icon { + margin-top: 3px; + flex-shrink: 0; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); } -.agent-queue-item__source-mark { - display: block; - width: 16px; - height: 16px; - max-width: 16px; - max-height: 16px; - flex: 0 0 16px; - object-fit: contain; +.agent-activity-timeline-item--shell .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); } -.agent-queue-item__source-image { - border-radius: 3px; +.agent-activity-timeline-item--read .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--notebook .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-action-sky) 68%, transparent); } -.agent-queue-item__text { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - font-size: 13px; - line-height: 1.4; - text-overflow: ellipsis; - white-space: nowrap; +.agent-activity-timeline-item--grep .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--search .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--glob .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--list .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-action-sky) 55%, transparent); } -.agent-queue-item__remove, -.agent-queue-item__steer { - display: inline-flex; - height: 28px; - align-items: center; - justify-content: center; - border-radius: 8px; - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); - cursor: pointer; - transition: background 140ms ease, color 140ms ease, opacity 140ms ease; +.agent-activity-timeline-item--edit .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--write .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-status-success) 68%, var(--color-text-ink)); } -.agent-queue-item__remove { - width: 28px; - flex: 0 0 28px; +.agent-activity-timeline-item--delete .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-status-error) 65%, var(--color-text-ink)); } -.agent-queue-item__steer { - flex: 0 0 auto; - gap: 4px; - padding: 0 8px; - color: var(--color-action-sky); - font-size: 12px; - font-weight: 500; +.agent-activity-timeline-item--web .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--browser .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-action-sky) 70%, transparent); } -.agent-queue-item__steer:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); +.agent-activity-timeline-item--mcp .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--image .agent-activity-timeline-item__icon, +.agent-activity-timeline-item--task .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); } -.agent-queue-item__remove:hover:not(:disabled) { - background: var(--color-canvas-oat); - color: var(--color-status-error); +.agent-activity-timeline-item--error .agent-activity-timeline-item__icon { + color: color-mix(in srgb, var(--color-status-error) 82%, transparent); } -.agent-queue-item__remove:focus-visible, -.agent-queue-item__steer:focus-visible { - outline: none; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +.agent-activity-timeline-item__body { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; } -.agent-queue-item__remove:disabled, -.agent-queue-item__steer:disabled { - cursor: not-allowed; - opacity: 0.38; -} - -.agent-chat-bubble-frame { +.agent-activity-timeline-item__line { display: flex; - flex-direction: column; - gap: 4px; + /* Verb (13px sans) and detail (12.5px mono) share one text baseline — + stretch alignment let the mono span ride visibly higher. */ + align-items: baseline; + min-width: 0; + max-width: 100%; + margin: 0; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.55; + overflow: hidden; + white-space: nowrap; } -.agent-chat-bubble-frame--assistant { - align-items: flex-start; +.agent-activity-timeline-item__verb { + flex: 0 0 auto; + color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); + font-weight: 500; } -.agent-chat-bubble-frame--user { - align-items: flex-end; +.agent-activity-timeline-item__detail { + min-width: 0; + flex: 1 1 auto; + margin-left: 6px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); + font-size: 12.5px; + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); } -.agent-message-copy-cluster { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 24px; - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease; +.agent-activity-timeline-item--error .agent-activity-timeline-item__line, +.agent-activity-timeline-item--error .agent-activity-timeline-item__verb { + color: color-mix(in srgb, var(--color-status-error) 80%, var(--color-text-ink)); } -.agent-queue-item + .agent-queue-item { - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); +.agent-activity-timeline-item__error { + margin: 2px 0 0; + color: color-mix(in srgb, var(--color-status-error) 80%, var(--color-text-ink)); + font-size: 12.5px; + line-height: 1.45; + overflow-wrap: anywhere; } -.agent-queue-item__icon { - flex: 0 0 auto; - color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +.agent-activity-tool-details { + display: block; + padding: 0; } -.agent-queue-item__source { - display: inline-flex; - width: 18px; - height: 18px; - flex: 0 0 18px; - align-items: center; - justify-content: center; +.agent-activity-tool-details__summary { + display: flex; + min-width: 0; + max-width: 100%; + align-items: flex-start; + gap: 8px; + padding: 3px 0; overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + cursor: pointer; + list-style: none; } -.agent-queue-item__source-mark { - display: block; - width: 16px; - height: 16px; - max-width: 16px; - max-height: 16px; - flex: 0 0 16px; - object-fit: contain; +.agent-activity-tool-details__summary::-webkit-details-marker { + display: none; } -.agent-queue-item__source-image { - border-radius: 3px; +.agent-activity-tool-details__chevron { + margin-top: 3px; + flex-shrink: 0; + color: color-mix(in srgb, var(--color-text-ink) 36%, transparent); + transition: transform 120ms ease; } -.agent-queue-item__text { - min-width: 0; - flex: 1 1 auto; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - font-size: 13px; - line-height: 1.4; - text-overflow: ellipsis; - white-space: nowrap; +.agent-activity-tool-details[open] .agent-activity-tool-details__chevron { + transform: rotate(90deg); } -.agent-queue-item__remove, -.agent-queue-item__steer { - display: inline-flex; - height: 28px; - align-items: center; - justify-content: center; +/* Expanded tool payload = ONE quiet code card (Cursor-style). The clickable + row above is already the human summary, so the card only carries raw + input/output: no ALL-CAPS form labels, sections separated by hairlines, + errors tinted, and long payloads scroll inside the card instead of + stretching the whole conversation. One generic shape fits every tool. */ +.agent-activity-tool-card { + margin: 4px 0 6px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); border-radius: 8px; - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); - cursor: pointer; - transition: background 140ms ease, color 140ms ease, opacity 140ms ease; + background: color-mix(in srgb, var(--color-background-paper) 62%, transparent); + overflow: hidden; } -.agent-queue-item__remove { - width: 28px; - flex: 0 0 28px; +.agent-activity-tool-card__section { + padding: 7px 12px; } -.agent-queue-item__steer { - flex: 0 0 auto; - gap: 4px; - padding: 0 8px; - color: var(--color-action-sky); - font-size: 12px; - font-weight: 500; +.agent-activity-tool-card__section + .agent-activity-tool-card__section { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 32%, transparent); } -.agent-queue-item__steer:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); +/* Input (arguments) reads dimmer than output so the two are tellable apart + without labels — the same quiet hierarchy a terminal has ($ cmd vs output). */ +.agent-activity-tool-card__section[data-detail="arguments"] .agent-activity-tool-card__value { + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } -.agent-queue-item__remove:hover:not(:disabled) { - background: var(--color-canvas-oat); - color: var(--color-status-error); +.agent-activity-tool-card__value { + max-width: 100%; + max-height: 260px; + margin: 0; + overflow: auto; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); + font-family: var(--font-mono); + font-size: 11.5px; + line-height: 1.55; } -.agent-queue-item__remove:focus-visible, -.agent-queue-item__steer:focus-visible { - outline: none; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +.agent-activity-tool-card__section--error .agent-activity-tool-card__value { + color: color-mix(in srgb, var(--color-status-error) 78%, var(--color-text-ink)); } -.agent-queue-item__remove:disabled, -.agent-queue-item__steer:disabled { - cursor: not-allowed; - opacity: 0.38; +/* Reasoning body uses the shared markdown renderer but stays in the compact + 13px muted style instead of the assistant answer's larger typography. */ +.agent-message-content.agent-message-content--reasoning { + font-size: 13px; + line-height: 1.55; + color: color-mix(in srgb, var(--color-text-ink) 54%, transparent); + font-weight: 400; } -.agent-chat-bubble-frame { - display: flex; - flex-direction: column; - gap: 4px; +.agent-message-content.agent-message-content--reasoning :where(h1, h2, h3, h4, h5, h6) { + margin: 7px 0 3px; + font-size: 13px; + font-weight: 500; + color: inherit; + letter-spacing: 0; } -.agent-chat-bubble-frame--assistant { - align-items: flex-start; +.agent-message-content.agent-message-content--reasoning .agent-message-content__p { + margin: 6px 0; + color: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; } -.agent-chat-bubble-frame--user { - align-items: flex-end; +.agent-message-content.agent-message-content--reasoning .agent-message-content__list { + margin: 6px 0; + padding-left: 18px; + color: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; } -.agent-message-copy-cluster { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 24px; - opacity: 0; - pointer-events: none; - transition: opacity 120ms ease; +.agent-message-content.agent-message-content--reasoning .agent-message-content__list li { + margin: 2px 0; } -.agent-message-copy-button { - display: inline-flex; - width: 24px; - height: 24px; - align-items: center; - justify-content: center; - border: 0; - border-radius: 6px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); - cursor: pointer; - transition: - background 140ms ease, - color 140ms ease; +.agent-message-content.agent-message-content--reasoning .agent-message-content__list p { + margin: 0; } -.agent-chat-bubble-frame:hover .agent-message-copy-cluster, -.agent-chat-bubble-frame:focus-within .agent-message-copy-cluster { - opacity: 1; - pointer-events: auto; +.agent-message-content.agent-message-content--reasoning .agent-message-content__inline-code { + font-size: 12.5px; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); + background: color-mix(in srgb, var(--color-text-ink) 5%, transparent); } -.agent-message-time-label { - min-width: max-content; - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); - font-size: 12px; - font-weight: 400; - line-height: 24px; - white-space: nowrap; +.agent-message-content.agent-message-content--reasoning .agent-message-content__code-block { + margin: 6px 0; + background: color-mix(in srgb, var(--color-canvas-oat) 28%, transparent); } -.agent-message-copy-button:hover { - background: color-mix(in srgb, var(--color-text-ink) 8%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); +.agent-message-content.agent-message-content--reasoning .agent-message-content__quote { + margin: 4px 0; + padding-left: 8px; + color: inherit; + border-left-color: color-mix(in srgb, var(--color-border-stone) 42%, transparent); } -.agent-message-copy-button:focus-visible { - outline: none; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); +.agent-message-content.agent-message-content--reasoning :where(strong, b) { + color: inherit; + font-weight: 500; } -.agent-context-compaction-divider { - display: flex; - width: 100%; - min-width: 0; - align-items: center; - justify-content: center; - gap: 10px; - margin: 4px 0; - padding: 4px 0; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); -} - -.agent-context-compaction-divider__line { - height: 1px; - min-width: 24px; - flex: 1 1 0; - background: var( - --agent-conversation-muted-separator, - color-mix(in srgb, var(--color-border-stone) 44%, transparent) - ); +.agent-message-content.agent-message-content--reasoning :where(em, i) { + color: inherit; } -.agent-conversation-panel .agent-activity-cluster__separator { - height: 1px; - margin-top: 8px; - background: var(--agent-conversation-muted-separator); +.agent-message-content.agent-message-content--reasoning .agent-message-content__link { + color: color-mix(in srgb, var(--color-action-sky) 58%, var(--color-text-ink)); } -.agent-context-compaction-divider__label { +/* Thinking-chain rail: drawn as a pseudo-element on the inner content + element, inset 4px top/bottom. A plain border would span the full line + boxes, and 13px text in a 1.55 line box leaves ~3.6px of half-leading + above the first line and below the last — the inset makes the rail's ends + sit flush with the GLYPHS of the first and last lines, not the box. */ +.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning, +.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning { position: relative; - display: inline-block; - max-width: min(70%, 24rem); - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - font-size: 12px; - font-weight: 700; - line-height: 18px; - text-align: center; - text-overflow: ellipsis; - white-space: nowrap; + padding-left: 12px; } -.agent-context-compaction-divider--running .agent-context-compaction-divider__label { - animation: agent-context-compaction-shimmer 1.7s ease-in-out infinite; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--color-text-ink) 48%, transparent) 0%, - color-mix(in srgb, white 88%, var(--color-text-ink) 12%) 45%, - color-mix(in srgb, var(--color-text-ink) 48%, transparent) 100% - ); - background-size: 220% 100%; - background-clip: text; - color: transparent; - text-shadow: 0 0 10px color-mix(in srgb, white 36%, transparent); - -webkit-background-clip: text; +.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning::before, +.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning::before { + content: ""; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 1px; + border-radius: 0.5px; + background: color-mix(in srgb, var(--color-text-ink) 14%, transparent); } -.agent-context-compaction-divider--done .agent-context-compaction-divider__label, -.agent-context-compaction-divider--error .agent-context-compaction-divider__label { - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning > :first-child, +.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning > :first-child { + margin-top: 0; } -.agent-retry-wait-line { - display: flex; - width: 100%; - min-width: 0; - justify-content: flex-start; - margin: 0 0 4px; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +.agent-reasoning-panel__body > .agent-message-content.agent-message-content--reasoning > :last-child, +.agent-activity-segment--thought .agent-activity-segment__body > .agent-message-content.agent-message-content--reasoning > :last-child { + margin-bottom: 0; } -.agent-retry-wait-line__label { - display: inline-block; - max-width: min(100%, 32rem); - font-size: 12px; - font-weight: 600; - line-height: 18px; +.agent-scroll-to-bottom-fab { + position: absolute; + right: 50%; + bottom: calc(var(--agent-composer-overlay-height, 120px) + 8px); + z-index: 3; + display: inline-flex; + width: 36px; + height: 36px; + align-items: center; + justify-content: center; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: 9999px; + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.08); + cursor: pointer; + transform: translateX(50%); + transition: + right 160ms ease, + background 140ms ease, + color 140ms ease, + box-shadow 140ms ease; } -.agent-model-error-notice { - max-width: min(100%, 32rem); - padding: 20px; - border: 1px solid color-mix(in srgb, var(--color-status-error) 22%, transparent); - border-radius: 14px; - background: color-mix(in srgb, var(--color-status-error-soft) 55%, var(--color-background-paper)); - box-shadow: 0 8px 24px color-mix(in srgb, var(--color-status-error) 5%, transparent); +@media (prefers-reduced-motion: reduce) { + .agent-conversation-content, + .agent-scroll-to-bottom-fab { + transition: none; + } } -.agent-model-error-notice__header { - display: flex; - align-items: flex-start; - gap: 12px; +.agent-scroll-to-bottom-fab:hover { + background: var(--color-canvas-oat); + color: var(--color-text-ink); } -.agent-model-error-notice__icon { - margin-top: 3px; - flex-shrink: 0; - color: var(--color-status-error); +.agent-scroll-to-bottom-fab:focus-visible { + outline: none; + box-shadow: + 0 8px 20px rgba(17, 29, 28, 0.08), + 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); } -.agent-model-error-notice__title { - margin: 0; - font-size: 14px; - line-height: 22px; - font-weight: 500; - color: color-mix(in srgb, var(--color-status-error) 88%, var(--color-text-ink)); +.agent-conversation-composer { + position: absolute; + right: 0; + bottom: 0; + left: 0; + z-index: 2; + padding: 40px 24px 12px; + pointer-events: none; } -.agent-model-error-notice__detail { - margin: 0; - overflow-wrap: anywhere; - white-space: pre-wrap; - font-family: var(--font-mono); - font-size: 12px; - line-height: 1.6; - color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); +.agent-conversation-composer::before { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + to bottom, + transparent 0, + var(--color-content-base) 40px, + var(--color-content-base) 100% + ); + pointer-events: none; } -.error-notice-detail__shell { - margin-left: 30px; - overflow: hidden; - contain: paint; - will-change: height; +.agent-conversation-composer > * { + position: relative; + z-index: 1; + pointer-events: auto; } -.error-notice-detail__gap { - padding-top: 16px; +.agent-composer-flow, +.agent-composer-stack { + position: relative; } -.error-notice-detail__panel { - padding: 12px 14px; - border: 1px solid color-mix(in srgb, var(--color-status-error) 14%, transparent); - border-radius: 10px; - background: color-mix(in srgb, var(--color-status-error-soft) 28%, var(--color-background-paper)); - will-change: opacity; +.agent-composer-stack { + display: flex; + flex-direction: column; + gap: 8px; } -.error-notice-detail__toggle { - display: inline-flex; - align-items: center; - gap: 4px; - margin: 12px 0 0 30px; - padding: 0; - border: 0; - background: transparent; - color: color-mix(in srgb, var(--color-status-error) 72%, var(--color-text-ink)); - font-size: 12px; - line-height: 18px; - font-weight: 500; - cursor: pointer; +.agent-composer-popover { + max-height: min(432px, calc(100dvh - var(--agent-composer-overlay-height, 120px) + 16px)); + overflow-y: auto; + overscroll-behavior: contain; } -.error-notice-detail__toggle:hover { - color: var(--color-status-error); +.agent-queue-panel { + max-height: min(200px, 32dvh); + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 36%, transparent); + border-radius: var(--radius-composer); + background: var(--color-background-paper); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.04); } -.error-notice-detail__toggle:focus-visible { - outline: 3px solid color-mix(in srgb, var(--color-action-sky) 28%, transparent); - outline-offset: 2px; +.agent-queue-list { + max-height: min(200px, 32dvh); + overflow-y: auto; + overscroll-behavior: contain; } -.error-notice-detail__chevron { - transform: rotate(180deg); - transition: transform 220ms cubic-bezier(0, 0, 0.2, 1); +.agent-queue-item { + display: flex; + min-height: 40px; + align-items: center; + gap: 8px; + padding: 0 10px; } -.error-notice-detail__toggle[aria-expanded="false"] .error-notice-detail__chevron { - transform: rotate(0deg); - transition-timing-function: cubic-bezier(0.4, 0, 1, 1); +.agent-queue-item + .agent-queue-item { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); } -@media (prefers-reduced-motion: reduce) { - .error-notice-detail__shell, - .error-notice-detail__panel { - will-change: auto; - } - - .error-notice-detail__chevron { - transition: none; - } +.agent-queue-item__icon { + flex: 0 0 auto; + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); } -.agent-retry-wait-line--running .agent-retry-wait-line__label { - animation: agent-context-compaction-shimmer 1.7s ease-in-out infinite; - background: linear-gradient( - 90deg, - color-mix(in srgb, var(--color-text-ink) 48%, transparent) 0%, - color-mix(in srgb, white 88%, var(--color-text-ink) 12%) 45%, - color-mix(in srgb, var(--color-text-ink) 48%, transparent) 100% - ); - background-size: 220% 100%; - background-clip: text; - color: transparent; - text-shadow: 0 0 10px color-mix(in srgb, white 36%, transparent); - -webkit-background-clip: text; -} - -.agent-message-content { - font-family: var(--font-chat); - font-feature-settings: "ss01", "cv11"; - min-width: 0; - max-width: 100%; +.agent-queue-item__source { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; + align-items: center; + justify-content: center; overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); - font-size: 14px; - line-height: 1.65; - overflow-wrap: anywhere; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); } -.agent-message-content > :first-child { - margin-top: 0; +.agent-queue-item__source-mark { + display: block; + width: 16px; + height: 16px; + max-width: 16px; + max-height: 16px; + flex: 0 0 16px; + object-fit: contain; } -.agent-message-content > :last-child { - margin-bottom: 0; +.agent-queue-item__source-image { + border-radius: 3px; } -/* Headings in a chat answer are section markers inside a reply, not document - titles: keep them close to body size (Cursor caps around 16–18px) so long - generated reports read as one calm column instead of shouting banners. */ -.agent-message-content :where(h1, h2, h3, h4, h5, h6) { - margin: 18px 0 8px; - color: var(--color-text-ink); - font-weight: 600; +.agent-queue-item__text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 13px; line-height: 1.4; - letter-spacing: -0.005em; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-message-content h1 { - font-size: 18px; +.agent-queue-item__remove { + display: inline-flex; + width: 28px; + height: 28px; + flex: 0 0 28px; + align-items: center; + justify-content: center; + border-radius: 8px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + cursor: pointer; + transition: background 140ms ease, color 140ms ease, opacity 140ms ease; } -.agent-message-content h2 { - font-size: 16px; +.agent-queue-item__remove:hover:not(:disabled) { + background: var(--color-canvas-oat); + color: var(--color-status-error); } -.agent-message-content :where(h3, h4, h5, h6) { - font-size: 14.5px; +.agent-queue-item__remove:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); } -/* ---- Vertical rhythm (single source of truth) ---- - Every markdown block uses one spacing scale: text blocks 10px apart, - framed blocks (tables/code) 12px, list items 4px, paragraphs inside list - items flush. Line-height always inherits from the content root (1.65 for - answers, 1.55 for the reasoning variant) so lists can never fall out of - step with surrounding paragraphs. */ -.agent-message-content__p { - margin: 10px 0; +.agent-queue-item__remove:disabled { + cursor: not-allowed; + opacity: 0.38; } -.agent-message-content__list { - margin: 10px 0; - padding-left: 22px; +.agent-chat-bubble-frame { + display: flex; + flex-direction: column; + gap: 4px; } -.agent-message-content__list--unordered { - list-style: disc; +.agent-chat-bubble-frame--assistant { + align-items: flex-start; } -.agent-message-content__list--ordered { - list-style: decimal; +.agent-chat-bubble-frame--user { + align-items: flex-end; } -.agent-message-content__list li { - margin: 4px 0; +.agent-message-copy-cluster { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 24px; + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; } -.agent-message-content__list .agent-message-content__list { - margin: 4px 0; +.agent-message-copy-button { + display: inline-flex; + width: 24px; + height: 24px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 6px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); + cursor: pointer; + transition: + background 140ms ease, + color 140ms ease; } -.agent-message-content__list p { - margin: 2px 0; +.agent-chat-bubble-frame:hover .agent-message-copy-cluster, +.agent-chat-bubble-frame:focus-within .agent-message-copy-cluster, +.agent-chat-bubble-frame:hover .agent-memory-evidence-toggle, +.agent-memory-evidence-toggle:focus-visible { + opacity: 1; + pointer-events: auto; } -.agent-message-content__quote { - margin: 10px 0; - padding: 2px 0 2px 12px; - border-left: 3px solid color-mix(in srgb, var(--color-action-sky) 34%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); +.agent-memory-evidence-toggle { + opacity: 0; + pointer-events: none; + transition: opacity 120ms ease; } -.agent-conversation-panel .agent-message-content__separator { - height: 1px; - margin: 14px 0; - border: 0; - background: var(--agent-conversation-muted-separator); +.agent-memory-evidence-delete { + transition: + background 140ms ease, + color 140ms ease; } -.agent-message-content__link { - color: var(--color-action-sky); - text-decoration-line: underline; - text-underline-offset: 2px; - overflow-wrap: anywhere; +.agent-memory-evidence-delete:hover:not(:disabled) { + background: rgb(220 38 38 / 8%); + color: #dc2626; } -.agent-message-content__inline-code { - border-radius: 4px; +.agent-memory-evidence-undo { + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); + transition: + background 140ms ease, + color 140ms ease; +} + +.agent-memory-evidence-undo:hover { background: color-mix(in srgb, var(--color-text-ink) 7%, transparent); - padding: 1px 4px; - font-family: var(--font-mono); - font-size: 0.92em; - white-space: pre-wrap; - word-break: break-word; - overflow-wrap: anywhere; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); } -.agent-message-content--deferred { - border-radius: 10px; - background: - linear-gradient(90deg, color-mix(in srgb, var(--color-canvas-oat) 60%, transparent) 0 38%, transparent 38% 100%), - linear-gradient(color-mix(in srgb, var(--color-canvas-oat) 42%, transparent), color-mix(in srgb, var(--color-canvas-oat) 42%, transparent)); - background-size: 100% 12px, 100% 100%; - background-repeat: repeat-y; - opacity: 0.7; +.agent-message-time-label { + min-width: max-content; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-size: 12px; + font-weight: 400; + line-height: 24px; + white-space: nowrap; } -.agent-message-content__image, -.agent-message-content__preview-image { - max-width: 100%; - max-height: 20rem; - object-fit: contain; +.agent-message-copy-button:hover { + background: color-mix(in srgb, var(--color-text-ink) 8%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); } -.agent-message-content__table-scroll { - max-width: 100%; - margin: 12px 0; - overflow-x: auto; +.agent-message-copy-button:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 32%, transparent); } -.agent-message-content__table { - min-width: 100%; - border-collapse: collapse; - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); - font-size: 13px; +.agent-context-compaction-divider { + display: flex; + width: 100%; + min-width: 0; + align-items: center; + justify-content: center; + gap: 10px; + margin: 4px 0; + padding: 4px 0; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } -.agent-message-content__th, -.agent-message-content__td { - border: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); - padding: 4px 8px; - vertical-align: top; - overflow-wrap: anywhere; +.agent-context-compaction-divider__line { + height: 1px; + min-width: 24px; + flex: 1 1 0; + background: var( + --agent-conversation-muted-separator, + color-mix(in srgb, var(--color-border-stone) 44%, transparent) + ); } -.agent-message-content__th { - background: var(--color-canvas-oat); - color: var(--color-text-ink); - font-weight: 700; - text-align: left; +.agent-conversation-panel .agent-activity-cluster__separator { + height: 1px; + margin-top: 8px; + background: var(--agent-conversation-muted-separator); } -.agent-message-content__code-block { - max-width: 100%; - margin: 12px 0; +.agent-context-compaction-divider__label { + position: relative; + display: inline-block; + max-width: min(70%, 24rem); overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); - border-radius: var(--radius-card); - background: color-mix(in srgb, var(--color-canvas-oat) 50%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 12px; + font-weight: 700; + line-height: 18px; + text-align: center; + text-overflow: ellipsis; + white-space: nowrap; } -.agent-message-content__code-header { - border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 25%, transparent); - padding: 6px 12px; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); - font-size: 11px; - font-weight: 700; +.agent-context-compaction-divider--running .agent-context-compaction-divider__label { + animation: agent-context-compaction-shimmer 1.7s ease-in-out infinite; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-text-ink) 48%, transparent) 0%, + color-mix(in srgb, white 88%, var(--color-text-ink) 12%) 45%, + color-mix(in srgb, var(--color-text-ink) 48%, transparent) 100% + ); + background-size: 220% 100%; + background-clip: text; + color: transparent; + text-shadow: 0 0 10px color-mix(in srgb, white 36%, transparent); + -webkit-background-clip: text; } -.agent-message-content__code-scroll, -.agent-message-content__pre { - max-width: 100%; - overflow-x: auto; +.agent-context-compaction-divider--done .agent-context-compaction-divider__label, +.agent-context-compaction-divider--error .agent-context-compaction-divider__label { + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); } -.agent-message-content__table-scroll, -.agent-message-content__code-scroll, -.agent-message-content__pre { - scrollbar-width: thin; - scrollbar-color: var(--codex-scrollbar-thumb) transparent; +.agent-retry-wait-line { + display: flex; + width: 100%; + min-width: 0; + justify-content: flex-start; + margin: 0 0 4px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); } -.agent-message-content__table-scroll::-webkit-scrollbar, -.agent-message-content__code-scroll::-webkit-scrollbar, -.agent-message-content__pre::-webkit-scrollbar { - display: block; - height: var(--codex-scrollbar-size); +.agent-retry-wait-line__label { + display: inline-block; + max-width: min(100%, 32rem); + font-size: 12px; + font-weight: 600; + line-height: 18px; } -.agent-message-content__table-scroll::-webkit-scrollbar-thumb, -.agent-message-content__code-scroll::-webkit-scrollbar-thumb, -.agent-message-content__pre::-webkit-scrollbar-thumb { - background: var(--codex-scrollbar-thumb); +.agent-model-error-notice { + max-width: min(100%, 32rem); + padding: 20px; + border: 1px solid color-mix(in srgb, var(--color-status-error) 22%, transparent); + border-radius: 14px; + background: color-mix(in srgb, var(--color-status-error-soft) 55%, var(--color-background-paper)); + box-shadow: 0 8px 24px color-mix(in srgb, var(--color-status-error) 5%, transparent); } -.agent-message-content__pre { +.agent-model-error-notice__header { + display: flex; + align-items: flex-start; + gap: 12px; +} + +.agent-model-error-notice__icon { + margin-top: 3px; + flex-shrink: 0; + color: var(--color-status-error); +} + +.agent-model-error-notice__title { margin: 0; - padding: 8px 12px; - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); + font-size: 14px; + line-height: 22px; + font-weight: 500; + color: color-mix(in srgb, var(--color-status-error) 88%, var(--color-text-ink)); +} + +.agent-model-error-notice__detail { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; font-family: var(--font-mono); font-size: 12px; - line-height: 1.625; + line-height: 1.6; + color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); } -.agent-message-content__pre code { - font-size: inherit; +.error-notice-detail__shell { + margin-left: 30px; + overflow: hidden; + contain: paint; + will-change: height; } -.agent-conversation-panel .agent-message-content__table-scroll { - border: 1px solid color-mix(in srgb, var(--color-border-stone) 54%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-canvas-oat)); - box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text-ink) 5%, transparent); +.error-notice-detail__gap { + padding-top: 16px; } -.agent-conversation-panel .agent-message-content__table { - border-collapse: separate; - border-spacing: 0; - color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); +.error-notice-detail__panel { + padding: 12px 14px; + border: 1px solid color-mix(in srgb, var(--color-status-error) 14%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-status-error-soft) 28%, var(--color-background-paper)); + will-change: opacity; } -/* Tables read as rows, not grids: horizontal separators only. The outer - rounded frame already bounds the table, so vertical cell borders would - double the line-work and make dense reports look like spreadsheets. */ -.agent-conversation-panel .agent-message-content__th, -.agent-conversation-panel .agent-message-content__td { +.error-notice-detail__toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 12px 0 0 30px; + padding: 0; border: 0; - border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); - padding: 6px 12px; + background: transparent; + color: color-mix(in srgb, var(--color-status-error) 72%, var(--color-text-ink)); + font-size: 12px; + line-height: 18px; + font-weight: 500; + cursor: pointer; } -.agent-conversation-panel .agent-message-content__table tbody tr:last-child .agent-message-content__td { - border-bottom: 0; +.error-notice-detail__toggle:hover { + color: var(--color-status-error); } -.agent-conversation-panel .agent-message-content__th { - background: color-mix(in srgb, var(--color-canvas-oat) 58%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 92%, transparent); - font-weight: 700; +.error-notice-detail__toggle:focus-visible { + outline: 3px solid color-mix(in srgb, var(--color-action-sky) 28%, transparent); + outline-offset: 2px; } -.agent-conversation-panel .agent-message-content__table tbody tr:nth-child(even) .agent-message-content__td { - background: color-mix(in srgb, var(--color-canvas-oat) 22%, transparent); +.error-notice-detail__chevron { + transform: rotate(180deg); + transition: transform 220ms cubic-bezier(0, 0, 0.2, 1); } -.agent-conversation-panel .agent-message-content__code-block { - border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); - border-radius: 8px; - background: color-mix(in srgb, var(--color-background-paper) 88%, var(--color-canvas-oat)); - box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text-ink) 6%, transparent); +.error-notice-detail__toggle[aria-expanded="false"] .error-notice-detail__chevron { + transform: rotate(0deg); + transition-timing-function: cubic-bezier(0.4, 0, 1, 1); } -.agent-conversation-panel .agent-message-content__code-header { - border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 48%, transparent); - background: color-mix(in srgb, var(--color-canvas-oat) 56%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); -} +@media (prefers-reduced-motion: reduce) { + .error-notice-detail__shell, + .error-notice-detail__panel { + will-change: auto; + } -.agent-conversation-panel .agent-message-content__pre { - color: color-mix(in srgb, var(--color-text-ink) 84%, transparent); + .error-notice-detail__chevron { + transition: none; + } } -.agent-message-content__preview { - display: block; - max-width: 100%; - margin: 12px 0; +.agent-retry-wait-line--running .agent-retry-wait-line__label { + animation: agent-context-compaction-shimmer 1.7s ease-in-out infinite; + background: linear-gradient( + 90deg, + color-mix(in srgb, var(--color-text-ink) 48%, transparent) 0%, + color-mix(in srgb, white 88%, var(--color-text-ink) 12%) 45%, + color-mix(in srgb, var(--color-text-ink) 48%, transparent) 100% + ); + background-size: 220% 100%; + background-clip: text; + color: transparent; + text-shadow: 0 0 10px color-mix(in srgb, white 36%, transparent); + -webkit-background-clip: text; } -.agent-message-content__file-chip { +.agent-message-content { + font-family: var(--font-chat); + font-feature-settings: "ss01", "cv11"; + min-width: 0; max-width: 100%; - vertical-align: baseline; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); + font-size: 14px; + line-height: 1.65; + overflow-wrap: anywhere; } -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - white-space: nowrap; - clip: rect(0, 0, 0, 0); - border: 0; +.agent-message-content > :first-child { + margin-top: 0; } -/* Styles styles. */ -:where(button, input, select, textarea) { - font-family: inherit; +.agent-message-content > :last-child { + margin-bottom: 0; } -/* Styles styles. */ -:where( - input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]), - textarea, - select -):is(:focus, :focus-visible) { - outline: none; - box-shadow: none !important; +/* Headings in a chat answer are section markers inside a reply, not document + titles: keep them close to body size (Cursor caps around 16–18px) so long + generated reports read as one calm column instead of shouting banners. */ +.agent-message-content :where(h1, h2, h3, h4, h5, h6) { + margin: 18px 0 8px; + color: var(--color-text-ink); + font-weight: 600; + line-height: 1.4; + letter-spacing: -0.005em; } -button { - letter-spacing: 0; +.agent-message-content h1 { + font-size: 18px; } -.composer-media-preview-strip { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 10px; - overflow-y: auto; - padding: 12px 16px 4px; +.agent-message-content h2 { + font-size: 16px; } -/* Conversation composers keep controls in a dedicated footer, allowing every - text line to use the full content width. */ -.agent-composer-shell--expanded textarea.agent-composer-input--conversation { - padding-right: 1rem; - padding-bottom: 6px; +.agent-message-content :where(h3, h4, h5, h6) { + font-size: 14.5px; } -/* Keep this more specific than `.agent-composer-shell textarea` above so its generic line-height cannot shift the caret. - Keep overflow-y:auto (not hidden): if single-line detection lags behind wrapped content, the field must stay scrollable. */ -.agent-composer-shell textarea.agent-composer-input--single { - height: 52px; - padding-top: 14px; - padding-bottom: 14px; - overflow-x: hidden; - overflow-y: auto; - line-height: 24px; +/* ---- Vertical rhythm (single source of truth) ---- + Every markdown block uses one spacing scale: text blocks 10px apart, + framed blocks (tables/code) 12px, list items 4px, paragraphs inside list + items flush. Line-height always inherits from the content root (1.65 for + answers, 1.55 for the reasoning variant) so lists can never fall out of + step with surrounding paragraphs. */ +.agent-message-content__p { + margin: 10px 0; } -.agent-attachment-card { - display: inline-flex; - width: 220px; - max-width: 100%; - height: 56px; - min-width: 0; - align-items: center; - gap: 10px; - overflow: hidden; - padding: 8px 10px; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); - border-radius: var(--radius-card); - background: color-mix(in srgb, var(--color-background-paper) 92%, var(--color-canvas-oat)); - box-shadow: 0 8px 20px rgba(17, 29, 28, 0.06); - color: var(--color-text-ink); - text-align: left; - transition: - border-color 160ms ease, - background 160ms ease, - box-shadow 160ms ease, - transform 160ms ease, - opacity 160ms ease; +.agent-message-content__list { + margin: 10px 0; + padding-left: 22px; } -.agent-attachment-card--interactive { - cursor: pointer; +.agent-message-content__list--unordered { + list-style: disc; } -.agent-attachment-card--interactive:hover { - background: color-mix(in srgb, var(--color-background-paper) 78%, var(--color-canvas-oat)); - box-shadow: 0 10px 22px rgba(17, 29, 28, 0.08); - transform: translateY(-1px); +.agent-message-content__list--ordered { + list-style: decimal; } -.agent-attachment-card--interactive:focus-visible { - outline: none; - box-shadow: - 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 24%, transparent), - 0 10px 22px rgba(17, 29, 28, 0.08); +.agent-message-content__list li { + margin: 4px 0; } -.agent-attachment-card__action { - display: inline-flex; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: 10px; - padding: 0; - border: 0; - background: transparent; - color: inherit; - text-align: left; - cursor: pointer; +.agent-message-content__list .agent-message-content__list { + margin: 4px 0; } -.agent-attachment-card__action:focus-visible { - outline: none; - border-radius: 10px; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 30%, transparent); +.agent-message-content__list p { + margin: 2px 0; } -.agent-attachment-card__action:disabled { - cursor: wait; +.agent-message-content__quote { + margin: 10px 0; + padding: 2px 0 2px 12px; + border-left: 3px solid color-mix(in srgb, var(--color-action-sky) 34%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); } -.agent-attachment-card--right { - align-self: flex-end; +.agent-conversation-panel .agent-message-content__separator { + height: 1px; + margin: 14px 0; + border: 0; + background: var(--agent-conversation-muted-separator); } -.agent-attachment-card--error { - border-color: color-mix(in srgb, var(--color-status-error) 48%, transparent); - background: color-mix(in srgb, var(--color-status-error-soft) 36%, var(--color-background-paper)); +.agent-message-content__link { + color: var(--color-action-sky); + text-decoration-line: underline; + text-underline-offset: 2px; + overflow-wrap: anywhere; } -.agent-attachment-card--disabled { - cursor: wait; +.agent-message-content__inline-code { + border-radius: 4px; + background: color-mix(in srgb, var(--color-text-ink) 7%, transparent); + padding: 1px 4px; + font-family: var(--font-mono); + font-size: 0.92em; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +.agent-message-content--deferred { + border-radius: 10px; + background: + linear-gradient(90deg, color-mix(in srgb, var(--color-canvas-oat) 60%, transparent) 0 38%, transparent 38% 100%), + linear-gradient(color-mix(in srgb, var(--color-canvas-oat) 42%, transparent), color-mix(in srgb, var(--color-canvas-oat) 42%, transparent)); + background-size: 100% 12px, 100% 100%; + background-repeat: repeat-y; opacity: 0.7; } -.agent-attachment-card:disabled { - cursor: wait; +.agent-message-content__image, +.agent-message-content__preview-image { + max-width: 100%; + max-height: 20rem; + object-fit: contain; } -.agent-attachment-card__preview, -.agent-attachment-card__file-tile { - display: inline-flex; - width: 40px; - height: 40px; - flex: 0 0 40px; - align-items: center; - justify-content: center; - overflow: hidden; - border-radius: 12px; +.agent-message-content__table-scroll { + max-width: 100%; + margin: 12px 0; + overflow-x: auto; } -.agent-attachment-card__preview { - position: relative; - background: color-mix(in srgb, var(--color-canvas-oat) 68%, var(--color-background-paper)); +.agent-message-content__table { + min-width: 100%; + border-collapse: collapse; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); + font-size: 13px; } -.agent-attachment-card__preview-image { - display: block; - width: 100%; - height: 100%; - object-fit: cover; +.agent-message-content__th, +.agent-message-content__td { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); + padding: 4px 8px; + vertical-align: top; + overflow-wrap: anywhere; } -.agent-attachment-card__overlay { - position: absolute; - inset: 0; - display: inline-flex; - align-items: center; - justify-content: center; - background: color-mix(in srgb, var(--color-text-ink) 36%, transparent); - color: white; +.agent-message-content__th { + background: var(--color-canvas-oat); + color: var(--color-text-ink); + font-weight: 700; + text-align: left; } -.agent-attachment-card__file-tile { - flex-direction: column; - gap: 2px; +.agent-message-content__code-block { + max-width: 100%; + margin: 12px 0; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 30%, transparent); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-canvas-oat) 50%, transparent); } -.agent-attachment-card__file-tile--sm { - width: 32px; - height: 32px; - flex-basis: 32px; - border-radius: 10px; +.agent-message-content__code-header { + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 25%, transparent); + padding: 6px 12px; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + font-size: 11px; + font-weight: 700; } -.agent-attachment-card__file-tile--md { - width: 40px; - height: 40px; - flex-basis: 40px; +.agent-message-content__code-scroll, +.agent-message-content__pre { + max-width: 100%; + overflow-x: auto; } -.agent-attachment-card__file-tile--pdf { - background: #fff1f3; - color: #e54861; +.agent-message-content__table-scroll, +.agent-message-content__code-scroll, +.agent-message-content__pre { + scrollbar-width: thin; + scrollbar-color: var(--codex-scrollbar-thumb) transparent; } -.agent-attachment-card__file-tile--docx { - background: #edf6ff; - color: #2179c7; +.agent-message-content__table-scroll::-webkit-scrollbar, +.agent-message-content__code-scroll::-webkit-scrollbar, +.agent-message-content__pre::-webkit-scrollbar { + display: block; + height: var(--codex-scrollbar-size); } -.agent-attachment-card__file-tile--xlsx { - background: #eefaf2; - color: #238b58; +.agent-message-content__table-scroll::-webkit-scrollbar-thumb, +.agent-message-content__code-scroll::-webkit-scrollbar-thumb, +.agent-message-content__pre::-webkit-scrollbar-thumb { + background: var(--codex-scrollbar-thumb); } -.agent-attachment-card__file-tile--pptx { - background: #fff6e8; - color: #c96a16; +.agent-message-content__pre { + margin: 0; + padding: 8px 12px; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); + font-family: var(--font-mono); + font-size: 12px; + line-height: 1.625; } -.agent-attachment-card__file-tile--file { - background: #f4f8f7; - color: #5a6865; +.agent-message-content__pre code { + font-size: inherit; } -.agent-attachment-card__file-label { - display: block; - max-width: 100%; - overflow: hidden; - font-size: 7px; - font-weight: 800; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; +.agent-conversation-panel .agent-message-content__table-scroll { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 54%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-canvas-oat)); + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text-ink) 5%, transparent); } -.agent-attachment-card__body { - min-width: 0; - flex: 1 1 auto; +.agent-conversation-panel .agent-message-content__table { + border-collapse: separate; + border-spacing: 0; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); } -.agent-attachment-card__name { - display: block; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); - font-size: 12px; +/* Tables read as rows, not grids: horizontal separators only. The outer + rounded frame already bounds the table, so vertical cell borders would + double the line-work and make dense reports look like spreadsheets. */ +.agent-conversation-panel .agent-message-content__th, +.agent-conversation-panel .agent-message-content__td { + border: 0; + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 38%, transparent); + padding: 6px 12px; +} + +.agent-conversation-panel .agent-message-content__table tbody tr:last-child .agent-message-content__td { + border-bottom: 0; +} + +.agent-conversation-panel .agent-message-content__th { + background: color-mix(in srgb, var(--color-canvas-oat) 58%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 92%, transparent); font-weight: 700; - line-height: 16px; - text-overflow: ellipsis; - white-space: nowrap; } -.agent-attachment-card__meta { - display: block; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - font-size: 10px; - font-weight: 600; - line-height: 14px; - text-overflow: ellipsis; - white-space: nowrap; +.agent-conversation-panel .agent-message-content__table tbody tr:nth-child(even) .agent-message-content__td { + background: color-mix(in srgb, var(--color-canvas-oat) 22%, transparent); } -.agent-attachment-card__meta--error { - color: var(--color-status-error); +.agent-conversation-panel .agent-message-content__code-block { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 58%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--color-background-paper) 88%, var(--color-canvas-oat)); + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text-ink) 6%, transparent); } -.agent-attachment-card__remove { - display: inline-flex; - width: 22px; - height: 22px; - flex: 0 0 22px; - align-self: flex-start; - align-items: center; - justify-content: center; - padding: 0; - border: 0; - border-radius: 999px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); - cursor: pointer; - transition: - background 160ms ease, - color 160ms ease, - box-shadow 160ms ease; +.agent-conversation-panel .agent-message-content__code-header { + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 48%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 56%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); } -.agent-attachment-card__remove:hover { - background: var(--color-canvas-oat); - color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); +.agent-conversation-panel .agent-message-content__pre { + color: color-mix(in srgb, var(--color-text-ink) 84%, transparent); } -.agent-attachment-card__remove:focus-visible { - outline: none; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 30%, transparent); +.agent-message-content__preview { + display: block; + max-width: 100%; + margin: 12px 0; } -.agent-image-context-menu { - position: fixed; - z-index: 60; - width: 144px; - overflow: hidden; - padding: 4px; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); - border-radius: var(--radius-card-lg); - background: color-mix(in srgb, var(--color-background-paper) 96%, transparent); - box-shadow: 0 18px 42px rgba(17, 29, 28, 0.14); - color: var(--color-text-ink); - backdrop-filter: blur(14px); +.agent-message-content__file-chip { + max-width: 100%; + vertical-align: baseline; } -.agent-image-context-menu__item { - display: flex; - width: 100%; - align-items: center; - padding: 8px 10px; +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + white-space: nowrap; + clip: rect(0, 0, 0, 0); border: 0; - border-radius: var(--radius-input); - appearance: none; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); - cursor: pointer; - font-size: 12.5px; - font-weight: 400; - line-height: 18px; - text-align: left; } -.agent-image-context-menu__item:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 68%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); +/* Styles styles. */ +:where(button, input, select, textarea) { + font-family: inherit; } -.agent-image-context-menu__item:focus-visible { +/* Styles styles. */ +:where( + input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="button"]):not([type="submit"]):not([type="reset"]), + textarea, + select +):is(:focus, :focus-visible) { outline: none; - box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 28%, transparent); + box-shadow: none !important; } -.agent-image-context-menu__item:disabled { - color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); - cursor: wait; +button { + letter-spacing: 0; } -.agent-image-context-menu__error { - display: block; - padding: 4px 10px 7px; - color: var(--color-status-error); - font-size: 11.5px; - line-height: 16px; +.composer-media-preview-strip { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + overflow-y: auto; + padding: 12px 16px 4px; } -code { - overflow-wrap: anywhere; - font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; +/* Expanded composers keep controls in a footer so text can use the full row. */ +.agent-composer-shell--expanded textarea.agent-composer-input--conversation { + padding-right: 1rem; + padding-bottom: 6px; } -@keyframes memory-drawer-backdrop-in { - from { - opacity: 0; - } - to { - opacity: 1; - } +/* Keep this more specific than `.agent-composer-shell textarea` above so its generic line-height cannot shift the caret. + Keep overflow-y:auto (not hidden): if single-line detection lags behind wrapped content, the field must stay scrollable. */ +.agent-composer-shell textarea.agent-composer-input--single { + height: 52px; + padding-top: 14px; + padding-bottom: 14px; + overflow-x: hidden; + overflow-y: auto; + line-height: 24px; } -@keyframes memory-drawer-in { - from { - opacity: 0; - transform: translateX(32px); - } - to { - opacity: 1; - transform: translateX(0); - } +.agent-attachment-card { + display: inline-flex; + width: 220px; + max-width: 100%; + height: 56px; + min-width: 0; + align-items: center; + gap: 10px; + overflow: hidden; + padding: 8px 10px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 42%, transparent); + border-radius: var(--radius-card); + background: color-mix(in srgb, var(--color-background-paper) 92%, var(--color-canvas-oat)); + box-shadow: 0 8px 20px rgba(17, 29, 28, 0.06); + color: var(--color-text-ink); + text-align: left; + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease, + opacity 160ms ease; } -@keyframes agent-source-progress-indeterminate { - from { - transform: translateX(-120%); - } - to { - transform: translateX(320%); - } +.agent-attachment-card--interactive { + cursor: pointer; } -@keyframes agent-context-compaction-shimmer { - from { - background-position: 120% 50%; - } - to { - background-position: -120% 50%; - } +.agent-attachment-card--interactive:hover { + background: color-mix(in srgb, var(--color-background-paper) 78%, var(--color-canvas-oat)); + box-shadow: 0 10px 22px rgba(17, 29, 28, 0.08); + transform: translateY(-1px); } -.agent-source-progress-fill--indeterminate { - width: 32%; - animation: agent-source-progress-indeterminate 1.25s ease-in-out infinite; +.agent-attachment-card--interactive:focus-visible { + outline: none; + box-shadow: + 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 24%, transparent), + 0 10px 22px rgba(17, 29, 28, 0.08); } -.memory-panel { +.agent-attachment-card__action { + display: inline-flex; + min-width: 0; + flex: 1 1 auto; + align-items: center; + gap: 10px; padding: 0; + border: 0; + background: transparent; + color: inherit; + text-align: left; + cursor: pointer; } -.memory-panel__header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; - margin-bottom: 20px; +.agent-attachment-card__action:focus-visible { + outline: none; + border-radius: 10px; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 30%, transparent); } -.memory-panel__header--single-line { - align-items: flex-start; - flex-wrap: wrap; +.agent-attachment-card__action:disabled { + cursor: wait; } -.memory-panel__header-actions { - display: flex; - align-items: center; - align-self: flex-start; - justify-content: flex-end; - gap: 8px; - transform: translateY(-7px); +.agent-attachment-card--right { + align-self: flex-end; } -.memory-panel__header-main { - min-width: 0; +.agent-attachment-card--error { + border-color: color-mix(in srgb, var(--color-status-error) 48%, transparent); + background: color-mix(in srgb, var(--color-status-error-soft) 36%, var(--color-background-paper)); } -.memory-panel__title { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 20px; - color: var(--color-text-ink); - font-size: 16px; - font-weight: 500; - line-height: 20px; - margin: 0; +.agent-attachment-card--disabled { + cursor: wait; + opacity: 0.7; } -.memory-page-title-row, -.memory-page-content-title { - display: flex; - min-height: 20px; +.agent-attachment-card:disabled { + cursor: wait; +} + +.agent-attachment-card__preview, +.agent-attachment-card__file-tile { + display: inline-flex; + width: 40px; + height: 40px; + flex: 0 0 40px; align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 12px; } -.memory-page-title, -.memory-page-content-title { - line-height: 20px; +.agent-attachment-card__preview { + position: relative; + background: color-mix(in srgb, var(--color-canvas-oat) 68%, var(--color-background-paper)); } -.memory-panel__subtitle { - margin: 6px 0 0; - color: color-mix(in srgb, var(--color-text-ink) 56%, transparent); - font-size: 12px; - line-height: 1.5; +.agent-attachment-card__preview-image { + display: block; + width: 100%; + height: 100%; + object-fit: cover; } -.memory-refresh-button { +.agent-attachment-card__overlay { + position: absolute; + inset: 0; display: inline-flex; align-items: center; justify-content: center; - flex: 0 0 auto; - width: 34px; - height: 34px; - border: 1px solid var(--border-content-panel); - border-radius: var(--radius-btn); - background: var(--color-background-paper); - color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); - cursor: pointer; - box-shadow: none; - -webkit-app-region: no-drag; - transition: - border-color var(--duration-fast), - color var(--duration-fast), - transform var(--duration-fast); -} - -.memory-refresh-button:hover { - color: var(--color-action-sky-hover); - transform: translateY(-1px); -} - -.memory-refresh-button:focus-visible { - outline: none; + background: color-mix(in srgb, var(--color-text-ink) 36%, transparent); + color: white; } -.memory-toolbar { - display: flex; +.agent-attachment-card__file-tile { flex-direction: column; - gap: 12px; - margin-bottom: 18px; + gap: 2px; } -.memory-source-search-control { - position: relative; - display: grid; - grid-template-columns: minmax(0, 1fr) max-content; - height: 44px; - align-items: stretch; - border: 1px solid var(--border-content-panel); - border-radius: var(--radius-input); - background: var(--color-background-paper); - transition: border-color var(--duration-fast); +.agent-attachment-card__file-tile--sm { + width: 32px; + height: 32px; + flex-basis: 32px; + border-radius: 10px; } -.memory-source-search-control:focus-within { - border-color: color-mix(in srgb, var(--color-action-sky) 36%, var(--border-content-panel)); +.agent-attachment-card__file-tile--md { + width: 40px; + height: 40px; + flex-basis: 40px; } -.memory-source-search-control .memory-search { - min-width: 0; +.agent-attachment-card__file-tile--pdf { + background: #fff1f3; + color: #e54861; } -.memory-source-search-control .memory-search__input { - height: 42px; - border: 0; - border-radius: var(--radius-input) 0 0 var(--radius-input); - background: transparent; +.agent-attachment-card__file-tile--docx { + background: #edf6ff; + color: #2179c7; } -.memory-source-filter { - min-width: 0; - width: max-content; - max-width: 144px; +.agent-attachment-card__file-tile--xlsx { + background: #eefaf2; + color: #238b58; } -.memory-source-search-control .memory-source-filter__button { - height: 42px; - min-height: 42px; - border: 0; - border-radius: 0 var(--radius-input) var(--radius-input) 0; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - font-size: 13px; - padding: 0 32px 0 8px; +.agent-attachment-card__file-tile--pptx { + background: #fff6e8; + color: #c96a16; } -.memory-source-search-control .select-control__selection { - gap: 6px; +.agent-attachment-card__file-tile--file { + background: #f4f8f7; + color: #5a6865; } -.memory-source-search-control .memory-source-filter__button:hover, -.memory-source-search-control .memory-source-filter__button[aria-expanded="true"] { - background: color-mix(in srgb, var(--color-canvas-oat) 42%, transparent); +.agent-attachment-card__file-label { + display: block; + max-width: 100%; + overflow: hidden; + font-size: 7px; + font-weight: 800; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; } -.memory-source-search-control .memory-source-filter__menu { - right: -1px; - left: auto; - width: 224px; - max-height: 360px; +.agent-attachment-card__body { + min-width: 0; + flex: 1 1 auto; } -.memory-source-filter__icon { - color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); +.agent-attachment-card__name { + display: block; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); + font-size: 12px; + font-weight: 700; + line-height: 16px; + text-overflow: ellipsis; + white-space: nowrap; } -.memory-source-filter__all-icon { - position: relative; - display: inline-block; - width: 32px; - height: 18px; +.agent-attachment-card__meta { + display: block; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 10px; + font-weight: 600; + line-height: 14px; + text-overflow: ellipsis; + white-space: nowrap; } -.select-control__option-icon:has(.memory-source-filter__all-icon) { - width: 32px; - flex-basis: 32px; +.agent-attachment-card__meta--error { + color: var(--color-status-error); } -.memory-source-filter__all-avatar { - position: absolute; - top: 1px; +.agent-attachment-card__remove { display: inline-flex; - width: 16px; - height: 16px; + width: 22px; + height: 22px; + flex: 0 0 22px; + align-self: flex-start; align-items: center; justify-content: center; - overflow: hidden; - border: 1px solid var(--color-background-paper); - border-radius: 50%; - background: color-mix(in srgb, var(--color-background-paper) 90%, var(--color-canvas-oat)); - box-shadow: 0 1px 3px rgba(28, 42, 40, 0.14); + padding: 0; + border: 0; + border-radius: 999px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + cursor: pointer; + transition: + background 160ms ease, + color 160ms ease, + box-shadow 160ms ease; } -.memory-source-filter__all-avatar:nth-child(1) { - left: 0; - z-index: 1; +.agent-attachment-card__remove:hover { + background: var(--color-canvas-oat); + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); } -.memory-source-filter__all-avatar:nth-child(2) { - left: 8px; - z-index: 2; +.agent-attachment-card__remove:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 30%, transparent); } -.memory-source-filter__all-avatar:nth-child(3) { - left: 16px; - z-index: 3; +.agent-image-context-menu { + position: fixed; + z-index: 60; + width: 144px; + overflow: hidden; + padding: 4px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: var(--radius-card-lg); + background: color-mix(in srgb, var(--color-background-paper) 96%, transparent); + box-shadow: 0 18px 42px rgba(17, 29, 28, 0.14); + color: var(--color-text-ink); + backdrop-filter: blur(14px); } -.memory-source-filter__all-avatar .memory-source-filter__logo { - width: 11px; - height: 11px; +.agent-image-context-menu__item { + display: flex; + width: 100%; + align-items: center; + padding: 8px 10px; + border: 0; + border-radius: var(--radius-input); + appearance: none; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + cursor: pointer; + font-size: 12.5px; + font-weight: 400; + line-height: 18px; + text-align: left; } -.memory-source-filter__all-avatar .memory-source-filter__logo--memmy { - width: 15px; - height: 12px; +.agent-image-context-menu__item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 68%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); } -.memory-source-filter__icon--other { - color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +.agent-image-context-menu__item:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-action-sky) 28%, transparent); } -.memory-source-filter__logo { - width: 15px; - height: 15px; - object-fit: contain; +.agent-image-context-menu__item:disabled { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); + cursor: wait; } -.memory-source-filter__logo--memmy { - width: 18px; - height: 14px; +.agent-image-context-menu__error { + display: block; + padding: 4px 10px 7px; + color: var(--color-status-error); + font-size: 11.5px; + line-height: 16px; } -.select-control__option-icon:has(.memory-source-filter__logo--memmy) { - border-radius: 5px; - background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); +code { + overflow-wrap: anywhere; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; } -.memory-search { - position: relative; +@keyframes memory-drawer-backdrop-in { + from { + opacity: 0; + } + to { + opacity: 1; + } } -.memory-search__icon { - position: absolute; - left: 16px; - top: 50%; - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); - transform: translateY(-50%); - pointer-events: none; +@keyframes memory-drawer-in { + from { + opacity: 0; + transform: translateX(32px); + } + to { + opacity: 1; + transform: translateX(0); + } } -.memory-search__input { - width: 100%; - height: 44px; - border: 1px solid var(--border-content-panel); - border-radius: var(--radius-input); - background: var(--color-background-paper); - color: var(--color-text-ink); - padding: 0 16px 0 42px; - font-size: 14px; - outline: none; - box-shadow: none; +@keyframes agent-source-progress-indeterminate { + from { + transform: translateX(-120%); + } + to { + transform: translateX(320%); + } } -.memory-search__input::placeholder { - color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); +@keyframes agent-context-compaction-shimmer { + from { + background-position: 120% 50%; + } + to { + background-position: -120% 50%; + } } -.memory-search__input:focus { - outline: none; +.agent-source-progress-fill--indeterminate { + width: 32%; + animation: agent-source-progress-indeterminate 1.25s ease-in-out infinite; } -.memory-log-filter { - border: 1px solid var(--border-content-panel); - border-radius: var(--radius-btn); - background: var(--color-background-paper); - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - cursor: pointer; - font-size: 12px; - font-weight: 400; - line-height: 1.3; - padding: 8px 14px; - box-shadow: none; - transition: - border-color var(--duration-fast), - background var(--duration-fast), - color var(--duration-fast); +.memory-panel { + padding: 0; } -.memory-log-filter-group { +.memory-panel__header { display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 20px; +} + +.memory-panel__header--single-line { + align-items: flex-start; flex-wrap: wrap; - gap: 8px; } -.memory-log-filter:hover { - color: var(--color-text-ink); +.memory-panel__header-actions { + display: flex; + align-items: center; + align-self: flex-start; + justify-content: flex-end; + gap: 8px; + transform: translateY(-7px); } -.memory-log-filter--active { - background: color-mix(in srgb, var(--color-action-sky) 16%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); +.memory-panel__header-main { + min-width: 0; } -.memory-log-tool { +.memory-panel__title { display: inline-flex; - flex: 0 0 auto; align-items: center; - min-height: 22px; - border-radius: var(--radius-tag); - padding: 3px 9px; - font-size: 11px; - font-weight: 400; - line-height: 1; + gap: 8px; + min-height: 20px; + color: var(--color-text-ink); + font-size: 16px; + font-weight: 500; + line-height: 20px; + margin: 0; } -.memory-log-tool--add { - background: color-mix(in srgb, var(--color-accent-lemon) 55%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); +.memory-page-title-row, +.memory-page-content-title { + display: flex; + min-height: 20px; + align-items: center; } -.memory-log-tool--search { - background: color-mix(in srgb, var(--color-action-sky) 13%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-action-sky-hover) 88%, var(--color-text-ink)); +.memory-page-title, +.memory-page-content-title { + line-height: 20px; } -.memory-log-card { - overflow: hidden; +.memory-panel__subtitle { + margin: 6px 0 0; + color: color-mix(in srgb, var(--color-text-ink) 56%, transparent); + font-size: 12px; + line-height: 1.5; +} + +.memory-refresh-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + width: 34px; + height: 34px; border: 1px solid var(--border-content-panel); - border-radius: var(--radius-card-lg); + border-radius: var(--radius-btn); background: var(--color-background-paper); - color: var(--color-text-ink); + color: color-mix(in srgb, var(--color-text-ink) 66%, transparent); + cursor: pointer; box-shadow: none; + -webkit-app-region: no-drag; transition: border-color var(--duration-fast), - transform var(--duration-fast), - background var(--duration-fast); + color var(--duration-fast), + transform var(--duration-fast); } -.memory-log-card:hover { - background: color-mix(in srgb, var(--color-background-paper) 88%, var(--color-canvas-oat)); +.memory-refresh-button:hover { + color: var(--color-action-sky-hover); transform: translateY(-1px); } -.memory-log-card--expanded { - background: color-mix(in srgb, var(--color-background-paper) 94%, var(--color-canvas-oat)); +.memory-refresh-button:focus-visible { + outline: none; } -.memory-log-card__button { +.memory-toolbar { display: flex; - width: 100%; - align-items: center; - gap: 14px; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - padding: 16px 18px; - text-align: left; + flex-direction: column; + gap: 12px; + margin-bottom: 18px; } -.memory-log-card__button:focus-visible { - outline: none; - box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--color-action-sky) 16%, transparent); +.memory-source-search-control { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; + height: 44px; + align-items: stretch; + border: 1px solid var(--border-content-panel); + border-radius: var(--radius-input); + background: var(--color-background-paper); + transition: border-color var(--duration-fast); } -.memory-agent-source-tag { - display: inline-flex; - min-width: 0; - max-width: 150px; - height: 24px; - flex: 0 1 auto; - align-items: center; - gap: 6px; - border: 1px solid color-mix(in srgb, var(--color-role-assistant) 22%, var(--color-border-stone)); - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--color-role-assistant-soft) 44%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-role-assistant) 72%, var(--color-text-ink)); - padding: 2px 9px 2px 3px; +.memory-source-search-control:focus-within { + border-color: color-mix(in srgb, var(--color-action-sky) 36%, var(--border-content-panel)); } -.memory-agent-source-tag__avatar { - display: inline-flex; - width: 18px; - height: 18px; - flex: 0 0 18px; - align-items: center; - justify-content: center; - overflow: hidden; - border-radius: 50%; - background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-role-assistant-soft)); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-role-assistant) 12%, transparent); - color: color-mix(in srgb, var(--color-role-assistant) 76%, var(--color-text-ink)); - font-size: 8px; - font-weight: 700; - letter-spacing: -0.02em; +.memory-source-search-control .memory-search { + min-width: 0; } -.memory-agent-source-tag__logo { - width: 12px; - height: 12px; - object-fit: contain; +.memory-source-search-control .memory-search__input { + height: 42px; + border: 0; + border-radius: var(--radius-input) 0 0 var(--radius-input); + background: transparent; } -.memory-agent-source-tag__logo--memmy { - width: 17px; - height: 14px; +.memory-source-filter { + min-width: 0; + width: max-content; + max-width: 144px; } -.memory-agent-source-tag--unknown { - border-color: color-mix(in srgb, var(--color-text-ink) 14%, var(--color-border-stone)); - background: color-mix(in srgb, var(--color-canvas-oat) 54%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); +.memory-source-search-control .memory-source-filter__button { + height: 42px; + min-height: 42px; + border: 0; + border-radius: 0 var(--radius-input) var(--radius-input) 0; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 13px; + padding: 0 32px 0 8px; } -.memory-agent-source-tag--unknown .memory-agent-source-tag__avatar { - background: transparent; - box-shadow: none; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +.memory-source-search-control .select-control__selection { + gap: 6px; } -.memory-agent-source-tag__unknown-icon { - display: block; +.memory-source-search-control .memory-source-filter__button:hover, +.memory-source-search-control .memory-source-filter__button[aria-expanded="true"] { + background: color-mix(in srgb, var(--color-canvas-oat) 42%, transparent); } -.memory-agent-source-tag__name { - min-width: 0; - overflow: hidden; - font-size: 11px; - font-weight: 600; - line-height: 1; - text-overflow: ellipsis; - white-space: nowrap; +.memory-source-search-control .memory-source-filter__menu { + right: -1px; + left: auto; + width: 224px; + max-height: 360px; } -.memory-log-card__summary { - min-width: 0; - flex: 1 1 360px; - max-width: clamp(280px, 46vw, 760px); - margin-right: auto; - overflow: hidden; - color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); - font-size: 13px; - font-weight: 400; - line-height: 1.55; - text-overflow: ellipsis; - white-space: nowrap; +.memory-source-filter__icon { + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); } -.memory-log-card__summary--with-tail { - margin-right: 0; +.memory-source-filter__all-icon { + position: relative; + display: inline-block; + width: 32px; + height: 18px; } -.memory-log-card__summary-tail { - flex: 0 0 auto; - margin-right: auto; - color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); - font-size: 13px; - font-weight: 400; - line-height: 1.55; - white-space: nowrap; +.select-control__option-icon:has(.memory-source-filter__all-icon) { + width: 32px; + flex-basis: 32px; } -.memory-log-card__meta, -.memory-log-card__action { - flex: 0 0 auto; - color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); - font-size: 12px; - font-weight: 400; - text-align: right; - white-space: nowrap; +.memory-source-filter__all-avatar { + position: absolute; + top: 1px; + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--color-background-paper); + border-radius: 50%; + background: color-mix(in srgb, var(--color-background-paper) 90%, var(--color-canvas-oat)); + box-shadow: 0 1px 3px rgba(28, 42, 40, 0.14); } -.memory-log-card__meta { - min-width: 48px; +.memory-source-filter__all-avatar:nth-child(1) { + left: 0; + z-index: 1; } -.memory-log-card__meta + .memory-log-card__meta { - min-width: 112px; +.memory-source-filter__all-avatar:nth-child(2) { + left: 8px; + z-index: 2; } -.memory-log-card__action { - min-width: 34px; - color: color-mix(in srgb, var(--color-text-ink) 54%, transparent); +.memory-source-filter__all-avatar:nth-child(3) { + left: 16px; + z-index: 3; } -.memory-log-card__details { - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 34%, transparent); - background: color-mix(in srgb, var(--color-canvas-oat) 24%, var(--color-background-paper)); - padding: 16px; +.memory-source-filter__all-avatar .memory-source-filter__logo { + width: 11px; + height: 11px; } -.memory-log-detail { - display: flex; - flex-direction: column; - gap: 12px; +.memory-source-filter__all-avatar .memory-source-filter__logo--memmy { + width: 15px; + height: 12px; } -.memory-log-section { - display: flex; - flex-direction: column; - gap: 9px; - padding-top: 12px; - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 24%, transparent); +.memory-source-filter__icon--other { + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); } -.memory-log-detail > .memory-log-section:first-child, -.memory-log-detail > .memory-log-text:first-child { - padding-top: 0; - border-top: 0; +.memory-source-filter__logo { + width: 15px; + height: 15px; + object-fit: contain; } -.memory-log-section--meta { - gap: 0; +.memory-source-filter__logo--memmy { + width: 18px; + height: 14px; } -.memory-log-section__header { - display: flex; - align-items: center; - gap: 8px; - padding-left: 16px; +.select-control__option-icon:has(.memory-source-filter__logo--memmy) { + border-radius: 5px; + background: color-mix(in srgb, var(--color-canvas-oat) 72%, var(--color-background-paper)); } -.memory-log-section__header--group { - margin-bottom: 6px; - padding-left: 0; +.memory-search { + position: relative; } -.memory-log-section__title { - color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); - font-size: 12px; - font-weight: 400; - line-height: 1.2; +.memory-search__icon { + position: absolute; + left: 16px; + top: 50%; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + transform: translateY(-50%); + pointer-events: none; } -.memory-log-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 22px; - min-height: 20px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--color-border-stone) 18%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); - font-size: 11px; - font-weight: 400; - line-height: 1; +.memory-search__input { + width: 100%; + height: 44px; + border: 1px solid var(--border-content-panel); + border-radius: var(--radius-input); + background: var(--color-background-paper); + color: var(--color-text-ink); + padding: 0 16px 0 42px; + font-size: 14px; + outline: none; + box-shadow: none; } -.memory-log-count--active { - background: color-mix(in srgb, var(--color-status-success) 11%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-status-success) 78%, var(--color-text-ink)); +.memory-search__input::placeholder { + color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); } -.memory-log-empty { - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +.memory-search__input:focus { + outline: none; +} + +.memory-log-filter { + border: 1px solid var(--border-content-panel); + border-radius: var(--radius-btn); + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + cursor: pointer; font-size: 12px; font-weight: 400; - line-height: 1.45; + line-height: 1.3; + padding: 8px 14px; + box-shadow: none; + transition: + border-color var(--duration-fast), + background var(--duration-fast), + color var(--duration-fast); } -.memory-log-meta-list { +.memory-log-filter-group { display: flex; - flex-direction: column; - border-block: 1px solid color-mix(in srgb, var(--color-border-stone) 26%, transparent); + flex-wrap: wrap; + gap: 8px; } -.memory-log-meta { - display: flex; - align-items: baseline; - gap: 8px; - padding: 8px 0; +.memory-log-filter:hover { + color: var(--color-text-ink); } -.memory-log-meta + .memory-log-meta { - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); +.memory-log-filter--active { + background: color-mix(in srgb, var(--color-action-sky) 16%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 88%, transparent); } -.memory-log-meta--agent { +.memory-log-tool { + display: inline-flex; + flex: 0 0 auto; align-items: center; - background: transparent; + min-height: 22px; + border-radius: var(--radius-tag); + padding: 3px 9px; + font-size: 11px; + font-weight: 400; + line-height: 1; } -.memory-log-meta__label { - padding-top: 1px; - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); - font-size: 10px; - font-weight: 400; - line-height: 1.2; - white-space: nowrap; +.memory-log-tool--add { + background: color-mix(in srgb, var(--color-accent-lemon) 55%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); } -.memory-log-meta__label::after { - content: ":"; +.memory-log-tool--search { + background: color-mix(in srgb, var(--color-action-sky) 13%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-action-sky-hover) 88%, var(--color-text-ink)); } -.memory-log-meta__value { - color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); - font-family: var(--font-mono); - font-size: 12px; - font-weight: 400; - line-height: 1.35; - overflow-wrap: anywhere; - white-space: normal; +.memory-log-card { + overflow: hidden; + border: 1px solid var(--border-content-panel); + border-radius: var(--radius-card-lg); + background: var(--color-background-paper); + color: var(--color-text-ink); + box-shadow: none; + transition: + border-color var(--duration-fast), + transform var(--duration-fast), + background var(--duration-fast); } -.memory-log-text { - position: relative; - border-radius: var(--radius-input); - background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-canvas-oat)); - padding: 10px 12px 10px 16px; +.memory-log-card:hover { + background: color-mix(in srgb, var(--color-background-paper) 88%, var(--color-canvas-oat)); + transform: translateY(-1px); } -.memory-log-text::before { - content: ""; - position: absolute; - top: 12px; - bottom: 12px; - left: 7px; - width: 3px; - border-radius: var(--radius-pill); - background: color-mix(in srgb, var(--color-border-stone) 34%, transparent); +.memory-log-card--expanded { + background: color-mix(in srgb, var(--color-background-paper) 94%, var(--color-canvas-oat)); } -.memory-log-text--query { - background: color-mix(in srgb, var(--color-action-sky) 6%, var(--color-background-paper)); +.memory-log-card__button { + display: flex; + width: 100%; + align-items: center; + gap: 14px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + padding: 16px 18px; + text-align: left; } -.memory-log-text--query::before { - background: var(--color-action-sky); +.memory-log-card__button:focus-visible { + outline: none; + box-shadow: inset 0 0 0 3px color-mix(in srgb, var(--color-action-sky) 16%, transparent); } -.memory-log-text--agent { - background: color-mix(in srgb, var(--color-role-assistant) 7%, var(--color-background-paper)); +.memory-agent-source-tag { + display: inline-flex; + min-width: 0; + max-width: 150px; + height: 24px; + flex: 0 1 auto; + align-items: center; + gap: 6px; + border: 1px solid color-mix(in srgb, var(--color-role-assistant) 22%, var(--color-border-stone)); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-role-assistant-soft) 44%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-role-assistant) 72%, var(--color-text-ink)); + padding: 2px 9px 2px 3px; } -.memory-log-text--agent::before { - background: var(--color-role-assistant); +.memory-agent-source-tag__avatar { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; + align-items: center; + justify-content: center; + overflow: hidden; + border-radius: 50%; + background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-role-assistant-soft)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-role-assistant) 12%, transparent); + color: color-mix(in srgb, var(--color-role-assistant) 76%, var(--color-text-ink)); + font-size: 8px; + font-weight: 700; + letter-spacing: -0.02em; } -.memory-log-text--error { - background: color-mix(in srgb, var(--color-status-error) 8%, var(--color-background-paper)); +.memory-agent-source-tag__logo { + width: 12px; + height: 12px; + object-fit: contain; } -.memory-log-text--error::before { - background: var(--color-status-error); +.memory-agent-source-tag__logo--memmy { + width: 17px; + height: 14px; } -.memory-log-text__label { - margin-bottom: 5px; +.memory-agent-source-tag--unknown { + border-color: color-mix(in srgb, var(--color-text-ink) 14%, var(--color-border-stone)); + background: color-mix(in srgb, var(--color-canvas-oat) 54%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); +} + +.memory-agent-source-tag--unknown .memory-agent-source-tag__avatar { + background: transparent; + box-shadow: none; color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); +} + +.memory-agent-source-tag__unknown-icon { + display: block; +} + +.memory-agent-source-tag__name { + min-width: 0; + overflow: hidden; font-size: 11px; - font-weight: 400; - line-height: 1.2; + font-weight: 600; + line-height: 1; + text-overflow: ellipsis; + white-space: nowrap; } -.memory-log-text__value { - white-space: normal; - overflow-wrap: anywhere; - color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); +.memory-log-card__summary { + min-width: 0; + flex: 1 1 360px; + max-width: clamp(280px, 46vw, 760px); + margin-right: auto; + overflow: hidden; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); font-size: 13px; font-weight: 400; line-height: 1.55; + text-overflow: ellipsis; + white-space: nowrap; } -.memory-log-candidate-list { - display: flex; - flex-direction: column; - gap: 7px; - padding-left: 16px; +.memory-log-card__summary--with-tail { + margin-right: 0; } -.memory-log-candidate-groups { - display: flex; - flex-direction: column; - gap: 14px; - padding-left: 16px; +.memory-log-card__summary-tail { + flex: 0 0 auto; + margin-right: auto; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.55; + white-space: nowrap; } -.memory-log-candidate-group { - min-width: 0; +.memory-log-card__meta, +.memory-log-card__action { + flex: 0 0 auto; + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + font-size: 12px; + font-weight: 400; + text-align: right; + white-space: nowrap; } -.memory-log-candidate-group + .memory-log-candidate-group { - padding-top: 12px; - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 18%, transparent); +.memory-log-card__meta { + min-width: 48px; } -.memory-log-candidate-group .memory-log-candidate-list { - padding-left: 0; +.memory-log-card__meta + .memory-log-card__meta { + min-width: 112px; } -.memory-log-candidate-group--muted .memory-log-count { - color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +.memory-log-card__action { + min-width: 34px; + color: color-mix(in srgb, var(--color-text-ink) 54%, transparent); } -.memory-log-candidate-group .memory-log-count { - min-width: 18px; - min-height: 18px; - font-size: 10px; +.memory-log-card__details { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 34%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 24%, var(--color-background-paper)); + padding: 16px; } -.memory-log-candidate { - padding: 8px 0; - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 18%, transparent); +.memory-log-detail { + display: flex; + flex-direction: column; + gap: 12px; } -.memory-log-candidate:first-child { - border-top: 0; - padding-top: 0; +.memory-log-section { + display: flex; + flex-direction: column; + gap: 9px; + padding-top: 12px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 24%, transparent); } -.memory-log-candidate__summary { - display: grid; - grid-template-columns: 54px 42px minmax(0, 1fr) 14px; - align-items: start; - gap: 10px; - cursor: pointer; - list-style: none; +.memory-log-detail > .memory-log-section:first-child, +.memory-log-detail > .memory-log-text:first-child { + padding-top: 0; + border-top: 0; } -.memory-log-candidate__summary::-webkit-details-marker { - display: none; +.memory-log-section--meta { + gap: 0; } -.memory-log-candidate__summary::after { +.memory-log-section__header { + display: flex; + align-items: center; + gap: 8px; + padding-left: 16px; +} + +.memory-log-section__header--group { + margin-bottom: 6px; + padding-left: 0; +} + +.memory-log-section__title { + color: color-mix(in srgb, var(--color-text-ink) 60%, transparent); + font-size: 12px; + font-weight: 400; + line-height: 1.2; +} + +.memory-log-count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + min-height: 20px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-border-stone) 18%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-text-ink) 52%, transparent); + font-size: 11px; + font-weight: 400; + line-height: 1; +} + +.memory-log-count--active { + background: color-mix(in srgb, var(--color-status-success) 11%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-status-success) 78%, var(--color-text-ink)); +} + +.memory-log-empty { + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-size: 12px; + font-weight: 400; + line-height: 1.45; +} + +.memory-log-meta-list { + display: flex; + flex-direction: column; + border-block: 1px solid color-mix(in srgb, var(--color-border-stone) 26%, transparent); +} + +.memory-log-meta { + display: flex; + align-items: baseline; + gap: 8px; + padding: 8px 0; +} + +.memory-log-meta + .memory-log-meta { + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 22%, transparent); +} + +.memory-log-meta--agent { + align-items: center; + background: transparent; +} + +.memory-log-meta__label { + padding-top: 1px; + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); + font-size: 10px; + font-weight: 400; + line-height: 1.2; + white-space: nowrap; +} + +.memory-log-meta__label::after { + content: ":"; +} + +.memory-log-meta__value { + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); + font-family: var(--font-mono); + font-size: 12px; + font-weight: 400; + line-height: 1.35; + overflow-wrap: anywhere; + white-space: normal; +} + +.memory-log-text { + position: relative; + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-background-paper) 86%, var(--color-canvas-oat)); + padding: 10px 12px 10px 16px; +} + +.memory-log-text::before { + content: ""; + position: absolute; + top: 12px; + bottom: 12px; + left: 7px; + width: 3px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--color-border-stone) 34%, transparent); +} + +.memory-log-text--query { + background: color-mix(in srgb, var(--color-action-sky) 6%, var(--color-background-paper)); +} + +.memory-log-text--query::before { + background: var(--color-action-sky); +} + +.memory-log-text--agent { + background: color-mix(in srgb, var(--color-role-assistant) 7%, var(--color-background-paper)); +} + +.memory-log-text--agent::before { + background: var(--color-role-assistant); +} + +.memory-log-text--error { + background: color-mix(in srgb, var(--color-status-error) 8%, var(--color-background-paper)); +} + +.memory-log-text--error::before { + background: var(--color-status-error); +} + +.memory-log-text__label { + margin-bottom: 5px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + font-size: 11px; + font-weight: 400; + line-height: 1.2; +} + +.memory-log-text__value { + white-space: normal; + overflow-wrap: anywhere; + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); + font-size: 13px; + font-weight: 400; + line-height: 1.55; +} + +.memory-log-candidate-list { + display: flex; + flex-direction: column; + gap: 7px; + padding-left: 16px; +} + +.memory-log-candidate-groups { + display: flex; + flex-direction: column; + gap: 14px; + padding-left: 16px; +} + +.memory-log-candidate-group { + min-width: 0; +} + +.memory-log-candidate-group + .memory-log-candidate-group { + padding-top: 12px; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 18%, transparent); +} + +.memory-log-candidate-group .memory-log-candidate-list { + padding-left: 0; +} + +.memory-log-candidate-group--muted .memory-log-count { + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +} + +.memory-log-candidate-group .memory-log-count { + min-width: 18px; + min-height: 18px; + font-size: 10px; +} + +.memory-log-candidate { + padding: 8px 0; + border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 18%, transparent); +} + +.memory-log-candidate:first-child { + border-top: 0; + padding-top: 0; +} + +.memory-log-candidate__summary { + display: grid; + grid-template-columns: 54px 42px minmax(0, 1fr) 14px; + align-items: start; + gap: 10px; + cursor: pointer; + list-style: none; +} + +.memory-log-candidate__summary::-webkit-details-marker { + display: none; +} + +.memory-log-candidate__summary::after { content: "›"; color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); font-size: 16px; @@ -5571,1640 +6150,1183 @@ input[type="checkbox"].check-sky:checked::after { transform: rotate(-2deg); } 75% { - transform: rotate(2deg); - } -} - -@keyframes fadeUp { - 0% { - opacity: 0; - transform: translateY(10px); - } - 100% { - opacity: 1; - transform: translateY(0); - } -} - -.memmy-bob { - animation: memmy-bob 3s ease-in-out infinite; - transform-origin: center; -} - -.memmy-wave { - animation: memmy-wave 2.5s ease-in-out infinite; - transform-origin: center bottom; -} - -.pet-idle-breath { - animation: pet-idle-breath 4s ease-in-out infinite; - transform-origin: center bottom; -} - -.pet-idle-tilt { - animation: pet-idle-tilt 9s ease-in-out infinite; - transform-origin: center bottom; -} - -.walkie-wave { - animation: walkie-wave-pulse 1.6s ease-out infinite; - transform-origin: left center; -} - -.walkie-wave-1 { - animation-delay: 0s; -} - -.walkie-wave-2 { - animation-delay: 0.25s; -} - -.walkie-wave-3 { - animation-delay: 0.5s; -} - -.rec-bar { - animation: rec-bar 0.85s ease-in-out infinite; - transform-origin: bottom center; -} - -.rec-bar-1 { - animation-delay: 0s; -} - -.rec-bar-2 { - animation-delay: 0.18s; -} - -.rec-bar-3 { - animation-delay: 0.34s; -} - -.rec-bar-4 { - animation-delay: 0.12s; -} - -.rec-bar-5 { - animation-delay: 0.42s; -} - -/* Bg black styles. */ -.bg-black\/30 { - background-color: rgb(0 0 0 / 0.3); -} - -.border-stone-200 { - border-color: #e5e5e5; -} - -.bg-stone-50 { - background-color: #fafafa; -} - -.text-stone-400 { - color: #a3a3a3; -} - -.text-stone-500 { - color: #737373; -} - -.text-stone-600 { - color: #525252; -} - -.text-stone-700 { - color: #404040; -} - -.text-stone-900 { - color: #171717; -} - -.hover\:bg-stone-50:hover { - background-color: #fafafa; -} - -.hover\:bg-stone-100:hover { - background-color: #f5f5f5; -} - -.hover\:text-stone-900:hover { - color: #171717; -} - -.border-amber-200 { - border-color: #fde68a; -} - -.bg-amber-50 { - background-color: #fffbeb; -} - -.bg-amber-500 { - background-color: #e8a728; -} - -.text-amber-800 { - color: #92400e; -} - -.channel-permission-note { - padding: 12px 16px; - font-size: 13px; - line-height: 1.6; - color: #92400e; -} - -.channel-permission-note__intro, -.channel-permission-note__item, -.channel-permission-note__footer { - margin: 0; - font-size: inherit; - line-height: inherit; - color: inherit; - word-break: keep-all; -} - -.channel-permission-note__list { - display: flex; - flex-direction: column; - gap: 0.35em; - margin: 0.6em 0 0; - padding-left: 1.25rem; - list-style: disc; -} - -.channel-permission-note__item::marker { - color: #d97706; -} - -.channel-permission-note__scope { - font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; - font-size: inherit; - line-height: inherit; - word-break: break-all; -} - -.channel-permission-note__footer { - margin-top: 0.6em; -} - -.text-sage-700 { - color: #15803d; -} - -.bg-sage-500 { - background-color: #34c759; -} - -.border-coral-200 { - border-color: #fecaca; -} - -.bg-coral-50 { - background-color: #fef2f2; -} - -.text-coral-700 { - color: #b91c1c; -} - -.hover\:bg-coral-100:hover { - background-color: #fee2e2; -} - -.max-w-\[460px\] { - max-width: 460px; -} - -.rounded-3xl { - border-radius: var(--radius-card-lg); -} - -.shadow-large { - --tw-shadow: - 0 8px 24px -4px rgba(0, 0, 0, 0.1), - 0 16px 32px -8px rgba(0, 0, 0, 0.1); - box-shadow: - var(--tw-inset-shadow, 0 0 #0000), - var(--tw-ring-offset-shadow, 0 0 #0000), - var(--tw-ring-shadow, 0 0 #0000), - var(--tw-shadow); -} - -.animate-fade-up { - animation: fadeUp 0.5s cubic-bezier(0.4, 0, 0.2, 1); -} - -.line-clamp-2 { - display: -webkit-box; - overflow: hidden; - text-overflow: ellipsis; - -webkit-box-orient: vertical; - -webkit-line-clamp: 2; -} - -.font-normal { - font-weight: 400; -} - -.flex-shrink-0 { - flex-shrink: 0; -} - -.focus\:ring-0:focus { - --tw-ring-offset-shadow: 0 0 #0000; - --tw-ring-shadow: 0 0 #0000; -} - -.disabled\:opacity-60:disabled { - opacity: 0.6; -} - -.center-screen, -.welcome-screen, -.pet-screen { - min-height: 100vh; - padding: 32px; - background: var(--color-canvas-oat); -} - -.center-screen, -.pet-screen { - display: grid; - place-items: center; -} - -.welcome-screen { - position: relative; - display: grid; - grid-template-rows: minmax(0, 1fr) auto; - overflow: hidden; - gap: 18px; -} - -.welcome-screen::before, -.welcome-screen::after { - content: ""; - position: absolute; - z-index: 0; - border-radius: 999px; - filter: blur(48px); - pointer-events: none; -} - -.welcome-screen::before { - top: -80px; - right: -60px; - width: 256px; - height: 256px; - background: color-mix(in srgb, var(--color-action-sky) 15%, transparent); -} - -.welcome-screen::after { - bottom: -60px; - left: -40px; - width: 224px; - height: 224px; - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); -} - -.welcome-copy { - position: relative; - z-index: 1; - display: grid; - align-content: end; - justify-items: center; - gap: 8px; - text-align: center; -} - -.welcome-copy h1, -.startup-panel h1, -.form-panel h1, -.topbar h1 { - margin: 0; - color: var(--color-text-ink); - font-size: 32px; - line-height: 1.15; -} - -.welcome-copy h1 { - max-width: 560px; - font-size: 32px; - font-weight: 800; -} - -.welcome-copy .banner { - margin-top: 14px; -} - -.choice-grid { - position: relative; - z-index: 1; - display: grid; - width: min(100%, 480px); - margin: 0 auto; - gap: 14px; -} - -.page-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 16px; -} - -.page-stack { - display: grid; - gap: 16px; -} - -.app-shell { - display: grid; - grid-template-columns: 224px minmax(0, 1fr); - min-height: 100vh; - background: var(--color-background-paper); -} - -.sidebar { - display: flex; - flex-direction: column; - gap: 14px; - padding: 12px 8px; - background: var(--color-sidebar-bg); -} - -.brand-row, -.account-row, -.settings-row, -.toolbar, -.inline-field { - display: flex; - align-items: center; - gap: 10px; -} - -.brand-row { - padding: 4px 8px 2px; -} - -.brand-row strong { - min-width: 0; - flex: 1; - font-size: 14px; - font-weight: 800; - letter-spacing: -0.01em; -} - -.brand-mark { - display: grid; - width: 32px; - height: 32px; - flex: 0 0 auto; - place-items: center; - overflow: hidden; - border-radius: var(--radius-card); - color: var(--color-action-sky); - font-weight: 800; -} - - -.brand-mark.pet { - width: 96px; - height: 96px; - border-radius: 28px; - font-size: 44px; -} - -.nav-list { - display: grid; - gap: 4px; -} - -.nav-item { - position: relative; - width: 100%; - border: 0; - border-radius: var(--radius-card); - padding: 10px 12px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); - cursor: pointer; - font-size: 14px; - font-weight: 700; - text-align: left; - transition: - background var(--duration-fast), - color var(--duration-fast), - box-shadow var(--duration-fast); + transform: rotate(2deg); + } } -.nav-item:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 85%, transparent); +@keyframes fadeUp { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } } -.nav-item.active { - background: color-mix(in srgb, var(--color-action-sky) 12%, transparent); - color: var(--color-action-sky); - box-shadow: var(--shadow-soft); +.memmy-bob { + animation: memmy-bob 3s ease-in-out infinite; + transform-origin: center; } -.workspace { - min-width: 0; - padding: 0; - overflow: hidden; +.memmy-wave { + animation: memmy-wave 2.5s ease-in-out infinite; + transform-origin: center bottom; } -.topbar { - display: none; +.pet-idle-breath { + animation: pet-idle-breath 4s ease-in-out infinite; + transform-origin: center bottom; } -.eyebrow { - margin: 0 0 6px; - color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); - font-size: 12px; - font-weight: 800; - letter-spacing: 0; - text-transform: uppercase; +.pet-idle-tilt { + animation: pet-idle-tilt 9s ease-in-out infinite; + transform-origin: center bottom; } -.startup-panel, -.form-panel, -.card { - border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); - border-radius: var(--radius-card-lg); - background: var(--color-background-paper); - box-shadow: var(--shadow-card); +.walkie-wave { + animation: walkie-wave-pulse 1.6s ease-out infinite; + transform-origin: left center; } -.startup-panel, -.form-panel { - width: min(100%, 560px); - padding: 28px; +.walkie-wave-1 { + animation-delay: 0s; } -.form-panel.wide { - width: min(100%, 880px); +.walkie-wave-2 { + animation-delay: 0.25s; } -.panel-danger { - border-color: color-mix(in srgb, var(--color-status-error) 38%, var(--color-border-stone)); +.walkie-wave-3 { + animation-delay: 0.5s; } -.card { - min-width: 0; - padding: 20px; +.rec-bar { + animation: rec-bar 0.85s ease-in-out infinite; + transform-origin: bottom center; } -.choice-grid .card:first-child { - overflow: hidden; +.rec-bar-1 { + animation-delay: 0s; } -.choice-grid .card:first-child::before { - content: ""; - display: block; - height: 46px; - margin: -20px -20px 14px; - background: color-mix(in srgb, var(--color-accent-lemon) 60%, transparent); - border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); +.rec-bar-2 { + animation-delay: 0.18s; } -.card-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - margin-bottom: 12px; +.rec-bar-3 { + animation-delay: 0.34s; } -.card-header h3, -.card-body p { - margin: 0; +.rec-bar-4 { + animation-delay: 0.12s; } -.card-header h3 { - font-size: 17px; - font-weight: 800; +.rec-bar-5 { + animation-delay: 0.42s; } -.card-body { - display: grid; - gap: 12px; +/* Bg black styles. */ +.bg-black\/30 { + background-color: rgb(0 0 0 / 0.3); } -.card-actions { - flex: 0 0 auto; +.border-stone-200 { + border-color: #e5e5e5; } -.button { - display: inline-flex; - min-height: 36px; - align-items: center; - justify-content: center; - border: 1px solid transparent; - border-radius: var(--radius-btn); - cursor: pointer; - font-weight: 750; - line-height: 1; - white-space: nowrap; - transition: - background var(--duration-fast), - border-color var(--duration-fast), - color var(--duration-fast), - transform var(--duration-fast), - box-shadow var(--duration-fast); +.bg-stone-50 { + background-color: #fafafa; } -.button:hover:not(:disabled) { - transform: translateY(-1px); +.text-stone-400 { + color: #a3a3a3; } -.button:disabled { - cursor: not-allowed; - opacity: 0.45; +.text-stone-500 { + color: #737373; } -.button-md { - padding: 9px 14px; +.text-stone-600 { + color: #525252; } -.button-sm { - min-height: 30px; - padding: 7px 10px; - font-size: 12px; +.text-stone-700 { + color: #404040; } -.button-primary { - background: var(--color-action-sky); - color: #fff; - box-shadow: 0 8px 18px color-mix(in srgb, var(--color-action-sky) 18%, transparent); +.text-stone-900 { + color: #171717; } -.button-primary:hover:not(:disabled) { - background: var(--color-action-sky-hover); +.hover\:bg-stone-50:hover { + background-color: #fafafa; } -.button-secondary { - border-color: var(--color-border-stone); - background: var(--color-background-paper); - color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); +.hover\:bg-stone-100:hover { + background-color: #f5f5f5; } -.button-secondary:hover:not(:disabled) { - color: var(--color-action-sky); +.hover\:text-stone-900:hover { + color: #171717; } -.button-soft { - border-color: color-mix(in srgb, var(--color-border-stone) 40%, transparent); - background: var(--color-canvas-oat); - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +.border-amber-200 { + border-color: #fde68a; } -.button-soft:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-canvas-oat) 80%, var(--color-background-paper)); +.bg-amber-50 { + background-color: #fffbeb; } -.button-ghost { - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +.bg-amber-500 { + background-color: #e8a728; } -.button-ghost:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); - color: var(--color-action-sky); +.text-amber-800 { + color: #92400e; } -.button-danger { - background: var(--color-status-error); - color: #fff; +.channel-permission-note { + padding: 12px 16px; + font-size: 13px; + line-height: 1.6; + color: #92400e; } -.github-star-prompt { - position: fixed; - right: var(--space-5); - bottom: 48px; - /* Below product-tour mask (z-40) and modals (--z-modal: 50). */ - z-index: 30; - width: 256px; - max-width: calc(100vw - 32px); - pointer-events: none; +.channel-permission-note__intro, +.channel-permission-note__item, +.channel-permission-note__footer { + margin: 0; + font-size: inherit; + line-height: inherit; + color: inherit; + word-break: keep-all; } -.github-star-prompt__card { - position: relative; - pointer-events: auto; - box-sizing: border-box; +.channel-permission-note__list { display: flex; flex-direction: column; - gap: var(--space-3); - width: 256px; - max-width: 100%; - padding: var(--space-4); - border-radius: var(--radius-card); - border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); - background: var(--color-background-paper); - box-shadow: var(--shadow-soft); + gap: 0.35em; + margin: 0.6em 0 0; + padding-left: 1.25rem; + list-style: disc; } -.github-star-prompt__title { - margin: 0; - font-size: 14px; - font-weight: 700; - line-height: 20px; - color: var(--color-text-ink); +.channel-permission-note__item::marker { + color: #d97706; +} + +.channel-permission-note__scope { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: inherit; + line-height: inherit; + word-break: break-all; +} + +.channel-permission-note__footer { + margin-top: 0.6em; +} + +.text-sage-700 { + color: #15803d; } -.github-star-prompt__body { - margin: 0; - display: flex; - flex-direction: column; - gap: 2px; - font-size: 12px; - font-weight: 400; - line-height: 18px; - color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); +.bg-sage-500 { + background-color: #34c759; } -.github-star-prompt__actions { - display: flex; - align-items: center; - gap: var(--space-2); - margin-top: var(--space-1); +.border-coral-200 { + border-color: #fecaca; } -.github-star-prompt__secondary { - flex: 0 0 auto; - min-width: 0; - height: 32px; - padding: 0 10px; - border: 0; - border-radius: var(--radius-btn); - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); - font-size: 11px; - font-weight: 500; - line-height: 16px; - cursor: pointer; - transition: color 150ms ease, background 150ms ease; +.bg-coral-50 { + background-color: #fef2f2; } -.github-star-prompt__secondary:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); +.text-coral-700 { + color: #b91c1c; } -.github-star-prompt__primary { - flex: 1; - height: 32px; - min-height: 32px; - border: 0; - border-radius: var(--radius-btn); - background: var(--color-action-sky); - color: #ffffff; - font-size: 12px; - font-weight: 600; - line-height: 16px; - cursor: pointer; - transition: background 150ms ease; +.hover\:bg-coral-100:hover { + background-color: #fee2e2; } -.github-star-prompt__primary:hover { - background: var(--color-action-sky-hover); +.max-w-\[460px\] { + max-width: 460px; } -.banner { - display: flex; - align-items: flex-start; - gap: 10px; - margin-bottom: 16px; - border-radius: var(--radius-card); - padding: 12px 14px; - font-size: 12px; - font-weight: 400; - line-height: 1.55; +.rounded-3xl { + border-radius: var(--radius-card-lg); } -.invite-result-toast { - position: fixed; - top: 4rem; - left: 50%; - z-index: 9998; - width: max-content; - max-width: min(92vw, 360px); - transform: translateX(-50%); - pointer-events: none; +.shadow-large { + --tw-shadow: + 0 8px 24px -4px rgba(0, 0, 0, 0.1), + 0 16px 32px -8px rgba(0, 0, 0, 0.1); + box-shadow: + var(--tw-inset-shadow, 0 0 #0000), + var(--tw-ring-offset-shadow, 0 0 #0000), + var(--tw-ring-shadow, 0 0 #0000), + var(--tw-shadow); } -.invite-result-toast__panel { - width: max-content; - max-width: 100%; +.animate-fade-up { + animation: fadeUp 0.5s cubic-bezier(0.4, 0, 0.2, 1); } -.invite-result-toast .banner { - margin-bottom: 0; - width: max-content; - max-width: 100%; - box-shadow: var(--shadow-soft); +.line-clamp-2 { + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; } -.banner__icon { - display: inline-flex; - width: 26px; - height: 26px; - flex: 0 0 26px; - align-items: center; - justify-content: center; - border-radius: 999px; +.font-normal { + font-weight: 400; } -.banner__body { - flex: 1; - min-width: 0; - padding-top: 3px; +.flex-shrink-0 { + flex-shrink: 0; } -.banner-success { - border: 1px solid color-mix(in srgb, var(--color-status-success) 22%, transparent); - background: color-mix(in srgb, var(--color-status-success-soft) 52%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-status-success) 78%, var(--color-text-ink)); +.focus\:ring-0:focus { + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; } -.banner-success .banner__icon { - background: color-mix(in srgb, var(--color-status-success) 12%, transparent); - color: var(--color-status-success); +.disabled\:opacity-60:disabled { + opacity: 0.6; } -.banner-warning { - border: 1px solid color-mix(in srgb, var(--color-icon-ember) 22%, transparent); - background: color-mix(in srgb, var(--color-accent-peach) 42%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-icon-ember) 78%, var(--color-text-ink)); +.center-screen, +.welcome-screen, +.pet-screen { + min-height: 100vh; + padding: 32px; + background: var(--color-canvas-oat); } -.banner-warning .banner__icon { - background: color-mix(in srgb, var(--color-icon-ember) 12%, transparent); - color: var(--color-icon-ember); +.center-screen, +.pet-screen { + display: grid; + place-items: center; } -.banner-info { - border: 1px solid color-mix(in srgb, var(--color-role-assistant) 20%, transparent); - background: color-mix(in srgb, var(--color-role-assistant-soft) 55%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-role-assistant) 82%, var(--color-text-ink)); +.welcome-screen { + position: relative; + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + overflow: hidden; + gap: 18px; } -.banner-info .banner__icon { - background: color-mix(in srgb, var(--color-role-assistant) 12%, transparent); - color: var(--color-role-assistant); +.welcome-screen::before, +.welcome-screen::after { + content: ""; + position: absolute; + z-index: 0; + border-radius: 999px; + filter: blur(48px); + pointer-events: none; } -.banner-danger { - border: 1px solid color-mix(in srgb, var(--color-status-error) 20%, transparent); - background: color-mix(in srgb, var(--color-status-error-soft) 48%, var(--color-background-paper)); - color: color-mix(in srgb, var(--color-status-error) 86%, var(--color-text-ink)); +.welcome-screen::before { + top: -80px; + right: -60px; + width: 256px; + height: 256px; + background: color-mix(in srgb, var(--color-action-sky) 15%, transparent); } -.banner-danger .banner__icon { - background: color-mix(in srgb, var(--color-status-error) 10%, transparent); - color: var(--color-status-error); +.welcome-screen::after { + bottom: -60px; + left: -40px; + width: 224px; + height: 224px; + background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); } -.badge, -.status-pill { - display: inline-flex; - min-height: 26px; - align-items: center; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); - border-radius: var(--radius-tag); - padding: 4px 9px; - background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); - font-size: 11px; - font-weight: 750; +.welcome-copy { + position: relative; + z-index: 1; + display: grid; + align-content: end; + justify-items: center; + gap: 8px; + text-align: center; } -.status-pill { - background: var(--color-background-paper); +.welcome-copy h1, +.startup-panel h1, +.form-panel h1, +.topbar h1 { + margin: 0; + color: var(--color-text-ink); + font-size: 32px; + line-height: 1.15; } -.badge-success { - border-color: transparent; - background: var(--color-status-success-soft); - color: var(--color-status-success); +.welcome-copy h1 { + max-width: 560px; + font-size: 32px; + font-weight: 800; } -.badge-warning { - border-color: transparent; - background: color-mix(in srgb, var(--color-accent-lemon) 58%, transparent); - color: var(--color-icon-ember); +.welcome-copy .banner { + margin-top: 14px; } -.badge-danger { - border-color: transparent; - background: var(--color-status-error-soft); - color: var(--color-status-error); +.choice-grid { + position: relative; + z-index: 1; + display: grid; + width: min(100%, 480px); + margin: 0 auto; + gap: 14px; } -.badge-info { - color: var(--color-role-assistant); +.page-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.page-stack { + display: grid; + gap: 16px; } -.field { +.app-shell { display: grid; - gap: 7px; - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); - font-size: 12px; - font-weight: 700; + grid-template-columns: 224px minmax(0, 1fr); + min-height: 100vh; + background: var(--color-background-paper); } -.field input, -.field select, -.composer textarea { - width: 100%; - border: 1px solid var(--color-border-stone); - border-radius: var(--radius-input); - padding: 10px 12px; - background: color-mix(in srgb, var(--color-canvas-oat) 30%, var(--color-background-paper)); - color: var(--color-text-ink); - outline: none; +.sidebar { + display: flex; + flex-direction: column; + gap: 14px; + padding: 12px 8px; + background: var(--color-sidebar-bg); } -.field input:focus, -.field select:focus, -.composer textarea:focus { - outline: none; +.brand-row, +.account-row, +.settings-row, +.toolbar, +.inline-field { + display: flex; + align-items: center; + gap: 10px; } -.field small, -.field strong, -.composer small, -.account-row small, -.source-row small { - color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); - font-size: 12px; +.brand-row { + padding: 4px 8px 2px; } -.field strong { - color: var(--color-status-error); +.brand-row strong { + min-width: 0; + flex: 1; + font-size: 14px; + font-weight: 800; + letter-spacing: -0.01em; } -.select-control { - position: relative; +.brand-mark { display: grid; - width: 100%; - gap: 7px; + width: 32px; + height: 32px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: var(--radius-card); + color: var(--color-action-sky); + font-weight: 800; } -.select-control__label { - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); - font-size: 12px; - font-weight: 700; + +.brand-mark.pet { + width: 96px; + height: 96px; + border-radius: 28px; + font-size: 44px; } -.select-control__button { +.nav-list { + display: grid; + gap: 4px; +} + +.nav-item { position: relative; - display: flex; width: 100%; - min-width: 0; - min-height: 40px; - align-items: center; - border: 1px solid var(--color-border-stone); - border-radius: var(--radius-input); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, var(--color-background-paper)); - color: var(--color-text-ink); + border: 0; + border-radius: var(--radius-card); + padding: 10px 12px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); cursor: pointer; - font-family: inherit; font-size: 14px; - font-weight: 400; - line-height: 1.25; - outline: none; - padding: 10px 42px 10px 14px; + font-weight: 700; text-align: left; transition: - background-color var(--duration-fast), - border-color var(--duration-fast), + background var(--duration-fast), + color var(--duration-fast), box-shadow var(--duration-fast); } -.select-control__button:hover { - background: color-mix(in srgb, var(--color-canvas-oat) 45%, var(--color-background-paper)); +.nav-item:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 85%, transparent); } -.select-control__button:focus, -.select-control__button:focus-visible, -.select-control__button[aria-expanded="true"] { - outline: none; - box-shadow: none !important; +.nav-item.active { + background: color-mix(in srgb, var(--color-action-sky) 12%, transparent); + color: var(--color-action-sky); + box-shadow: var(--shadow-soft); } -.select-control__button:disabled { - cursor: not-allowed; - opacity: 0.55; +.workspace { + min-width: 0; + padding: 0; + overflow: hidden; } -.select-control__button--disabled-value, -.agent-model-selector__button.select-control__button--disabled-value { - color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); +.topbar { + display: none; } -.select-control__button--disabled-value .select-control__option-icon { - opacity: 0.38; - filter: grayscale(1); +.eyebrow { + margin: 0 0 6px; + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); + font-size: 12px; + font-weight: 800; + letter-spacing: 0; + text-transform: uppercase; } -.select-control__button--disabled-value .select-control__icon { - opacity: 0.42; +.startup-panel, +.form-panel, +.card { + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: var(--radius-card-lg); + background: var(--color-background-paper); + box-shadow: var(--shadow-card); } -.select-control__selection, -.select-control__option-content { - display: inline-flex; +.startup-panel, +.form-panel { + width: min(100%, 560px); + padding: 28px; +} + +.form-panel.wide { + width: min(100%, 880px); +} + +.panel-danger { + border-color: color-mix(in srgb, var(--color-status-error) 38%, var(--color-border-stone)); +} + +.card { min-width: 0; - align-items: center; - gap: 8px; + padding: 20px; } -.select-control__selection { - flex: 1 1 auto; - max-width: 100%; +.choice-grid .card:first-child { overflow: hidden; } -.select-control__option-content { - flex: 1 1 auto; +.choice-grid .card:first-child::before { + content: ""; + display: block; + height: 46px; + margin: -20px -20px 14px; + background: color-mix(in srgb, var(--color-accent-lemon) 60%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); } -.select-control__option-icon { - display: inline-flex; - width: 18px; - height: 18px; - flex: 0 0 18px; - align-items: center; - justify-content: center; +.card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; } -.select-control__value { - display: block; - flex: 1 1 auto; - max-width: 100%; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.card-header h3, +.card-body p { + margin: 0; } -.select-control__value--placeholder { - color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); +.card-header h3 { + font-size: 17px; + font-weight: 800; } -.select-control__icon { - position: absolute; - top: 50%; - right: 14px; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - pointer-events: none; - transform: translateY(-50%); - transition: - color var(--duration-fast), - transform var(--duration-fast); +.card-body { + display: grid; + gap: 12px; } -.select-control__button[aria-expanded="true"] .select-control__icon { - color: var(--color-action-sky-hover); - transform: translateY(-50%) rotate(180deg); +.card-actions { + flex: 0 0 auto; } -.select-control__menu { - position: absolute; - top: calc(100% + 6px); - right: 0; - left: 0; - z-index: 80; - max-height: 242px; - overflow: auto; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); - border-radius: var(--radius-input); - background: var(--color-background-paper); - box-shadow: var(--shadow-popover); - padding: 6px; +.button { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: var(--radius-btn); + cursor: pointer; + font-weight: 750; + line-height: 1; + white-space: nowrap; + transition: + background var(--duration-fast), + border-color var(--duration-fast), + color var(--duration-fast), + transform var(--duration-fast), + box-shadow var(--duration-fast); } -.select-control--placement-top .select-control__menu { - top: auto; - bottom: calc(100% + 8px); +.button:hover:not(:disabled) { + transform: translateY(-1px); } -.chat-model-select { - width: max-content; - min-width: 0; - max-width: min(240px, 26vw); - flex: 0 1 auto; +.button:disabled { + cursor: not-allowed; + opacity: 0.45; } -.chat-model-select .chat-model-select__button { - min-height: 30px; - padding: 6px 28px 6px 8px; - border-color: transparent; - border-radius: 10px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); - font-size: 11px; +.button-md { + padding: 9px 14px; } -.chat-model-select .chat-model-select__button:hover { - border-color: transparent; - background: color-mix(in srgb, var(--color-canvas-oat) 62%, var(--color-background-paper)); +.button-sm { + min-height: 30px; + padding: 7px 10px; + font-size: 12px; } -.chat-model-select .select-control__icon { - right: 10px; - width: 14px; - height: 14px; +.button-primary { + background: var(--color-action-sky); + color: #fff; + box-shadow: 0 8px 18px color-mix(in srgb, var(--color-action-sky) 18%, transparent); } -.chat-model-select .chat-model-select__menu { - right: 0; - left: auto; - width: max-content; - min-width: 100%; - max-width: min(320px, calc(100vw - 32px)); - max-height: min(240px, 40vh); - padding: 6px; - border-color: color-mix(in srgb, var(--color-border-stone) 46%, transparent); - border-radius: 14px; - box-shadow: var(--shadow-popover); +.button-primary:hover:not(:disabled) { + background: var(--color-action-sky-hover); } -.chat-model-select .select-control__option { - min-height: 36px; - border-radius: 9px; - color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); - font-size: 12px; +.button-secondary { + border-color: var(--color-border-stone); + background: var(--color-background-paper); + color: color-mix(in srgb, var(--color-text-ink) 75%, transparent); } -.chat-model-select .select-control__selection, -.chat-model-select .select-control__option-content { - gap: 7px; +.button-secondary:hover:not(:disabled) { + color: var(--color-action-sky); } -.chat-model-select .select-control__option-icon { - width: 1.25em; - height: 1.25em; - flex-basis: 1.25em; - overflow: hidden; +.button-soft { + border-color: color-mix(in srgb, var(--color-border-stone) 40%, transparent); + background: var(--color-canvas-oat); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); } -.llm-provider-logo { - display: block; - width: 100%; - height: 100%; - object-fit: contain; +.button-soft:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-oat) 80%, var(--color-background-paper)); } -/* The copied app icon has a 30px transparent inset in its 512px canvas. */ -.llm-provider-logo[data-provider="memmy_account"] { - transform: scale(1.133); +.button-ghost { + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); } -.chat-model-select .select-control__option:hover:not(.select-control__option--selected), -.chat-model-select .select-control__option--active { - background: color-mix(in srgb, var(--color-action-sky) 8%, var(--color-background-paper)); +.button-ghost:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: var(--color-action-sky); } -.chat-model-select .select-control__option--selected { - background: color-mix(in srgb, var(--color-action-sky) 12%, var(--color-background-paper)); - color: var(--color-action-sky-hover); - font-weight: 600; +.button-danger { + background: var(--color-status-error); + color: #fff; } -.select-control__group-label { - padding: 7px 9px 4px; - color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); - font-size: 11px; - font-weight: 800; - line-height: 1.2; +.github-star-prompt { + position: fixed; + right: var(--space-5); + bottom: 48px; + /* Below product-tour mask (z-40) and modals (--z-modal: 50). */ + z-index: 30; + width: 256px; + max-width: calc(100vw - 32px); + pointer-events: none; } -.select-control__option { +.github-star-prompt__card { + position: relative; + pointer-events: auto; + box-sizing: border-box; display: flex; - width: 100%; - min-height: 34px; - align-items: center; - justify-content: space-between; - gap: 10px; - border: 0; - border-radius: 7px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); - cursor: pointer; - font-family: inherit; - font-size: 13px; - font-weight: 400; - line-height: 1.25; - padding: 8px 9px; - text-align: left; - transition: - background-color var(--duration-fast), - color var(--duration-fast); -} - -.select-control__option-label { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + flex-direction: column; + gap: var(--space-3); + width: 256px; + max-width: 100%; + padding: var(--space-4); + border-radius: var(--radius-card); + border: 1px solid color-mix(in srgb, var(--color-border-stone) 50%, transparent); + background: var(--color-background-paper); + box-shadow: var(--shadow-soft); } -.select-control__option:hover:not(:disabled), -.select-control__option--active:not(:disabled):not(.select-control__option--selected) { - background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); +.github-star-prompt__title { + margin: 0; + font-size: 14px; + font-weight: 700; + line-height: 20px; color: var(--color-text-ink); } -.select-control__option--selected { - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); +.github-star-prompt__body { + margin: 0; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 12px; + font-weight: 400; + line-height: 18px; + color: color-mix(in srgb, var(--color-text-ink) 58%, transparent); } -.select-control__option--selected:hover:not(:disabled) { - background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); - color: var(--color-text-ink); +.github-star-prompt__actions { + display: flex; + align-items: center; + gap: var(--space-2); + margin-top: var(--space-1); } -.select-control__option-check { +.github-star-prompt__secondary { flex: 0 0 auto; - color: var(--color-action-sky); + min-width: 0; + height: 32px; + padding: 0 10px; + border: 0; + border-radius: var(--radius-btn); + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); + font-size: 11px; + font-weight: 500; + line-height: 16px; + cursor: pointer; + transition: color 150ms ease, background 150ms ease; } -.select-control__option:disabled { - cursor: not-allowed; - opacity: 0.45; +.github-star-prompt__secondary:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 62%, transparent); } -.select-control--paper .select-control__button { - background: var(--color-background-paper); +.github-star-prompt__primary { + flex: 1; + height: 32px; + min-height: 32px; + border: 0; + border-radius: var(--radius-btn); + background: var(--color-action-sky); + color: #ffffff; + font-size: 12px; + font-weight: 600; + line-height: 16px; + cursor: pointer; + transition: background 150ms ease; } -.select-control--subtle .select-control__button { - border-color: color-mix(in srgb, var(--color-border-stone) 44%, transparent); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); +.github-star-prompt__primary:hover { + background: var(--color-action-sky-hover); } -.model-assignment-label-row { - display: inline-flex; - align-items: center; - gap: 5px; - min-height: 14px; - line-height: 14px; +.banner { + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 16px; + border-radius: var(--radius-card); + padding: 12px 14px; + font-size: 12px; + font-weight: 400; + line-height: 1.55; +} + +.invite-result-toast { + position: fixed; + top: 4rem; + left: 50%; + z-index: 9998; + width: max-content; + max-width: min(92vw, 360px); + transform: translateX(-50%); + pointer-events: none; } -.model-assignment-label { - margin: 0; - color: color-mix(in srgb, var(--color-text-ink) 80%, transparent); - font-size: 14px; - line-height: 14px; +.invite-result-toast__panel { + width: max-content; + max-width: 100%; } -.model-assignment-tip-icon { +.invite-result-toast .banner { + margin-bottom: 0; + width: max-content; + max-width: 100%; + box-shadow: var(--shadow-soft); +} + +.banner__icon { display: inline-flex; - width: 14px; - height: 14px; - flex: 0 0 auto; + width: 26px; + height: 26px; + flex: 0 0 26px; align-items: center; justify-content: center; - margin: 0; - padding: 0; - border: 0; border-radius: 999px; - background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 38%, transparent); - cursor: help; - line-height: 0; } -.model-assignment-tip-icon svg { - display: block; - width: 12px; - height: 12px; +.banner__body { + flex: 1; + min-width: 0; + padding-top: 3px; } -.model-assignment-tip-icon:hover, -.model-assignment-tip-icon:focus-visible { - background: color-mix(in srgb, var(--color-canvas-oat) 70%, transparent); - color: color-mix(in srgb, var(--color-action-sky) 72%, var(--color-text-ink)); - outline: none; +.banner-success { + border: 1px solid color-mix(in srgb, var(--color-status-success) 22%, transparent); + background: color-mix(in srgb, var(--color-status-success-soft) 52%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-status-success) 78%, var(--color-text-ink)); } -.model-assignment-select .select-control__button { - border-color: transparent; - background: color-mix(in srgb, var(--color-canvas-oat) 52%, transparent); +.banner-success .banner__icon { + background: color-mix(in srgb, var(--color-status-success) 12%, transparent); + color: var(--color-status-success); } -.model-assignment-select .select-control__button:hover, -.model-assignment-select .select-control__button[aria-expanded="true"] { - border-color: color-mix(in srgb, var(--color-action-sky) 18%, transparent); - background: color-mix(in srgb, var(--color-canvas-oat) 75%, transparent); +.banner-warning { + border: 1px solid color-mix(in srgb, var(--color-icon-ember) 22%, transparent); + background: color-mix(in srgb, var(--color-accent-peach) 42%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-icon-ember) 78%, var(--color-text-ink)); } -.model-assignment-select__menu { - right: 0; - left: auto; - width: max-content; - min-width: 230px; - max-width: min(320px, calc(100vw - 48px)); +.banner-warning .banner__icon { + background: color-mix(in srgb, var(--color-icon-ember) 12%, transparent); + color: var(--color-icon-ember); } -.provider-model-list { - max-width: 100%; - max-height: 220px; - overflow-x: hidden; - overflow-y: auto; +.banner-info { + border: 1px solid color-mix(in srgb, var(--color-role-assistant) 20%, transparent); + background: color-mix(in srgb, var(--color-role-assistant-soft) 55%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-role-assistant) 82%, var(--color-text-ink)); } -.modal.model-connection-modal { - display: flex; - width: min(600px, calc(100vw - 64px)); - max-height: calc(100vh - 48px); - flex-direction: column; +.banner-info .banner__icon { + background: color-mix(in srgb, var(--color-role-assistant) 12%, transparent); + color: var(--color-role-assistant); } -@media (min-width: 720px) { - .modal-backdrop.model-connection-modal__backdrop { - padding-left: calc(var(--codex-sidebar-width) + 24px); - } +.banner-danger { + border: 1px solid color-mix(in srgb, var(--color-status-error) 20%, transparent); + background: color-mix(in srgb, var(--color-status-error-soft) 48%, var(--color-background-paper)); + color: color-mix(in srgb, var(--color-status-error) 86%, var(--color-text-ink)); +} - .modal.model-connection-modal { - width: min(600px, calc(100vw - var(--codex-sidebar-width) - 64px)); - } +.banner-danger .banner__icon { + background: color-mix(in srgb, var(--color-status-error) 10%, transparent); + color: var(--color-status-error); } -.model-connection-modal__body { - min-height: 0; - overflow-y: auto; +.badge, +.status-pill { + display: inline-flex; + min-height: 26px; + align-items: center; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 40%, transparent); + border-radius: var(--radius-tag); + padding: 4px 9px; + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + font-size: 11px; + font-weight: 750; } -.model-connection-modal .modal-header { - position: relative; - align-items: flex-start; - padding-right: 52px; +.status-pill { + background: var(--color-background-paper); } -.model-connection-modal .modal-header > button { - display: inline-flex; - position: absolute; - top: 12px; - right: 12px; - width: 32px; - height: 32px; - align-items: center; - justify-content: center; - padding: 0; +.badge-success { + border-color: transparent; + background: var(--color-status-success-soft); + color: var(--color-status-success); } -.model-connection-modal__footer { - justify-content: stretch; +.badge-warning { + border-color: transparent; + background: color-mix(in srgb, var(--color-accent-lemon) 58%, transparent); + color: var(--color-icon-ember); } -.model-connection-modal__footer-actions { - display: flex; - width: 100%; - align-items: center; - justify-content: space-between; - gap: var(--space-3); +.badge-danger { + border-color: transparent; + background: var(--color-status-error-soft); + color: var(--color-status-error); } -.model-connection-modal__footer-primary { - display: flex; - align-items: center; - gap: var(--space-2); +.badge-info { + color: var(--color-role-assistant); } -.model-editor-fields { +.field { display: grid; - grid-template-columns: minmax(0, 1.5fr) minmax(140px, 1fr); - gap: var(--space-2); + gap: 7px; + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); + font-size: 12px; + font-weight: 700; } -.model-editor-actions { - display: flex; - justify-content: flex-end; - gap: var(--space-2); +.field input, +.field select, +.composer textarea { + width: 100%; + border: 1px solid var(--color-border-stone); + border-radius: var(--radius-input); + padding: 10px 12px; + background: color-mix(in srgb, var(--color-canvas-oat) 30%, var(--color-background-paper)); + color: var(--color-text-ink); + outline: none; } -.model-connection-select__label, -.model-capability-select__label, -.model-editor-fields label { - color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); +.field input:focus, +.field select:focus, +.composer textarea:focus { + outline: none; +} + +.field small, +.field strong, +.composer small, +.account-row small, +.source-row small { + color: color-mix(in srgb, var(--color-text-ink) 50%, transparent); font-size: 12px; - font-weight: 400; - line-height: 1.25; } -.model-connection-modal .auth-code-form-input, -.model-connection-select .select-control__button { - min-height: 46px; - border-color: color-mix(in srgb, var(--color-border-stone) 70%, white); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); - font-size: 14px; - font-weight: 400; - line-height: 20px; +.field strong { + color: var(--color-status-error); } -.model-connection-select .select-control__button { - padding: 12px 42px 12px 20px; +.select-control { + position: relative; + display: grid; + width: 100%; + gap: 7px; } -/* Nested add/edit model row: match list density, not full form controls. */ -.model-connection-modal .model-editor-fields .auth-code-form-input, -.model-capability-select .select-control__button { - min-height: 36px; - height: 36px; - border-color: color-mix(in srgb, var(--color-border-stone) 70%, white); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); +.select-control__label { + color: color-mix(in srgb, var(--color-text-ink) 65%, transparent); font-size: 12px; - font-weight: 400; - line-height: 16px; + font-weight: 700; } -.model-connection-modal .model-editor-fields .auth-code-form-input { - padding: 6px 12px; +.select-control__button { + position: relative; + display: flex; + width: 100%; + min-width: 0; + min-height: 40px; + align-items: center; + border: 1px solid var(--color-border-stone); + border-radius: var(--radius-input); + background: color-mix(in srgb, var(--color-canvas-oat) 30%, var(--color-background-paper)); + color: var(--color-text-ink); + cursor: pointer; + font-family: inherit; + font-size: 14px; + font-weight: 400; + line-height: 1.25; + outline: none; + padding: 10px 42px 10px 14px; + text-align: left; + transition: + background-color var(--duration-fast), + border-color var(--duration-fast), + box-shadow var(--duration-fast); +} + +.select-control__button:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 45%, var(--color-background-paper)); } -.model-capability-select .select-control__button { - padding: 6px 34px 6px 12px; +.select-control__button:focus, +.select-control__button:focus-visible, +.select-control__button[aria-expanded="true"] { + outline: none; + box-shadow: none !important; } -.model-capability-select .select-control__icon { - right: 10px; - width: 14px; - height: 14px; +.select-control__button:disabled { + cursor: not-allowed; + opacity: 0.55; } -.model-capability-select__menu { - top: auto; - right: 0; - bottom: calc(100% + 6px); - left: auto; - width: max-content; - min-width: 100%; - max-width: min(300px, calc(100vw - 48px)); +.select-control__button--disabled-value, +.agent-model-selector__button.select-control__button--disabled-value { + color: color-mix(in srgb, var(--color-text-ink) 42%, transparent); } -.model-capability-select__menu .select-control__option { - min-height: 30px; - padding: 6px 8px; - font-size: 12px; +.select-control__button--disabled-value .select-control__option-icon { + opacity: 0.38; + filter: grayscale(1); } -.model-capability-picker { - position: relative; - min-width: 0; +.select-control__button--disabled-value .select-control__icon { + opacity: 0.42; } -.model-capability-picker__trigger { - display: flex; - width: 100%; - height: 36px; +.select-control__selection, +.select-control__option-content { + display: inline-flex; min-width: 0; - cursor: pointer; - list-style: none; align-items: center; - justify-content: space-between; gap: 8px; +} + +.select-control__selection { + flex: 1 1 auto; + max-width: 100%; overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, white); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); - padding: 6px 10px 6px 12px; - color: var(--color-text-ink); - font-size: 12px; - line-height: 16px; } -.model-capability-picker__trigger::-webkit-details-marker { - display: none; +.select-control__option-content { + flex: 1 1 auto; +} + +.select-control__option-icon { + display: inline-flex; + width: 18px; + height: 18px; + flex: 0 0 18px; + align-items: center; + justify-content: center; } -.model-capability-picker__value { +.select-control__value { + display: block; + flex: 1 1 auto; + max-width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.model-capability-picker__trigger svg { - flex: 0 0 auto; - transition: transform 160ms ease; +.select-control__value--placeholder { + color: color-mix(in srgb, var(--color-text-ink) 44%, transparent); } -.model-capability-picker[open] .model-capability-picker__trigger svg { - transform: rotate(180deg); +.select-control__icon { + position: absolute; + top: 50%; + right: 14px; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + pointer-events: none; + transform: translateY(-50%); + transition: + color var(--duration-fast), + transform var(--duration-fast); } -.model-capability-picker__menu { +.select-control__button[aria-expanded="true"] .select-control__icon { + color: var(--color-action-sky-hover); + transform: translateY(-50%) rotate(180deg); +} + +.select-control__menu { position: absolute; + top: calc(100% + 6px); right: 0; - bottom: calc(100% + 6px); - z-index: 40; - width: 220px; - max-width: min(280px, calc(100vw - 48px)); - overflow: hidden; + left: 0; + z-index: 80; + max-height: 242px; + overflow: auto; border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); - border-radius: var(--radius-lg); + border-radius: var(--radius-input); background: var(--color-background-paper); - box-shadow: 0 12px 30px rgb(47 42 35 / 14%); + box-shadow: var(--shadow-popover); padding: 6px; } -.model-capability-picker__text-group { - display: grid; - gap: 2px; - border: 0; - margin: 0; - padding: 0 0 6px; +.select-control--placement-top .select-control__menu { + top: auto; + bottom: calc(100% + 8px); } -.model-capability-picker__text-group legend { - width: 100%; - padding: 5px 8px 4px; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - font-size: 10px; - line-height: 14px; +.chat-model-select { + width: max-content; + min-width: 0; + max-width: min(240px, 26vw); + flex: 0 1 auto; } -.model-capability-picker__option, -.model-capability-picker__type { - display: flex; +.chat-model-select .chat-model-select__button { min-height: 30px; - cursor: pointer; - align-items: center; - gap: 8px; - border: 0; - border-radius: var(--radius-md); + padding: 6px 28px 6px 8px; + border-color: transparent; + border-radius: 10px; background: transparent; - padding: 6px 8px; - color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); - font-size: 12px; - line-height: 16px; - text-align: left; + color: color-mix(in srgb, var(--color-text-ink) 68%, transparent); + font-size: 11px; } -.model-capability-picker__option:hover, -.model-capability-picker__type:hover { - background: color-mix(in srgb, var(--color-action-sky) 8%, transparent); +.chat-model-select .chat-model-select__button:hover { + border-color: transparent; + background: color-mix(in srgb, var(--color-canvas-oat) 62%, var(--color-background-paper)); } -.model-capability-picker__option input { +.chat-model-select .select-control__icon { + right: 10px; width: 14px; height: 14px; - accent-color: var(--color-action-sky); } -.model-capability-picker__single-types { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 3px; - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); - padding-top: 6px; +.chat-model-select .chat-model-select__menu { + right: 0; + left: auto; + width: max-content; + min-width: 100%; + max-width: min(320px, calc(100vw - 32px)); + max-height: min(240px, 40vh); + padding: 6px; + border-color: color-mix(in srgb, var(--color-border-stone) 46%, transparent); + border-radius: 14px; + box-shadow: var(--shadow-popover); } -.model-capability-picker__type { - min-width: 0; - justify-content: center; - padding-inline: 5px; - white-space: nowrap; +.chat-model-select .select-control__option { + min-height: 36px; + border-radius: 9px; + color: color-mix(in srgb, var(--color-text-ink) 72%, transparent); + font-size: 12px; } -.model-capability-picker__type--selected { - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); - color: var(--color-action-sky); +.chat-model-select .select-control__selection, +.chat-model-select .select-control__option-content { + gap: 7px; } -.task-model-selection-summary { - max-width: 360px; - margin: 4px 0 0; +.chat-model-select .select-control__option-icon { + width: 1.25em; + height: 1.25em; + flex-basis: 1.25em; overflow: hidden; - color: color-mix(in srgb, var(--color-action-sky) 78%, var(--color-text-ink)); - font-size: 11px; - line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; } -.task-model-picker { - margin: 6px 14px 12px; - padding: 10px; - border-radius: var(--radius-card); - background: color-mix(in srgb, var(--color-canvas-oat) 55%, transparent); +.llm-provider-logo { + display: block; + width: 100%; + height: 100%; + object-fit: contain; } -.task-model-picker__group + .task-model-picker__group { - margin-top: 10px; +/* The copied app icon has a 30px transparent inset in its 512px canvas. */ +.llm-provider-logo[data-provider="memmy_account"] { + transform: scale(1.133); } -.task-model-picker__group-title { - display: flex; - align-items: center; - gap: 6px; - padding: 0 4px 4px; - color: color-mix(in srgb, var(--color-text-ink) 45%, transparent); - font-size: 10px; +.chat-model-select .select-control__option:hover:not(.select-control__option--selected), +.chat-model-select .select-control__option--active { + background: color-mix(in srgb, var(--color-action-sky) 8%, var(--color-background-paper)); +} + +.chat-model-select .select-control__option--selected { + background: color-mix(in srgb, var(--color-action-sky) 12%, var(--color-background-paper)); + color: var(--color-action-sky-hover); font-weight: 600; } -.task-model-picker__option { +.select-control__group-label { + padding: 7px 9px 4px; + color: color-mix(in srgb, var(--color-text-ink) 46%, transparent); + font-size: 11px; + font-weight: 800; + line-height: 1.2; +} + +.select-control__option { display: flex; width: 100%; - min-height: 32px; + min-height: 34px; align-items: center; - gap: 8px; - padding: 6px 8px; + justify-content: space-between; + gap: 10px; border: 0; - border-radius: var(--radius-input); + border-radius: 7px; background: transparent; - color: color-mix(in srgb, var(--color-text-ink) 70%, transparent); - cursor: pointer; - font-family: inherit; - font-size: 12px; -} - -.task-model-picker__choice { - display: flex; - min-width: 0; - flex: 1 1 auto; - align-items: center; - gap: 8px; - text-align: left; -} - -.task-model-picker__default-button { - margin-left: 8px; - display: inline-flex; - min-height: 24px; - align-items: center; - justify-content: center; - padding: 3px 9px; - border-width: 1px; - border-style: solid; - border-color: color-mix(in srgb, var(--color-action-sky) 42%, transparent); - border-radius: var(--radius-pill); - background: var(--color-background-paper); - color: var(--color-action-sky-hover); + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); cursor: pointer; font-family: inherit; - font-size: 10px; - font-weight: 600; - line-height: 1; - transition: border-color var(--duration-fast), background var(--duration-fast), color var(--duration-fast); + font-size: 13px; + font-weight: 400; + line-height: 1.25; + padding: 8px 9px; + text-align: left; + transition: + background-color var(--duration-fast), + color var(--duration-fast); } -.task-model-picker__default-button:hover { - border-color: var(--color-action-sky); - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); +.select-control__option-label { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.task-model-picker__default-button[aria-pressed="true"] { - border-color: var(--color-action-sky); - background: var(--color-action-sky); - color: white; - font-weight: 700; +.select-control__option:hover:not(:disabled), +.select-control__option--active:not(:disabled):not(.select-control__option--selected) { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); + color: var(--color-text-ink); } -.task-model-picker__default-button[aria-pressed="true"]:hover { - border-color: var(--color-action-sky-hover); - background: var(--color-action-sky-hover); +.select-control__option--selected { + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 76%, transparent); } -.task-model-picker__default-button:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-action-sky) 55%, transparent); - outline-offset: 2px; +.select-control__option--selected:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-oat) 72%, transparent); + color: var(--color-text-ink); } -.task-model-picker__option:hover:not(:disabled) { - background: var(--color-background-paper); +.select-control__option-check { + flex: 0 0 auto; + color: var(--color-action-sky); } -.task-model-picker__option:disabled { +.select-control__option:disabled { cursor: not-allowed; - opacity: 0.65; + opacity: 0.45; } -.task-model-picker__checkbox { - display: inline-flex; - width: 16px; - height: 16px; - flex: 0 0 16px; - align-items: center; - justify-content: center; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, transparent); - border-radius: 4px; +.select-control--paper .select-control__button { background: var(--color-background-paper); } -.task-model-picker__checkbox.is-selected { - border-color: var(--color-action-sky); - background: var(--color-action-sky); - color: white; +.select-control--subtle .select-control__button { + border-color: color-mix(in srgb, var(--color-border-stone) 44%, transparent); + background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); } .model-assignment-label-row { @@ -7272,12 +7394,20 @@ input[type="checkbox"].check-sky:checked::after { } .provider-model-list { + --provider-model-row-height: 41px; + --provider-model-visible-rows: 6; + + width: 100%; max-width: 100%; - max-height: 220px; + max-height: calc(var(--provider-model-row-height) * var(--provider-model-visible-rows)); overflow-x: hidden; overflow-y: auto; } +.provider-model-list__row { + height: var(--provider-model-row-height); +} + .modal.model-connection-modal { display: flex; width: min(600px, calc(100vw - 64px)); @@ -7413,130 +7543,6 @@ input[type="checkbox"].check-sky:checked::after { font-size: 12px; } -.model-capability-picker { - position: relative; - min-width: 0; -} - -.model-capability-picker__trigger { - display: flex; - width: 100%; - height: 36px; - min-width: 0; - cursor: pointer; - list-style: none; - align-items: center; - justify-content: space-between; - gap: 8px; - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 70%, white); - border-radius: var(--radius-lg); - background: color-mix(in srgb, var(--color-canvas-oat) 30%, transparent); - padding: 6px 10px 6px 12px; - color: var(--color-text-ink); - font-size: 12px; - line-height: 16px; -} - -.model-capability-picker__trigger::-webkit-details-marker { - display: none; -} - -.model-capability-picker__value { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.model-capability-picker__trigger svg { - flex: 0 0 auto; - transition: transform 160ms ease; -} - -.model-capability-picker[open] .model-capability-picker__trigger svg { - transform: rotate(180deg); -} - -.model-capability-picker__menu { - position: absolute; - right: 0; - bottom: calc(100% + 6px); - z-index: 40; - width: 220px; - max-width: min(280px, calc(100vw - 48px)); - overflow: hidden; - border: 1px solid color-mix(in srgb, var(--color-border-stone) 72%, transparent); - border-radius: var(--radius-lg); - background: var(--color-background-paper); - box-shadow: 0 12px 30px rgb(47 42 35 / 14%); - padding: 6px; -} - -.model-capability-picker__text-group { - display: grid; - gap: 2px; - border: 0; - margin: 0; - padding: 0 0 6px; -} - -.model-capability-picker__text-group legend { - width: 100%; - padding: 5px 8px 4px; - color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); - font-size: 10px; - line-height: 14px; -} - -.model-capability-picker__option, -.model-capability-picker__type { - display: flex; - min-height: 30px; - cursor: pointer; - align-items: center; - gap: 8px; - border: 0; - border-radius: var(--radius-md); - background: transparent; - padding: 6px 8px; - color: color-mix(in srgb, var(--color-text-ink) 82%, transparent); - font-size: 12px; - line-height: 16px; - text-align: left; -} - -.model-capability-picker__option:hover, -.model-capability-picker__type:hover { - background: color-mix(in srgb, var(--color-action-sky) 8%, transparent); -} - -.model-capability-picker__option input { - width: 14px; - height: 14px; - accent-color: var(--color-action-sky); -} - -.model-capability-picker__single-types { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 3px; - border-top: 1px solid color-mix(in srgb, var(--color-border-stone) 45%, transparent); - padding-top: 6px; -} - -.model-capability-picker__type { - min-width: 0; - justify-content: center; - padding-inline: 5px; - white-space: nowrap; -} - -.model-capability-picker__type--selected { - background: color-mix(in srgb, var(--color-action-sky) 10%, transparent); - color: var(--color-action-sky); -} - .task-model-selection-summary { max-width: 360px; margin: 4px 0 0; @@ -7595,11 +7601,11 @@ input[type="checkbox"].check-sky:checked::after { } .task-model-picker__default-button { - margin-left: 8px; display: inline-flex; min-height: 24px; align-items: center; justify-content: center; + margin-left: 8px; padding: 3px 9px; border-width: 1px; border-style: solid; @@ -10066,6 +10072,12 @@ input[type="checkbox"].check-sky:checked::after { padding: 4px 8px 6px; } +.app-frame-sidebar-footer--compound { + display: flex; + align-items: center; + gap: 4px; +} + .app-frame-sidebar-footer--button { display: block; width: 100%; @@ -10084,6 +10096,12 @@ input[type="checkbox"].check-sky:checked::after { transition: background-color 120ms ease; } +.app-frame-sidebar-footer-account { + flex: 1 1 auto; + min-width: 0; + width: auto; +} + .app-frame-sidebar-footer--button:hover, .app-frame-sidebar-footer--button:focus-visible { background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); @@ -10134,11 +10152,128 @@ input[type="checkbox"].check-sky:checked::after { border-radius: 6px; } +.app-frame-profile-settings-button { + border: 0; + appearance: none; + background: transparent; + cursor: pointer; + font-family: inherit; +} + +.app-frame-profile-settings-button:hover, +.app-frame-profile-settings-button:focus-visible { + background: color-mix(in srgb, var(--color-canvas-oat) 60%, transparent); +} + +.app-frame-profile-settings-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 55%, transparent); + outline-offset: -2px; +} + .app-frame-profile-settings svg { width: 14px; height: 14px; } +.app-frame-sidebar-update-button { + display: inline-flex; + flex: 0 0 auto; + align-items: center; + justify-content: center; + gap: 4px; + height: 32px; + min-width: 32px; + max-width: 86px; + padding: 0 10px; + border: 0; + border-radius: 999px; + appearance: none; + background: #2f8fff; + color: #fff; + cursor: pointer; + font-family: inherit; + font-size: 12px; + font-weight: 700; + line-height: 1; + letter-spacing: 0; + white-space: nowrap; + box-shadow: 0 4px 10px rgba(47, 143, 255, 0.22); + transition: background-color 120ms ease, box-shadow 120ms ease, min-width 120ms ease, padding 120ms ease; +} + +.app-frame-sidebar-update-button:hover, +.app-frame-sidebar-update-button:focus-visible { + background: #1d7df2; + box-shadow: 0 6px 14px rgba(47, 143, 255, 0.28); +} + +.app-frame-sidebar-update-button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-action-sky) 55%, transparent); + outline-offset: 2px; +} + +.app-frame-sidebar-update-button:disabled { + cursor: default; + opacity: 1; +} + +.app-frame-sidebar-update-button svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.app-frame-sidebar-update-button__label { + display: inline-block; + overflow: hidden; + text-overflow: clip; + white-space: nowrap; +} + +.app-frame-sidebar-update-button--available { + width: 32px; + padding: 0; +} + +.app-frame-sidebar-update-button--available .app-frame-sidebar-update-button__label { + max-width: 0; + opacity: 0; + transition: max-width 120ms ease, opacity 120ms ease; +} + +.app-frame-sidebar-update-button--available:hover, +.app-frame-sidebar-update-button--available:focus-visible { + width: auto; + min-width: 54px; + padding: 0 10px; +} + +.app-frame-sidebar-update-button--available:hover .app-frame-sidebar-update-button__label, +.app-frame-sidebar-update-button--available:focus-visible .app-frame-sidebar-update-button__label { + max-width: 40px; + opacity: 1; +} + +.app-frame-sidebar-update-button--downloading { + min-width: 50px; +} + +.app-frame-sidebar-update-button--installing { + min-width: 72px; +} + +.app-frame-sidebar-update-button--prepared { + min-width: 58px; + background: #159a84; + box-shadow: 0 4px 10px rgba(21, 154, 132, 0.2); +} + +.app-frame-sidebar-update-button--prepared:hover, +.app-frame-sidebar-update-button--prepared:focus-visible { + background: #118572; + box-shadow: 0 6px 14px rgba(21, 154, 132, 0.24); +} + .rounded-menu { border-radius: var(--radius-menu); } @@ -11131,6 +11266,7 @@ input[type="checkbox"].check-sky:checked::after { .agent-goal-bar { position: relative; container-type: inline-size; + margin-bottom: 0.5rem; border: 1px solid color-mix(in srgb, var(--color-border-stone) 35%, transparent); border-radius: 0.75rem; background: color-mix(in srgb, var(--color-background-paper) 96%, transparent); diff --git a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts index 533fca659..cb606d84c 100644 --- a/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts +++ b/App/frontend/desktop/src/theme/tests/prototype-page-alignment.test.ts @@ -54,8 +54,9 @@ describe("prototype page structure alignment", () => { expect(source("pages/home-page.tsx")).toContain("max-w-3xl mx-auto space-y-3"); expect(source("pages/home-page.tsx")).toContain("relative agent-composer-shell"); expect(source("pages/home-page.tsx")).not.toContain("relative overflow-hidden agent-composer-shell rounded-card-lg"); - expect(source("pages/home-page.tsx")).toContain('${isComposerSingleLine ? "agent-composer-input--single " : ""}${selectedComposerCommand ? "agent-composer-input--command-selected " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40'); - expect(source("pages/home-page.tsx")).toContain('
'); + expect(source("pages/home-page.tsx")).toContain('${isComposerSingleLine ? "agent-composer-input--single " : ""}agent-composer-input--conversation block w-full pl-4 py-3 text-sm resize-none focus:outline-none rounded-card-lg bg-background-paper placeholder:text-text-ink/40'); + expect(source("pages/home-page.tsx")).not.toContain("agent-composer-input--command-selected"); + expect(source("pages/home-page.tsx")).toContain('
'); expect(source("pages/home-page.tsx")).toContain('className="relative agent-composer-shell agent-composer-shell--expanded rounded-card-lg"'); expect(source("pages/home-page.tsx")).toContain('className="agent-composer-toolbar"'); }); diff --git a/App/memmy-agent/package-lock.json b/App/memmy-agent/package-lock.json index 6b68e9585..e24e480cd 100644 --- a/App/memmy-agent/package-lock.json +++ b/App/memmy-agent/package-lock.json @@ -1,12 +1,12 @@ { "name": "memmy-agent", - "version": "1.0.8", + "version": "1.0.9", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "memmy-agent", - "version": "1.0.8", + "version": "1.0.9", "dependencies": { "@anthropic-ai/sdk": "^0.100.1", "@aws-sdk/client-bedrock-runtime": "^3.1061.0", diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 84f59e623..6a5a3090e 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -1,6 +1,6 @@ { "name": "memmy-agent", - "version": "1.0.8", + "version": "1.0.9", "description": "TypeScript refactor of memmy's agent runtime.", "type": "module", "main": "./dist/index.js", diff --git a/App/memmy-agent/src/analytics/cloud-analytics.ts b/App/memmy-agent/src/analytics/cloud-analytics.ts index d9a39658f..b8480729f 100644 --- a/App/memmy-agent/src/analytics/cloud-analytics.ts +++ b/App/memmy-agent/src/analytics/cloud-analytics.ts @@ -1,28 +1,37 @@ -import { existsSync as nodeExistsSync, readFileSync as nodeReadFileSyncFs } from "node:fs"; +import { randomUUID } from "node:crypto"; +import { + existsSync as nodeExistsSync, + mkdirSync as nodeMkdirSync, + readFileSync as nodeReadFileSyncFs, + writeFileSync as nodeWriteFileSync, +} from "node:fs"; import { homedir as nodeHomedir } from "node:os"; import { join as nodeJoin } from "node:path"; import { loadConfig } from "../config/loader.js"; +import { VERSION } from "../version.js"; export type AnalyticsAppEnv = "dev" | "prod"; export type AnalyticsAppEdition = "cn" | "intl"; -export type AnalyticsUserMode = "account" | "byok"; +export type AnalyticsUserMode = "account" | "account_byok" | "byok"; export type AnalyticsParams = Record; export type AnalyticsEventInput = { eventName: string; params?: AnalyticsParams; - /** Epoch millis; converted to params.timestamp_micros for the batch API. */ + /** Epoch millis; serialized as `params.timestamp_micros`. */ eventTimeMillis?: number; + source?: string; }; export type PostAnalyticsEventsInput = { events: AnalyticsEventInput[]; - /** GA4 / install client id (request body `clientId`). */ + installationId?: string | null; + /** Optional GA4 client id, serialized as `analytics_client_id`. */ clientId?: string | null; - /** Optional GA4 user id: request body `userId` (with `clientId`) and each event's `params.user_id`. */ + /** Optional original account user id, serialized as `user_id`. */ userId?: string | null; - /** account | byok; unset/unknown omitted from params. */ + /** account | account_byok | byok; unset/unknown omitted. */ userMode?: string | null; appEnv?: AnalyticsAppEnv | null; appEdition?: AnalyticsAppEdition | null; @@ -44,6 +53,7 @@ export type QueuedAnalytics = { const DEFAULT_ENGAGEMENT_TIME_MSEC = 100; const ANALYTICS_PATH = "/api/analytics/events"; const CLIENT_ID_FILENAME = "analytics-client-id"; +const INSTALLATION_ID_FILENAME = "installation-id"; export function resolveAnalyticsBaseUrl(env: NodeJS.ProcessEnv = process.env): string | null { const raw = env.MEMMY_CLOUD_SERVICE?.trim(); @@ -136,6 +146,30 @@ export function readAnalyticsClientId(options: { } } +export function getOrCreateInstallationId(options: { + env?: NodeJS.ProcessEnv; + homeDir?: string; +} = {}): string { + const homeDir = options.homeDir ?? nodeHomedir(); + const memmyHome = (options.env?.MEMMY_HOME?.trim() || nodeJoin(homeDir, ".memmy")).replace( + /^~(?=$|[/\\])/, + homeDir, + ); + const filePath = nodeJoin(memmyHome, INSTALLATION_ID_FILENAME); + try { + if (nodeExistsSync(filePath)) { + const existing = nodeReadFileSyncFs(filePath, "utf8").trim(); + if (existing) return existing; + } + } catch { + // Recreate an unreadable or empty identifier below. + } + const installationId = randomUUID(); + nodeMkdirSync(memmyHome, { recursive: true }); + nodeWriteFileSync(filePath, `${installationId}\n`, "utf8"); + return installationId; +} + export function normalizeAnalyticsUserId(userId: string | null | undefined): string | null { const trimmed = userId?.trim() || null; if (!trimmed || trimmed === "local-user") return null; @@ -145,14 +179,16 @@ export function normalizeAnalyticsUserId(userId: string | null | undefined): str export function resolveAnalyticsUserMode( mode: string | null | undefined, ): AnalyticsUserMode | null { - return mode === "account" || mode === "byok" ? mode : null; + return mode === "account" || mode === "account_byok" || mode === "byok" ? mode : null; } export function resolveAnalyticsUserModeParams( mode: string | null | undefined, + userId?: string | null, ): AnalyticsParams { const resolved = resolveAnalyticsUserMode(mode); - return resolved ? { user_mode: resolved } : {}; + if (!userId) return { user_mode: "byok" }; + return { user_mode: resolved === "byok" || resolved === "account_byok" ? "account_byok" : "account" }; } export function resolveLoggedInAnalyticsUserId( @@ -193,17 +229,20 @@ export function resolveAnalyticsUserModeFromConfig( : ""; const cloudUuid = typeof app?.cloudUuid === "string" ? app.cloudUuid.trim() : ""; const userId = typeof app?.userId === "string" ? app.userId.trim() : ""; - if (cloudUuid && owner && (!userId || owner === userId)) return "account"; + const hasAccount = Boolean(cloudUuid && owner && (!userId || owner === userId)); const byok = assignments?.byok; const candidates = Array.isArray(byok?.agent?.candidates) ? byok.agent.candidates : []; - if (candidates.length || [ + const hasByok = Boolean(candidates.length || [ byok?.memorySummary, byok?.memoryEvolution, byok?.embedding, byok?.asr, byok?.imageGeneration, - ].some((value) => typeof value === "string" && value.trim())) return "byok"; + ].some((value) => typeof value === "string" && value.trim())); + if (hasAccount && hasByok) return "account_byok"; + if (hasAccount) return "account"; + if (hasByok) return "byok"; return null; } @@ -243,19 +282,19 @@ export function toTimestampMicros(eventTimeMillis: number): number { } /** - * POST batched analytics events (no auth): - * `{ clientId, userId?, events: [{ eventName, params }] }`. + * POST batched analytics events (no auth). */ export function postAnalyticsEvents(input: PostAnalyticsEventsInput): Promise { const clientId = input.clientId?.trim() || null; + const installationId = input.installationId?.trim() || getOrCreateInstallationId({ env: input.env }); const userId = normalizeAnalyticsUserId(input.userId); - const userModeParams = resolveAnalyticsUserModeParams(input.userMode); + const userModeParams = resolveAnalyticsUserModeParams(input.userMode, userId); const env = input.env ?? process.env; const resolvedBase = input.baseUrl !== undefined ? input.baseUrl : resolveAnalyticsBaseUrl(env); const baseUrl = resolvedBase?.replace(/\/+$/, "") || null; const events = Array.isArray(input.events) ? input.events : []; - if (!baseUrl || !clientId || events.length === 0) { + if (!baseUrl || !installationId || events.length === 0) { return Promise.resolve(); } @@ -267,18 +306,21 @@ export function postAnalyticsEvents(input: PostAnalyticsEventsInput): Promise { const eventTimeMillis = event.eventTimeMillis ?? Date.now(); return { eventName: event.eventName, params: compactAnalyticsParams({ engagement_time_msec: DEFAULT_ENGAGEMENT_TIME_MSEC, + source: event.source ?? "memmy-agent", ...userModeParams, ...(event.params ?? {}), ...(userId ? { user_id: userId } : {}), ...envParams, + app_version: VERSION, timestamp_micros: toTimestampMicros(eventTimeMillis), }), }; @@ -314,6 +356,7 @@ export function trackAnalyticsEvent(input: TrackAnalyticsEventInput): Promise string | null | undefined; + getInstallationId?: () => string | null | undefined; getUserId?: () => string | null | undefined; getUserMode?: () => string | null | undefined; source?: string; @@ -326,6 +369,7 @@ export function createQueuedAnalytics(options: { } = {}): QueuedAnalytics { const source = options.source; const getClientId = options.getClientId ?? (() => readAnalyticsClientId({ env: options.env })); + const getInstallationId = options.getInstallationId ?? (() => getOrCreateInstallationId({ env: options.env })); const getUserId = options.getUserId ?? (() => null); const getUserMode = options.getUserMode ?? (() => null); let pending: AnalyticsEventInput[] = []; @@ -338,7 +382,8 @@ export function createQueuedAnalytics(options: { lastEventTimeMillis = eventTimeMillis; pending.push({ eventName, - params: source ? { source, ...params } : { ...params }, + params: { ...params }, + source, eventTimeMillis, }); }; @@ -352,6 +397,7 @@ export function createQueuedAnalytics(options: { postAnalyticsEvents({ events: batch, clientId: getClientId(), + installationId: getInstallationId(), userId: getUserId(), userMode: getUserMode(), appEnv: options.appEnv, diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 1f93ce21a..d3e6c0960 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -1030,6 +1030,7 @@ export class AgentLoop { contextWindowTokens: selection.snapshot.contextWindowTokens, modelPreset: selection.preset, modelProvider: selection.provider, + actualModelContext: persistedModelSelection(selection), }), }; } @@ -1626,6 +1627,40 @@ export class AgentLoop { return lock; } + private async tryAdmitDirectSteer( + sessionKey: string, + message: InboundMessage, + ): Promise { + if (message.turnAdmission !== "steer") return null; + const route = `${message.channel}\0${String(message.chatId)}`; + const requestedSource = sharedTurnSource(message); + return this.queueMutationLockFor(sessionKey).runExclusive(async () => { + const activeSlot = (this.turnSlots.get(sessionKey) ?? []).find((slot) => { + if ( + slot.state !== "running" + || !slot.acceptingSteer + || slot.route !== route + ) return false; + if (requestedSource?.kind !== "tui") return true; + const activeSource = sharedTurnSource(slot.root); + return slot.turnId === message.expectedTurnId + && activeSource?.kind === "tui" + && activeSource.channel === requestedSource.channel; + }); + if (!activeSlot) return null; + const steer = this.cloneInboundMessage( + message, + requestedSource?.kind === "tui" ? { turnId: activeSlot.turnId } : {}, + ); + try { + activeSlot.pendingSteer.put(steer); + return activeSlot.turnId; + } catch { + return null; + } + }); + } + private queueRevision(sessionKey: string): number { return this.queueRevisionBySession.get(sessionKey) ?? 0; } @@ -4983,6 +5018,18 @@ export class AgentLoop { await this.dispatchCommandInline(msg, effectiveKey, raw, (ctx) => this.commands.dispatch(ctx)); continue; } + const shouldTryEarlyTuiSteer = msg.turnAdmission === "steer" + && sharedTurnSource(msg)?.kind === "tui" + && !msg.content.trimStart().startsWith("/"); + let didTryDirectSteer = false; + if (shouldTryEarlyTuiSteer) { + didTryDirectSteer = true; + const steeredTurnId = await this.tryAdmitDirectSteer(effectiveKey, msg); + if (steeredTurnId) { + await this.publishWebuiMessageSteered(msg, steeredTurnId); + continue; + } + } const goal = this.goalRuntime.get(effectiveKey); const shouldPersistGoalInbox = !msg.internal && !ownsGoalReservation @@ -5038,34 +5085,9 @@ export class AgentLoop { await this.dispatchCommandInline(msg, effectiveKey, raw, (ctx) => this.commands.dispatch(ctx)); continue; } - if (msg.turnAdmission === "steer") { - let steeredTurnId: string | null = null; + if (msg.turnAdmission === "steer" && !didTryDirectSteer) { const requestedSource = sharedTurnSource(msg); - await this.queueMutationLockFor(effectiveKey).runExclusive(async () => { - const currentSlots = this.turnSlots.get(effectiveKey) ?? []; - const activeSlot = currentSlots.find((slot) => ( - slot.state === "running" - && slot.acceptingSteer - && slot.route === route - && ( - requestedSource?.kind === "tui" - ? slot.turnId === msg.expectedTurnId - && sharedTurnSource(slot.root)?.kind === "tui" - : true - ) - )); - if (!activeSlot) return; - const steer = this.cloneInboundMessage( - msg, - requestedSource?.kind === "tui" ? { turnId: activeSlot.turnId } : {}, - ); - try { - activeSlot.pendingSteer.put(steer); - steeredTurnId = activeSlot.turnId; - } catch { - // A closing active Slot safely falls back to a queued Turn. - } - }); + const steeredTurnId = await this.tryAdmitDirectSteer(effectiveKey, msg); if (steeredTurnId) { if (requestedSource?.kind === "tui") { await this.publishWebuiMessageSteered(msg, steeredTurnId); diff --git a/App/memmy-agent/src/core/agent-runtime/memory.ts b/App/memmy-agent/src/core/agent-runtime/memory.ts index f023bd5ee..c673849df 100644 --- a/App/memmy-agent/src/core/agent-runtime/memory.ts +++ b/App/memmy-agent/src/core/agent-runtime/memory.ts @@ -6,7 +6,12 @@ import { CONTEXT_SAFETY_BUFFER_TOKENS } from "../../token-budget.js"; import { GitStore } from "../../utils/gitstore.js"; import { ensureDir, estimatePromptTokensChain, stripThink, truncateText } from "../../utils/helpers.js"; import { renderTemplate } from "../../utils/prompt-templates.js"; -import { DagSnapshotBuilder, SessionDagStore, type SessionDagQueueManager } from "../../session-dag/index.js"; +import { + DagSnapshotBuilder, + SessionDagStore, + sessionDagDbPath, + type SessionDagQueueManager, +} from "../../session-dag/index.js"; import { AgentHook, AgentHookContext } from "./hook.js"; import { AgentRunner, AgentRunSpec } from "./runner.js"; import { EditFileTool, ReadFileTool, WriteFileTool } from "./tools/filesystem.js"; @@ -43,6 +48,48 @@ type TokenCompactionOptions = { notifyOnLockWait?: boolean; }; +type DagCompactionFailureRecord = { + event: "session_dag_compaction_failed"; + timestamp: string; + platform: NodeJS.Platform; + pid: number; + sessionKey: string; + databasePath: string; + summaryMode: "dag"; + errorName: string; + errorCode: string | null; + message: string; + stack: string | null; +}; + +function dagCompactionFailureRecord(sessionKey: string, caught: unknown): DagCompactionFailureRecord { + const errorName = caught instanceof Error + ? caught.name.trim() || "Error" + : typeof caught; + let errorCode: string | null = null; + if (caught && typeof caught === "object" && "code" in caught) { + const code = (caught as { code?: unknown }).code; + if (typeof code === "string" || typeof code === "number") errorCode = String(code); + } + const message = caught instanceof Error ? caught.message : String(caught); + const stack = caught instanceof Error && typeof caught.stack === "string" && caught.stack.trim() + ? caught.stack + : null; + return { + event: "session_dag_compaction_failed", + timestamp: new Date().toISOString(), + platform: process.platform, + pid: process.pid, + sessionKey, + databasePath: sessionDagDbPath(sessionKey), + summaryMode: "dag", + errorName, + errorCode, + message, + stack, + }; +} + export interface HistoryEntry { cursor?: unknown; timestamp?: string; @@ -1086,7 +1133,9 @@ export class Consolidator { }); } } catch (caught) { - error = caught instanceof Error ? caught.message : String(caught); + const failure = dagCompactionFailureRecord(sessionKey, caught); + error = failure.message; + console.error("[session-dag] compaction failed", JSON.stringify(failure)); if (started) { await emitCompactionEvent({ kind: "token", diff --git a/App/memmy-agent/src/core/agent-runtime/runner.ts b/App/memmy-agent/src/core/agent-runtime/runner.ts index 649cc1ae0..5033d7dff 100644 --- a/App/memmy-agent/src/core/agent-runtime/runner.ts +++ b/App/memmy-agent/src/core/agent-runtime/runner.ts @@ -1,7 +1,11 @@ import crypto from "node:crypto"; import path from "node:path"; -import { FallbackProvider } from "../../providers/fallback-provider.js"; import { LLMProvider, LLMResponse, ToolCallRequest } from "../../providers/base.js"; +import { + coversInputModalities, + getModelInputModalities, + requiredInputModalities, +} from "../../providers/model-input-capabilities.js"; import type { ActualModelContext } from "@memmy/local-api-contracts"; import { CONTEXT_SAFETY_BUFFER_TOKENS } from "../../token-budget.js"; import { ToolRegistry } from "./tools/registry.js"; @@ -531,7 +535,7 @@ export class AgentRunner { private canRunAccountImageTextFallback( spec: AgentRunSpec, - response: LLMResponse, + actualProvider: string | null | undefined, ): boolean { const provider = spec.provider ?? this.provider; if ( @@ -540,9 +544,9 @@ export class AgentRunner { || !provider.supportsAccountImageTextFallback() ) return false; const modelContext = spec.actualModelContext; - if (!modelContext) return !(provider instanceof FallbackProvider); + if (!modelContext) return false; if (modelContext.source !== "account" || modelContext.provider !== "memmy_account") return false; - return response.actualProvider == null || response.actualProvider === "memmy_account"; + return actualProvider == null || actualProvider === "memmy_account"; } private async requestAccountImageText( @@ -615,6 +619,121 @@ export class AgentRunner { } } + private combinedResponseUsage(...responses: Array): Record { + const usage: Record = {}; + for (const response of responses) this.accumulateUsage(usage, this.usageDict(response?.usage)); + return usage; + } + + private async requestModelWithImagePolicy( + spec: AgentRunSpec, + messages: Record[], + hook: AgentHook, + context: AgentHookContext, + imageTextState: TurnImageTextState, + options: ModelRequestOptions = {}, + ): Promise { + if (spec.abortSignal?.aborted) return abortedResponse(); + + const messagesForModel = this.prepareImageMessages(messages, imageTextState); + const model = spec.actualModelContext?.model ?? spec.model; + const required = requiredInputModalities(messagesForModel); + const supported = getModelInputModalities(model); + const missing = required.filter((modality) => !supported.includes(modality)); + const canUseAccountFallback = missing.length === 1 + && missing[0] === "image" + && this.canRunAccountImageTextFallback(spec, null); + + let initialResponse: LLMResponse | null = null; + if (coversInputModalities(supported, required)) { + initialResponse = await this.requestModel(spec, messagesForModel, hook, context, options); + const canRecoverExplicitRejection = initialResponse.errorCategory === "image_input_unsupported" + && !context.streamedContent + && !context.streamedReasoning + && initialResponse.toolCalls.length === 0 + && this.canRunAccountImageTextFallback(spec, initialResponse.actualProvider); + if (!canRecoverExplicitRejection) return initialResponse; + } else if (!canUseAccountFallback) { + return new LLMResponse({ + content: "Current model does not support image input.", + finishReason: "error", + errorCategory: "image_input_unsupported", + errorShouldRetry: false, + actualProvider: spec.actualModelContext?.provider ?? null, + actualModel: model ?? null, + }); + } + + const pendingBatch = this.collectPendingImageBatch(messagesForModel, imageTextState); + if (!pendingBatch) { + return initialResponse ?? new LLMResponse({ + content: "Current model does not support image input.", + finishReason: "error", + errorCategory: "image_input_unsupported", + errorShouldRetry: false, + actualProvider: spec.actualModelContext?.provider ?? null, + actualModel: model ?? null, + }); + } + + const mainActualProvider = initialResponse?.actualProvider + ?? spec.actualModelContext?.provider + ?? null; + const mainActualModel = initialResponse?.actualModel + ?? model + ?? null; + const imageResponse = await this.requestAccountImageText(spec, pendingBatch); + if (spec.abortSignal?.aborted || imageResponse.errorKind === "aborted") { + const response = abortedResponse(); + response.usage = this.combinedResponseUsage(initialResponse, imageResponse); + return response; + } + if ( + imageResponse.finishReason === "error" + || isBlankText(imageResponse.content) + || imageResponse.toolCalls.length > 0 + ) { + const failureDetail = imageResponse.finishReason === "error" + ? imageResponse.content + : isBlankText(imageResponse.content) + ? "image2text returned an empty description" + : "image2text returned an unexpected tool call"; + return new LLMResponse({ + content: failureDetail || "Image analysis failed.", + finishReason: "error", + usage: this.combinedResponseUsage(initialResponse, imageResponse), + errorStatusCode: imageResponse.errorStatusCode, + errorKind: imageResponse.errorKind, + errorType: imageResponse.errorType, + errorCode: imageResponse.errorCode, + errorRetryAfterS: imageResponse.errorRetryAfterS, + errorShouldRetry: false, + actualProvider: mainActualProvider, + actualModel: mainActualModel, + failedProvider: "memmy_account", + failedModel: "image2text", + errorCategory: imageResponse.errorCategory === "quota_exhausted" + ? "quota_exhausted" + : "image_analysis_failed", + }); + } + + const batchId = imageTextState.nextBatchId; + imageTextState.nextBatchId += 1; + const description = AgentRunner.safeImageAnalysisDescription(imageResponse.content ?? ""); + for (const record of pendingBatch.records) record.batchId = batchId; + imageTextState.batches.set(batchId, { + id: batchId, + identities: pendingBatch.records.map((record) => record.identity), + description, + }); + + const retryMessages = this.prepareImageMessages(messagesForModel, imageTextState); + const retryResponse = await this.requestModel(spec, retryMessages, hook, context, options); + retryResponse.usage = this.combinedResponseUsage(initialResponse, imageResponse, retryResponse); + return retryResponse; + } + private static terminalImageError(response: LLMResponse): boolean { return response.finishReason === "error" && ( response.errorCategory === "image_input_unsupported" @@ -779,18 +898,18 @@ export class AgentRunner { } } - private async requestFinalizationRetry(spec: AgentRunSpec, messages: Record[]): Promise { - const provider = spec.provider ?? this.provider; - if (!provider) throw new Error("AgentRunSpec.provider is required"); + private async requestFinalizationRetry( + spec: AgentRunSpec, + messages: Record[], + hook: AgentHook, + context: AgentHookContext, + imageTextState: TurnImageTextState, + ): Promise { const retryMessages = [...messages, buildFinalizationRetryMessage()]; - const args = this.buildRequestArgs(spec, retryMessages, null); - if (spec.abortSignal) args.signal = spec.abortSignal; - try { - return await withAbort(this.callProvider(provider, args, false), spec.abortSignal); - } catch (error) { - if (isAbortError(error)) return abortedResponse(); - throw error; - } + return this.requestModelWithImagePolicy(spec, retryMessages, hook, context, imageTextState, { + toolsOverride: null, + forceNonStreaming: true, + }); } private usageDict(usage: Record | null | undefined): Record { @@ -1290,8 +1409,14 @@ export class AgentRunner { const context = new AgentHookContext({ spec, messages, iteration, usage }); await hook.beforeIteration(context); - response = await this.requestModel(spec, messagesForModel, hook, context); - let iterationUsage = this.usageDict(response.usage); + response = await this.requestModelWithImagePolicy( + spec, + messagesForModel, + hook, + context, + imageTextState, + ); + const iterationUsage = this.usageDict(response.usage); this.accumulateUsage(usage, iterationUsage); if (spec.abortSignal?.aborted || response.errorKind === "aborted") { finalContent = "Error: task cancelled"; @@ -1304,81 +1429,6 @@ export class AgentRunner { break; } - if ( - response.errorCategory === "image_input_unsupported" - && !context.streamedContent - && !context.streamedReasoning - && response.toolCalls.length === 0 - && this.canRunAccountImageTextFallback(spec, response) - ) { - const pendingBatch = this.collectPendingImageBatch(messagesForModel, imageTextState); - if (pendingBatch) { - const mainActualProvider = response.actualProvider - ?? spec.actualModelContext?.provider - ?? null; - const mainActualModel = response.actualModel - ?? spec.actualModelContext?.model - ?? spec.model - ?? null; - const imageResponse = await this.requestAccountImageText(spec, pendingBatch); - const imageUsage = this.usageDict(imageResponse.usage); - this.accumulateUsage(usage, imageUsage); - iterationUsage = this.mergeUsage(iterationUsage, imageUsage); - - if (spec.abortSignal?.aborted || imageResponse.errorKind === "aborted") { - response = abortedResponse(); - } else if ( - imageResponse.finishReason === "error" - || isBlankText(imageResponse.content) - || imageResponse.toolCalls.length > 0 - ) { - const failureDetail = imageResponse.finishReason === "error" - ? imageResponse.content - : isBlankText(imageResponse.content) - ? "image2text returned an empty description" - : "image2text returned an unexpected tool call"; - response = new LLMResponse({ - content: failureDetail || "Image analysis failed.", - finishReason: "error", - usage: imageResponse.usage, - errorStatusCode: imageResponse.errorStatusCode, - errorKind: imageResponse.errorKind, - errorType: imageResponse.errorType, - errorCode: imageResponse.errorCode, - errorRetryAfterS: imageResponse.errorRetryAfterS, - errorShouldRetry: false, - actualProvider: mainActualProvider, - actualModel: mainActualModel, - failedProvider: "memmy_account", - failedModel: "image2text", - errorCategory: imageResponse.errorCategory === "quota_exhausted" - ? "quota_exhausted" - : "image_analysis_failed", - }); - } else { - const batchId = imageTextState.nextBatchId; - imageTextState.nextBatchId += 1; - const description = AgentRunner.safeImageAnalysisDescription(imageResponse.content ?? ""); - for (const record of pendingBatch.records) record.batchId = batchId; - imageTextState.batches.set(batchId, { - id: batchId, - identities: pendingBatch.records.map((record) => record.identity), - description, - }); - - messagesForModel = this.prepareMessagesForModel(spec, messages, { - toolsForRequest: spec.tools?.getDefinitions?.() ?? [], - reservedPromptTokens: 0, - }); - messagesForModel = this.prepareImageMessages(messagesForModel, imageTextState); - response = await this.requestModel(spec, messagesForModel, hook, context); - const retryUsage = this.usageDict(response.usage); - this.accumulateUsage(usage, retryUsage); - iterationUsage = this.mergeUsage(iterationUsage, retryUsage); - } - } - } - (context as any).response = response; context.usage = iterationUsage; context.toolCalls = [...response.toolCalls]; @@ -1500,7 +1550,13 @@ export class AgentRunner { if (hook.wantsStreaming()) { await hook.onStreamEnd(context, { resuming: false }); } - response = await this.requestFinalizationRetry(spec, messagesForModel); + response = await this.requestFinalizationRetry( + spec, + messagesForModel, + hook, + context, + imageTextState, + ); const retryUsage = this.usageDict(response.usage); this.accumulateUsage(usage, retryUsage); context.usage = this.mergeUsage(iterationUsage, retryUsage); @@ -1619,7 +1675,7 @@ export class AgentRunner { role: "user", content: spec.maxIterationsFinalPrompt, }]); - let requestMessages = this.prepareImageMessages(preparedMessages, imageTextState); + const requestMessages = this.prepareImageMessages(preparedMessages, imageTextState); const context = new AgentHookContext({ spec, messages, @@ -1627,98 +1683,24 @@ export class AgentRunner { usage, }); try { - response = await this.requestModel(spec, requestMessages, hook, context, { - toolsOverride: null, - forceNonStreaming: true, - }); + response = await this.requestModelWithImagePolicy( + spec, + requestMessages, + hook, + context, + imageTextState, + { + toolsOverride: null, + forceNonStreaming: true, + }, + ); const finalUsage = this.usageDict(response.usage); this.accumulateUsage(usage, finalUsage); - let recoveredImageInput = false; - if ( - response.errorCategory === "image_input_unsupported" - && response.toolCalls.length === 0 - && this.canRunAccountImageTextFallback(spec, response) - ) { - const pendingBatch = this.collectPendingImageBatch(requestMessages, imageTextState); - if (pendingBatch) { - recoveredImageInput = true; - const mainActualProvider = response.actualProvider - ?? spec.actualModelContext?.provider - ?? null; - const mainActualModel = response.actualModel - ?? spec.actualModelContext?.model - ?? spec.model - ?? null; - const imageResponse = await this.requestAccountImageText(spec, pendingBatch); - this.accumulateUsage(usage, this.usageDict(imageResponse.usage)); - if (spec.abortSignal?.aborted || imageResponse.errorKind === "aborted") { - response = abortedResponse(); - } else if ( - imageResponse.finishReason === "error" - || isBlankText(imageResponse.content) - || imageResponse.toolCalls.length > 0 - ) { - const failureDetail = imageResponse.finishReason === "error" - ? imageResponse.content - : isBlankText(imageResponse.content) - ? "image2text returned an empty description" - : "image2text returned an unexpected tool call"; - response = new LLMResponse({ - content: failureDetail || "Image analysis failed.", - finishReason: "error", - usage: imageResponse.usage, - errorStatusCode: imageResponse.errorStatusCode, - errorKind: imageResponse.errorKind, - errorType: imageResponse.errorType, - errorCode: imageResponse.errorCode, - errorRetryAfterS: imageResponse.errorRetryAfterS, - errorShouldRetry: false, - actualProvider: mainActualProvider, - actualModel: mainActualModel, - failedProvider: "memmy_account", - failedModel: "image2text", - errorCategory: imageResponse.errorCategory === "quota_exhausted" - ? "quota_exhausted" - : "image_analysis_failed", - }); - } else { - const batchId = imageTextState.nextBatchId; - imageTextState.nextBatchId += 1; - const description = AgentRunner.safeImageAnalysisDescription(imageResponse.content ?? ""); - for (const record of pendingBatch.records) record.batchId = batchId; - imageTextState.batches.set(batchId, { - id: batchId, - identities: pendingBatch.records.map((record) => record.identity), - description, - }); - const retryMessages = this.prepareMessagesForModel(spec, messages, { - toolsForRequest: null, - reservedPromptTokens: estimateMessageTokens({ - role: "user", - content: spec.maxIterationsFinalPrompt, - }), - }).map((message) => ({ ...message })); - AgentRunner.appendInjectedMessages(retryMessages, [{ - role: "user", - content: spec.maxIterationsFinalPrompt, - }]); - requestMessages = this.prepareImageMessages(retryMessages, imageTextState); - response = await this.requestModel(spec, requestMessages, hook, context, { - toolsOverride: null, - forceNonStreaming: true, - }); - this.accumulateUsage(usage, this.usageDict(response.usage)); - } - } - } if (spec.abortSignal?.aborted || response.errorKind === "aborted") { finalContent = "Error: task cancelled"; stopReason = "cancelled"; error = finalContent; - } else if ( - response.finishReason === "error" - && (recoveredImageInput || AgentRunner.terminalImageError(response)) - ) { + } else if (response.finishReason === "error" && AgentRunner.terminalImageError(response)) { const [, cleanedContent] = extractReasoning( response.reasoningContent, response.thinkingBlocks, diff --git a/App/memmy-agent/src/core/agent-runtime/subagent.ts b/App/memmy-agent/src/core/agent-runtime/subagent.ts index 931d81c7e..46c94a2cf 100644 --- a/App/memmy-agent/src/core/agent-runtime/subagent.ts +++ b/App/memmy-agent/src/core/agent-runtime/subagent.ts @@ -11,6 +11,7 @@ import { AgentDefaults, Config } from "../../config/schema.js"; import { readTemplate } from "../../templates/index.js"; import { ContextBuilder } from "./context.js"; import { SkillsLoader } from "./skills.js"; +import type { ActualModelContext } from "../../providers/model-catalog.js"; export class SubagentStatus { static PENDING = "pending"; @@ -79,6 +80,7 @@ export class SubagentHook extends AgentHook { override async beforeExecuteTools(context: AgentHookContext): Promise { // Hook point retained for parity with memmy logging and tests. + void context; } override async afterIteration(context: AgentHookContext): Promise { @@ -250,6 +252,7 @@ export class SubagentManager { contextWindowTokens?: number; modelPreset?: string | null; modelProvider?: string | null; + actualModelContext?: ActualModelContext | null; }, label: string | null = null, originChannel = "cli", originChatId = "direct", sessionKey: string | null = null, originMessageId: string | null = null, temperature: number | null = null): Promise { const args = typeof input === "string" ? { task: input, label, originChannel, originChatId, sessionKey, originMessageId, temperature } : input; const task = String(args.task ?? ""); @@ -266,6 +269,12 @@ export class SubagentManager { contextWindowTokens: args.contextWindowTokens ?? this.contextWindowTokens, modelPreset: args.modelPreset ?? null, modelProvider: args.modelProvider ?? null, + actualModelContext: args.actualModelContext == null + ? null + : Object.freeze({ + ...args.actualModelContext, + capabilities: Object.freeze([...args.actualModelContext.capabilities]), + }), }; const status = new SubagentStatus({ taskId, @@ -380,6 +389,7 @@ export class SubagentManager { llmTimeoutS: this.llmWallTimeoutForSession?.(sessKey) ?? null, abortSignal: signal, hook: new SubagentHook(taskId, status), + actualModelContext: origin.actualModelContext ?? null, })); if (isCancelled()) { status.phase = SubagentStatus.CANCELLED; diff --git a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts index 46ab57971..671a0564b 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/filesystem.ts @@ -634,7 +634,6 @@ export class ReadFileTool extends Tool { image_url: { url: `data:${mime};base64,${data.toString("base64")}` }, meta: { path: target }, }, - { type: "text", text: `(Image file: ${target})` }, ]; } const content = await fs.readFile(target, "utf8"); diff --git a/App/memmy-agent/src/core/session/workspace-environment.ts b/App/memmy-agent/src/core/session/workspace-environment.ts new file mode 100644 index 000000000..26cfd9877 --- /dev/null +++ b/App/memmy-agent/src/core/session/workspace-environment.ts @@ -0,0 +1,472 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { readGoalState } from "./goal-state.js"; +import { readWebuiSessionBinding, type Session } from "./manager.js"; + +export const GOAL_WORKSPACE_BASELINE_KEY = "goalWorkspaceBaselineV1"; + +export type WorkspaceEnvironmentScope = { + kind: "session" | "project"; + key: string; +}; + +export type WorkspaceEnvironmentContext = { + scope: WorkspaceEnvironmentScope; + cwd: string | null; + metadata: Session["metadata"]; +}; + +export type WorkspaceEnvironmentFile = { + path: string; + status: string; + staged: boolean; + unstaged: boolean; + untracked: boolean; + conflict: boolean; + additions: number | null; + deletions: number | null; + attribution: "goal" | "preexisting" | "uncertain" | "unattributed"; +}; + +export type WorkspaceEnvironmentSnapshot = { + scope_kind: WorkspaceEnvironmentScope["kind"]; + scope_key: string; + cwd: string; + status: "ready" | "not_git" | "workspace_unavailable" | "error"; + revision: string; + captured_at: string; + repository: null | { + display_name: string; + root: string; + head_sha: string; + branch: string | null; + detached: boolean; + upstream: string | null; + ahead: number; + behind: number; + worktree: "clean" | "dirty"; + }; + changes: null | { + file_count: number; + additions: number | null; + deletions: number | null; + conflicts: number; + staged: number; + unstaged: number; + untracked: number; + }; + goal: null | { + goal_id: string; + base_head: string | null; + base_branch: string | null; + goal_files: number; + preexisting_files: number; + uncertain_files: number; + verification: "not_run" | "running" | "passed" | "failed" | "stale"; + completion_audit: "pending" | "risk" | "satisfied"; + baseline_status: "captured" | "unavailable"; + }; +}; + +type GitStatusEntry = Omit; +type GitState = { + root: string; + head: string; + branch: string | null; + upstream: string | null; + ahead: number; + behind: number; + branches: string[]; + files: GitStatusEntry[]; +}; + +type GoalWorkspaceBaseline = { + version: 1; + goalId: string; + capturedAt: string; + status: "captured" | "unavailable"; + head: string | null; + branch: string | null; + files: Record; +}; + +type GitCommandResult = { ok: true; stdout: string } | { ok: false; stderr: string }; + +export class WorkspaceEnvironmentError extends Error { + constructor( + readonly code: "workspace_environment_stale" | "workspace_branch_invalid" | "workspace_branch_switch_failed", + readonly status: number, + ) { + super(code); + } +} + +const GIT_TIMEOUT_MS = 5_000; +const GIT_OUTPUT_LIMIT = 8 * 1024 * 1024; +const DIFF_OUTPUT_LIMIT = 512 * 1024; + +function runGit(cwd: string, args: string[], maxBuffer = GIT_OUTPUT_LIMIT): Promise { + return new Promise((resolve) => { + execFile("git", args, { + cwd, + encoding: "utf8", + timeout: GIT_TIMEOUT_MS, + maxBuffer, + windowsHide: true, + }, (error, stdout, stderr) => { + if (error) { + resolve({ ok: false, stderr: String(stderr || error.message || "git command failed").trim() }); + return; + } + resolve({ ok: true, stdout: String(stdout ?? "") }); + }); + }); +} + +function parseBranchHeader(record: string, state: Pick): void { + if (record.startsWith("# branch.oid ")) state.head = record.slice(13).trim(); + else if (record.startsWith("# branch.head ")) { + const branch = record.slice(14).trim(); + state.branch = branch === "(detached)" ? null : branch; + } else if (record.startsWith("# branch.upstream ")) state.upstream = record.slice(18).trim() || null; + else if (record.startsWith("# branch.ab ")) { + const match = record.match(/\+(\d+)\s+-(\d+)/); + if (match) { + state.ahead = Number(match[1]); + state.behind = Number(match[2]); + } + } +} + +function parseNumstat(raw: string): Map { + const stats = new Map(); + for (const record of raw.split("\0")) { + if (!record) continue; + const firstTab = record.indexOf("\t"); + const secondTab = firstTab < 0 ? -1 : record.indexOf("\t", firstTab + 1); + if (firstTab < 0 || secondTab < 0) continue; + const additionsRaw = record.slice(0, firstTab); + const deletionsRaw = record.slice(firstTab + 1, secondTab); + const filePath = record.slice(secondTab + 1); + stats.set(filePath, { + additions: /^\d+$/.test(additionsRaw) ? Number(additionsRaw) : null, + deletions: /^\d+$/.test(deletionsRaw) ? Number(deletionsRaw) : null, + }); + } + return stats; +} + +function parseStatus(cwd: string, raw: string): GitState { + const state: GitState = { + root: cwd, + head: "", + branch: null, + upstream: null, + ahead: 0, + behind: 0, + branches: [], + files: [], + }; + const records = raw.split("\0"); + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (!record) continue; + if (record.startsWith("# ")) { + parseBranchHeader(record, state); + continue; + } + const kind = record[0]; + if (kind === "!" || kind === "#") continue; + let xy = "??"; + let filePath = ""; + if (kind === "?") { + filePath = record.slice(2); + } else { + const fields = record.split(" "); + xy = fields[1] ?? "??"; + const fixedFields = kind === "1" ? 8 : kind === "2" ? 9 : 10; + filePath = fields.slice(fixedFields).join(" "); + if (kind === "2") index += 1; + } + if (!filePath) continue; + const untracked = kind === "?"; + const conflict = kind === "u" || xy.includes("U") || xy === "AA" || xy === "DD"; + state.files.push({ + path: filePath, + status: untracked ? "??" : xy, + staged: !untracked && xy[0] !== ".", + unstaged: !untracked && xy[1] !== ".", + untracked, + conflict, + additions: null, + deletions: null, + }); + } + return state; +} + +async function readGitState(cwd: string): Promise<{ status: "ready"; state: GitState } | { status: "not_git" | "error" }> { + const rootResult = await runGit(cwd, ["rev-parse", "--show-toplevel"]); + if (!rootResult.ok) { + return { status: /not a git repository/i.test(rootResult.stderr) ? "not_git" : "error" }; + } + const root = path.resolve(rootResult.stdout.trim()); + const statusResult = await runGit(root, ["status", "--porcelain=v2", "--branch", "-z", "--untracked-files=all"]); + if (!statusResult.ok) return { status: "error" }; + const state = parseStatus(root, statusResult.stdout); + state.root = root; + const [numstatResult, branchesResult] = await Promise.all([ + runGit(root, ["diff", "HEAD", "--numstat", "-z", "--no-renames"]), + runGit(root, ["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads"]), + ]); + if (numstatResult.ok) { + const stats = parseNumstat(numstatResult.stdout); + state.files = state.files.map((file) => ({ ...file, ...(stats.get(file.path) ?? {}) })); + } + if (branchesResult.ok) { + state.branches = branchesResult.stdout.split("\n").map((branch) => branch.trim()).filter(Boolean); + } + if (state.branch && !state.branches.includes(state.branch)) state.branches = [state.branch, ...state.branches]; + return { status: "ready", state }; +} + +async function fileFingerprint(root: string, relativePath: string): Promise { + const candidate = path.resolve(root, relativePath); + if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) return null; + try { + const stat = await fs.lstat(candidate); + if (!stat.isFile() || stat.size > 4 * 1024 * 1024) return null; + return crypto.createHash("sha256").update(await fs.readFile(candidate)).digest("hex"); + } catch { + return null; + } +} + +async function fileSignature(root: string, file: GitStatusEntry): Promise { + const fingerprint = await fileFingerprint(root, file.path); + return `${file.status}:${fingerprint ?? "unavailable"}`; +} + +function readBaseline(context: WorkspaceEnvironmentContext, goalId: string): GoalWorkspaceBaseline | null { + const raw = context.metadata?.[GOAL_WORKSPACE_BASELINE_KEY]; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + const baseline = raw as Partial; + if (baseline.version !== 1 || baseline.goalId !== goalId || !baseline.files || typeof baseline.files !== "object") return null; + return baseline as GoalWorkspaceBaseline; +} + +export async function captureGoalWorkspaceBaseline(session: Session, goalId: string): Promise { + let cwd: string; + try { + cwd = readWebuiSessionBinding(session).cwd; + } catch { + return; + } + const result = await readGitState(cwd); + const capturedAt = new Date().toISOString(); + if (result.status !== "ready") { + session.metadata[GOAL_WORKSPACE_BASELINE_KEY] = { + version: 1, + goalId, + capturedAt, + status: "unavailable", + head: null, + branch: null, + files: {}, + } satisfies GoalWorkspaceBaseline; + return; + } + session.metadata[GOAL_WORKSPACE_BASELINE_KEY] = { + version: 1, + goalId, + capturedAt, + status: "captured", + head: result.state.head || null, + branch: result.state.branch, + files: Object.fromEntries(await Promise.all( + result.state.files.map(async (file) => [file.path, await fileSignature(result.state.root, file)] as const), + )), + } satisfies GoalWorkspaceBaseline; +} + +async function attributedFiles(context: WorkspaceEnvironmentContext, state: GitState): Promise { + const goal = readGoalState(context.metadata); + if (!goal) return state.files.map((file) => ({ ...file, attribution: "unattributed" })); + const baseline = readBaseline(context, goal.goalId); + if (!baseline || baseline.status !== "captured") { + return state.files.map((file) => ({ ...file, attribution: "uncertain" })); + } + return Promise.all(state.files.map(async (file) => { + if (!Object.prototype.hasOwnProperty.call(baseline.files, file.path)) { + return { ...file, attribution: "goal" }; + } + const currentSignature = await fileSignature(state.root, file); + const attribution = currentSignature === baseline.files[file.path] ? "preexisting" : "uncertain"; + return { ...file, attribution }; + })); +} + +function revisionFor(value: unknown): string { + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16); +} + +export async function readWorkspaceEnvironment(context: WorkspaceEnvironmentContext): Promise<{ + snapshot: WorkspaceEnvironmentSnapshot; + files: WorkspaceEnvironmentFile[]; + branches: string[]; +}> { + const cwd = context.cwd ?? ""; + if (!context.cwd) { + const captured_at = new Date().toISOString(); + const snapshot: WorkspaceEnvironmentSnapshot = { + scope_kind: context.scope.kind, + scope_key: context.scope.key, + cwd, + status: "workspace_unavailable", + revision: revisionFor([context.scope, "workspace_unavailable"]), + captured_at, + repository: null, + changes: null, + goal: null, + }; + return { snapshot, files: [], branches: [] }; + } + const captured_at = new Date().toISOString(); + const result = await readGitState(cwd); + if (result.status !== "ready") { + const snapshot: WorkspaceEnvironmentSnapshot = { + scope_kind: context.scope.kind, + scope_key: context.scope.key, + cwd, + status: result.status, + revision: revisionFor([context.scope, cwd, result.status]), + captured_at, + repository: null, + changes: null, + goal: null, + }; + return { snapshot, files: [], branches: [] }; + } + const files = await attributedFiles(context, result.state); + const goalState = readGoalState(context.metadata); + const baseline = goalState ? readBaseline(context, goalState.goalId) : null; + const hasIncompleteStats = files.some((file) => file.additions == null || file.deletions == null); + const changes = { + file_count: files.length, + additions: hasIncompleteStats ? null : files.reduce((sum, file) => sum + (file.additions ?? 0), 0), + deletions: hasIncompleteStats ? null : files.reduce((sum, file) => sum + (file.deletions ?? 0), 0), + conflicts: files.filter((file) => file.conflict).length, + staged: files.filter((file) => file.staged).length, + unstaged: files.filter((file) => file.unstaged).length, + untracked: files.filter((file) => file.untracked).length, + }; + const goal = goalState ? { + goal_id: goalState.goalId, + base_head: baseline?.head ?? null, + base_branch: baseline?.branch ?? null, + goal_files: files.filter((file) => file.attribution === "goal").length, + preexisting_files: files.filter((file) => file.attribution === "preexisting").length, + uncertain_files: files.filter((file) => file.attribution === "uncertain").length, + verification: "not_run" as const, + completion_audit: (changes.conflicts > 0 + || files.some((file) => file.attribution === "uncertain") + || goalState.status === "completed" + ? "risk" + : "pending") as "pending" | "risk" | "satisfied", + baseline_status: baseline?.status ?? "unavailable", + } : null; + const repository = { + display_name: path.basename(result.state.root), + root: result.state.root, + head_sha: result.state.head, + branch: result.state.branch, + detached: result.state.branch == null, + upstream: result.state.upstream, + ahead: result.state.ahead, + behind: result.state.behind, + worktree: files.length ? "dirty" as const : "clean" as const, + }; + const revision = revisionFor({ repository, changes, goal, files, branches: result.state.branches }); + return { + snapshot: { + scope_kind: context.scope.kind, + scope_key: context.scope.key, + cwd, + status: "ready", + revision, + captured_at, + repository, + changes, + goal, + }, + files, + branches: result.state.branches, + }; +} + +export async function switchWorkspaceBranch( + context: WorkspaceEnvironmentContext, + environment: Awaited>, + branch: string, + expectedRevision: string, +): Promise>> { + if (environment.snapshot.revision !== expectedRevision) { + throw new WorkspaceEnvironmentError("workspace_environment_stale", 409); + } + if (!environment.snapshot.repository || !environment.branches.includes(branch)) { + throw new WorkspaceEnvironmentError("workspace_branch_invalid", 400); + } + if (environment.snapshot.repository.branch === branch) return environment; + const switched = await runGit(environment.snapshot.repository.root, ["switch", "--no-guess", branch]); + if (!switched.ok) throw new WorkspaceEnvironmentError("workspace_branch_switch_failed", 409); + return readWorkspaceEnvironment(context); +} + +export async function createOrCheckoutWorkspaceBranch( + context: WorkspaceEnvironmentContext, + environment: Awaited>, + branch: string, + expectedRevision: string, +): Promise>> { + if (environment.snapshot.revision !== expectedRevision) { + throw new WorkspaceEnvironmentError("workspace_environment_stale", 409); + } + const repository = environment.snapshot.repository; + const normalizedBranch = branch.trim(); + if (!repository || !normalizedBranch) { + throw new WorkspaceEnvironmentError("workspace_branch_invalid", 400); + } + if (environment.branches.includes(normalizedBranch)) { + return switchWorkspaceBranch(context, environment, normalizedBranch, expectedRevision); + } + const validated = await runGit(repository.root, ["check-ref-format", "--branch", normalizedBranch]); + if (!validated.ok) throw new WorkspaceEnvironmentError("workspace_branch_invalid", 400); + const switched = await runGit(repository.root, ["switch", "-c", normalizedBranch]); + if (!switched.ok) throw new WorkspaceEnvironmentError("workspace_branch_switch_failed", 409); + return readWorkspaceEnvironment(context); +} + +export async function readWorkspaceFileDiff(environment: { + snapshot: WorkspaceEnvironmentSnapshot; + files: WorkspaceEnvironmentFile[]; +}, relativePath: string): Promise<{ + path: string; + diff: string; + truncated: boolean; + unavailable_reason: string | null; +} | null> { + if (environment.snapshot.status !== "ready" || !environment.snapshot.repository) return null; + const file = environment.files.find((entry) => entry.path === relativePath); + if (!file) return null; + if (file.untracked) { + return { path: relativePath, diff: "", truncated: false, unavailable_reason: "untracked_diff_unavailable" }; + } + const result = await runGit(environment.snapshot.repository.root, [ + "diff", "--no-ext-diff", "--no-color", "HEAD", "--", relativePath, + ], DIFF_OUTPUT_LIMIT); + if (!result.ok) return { path: relativePath, diff: "", truncated: false, unavailable_reason: "diff_unavailable" }; + const truncated = Buffer.byteLength(result.stdout, "utf8") >= DIFF_OUTPUT_LIMIT; + return { path: relativePath, diff: result.stdout, truncated, unavailable_reason: null }; +} diff --git a/App/memmy-agent/src/integrations/channels/websocket.ts b/App/memmy-agent/src/integrations/channels/websocket.ts index 2ea783114..ecaa21ab9 100644 --- a/App/memmy-agent/src/integrations/channels/websocket.ts +++ b/App/memmy-agent/src/integrations/channels/websocket.ts @@ -14,7 +14,6 @@ import { requestMcpReload } from "../../core/agent-runtime/tools/mcp.js"; import { BaseChannel, type ChannelHandleMessageOptions } from "./base.js"; import { OUTBOUND_META_AGENT_UI, - MessageBus, OutboundMessage, parseTurnSource, type TurnSource, @@ -40,11 +39,7 @@ import type { } from "../../core/agent-runtime/loop.js"; import { getMediaDir, getWorkspacePath } from "../../config/paths.js"; import type { CronService } from "../../cron/service.js"; -import { - GOAL_TURN_INBOX_KEY, - goalStateWsBlob, - type GoalStatus, -} from "../../core/session/goal-state.js"; +import { goalStateWsBlob, type GoalStatus } from "../../core/session/goal-state.js"; import { readWebuiSessionBinding, Session, @@ -55,6 +50,14 @@ import { websocketTurnWallStartedAt, websocketTurnWallStartTimes } from "../../c import type { WebuiTitleService } from "../../core/session/webui-title.js"; import { visibleWebuiUserContent } from "../../core/session/webui-user-content.js"; import { TerminalRunControl } from "../../core/session/terminal-session-control.js"; +import { + createOrCheckoutWorkspaceBranch, + readWorkspaceEnvironment, + readWorkspaceFileDiff, + switchWorkspaceBranch, + WorkspaceEnvironmentError, + type WorkspaceEnvironmentContext, +} from "../../core/session/workspace-environment.js"; import { scrubSubagentMessagesForChannel } from "../../utils/subagent-channel-display.js"; import { mcpPresetsSettingsAction, @@ -109,6 +112,9 @@ import { MAX_FILE_SIZE } from "../../utils/media-decode.js"; type Query = Record; type HttpRequestLike = { path: string; method?: string; headers?: http.IncomingHttpHeaders | Record; body?: Buffer | string }; type HttpLikeResponse = { status: number; headers: Record; body: Buffer | string }; +type WorkspaceEnvironmentContextResult = + | { ok: true; context: WorkspaceEnvironmentContext } + | { ok: false; response: HttpLikeResponse }; type RuntimeModelNameResolver = (() => string | null | undefined) | null; type RuntimeToolNamesResolver = (() => string[] | null | undefined) | null; type WebuiMediaKind = "image" | "video" | "file"; @@ -1649,6 +1655,89 @@ export class WebSocketChannel extends BaseChannel { } } + async handleProjectWorkspaceEnvironment( + request: HttpRequestLike, + rawId: string, + view: "environment" | "diff" | "branch", + ): Promise { + if (!this.checkApiToken(request)) return httpError(401, "Unauthorized"); + const expectedMethod = view === "branch" ? "POST" : "GET"; + if ((request.method ?? "GET").toUpperCase() !== expectedMethod) return httpError(405, "method not allowed"); + try { + const id = decodeApiKey(rawId); + if (!id) throw new WebuiProjectError("project_not_found", 404); + const project = this.activeProject(id); + const cwd = assertWebuiWorkspaceAvailable(project.rootPath); + if (cwd !== project.rootPath) throw new WebuiProjectError("project_directory_unavailable", 422); + const context: WorkspaceEnvironmentContext = { + scope: { kind: "project", key: id }, + cwd, + metadata: { + [WEBUI_PROJECT_ID_METADATA_KEY]: id, + [WEBUI_WORKSPACE_CWD_METADATA_KEY]: cwd, + }, + }; + return await this.workspaceEnvironmentResponse(request, context, view); + } catch (error) { + return this.projectErrorResponse(error); + } + } + + private async workspaceEnvironmentResponse( + request: HttpRequestLike, + context: WorkspaceEnvironmentContext, + view: "environment" | "diff" | "branch", + ): Promise { + try { + const environment = await readWorkspaceEnvironment(context); + if (view === "environment") return httpJsonResponse(environment); + if (view === "branch") { + let body: unknown; + try { + body = JSON.parse(requestBodyText(request)); + } catch { + return httpError(400, "workspace_branch_request_invalid"); + } + const bodyRecord = body && typeof body === "object" && !Array.isArray(body) + ? body as Record + : null; + const branch = bodyRecord?.branch; + const expectedRevision = bodyRecord?.expected_revision; + const create = bodyRecord?.create; + if ( + !bodyRecord + || typeof branch !== "string" + || typeof expectedRevision !== "string" + || (create !== undefined && typeof create !== "boolean") + ) { + return httpError(400, "workspace_branch_request_invalid"); + } + const next = create === true + ? await createOrCheckoutWorkspaceBranch( + context, + environment, + branch, + expectedRevision, + ) + : await switchWorkspaceBranch( + context, + environment, + branch, + expectedRevision, + ); + return httpJsonResponse(next); + } + const [, query] = parseRequestPath(String(request.path ?? "")); + const relativePath = queryFirst(query, "path"); + if (!relativePath) return httpError(400, "missing path"); + const diff = await readWorkspaceFileDiff(environment, relativePath); + return diff ? httpJsonResponse(diff) : httpError(404, "changed file not found"); + } catch (error) { + if (error instanceof WorkspaceEnvironmentError) return httpError(error.status, error.code); + throw error; + } + } + settingsErrorResponse(error: any): HttpLikeResponse { if (error instanceof WebUISettingsError || typeof error?.status === "number") { return httpError(error.status ?? 400, error.message ?? String(error)); @@ -1817,6 +1906,43 @@ export class WebSocketChannel extends BaseChannel { return httpJsonResponse(data); } + private workspaceEnvironmentSession(key: string): WorkspaceEnvironmentContextResult { + if (!this.sessionManager) return { ok: false, response: httpError(503, "session manager unavailable") }; + const decodedKey = decodeGuiSessionApiKey(key); + if (decodedKey == null) return { ok: false, response: invalidGuiSessionKeyResponse(key) }; + const resolved = this.resolveGuiSessionResponse(decodedKey); + if ("status" in resolved) return { ok: false, response: resolved }; + const session = this.sessionManager.get?.(resolved.canonicalSessionKey) as Session | null; + if (!session) return { ok: false, response: httpError(404, "session not found") }; + let cwd: string | null = null; + try { + cwd = readWebuiSessionBinding(session).cwd; + } catch { + // Preserve the session scope so the service can report workspace_unavailable. + } + return { + ok: true, + context: { + scope: { kind: "session", key: resolved.guiSessionKey }, + cwd, + metadata: session.metadata, + }, + }; + } + + async handleWorkspaceEnvironment( + request: HttpRequestLike, + key: string, + view: "environment" | "diff" | "branch", + ): Promise { + if (!this.checkApiToken(request)) return httpError(401, "Unauthorized"); + const expectedMethod = view === "branch" ? "POST" : "GET"; + if ((request.method ?? "GET").toUpperCase() !== expectedMethod) return httpError(405, "method not allowed"); + const result = this.workspaceEnvironmentSession(key); + if (!result.ok) return result.response; + return this.workspaceEnvironmentResponse(request, result.context, view); + } + handleWebuiThreadGet(request: any, key: string): HttpLikeResponse { if (!this.checkApiToken(request)) return httpError(401, "Unauthorized"); const [, query] = parseRequestPath(String(request?.path ?? "")); @@ -2606,6 +2732,12 @@ export class WebSocketChannel extends BaseChannel { if (MCP_PRESET_ACTIONS_BY_PATH[got]) return this.handleSettingsMcpPresets(request, MCP_PRESET_ACTIONS_BY_PATH[got]); let match = got.match(/^\/api\/sessions\/([^/]+)\/messages$/); if (match) return this.handleSessionMessages(request, match[1]); + match = got.match(/^\/api\/sessions\/([^/]+)\/environment$/); + if (match) return this.handleWorkspaceEnvironment(request, match[1], "environment"); + match = got.match(/^\/api\/sessions\/([^/]+)\/environment\/diff$/); + if (match) return this.handleWorkspaceEnvironment(request, match[1], "diff"); + match = got.match(/^\/api\/sessions\/([^/]+)\/environment\/branch$/); + if (match) return this.handleWorkspaceEnvironment(request, match[1], "branch"); match = got.match(/^\/api\/sessions\/([^/]+)\/webui-thread$/); if (match) return this.handleWebuiThreadGet(request, match[1]); match = got.match(/^\/api\/sessions\/([^/]+)\/last-compaction$/); @@ -2614,6 +2746,12 @@ export class WebSocketChannel extends BaseChannel { if (match) return this.handleSessionDelete(request, match[1]); match = got.match(/^\/api\/sessions\/([^/]+)\/title$/); if (match) return this.handleSessionTitleUpdate(request, match[1]); + match = got.match(/^\/api\/projects\/([^/]+)\/environment$/); + if (match) return this.handleProjectWorkspaceEnvironment(request, match[1], "environment"); + match = got.match(/^\/api\/projects\/([^/]+)\/environment\/diff$/); + if (match) return this.handleProjectWorkspaceEnvironment(request, match[1], "diff"); + match = got.match(/^\/api\/projects\/([^/]+)\/environment\/branch$/); + if (match) return this.handleProjectWorkspaceEnvironment(request, match[1], "branch"); match = got.match(/^\/api\/projects\/([^/]+)\/reveal$/); if (match) return this.handleProjectReveal(request, match[1]); match = got.match(/^\/api\/projects\/([^/]+)$/); diff --git a/App/memmy-agent/src/memmy-memory/hook.ts b/App/memmy-agent/src/memmy-memory/hook.ts index 90b72b9ac..2b53f5fae 100644 --- a/App/memmy-agent/src/memmy-memory/hook.ts +++ b/App/memmy-agent/src/memmy-memory/hook.ts @@ -1,7 +1,7 @@ import { createHash, randomUUID } from "node:crypto"; import { AgentHook, AgentHookContext, type AgentToolRegistrationContext, type SystemPromptBuildContext } from "../core/agent-runtime/hook.js"; import { ContextBuilder } from "../core/agent-runtime/context.js"; -import { extractReasoning, stripThink } from "../utils/helpers.js"; +import { extractReasoning, imagePlaceholderText, stripThink } from "../utils/helpers.js"; import { CURRENT_USER_REQUEST_TAG, extractCurrentUserRequestText, @@ -139,7 +139,7 @@ export class MemmyMemoryHook extends AgentHook implements MemmyMemoryToolRuntime if (isGoalContinuation && !internalObjective) return; try { const sessionId = await this.ensureSession(ctx, sessionKey); - const turnId = randomUUID(); + const turnId = stringOrUndefined(ctx.spec?.turnId) ?? randomUUID(); const userText = isGoalContinuation ? internalObjective : lastUserText(messages); const turn: MemmyMemoryTurnState = { sessionKey, @@ -551,7 +551,15 @@ function arrayOfStrings(value: any): string[] | undefined { function messageContentText(content: any): string { if (typeof content === "string") return content; - if (Array.isArray(content)) return content.map((item) => item?.text ?? item?.content ?? "").filter(Boolean).join("\n"); + if (Array.isArray(content)) { + return content.map((item) => { + if (item?.type === "image_url") { + const mediaPath = typeof item.meta?.path === "string" ? item.meta.path : ""; + return imagePlaceholderText(mediaPath); + } + return item?.text ?? item?.content ?? ""; + }).filter(Boolean).join("\n"); + } if (content == null) return ""; return String(content); } diff --git a/App/memmy-agent/src/providers/fallback-provider.ts b/App/memmy-agent/src/providers/fallback-provider.ts index 43ed675fc..d9c4e5bc4 100644 --- a/App/memmy-agent/src/providers/fallback-provider.ts +++ b/App/memmy-agent/src/providers/fallback-provider.ts @@ -3,6 +3,11 @@ import { LLMProvider, LLMResponse, } from "./base.js"; +import { + coversInputModalities, + getModelInputModalities, + requiredInputModalities, +} from "./model-input-capabilities.js"; const PRIMARY_FAILURE_THRESHOLD = 3; const PRIMARY_COOLDOWN_MS = 60_000; @@ -148,8 +153,11 @@ export class FallbackProvider extends LLMProvider { } } + const required = requiredInputModalities(args.messages ?? []); for (const fallback of this.fallbackPresets) { if (hasStreamed?.[0]) break; + const supported = getModelInputModalities(fallback.model); + if (!coversInputModalities(supported, required)) continue; let fallbackProvider: LLMProvider; try { fallbackProvider = this.providerFactory(fallback); diff --git a/App/memmy-agent/src/providers/model-input-capabilities.ts b/App/memmy-agent/src/providers/model-input-capabilities.ts new file mode 100644 index 000000000..0f896870f --- /dev/null +++ b/App/memmy-agent/src/providers/model-input-capabilities.ts @@ -0,0 +1,367 @@ +export type ModelInputModality = "text" | "image" | "video"; + +const TEXT = Object.freeze(["text"] as const); +const TEXT_IMAGE = Object.freeze(["text", "image"] as const); +const TEXT_IMAGE_VIDEO = Object.freeze(["text", "image", "video"] as const); + +export const MODEL_INPUT_CAPABILITIES_REVIEWED_AT = "2026-08-13"; + +export function defineModelInputCapabilities( + entries: ReadonlyArray, +): Readonly> { + const result: Record = Object.create(null); + for (const [model, modalities] of entries) { + if (Object.prototype.hasOwnProperty.call(result, model)) { + throw new Error(`Duplicate model input capability: ${model}`); + } + result[model] = Object.freeze([...modalities]); + } + return Object.freeze(result); +} + +export const MODEL_INPUT_CAPABILITIES = defineModelInputCapabilities([ + // Memmy. Reviewed 2026-08-13. + // Source: App/backend/src/infrastructure/memmy-config/index.ts + ["agent_chat", TEXT], + + // OpenAI / Codex. Reviewed 2026-08-13. + // Source: https://developers.openai.com/api/docs/models + ["gpt-5.6", TEXT_IMAGE], + ["gpt-5.6-sol", TEXT_IMAGE], + ["gpt-5.6-terra", TEXT_IMAGE], + ["gpt-5.6-luna", TEXT_IMAGE], + ["gpt-5.5", TEXT_IMAGE], + ["gpt-5.5-pro", TEXT_IMAGE], + ["gpt-5.4", TEXT_IMAGE], + ["gpt-5.4-pro", TEXT_IMAGE], + ["gpt-5.4-mini", TEXT_IMAGE], + ["gpt-5.4-nano", TEXT_IMAGE], + ["gpt-5.3-codex", TEXT_IMAGE], + ["gpt-5.2", TEXT_IMAGE], + ["gpt-5.2-pro", TEXT_IMAGE], + ["gpt-5.2-codex", TEXT_IMAGE], + ["gpt-5.1", TEXT_IMAGE], + ["gpt-5.1-codex", TEXT_IMAGE], + ["gpt-5", TEXT_IMAGE], + ["gpt-5-pro", TEXT_IMAGE], + ["gpt-5-mini", TEXT_IMAGE], + ["gpt-5-nano", TEXT_IMAGE], + ["gpt-5-codex", TEXT_IMAGE], + ["gpt-4.1", TEXT_IMAGE], + ["gpt-4.1-mini", TEXT_IMAGE], + ["gpt-4o", TEXT_IMAGE], + ["gpt-4o-mini", TEXT_IMAGE], + + // Anthropic Claude API IDs. Reviewed 2026-08-13. + // Source: https://platform.claude.com/docs/en/about-claude/models/overview + ["claude-fable-5", TEXT_IMAGE], + ["claude-opus-5", TEXT_IMAGE], + ["claude-sonnet-5", TEXT_IMAGE], + ["claude-mythos-5", TEXT_IMAGE], + ["claude-mythos-preview", TEXT_IMAGE], + ["claude-opus-4-8", TEXT_IMAGE], + ["claude-opus-4-7", TEXT_IMAGE], + ["claude-opus-4-6", TEXT_IMAGE], + ["claude-sonnet-4-6", TEXT_IMAGE], + ["claude-haiku-4-5", TEXT_IMAGE], + ["claude-haiku-4-5-20251001", TEXT_IMAGE], + + // AWS 公布的 Claude model IDs. Reviewed 2026-08-13. + // Source: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-anthropic-claude-sonnet-5.html + ["anthropic.claude-fable-5", TEXT_IMAGE], + ["us.anthropic.claude-fable-5", TEXT_IMAGE], + ["global.anthropic.claude-fable-5", TEXT_IMAGE], + ["anthropic.claude-opus-5", TEXT_IMAGE], + ["anthropic.claude-sonnet-5", TEXT_IMAGE], + ["us.anthropic.claude-sonnet-5", TEXT_IMAGE], + ["eu.anthropic.claude-sonnet-5", TEXT_IMAGE], + ["au.anthropic.claude-sonnet-5", TEXT_IMAGE], + ["global.anthropic.claude-sonnet-5", TEXT_IMAGE], + ["anthropic.claude-opus-4-8", TEXT_IMAGE], + ["us.anthropic.claude-opus-4-8", TEXT_IMAGE], + ["eu.anthropic.claude-opus-4-8", TEXT_IMAGE], + ["jp.anthropic.claude-opus-4-8", TEXT_IMAGE], + ["au.anthropic.claude-opus-4-8", TEXT_IMAGE], + ["global.anthropic.claude-opus-4-8", TEXT_IMAGE], + ["anthropic.claude-opus-4-7", TEXT_IMAGE], + ["global.anthropic.claude-opus-4-7", TEXT_IMAGE], + ["anthropic.claude-opus-4-6-v1", TEXT_IMAGE], + ["us.anthropic.claude-opus-4-6-v1", TEXT_IMAGE], + ["eu.anthropic.claude-opus-4-6-v1", TEXT_IMAGE], + ["au.anthropic.claude-opus-4-6-v1", TEXT_IMAGE], + ["global.anthropic.claude-opus-4-6-v1", TEXT_IMAGE], + ["anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["us.anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["eu.anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["au.anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["jp.anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["global.anthropic.claude-sonnet-4-6", TEXT_IMAGE], + ["anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + ["us.anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + ["eu.anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + ["au.anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + ["jp.anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + ["global.anthropic.claude-haiku-4-5-20251001-v1:0", TEXT_IMAGE], + + // Google Gemini. Reviewed 2026-08-13. + // Source: https://ai.google.dev/gemini-api/docs/models + ["gemini-3.6-flash", TEXT_IMAGE_VIDEO], + ["gemini-3.5-flash", TEXT_IMAGE_VIDEO], + ["gemini-3.5-flash-lite", TEXT_IMAGE_VIDEO], + ["gemini-3.1-pro-preview", TEXT_IMAGE_VIDEO], + ["gemini-3.1-pro-preview-customtools", TEXT_IMAGE_VIDEO], + ["gemini-3.1-flash-lite", TEXT_IMAGE_VIDEO], + ["gemini-3-flash-preview", TEXT_IMAGE_VIDEO], + ["gemini-2.5-pro", TEXT_IMAGE_VIDEO], + ["gemini-2.5-flash", TEXT_IMAGE_VIDEO], + ["gemini-2.5-flash-lite", TEXT_IMAGE_VIDEO], + + // Amazon Nova. Reviewed 2026-08-13. + // Source: https://docs.aws.amazon.com/bedrock/latest/userguide/model-cards-amazon.html + ["amazon.nova-micro-v1:0", TEXT], + ["amazon.nova-premier-v1:0", TEXT_IMAGE_VIDEO], + ["amazon.nova-pro-v1:0", TEXT_IMAGE_VIDEO], + ["amazon.nova-lite-v1:0", TEXT_IMAGE_VIDEO], + ["global.amazon.nova-2-lite-v1:0", TEXT_IMAGE_VIDEO], + ["us.amazon.nova-2-lite-v1:0", TEXT_IMAGE_VIDEO], + + // Mistral. Reviewed 2026-08-13. + // Source: https://docs.mistral.ai/models/overview + ["mistral-medium-3-5", TEXT_IMAGE], + ["mistral-medium-latest", TEXT_IMAGE], + ["mistral-small-2603", TEXT_IMAGE], + ["mistral-small-latest", TEXT_IMAGE], + ["mistral-large-2512", TEXT_IMAGE], + ["mistral-large-latest", TEXT_IMAGE], + ["ministral-14b-2512", TEXT_IMAGE], + ["ministral-8b-2512", TEXT_IMAGE], + ["ministral-3b-2512", TEXT_IMAGE], + + // xAI Grok. Reviewed 2026-08-13. + // Source: https://docs.x.ai/developers/models + ["grok-build-0.1", TEXT], + ["grok-4.5", TEXT_IMAGE], + ["grok-4.5-latest", TEXT_IMAGE], + ["grok-build-latest", TEXT_IMAGE], + ["grok-4.3", TEXT_IMAGE], + ["grok-4.3-latest", TEXT_IMAGE], + ["grok-latest", TEXT_IMAGE], + + // Meta Llama(AWS 公布的 model IDs). Reviewed 2026-08-13. + // Source: https://docs.aws.amazon.com/bedrock/latest/userguide/model-card-meta-llama-4-scout-17b-instruct.html + ["meta.llama4-scout-17b-instruct-v1:0", TEXT_IMAGE], + ["us.meta.llama4-scout-17b-instruct-v1:0", TEXT_IMAGE], + + // NVIDIA Nemotron. Reviewed 2026-08-13. + // Source: https://build.nvidia.com/models?q=nemotron + ["nvidia/nemotron-3-super-120b-a12b", TEXT], + ["nvidia/nemotron-3-nano-30b-a3b", TEXT], + ["nvidia/nemotron-3-nano-omni-30b-a3b-reasoning", TEXT_IMAGE_VIDEO], + + // Groq 公布的 model IDs. Reviewed 2026-08-13. + // Source: https://console.groq.com/docs/models + ["openai/gpt-oss-120b", TEXT], + ["openai/gpt-oss-20b", TEXT], + ["groq/compound", TEXT], + ["groq/compound-mini", TEXT], + ["qwen/qwen3.6-27b", TEXT_IMAGE], + + // DeepSeek. Reviewed 2026-08-13. + // Source: https://api-docs.deepseek.com/updates/ + ["deepseek-v4-pro", TEXT], + ["deepseek-v4-flash", TEXT], + ["deepseek-v4-flash-0731", TEXT], + ["deepseek-v3.2", TEXT], + + // Qwen / 百炼. Reviewed 2026-08-13. + // Source: https://help.aliyun.com/zh/model-studio/token-plan-team-overview + // Source: https://help.aliyun.com/zh/model-studio/vision-model/ + ["qwen3.7-max", TEXT], + ["qwen3.7-max-preview", TEXT], + ["qwen3.7-max-2026-05-20", TEXT], + ["qwen3.7-max-2026-05-17", TEXT], + ["qwen3.6-max-preview", TEXT], + ["qwen3-coder-next", TEXT], + ["qwen3-coder-plus", TEXT], + ["qwen3-coder-flash", TEXT], + ["qwen3.8-max-preview", TEXT_IMAGE], + ["qwen3.7-max-2026-06-08", TEXT_IMAGE_VIDEO], + ["qwen3.7-plus", TEXT_IMAGE_VIDEO], + ["qwen3.7-plus-2026-05-26", TEXT_IMAGE_VIDEO], + ["qwen3.7-flash", TEXT_IMAGE_VIDEO], + ["qwen3.7-flash-2026-07-15", TEXT_IMAGE_VIDEO], + ["qwen3.6-plus", TEXT_IMAGE_VIDEO], + ["qwen3.6-plus-2026-04-02", TEXT_IMAGE_VIDEO], + ["qwen3.6-flash", TEXT_IMAGE_VIDEO], + ["qwen3.6-flash-2026-04-16", TEXT_IMAGE_VIDEO], + ["qwen3.6-35b-a3b", TEXT_IMAGE_VIDEO], + ["qwen3.5-plus", TEXT_IMAGE_VIDEO], + ["qwen3.5-plus-2026-02-15", TEXT_IMAGE_VIDEO], + ["qwen3.5-flash", TEXT_IMAGE_VIDEO], + ["qwen3.5-flash-2026-02-23", TEXT_IMAGE_VIDEO], + ["qwen3.5-397b-a17b", TEXT_IMAGE_VIDEO], + ["qwen3.5-122b-a10b", TEXT_IMAGE_VIDEO], + ["qwen3.5-35b-a3b", TEXT_IMAGE_VIDEO], + ["qwen3.5-27b", TEXT_IMAGE_VIDEO], + ["qwen3-vl-plus", TEXT_IMAGE_VIDEO], + ["qwen3-vl-flash", TEXT_IMAGE_VIDEO], + ["qwen3.5-omni-plus", TEXT_IMAGE_VIDEO], + ["qwen3.5-omni-plus-2026-03-15", TEXT_IMAGE_VIDEO], + ["qwen3.5-omni-flash", TEXT_IMAGE_VIDEO], + ["qwen3.5-omni-flash-2026-03-15", TEXT_IMAGE_VIDEO], + ["qwen3-omni-flash", TEXT_IMAGE_VIDEO], + ["qwen3-omni-flash-2025-12-01", TEXT_IMAGE_VIDEO], + + // Kimi / Moonshot. Reviewed 2026-08-13. + // Source: https://platform.kimi.com/docs/models + // Source: https://www.kimi.com/code/docs/en/kimi-code/models.html + ["k3-256k", TEXT_IMAGE], + ["kimi-k3", TEXT_IMAGE_VIDEO], + ["k3", TEXT_IMAGE_VIDEO], + ["kimi-for-coding", TEXT_IMAGE_VIDEO], + ["kimi-for-coding-highspeed", TEXT_IMAGE_VIDEO], + ["kimi-k2.7-code", TEXT_IMAGE_VIDEO], + ["kimi-k2.7-code-highspeed", TEXT_IMAGE_VIDEO], + ["kimi-k2.6", TEXT_IMAGE_VIDEO], + ["kimi-k2.5", TEXT_IMAGE_VIDEO], + + // 智谱 GLM. Reviewed 2026-08-13. + // Source: https://docs.bigmodel.cn/cn/guide/start/model-overview + ["glm-5.2", TEXT], + ["glm-5.1", TEXT], + ["glm-5", TEXT], + ["glm-5-turbo", TEXT], + ["glm-4.7", TEXT], + ["glm-4.7-flashx", TEXT], + ["glm-4.7-flash", TEXT], + ["glm-5v-turbo", TEXT_IMAGE_VIDEO], + ["glm-4.6v", TEXT_IMAGE_VIDEO], + ["glm-4.6v-flash", TEXT_IMAGE_VIDEO], + + // MiniMax. Reviewed 2026-08-13. + // Source: https://platform.minimaxi.com/docs/api-reference/api-overview + // Source: https://www.minimax.io/blog/minimax-m3 + ["MiniMax-M2.7", TEXT], + ["MiniMax-M2.7-highspeed", TEXT], + ["MiniMax-M2.5", TEXT], + ["MiniMax-M2.5-highspeed", TEXT], + ["MiniMax-M3", TEXT_IMAGE_VIDEO], + + // 豆包 / 火山方舟. Reviewed 2026-08-13. + // Source: https://www.volcengine.com/product/doubao + ["doubao-seed-evolving", TEXT_IMAGE], + ["doubao-seed-2-1-pro", TEXT_IMAGE], + ["doubao-seed-2-1-turbo", TEXT_IMAGE], + ["doubao-seed-2-0-pro-260215", TEXT_IMAGE_VIDEO], + ["doubao-seed-2-0-lite-260215", TEXT_IMAGE_VIDEO], + ["doubao-seed-2-0-mini-260215", TEXT_IMAGE_VIDEO], + + // 阶跃星辰. Reviewed 2026-08-13. + // Source: https://platform.stepfun.com/docs/zh/guides/models/overview + ["step-3.5-flash", TEXT], + ["step-3.5-flash-2603", TEXT], + ["step-2-mini", TEXT], + ["step-router-v1", TEXT], + ["step-3", TEXT_IMAGE], + ["step-r1-v-mini", TEXT_IMAGE], + ["step-1o-vision-32k", TEXT_IMAGE], + ["step-1v-8k", TEXT_IMAGE], + ["step-1v-32k", TEXT_IMAGE], + ["step-gui", TEXT_IMAGE], + ["step-1o-turbo-vision", TEXT_IMAGE_VIDEO], + + // 小米 MiMo. Reviewed 2026-08-13. + // Source: https://mimo.mi.com/docs + ["mimo-v2.5-pro", TEXT], + ["mimo-v2.5-pro-ultraspeed", TEXT], + ["mimo-v2.5", TEXT_IMAGE_VIDEO], + + // 美团 LongCat. Reviewed 2026-08-13. + // Source: https://longcat.chat/platform/docs/api/chat.html + ["LongCat-2.0", TEXT], + + // 蚂蚁 Ling / Ring / Ming. Reviewed 2026-08-13. + // Source: https://developer.ant-ling.com/zh-CN/docs/api-reference/ + ["Ling-3.0-flash", TEXT], + ["Ling-2.6-1T", TEXT], + ["Ling-2.6-flash", TEXT], + ["Ring-2.6-1T", TEXT], + ["Ming-Flash-Omni", TEXT_IMAGE_VIDEO], + + // 昆仑万维 Skywork. Reviewed 2026-08-13. + // Source: https://skywork.ai/slide/en/skyclaw-ai-code-generation-2064558443047112705 + ["skywork-ai/skyclaw-v1", TEXT], + ["skywork-ai/skyclaw-v1-lite", TEXT], + + // 百度 ERNIE. Reviewed 2026-08-13. + // Source: https://qianfan.cloud.baidu.com/qianfandev-docs/ + ["ernie-5.1", TEXT], + ["ernie-x1.1", TEXT], + ["ernie-x1.1-preview", TEXT], + ["ernie-4.5-turbo-128k", TEXT], + ["ernie-5.0", TEXT_IMAGE_VIDEO], + ["ernie-5.0-thinking-preview", TEXT_IMAGE_VIDEO], + ["ernie-5.0-thinking-latest", TEXT_IMAGE_VIDEO], + ["ernie-4.5-turbo-vl", TEXT_IMAGE_VIDEO], + ["ernie-4.5-turbo-vl-32k", TEXT_IMAGE_VIDEO], + + // 腾讯混元. Reviewed 2026-08-13. + // Source: https://cloud.tencent.com/document/product/1823/130051 + ["hy3", TEXT], + ["hy3-preview", TEXT], + + // 科大讯飞星火 / 星辰 Coding Plan. Reviewed 2026-08-13. + // Source: https://www.xfyun.cn/doc/spark/CodingPlan.html + ["xsparkx2agent", TEXT], + ["xsparkx2", TEXT], + ["xsparkx2flash", TEXT], + ["astron-code-latest", TEXT], + ["xopglm52", TEXT], + ["xopglm51", TEXT], + ["xopglm5", TEXT], + ["xopdeepseekv4pro", TEXT], + ["xopdeepseekv4flash", TEXT], + ["xopdeepseekv32", TEXT], + ["xopkimik26", TEXT], + ["xopkimik25", TEXT], + ["xminimaxm25", TEXT], + ["xopqwen35397b", TEXT], + ["xopqwen36v35b", TEXT], + ["xopqwen35v35b", TEXT], + ["xop3qwencodernext", TEXT], + ["xopglmv47flash", TEXT], + ["xopkimi27code", TEXT], + + // 商汤日日新. Reviewed 2026-08-13. + // Source: https://www.sensecore.cn/help/docs/model-as-a-service/nova/model/fusionllm/FusionLLMs + // Source: https://www.sensecore.cn/help/docs/model-as-a-service/nova/overview/compatible-mode + ["SenseNova-V6-Pro", TEXT_IMAGE], + ["SenseNova-V6-Reasoner", TEXT_IMAGE], + ["SenseNova-V6-5-Pro", TEXT_IMAGE_VIDEO], + ["SenseNova-V6-5-Turbo", TEXT_IMAGE_VIDEO], + ["SenseNova-V6-Turbo", TEXT_IMAGE_VIDEO], +]); + +export function getModelInputModalities( + model: string | null | undefined, +): readonly ModelInputModality[] { + if (typeof model !== "string") return TEXT; + return MODEL_INPUT_CAPABILITIES[model] ?? TEXT; +} + +export function requiredInputModalities( + messages: readonly Record[], +): readonly ModelInputModality[] { + const hasImage = messages.some((message) => + Array.isArray(message?.content) + && message.content.some((block: any) => block?.type === "image_url"), + ); + return hasImage ? TEXT_IMAGE : TEXT; +} + +export function coversInputModalities( + supported: readonly ModelInputModality[], + required: readonly ModelInputModality[], +): boolean { + return required.every((modality) => supported.includes(modality)); +} diff --git a/App/memmy-agent/tests/analytics/cloud-analytics.test.ts b/App/memmy-agent/tests/analytics/cloud-analytics.test.ts new file mode 100644 index 000000000..40e56c42b --- /dev/null +++ b/App/memmy-agent/tests/analytics/cloud-analytics.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; +import { + postAnalyticsEvents, + resolveAnalyticsUserModeFromConfig, +} from "../../src/analytics/cloud-analytics.js"; + +describe("cloud analytics event contract", () => { + it("derives account_byok when account identity and BYOK assignments coexist", () => { + expect(resolveAnalyticsUserModeFromConfig({ + app: { cloudUuid: "cloud-1", userId: "user-1" }, + modelAssignments: { + account: { ownerAccountId: "user-1" }, + byok: { embedding: "openai:model-1" }, + }, + })).toBe("account_byok"); + }); + + it("posts the installation id alongside the existing GA-style event contract", async () => { + const fetchImpl = vi.fn(async () => new Response(null, { status: 204 })); + + await postAnalyticsEvents({ + events: [{ + eventName: "memory_turn_completed", + eventTimeMillis: 1_700_000_000_000, + params: { duration_ms: 42 }, + }], + installationId: "install-1", + clientId: "ga-client-1", + userId: "user-1", + userMode: "account_byok", + appEnv: "prod", + appEdition: "cn", + baseUrl: "https://cloud.example.com", + fetchImpl, + }); + + const body = JSON.parse(String(fetchImpl.mock.calls[0]?.[1]?.body)); + expect(body).toMatchObject({ + clientId: "ga-client-1", + userId: "user-1", + installationId: "install-1", + }); + expect(body.events).toEqual([ + expect.objectContaining({ + eventName: "memory_turn_completed", + params: expect.objectContaining({ + engagement_time_msec: 100, + duration_ms: 42, + user_id: "user-1", + user_mode: "account_byok", + source: "memmy-agent", + app_env: "prod", + app_edition: "cn", + app_version: expect.any(String), + timestamp_micros: 1_700_000_000_000_000, + }), + }), + ]); + expect(body.events[0]).not.toHaveProperty("event_id"); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/loop-runner-integration.test.ts b/App/memmy-agent/tests/core/agent-runtime/loop-runner-integration.test.ts index 517225dff..ab1798981 100644 --- a/App/memmy-agent/tests/core/agent-runtime/loop-runner-integration.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/loop-runner-integration.test.ts @@ -61,6 +61,14 @@ function loop(p = provider(), extra: Record = {}): AgentLoop { }); } +async function waitUntil(predicate: () => boolean | Promise, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!(await predicate()) && Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(await predicate()).toBe(true); +} + afterEach(() => { vi.restoreAllMocks(); if (originalDataDir === undefined) delete process.env.MEMMY_AGENT_DATA_DIR; @@ -425,6 +433,161 @@ describe("AgentLoop direct processing", () => { expect(seenSpec.goalContinueMessage).toBeUndefined(); }); + it("consumes a TUI Goal Steer in the same Runner Turn and persists its identity", async () => { + const calls: Array<{ messages: Record[] }> = []; + let notifyFirstCall!: () => void; + const firstCallStarted = new Promise((resolve) => { + notifyFirstCall = resolve; + }); + let releaseFirstCall!: () => void; + const firstCallGate = new Promise((resolve) => { + releaseFirstCall = resolve; + }); + const p = { + generation: { maxTokens: 100 }, + getDefaultModel: () => "test-model", + chat: vi.fn(async (args: any) => { + calls.push({ messages: structuredClone(args.messages) }); + if (calls.length === 1) { + notifyFirstCall(); + await firstCallGate; + return new LLMResponse({ + content: "Initial Goal response", + usage: { prompt_tokens: 4, completion_tokens: 2, total_tokens: 6 }, + }); + } + return new LLMResponse({ + content: "Goal response after steer", + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + }); + }), + }; + const agent = loop(p); + agent.initializeRuntimeTools = vi.fn(async () => undefined); + vi.spyOn(agent, "scheduleGoalWork").mockImplementation(() => undefined); + const sessionKey = "websocket:tui-goal-runner"; + const chatId = "tui-goal-runner"; + const activeTurnId = "17171717-1717-4717-8717-171717171717"; + const clientRequestId = "18181818-1818-4818-8818-181818181818"; + agent.sessions.reserveWebuiSessionBinding(sessionKey, { + projectId: null, + cwd: fs.realpathSync(agent.workspace), + }); + const session = agent.sessions.getOrCreate(sessionKey); + session.metadata.webui = true; + session.metadata.webuiProjectId = null; + session.metadata.webuiWorkspaceCwd = fs.realpathSync(agent.workspace); + agent.sessions.save(session); + const goal = await agent.goalRuntime.create({ + sessionKey, + objective: "Complete the TUI Goal steer implementation", + tokenBudget: 1_000, + route: { + channel: "websocket", + chatId, + source: { kind: "tui", channel: "websocket" }, + }, + turnId: "goal-create-turn", + }); + agent.goalRuntime.releaseTurn(sessionKey, "goal-create-turn"); + while (agent.bus.outboundSize) await agent.bus.consumeOutbound(); + + const running = agent.run(); + expect(agent.goalRuntime.reserveWork(sessionKey, activeTurnId, "continuation")).toBe(true); + await agent.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Continue the active Goal", + metadata: { webui: true, turn_id: activeTurnId }, + internal: { + kind: "goal_continuation", + goalId: goal.goalId, + goalUpdatedAt: goal.updatedAt, + }, + sessionKeyOverride: sessionKey, + turnSource: { kind: "tui", channel: "websocket" }, + })); + await firstCallStarted; + await waitUntil(() => (agent.turnSlots.get(sessionKey) as any[])?.[0]?.acceptingSteer === true); + + await agent.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Adjust the implementation and keep the same Goal Turn", + metadata: { + webui: true, + client_request_id: clientRequestId, + webui_request_digest: "tui-goal-runner-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + })); + await waitUntil(() => ((agent.turnSlots.get(sessionKey) as any[])?.[0]?.pendingSteer.size ?? 0) === 1); + expect(agent.goalRuntime.inbox(sessionKey)).toEqual([]); + expect(await agent.getQueueSnapshot(sessionKey)).toMatchObject({ revision: 0, items: [] }); + releaseFirstCall(); + + await waitUntil(() => calls.length === 2); + await waitUntil(() => agent.sessions.getOrCreate(sessionKey).messages.some((message) => ( + message.client_request_id === clientRequestId + )), 5_000); + await waitUntil(() => !agent.isSessionBusy(sessionKey), 5_000); + agent.stop(); + await running; + + expect(JSON.stringify(calls[1].messages)).toContain( + "Adjust the implementation and keep the same Goal Turn", + ); + expect(JSON.stringify(calls[1].messages)).not.toContain(clientRequestId); + expect(JSON.stringify(calls[1].messages)).not.toContain("turn_source"); + expect(JSON.stringify(calls[1].messages)).not.toContain("turn_id"); + const persisted = agent.sessions.getOrCreate(sessionKey).messages; + expect(persisted.find((message) => message.client_request_id === clientRequestId)).toMatchObject({ + role: "user", + content: "Adjust the implementation and keep the same Goal Turn", + client_request_id: clientRequestId, + turn_id: activeTurnId, + turn_source: { kind: "tui", channel: "websocket" }, + }); + expect(persisted).toContainEqual(expect.objectContaining({ + role: "assistant", + content: "Goal response after steer", + })); + expect(agent.goalRuntime.get(sessionKey)).toMatchObject({ + goalId: goal.goalId, + objective: goal.objective, + status: "active", + tokensUsed: 14, + }); + expect(agent.goalRuntime.route(sessionKey)).toEqual({ + channel: "websocket", + chatId, + source: { kind: "tui", channel: "websocket" }, + }); + expect(agent.goalRuntime.inbox(sessionKey)).toEqual([]); + expect(await agent.getQueueSnapshot(sessionKey)).toMatchObject({ revision: 0, items: [] }); + const outbound = []; + while (agent.bus.outboundSize) outbound.push(await agent.bus.consumeOutbound()); + expect(outbound).toContainEqual(expect.objectContaining({ + metadata: expect.objectContaining({ + webuiMessageSteered: true, + clientRequestId, + turnId: activeTurnId, + }), + })); + expect(outbound).toContainEqual(expect.objectContaining({ + metadata: expect.objectContaining({ + turnEnd: true, + turn_id: activeTurnId, + goalId: goal.goalId, + goalOutcome: "active", + }), + })); + expect(outbound.some((message) => message.metadata?.webuiMessageQueued)).toBe(false); + }); + it("extracts document media before building prompt and keeps image media for multimodal content", async () => { const p = provider(["read it"]); const root = workspace(); @@ -435,7 +598,7 @@ describe("AgentLoop direct processing", () => { const agent = new AgentLoop({ provider: p, workspace: root, - model: "test-model", + model: "gpt-4.1", contextWindowTokens: 4096, sessionDir: path.join(root, "sessions"), }); diff --git a/App/memmy-agent/tests/core/agent-runtime/loop-save-turn.test.ts b/App/memmy-agent/tests/core/agent-runtime/loop-save-turn.test.ts index 2b1421789..111e80982 100644 --- a/App/memmy-agent/tests/core/agent-runtime/loop-save-turn.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/loop-save-turn.test.ts @@ -524,6 +524,31 @@ describe("AgentLoop turn persistence", () => { ]); }); + it("persists a read_file image as one path placeholder without the data URL", () => { + const loop = makeLoop(); + const session = new Session({ key: "read-file-image" }); + + loop.saveTurn( + session, + [{ + role: "tool", + tool_call_id: "read_image", + name: "read_file", + content: [{ + type: "image_url", + image_url: { url: "data:image/png;base64,abc" }, + meta: { path: "/tmp/image.png" }, + }], + }], + 0, + ); + + expect(session.messages[0].content).toEqual([ + { type: "text", text: "[image: /tmp/image.png]" }, + ]); + expect(JSON.stringify(session.messages)).not.toContain("data:image"); + }); + it("rehydrates runtime checkpoints with completed and pending tools", () => { const loop = makeLoop(); const session = new Session({ diff --git a/App/memmy-agent/tests/core/agent-runtime/loop-turn-admission.test.ts b/App/memmy-agent/tests/core/agent-runtime/loop-turn-admission.test.ts index 0e62b063a..e458b4e9f 100644 --- a/App/memmy-agent/tests/core/agent-runtime/loop-turn-admission.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/loop-turn-admission.test.ts @@ -34,6 +34,61 @@ async function waitUntil(predicate: () => boolean | Promise, timeoutMs expect(await predicate()).toBe(true); } +async function startBlockedGoalTurn( + loop: AgentLoop, + { + sessionKey, + chatId, + activeTurnId, + sourceKind, + }: { + sessionKey: string; + chatId: string; + activeTurnId: string; + sourceKind: "gui" | "tui"; + }, +): Promise<{ releaseActive: () => void; running: Promise }> { + loop.sessions.getOrCreate(sessionKey); + const source = { kind: sourceKind, channel: "websocket" } as const; + const goal = await loop.goalRuntime.create({ + sessionKey, + objective: "Keep working on the Goal", + route: { channel: "websocket", chatId, source }, + turnId: "goal-create-turn", + }); + loop.goalRuntime.releaseTurn(sessionKey, "goal-create-turn"); + while (loop.bus.outboundSize) await loop.bus.consumeOutbound(); + let releaseActive!: () => void; + const activeGate = new Promise((resolve) => { + releaseActive = resolve; + }); + loop.processMessageInternal = vi.fn(async (message: InboundMessage, _key, options: any) => { + if (message.internal?.kind === "goal_continuation") { + options.slot.acceptingSteer = true; + await activeGate; + } + options.slot.stopReason = "completed"; + return null; + }) as any; + const running = loop.run(); + expect(loop.goalRuntime.reserveWork(sessionKey, activeTurnId, "continuation")).toBe(true); + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Continue the Goal", + metadata: { turn_id: activeTurnId }, + internal: { + kind: "goal_continuation", + goalId: goal.goalId, + goalUpdatedAt: goal.updatedAt, + }, + sessionKeyOverride: sessionKey, + turnSource: source, + })); + await waitUntil(() => (loop.turnSlots.get(sessionKey) as any[])?.[0]?.acceptingSteer === true); + return { releaseActive, running }; +} + afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); @@ -145,6 +200,337 @@ describe("AgentLoop Turn admission", () => { await running; }); + it("admits a TUI Steer into the active TUI-owned Goal Turn before the Goal inbox", async () => { + const loop = makeLoop(); + const sessionKey = "websocket:tui-goal-steer"; + const chatId = "tui-goal-steer"; + const activeTurnId = "11111111-1111-4111-8111-111111111111"; + const clientRequestId = "12121212-1212-4212-8212-121212121212"; + loop.sessions.getOrCreate(sessionKey); + const goal = await loop.goalRuntime.create({ + sessionKey, + objective: "Keep implementing the Goal", + route: { + channel: "websocket", + chatId, + source: { kind: "tui", channel: "websocket" }, + }, + turnId: "goal-create-turn", + }); + loop.goalRuntime.releaseTurn(sessionKey, "goal-create-turn"); + while (loop.bus.outboundSize) await loop.bus.consumeOutbound(); + + let releaseActive!: () => void; + const activeGate = new Promise((resolve) => { + releaseActive = resolve; + }); + loop.processMessageInternal = vi.fn(async (message: InboundMessage, _key, options: any) => { + if (message.internal?.kind === "goal_continuation") { + options.slot.acceptingSteer = true; + await activeGate; + } + options.slot.stopReason = "completed"; + return null; + }) as any; + + const running = loop.run(); + expect(loop.goalRuntime.reserveWork(sessionKey, activeTurnId, "continuation")).toBe(true); + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Continue the Goal", + metadata: { turn_id: activeTurnId }, + internal: { + kind: "goal_continuation", + goalId: goal.goalId, + goalUpdatedAt: goal.updatedAt, + }, + sessionKeyOverride: sessionKey, + turnSource: { kind: "tui", channel: "websocket" }, + })); + await waitUntil(() => (loop.turnSlots.get(sessionKey) as any[])?.[0]?.acceptingSteer === true); + expect(await loop.getQueueSnapshot(sessionKey)).toMatchObject({ revision: 0, items: [] }); + + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Also verify the TUI path", + media: ["/tmp/tui-goal-reference.png"], + metadata: { + client_request_id: clientRequestId, + webui_request_digest: "tui-goal-steer-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + })); + + await waitUntil(() => ((loop.turnSlots.get(sessionKey) as any[])?.[0]?.pendingSteer.size ?? 0) === 1); + const slot = (loop.turnSlots.get(sessionKey) as any[])[0]; + expect(slot.pendingSteer.getNowait()).toMatchObject({ + content: "Also verify the TUI path", + media: ["/tmp/tui-goal-reference.png"], + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + metadata: { + client_request_id: clientRequestId, + turn_id: activeTurnId, + turnId: activeTurnId, + }, + }); + expect(loop.goalRuntime.inbox(sessionKey)).toEqual([]); + expect(await loop.getQueueSnapshot(sessionKey)).toMatchObject({ + revision: 0, + items: [], + startedItems: [], + }); + const outbound = []; + while (loop.bus.outboundSize) outbound.push(await loop.bus.consumeOutbound()); + expect(outbound.filter((message) => message.metadata?.webuiMessageSteered)).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ + clientRequestId, + turnId: activeTurnId, + }), + }), + ]); + expect(outbound.some((message) => message.metadata?.webuiMessageQueued)).toBe(false); + + releaseActive(); + await waitUntil(() => !loop.isSessionBusy(sessionKey)); + loop.stop(); + await running; + }); + + it("queues Enter, stale TUI Steer, and slash TUI Steer once during an active Goal", async () => { + const loop = makeLoop(); + const sessionKey = "websocket:tui-goal-fallback"; + const chatId = "tui-goal-fallback"; + const activeTurnId = "13131313-1313-4313-8313-131313131313"; + loop.sessions.getOrCreate(sessionKey); + const goal = await loop.goalRuntime.create({ + sessionKey, + objective: "Keep the Goal queue ordered", + route: { + channel: "websocket", + chatId, + source: { kind: "tui", channel: "websocket" }, + }, + turnId: "goal-create-turn", + }); + loop.goalRuntime.releaseTurn(sessionKey, "goal-create-turn"); + while (loop.bus.outboundSize) await loop.bus.consumeOutbound(); + + let releaseActive!: () => void; + const activeGate = new Promise((resolve) => { + releaseActive = resolve; + }); + loop.processMessageInternal = vi.fn(async (message: InboundMessage, _key, options: any) => { + if (message.internal?.kind === "goal_continuation") { + options.slot.acceptingSteer = true; + await activeGate; + } + options.slot.stopReason = "completed"; + return null; + }) as any; + + const running = loop.run(); + expect(loop.goalRuntime.reserveWork(sessionKey, activeTurnId, "continuation")).toBe(true); + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Continue the Goal", + metadata: { turn_id: activeTurnId }, + internal: { + kind: "goal_continuation", + goalId: goal.goalId, + goalUpdatedAt: goal.updatedAt, + }, + sessionKeyOverride: sessionKey, + turnSource: { kind: "tui", channel: "websocket" }, + })); + await waitUntil(() => (loop.turnSlots.get(sessionKey) as any[])?.[0]?.acceptingSteer === true); + + const messages = [ + new InboundMessage({ + channel: "websocket", + chatId, + content: "queue this with Enter", + metadata: { + webui: true, + client_request_id: "14141414-1414-4414-8414-141414141414", + webui_request_digest: "tui-goal-enter-digest", + }, + sessionKeyOverride: sessionKey, + turnSource: { kind: "tui", channel: "websocket" }, + }), + new InboundMessage({ + channel: "websocket", + chatId, + content: "stale correction", + metadata: { + webui: true, + client_request_id: "15151515-1515-4515-8515-151515151515", + webui_request_digest: "tui-goal-stale-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: "stale-turn-id", + turnSource: { kind: "tui", channel: "websocket" }, + }), + new InboundMessage({ + channel: "websocket", + chatId, + content: "/help", + metadata: { + webui: true, + client_request_id: "16161616-1616-4616-8616-161616161616", + webui_request_digest: "tui-goal-slash-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + }), + ]; + for (const message of messages) await loop.bus.publishInbound(message); + + await waitUntil(() => loop.goalRuntime.inbox(sessionKey).length === messages.length); + expect(loop.goalRuntime.inbox(sessionKey).map((entry) => entry.content)).toEqual([ + "queue this with Enter", + "stale correction", + "/help", + ]); + expect((loop.turnSlots.get(sessionKey) as any[])[0].pendingSteer.size).toBe(0); + expect(await loop.getQueueSnapshot(sessionKey)).toMatchObject({ + revision: 3, + items: [ + expect.objectContaining({ content: "queue this with Enter" }), + expect.objectContaining({ content: "stale correction" }), + expect.objectContaining({ content: "/help" }), + ], + }); + const outbound = []; + while (loop.bus.outboundSize) outbound.push(await loop.bus.consumeOutbound()); + expect(outbound.filter((message) => message.metadata?.webuiMessageQueued)).toHaveLength(3); + expect(outbound.some((message) => message.metadata?.webuiMessageSteered)).toBe(false); + + releaseActive(); + await waitUntil(() => !loop.isSessionBusy(sessionKey)); + loop.stop(); + await running; + }); + + it("does not let a TUI Steer enter a GUI-owned Goal Turn", async () => { + const loop = makeLoop(); + const sessionKey = "websocket:gui-owned-goal"; + const chatId = "gui-owned-goal"; + const activeTurnId = "19191919-1919-4919-8919-191919191919"; + const clientRequestId = "20202020-2020-4020-8020-202020202020"; + const { releaseActive, running } = await startBlockedGoalTurn(loop, { + sessionKey, + chatId, + activeTurnId, + sourceKind: "gui", + }); + + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Do not inject this into the GUI Turn", + metadata: { + webui: true, + client_request_id: clientRequestId, + webui_request_digest: "gui-owned-goal-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + })); + + await waitUntil(() => loop.goalRuntime.inbox(sessionKey).length === 1); + expect(loop.goalRuntime.inbox(sessionKey)).toEqual([ + expect.objectContaining({ id: clientRequestId, content: "Do not inject this into the GUI Turn" }), + ]); + expect((loop.turnSlots.get(sessionKey) as any[])[0].pendingSteer.size).toBe(0); + expect(await loop.getQueueSnapshot(sessionKey)).toMatchObject({ revision: 1 }); + const outbound = []; + while (loop.bus.outboundSize) outbound.push(await loop.bus.consumeOutbound()); + expect(outbound.filter((message) => message.metadata?.webuiMessageQueued)).toHaveLength(1); + expect(outbound.some((message) => message.metadata?.webuiMessageSteered)).toBe(false); + + releaseActive(); + await waitUntil(() => !loop.isSessionBusy(sessionKey)); + loop.stop(); + await running; + }); + + it("queues TUI Goal Steer when the source channel mismatches or the Turn is settling", async () => { + const loop = makeLoop(); + const sessionKey = "websocket:tui-goal-race"; + const chatId = "tui-goal-race"; + const activeTurnId = "21212121-2121-4121-8121-212121212121"; + const { releaseActive, running } = await startBlockedGoalTurn(loop, { + sessionKey, + chatId, + activeTurnId, + sourceKind: "tui", + }); + + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "Wrong trusted source channel", + metadata: { + webui: true, + client_request_id: "22222222-2222-4222-8222-222222222222", + webui_request_digest: "tui-goal-channel-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "cli" }, + })); + await waitUntil(() => loop.goalRuntime.inbox(sessionKey).length === 1); + + const slot = (loop.turnSlots.get(sessionKey) as any[])[0]; + slot.acceptingSteer = false; + slot.state = "settling"; + await loop.bus.publishInbound(new InboundMessage({ + channel: "websocket", + chatId, + content: "The Turn is already settling", + metadata: { + webui: true, + client_request_id: "23232323-2323-4323-8323-232323232323", + webui_request_digest: "tui-goal-settling-digest", + }, + sessionKeyOverride: sessionKey, + turnAdmission: "steer", + expectedTurnId: activeTurnId, + turnSource: { kind: "tui", channel: "websocket" }, + })); + + await waitUntil(() => loop.goalRuntime.inbox(sessionKey).length === 2); + expect(loop.goalRuntime.inbox(sessionKey).map((entry) => entry.content)).toEqual([ + "Wrong trusted source channel", + "The Turn is already settling", + ]); + expect(slot.pendingSteer.size).toBe(0); + expect(await loop.getQueueSnapshot(sessionKey)).toMatchObject({ revision: 2 }); + const outbound = []; + while (loop.bus.outboundSize) outbound.push(await loop.bus.consumeOutbound()); + expect(outbound.filter((message) => message.metadata?.webuiMessageQueued)).toHaveLength(2); + expect(outbound.some((message) => message.metadata?.webuiMessageSteered)).toBe(false); + + releaseActive(); + await waitUntil(() => !loop.isSessionBusy(sessionKey)); + loop.stop(); + await running; + }); + it("degrades steer to queue when route or active state does not match", async () => { const loop = makeLoop(); let releaseActive!: () => void; diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-core.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-core.test.ts index d213a2a75..8f9390a1d 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-core.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-core.test.ts @@ -218,7 +218,7 @@ describe("AgentRunner expanded behavior", () => { new AgentRunSpec({ initialMessages: [{ role: "user", content: "describe image" }], tools, - model: "test-model", + model: "gpt-4.1", maxIterations: 2, }), ); diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-fallback.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-fallback.test.ts index a4ac2a726..1b162d6cb 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-fallback.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-fallback.test.ts @@ -541,6 +541,74 @@ describe("FallbackProvider failover", () => { expect(provider.primaryTrippedAt).toBeNull(); }); + it("skips a text-only fallback candidate for a retryable image request", async () => { + const primaryError = makeResponse("primary timeout", "error", { errorKind: "timeout" }); + const primary = new FakeProvider("primary", primaryError); + const factory = vi.fn(() => new FakeProvider("fallback", makeResponse("must not run"))); + const provider = new FallbackProvider({ + primary, + fallbackPresets: [fallback("agent_chat")], + providerFactory: factory, + }); + + const result = await provider.chat({ messages: [{ + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,one" } }], + }] }); + + expect(result).toBe(primaryError); + expect(factory).not.toHaveBeenCalled(); + }); + + it("skips incompatible candidates and calls the first image-capable fallback", async () => { + const primary = new FakeProvider( + "primary", + makeResponse("primary timeout", "error", { errorKind: "timeout" }), + ); + const imageFallback = new FakeProvider("image-fallback", makeResponse("fallback ok")); + const factory = vi.fn((preset: { model: string }) => { + expect(preset.model).toBe("gpt-4.1"); + return imageFallback; + }); + const provider = new FallbackProvider({ + primary, + fallbackPresets: [fallback("agent_chat"), fallback("gpt-4.1")], + providerFactory: factory, + }); + + const result = await provider.chat({ messages: [{ + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,one" } }], + }] }); + + expect(result.content).toBe("fallback ok"); + expect(factory).toHaveBeenCalledTimes(1); + expect(imageFallback.chatCalls).toHaveLength(1); + expect(imageFallback.chatCalls[0].model).toBe("gpt-4.1"); + }); + + it("keeps the circuit-open error when every image fallback is incompatible", async () => { + const primary = new FakeProvider("primary", makeResponse("unused")); + const factory = vi.fn(); + const provider = new FallbackProvider({ + primary, + fallbackPresets: [fallback("agent_chat")], + providerFactory: factory, + }); + provider.primaryFailures = 3; + provider.primaryTrippedAt = Date.now(); + + const result = await provider.chat({ messages: [{ + role: "user", + content: [{ type: "image_url", image_url: { url: "data:image/png;base64,one" } }], + }] }); + + expect(result.finishReason).toBe("error"); + expect(result.content).toContain("circuit open and no fallbacks available"); + expect(primary.chatCalls).toHaveLength(0); + expect(factory).not.toHaveBeenCalled(); + }); + it("does not fail over on auth errors", async () => { const primary = new FakeProvider( "primary", diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-image-text-fallback.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-image-text-fallback.test.ts index 319fdc38e..689c8b621 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-image-text-fallback.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-image-text-fallback.test.ts @@ -20,13 +20,17 @@ function imageMessage(url = "data:image/png;base64,one", mediaPath = "/media/one }; } -function modelContext(source: "account" | "byok" = "account") { +function modelContext( + source: "account" | "byok" = "account", + model = "agent_chat", + provider = "memmy_account", +) { return { presetId: source === "account" ? "account-default" : "custom", - provider: "memmy_account", + provider, endpointId: "chat", protocol: "openai-chat-completions" as const, - model: "agent_chat", + model, source, ownerAccountId: source === "account" ? "account-1" : null, capability: "agent" as const, @@ -105,10 +109,9 @@ class ImageTool extends StaticTool { } describe("AgentRunner account image-to-text fallback", () => { - it("uses the exact main, image2text, main sequence with temporary descriptions", async () => { + it("uses image2text before the text-only account model and keeps descriptions temporary", async () => { const provider = new AccountFallbackProvider( [ - unsupported({ prompt_tokens: 3, completion_tokens: 1 }), new LLMResponse({ content: "final answer", usage: { prompt_tokens: 5, completion_tokens: 2 } }), ], [new LLMResponse({ @@ -126,15 +129,15 @@ describe("AgentRunner account image-to-text fallback", () => { actualModelContext: modelContext(), })); - expect(provider.events).toEqual(["agent_chat", "image2text", "agent_chat"]); - expect(provider.mainCalls).toHaveLength(2); + expect(provider.events).toEqual(["image2text", "agent_chat"]); + expect(provider.mainCalls).toHaveLength(1); expect(provider.imageCalls).toHaveLength(1); expect(provider.imageCalls[0].messages).toHaveLength(1); expect(provider.imageCalls[0].messages[0]).toMatchObject({ role: "user" }); expect(provider.imageCalls[0].messages[0].content.filter((block: any) => block.type === "image_url")) .toHaveLength(1); - const retryContent = provider.mainCalls[1].messages[0].content; + const retryContent = provider.mainCalls[0].messages[0].content; expect(retryContent.some((block: any) => block.type === "image_url")).toBe(false); expect(retryContent).toEqual(expect.arrayContaining([ { type: "text", text: "[Image 1]" }, @@ -143,17 +146,114 @@ describe("AgentRunner account image-to-text fallback", () => { text: expect.stringContaining(""), }), ])); - expect(JSON.stringify(provider.mainCalls[1].messages)).not.toContain("/media/one.png"); - expect(JSON.stringify(provider.mainCalls[1].messages)).not.toContain('source="image2text"'); + expect(JSON.stringify(provider.mainCalls[0].messages)).not.toContain("/media/one.png"); + expect(JSON.stringify(provider.mainCalls[0].messages)).not.toContain('source="image2text"'); expect(initialMessages).toEqual(original); expect(result.messages[0]).toEqual(original[0]); expect(result.finalContent).toBe("final answer"); - expect(result.usage).toEqual({ prompt_tokens: 15, completion_tokens: 7 }); + expect(result.usage).toEqual({ prompt_tokens: 12, completion_tokens: 6 }); + }); + + it("sends images directly to a model whose exact capability entry includes image", async () => { + const provider = new AccountFallbackProvider( + [new LLMResponse({ content: "native answer" })], + [], + ); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "gpt-4.1", + actualModelContext: modelContext("account", "gpt-4.1"), + })); + + expect(result.finalContent).toBe("native answer"); + expect(provider.events).toEqual(["gpt-4.1"]); + expect(provider.imageCalls).toHaveLength(0); + expect(JSON.stringify(provider.mainCalls[0].messages)).toContain('"type":"image_url"'); + }); + + it("uses the account fallback defensively when a declared image model rejects the image", async () => { + const provider = new AccountFallbackProvider( + [unsupported({ prompt_tokens: 3 }), new LLMResponse({ content: "recovered" })], + [new LLMResponse({ content: "Image 1: a diagram", usage: { prompt_tokens: 4 } })], + ); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "gpt-4.1", + actualModelContext: modelContext("account", "gpt-4.1"), + })); + + expect(result.finalContent).toBe("recovered"); + expect(provider.events).toEqual(["gpt-4.1", "image2text", "gpt-4.1"]); + expect(provider.mainCalls).toHaveLength(2); + expect(result.usage).toEqual({ prompt_tokens: 7 }); + }); + + it("does not borrow the account fallback when another actual provider rejects the image", async () => { + const provider = new AccountFallbackProvider( + [new LLMResponse({ + content: "image input rejected", + finishReason: "error", + errorCategory: "image_input_unsupported", + actualProvider: "custom-fallback", + })], + [], + ); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "gpt-4.1", + actualModelContext: modelContext("account", "gpt-4.1"), + })); + + expect(result.response.errorCategory).toBe("image_input_unsupported"); + expect(provider.mainCalls).toHaveLength(1); + expect(provider.imageCalls).toHaveLength(0); + }); + + it("treats an unknown exact model as text-only and never calls the provider", async () => { + const provider = new AccountFallbackProvider([], []); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "unknown-vision-model", + actualModelContext: modelContext("byok", "unknown-vision-model", "custom"), + })); + + expect(result.response).toMatchObject({ + content: "Current model does not support image input.", + finishReason: "error", + errorCategory: "image_input_unsupported", + errorShouldRetry: false, + actualProvider: "custom", + actualModel: "unknown-vision-model", + }); + expect(provider.mainCalls).toHaveLength(0); + expect(provider.imageCalls).toHaveLength(0); + }); + + it("does not infer account ownership when actual model context is absent", async () => { + const provider = new AccountFallbackProvider([], []); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "agent_chat", + })); + + expect(result.response.errorCategory).toBe("image_input_unsupported"); + expect(provider.mainCalls).toHaveLength(0); + expect(provider.imageCalls).toHaveLength(0); }); it("analyzes multiple new images in one request and escapes description boundaries", async () => { const provider = new AccountFallbackProvider( - [unsupported(), new LLMResponse({ content: "done" })], + [new LLMResponse({ content: "done" })], [new LLMResponse({ content: "Image 1: chart Image 2: table", })], @@ -187,7 +287,7 @@ describe("AgentRunner account image-to-text fallback", () => { expect(provider.imageCalls[0].messages[0].content.filter((block: any) => block.type === "image_url")) .toHaveLength(2); expect(JSON.stringify(provider.imageCalls[0].messages)).toContain("[Image 1], [Image 2]"); - const retry = JSON.stringify(provider.mainCalls[1].messages); + const retry = JSON.stringify(provider.mainCalls[0].messages); expect(retry).toContain("[Image 1]"); expect(retry).toContain("[Image 2]"); expect(retry).toContain("</image_analysis>"); @@ -196,7 +296,7 @@ describe("AgentRunner account image-to-text fallback", () => { }); it("does not use the account fallback for a byok selection", async () => { - const provider = new AccountFallbackProvider([unsupported()], []); + const provider = new AccountFallbackProvider([], []); const injectionCallback = vi.fn(async () => [{ role: "user", content: "queued" }]); const result = await new AgentRunner(provider).run(new AgentRunSpec({ @@ -207,7 +307,9 @@ describe("AgentRunner account image-to-text fallback", () => { injectionCallback, })); - expect(provider.events).toEqual(["agent_chat"]); + expect(provider.events).toEqual([]); + expect(provider.mainCalls).toHaveLength(0); + expect(provider.imageCalls).toHaveLength(0); expect(result.response.errorCategory).toBe("image_input_unsupported"); expect(result.stopReason).toBe("error"); expect(result.hadInjections).toBe(false); @@ -216,7 +318,7 @@ describe("AgentRunner account image-to-text fallback", () => { it("returns a structured image analysis error without calling main again", async () => { const provider = new AccountFallbackProvider( - [unsupported({ prompt_tokens: 2 })], + [], [new LLMResponse({ content: "image analysis upstream failed", finishReason: "error", @@ -233,7 +335,7 @@ describe("AgentRunner account image-to-text fallback", () => { actualModelContext: modelContext(), })); - expect(provider.events).toEqual(["agent_chat", "image2text"]); + expect(provider.events).toEqual(["image2text"]); expect(result.response).toMatchObject({ errorCategory: "image_analysis_failed", actualProvider: "memmy_account", @@ -241,12 +343,12 @@ describe("AgentRunner account image-to-text fallback", () => { failedProvider: "memmy_account", failedModel: "image2text", }); - expect(result.usage).toEqual({ prompt_tokens: 6 }); + expect(result.usage).toEqual({ prompt_tokens: 4 }); }); it("preserves quota errors and the internal failed model", async () => { const provider = new AccountFallbackProvider( - [unsupported()], + [], [new LLMResponse({ content: "quota exhausted", finishReason: "error", @@ -268,7 +370,7 @@ describe("AgentRunner account image-to-text fallback", () => { actualModel: "agent_chat", failedModel: "image2text", }); - expect(provider.mainCalls).toHaveLength(1); + expect(provider.mainCalls).toHaveLength(0); expect(result.hadInjections).toBe(false); expect(injectionCallback).not.toHaveBeenCalled(); }); @@ -276,7 +378,6 @@ describe("AgentRunner account image-to-text fallback", () => { it("reuses one image description through a tool iteration", async () => { const provider = new AccountFallbackProvider( [ - unsupported(), new LLMResponse({ content: "checking", toolCalls: [new ToolCallRequest({ id: "call-1", name: "inspect", arguments: {} })], @@ -300,8 +401,9 @@ describe("AgentRunner account image-to-text fallback", () => { expect(result.finalContent).toBe("done"); expect(provider.imageCalls).toHaveLength(1); - expect(provider.mainCalls).toHaveLength(3); - for (const call of provider.mainCalls.slice(1)) { + expect(provider.events).toEqual(["image2text", "agent_chat", "agent_chat"]); + expect(provider.mainCalls).toHaveLength(2); + for (const call of provider.mainCalls) { expect(JSON.stringify(call.messages)).toContain("Image 1: a receipt"); expect(JSON.stringify(call.messages)).not.toContain('"type":"image_url"'); } @@ -310,13 +412,11 @@ describe("AgentRunner account image-to-text fallback", () => { it("keeps existing labels and analyzes only a newly injected image", async () => { const provider = new AccountFallbackProvider( [ - unsupported(), new LLMResponse({ content: "checking", toolCalls: [new ToolCallRequest({ id: "call-1", name: "inspect", arguments: {} })], finishReason: "tool_calls", }), - unsupported(), new LLMResponse({ content: "done" }), ], [ @@ -345,10 +445,8 @@ describe("AgentRunner account image-to-text fallback", () => { expect(result.finalContent).toBe("done"); expect(provider.events).toEqual([ - "agent_chat", "image2text", "agent_chat", - "agent_chat", "image2text", "agent_chat", ]); @@ -358,17 +456,15 @@ describe("AgentRunner account image-to-text fallback", () => { ))).toEqual([1, 1]); expect(JSON.stringify(provider.imageCalls[0].messages)).toContain("[Image 1]"); expect(JSON.stringify(provider.imageCalls[1].messages)).toContain("[Image 2]"); - expect(JSON.stringify(provider.mainCalls[2].messages)).toContain("Image 1: a receipt"); - expect(JSON.stringify(provider.mainCalls[2].messages)).toContain("data:image/png;base64,two"); - expect(JSON.stringify(provider.mainCalls[3].messages)).toContain("Image 1: a receipt"); - expect(JSON.stringify(provider.mainCalls[3].messages)).toContain("Image 2: a delivery label"); - expect(JSON.stringify(provider.mainCalls[3].messages)).not.toContain('"type":"image_url"'); + expect(JSON.stringify(provider.imageCalls[1].messages)).toContain("data:image/png;base64,two"); + expect(JSON.stringify(provider.mainCalls[1].messages)).toContain("Image 1: a receipt"); + expect(JSON.stringify(provider.mainCalls[1].messages)).toContain("Image 2: a delivery label"); + expect(JSON.stringify(provider.mainCalls[1].messages)).not.toContain('"type":"image_url"'); }); it("reuses image descriptions for the max-iteration finalization request", async () => { const provider = new AccountFallbackProvider( [ - unsupported(), new LLMResponse({ content: "checking", toolCalls: [new ToolCallRequest({ id: "call-1", name: "inspect", arguments: {} })], @@ -392,7 +488,7 @@ describe("AgentRunner account image-to-text fallback", () => { })); expect(result.finalContent).toBe("finalized"); - expect(provider.events).toEqual(["agent_chat", "image2text", "agent_chat", "agent_chat"]); + expect(provider.events).toEqual(["image2text", "agent_chat", "agent_chat"]); expect(JSON.stringify(provider.mainCalls.at(-1)?.messages)).toContain("Image 1: a receipt"); expect(JSON.stringify(provider.mainCalls.at(-1)?.messages)).not.toContain('"type":"image_url"'); }); @@ -405,7 +501,6 @@ describe("AgentRunner account image-to-text fallback", () => { toolCalls: [new ToolCallRequest({ id: "call-1", name: "inspect", arguments: {} })], finishReason: "tool_calls", }), - unsupported(), new LLMResponse({ content: "finalized from tool image" }), ], [new LLMResponse({ content: "Image 1: a shipping label" })], @@ -424,7 +519,7 @@ describe("AgentRunner account image-to-text fallback", () => { })); expect(result.finalContent).toBe("finalized from tool image"); - expect(provider.events).toEqual(["agent_chat", "agent_chat", "image2text", "agent_chat"]); + expect(provider.events).toEqual(["agent_chat", "image2text", "agent_chat"]); expect(JSON.stringify(provider.imageCalls[0].messages)).toContain("data:image/png;base64,tool"); expect(JSON.stringify(provider.mainCalls.at(-1)?.messages)).toContain("Image 1: a shipping label"); expect(JSON.stringify(provider.mainCalls.at(-1)?.messages)).not.toContain('"type":"image_url"'); @@ -461,8 +556,8 @@ describe("AgentRunner account image-to-text fallback", () => { initialMessages: [imageMessage()], provider, hook: new StreamingHook(), - model: "agent_chat", - actualModelContext: modelContext(), + model: "gpt-4.1", + actualModelContext: modelContext("account", "gpt-4.1"), })); expect(deltas).toEqual(["partial answer"]); @@ -473,7 +568,7 @@ describe("AgentRunner account image-to-text fallback", () => { it("aborts image2text and does not start the retrying main request", async () => { const controller = new AbortController(); - const provider = new AccountFallbackProvider([unsupported()], []); + const provider = new AccountFallbackProvider([], []); provider.runAccountImageTextFallback = vi.fn(async ({ signal }: AccountImageTextFallbackArgs) => ( new Promise((resolve) => { signal?.addEventListener("abort", () => resolve(new LLMResponse({ @@ -494,6 +589,6 @@ describe("AgentRunner account image-to-text fallback", () => { })); expect(result.stopReason).toBe("cancelled"); - expect(provider.mainCalls).toHaveLength(1); + expect(provider.mainCalls).toHaveLength(0); }); }); diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-injections.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-injections.test.ts index 4f767ecf4..0f6da71e4 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-injections.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-injections.test.ts @@ -251,7 +251,7 @@ describe("AgentRunner injection checkpoints", () => { captured.push(messages.map((msg: any) => ({ ...msg }))); return new LLMResponse({ content: calls === 1 ? "first answer" : "second answer" }); }); - const loop = new AgentLoop({ bus: new MessageBus(), provider, workspace: root, model: "test-model" }); + const loop = new AgentLoop({ bus: new MessageBus(), provider, workspace: root, model: "gpt-4.1" }); loop.tools.getDefinitions = vi.fn(() => []); const pending = new AsyncQueue(); pending.put(inbound("", { media: [imagePath] })); @@ -275,6 +275,7 @@ describe("AgentRunner injection checkpoints", () => { const result = await new AgentRunner(provider).run(new AgentRunSpec({ messages: [{ role: "user", content: "hello" }], + model: "gpt-4.1", provider, tools: makeTools(), maxIterations: 5, diff --git a/App/memmy-agent/tests/core/agent-runtime/subagent.test.ts b/App/memmy-agent/tests/core/agent-runtime/subagent.test.ts index 6a46fee80..0fb598ca7 100644 --- a/App/memmy-agent/tests/core/agent-runtime/subagent.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/subagent.test.ts @@ -6,6 +6,7 @@ import { AgentHookContext } from "../../../src/core/agent-runtime/hook.js"; import { AgentRunResult } from "../../../src/core/agent-runtime/runner.js"; import { SubagentManager, SubagentStatus, SubagentHook } from "../../../src/core/agent-runtime/subagent.js"; import { MessageBus } from "../../../src/core/runtime-messages/queue.js"; +import { LLMProvider, LLMResponse, ToolCallRequest } from "../../../src/providers/base.js"; import { DEFAULT_MAX_TOKENS } from "../../../src/token-budget.js"; function tmpDir(): string { @@ -117,11 +118,23 @@ describe("SubagentManager", () => { return new AgentRunResult({ finalContent: "done", messages: [], stopReason: "completed" }); }); sm.announceResult = vi.fn(async () => undefined) as any; + const actualModelContext = { + presetId: "account-default", + provider: "memmy_account", + endpointId: "platform", + protocol: "openai-chat-completions" as const, + model: "agent_chat", + source: "account" as const, + ownerAccountId: "account-1", + capability: "agent" as const, + capabilities: ["agent" as const], + }; const out = await sm.spawn({ task: "A long task that needs a short display label", sessionKey: "cli:direct", temperature: 0.9, + actualModelContext, }); expect(out).toContain("started"); expect(sm.getRunningCount()).toBe(1); @@ -144,10 +157,85 @@ describe("SubagentManager", () => { expect(seenSpec.llmTimeoutS).toBe(0); expect(seenSpec.abortSignal).toBeInstanceOf(AbortSignal); expect(seenSpec.checkpointCallback).toBeTypeOf("function"); + expect(seenSpec.actualModelContext).toEqual(actualModelContext); + expect(Object.isFrozen(seenSpec.actualModelContext)).toBe(true); + expect(Object.isFrozen(seenSpec.actualModelContext.capabilities)).toBe(true); expect(sm.getRunningCount()).toBe(0); expect(sm.getRunningCountBySession("cli:direct")).toBe(0); }); + it("uses inherited account model context when a subagent read_file result contains an image", async () => { + const workspace = tmpDir(); + const imagePath = path.join(workspace, "input.png"); + fs.writeFileSync(imagePath, Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(16), + ])); + + class AccountProvider extends LLMProvider { + mainCalls: any[] = []; + imageCalls: any[] = []; + + getDefaultModel(): string { + return "agent_chat"; + } + + supportsAccountImageTextFallback(): boolean { + return true; + } + + async chat(args: any): Promise { + this.mainCalls.push(args); + if (this.mainCalls.length === 1) { + return new LLMResponse({ + content: "", + finishReason: "tool_calls", + toolCalls: [new ToolCallRequest({ + id: "read-image", + name: "read_file", + arguments: { path: imagePath }, + })], + }); + } + return new LLMResponse({ content: "The image is a test fixture." }); + } + + async runAccountImageTextFallback(args: any): Promise { + this.imageCalls.push(args); + return new LLMResponse({ content: "Image 1: a test fixture" }); + } + } + + const provider = new AccountProvider(); + const sm = manager({ provider, workspace, model: "agent_chat", maxIterations: 3 }); + sm.announceResult = vi.fn(async () => undefined) as any; + + await sm.spawn({ + task: `Read and describe ${imagePath}`, + workspace, + provider, + model: "agent_chat", + actualModelContext: { + presetId: "account-default", + provider: "memmy_account", + endpointId: "platform", + protocol: "openai-chat-completions", + model: "agent_chat", + source: "account", + ownerAccountId: "account-1", + capability: "agent", + capabilities: ["agent"], + }, + }); + await Promise.all([...sm.runningTasks.values()]); + + expect(provider.mainCalls).toHaveLength(2); + expect(provider.imageCalls).toHaveLength(1); + expect(JSON.stringify(provider.imageCalls[0].messages)).toContain("data:image/png;base64"); + expect(JSON.stringify(provider.mainCalls[1].messages)).toContain("Image 1: a test fixture"); + expect(JSON.stringify(provider.mainCalls[1].messages)).not.toContain('"type":"image_url"'); + }); + it("forwards an explicit provider maxTokens value without replacing it", async () => { const sm = manager({ provider: { diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts index 61a439d32..fb57219fa 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/filesystem-tools.test.ts @@ -118,7 +118,7 @@ describe("ReadFileTool", () => { expect(result[0].type).toBe("image_url"); expect(result[0].image_url.url).toMatch(/^data:image\/png;base64,/); expect(result[0].meta.path).toBe(file); - expect(result[1]).toEqual({ type: "text", text: `(Image file: ${file})` }); + expect(result).toHaveLength(1); }); it("reports missing files", async () => { diff --git a/App/memmy-agent/tests/core/session/workspace-environment.test.ts b/App/memmy-agent/tests/core/session/workspace-environment.test.ts new file mode 100644 index 000000000..1a84d1bff --- /dev/null +++ b/App/memmy-agent/tests/core/session/workspace-environment.test.ts @@ -0,0 +1,186 @@ +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { GOAL_STATE_KEY } from "../../../src/core/session/goal-state.js"; +import { Session } from "../../../src/core/session/manager.js"; +import { + captureGoalWorkspaceBaseline, + createOrCheckoutWorkspaceBranch, + readWorkspaceEnvironment, + readWorkspaceFileDiff, + switchWorkspaceBranch, + type WorkspaceEnvironmentContext, +} from "../../../src/core/session/workspace-environment.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function git(root: string, args: string[]): void { + const result = spawnSync("git", args, { cwd: root, encoding: "utf8" }); + if (result.status !== 0) throw new Error(String(result.stderr || "git failed")); +} + +function repository(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-workspace-environment-")); + roots.push(root); + git(root, ["init"]); + git(root, ["config", "user.name", "Memmy Test"]); + git(root, ["config", "user.email", "memmy@example.test"]); + fs.writeFileSync(path.join(root, "tracked.txt"), "base\n", "utf8"); + git(root, ["add", "tracked.txt"]); + git(root, ["commit", "-m", "base"]); + return root; +} + +function sessionFor(root: string): Session { + const session = new Session({ key: "websocket:environment-test" }); + session.metadata.webui = true; + session.metadata.webuiProjectId = null; + session.metadata.webuiWorkspaceCwd = fs.realpathSync(root); + return session; +} + +function contextFor(session: Session): WorkspaceEnvironmentContext { + return { + scope: { kind: "session", key: session.key }, + cwd: String(session.metadata.webuiWorkspaceCwd), + metadata: session.metadata, + }; +} + +function activateGoal(session: Session, goalId: string): void { + const now = new Date().toISOString(); + session.metadata[GOAL_STATE_KEY] = { + goalId, + objective: "Implement environment panel", + status: "active", + tokenBudget: null, + tokensUsed: 0, + timeUsedSeconds: 0, + createdAt: now, + updatedAt: now, + }; +} + +describe("workspace environment", () => { + it("attributes Goal changes without claiming pre-existing dirty files", async () => { + const root = repository(); + const session = sessionFor(root); + const goalId = "8f59f58a-7295-4c34-8e03-55e7035a5a8d"; + fs.writeFileSync(path.join(root, "tracked.txt"), "dirty before goal\n", "utf8"); + + await captureGoalWorkspaceBaseline(session, goalId); + activateGoal(session, goalId); + fs.writeFileSync(path.join(root, "goal-file.ts"), "export const goal = true;\n", "utf8"); + + const { snapshot, files } = await readWorkspaceEnvironment(contextFor(session)); + expect(snapshot).toMatchObject({ + status: "ready", + repository: { worktree: "dirty" }, + changes: { file_count: 2, untracked: 1 }, + goal: { + goal_id: goalId, + goal_files: 1, + preexisting_files: 1, + uncertain_files: 0, + baseline_status: "captured", + }, + }); + expect(files.find((file) => file.path === "tracked.txt")?.attribution).toBe("preexisting"); + expect(files.find((file) => file.path === "goal-file.ts")?.attribution).toBe("goal"); + }); + + it("marks a pre-existing dirty file uncertain when it changes during the Goal", async () => { + const root = repository(); + const session = sessionFor(root); + const goalId = "8f59f58a-7295-4c34-8e03-55e7035a5a8d"; + fs.writeFileSync(path.join(root, "tracked.txt"), "dirty before goal\n", "utf8"); + await captureGoalWorkspaceBaseline(session, goalId); + activateGoal(session, goalId); + fs.writeFileSync(path.join(root, "tracked.txt"), "changed during goal\n", "utf8"); + + const { snapshot, files } = await readWorkspaceEnvironment(contextFor(session)); + expect(files[0]?.attribution).toBe("uncertain"); + expect(snapshot.goal).toMatchObject({ uncertain_files: 1, completion_audit: "risk" }); + }); + + it("treats staging a pre-existing dirty file as a Goal-time mutation", async () => { + const root = repository(); + const session = sessionFor(root); + const goalId = "8f59f58a-7295-4c34-8e03-55e7035a5a8d"; + fs.writeFileSync(path.join(root, "tracked.txt"), "dirty before goal\n", "utf8"); + await captureGoalWorkspaceBaseline(session, goalId); + activateGoal(session, goalId); + + git(root, ["add", "tracked.txt"]); + + expect((await readWorkspaceEnvironment(contextFor(session))).files[0]?.attribution).toBe("uncertain"); + }); + + it("returns a bounded diff only for a currently changed tracked file", async () => { + const root = repository(); + const session = sessionFor(root); + fs.writeFileSync(path.join(root, "tracked.txt"), "changed\n", "utf8"); + + const environment = await readWorkspaceEnvironment(contextFor(session)); + expect(await readWorkspaceFileDiff(environment, "tracked.txt")).toMatchObject({ + path: "tracked.txt", + unavailable_reason: null, + }); + expect((await readWorkspaceFileDiff(environment, "tracked.txt"))?.diff).toContain("+changed"); + expect(await readWorkspaceFileDiff(environment, "../outside.txt")).toBeNull(); + }); + + it("reports non-Git workspaces explicitly", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-workspace-no-git-")); + roots.push(root); + const session = sessionFor(root); + expect((await readWorkspaceEnvironment(contextFor(session))).snapshot).toMatchObject({ + status: "not_git", + repository: null, + changes: null, + }); + }); + + it("lists local branches and switches only from the expected environment revision", async () => { + const root = repository(); + const session = sessionFor(root); + const context = contextFor(session); + git(root, ["branch", "alternate"]); + const environment = await readWorkspaceEnvironment(context); + + expect(environment.branches).toContain("alternate"); + const switched = await switchWorkspaceBranch(context, environment, "alternate", environment.snapshot.revision); + expect(switched.snapshot.repository?.branch).toBe("alternate"); + + await expect(switchWorkspaceBranch(context, switched, environment.snapshot.repository?.branch ?? "", "stale")) + .rejects.toMatchObject({ code: "workspace_environment_stale", status: 409 }); + }); + + it("creates and checks out a valid branch while rejecting invalid names", async () => { + const root = repository(); + const context = contextFor(sessionFor(root)); + const environment = await readWorkspaceEnvironment(context); + + const created = await createOrCheckoutWorkspaceBranch( + context, + environment, + "feature/new-branch", + environment.snapshot.revision, + ); + expect(created.snapshot.repository?.branch).toBe("feature/new-branch"); + expect(created.branches).toContain("feature/new-branch"); + + await expect(createOrCheckoutWorkspaceBranch( + context, + created, + "invalid branch name", + created.snapshot.revision, + )).rejects.toMatchObject({ code: "workspace_branch_invalid", status: 400 }); + }); +}); diff --git a/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts b/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts index d7ac41604..92ebb77f5 100644 --- a/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts +++ b/App/memmy-agent/tests/integrations/channels/websocket-http-routes.test.ts @@ -7,18 +7,21 @@ import { ChannelManager } from "../../../src/integrations/channels/manager.js"; import { WebSocketChannel, normalizeConfigPath, stripTrailingSlash } from "../../../src/integrations/channels/websocket.js"; import { Session, SessionManager } from "../../../src/core/session/manager.js"; import { appendTranscriptObject, webuiTranscriptPath } from "../../../src/entrypoints/frontend-bridge/transcript.js"; +import { ProjectStore } from "../../../src/entrypoints/frontend-bridge/projects.js"; import { loadConfig } from "../../../src/config/loader.js"; const routeMocks = vi.hoisted(() => ({ mcpPresetsSettingsAction: vi.fn(), })); const childProcessMocks = vi.hoisted(() => ({ + execFile: vi.fn((_command?: string, _args?: string[], _options?: unknown, callback?: (...args: any[]) => void) => callback?.(null, "", "")), spawn: vi.fn(() => ({ unref: vi.fn() })), spawnSync: vi.fn(() => ({ status: 0, stderr: "" })), })); vi.mock("node:child_process", async (importOriginal: () => Promise) => ({ ...await importOriginal(), + execFile: childProcessMocks.execFile, spawn: childProcessMocks.spawn, spawnSync: childProcessMocks.spawnSync, })); @@ -40,6 +43,7 @@ afterEach(async () => { await Promise.all(running.splice(0).map((channel) => channel.stop())); for (const dir of tmpDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); vi.restoreAllMocks(); + childProcessMocks.execFile.mockClear(); childProcessMocks.spawn.mockClear(); childProcessMocks.spawnSync.mockClear(); routeMocks.mcpPresetsSettingsAction.mockReset(); @@ -103,6 +107,7 @@ describe("WebSocket HTTP route helpers", () => { function makeChannel({ sessionManager = null, + projectStore = null, staticDistPath = null, runtimeModelName = null, runtimeToolNames = null, @@ -113,6 +118,7 @@ describe("WebSocket HTTP route helpers", () => { config = {}, }: { sessionManager?: SessionManager | null; + projectStore?: ProjectStore | null; staticDistPath?: string | null; runtimeModelName?: (() => string | null | undefined) | null; runtimeToolNames?: (() => string[] | null | undefined) | null; @@ -135,6 +141,7 @@ describe("WebSocket HTTP route helpers", () => { new MessageBus(), { sessionManager, + projectStore, staticDistPath, runtimeModelName, runtimeToolNames, @@ -177,6 +184,27 @@ describe("WebSocket HTTP route helpers", () => { process.env.MEMMY_CONFIG = configPath; } + function mockDirtyGitWorkspace(root: string): void { + let currentBranch = "zy_git_v1.0.7"; + childProcessMocks.execFile.mockImplementation((_command?: string, args: string[] = [], _options?: unknown, callback?: (...args: any[]) => void) => { + if (args[0] === "rev-parse") return callback?.(null, `${root}\n`, ""); + if (args[0] === "status") { + return callback?.(null, `# branch.oid 84d10f8f00\u0000# branch.head ${currentBranch}\u00001 .M N... 100644 100644 100644 abc abc tracked.ts\u0000`, ""); + } + if (args[0] === "for-each-ref") return callback?.(null, "zy_git_v1.0.7\nmain\n", ""); + if (args[0] === "check-ref-format") return callback?.(null, args.at(-1) ?? "", ""); + if (args[0] === "switch") { + currentBranch = args.at(-1) ?? currentBranch; + return callback?.(null, "", ""); + } + if (args[0] === "diff" && args.includes("--numstat")) { + return callback?.(null, "2\t1\ttracked.ts\0", ""); + } + if (args[0] === "diff") return callback?.(null, "+changed\n", ""); + return callback?.(new Error("unexpected git command"), "", "unexpected git command"); + }); + } + function configImageModel(root: string): void { const configPath = path.join(root, "config.yaml"); fs.writeFileSync(configPath, [ @@ -299,6 +327,93 @@ describe("WebSocket HTTP route helpers", () => { expect(admin.stop).toHaveBeenCalledWith("feishu"); }); + it("serves a read-only workspace environment and selected diff", async () => { + const root = tmpRoot(); + fs.writeFileSync(path.join(root, "tracked.ts"), "changed\n", "utf8"); + const manager = seedSession(path.join(root, "sessions"), "websocket:environment", root); + const channel = makeChannel({ sessionManager: manager, workspacePath: root }); + const headers = withApiToken(channel); + mockDirtyGitWorkspace(root); + + const encoded = encodeURIComponent("websocket:environment"); + const snapshotResponse = await channel.dispatchHttp(localConnection, { + path: `/api/sessions/${encoded}/environment`, + headers, + }); + expect(snapshotResponse?.status).toBe(200); + const environment = responseJson(snapshotResponse!); + expect(environment).toMatchObject({ + snapshot: { + scope_kind: "session", + scope_key: "websocket:environment", + status: "ready", + repository: { branch: "zy_git_v1.0.7", head_sha: "84d10f8f00" }, + changes: { file_count: 1, additions: 2, deletions: 1 }, + }, + files: [{ path: "tracked.ts", status: ".M" }], + branches: ["zy_git_v1.0.7", "main"], + }); + + const diffResponse = await channel.dispatchHttp(localConnection, { + path: `/api/sessions/${encoded}/environment/diff?path=tracked.ts`, + headers, + }); + expect(responseJson(diffResponse!)).toMatchObject({ path: "tracked.ts", diff: "+changed\n" }); + + const branchResponse = await channel.dispatchHttp(localConnection, { + path: `/api/sessions/${encoded}/environment/branch`, + method: "POST", + headers, + body: JSON.stringify({ branch: "main", expected_revision: environment.snapshot.revision }), + }); + expect(responseJson(branchResponse!)).toMatchObject({ + snapshot: { scope_kind: "session", repository: { branch: "main" } }, + }); + + const createBranchResponse = await channel.dispatchHttp(localConnection, { + path: `/api/sessions/${encoded}/environment/branch`, + method: "POST", + headers, + body: JSON.stringify({ + branch: "feature/new-branch", + expected_revision: responseJson(branchResponse!).snapshot.revision, + create: true, + }), + }); + expect(responseJson(createBranchResponse!)).toMatchObject({ + snapshot: { scope_kind: "session", repository: { branch: "feature/new-branch" } }, + }); + }); + + it("serves the selected project environment before a Session exists", async () => { + const root = tmpRoot(); + const workspace = path.join(root, "workspace"); + fs.mkdirSync(workspace); + fs.writeFileSync(path.join(workspace, "tracked.ts"), "changed\n", "utf8"); + const projectStore = new ProjectStore({ filePath: path.join(root, "projects.json") }); + const project = projectStore.add(workspace, "existing"); + const channel = makeChannel({ projectStore, workspacePath: workspace }); + const headers = withApiToken(channel); + mockDirtyGitWorkspace(workspace); + + const response = await channel.dispatchHttp(localConnection, { + path: `/api/projects/${encodeURIComponent(project.id)}/environment`, + headers, + }); + + expect(response?.status).toBe(200); + expect(responseJson(response!)).toMatchObject({ + snapshot: { + scope_kind: "project", + scope_key: project.id, + cwd: fs.realpathSync(workspace), + status: "ready", + repository: { branch: "zy_git_v1.0.7" }, + goal: null, + }, + }); + }); + it("serves bootstrap, session listing, and session messages behind API tokens", async ({ task }) => { const root = fs.mkdtempSync(path.join(os.tmpdir(), `memmy-ws-http-${task.id}-`)); tmpDirs.push(root); diff --git a/App/memmy-agent/tests/memmy-memory/hook.test.ts b/App/memmy-agent/tests/memmy-memory/hook.test.ts index 19ec2e25c..9668d5ef8 100644 --- a/App/memmy-agent/tests/memmy-memory/hook.test.ts +++ b/App/memmy-agent/tests/memmy-memory/hook.test.ts @@ -64,6 +64,7 @@ describe("MemmyMemoryHook", () => { const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace", userId: "user_hook_1" }); const spec = { sessionKey: "cli:direct", + turnId: "agent-turn-1", workspace: "/tmp/workspace", tools: { toolNames: ["read_file", "memmy_memory_search"] }, contextWindowTokens: 4096, @@ -82,6 +83,7 @@ describe("MemmyMemoryHook", () => { const openSessionBody = (client.openSession as any).mock.calls[0][0]; const startBody = (client.startTurn as any).mock.calls[0][1]; expect(client.openSession).toHaveBeenCalledTimes(1); + expect((client.startTurn as any).mock.calls[0][0]).toBe("agent-turn-1"); expect(openSessionBody.sessionId).toBeUndefined(); expect(openSessionBody.namespace).toMatchObject({ source: "memmy-agent", @@ -281,7 +283,9 @@ describe("MemmyMemoryHook", () => { await hook.beforeRun(new AgentHookContext({ spec, messages })); - expect((client.startTurn as any).mock.calls[0][1].query).toBe("请比较图片和文件里的内容"); + expect((client.startTurn as any).mock.calls[0][1].query).toBe( + "[image: /tmp/original.png]\n请比较图片和文件里的内容", + ); const injected = messages[1].content as Array>; expect(injected[0]?.text).toContain(''); expect(injected[1]).toEqual({ type: "text", text: "" }); @@ -300,6 +304,29 @@ describe("MemmyMemoryHook", () => { expect(reinjected.filter((block) => block.type === "file")).toEqual([file]); }); + it("uses a stable placeholder when the user turn contains only an image", async () => { + const client = fakeClient(); + const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace", userId: "user_hook_1" }); + const spec = { + sessionKey: "cli:image-only", + workspace: "/tmp/workspace", + contextWindowTokens: 4096, + }; + const messages = [{ + role: "user", + content: [{ + type: "image_url", + image_url: { url: "data:image/png;base64,user-image" }, + meta: { path: "/tmp/user-image.png" }, + }], + }]; + + await hook.beforeRun(new AgentHookContext({ spec, messages })); + + expect((client.startTurn as any).mock.calls[0][1].query).toBe("[image: /tmp/user-image.png]"); + expect(JSON.stringify((client.startTurn as any).mock.calls[0][1])).not.toContain("data:image"); + }); + it("passes raw protocol content to memory service for storage-side sanitization", async () => { const client = fakeClient(); const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace", userId: "user_hook_1" }); @@ -336,6 +363,47 @@ describe("MemmyMemoryHook", () => { }); }); + it("normalizes pure image tool results without sending data URLs to memory", async () => { + const client = fakeClient(); + const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace", userId: "user_hook_1" }); + const spec = { + sessionKey: "cli:image-tool", + workspace: "/tmp/workspace", + contextWindowTokens: 4096, + }; + const messages = [{ role: "user", content: "Inspect the image" }]; + + await hook.beforeRun(new AgentHookContext({ spec, messages })); + await hook.afterRun(new AgentHookContext({ spec }), { + finalContent: "Done", + messages: [{ + role: "tool", + tool_call_id: "call-image", + name: "read_file", + content: [ + { + type: "image_url", + image_url: { url: "data:image/png;base64,tool-image" }, + meta: { path: "/tmp/tool-image.png" }, + }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,no-path" }, + }, + ], + }], + toolCalls: [{ + id: "call-image", + function: { name: "read_file", arguments: JSON.stringify({ path: "/tmp/tool-image.png" }) }, + }], + stopReason: "completed", + }); + + const completeBody = (client.completeTurn as any).mock.calls[0][1]; + expect(completeBody.toolResults[0].output).toBe("[image: /tmp/tool-image.png]\n[image]"); + expect(JSON.stringify(completeBody)).not.toContain("data:image"); + }); + it("forwards current-turn assistant reasoning to memory", async () => { const client = fakeClient(); const hook = new MemmyMemoryHook(client as any, { workspace: "/tmp/workspace", userId: "user_hook_1" }); @@ -569,7 +637,7 @@ describe("MemmyMemoryHook", () => { expect(warnSpy).toHaveBeenCalledTimes(1); - client.openSession = vi.fn(async (_body: any) => ({ + client.openSession = vi.fn(async () => ({ sessionId: "session-recovered", userId: "local-user", resumed: false, diff --git a/App/memmy-agent/tests/providers/model-input-capabilities.test.ts b/App/memmy-agent/tests/providers/model-input-capabilities.test.ts new file mode 100644 index 000000000..4821906e5 --- /dev/null +++ b/App/memmy-agent/tests/providers/model-input-capabilities.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { + coversInputModalities, + defineModelInputCapabilities, + getModelInputModalities, + MODEL_INPUT_CAPABILITIES, + MODEL_INPUT_CAPABILITIES_REVIEWED_AT, + requiredInputModalities, + type ModelInputModality, +} from "../../src/providers/model-input-capabilities.js"; + +describe("model input capabilities", () => { + it("contains only immutable, valid modality sets with text support", () => { + const allowed = new Set(["text", "image", "video"]); + + expect(MODEL_INPUT_CAPABILITIES_REVIEWED_AT).toBe("2026-08-13"); + expect(Object.keys(MODEL_INPUT_CAPABILITIES)).toHaveLength(239); + expect(Object.isFrozen(MODEL_INPUT_CAPABILITIES)).toBe(true); + for (const [model, modalities] of Object.entries(MODEL_INPUT_CAPABILITIES)) { + expect(model).toBeTruthy(); + expect(modalities.length).toBeGreaterThan(0); + expect(modalities).toContain("text"); + expect(modalities.every((modality) => allowed.has(modality))).toBe(true); + expect(new Set(modalities).size).toBe(modalities.length); + expect(Object.isFrozen(modalities)).toBe(true); + } + }); + + it("uses exact full model IDs without normalization or family inheritance", () => { + expect(getModelInputModalities("gpt-5.6")).toEqual(["text", "image"]); + expect(getModelInputModalities("claude-sonnet-5")).toEqual(["text", "image"]); + expect(getModelInputModalities("global.anthropic.claude-sonnet-5")).toEqual(["text", "image"]); + expect(getModelInputModalities("qwen/qwen3.6-27b")).toEqual(["text", "image"]); + expect(getModelInputModalities("deepseek-v4-pro")).toEqual(["text"]); + expect(getModelInputModalities("kimi-k3")).toEqual(["text", "image", "video"]); + expect(getModelInputModalities("glm-5v-turbo")).toEqual(["text", "image", "video"]); + expect(getModelInputModalities("qwen3.7-max-2026-05-20")).toEqual(["text"]); + expect(getModelInputModalities("qwen3.7-max-2026-06-08")).toEqual(["text", "image", "video"]); + expect(getModelInputModalities("Gpt-5.6")).toEqual(["text"]); + expect(getModelInputModalities(" gpt-5.6 ")).toEqual(["text"]); + expect(getModelInputModalities("gpt-5.6-unknown-snapshot")).toEqual(["text"]); + expect(getModelInputModalities(null)).toEqual(["text"]); + }); + + it("rejects duplicate model keys instead of overwriting them", () => { + expect(() => defineModelInputCapabilities([ + ["same-model", ["text"]], + ["same-model", ["text", "image"]], + ])).toThrow("Duplicate model input capability: same-model"); + }); + + it("scans unified messages and checks required modality coverage", () => { + const textRequired = requiredInputModalities([{ role: "user", content: "hello" }]); + const imageRequired = requiredInputModalities([{ + role: "tool", + content: [ + { type: "text", text: "result" }, + { type: "image_url", image_url: { url: "data:image/png;base64,abc" } }, + ], + }]); + + expect(textRequired).toEqual(["text"]); + expect(imageRequired).toEqual(["text", "image"]); + expect(coversInputModalities(["text", "image"], imageRequired)).toBe(true); + expect(coversInputModalities(["text"], imageRequired)).toBe(false); + }); +}); diff --git a/App/memmy-agent/tests/session-dag/integration.test.ts b/App/memmy-agent/tests/session-dag/integration.test.ts index c5cfe29b2..2440824da 100644 --- a/App/memmy-agent/tests/session-dag/integration.test.ts +++ b/App/memmy-agent/tests/session-dag/integration.test.ts @@ -380,6 +380,79 @@ describe("Session DAG integration", () => { } }); + it("logs a structured DAG compaction failure without changing session state", async () => { + const root = tmpRoot(); + const dagRoot = path.join(root, "dag"); + const sessionKey = "websocket:dag-cant-open"; + const sessions = new SessionManager(path.join(root, "sessions")); + const session = new Session({ key: sessionKey }); + session.messages = [ + { role: "user", content: "private-user-message-0" }, + { role: "assistant", content: "private-assistant-message-0" }, + { role: "user", content: "private-user-message-1" }, + { role: "assistant", content: "private-assistant-message-1" }, + ]; + sessions.save(session); + const messagesBefore = session.messages.map((message) => ({ ...message })); + const queue = { waitUntilProcessed: vi.fn(async () => true) }; + const cantOpenError = Object.assign(new Error("unable to open database file"), { + name: "SqliteError", + code: "SQLITE_CANTOPEN", + }); + const consolidator = new Consolidator({ + store: new MemoryStore(root), + provider: provider(), + model: "test-model", + sessions, + contextWindowTokens: 10_000, + maxCompletionTokens: 100, + buildMessages: ({ history }: any) => history, + getToolDefinitions: () => [], + summaryMode: "dag", + dagQueue: queue as any, + createDagStore: () => { + throw cantOpenError; + }, + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const previousDagRoot = process.env.MEMMY_AGENT_SESSION_DAG_DIR; + process.env.MEMMY_AGENT_SESSION_DAG_DIR = dagRoot; + + try { + await expect( + consolidator.maybeConsolidateByTokens(session, { replayMaxMessages: 2 }), + ).rejects.toBe(cantOpenError); + + expect(consoleError).toHaveBeenCalledTimes(1); + const [prefix, serialized] = consoleError.mock.calls[0] ?? []; + expect(prefix).toBe("[session-dag] compaction failed"); + expect(serialized).toEqual(expect.any(String)); + const record = JSON.parse(String(serialized)); + expect(new Date(record.timestamp).toISOString()).toBe(record.timestamp); + expect(record).toMatchObject({ + event: "session_dag_compaction_failed", + platform: process.platform, + pid: process.pid, + sessionKey, + databasePath: sessionDagDbPath(sessionKey), + summaryMode: "dag", + errorName: "SqliteError", + errorCode: "SQLITE_CANTOPEN", + message: "unable to open database file", + stack: expect.stringContaining("unable to open database file"), + }); + expect(serialized).not.toContain("private-user-message"); + expect(serialized).not.toContain("private-assistant-message"); + expect(queue.waitUntilProcessed).not.toHaveBeenCalled(); + expect(session.lastConsolidated).toBe(0); + expect(session.metadata.lastSummary).toBeUndefined(); + expect(session.messages).toEqual(messagesBefore); + } finally { + if (previousDagRoot === undefined) delete process.env.MEMMY_AGENT_SESSION_DAG_DIR; + else process.env.MEMMY_AGENT_SESSION_DAG_DIR = previousDagRoot; + } + }); + it("DAG mode does not run idle text compaction", async () => { const root = tmpRoot(); const sessions = new SessionManager(path.join(root, "sessions")); diff --git a/App/memmy-agent/tsconfig.json b/App/memmy-agent/tsconfig.json index bca387c69..2352b2d9d 100644 --- a/App/memmy-agent/tsconfig.json +++ b/App/memmy-agent/tsconfig.json @@ -13,10 +13,9 @@ "declaration": true, "sourceMap": true, "rootDir": ".", - "baseUrl": ".", "paths": { - "memmy-agent": ["src/index.ts"], - "memmy-agent/*": ["src/*"] + "memmy-agent": ["./src/index.ts"], + "memmy-agent/*": ["./src/*"] }, "types": ["node", "vitest/globals"] }, diff --git a/App/shell/desktop/build/MemmyWindowsUpgradeCleanup.ps1 b/App/shell/desktop/build/MemmyWindowsUpgradeCleanup.ps1 new file mode 100644 index 000000000..2af07a0a4 --- /dev/null +++ b/App/shell/desktop/build/MemmyWindowsUpgradeCleanup.ps1 @@ -0,0 +1,6 @@ +param( + [Parameter(Mandatory = $true)][string]$WorkDir +) + +Start-Sleep -Seconds 3 +Remove-Item -LiteralPath $WorkDir -Recurse -Force -ErrorAction SilentlyContinue diff --git a/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 b/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 new file mode 100644 index 000000000..cd5573b34 --- /dev/null +++ b/App/shell/desktop/build/MemmyWindowsUpgradeRelay.ps1 @@ -0,0 +1,242 @@ +param( + [Parameter(Mandatory = $true)][string]$InstallerPath, + [Parameter(Mandatory = $true)][string]$InstallDir, + [Parameter(Mandatory = $true)][int]$OriginalInstallerPid, + [Parameter(Mandatory = $true)][int]$LegacyHelperPid, + [Parameter(Mandatory = $true)][string]$ExpectedVersion, + [Parameter(Mandatory = $true)][ValidateSet('0', '1')][string]$ReopenAfterInstall, + [Parameter(Mandatory = $true)][string]$ReadyPath, + [Parameter(Mandatory = $true)][string]$WorkDir, + [Parameter(Mandatory = $true)][string]$LogPath +) + +$ErrorActionPreference = 'Stop' +$dataPath = Join-Path $InstallDir 'data' +$backupPath = Join-Path $WorkDir 'data-backup' +$stagingRoot = Split-Path -Parent $WorkDir +$lockPath = Join-Path $stagingRoot 'active.lock' +$appExe = Join-Path $InstallDir 'Memmy.exe' +$installerExit = 1 +$dataMoved = $false +$dataRestored = $false +$lockAcquired = $false +$resolvedReopenAfterInstall = $ReopenAfterInstall + +function Write-MemmyUpgradeLog([string]$Message) { + $logDirectory = Split-Path -Parent $LogPath + if ($logDirectory) { + New-Item -ItemType Directory -Force -Path $logDirectory | Out-Null + } + Add-Content -LiteralPath $LogPath -Value ('[{0:O}] {1}' -f (Get-Date), $Message) +} + +function Resolve-MemmyLegacyHelperReopenIntent([int]$HelperPid, [string]$MarkerPath, [string]$Fallback) { + try { + $helper = Get-CimInstance -ClassName Win32_Process -Filter ("ProcessId = {0}" -f $HelperPid) -ErrorAction Stop + $commandLine = [string]$helper.CommandLine + if (-not $commandLine) { + throw "legacy helper command line is unavailable" + } + $pattern = '(?:^|\s)"?(?[01])"?\s+"?' + [Regex]::Escape($MarkerPath) + '"?(?:\s|$)' + $match = [Regex]::Match($commandLine, $pattern, [Text.RegularExpressions.RegexOptions]::IgnoreCase) + if (-not $match.Success) { + throw "legacy helper reopen argument is unavailable" + } + $intent = $match.Groups['intent'].Value + Write-MemmyUpgradeLog "reopen intent resolved from legacy helper pid ${HelperPid}: $intent" + return $intent + } catch { + Write-MemmyUpgradeLog "reopen intent fallback=$Fallback legacyHelperPid=$HelperPid reason=$($_.Exception.Message)" + return $Fallback + } +} + +function Wait-MemmyProcessExit([int]$ProcessId, [int]$TimeoutSeconds) { + $process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if ($null -eq $process) { + return + } + Write-MemmyUpgradeLog "waiting for original installer pid $ProcessId" + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + throw "original installer pid $ProcessId did not exit" + } +} + +function Move-MemmyDirectory([string]$Source, [string]$Destination) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Destination) | Out-Null + for ($attempt = 1; $attempt -le 120; $attempt++) { + try { + [System.IO.Directory]::Move($Source, $Destination) + return + } catch { + if ($attempt -eq 120) { + throw + } + Start-Sleep -Milliseconds 500 + } + } +} + +function Restore-MemmyData { + if (-not $dataMoved) { + $script:dataRestored = $true + return + } + if (-not (Test-Path -LiteralPath $backupPath -PathType Container)) { + if (Test-Path -LiteralPath $dataPath -PathType Container) { + $script:dataRestored = $true + Write-MemmyUpgradeLog "data restore verified by child installer $dataPath" + return + } + throw "data backup is missing: $backupPath" + } + if (Test-Path -LiteralPath $dataPath) { + $installerDataPath = Join-Path $WorkDir 'installer-created-data' + if (Test-Path -LiteralPath $installerDataPath) { + throw "installer-created data backup already exists: $installerDataPath" + } + Move-MemmyDirectory -Source $dataPath -Destination $installerDataPath + Write-MemmyUpgradeLog "preserved installer-created data at $installerDataPath" + } + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null + Move-MemmyDirectory -Source $backupPath -Destination $dataPath + if (-not (Test-Path -LiteralPath $dataPath -PathType Container)) { + throw "restored data directory is unavailable: $dataPath" + } + $script:dataRestored = $true + Write-MemmyUpgradeLog "data restore verified $dataPath" +} + +function Get-MemmyInstalledVersion { + if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { + return '' + } + $versionInfo = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($appExe) + foreach ($value in @($versionInfo.ProductVersion, $versionInfo.FileVersion)) { + if ($value) { + return $value + } + } + return '' +} + +function Clear-MemmyUpdateMarkers { + $markerPath = Join-Path $dataPath 'Memmy\prepared-required-update.json' + foreach ($path in @($markerPath, "$markerPath.lock", "$markerPath.prompt", "$markerPath.attempt")) { + Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue + } +} + +function Start-MemmyInstalledApp { + if (-not (Test-Path -LiteralPath $appExe -PathType Leaf)) { + Write-MemmyUpgradeLog "app executable is unavailable for reopen: $appExe" + return + } + Start-Process -FilePath $appExe -WorkingDirectory $InstallDir -WindowStyle Normal + Write-MemmyUpgradeLog "started app $appExe" +} + +function Test-MemmyInstalledAppRunning { + $expectedPath = [System.IO.Path]::GetFullPath($appExe) + foreach ($process in @(Get-Process -Name 'Memmy' -ErrorAction SilentlyContinue)) { + try { + if ([string]::Equals([System.IO.Path]::GetFullPath($process.Path), $expectedPath, [System.StringComparison]::OrdinalIgnoreCase)) { + return $true + } + } catch { + continue + } + } + return $false +} + +function Ensure-MemmyInstalledAppStarted { + for ($attempt = 1; $attempt -le 4; $attempt++) { + if (Test-MemmyInstalledAppRunning) { + Write-MemmyUpgradeLog "app already started by child installer $appExe" + return + } + if ($attempt -lt 4) { + Start-Sleep -Milliseconds 100 + } + } + Start-MemmyInstalledApp +} + +function Schedule-MemmyStagingCleanup { + $cleanupScriptPath = Join-Path $WorkDir 'MemmyWindowsUpgradeCleanup.ps1' + if (-not (Test-Path -LiteralPath $cleanupScriptPath -PathType Leaf)) { + return + } + $powershellPath = Join-Path $PSHOME 'powershell.exe' + $cleanupArguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$cleanupScriptPath`" -WorkDir `"$WorkDir`"" + Start-Process -FilePath $powershellPath -ArgumentList $cleanupArguments -WorkingDirectory $stagingRoot -WindowStyle Hidden +} + +try { + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + Write-MemmyUpgradeLog "relay starting installer=$InstallerPath installDir=$InstallDir expected=$ExpectedVersion reopenFallback=$ReopenAfterInstall" + $markerPath = Join-Path $dataPath 'Memmy\prepared-required-update.json' + $resolvedReopenAfterInstall = Resolve-MemmyLegacyHelperReopenIntent -HelperPid $LegacyHelperPid -MarkerPath $markerPath -Fallback $ReopenAfterInstall + New-Item -ItemType Directory -Path $lockPath -ErrorAction Stop | Out-Null + $lockAcquired = $true + [System.IO.File]::WriteAllText($ReadyPath, $resolvedReopenAfterInstall) + Write-MemmyUpgradeLog "relay ready reopen=$resolvedReopenAfterInstall" + Wait-MemmyProcessExit -ProcessId $OriginalInstallerPid -TimeoutSeconds 120 + + if (Test-Path -LiteralPath $dataPath -PathType Container) { + if (Test-Path -LiteralPath $backupPath) { + throw "refusing to overwrite existing data backup: $backupPath" + } + Move-MemmyDirectory -Source $dataPath -Destination $backupPath + $dataMoved = $true + Write-MemmyUpgradeLog "data moved to $backupPath" + } + + $arguments = @('/S', '--updated', '--memmy-upgrade-relayed', '/currentuser', ('/D=' + $InstallDir)) + $env:MEMMY_UPGRADE_WORK_DIR = $WorkDir + $env:MEMMY_UPGRADE_REOPEN_AFTER_INSTALL = $resolvedReopenAfterInstall + Write-MemmyUpgradeLog "child installer context workDir=$env:MEMMY_UPGRADE_WORK_DIR reopen=$env:MEMMY_UPGRADE_REOPEN_AFTER_INSTALL" + $installerProcess = Start-Process -FilePath $InstallerPath -ArgumentList $arguments -PassThru -WindowStyle Hidden + $installerProcess.WaitForExit() + $installerExit = if ($null -eq $installerProcess.ExitCode) { 1 } else { $installerProcess.ExitCode } + Write-MemmyUpgradeLog "installer exit $installerExit" +} catch { + Write-MemmyUpgradeLog ('relay error: ' + ($_ | Out-String)) + if ($installerExit -eq 0) { + $installerExit = 1 + } +} finally { + try { + Restore-MemmyData + } catch { + Write-MemmyUpgradeLog ('data restore failed: ' + ($_ | Out-String)) + $dataRestored = $false + } + if ($lockAcquired) { + Remove-Item -LiteralPath $lockPath -Recurse -Force -ErrorAction SilentlyContinue + } +} + +if (-not $dataRestored) { + Write-MemmyUpgradeLog "upgrade stopped with recoverable data backup $backupPath" + exit 3 +} + +$installedVersion = Get-MemmyInstalledVersion +$upgradeVerified = $installerExit -eq 0 -and $installedVersion.StartsWith($ExpectedVersion, [System.StringComparison]::OrdinalIgnoreCase) +if ($upgradeVerified) { + Clear-MemmyUpdateMarkers + Write-MemmyUpgradeLog "upgrade verified installedVersion=$installedVersion" + if ($resolvedReopenAfterInstall -eq '1') { + Ensure-MemmyInstalledAppStarted + } + Schedule-MemmyStagingCleanup + exit 0 +} + +Write-MemmyUpgradeLog "upgrade not verified installedVersion=$installedVersion installerExit=$installerExit" +if ($resolvedReopenAfterInstall -eq '1') { + Start-MemmyInstalledApp +} +exit $(if ($installerExit -ne 0) { $installerExit } else { 4 }) diff --git a/App/shell/desktop/build/installer-win-unsigned.nsh b/App/shell/desktop/build/installer-win-unsigned.nsh index c53da360a..8bcf51ccd 100644 --- a/App/shell/desktop/build/installer-win-unsigned.nsh +++ b/App/shell/desktop/build/installer-win-unsigned.nsh @@ -1,3 +1,6 @@ +!include "FileFunc.nsh" +!include "getProcessInfo.nsh" + !ifndef MEMMY_WM_SETTINGCHANGE !define MEMMY_WM_SETTINGCHANGE 0x001A !endif @@ -19,10 +22,27 @@ !macroend !ifndef BUILD_UNINSTALLER + Var MemmyIsRelayedUpgrade + Var MemmyUpgradeWorkDir + Var MemmyUpgradeReopenAfterInstall + + ; The 1.0.8 updater starts the downloaded installer from $INSTDIR\data. electron-builder's + ; normal upgrade path asks the old uninstaller to move all of $INSTDIR, so that running + ; installer keeps the directory busy and the old uninstaller exits with code 2. Relay only + ; this legacy --updated invocation through a copy outside $INSTDIR. The relayed child carries + ; an explicit marker so normal installs and future upgrades continue through electron-builder. + !macro customInit + Call MemmyRelayLegacyUpgrade + !macroend + !macro customInstall + Call MemmyRestoreRelayedUpgradeData Call MemmyAddCliToUserPath Call MemmyInstallLaunchProxy !insertmacro MemmyPointShortcutsToLaunchProxy + Call MemmyClearRelayedUpgradeMarkers + Call MemmyLaunchRelayedUpgrade + Call MemmyScheduleRelayedUpgradeCleanup !macroend !endif @@ -34,6 +54,168 @@ !endif !ifndef BUILD_UNINSTALLER +Function MemmyRelayLegacyUpgrade + ${GetParameters} $R0 + + ClearErrors + ${GetOptions} $R0 "--memmy-upgrade-relayed" $R1 + IfErrors memmy_relay_check_legacy + Call MemmyReadRelayedUpgradeContext + Return + + memmy_relay_check_legacy: + ClearErrors + ${GetOptions} $R0 "--updated" $R1 + IfErrors memmy_relay_done + + System::Call 'kernel32::GetCurrentProcessId() i .r2' + StrCmp $2 "" memmy_relay_failed + StrCmp $2 "0" memmy_relay_failed + ${GetProcessInfo} $2 $2 $3 $4 $5 $6 + StrCmp $3 "" memmy_relay_failed + StrCmp $3 "0" memmy_relay_failed + StrCpy $R1 "$LOCALAPPDATA\Memmy\upgrade-staging\$2" + StrCpy $R3 "$R1\MemmyWindowsUpgradeRelay.ps1" + StrCpy $R4 "$R1\Memmy-${VERSION}-upgrade.exe" + StrCpy $R5 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + StrCpy $R7 "$LOCALAPPDATA\Memmy\upgrade-logs\windows-upgrade.log" + StrCpy $R8 "$R1\relay-ready" + + ; Direct/manual updates reopen Memmy. A prepared update without the boot-attempt marker was + ; launched during normal app shutdown and must remain closed. Boot fallback writes .attempt. + StrCpy $R6 "1" + IfFileExists "$INSTDIR\data\Memmy\prepared-required-update.json" 0 memmy_relay_stage + IfFileExists "$INSTDIR\data\Memmy\prepared-required-update.json.attempt" memmy_relay_stage + StrCpy $R6 "0" + + memmy_relay_stage: + ; Refresh the launch proxy before the relay moves $INSTDIR\data. The 1.0.8 proxy only knows + ; the install-local marker lock, which disappears with that move; the refreshed proxy also + ; recognizes the relay lock outside $INSTDIR and keeps desktop launches blocked throughout. + Call MemmyInstallLaunchProxy + ClearErrors + CreateDirectory "$R1" + SetOutPath "$R1" + File /oname=MemmyWindowsUpgradeRelay.ps1 "${BUILD_RESOURCES_DIR}\MemmyWindowsUpgradeRelay.ps1" + IfErrors memmy_relay_failed + IfFileExists "$R3" 0 memmy_relay_failed + File /oname=MemmyWindowsUpgradeCleanup.ps1 "${BUILD_RESOURCES_DIR}\MemmyWindowsUpgradeCleanup.ps1" + IfErrors memmy_relay_failed + IfFileExists "$R1\MemmyWindowsUpgradeCleanup.ps1" 0 memmy_relay_failed + + ClearErrors + CopyFiles /SILENT "$EXEPATH" "$R4" + IfErrors memmy_relay_failed + IfFileExists "$R4" 0 memmy_relay_failed + IfFileExists "$R5" 0 memmy_relay_failed + Delete "$R8" + + ClearErrors + ExecShell "open" "$R5" '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File $\"$R3$\" -InstallerPath $\"$R4$\" -InstallDir $\"$INSTDIR$\" -OriginalInstallerPid $2 -LegacyHelperPid $3 -ExpectedVersion $\"${VERSION}$\" -ReopenAfterInstall $R6 -ReadyPath $\"$R8$\" -WorkDir $\"$R1$\" -LogPath $\"$R7$\"' SW_HIDE + IfErrors memmy_relay_failed + StrCpy $R9 "0" + + memmy_relay_wait_ready: + IfFileExists "$R8" memmy_relay_ready + Sleep 100 + IntOp $R9 $R9 + 1 + IntCmp $R9 100 memmy_relay_failed memmy_relay_wait_ready memmy_relay_failed + + memmy_relay_ready: + + ; The old 1.0.8 helper must not reopen the old executable while the relay owns the upgrade. + SetErrorLevel 1602 + Quit + + memmy_relay_failed: + SetOutPath "$INSTDIR" + SetErrorLevel 2 + Quit + + memmy_relay_done: +FunctionEnd + +Function MemmyReadRelayedUpgradeContext + StrCpy $MemmyIsRelayedUpgrade "0" + ReadEnvStr $MemmyUpgradeWorkDir "MEMMY_UPGRADE_WORK_DIR" + ReadEnvStr $MemmyUpgradeReopenAfterInstall "MEMMY_UPGRADE_REOPEN_AFTER_INSTALL" + StrCmp $MemmyUpgradeWorkDir "" memmy_relay_context_failed + StrCmp $MemmyUpgradeReopenAfterInstall "0" memmy_relay_context_validate_path + StrCmp $MemmyUpgradeReopenAfterInstall "1" memmy_relay_context_validate_path memmy_relay_context_failed + + memmy_relay_context_validate_path: + StrCpy $0 "$LOCALAPPDATA\Memmy\upgrade-staging\" + StrLen $1 $0 + StrLen $2 $MemmyUpgradeWorkDir + IntCmp $2 $1 memmy_relay_context_failed memmy_relay_context_failed memmy_relay_context_compare_path + + memmy_relay_context_compare_path: + StrCpy $3 $MemmyUpgradeWorkDir $1 + StrCmp $3 $0 0 memmy_relay_context_failed + StrCpy $MemmyIsRelayedUpgrade "1" + Return + + memmy_relay_context_failed: + SetErrorLevel 2 + Quit +FunctionEnd + +Function MemmyRestoreRelayedUpgradeData + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_restore_relayed_data_done + StrCpy $0 "$MemmyUpgradeWorkDir\data-backup" + StrCpy $1 "$INSTDIR\data" + StrCpy $2 "$MemmyUpgradeWorkDir\installer-created-data" + IfFileExists "$0\*" 0 memmy_restore_relayed_data_failed + IfFileExists "$2\*" memmy_restore_relayed_data_failed + IfFileExists "$1\*" 0 memmy_restore_relayed_data_backup + ClearErrors + Rename "$1" "$2" + IfErrors memmy_restore_relayed_data_failed + + memmy_restore_relayed_data_backup: + ClearErrors + Rename "$0" "$1" + IfErrors memmy_restore_relayed_data_failed + IfFileExists "$1\*" memmy_restore_relayed_data_done memmy_restore_relayed_data_failed + + memmy_restore_relayed_data_failed: + SetErrorLevel 3 + Quit + + memmy_restore_relayed_data_done: +FunctionEnd + +Function MemmyClearRelayedUpgradeMarkers + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_clear_relayed_markers_done + StrCpy $0 "$INSTDIR\data\Memmy\prepared-required-update.json" + Delete "$0" + RMDir /r "$0.lock" + Delete "$0.prompt" + Delete "$0.attempt" + RMDir /r "$LOCALAPPDATA\Memmy\upgrade-staging\active.lock" + + memmy_clear_relayed_markers_done: +FunctionEnd + +Function MemmyLaunchRelayedUpgrade + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_launch_relayed_upgrade_done + StrCmp $MemmyUpgradeReopenAfterInstall "1" 0 memmy_launch_relayed_upgrade_done + Exec '$\"$INSTDIR\${PRODUCT_FILENAME}.exe$\" --updated' + + memmy_launch_relayed_upgrade_done: +FunctionEnd + +Function MemmyScheduleRelayedUpgradeCleanup + StrCmp $MemmyIsRelayedUpgrade "1" 0 memmy_schedule_relayed_cleanup_done + StrCpy $0 "$MemmyUpgradeWorkDir\MemmyWindowsUpgradeCleanup.ps1" + StrCpy $1 "$SYSDIR\WindowsPowerShell\v1.0\powershell.exe" + IfFileExists "$0" 0 memmy_schedule_relayed_cleanup_done + IfFileExists "$1" 0 memmy_schedule_relayed_cleanup_done + ExecShell "open" "$1" '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File $\"$0$\" -WorkDir $\"$MemmyUpgradeWorkDir$\"' SW_HIDE + + memmy_schedule_relayed_cleanup_done: +FunctionEnd + Function MemmyPathContains Exch $R1 Exch @@ -103,6 +285,10 @@ Function MemmyInstallLaunchProxy FileWrite $1 "languagePath = dataRoot & $\"\Memmy\update-prompt-language.txt$\"$\r$\n" FileWrite $1 "markerPath = dataRoot & $\"\Memmy\prepared-required-update.json$\"$\r$\n" FileWrite $1 "lockPath = markerPath & $\".lock$\"$\r$\n" + FileWrite $1 "relayLockPath = shell.ExpandEnvironmentStrings($\"%LOCALAPPDATA%$\") & $\"\Memmy\upgrade-staging\active.lock$\"$\r$\n" + FileWrite $1 "If fso.FolderExists(relayLockPath) Then$\r$\n" + FileWrite $1 " lockPath = relayLockPath$\r$\n" + FileWrite $1 "End If$\r$\n" FileWrite $1 "promptMarkerPath = markerPath & $\".prompt$\"$\r$\n" FileWrite $1 "If fso.FolderExists(lockPath) And fso.FileExists(promptMarkerPath) Then$\r$\n" FileWrite $1 " If fso.FileExists(powerShellPath) And fso.FileExists(promptPath) Then$\r$\n" diff --git a/App/shell/desktop/electron-builder.unsigned.yml b/App/shell/desktop/electron-builder.unsigned.yml index 0e885d1f2..602e88b04 100644 --- a/App/shell/desktop/electron-builder.unsigned.yml +++ b/App/shell/desktop/electron-builder.unsigned.yml @@ -25,6 +25,7 @@ asarUnpack: - "**/@img/sharp-libvips-darwin-*/lib/libvips*.dylib" - "**/@lydell/node-pty-darwin-*/prebuilds/darwin-*/spawn-helper" - "**/node_modules/sqlite-vec-*/vec0.*" + - "dist/runtime/memory/node_modules/@memmy/**" - "dist/runtime/memmy-agent/node_modules/@memmy/migrations/**" - "dist/renderer/**" diff --git a/App/shell/desktop/electron-builder.yml b/App/shell/desktop/electron-builder.yml index 238ce99d5..ccd1dedbd 100644 --- a/App/shell/desktop/electron-builder.yml +++ b/App/shell/desktop/electron-builder.yml @@ -25,6 +25,7 @@ asarUnpack: - "**/@img/sharp-libvips-darwin-*/lib/libvips*.dylib" - "**/@lydell/node-pty-darwin-*/prebuilds/darwin-*/spawn-helper" - "**/node_modules/sqlite-vec-*/vec0.*" + - "dist/runtime/memory/node_modules/@memmy/**" - "dist/runtime/memmy-agent/node_modules/@memmy/migrations/**" - "dist/renderer/**" diff --git a/App/shell/desktop/package.json b/App/shell/desktop/package.json index f3303e6a9..6d83557b7 100644 --- a/App/shell/desktop/package.json +++ b/App/shell/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@memmy/desktop", - "version": "1.0.8", + "version": "1.0.9", "private": true, "type": "module", "description": "Memmy desktop client.", diff --git a/App/shell/desktop/src/main/installation-id-store.ts b/App/shell/desktop/src/main/installation-id-store.ts new file mode 100644 index 000000000..bf02518c9 --- /dev/null +++ b/App/shell/desktop/src/main/installation-id-store.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +const INSTALLATION_ID_FILENAME = "installation-id"; + +export function resolveInstallationIdPath( + env: NodeJS.ProcessEnv = process.env, + home = homedir(), +): string { + const memmyHome = (env.MEMMY_HOME?.trim() || join(home, ".memmy")).replace(/^~(?=$|[/\\])/, home); + return join(memmyHome, INSTALLATION_ID_FILENAME); +} + +export function getOrCreateInstallationId(options: { + env?: NodeJS.ProcessEnv; + home?: string; + existsSyncImpl?: typeof existsSync; + readFileSyncImpl?: (path: string, encoding: "utf8") => string; + mkdirSyncImpl?: typeof mkdirSync; + writeFileSyncImpl?: typeof writeFileSync; + createId?: () => string; +} = {}): string { + const filePath = resolveInstallationIdPath(options.env, options.home); + const exists = options.existsSyncImpl ?? existsSync; + const read = options.readFileSyncImpl ?? readFileSync; + const mkdir = options.mkdirSyncImpl ?? mkdirSync; + const write = options.writeFileSyncImpl ?? writeFileSync; + + if (exists(filePath)) { + const persisted = read(filePath, "utf8").trim(); + if (persisted) return persisted; + } + + const installationId = (options.createId ?? randomUUID)(); + mkdir(dirname(filePath), { recursive: true }); + write(filePath, `${installationId}\n`, "utf8"); + return installationId; +} diff --git a/App/shell/desktop/src/main/main.ts b/App/shell/desktop/src/main/main.ts index 3daf97267..254a5a288 100644 --- a/App/shell/desktop/src/main/main.ts +++ b/App/shell/desktop/src/main/main.ts @@ -1,5 +1,5 @@ -import { createLocalBackend, loadCloudServiceEnv, trackAnalyticsEvent, type BootstrapScenario, type LocalBackend } from "@memmy/backend"; -import { resolveCloudServiceBaseUrl } from "@memmy/local-api-contracts"; +import { createLocalBackend, loadCloudServiceEnv, syncRuntimeConfigForStartup, trackAnalyticsEvent, type BootstrapScenario, type LocalBackend } from "@memmy/backend"; +import { resolveCloudServiceBaseUrl, type AccountChannel } from "@memmy/local-api-contracts"; import type { DesktopAppInfo, DesktopImageActionRequest, @@ -43,8 +43,10 @@ import { preparePackagedRuntimeConfig, restartExternalMemoryService, resolveAgentGatewayRuntimeConfig, - startPackagedRuntimeServices, - type PackagedRuntimeServices + resolveDevelopmentRuntimeEntryPaths, + resolveDevelopmentRuntimeExecutable, + startManagedRuntimeServices, + type ManagedRuntimeServices } from "./runtime-services.js"; import { resolveRendererContextMenuCommands, resolveRendererContextMenuMaxLabelWidth, type RendererContextMenuCommand } from "./renderer-context-menu.js"; import { startPackagedRendererStaticServer, type PackagedRendererStaticServer } from "./renderer-static-server.js"; @@ -88,10 +90,12 @@ import { type LogLevel } from "./logger.js"; import { persistSharedAnalyticsClientId } from "./analytics-client-id-store.js"; +import { getOrCreateInstallationId } from "./installation-id-store.js"; import { backupSqliteDatabase } from "./sqlite-backup.js"; import { resolveStartupSplashHtml, resolveStartupSplashLanguage, + resolveUpdateSplashHtml, type StartupSplashLanguage } from "./startup-splash.js"; @@ -100,7 +104,7 @@ let petWindow: BrowserWindow | null = null; let localBackend: LocalBackend | null = null; let menuBarTray: Tray | null = null; const MENU_BAR_TRAY_GUID = "8B2A0C33-45C0-4C43-8F1C-77F7D4FDF2D4"; -let runtimeServices: PackagedRuntimeServices | null = null; +let runtimeServices: ManagedRuntimeServices | null = null; let runtimeConfig: DesktopRuntimeConfig | null = null; let memoryServiceControl: { baseUrl: string; token: string } | null = null; let memoryServiceRestart: Promise | null = null; @@ -306,15 +310,27 @@ async function boot(): Promise { registerIpcHandlers(); await installBundledCliIfNeeded(); await startPackagedRendererServerIfNeeded(); - runtimeServices = app.isPackaged - ? await startPackagedRuntimeServices({ - appPath: app.getAppPath(), - appDatabaseFile: join(app.getPath("userData"), "app.sqlite"), - resourcesPath: process.resourcesPath, - logDirectory: app.getPath("logs"), - logLevel: getCurrentLogLevel() - }) - : null; + const appDatabaseFile = join(app.getPath("userData"), "app.sqlite"); + runtimeServices = await startManagedRuntimeServices({ + appPath: app.getAppPath(), + appDatabaseFile, + resourcesPath: process.resourcesPath, + logDirectory: app.getPath("logs"), + logLevel: getCurrentLogLevel(), + beforeStartServices: async ({ databasePath, configPath }) => { + await syncRuntimeConfigForStartup({ + databasePath, + memmyConfigPath: configPath, + accountChannel: resolveCurrentDesktopAccountChannel() + }); + }, + runtimeEntries: app.isPackaged + ? undefined + : resolveDevelopmentRuntimeEntryPaths(import.meta.dirname), + runtimeExecutable: app.isPackaged + ? undefined + : resolveDevelopmentRuntimeExecutable() + }); runtimeConfig = await startLocalApi(runtimeServices); isBootReady = true; createInitialWindow(); @@ -431,6 +447,10 @@ function resolveCurrentDesktopEdition(): DesktopEdition { return resolveDesktopEdition(readCurrentDesktopEditionManifest(), process.env.MEMMY_ACCOUNT_CHANNEL); } +function resolveCurrentDesktopAccountChannel(): AccountChannel { + return resolveCurrentDesktopEdition() === "intl" ? "email" : "phone"; +} + /** * Reads the signing identity written at packaging time. * @returns The desktop package signing identity. @@ -713,7 +733,7 @@ function showPackagedStartupError(error: unknown): void { * Starts the local API backend. * @returns The runtime config the main process stores and exposes to the renderer. */ -async function startLocalApi(services: PackagedRuntimeServices | null): Promise { +async function startLocalApi(services: ManagedRuntimeServices | null): Promise { const databasePath = join(app.getPath("userData"), "app.sqlite"); let memoryControl: { baseUrl: string; token: string }; if (services) { @@ -749,17 +769,22 @@ async function startLocalApi(services: PackagedRuntimeServices | null): Promise< // bootstrapScenario: overrides the first-launch state during development/debugging. bootstrapScenario: getBootstrapScenario(), desktopInstallFingerprint, + accountChannel: resolveCurrentDesktopAccountChannel(), memmyConfigPath: process.env.MEMMY_CONFIG, memoryBaseUrl: memoryControl.baseUrl, runtimeConfigPath: process.env.MEMMY_HOME ? join(process.env.MEMMY_HOME, "runtime.json") : undefined }); - const agentGateway = services?.agentGateway ?? await resolveAgentGatewayRuntimeConfig(); + const agentGateway: NonNullable = + services?.agentGateway ?? await resolveAgentGatewayRuntimeConfig(); const agentGatewayConfig: NonNullable = { baseUrl: agentGateway.baseUrl }; if (agentGateway.bootstrapSecret) { agentGatewayConfig.bootstrapSecret = agentGateway.bootstrapSecret; } + if (agentGateway.startupIssue) { + agentGatewayConfig.startupIssue = agentGateway.startupIssue; + } return { ...localBackend.runtimeConfig, @@ -821,6 +846,7 @@ function registerIpcHandlers(): void { }); ipcMain.handle("memmy:get-app-info", () => getDesktopAppInfo()); + ipcMain.handle("memmy:get-installation-id", () => getOrCreateInstallationId()); ipcMain.handle("memmy:check-for-updates", async () => checkForUpdates()); @@ -1104,6 +1130,7 @@ async function installPreparedRequiredUpdateBeforeBoot(): Promise { const safeFilePath = resolveDownloadedUpdatePath(preparedUpdate.filePath); await access(safeFilePath, fsConstants.R_OK); + showUpdateInstallSplashWindow(targetVersion); hideMacDockForPreparedUpdateInstall(); await writePackagedStartupLog(`boot:prepared-required-update ${targetVersion}`); if (existsSync(resolvePreparedRequiredUpdateLockPath())) { @@ -1121,6 +1148,7 @@ async function installPreparedRequiredUpdateBeforeBoot(): Promise { return Boolean(installResult.willQuit); } catch (error) { console.warn("prepared required app update skipped:", error); + closeSplashWindow(); await clearPreparedRequiredUpdate(); await clearPreparedRequiredUpdateAttempt(); await writePackagedStartupLog(`boot:prepared-required-update skipped\n${formatStartupError(error)}`); @@ -1470,7 +1498,10 @@ async function installPreparedRequiredUpdateOnQuit(): Promise { if (process.platform === "win32") { installOptions.expectedVersion = preparedUpdate.latestVersion; } - await openBackgroundUpdateInstaller(safeFilePath, installOptions); + const installResult = await openBackgroundUpdateInstaller(safeFilePath, installOptions); + if (process.platform === "darwin" && installResult.background && !(await waitForPreparedRequiredUpdateLockStart())) { + await writePackagedStartupLog("quit:prepared-required-update lock-start-timeout"); + } } catch (error) { console.warn("prepared required app update on quit skipped:", error); if (isMissingFileError(error)) { @@ -1696,9 +1727,28 @@ function resolvePreparedRequiredUpdatePath(): string { * @returns The lock directory path next to the marker file. */ function resolvePreparedRequiredUpdateLockPath(): string { + const relayLockPath = resolveWindowsUpgradeRelayLockPath(); + if (relayLockPath && existsSync(relayLockPath)) { + return relayLockPath; + } return `${resolvePreparedRequiredUpdatePath()}.lock`; } +/** + * Resolves the install-independent lock held by the Windows legacy-upgrade relay. + * + * The relay moves the old install-local data directory out of the way, so the legacy marker lock + * temporarily disappears. New Windows builds prefer this external lock while preserving the old + * marker lock as a fallback for updates that do not need the relay. + * + * @returns The relay lock path on Windows, or null when it cannot be resolved. + */ +function resolveWindowsUpgradeRelayLockPath(): string | null { + if (process.platform !== "win32") return null; + const localAppData = process.env.LOCALAPPDATA?.trim(); + return localAppData ? join(localAppData, "Memmy", "upgrade-staging", "active.lock") : null; +} + /** * Path of the Windows silent-update prompt marker. * @@ -1891,6 +1941,7 @@ async function downloadUpdate( console.warn("mac update package staging skipped:", error); await writePackagedStartupLog(`mac-update-stage skipped\n${formatStartupError(error)}`); }); + await writePreparedRequiredUpdate(update, filePath); return { filePath, opened: false }; } @@ -2189,6 +2240,7 @@ OPEN_AFTER_INSTALL="\${5:-1}" MARKER_PATH="\${6:-}" STAGED_APP_PATH="\${7:-}" STAGED_READY_PATH="\${8:-}" +REOPEN_AFTER_INSTALL="$OPEN_AFTER_INSTALL" SCRIPT_PATH="$0" MOUNT_POINT="" LOCK_DIR="" @@ -2261,6 +2313,10 @@ fi LEFTOVER_PIDS="$(/usr/bin/pgrep -f "$DEST_APP_PATH/Contents/MacOS/" || true)" if [[ -n "$LEFTOVER_PIDS" ]]; then echo "terminating leftover Memmy runtime processes: $LEFTOVER_PIDS" + if [[ "$OPEN_AFTER_INSTALL" != "1" ]]; then + REOPEN_AFTER_INSTALL="1" + echo "detected reopen while background update is installing; will reopen after replacement" + fi /bin/kill $LEFTOVER_PIDS >/dev/null 2>&1 || true for _ in 1 2 3 4 5; do LEFTOVER_PIDS="$(/usr/bin/pgrep -f "$DEST_APP_PATH/Contents/MacOS/" || true)" @@ -2305,7 +2361,7 @@ if [[ -n "$MARKER_PATH" ]]; then fi /bin/rm -rf "$BACKUP_APP_PATH" >/dev/null 2>&1 || true INSTALL_SUCCEEDED=1 -if [[ "$OPEN_AFTER_INSTALL" == "1" ]]; then +if [[ "$REOPEN_AFTER_INSTALL" == "1" ]]; then /bin/sleep 0.1 /usr/bin/open -n "$DEST_APP_PATH" >/dev/null 2>&1 || true fi @@ -3119,6 +3175,7 @@ let splashCloseTimer: ReturnType | null = null; // Fallback: regardless of whether the close signal arrives, force-close after at most this long, so // it never blocks the UI permanently. const SPLASH_MAX_VISIBLE_MS = 15 * 1000; +const UPDATE_SPLASH_MAX_VISIBLE_MS = 60 * 1000; /** * Shows the startup splash. Only called on the normal boot path; creation failures do not affect the boot flow. @@ -3126,13 +3183,30 @@ const SPLASH_MAX_VISIBLE_MS = 15 * 1000; * @returns Nothing. */ function showSplashWindow(): void { + const language = resolveCurrentStartupSplashLanguage(); + showSplashHtml(resolveStartupSplashHtml(language), 300, 200, SPLASH_MAX_VISIBLE_MS); +} + +function showUpdateInstallSplashWindow(version?: string): void { + const language = resolveCurrentStartupSplashLanguage(); + showSplashHtml(resolveUpdateSplashHtml(language, version), 360, 220, UPDATE_SPLASH_MAX_VISIBLE_MS); +} + +function resolveCurrentStartupSplashLanguage(): StartupSplashLanguage { + return resolveStartupSplashLanguage( + join(app.getPath("userData"), "app.sqlite"), + resolveDefaultDesktopDisplayLanguage() + ); +} + +function showSplashHtml(html: string, width: number, height: number, maxVisibleMs: number): void { try { if (splashWindow && !splashWindow.isDestroyed()) { return; } const splash = new BrowserWindow({ - width: 300, - height: 200, + width, + height, frame: false, resizable: false, movable: false, @@ -3151,12 +3225,8 @@ function showSplashWindow(): void { splash.show(); } }); - const language = resolveStartupSplashLanguage( - join(app.getPath("userData"), "app.sqlite"), - resolveDefaultDesktopDisplayLanguage() - ); - void splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(resolveStartupSplashHtml(language))}`); - splashCloseTimer = setTimeout(closeSplashWindow, SPLASH_MAX_VISIBLE_MS); + void splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); + splashCloseTimer = setTimeout(closeSplashWindow, maxVisibleMs); splashCloseTimer.unref?.(); } catch (error) { console.warn("splash window skipped:", error); @@ -4714,6 +4784,7 @@ async function cleanupBeforeQuit(): Promise { await installPreparedRequiredUpdateOnQuit(); ipcMain.removeHandler("memmy:get-runtime-config"); ipcMain.removeHandler("memmy:get-app-info"); + ipcMain.removeHandler("memmy:get-installation-id"); ipcMain.removeHandler("memmy:check-for-updates"); ipcMain.removeHandler("memmy:download-update"); ipcMain.removeHandler("memmy:open-update-installer"); diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index 0dd3043fb..c3e33edc4 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -1,4 +1,5 @@ import { mutateRuntimeConfig } from "@memmy/migrations"; +import type { AgentGatewayStartupIssue } from "@memmy/local-api-contracts"; import { execFileSync, spawn, type ChildProcess } from "node:child_process"; import { randomBytes, randomUUID } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"; @@ -22,7 +23,7 @@ const EXISTING_MEMORY_STARTUP_GRACE_MS = 10_000; type RuntimeEnv = Record; type ConfigRecord = Record; -export interface PackagedRuntimeServices { +export interface ManagedRuntimeServices { memory: { baseUrl: string; token: string; @@ -34,6 +35,7 @@ export interface PackagedRuntimeServices { bootstrapSecret: string; configPath: string; workspace: string; + startupIssue?: AgentGatewayStartupIssue; }; restartMemory(): Promise; close(): Promise; @@ -48,6 +50,15 @@ export interface StartPackagedRuntimeServicesOptions { logLevel: LogLevel; } +export interface StartManagedRuntimeServicesOptions extends StartPackagedRuntimeServicesOptions { + runtimeEntries?: RuntimeEntryPaths; + runtimeExecutable?: string; + /** Runs after migrations/config preparation and before any managed child starts. */ + beforeStartServices?: (input: { databasePath: string; configPath: string }) => Promise; +} + +export type PackagedRuntimeServices = ManagedRuntimeServices; + export interface PreparePackagedRuntimeConfigOptions { env?: RuntimeEnv; secretFactory?: () => string; @@ -94,6 +105,7 @@ interface ServiceLogOptions { logFilePath: string; logLevel: LogLevel; ipc?: boolean; + executablePath?: string; } const DAEMON_LOG_MAX_SIZE = 5 * 1024 * 1024; @@ -139,9 +151,9 @@ export interface MemoryServerLock { sqlitePath?: string; } -export async function startPackagedRuntimeServices( - options: StartPackagedRuntimeServicesOptions -): Promise { +export async function startManagedRuntimeServices( + options: StartManagedRuntimeServicesOptions +): Promise { const entries = resolveRuntimeEntryPaths(options); const migrationTargets = await resolvePackagedRuntimeMigrationTargets(); await runPackagedMigrationCommand({ @@ -150,10 +162,15 @@ export async function startPackagedRuntimeServices( agentWorkspace: migrationTargets.agentWorkspace, appDatabaseFile: options.appDatabaseFile, logDirectory: options.logDirectory, - logLevel: options.logLevel + logLevel: options.logLevel, + runtimeExecutable: options.runtimeExecutable }); const runtimeConfig = await preparePackagedRuntimeConfig(); runtimeConfig.appDatabaseFile = options.appDatabaseFile; + await options.beforeStartServices?.({ + databasePath: options.appDatabaseFile, + configPath: runtimeConfig.configPath + }); const browserPreparationAttemptId = randomUUID(); const children: ManagedChild[] = []; const gatewaySupervisor = new AgentGatewaySupervisor( @@ -185,7 +202,7 @@ export async function startPackagedRuntimeServices( .catch((error) => { console.warn(`Memory service unavailable during desktop startup: ${errorMessage(error)}`); }); - await gatewaySupervisor.ensureStarted(); + const agentGatewayStartupIssue = await startAgentGatewayWithRecovery(gatewaySupervisor); return { memory: { @@ -198,7 +215,8 @@ export async function startPackagedRuntimeServices( baseUrl: runtimeConfig.agentGatewayBaseUrl, bootstrapSecret: runtimeConfig.agentGatewayBootstrapSecret, configPath: runtimeConfig.configPath, - workspace: runtimeConfig.agentWorkspace + workspace: runtimeConfig.agentWorkspace, + ...(agentGatewayStartupIssue ? { startupIssue: agentGatewayStartupIssue } : {}) }, async restartMemory() { if (closing) { @@ -239,6 +257,12 @@ export async function startPackagedRuntimeServices( } } +export async function startPackagedRuntimeServices( + options: StartPackagedRuntimeServicesOptions +): Promise { + return startManagedRuntimeServices(options); +} + export async function preparePackagedRuntimeConfig( options: PreparePackagedRuntimeConfigOptions = {} ): Promise { @@ -367,6 +391,7 @@ export async function runPackagedMigrationCommand(options: { appDatabaseFile: string; logDirectory: string; logLevel: LogLevel; + runtimeExecutable?: string; spawnProcess?: typeof spawn; timeoutMs?: number; }): Promise { @@ -398,7 +423,7 @@ export async function runPackagedMigrationCommand(options: { options.appDatabaseFile ]; child = (options.spawnProcess ?? spawn)( - process.execPath, + options.runtimeExecutable ?? process.execPath, migrationArgs, { env, @@ -536,7 +561,7 @@ function readDesktopBrowserPreparationState( export async function preparePackagedBrowser( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, - options: StartPackagedRuntimeServicesOptions, + options: StartManagedRuntimeServicesOptions, spawnProcess: typeof spawn = spawn ): Promise { return startPackagedBrowserPreparation( @@ -550,7 +575,7 @@ export async function preparePackagedBrowser( export function startPackagedBrowserPreparation( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, - options: StartPackagedRuntimeServicesOptions, + options: StartManagedRuntimeServicesOptions, spawnProcess: typeof spawn = spawn, attemptId: string = randomUUID() ): PackagedBrowserPreparation { @@ -594,7 +619,7 @@ export function startPackagedBrowserPreparation( try { child = spawnProcess( - process.execPath, + options.runtimeExecutable ?? process.execPath, [entries.agentEntry, "internal", "browser-prepare"], { env: { @@ -677,7 +702,7 @@ export async function ensureMemoryService( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, children: ManagedChild[], - options: StartPackagedRuntimeServicesOptions + options: StartManagedRuntimeServicesOptions ): Promise { const healthUrl = `${runtimeConfig.memoryBaseUrl}/api/v1/health`; const healthHeaders = memoryAuthHeaders(runtimeConfig.memoryToken); @@ -715,7 +740,8 @@ export async function ensureMemoryService( MEMORY_SERVICE_DB: runtimeConfig.memoryDatabasePath }, { logFilePath: join(options.logDirectory, "memory.log"), - logLevel: options.logLevel + logLevel: options.logLevel, + executablePath: options.runtimeExecutable }); children.push(memoryChild); try { @@ -733,7 +759,7 @@ async function restartManagedMemoryService( entries: RuntimeEntryPaths, runtimeConfig: PackagedRuntimeConfig, children: ManagedChild[], - options: StartPackagedRuntimeServicesOptions + options: StartManagedRuntimeServicesOptions ): Promise { const healthUrl = `${runtimeConfig.memoryBaseUrl}/api/v1/health`; const healthHeaders = memoryAuthHeaders(runtimeConfig.memoryToken); @@ -806,6 +832,26 @@ export interface AgentGatewaySupervisorDependencies { clearTimer?: typeof clearTimeout; } +export async function startAgentGatewayWithRecovery( + supervisor: Pick +): Promise { + try { + await supervisor.ensureStarted(); + return null; + } catch (error) { + console.warn(`Agent gateway unavailable during desktop startup: ${errorMessage(error)}`); + supervisor.startRecovery(); + return classifyAgentGatewayStartupIssue(error); + } +} + +function classifyAgentGatewayStartupIssue(error: unknown): AgentGatewayStartupIssue | null { + const message = errorMessage(error); + return /failed to load config[\s\S]*\b(providers|modelPresets|modelAssignments|agents\.defaults)\b/i.test(message) + ? "model_config_invalid" + : null; +} + export class AgentGatewaySupervisor { ownership: "external" | "owned" | null = null; ownedChild: ManagedChild | null = null; @@ -827,7 +873,7 @@ export class AgentGatewaySupervisor { private readonly entries: RuntimeEntryPaths, private readonly runtimeConfig: PackagedRuntimeConfig, private readonly children: ManagedChild[], - private readonly options: StartPackagedRuntimeServicesOptions, + private readonly options: StartManagedRuntimeServicesOptions, dependencies: AgentGatewaySupervisorDependencies = {}, private readonly browserPreparationAttemptId: string = process.env[BROWSER_PREPARATION_ATTEMPT_ID_ENV]?.trim() || "" @@ -854,6 +900,11 @@ export class AgentGatewaySupervisor { return this.startPromise; } + startRecovery(): void { + if (this.stopping || this.hasReachedReady) return; + this.scheduleReplacement(); + } + async close(): Promise { this.stopping = true; this.clearTimers(); @@ -934,7 +985,8 @@ export class AgentGatewaySupervisor { }, { logFilePath: join(this.options.logDirectory, "agent-gateway.log"), logLevel: this.options.logLevel, - ipc: true + ipc: true, + executablePath: this.options.runtimeExecutable }); this.ownership = "owned"; this.ownedChild = child; @@ -1044,6 +1096,9 @@ export class AgentGatewaySupervisor { } try { await this.spawnOwnedGateway(false); + if (!this.hasReachedReady) { + this.scheduleReplacement(); + } } catch { this.scheduleReplacement(); } @@ -1080,8 +1135,26 @@ export class AgentGatewaySupervisor { } } -function resolveRuntimeEntryPaths(options: StartPackagedRuntimeServicesOptions): RuntimeEntryPaths { - void options.resourcesPath; +export function resolveDevelopmentRuntimeEntryPaths(mainDirectory: string): RuntimeEntryPaths { + const repoRoot = resolve(mainDirectory, "../../../../.."); + return { + memoryEntry: join(repoRoot, "Memory", "dist", "src", "server", "index.js"), + agentEntry: join(repoRoot, "App", "memmy-agent", "dist", "main.js") + }; +} + +export function resolveDevelopmentRuntimeExecutable( + env: NodeJS.ProcessEnv = process.env +): string { + return env.MEMMY_RUNTIME_NODE_PATH?.trim() + || env.npm_node_execpath?.trim() + || "node"; +} + +export function resolveRuntimeEntryPaths(options: StartManagedRuntimeServicesOptions): RuntimeEntryPaths { + if (options.runtimeEntries) { + return { ...options.runtimeEntries }; + } return { memoryEntry: join(options.appPath, "dist/runtime/memory/src/server/index.js"), agentEntry: join(options.appPath, "dist/runtime/memmy-agent/dist/main.js") @@ -1106,7 +1179,7 @@ export function spawnNodeService( ELECTRON_RUN_AS_NODE: "1", NODE_ENV: process.env.NODE_ENV ?? "production" }; - const child = spawn(process.execPath, [entry, ...args], { + const child = spawn(logOptions.executablePath ?? process.execPath, [entry, ...args], { env: childEnv, stdio: logOptions.ipc ? ["ignore", "pipe", "pipe", "ipc"] : ["ignore", "pipe", "pipe"], windowsHide: true diff --git a/App/shell/desktop/src/main/startup-splash.ts b/App/shell/desktop/src/main/startup-splash.ts index 002947f4a..737ae687c 100644 --- a/App/shell/desktop/src/main/startup-splash.ts +++ b/App/shell/desktop/src/main/startup-splash.ts @@ -37,3 +37,33 @@ body{display:flex;align-items:center;justify-content:center;background:#1f2937;c @keyframes spin{to{transform:rotate(360deg);}}
Memmy
${hint}
`; } + +export function resolveUpdateSplashHtml(language: StartupSplashLanguage, version?: string): string { + const title = language === "en-US" ? "Completing Memmy update" : "正在完成 Memmy 更新"; + const detail = language === "en-US" + ? "Memmy will reopen automatically when the update is ready." + : "安装完成后会自动打开新版。"; + const versionText = version?.trim() + ? language === "en-US" + ? `Installing ${escapeSplashText(version)}` + : `正在安装 ${escapeSplashText(version)}` + : language === "en-US" ? "Installing update" : "正在安装更新"; + return `
${title}
${versionText}
${detail}
`; +} + +function escapeSplashText(value: string): string { + return value + .replace(/&/gu, "&") + .replace(//gu, ">") + .replace(/"/gu, """); +} diff --git a/App/shell/desktop/src/preload/preload.cts b/App/shell/desktop/src/preload/preload.cts index e8ede4299..1c4bab37c 100644 --- a/App/shell/desktop/src/preload/preload.cts +++ b/App/shell/desktop/src/preload/preload.cts @@ -25,6 +25,7 @@ interface MemmyPreloadApi { platform: string; getRuntimeConfig(): Promise; getAppInfo(): Promise; + getInstallationId(): Promise; checkForUpdates(): Promise; downloadUpdate(update: DesktopUpdateCheckResult, options?: DesktopUpdateDownloadOptions): Promise; onUpdateDownloadProgress(callback: (progress: DesktopUpdateDownloadProgress) => void): () => void; @@ -118,6 +119,10 @@ const memmyPreloadApi: MemmyPreloadApi = { return ipcRenderer.invoke("memmy:get-app-info"); }, + async getInstallationId(): Promise { + return ipcRenderer.invoke("memmy:get-installation-id"); + }, + async checkForUpdates(): Promise { return ipcRenderer.invoke("memmy:check-for-updates"); }, diff --git a/App/shell/desktop/tests/installation-id-store.test.ts b/App/shell/desktop/tests/installation-id-store.test.ts new file mode 100644 index 000000000..2d87bffcd --- /dev/null +++ b/App/shell/desktop/tests/installation-id-store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it, vi } from "vitest"; +import { getOrCreateInstallationId } from "../src/main/installation-id-store.js"; + +describe("installation-id-store", () => { + it("reuses the persisted installation id instead of generating a new one", () => { + const createId = vi.fn(() => "new-install-id"); + const writeFileSyncImpl = vi.fn(); + + const id = getOrCreateInstallationId({ + existsSyncImpl: () => true, + readFileSyncImpl: () => " persisted-install-id\n", + writeFileSyncImpl, + createId, + }); + + expect(id).toBe("persisted-install-id"); + expect(createId).not.toHaveBeenCalled(); + expect(writeFileSyncImpl).not.toHaveBeenCalled(); + }); + + it("creates and persists an installation id when none exists", () => { + const writeFileSyncImpl = vi.fn(); + + const id = getOrCreateInstallationId({ + existsSyncImpl: () => false, + readFileSyncImpl: () => "", + mkdirSyncImpl: vi.fn(), + writeFileSyncImpl, + createId: () => "new-install-id", + }); + + expect(id).toBe("new-install-id"); + expect(writeFileSyncImpl).toHaveBeenCalledWith( + expect.stringMatching(/installation-id$/), + "new-install-id\n", + "utf8", + ); + }); +}); diff --git a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts index e2261dd46..217bba5ac 100644 --- a/App/shell/desktop/tests/packaged-runtime-boundary.test.ts +++ b/App/shell/desktop/tests/packaged-runtime-boundary.test.ts @@ -14,7 +14,6 @@ const startupMigrationsPath = fileURLToPath( new URL("../../../memmy-agent/src/entrypoints/cli/startup-migrations.ts", import.meta.url) ); const devStartPath = fileURLToPath(new URL("../../../../scripts/dev-start.sh", import.meta.url)); -const devMemorySupervisorPath = fileURLToPath(new URL("../../../../scripts/internal/shared/dev-memory-supervisor.mjs", import.meta.url)); const clearAllPath = fileURLToPath(new URL("../../../../scripts/clear-all.sh", import.meta.url)); const clearAllWindowsPath = fileURLToPath(new URL("../../../../scripts/clear-all-windows.ps1", import.meta.url)); const packageMacPath = fileURLToPath(new URL("../../../../scripts/package-mac.sh", import.meta.url)); @@ -33,6 +32,7 @@ const packageWinPath = fileURLToPath(new URL("../../../../scripts/package-win.sh const packageWinX64Path = fileURLToPath(new URL("../../../../scripts/internal/win/build-nsis.sh", import.meta.url)); const winUnsignedBuilderPath = fileURLToPath(new URL("../electron-builder.win.unsigned.yml", import.meta.url)); const winUnsignedInstallerIncludePath = fileURLToPath(new URL("../build/installer-win-unsigned.nsh", import.meta.url)); +const winUpgradeRelayScriptPath = fileURLToPath(new URL("../build/MemmyWindowsUpgradeRelay.ps1", import.meta.url)); const desktopInterfacePath = fileURLToPath(new URL("../interface/src/index.ts", import.meta.url)); const localApiContractsPath = fileURLToPath(new URL("../../../../App/backend/local-api-contracts/src/index.ts", import.meta.url)); const rootPackagePath = fileURLToPath(new URL("../../../../package.json", import.meta.url)); @@ -451,8 +451,17 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('app.setPath("userData", userDataPath);'); expect(source).toContain('return join(dirname(process.execPath), "data");'); expect(source).toContain("process.env.MEMMY_MEMORY_DB = memoryDatabasePath;"); - expect(source).toMatch(/runtimeServices = app\.isPackaged\s*\?\s*await startPackagedRuntimeServices\(/); - expect(source).toContain('appDatabaseFile: join(app.getPath("userData"), "app.sqlite")'); + expect(source).toContain('const appDatabaseFile = join(app.getPath("userData"), "app.sqlite");'); + expect(source).toContain("accountChannel: resolveCurrentDesktopAccountChannel()"); + expect(source).toContain("runtimeServices = await startManagedRuntimeServices({"); + const runtimeServicesSource = readFileSync(runtimeServicesPath, "utf8"); + expect(runtimeServicesSource.indexOf("await runPackagedMigrationCommand({")).toBeLessThan( + runtimeServicesSource.indexOf("await options.beforeStartServices?.({") + ); + expect(runtimeServicesSource.indexOf("await options.beforeStartServices?.({")).toBeLessThan( + runtimeServicesSource.indexOf("browserPreparation = startPackagedBrowserPreparation(") + ); + expect(source).toContain("resolveDevelopmentRuntimeEntryPaths(import.meta.dirname)"); expect(source).toContain("memmyConfigPath: process.env.MEMMY_CONFIG"); expect(source).not.toContain("startDesktopRuntimeServices"); }); @@ -468,8 +477,11 @@ describe("desktop packaged runtime boundaries", () => { const contractsSource = readFileSync(localApiContractsPath, "utf8"); expect(contractsSource).toContain("bootstrapSecret: z.string().min(1).optional()"); + expect(contractsSource).toContain("startupIssue: AgentGatewayStartupIssueSchema.optional()"); expect(mainSource).toContain("if (agentGateway.bootstrapSecret) {"); expect(mainSource).toContain("agentGatewayConfig.bootstrapSecret = agentGateway.bootstrapSecret;"); + expect(mainSource).toContain("if (agentGateway.startupIssue) {"); + expect(mainSource).toContain("agentGatewayConfig.startupIssue = agentGateway.startupIssue;"); expect(mainSource).not.toContain("bootstrapSecret: agentGateway.bootstrapSecret"); }); @@ -535,6 +547,61 @@ describe("desktop packaged runtime boundaries", () => { expect(includeSource).toContain("CRCCheck off"); }); + it("relays legacy in-app upgrades outside the installed data directory", () => { + const signedBuilderConfig = readFileSync(winElectronBuilderPath, "utf8"); + const unsignedBuilderConfig = readFileSync(winUnsignedBuilderPath, "utf8"); + const includeSource = readFileSync(winUnsignedInstallerIncludePath, "utf8"); + const relaySource = readFileSync(winUpgradeRelayScriptPath, "utf8"); + const mainSource = readFileSync(mainSourcePath, "utf8"); + + expect(signedBuilderConfig).toContain("include: build/installer-win-unsigned.nsh"); + expect(unsignedBuilderConfig).toContain("include: build/installer-win-unsigned.nsh"); + expect(includeSource).toContain("!macro customInit"); + expect(includeSource).toContain("--memmy-upgrade-relayed"); + expect(includeSource).toContain("MemmyWindowsUpgradeRelay.ps1"); + expect(includeSource).toContain('$LOCALAPPDATA\\Memmy\\upgrade-staging'); + expect(includeSource).toContain("GetCurrentProcessId"); + expect(includeSource).toContain('upgrade-staging\\$2'); + expect(includeSource).toContain("OriginalInstallerPid $2"); + expect(includeSource).toContain("LegacyHelperPid $3"); + expect(includeSource).toContain("ReopenAfterInstall"); + expect(includeSource).toContain("Call MemmyRestoreRelayedUpgradeData"); + expect(includeSource).toContain("Call MemmyClearRelayedUpgradeMarkers"); + expect(includeSource).toContain("Call MemmyLaunchRelayedUpgrade"); + expect(includeSource).toContain("Call MemmyScheduleRelayedUpgradeCleanup"); + expect(includeSource).toContain("MEMMY_UPGRADE_WORK_DIR"); + expect(includeSource).toContain("MEMMY_UPGRADE_REOPEN_AFTER_INSTALL"); + expect(includeSource).toContain("relay-ready"); + expect(includeSource).toContain("ReadyPath"); + expect(includeSource).toContain("MemmyWindowsUpgradeCleanup.ps1"); + expect(includeSource).toContain('ExecShell "open" "$R5"'); + expect(includeSource).toContain('ExecShell "open" "$1"'); + expect(includeSource.match(/ExecShell "open" .* SW_HIDE/g)).toHaveLength(2); + expect(includeSource).not.toContain('Exec \'$\\\"$R5$\\\"'); + expect(includeSource).toContain('Exec \'$\\\"$INSTDIR\\${PRODUCT_FILENAME}.exe$\\\" --updated\''); + expect(includeSource).toContain('$LOCALAPPDATA\\Memmy\\upgrade-staging\\active.lock'); + const relayInitIndex = includeSource.indexOf("Function MemmyRelayLegacyUpgrade"); + const earlyLaunchProxyIndex = includeSource.indexOf("Call MemmyInstallLaunchProxy", relayInitIndex); + const relayStartIndex = includeSource.indexOf('ExecShell "open" "$R5"', relayInitIndex); + expect(earlyLaunchProxyIndex).toBeGreaterThan(relayInitIndex); + expect(earlyLaunchProxyIndex).toBeLessThan(relayStartIndex); + expect(includeSource).toContain("SetErrorLevel"); + expect(relaySource).toContain("Move-MemmyDirectory"); + expect(relaySource).toContain("Restore-MemmyData"); + expect(relaySource).toContain("Resolve-MemmyLegacyHelperReopenIntent"); + expect(relaySource).toContain("MEMMY_UPGRADE_WORK_DIR"); + expect(relaySource).toContain("MEMMY_UPGRADE_REOPEN_AFTER_INSTALL"); + expect(relaySource).toContain("$installerProcess.WaitForExit()"); + expect(relaySource).not.toContain("-Wait -PassThru"); + expect(relaySource).toContain("MemmyWindowsUpgradeCleanup.ps1"); + expect(relaySource).not.toContain("cmd.exe"); + expect(relaySource).not.toContain("$env:ComSpec"); + expect(relaySource).not.toContain("ping.exe"); + expect(relaySource).toContain("--memmy-upgrade-relayed"); + expect(relaySource).toContain("upgrade verified"); + expect(mainSource).toContain('spawn("/bin/zsh", [helperPath, filePath, destinationAppPath, logPath, String(process.pid), options.openAfterInstall ? "1" : "0"'); + }); + it("adds packaged Windows CLI launchers to the user PATH", () => { const signedBuilderConfig = readFileSync(winElectronBuilderPath, "utf8"); const unsignedBuilderConfig = readFileSync(winUnsignedBuilderPath, "utf8"); @@ -575,6 +642,9 @@ describe("desktop packaged runtime boundaries", () => { expect(includeSource).toContain('dataRoot = $\\"$INSTDIR\\data$\\"'); expect(includeSource).toContain('languagePath = dataRoot & $\\"\\Memmy\\update-prompt-language.txt$\\"'); expect(includeSource).toContain('markerPath = dataRoot & $\\"\\Memmy\\prepared-required-update.json$\\"'); + expect(includeSource).toContain('relayLockPath = shell.ExpandEnvironmentStrings($\\"%LOCALAPPDATA%$\\") & $\\"\\Memmy\\upgrade-staging\\active.lock$\\"'); + expect(includeSource).toContain("If fso.FolderExists(relayLockPath) Then"); + expect(includeSource).toContain("lockPath = relayLockPath"); expect(includeSource).toContain("WindowsPowerShell\\v1.0\\powershell.exe"); expect(includeSource).toContain('promptMarkerPath = markerPath & $\\".prompt$\\"'); expect(includeSource).toContain("If fso.FolderExists(lockPath) And fso.FileExists(promptMarkerPath) Then"); @@ -810,9 +880,12 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("await writePreparedRequiredUpdate(update, preparedFilePath)"); expect(mainSource).toContain("async function installPreparedRequiredUpdateOnQuit"); expect(mainSource).toContain("await installPreparedRequiredUpdateOnQuit()"); + expect(mainSource).toContain("showUpdateInstallSplashWindow(targetVersion)"); expect(mainSource).toContain("openAfterInstall: false"); expect(mainSource).not.toContain('openAfterInstall: process.platform === "win32"'); expect(mainSource).toContain("function resolvePreparedRequiredUpdateLockPath"); + expect(mainSource).toContain("function resolveWindowsUpgradeRelayLockPath"); + expect(mainSource).toContain('join(localAppData, "Memmy", "upgrade-staging", "active.lock")'); expect(mainSource).toContain("async function waitForPreparedRequiredUpdateLock"); expect(mainSource).toContain("async function waitForWindowsPreparedRequiredUpdateBeforeBoot"); expect(mainSource).toContain('boot:prepared-required-update waiting-for-lock win32'); @@ -848,6 +921,7 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).not.toContain("Memmy 正在更新"); expect(mainSource).toContain("boot:prepared-required-update win32"); expect(mainSource).toContain("async function waitForPreparedRequiredUpdateLockStart"); + expect(mainSource).toContain("quit:prepared-required-update lock-start-timeout"); expect(windowsPreparedUpdateSource).toContain("openBackgroundUpdateInstaller(safeFilePath"); expect(mainSource).toContain("$arguments = @('/S', '--updated', '/currentuser', ('/D=' + $appDir))"); expect(mainSource).not.toContain("app reopened before install; deferring update"); @@ -879,6 +953,7 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain('value.code !== 0'); expect(mainSource).toContain('readManifestRecord(value, "data") ?? {}'); expect(mainSource).toContain("async function downloadUpdate"); + expect(mainSource).toContain("await writePreparedRequiredUpdate(update, filePath)"); expect(mainSource).toContain("function resolveUpdatesDirectory()"); expect(mainSource).toContain('join(app.getPath("userData"), "updates")'); expect(mainSource).toContain("function resolveDownloadedUpdatePath"); @@ -902,6 +977,9 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("$arguments = @('/S', '--updated', '/currentuser', ('/D=' + $appDir))"); expect(mainSource).toContain("CURRENT_APP_PID"); expect(mainSource).toContain("OPEN_AFTER_INSTALL"); + expect(mainSource).toContain('REOPEN_AFTER_INSTALL="$OPEN_AFTER_INSTALL"'); + expect(mainSource).toContain("detected reopen while background update is installing; will reopen after replacement"); + expect(mainSource).toContain('if [[ "$REOPEN_AFTER_INSTALL" == "1" ]]'); expect(mainSource).toContain('while /bin/kill -0 "$CURRENT_APP_PID"'); expect(mainSource).toContain("terminating leftover Memmy runtime processes"); expect(mainSource).toContain("-WindowStyle Hidden"); @@ -1010,9 +1088,10 @@ describe("desktop packaged runtime boundaries", () => { expect(mainSource).toContain("syncMenuBarTray(true);"); }); - it("keeps runtime-services packaged-only and out of Electron userData", () => { + it("keeps managed runtime services out of Electron userData", () => { const source = readFileSync(runtimeServicesPath, "utf8"); + expect(source).toContain("startManagedRuntimeServices"); expect(source).toContain("startPackagedRuntimeServices"); expect(source).toContain('env.MEMMY_CONFIG ?? join(memmyHome, "config.yaml")'); expect(source).toContain("const explicitWorkspace = stringValue(env.MEMMY_AGENT_WORKSPACE);"); @@ -1045,16 +1124,14 @@ describe("desktop packaged runtime boundaries", () => { expect(source).not.toContain("DesktopRuntimeServices"); expect(source).not.toContain("StartDesktopRuntimeServicesOptions"); expect(source).not.toContain("userDataPath"); - expect(source).not.toContain("mainDir"); expect(source).not.toContain("getFreePort"); expect(source).not.toContain(legacyApplicationSupportDir); - expect(source).not.toContain("dist/src/server/index.js"); - expect(source).not.toContain("App/memmy-agent/dist/main.js"); + expect(source).toContain('join(repoRoot, "Memory", "dist", "src", "server", "index.js")'); + expect(source).toContain('join(repoRoot, "App", "memmy-agent", "dist", "main.js")'); }); it("exports shared config and workspace paths from dev-start", () => { const source = readFileSync(devStartPath, "utf8"); - const supervisorSource = readFileSync(devMemorySupervisorPath, "utf8"); const runMainIndex = source.indexOf("run_main() {"); const migrationIndex = source.indexOf("local migration_args=(dist/main.js migrate", runMainIndex); const memoryInitIndex = source.indexOf("build_and_install_memory_cli", runMainIndex); @@ -1083,17 +1160,14 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('export PATH="$runtime_node_dir:$PATH"'); expect(source).not.toContain('MEMMY_BIN_DIR="$HOME/.memmy/bin"'); expect(source).not.toContain('"bash -lc '); - expect(source.match(/"bash -c /g)).toHaveLength(5); + expect(source.match(/"bash -c /g)).toHaveLength(3); expect(source).toContain('const Database = require("better-sqlite3")'); expect(source).toContain("npm run dev -w @memmy/desktop"); expect(source).toContain("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci"); - expect(source).toContain( - '"$MEMMY_RUNTIME_NODE_PATH" dist/main.js internal browser-prepare', - ); expect(source).toContain("env -u ELECTRON_RUN_AS_NODE npm run dev -w @memmy/desktop"); - expect(source).toContain("node scripts/internal/shared/dev-memory-supervisor.mjs"); - expect(supervisorSource).toContain('["run", "memory:dev"]'); - expect(supervisorSource).toContain("Memory dev process stopped"); + expect(source).not.toContain("node scripts/internal/shared/dev-memory-supervisor.mjs"); + expect(source).not.toContain("node dist/main.js gateway"); + expect(source).not.toContain("--gateway)"); expect(source).toContain('pgrep -f "/Memmy.app/Contents/MacOS/Memmy"'); expect(source.match(/lsof -tiTCP:18997/g)).toHaveLength(2); expect(source.match(/lsof -tiTCP:18999/g)).toHaveLength(2); @@ -1177,7 +1251,21 @@ describe("desktop packaged runtime boundaries", () => { expect(source).toContain('delete dependencies["@memmy/migrations"]'); expect(source).toContain('cp "$LOCAL_API_CONTRACTS_DIR/package.json"'); expect(source).toContain('cp -R "$LOCAL_API_CONTRACTS_DIR/dist"'); - expect(source).toContain('"$RUNTIME_DIR/memory/node_modules/@memmy/migrations/package.json"'); + expect(source).toContain( + 'MEMORY_RUNTIME_CONTRACTS_DIR="$RUNTIME_DIR/memory/node_modules/@memmy/local-api-contracts"', + ); + expect(source).toContain( + 'MEMORY_RUNTIME_MIGRATIONS_DIR="$RUNTIME_DIR/memory/node_modules/@memmy/migrations"', + ); + expect(source).toContain( + 'cp "$MIGRATIONS_STAGING_DIR/package.json" "$MEMORY_RUNTIME_MIGRATIONS_DIR/package.json"', + ); + expect(source).toContain( + 'require_packaged_runtime_file "$MEMORY_RUNTIME_CONTRACTS_DIR/dist/index.js"', + ); + expect(source).toContain( + 'require_packaged_runtime_file "$MEMORY_RUNTIME_MIGRATIONS_DIR/dist/index.js"', + ); expect(source).toContain("node_modules/.bin/electron-rebuild"); expect(source).toContain('-m "$RUNTIME_DIR/memory"'); expect(source).not.toContain('cp -R "$ROOT_DIR/dist/src" "$RUNTIME_DIR/memory/src"'); diff --git a/App/shell/desktop/tests/runtime-services.test.ts b/App/shell/desktop/tests/runtime-services.test.ts index 0a254b02a..b7b7a97af 100644 --- a/App/shell/desktop/tests/runtime-services.test.ts +++ b/App/shell/desktop/tests/runtime-services.test.ts @@ -13,10 +13,14 @@ import { preparePackagedBrowser, preparePackagedRuntimeConfig, readLiveMemoryServerLock, + resolveDevelopmentRuntimeExecutable, + resolveDevelopmentRuntimeEntryPaths, resolvePackagedRuntimeMigrationTargets, + resolveRuntimeEntryPaths, runPackagedMigrationCommand, restartExternalMemoryService, spawnNodeService, + startAgentGatewayWithRecovery, startPackagedBrowserPreparation, stopManagedChild, syncBundledAgentSkills, @@ -836,6 +840,89 @@ describe("AgentGatewaySupervisor", () => { expect(harness.supervisor.restartTimer).toBeNull(); }); + it("keeps retrying after an explicitly recoverable initial startup failure", async () => { + vi.useFakeTimers(); + const waitForHttpService = vi.fn() + .mockRejectedValueOnce(new Error("invalid runtime config")) + .mockRejectedValueOnce(new Error("runtime config is still invalid")) + .mockResolvedValueOnce(undefined); + const harness = createSupervisorHarness({ + waitForHttpService, + stopManagedChild: vi.fn(async (child: ManagedChild) => { + emitChildClose(child, 1); + }) + }); + + await expect(harness.supervisor.ensureStarted()).rejects.toThrow("invalid runtime config"); + harness.supervisor.startRecovery(); + await vi.advanceTimersByTimeAsync(249); + expect(harness.spawn).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + expect(harness.spawn).toHaveBeenCalledTimes(2); + expect(harness.supervisor.hasReachedReady).toBe(false); + await vi.advanceTimersByTimeAsync(999); + expect(harness.spawn).toHaveBeenCalledTimes(2); + await vi.advanceTimersByTimeAsync(1); + + expect(harness.spawn).toHaveBeenCalledTimes(3); + expect(harness.supervisor.hasReachedReady).toBe(true); + expect(harness.supervisor.restartTimer).toBeNull(); + }); + + it("cancels pending initial recovery during shutdown", async () => { + vi.useFakeTimers(); + const harness = createSupervisorHarness({ + waitForHttpService: vi.fn(async () => { + throw new Error("invalid runtime config"); + }), + stopManagedChild: vi.fn(async (child: ManagedChild) => { + emitChildClose(child, 1); + }) + }); + + await expect(harness.supervisor.ensureStarted()).rejects.toThrow("invalid runtime config"); + harness.supervisor.startRecovery(); + await harness.supervisor.close(); + await vi.advanceTimersByTimeAsync(60_000); + + expect(harness.spawn).toHaveBeenCalledTimes(1); + expect(harness.supervisor.restartTimer).toBeNull(); + }); + + it("contains the initial Agent failure and enables background recovery", async () => { + const failure = new Error("invalid runtime config"); + const supervisor = { + ensureStarted: vi.fn(async () => { + throw failure; + }), + startRecovery: vi.fn() + }; + const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(startAgentGatewayWithRecovery(supervisor)).resolves.toBeNull(); + + expect(supervisor.startRecovery).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + "Agent gateway unavailable during desktop startup: invalid runtime config" + ); + }); + + it("classifies a rejected model config without exposing the startup error", async () => { + const supervisor = { + ensureStarted: vi.fn(async () => { + throw new Error( + "agent-gateway exited before it became ready (code 1). stderr: memmy: Failed to load config from C:/Memmy/config.yaml: providers current contract does not accept legacy field 'apiBase'" + ); + }), + startRecovery: vi.fn() + }; + vi.spyOn(console, "warn").mockImplementation(() => undefined); + + await expect(startAgentGatewayWithRecovery(supervisor)).resolves.toBe("model_config_invalid"); + expect(supervisor.startRecovery).toHaveBeenCalledTimes(1); + }); + it("restarts an owned gateway with bounded escalating delays and ignores old child callbacks", async () => { vi.useFakeTimers(); const harness = createSupervisorHarness(); @@ -1058,6 +1145,24 @@ describe("spawnNodeService 落盘与 env 注入", () => { expect(await readFile(logFile, "utf8")).toContain("hello-from-child"); }); + it("把 Agent Gateway 子进程 stderr 落盘到 agent-gateway.log", async () => { + const root = await makeTempRoot(); + const entry = join(root, "entry.js"); + await writeFile(entry, "process.stderr.write('[session-dag] compaction failed SQLITE_CANTOPEN\\n');\n"); + const logFile = join(root, "agent-gateway.log"); + + const managed = spawnNodeService("agent-gateway", entry, [], {}, { + logFilePath: logFile, + logLevel: "info" + }); + await new Promise((done) => managed.process.once("exit", () => done())); + await new Promise((resolveDelay) => setTimeout(resolveDelay, 50)); + + const logText = await readFile(logFile, "utf8"); + expect(logText).toContain("[session-dag] compaction failed"); + expect(logText).toContain("SQLITE_CANTOPEN"); + }); + it("把 MEMMY_LOG_LEVEL 注入子进程环境", async () => { const root = await makeTempRoot(); const entry = join(root, "entry.js"); diff --git a/App/shell/desktop/tests/startup-splash.test.ts b/App/shell/desktop/tests/startup-splash.test.ts index ec13689b4..7bfe62403 100644 --- a/App/shell/desktop/tests/startup-splash.test.ts +++ b/App/shell/desktop/tests/startup-splash.test.ts @@ -6,6 +6,7 @@ import { DatabaseSync } from "node:sqlite"; import { resolveStartupSplashHtml, resolveStartupSplashLanguage, + resolveUpdateSplashHtml, type StartupSplashLanguage } from "../src/main/startup-splash.js"; @@ -48,6 +49,18 @@ describe("startup splash localization", () => { expect(chineseHtml).toContain("正在启动…"); expect(chineseHtml).not.toContain("Starting…"); }); + + it("renders the update splash without exposing unescaped version text", () => { + const englishHtml = resolveUpdateSplashHtml("en-US", "1.0.8