From f8fc171ad17184f47c1d52b42dc742baf4a03f4c Mon Sep 17 00:00:00 2001 From: bo Date: Sun, 9 Aug 2026 19:16:32 +0800 Subject: [PATCH 1/2] feat(memory): rebuild durable learning and management --- AGENTS.md | 14 +- README.md | 9 + apps/server/src/app.test.ts | 4 + apps/server/src/app.ts | 3 + apps/server/src/errors.ts | 6 + apps/server/src/routes/config-recovery.ts | 2 +- apps/server/src/routes/memory.test.ts | 356 ++ apps/server/src/routes/memory.ts | 163 + apps/web/src/api/config.test.ts | 2 +- apps/web/src/api/memory.test.ts | 55 + apps/web/src/api/memory.ts | 96 + .../ExecutionWorkstream.interaction.tsx | 6 + .../components/features/ChatHeader.test.tsx | 5 + .../features/SettingsDialog.interaction.tsx | 17 +- .../features/SettingsDialog.test.tsx | 2 +- .../components/features/SettingsDialog.tsx | 16 +- .../features/SettingsMemoryPanel.test.tsx | 379 ++ .../features/SettingsMemoryPanel.tsx | 400 ++ .../TodoProgressButton.interaction.tsx | 6 + .../components/features/settings-helpers.ts | 2 +- .../components/features/settings-panels.tsx | 7 +- apps/web/src/components/ui/Dialog.test.tsx | 5 +- apps/web/src/components/ui/Dialog.tsx | 4 +- apps/web/src/context/settings-modal.test.tsx | 9 +- apps/web/src/context/settings-modal.tsx | 8 +- apps/web/src/routes/session.test.tsx | 7 + apps/web/src/store/session-store.test.ts | 19 + config.example.json | 4 + docs/configuration.md | 14 +- .../goals/memory-system-hard-cut-plan-goal.md | 230 + docs/goals/memory-system-hard-cut-progress.md | 106 + .../src/__arch__/architecture.test.ts | 5 +- .../src/__arch__/module-boundaries.test.ts | 2 - .../src/agents/configured-agent.test.ts | 340 +- .../agent-core/src/agents/configured-agent.ts | 62 +- .../src/agents/definitions/analyst.ts | 2 - .../src/agents/definitions/build.ts | 2 - .../src/agents/definitions/discussion.ts | 2 - .../src/agents/definitions/explore.ts | 2 - .../agent-core/src/agents/definitions/lead.ts | 2 - .../src/agents/definitions/librarian.ts | 2 - .../agent-core/src/agents/factory-types.ts | 2 - .../agent-core/src/agents/factory.test.ts | 2 - packages/agent-core/src/agents/factory.ts | 3 - .../src/agents/query/hooks/index.ts | 2 - .../query/hooks/memory-consolidation.test.ts | 106 - .../query/hooks/memory-consolidation.ts | 37 - .../query/hooks/memory-extraction.test.ts | 428 -- .../agents/query/hooks/memory-extraction.ts | 77 - .../agent-core/src/agents/query/loop.test.ts | 3 +- ...vider-secret-redaction.integration.test.ts | 2 + .../src/agents/query/recovery.test.ts | 3 +- .../agents/query/todo-continuation.test.ts | 2 - .../src/agents/session-agent-manager.test.ts | 13 +- .../src/agents/session-agent-manager.ts | 3 - packages/agent-core/src/agents/types.ts | 3 + packages/agent-core/src/background/index.ts | 1 - .../agent-core/src/background/tasks/index.ts | 2 - .../tasks/memory-consolidation.test.ts | 365 -- .../background/tasks/memory-consolidation.ts | 83 - .../tasks/memory-extraction.test.ts | 1350 ------ .../src/background/tasks/memory-extraction.ts | 327 -- .../background/tasks/title-generation.test.ts | 3 +- .../src/compression/dynamic-range.test.ts | 2 - .../src/compression/original-range.test.ts | 4 - packages/agent-core/src/config/config.test.ts | 30 +- packages/agent-core/src/config/index.ts | 4 +- packages/agent-core/src/config/schema.ts | 12 +- .../src/config/server-config-service.test.ts | 33 +- .../src/config/server-config-service.ts | 57 +- .../session-execution-manager.test.ts | 59 +- .../execution/session-execution-manager.ts | 10 + packages/agent-core/src/index.ts | 1 + .../agent-core/src/llm/run-object.test.ts | 8 +- packages/agent-core/src/llm/run-object.ts | 1 - packages/agent-core/src/main.test.ts | 4 +- packages/agent-core/src/memory/constants.ts | 27 +- packages/agent-core/src/memory/errors.ts | 57 + .../src/memory/file-manager.test.ts | 38 +- .../agent-core/src/memory/file-manager.ts | 75 +- .../src/memory/idle-coordinator.test.ts | 3719 +++++++++++++++++ .../agent-core/src/memory/idle-coordinator.ts | 1678 ++++++++ packages/agent-core/src/memory/index.ts | 48 +- .../src/memory/learning-input.test.ts | 416 ++ .../agent-core/src/memory/learning-input.ts | 310 ++ .../agent-core/src/memory/learning-schemas.ts | 161 + .../agent-core/src/memory/learning-state.ts | 107 + packages/agent-core/src/memory/manifest.ts | 44 - .../src/memory/policy-runtime.test.ts | 173 + .../agent-core/src/memory/policy-runtime.ts | 184 + .../src/memory/reconciliation.test.ts | 248 ++ .../agent-core/src/memory/reconciliation.ts | 196 + packages/agent-core/src/memory/schemas.ts | 44 +- .../agent-core/src/memory/service.test.ts | 561 +++ packages/agent-core/src/memory/service.ts | 714 ++++ packages/agent-core/src/memory/types.test.ts | 116 +- packages/agent-core/src/memory/types.ts | 4 +- .../src/projects/context-resolver.test.ts | 4 +- .../src/projects/context-resolver.ts | 14 +- packages/agent-core/src/projects/types.ts | 4 +- packages/agent-core/src/prompt/compiler.ts | 9 +- packages/agent-core/src/runtime.ts | 30 +- .../src/session-input/service.test.ts | 2 + packages/agent-core/src/store/helpers.test.ts | 8 + packages/agent-core/src/store/helpers.ts | 79 +- .../src/store/logical-execution.test.ts | 4 + .../src/store/memory-learning.test.ts | 172 + .../src/store/message-phase-hard-cut.test.ts | 3 + packages/agent-core/src/store/reduce.ts | 43 + .../src/store/session-store-manager.test.ts | 11 + .../src/store/session-store-manager.ts | 3 +- packages/agent-core/src/store/store.test.ts | 2 + packages/agent-core/src/store/test-helpers.ts | 2 - packages/agent-core/src/store/types.ts | 6 +- .../src/testing/test-execution-fixtures.ts | 9 + .../src/tools/builtins/memory-read.test.ts | 90 +- .../src/tools/builtins/memory-read.ts | 80 +- .../src/tools/builtins/memory-write.test.ts | 241 +- .../src/tools/builtins/memory-write.ts | 109 +- .../builtins/model-visible-contract.test.ts | 2 +- .../src/tools/test-project-context.ts | 5 +- packages/protocol/src/execution.test.ts | 7 + packages/protocol/src/guards.test.ts | 13 +- packages/protocol/src/guards.ts | 19 +- packages/protocol/src/index.ts | 1 + packages/protocol/src/memory.ts | 93 + .../src/message-phase-hard-cut.test.ts | 5 + packages/protocol/src/reduce.test.ts | 7 +- packages/protocol/src/reduce.ts | 1 + packages/protocol/src/types.ts | 11 +- 130 files changed, 11946 insertions(+), 3482 deletions(-) create mode 100644 apps/server/src/routes/memory.test.ts create mode 100644 apps/server/src/routes/memory.ts create mode 100644 apps/web/src/api/memory.test.ts create mode 100644 apps/web/src/api/memory.ts create mode 100644 apps/web/src/components/features/SettingsMemoryPanel.test.tsx create mode 100644 apps/web/src/components/features/SettingsMemoryPanel.tsx create mode 100644 docs/goals/memory-system-hard-cut-plan-goal.md create mode 100644 docs/goals/memory-system-hard-cut-progress.md delete mode 100644 packages/agent-core/src/agents/query/hooks/memory-consolidation.test.ts delete mode 100644 packages/agent-core/src/agents/query/hooks/memory-consolidation.ts delete mode 100644 packages/agent-core/src/agents/query/hooks/memory-extraction.test.ts delete mode 100644 packages/agent-core/src/agents/query/hooks/memory-extraction.ts delete mode 100644 packages/agent-core/src/background/tasks/memory-consolidation.test.ts delete mode 100644 packages/agent-core/src/background/tasks/memory-consolidation.ts delete mode 100644 packages/agent-core/src/background/tasks/memory-extraction.test.ts delete mode 100644 packages/agent-core/src/background/tasks/memory-extraction.ts create mode 100644 packages/agent-core/src/memory/errors.ts create mode 100644 packages/agent-core/src/memory/idle-coordinator.test.ts create mode 100644 packages/agent-core/src/memory/idle-coordinator.ts create mode 100644 packages/agent-core/src/memory/learning-input.test.ts create mode 100644 packages/agent-core/src/memory/learning-input.ts create mode 100644 packages/agent-core/src/memory/learning-schemas.ts create mode 100644 packages/agent-core/src/memory/learning-state.ts delete mode 100644 packages/agent-core/src/memory/manifest.ts create mode 100644 packages/agent-core/src/memory/policy-runtime.test.ts create mode 100644 packages/agent-core/src/memory/policy-runtime.ts create mode 100644 packages/agent-core/src/memory/reconciliation.test.ts create mode 100644 packages/agent-core/src/memory/reconciliation.ts create mode 100644 packages/agent-core/src/memory/service.test.ts create mode 100644 packages/agent-core/src/memory/service.ts create mode 100644 packages/agent-core/src/store/memory-learning.test.ts create mode 100644 packages/protocol/src/memory.ts diff --git a/AGENTS.md b/AGENTS.md index 9940bf17..208ca2bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,7 @@ packages/agent-core/src/ ├── agents/tool-filter.test.ts # Architecture coverage for definition-based capability filtering ├── agents/query/ # runLlmStream + tool execution cycle (max 50 steps), doom detection ├── agents/query/loop-hooks.ts # 4 hook points: beforeModelBuild, beforeModelCall, afterStepEnd, afterLoopEnd -├── agents/query/hooks/ # auto-compact, auto-inject-reminder, title-generation, todo-continuation, memory-extraction, memory-consolidation +├── agents/query/hooks/ # auto-compact, auto-inject-reminder, title-generation, todo-continuation ├── execution/session-execution-manager.ts # Sole logical Execution lifecycle/admission, run resources, abort, recovery, and terminal owner ├── process/ # ProcessRunner lifecycle, bounded streaming, timeout/abort, and structured results ├── tools/define-tool.ts # defineTool() → ToolDescriptor (strict RawToolResult + explicit outputPolicy) @@ -188,11 +188,11 @@ packages/agent-core/src/ ├── tools/riipgrep/ # Ripgrep wrapper for search tools ├── core/ # register-tools.ts: wires tools and finalized-result audit/logger hooks ├── store/ # Zustand vanilla store: createSessionStore, StreamEvent reducer, ModelMessage projection, persist/load -├── background/ # BackgroundTaskManager (fire-and-forget, dedup) + tasks: title-generation, memory-extraction, memory-consolidation +├── background/ # BackgroundTaskManager (fire-and-forget, dedup) + title-generation task ├── commands/ # CommandRegistry + /compact command ├── compression/ # DCP-like dynamic range compression: model tool action, refs, block state, soft/strong nudges below hard threshold ├── compact/ # Mandatory hard compact safety path at >=85% context pressure plus /compact command -├── memory/ # MemoryFileManager (atomic writes, frontmatter, index), schemas, types, constants +├── memory/ # MemoryService, Markdown adapter, idle learning coordinator, policy, schemas, limits ├── session-goal/ # Session.goal schema, ownership service, status, budget, and usage ├── hitl/ # Durable project-scoped approval/question queue and redacted display payloads ├── automations/ # Canonical Automation schemas, schedule, durable Invocation, Session dispatch @@ -420,9 +420,13 @@ All six implement `Agent`: `store: StoreApi`, `run(options) beforeModelBuild (auto-compact) → toModelMessages → beforeModelCall (auto-inject-reminder) → runLlmStream → consumeFullStream → afterStepEnd (todo-continuation) → executeToolCalls (doom detection → partition → guards → execute) -→ afterLoopEnd (todo-continuation, memory-extraction, memory-consolidation) +→ afterLoopEnd (todo-continuation) ``` +Successful root Lead/Discussion terminals update the durable Memory cursor; +`MemoryIdleCoordinator` performs automatic learning outside the Query Loop after +10 minutes of inactivity. + ## Tool System **35+ builtin tools** (base tools via `createBuiltinToolDescriptors()`, memory, Goal, Automation, Project Todo, and GitHub connector tools — all registered in `core/register-tools.ts`): @@ -457,7 +461,7 @@ ArchCode has two intentionally separate context-reduction paths. Dynamic DCP-lik ## Memory System -Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, not under project runtime). Structure: `index.md` (topic index), `preferences.md`, `knowledge/{topic}.md` (frontmatter + markdown). Types: `"user" | "feedback" | "project" | "reference"`. `MemoryFileManager`: atomic writes, path validation, frontmatter parse/format, index rebuild/search. Extraction (background task via `runLlmObject`) → writes topics. Consolidation (background task) → reorganizes index. Injection: ConfiguredAgent resolves one immutable Execution snapshot; PromptContractCompiler labels it non-authoritative and emits its source/status in the durable Prompt trace. `memory_write` rejects secrets. +Project: `.archcode/runtime/memory/`, User: `~/.archcode/memory/` (user-global, not under project runtime). Structure: `index.md` (generated topic index), `preferences.md`, `knowledge/{topic}.md` (frontmatter + Markdown). Types: `"user" | "feedback" | "project" | "reference"`. `MemoryService` is the sole mutation boundary over `MemoryFileManager`: it owns CAS revisions, secret rejection, 8 KiB preferences, 16 KiB complete topic documents, the 200-topic cap, legacy shrink-only edits, index rebuilds, and deterministic receipt replay. Existing `memory_write` remains the immediate explicit-write path. Automatic learning is owned by the runtime-scoped `MemoryIdleCoordinator`: successful root Lead/Discussion conversations wait for 10 minutes of inactivity, then use at most one `fast` extraction call and one full-file reconciliation call; durable cursors, receipts, policy epochs, and warnings make restart and opt-out behavior explicit. Injection: ConfiguredAgent resolves one immutable Execution snapshot containing complete in-capacity preferences and project index; PromptContractCompiler labels it non-authoritative and emits its source/status in the durable Prompt trace. ## Session Goal System diff --git a/README.md b/README.md index e1c2b227..2ccd3fb0 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,15 @@ Use an Automation when work should start once or on a recurring schedule. Goals and Automations are optional; both remain visible through Sessions inside the same workbench. +## Durable, inspectable memory + +ArchCode keeps personal preferences and project knowledge as ordinary Markdown. +Explicit requests to remember something are saved immediately through the +Memory tool. Other durable context is considered only after a successful root +conversation has been idle for 10 minutes, then reconciled against the complete +Memory files it actually affects. Settings → Memory lets you inspect, edit, +delete, disable recall, or opt out of automatic learning without deleting data. + ## Run it your way | Where ArchCode runs | Good for | How you open it | diff --git a/apps/server/src/app.test.ts b/apps/server/src/app.test.ts index 18944de0..a91713d2 100644 --- a/apps/server/src/app.test.ts +++ b/apps/server/src/app.test.ts @@ -29,6 +29,10 @@ describe("createRuntimeApp", () => { subscribeSessionEvents: mock((listener: (event: GlobalSSEEvent) => void) => { listener({ type: "event", slug: "proj", sessionId: "session-1", eventId: 1, createdAt: 1, agentName: "lead", payload: { type: "execution-start", + memoryPolicy: { + policy: { useMemory: true, autoLearning: true }, + epoch: { bootId: "test-memory-boot", generation: 0 }, + }, executionId: "run-1", binding: { selection: { model: "local:test" }, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 83d04482..c5863264 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -18,6 +18,7 @@ import { createAutomationsRoutes } from "./routes/automations"; import { createAttachmentsRoutes } from "./routes/attachments"; import { createMessagesRoutes } from "./routes/messages"; import { createMcpRoutes } from "./routes/mcp"; +import { createMemoryRoutes } from "./routes/memory"; import { createProjectsRoutes } from "./routes/projects"; import { createSessionsRoutes } from "./routes/sessions"; import { createTodosRoutes } from "./routes/todos"; @@ -82,6 +83,7 @@ export function createRuntimeApp( const files = createFilesRoutes(serverRuntime); const directories = createDirectoriesRoutes(); const mcp = createMcpRoutes(serverRuntime); + const memory = createMemoryRoutes(serverRuntime); app.route("/api", globalWork); app.route("/api/projects", projects); @@ -95,6 +97,7 @@ export function createRuntimeApp( app.route("/api/projects/:slug/sessions/:sessionId/tool-outputs", toolOutputs); app.route("/api/events", globalEvents); app.route("/api/projects", files); + app.route("/api/projects", memory); app.route("/api/mcp", mcp); app.route("/api/sessions", new Hono()); app.route("/api/agents", agents); diff --git a/apps/server/src/errors.ts b/apps/server/src/errors.ts index 35bf4bcd..46cde2b9 100644 --- a/apps/server/src/errors.ts +++ b/apps/server/src/errors.ts @@ -28,6 +28,12 @@ export type ServerErrorCode = | "CONFIG_REVISION_CONFLICT" | "CONFIG_VALIDATION_ERROR" | "CONFIG_RECOVERY_CONFLICT" + | "MEMORY_NOT_FOUND" + | "MEMORY_REVISION_CONFLICT" + | "MEMORY_CAPACITY_EXCEEDED" + | "MEMORY_INVALID_INPUT" + | "MEMORY_SECRET_DETECTED" + | "MEMORY_OPERATION_FAILED" | "TOOL_OUTPUT_FORBIDDEN" | "TOOL_OUTPUT_NOT_FOUND" | "TOOL_OUTPUT_EXPIRED" diff --git a/apps/server/src/routes/config-recovery.ts b/apps/server/src/routes/config-recovery.ts index f91ac41f..60d2c7f3 100644 --- a/apps/server/src/routes/config-recovery.ts +++ b/apps/server/src/routes/config-recovery.ts @@ -263,7 +263,7 @@ function safeIssuePath(path: string, configPath: string): string { } if (segments[0] === "memory") { const field = segments[1]; - return new Set(["enabled", "minMessages", "minContentLength", "cooldownMs"]).has(field ?? "") + return new Set(["useMemory", "autoLearning"]).has(field ?? "") ? `memory.${field}` : "memory."; } diff --git a/apps/server/src/routes/memory.test.ts b/apps/server/src/routes/memory.test.ts new file mode 100644 index 00000000..1cc0c8be --- /dev/null +++ b/apps/server/src/routes/memory.test.ts @@ -0,0 +1,356 @@ +import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { Hono } from "hono"; +import { + MemoryCapacityError, + MemoryRevisionConflictError, + MemorySecretError, + ProjectRegistry, + createInMemoryLogger, + silentLogger, + type AgentRuntime, +} from "@archcode/agent-core"; +import type { + MemoryPreferencesItem, + MemorySnapshot, + MemoryTopicItem, +} from "@archcode/protocol"; +import { errorHandler } from "../error-handler"; +import { createMemoryRoutes } from "./memory"; + +const tempRoot = resolve(tmpdir(), "archcode-memory-routes-test"); +const capacity = { + bytes: 12, + maxBytes: 8192, + state: "within-limit" as const, + mutationPolicy: "normal" as const, +}; +const preferences: MemoryPreferencesItem = { + content: "Concise answers", + revision: "preferences-rev", + capacity, + availableForPrompt: true, +}; +const topic: MemoryTopicItem = { + name: "build_tools", + title: "Build tools", + description: "Project build commands", + type: "project", + content: "Use Bun", + revision: "topic-rev", + capacity: { ...capacity, maxBytes: 16384 }, +}; +const topicSummary = { + name: topic.name, + title: topic.title, + description: topic.description, + type: topic.type, + revision: topic.revision, + capacity: topic.capacity, +}; +const snapshot: MemorySnapshot = { + preferences, + topics: [topicSummary], + index: { + revision: "index-rev", + bytes: 40, + topicCount: { + count: 1, + max: 200, + state: "within-limit", + canCreate: true, + }, + availableForPrompt: true, + }, + warnings: [], +}; + +function createService() { + return { + snapshot: mock(async () => snapshot), + readPreferences: mock(async () => preferences as MemoryPreferencesItem | null), + putPreferences: mock(async () => preferences), + deletePreferences: mock(async () => undefined), + readTopic: mock(async () => topic as MemoryTopicItem | null), + putTopic: mock(async () => topic), + deleteTopic: mock(async () => undefined), + }; +} + +async function createFixture(name: string, options: { captureLogs?: boolean } = {}) { + const homeDir = resolve(tempRoot, "homes", name); + const workspaceRoot = resolve(tempRoot, "workspaces", name); + await mkdir(homeDir, { recursive: true }); + await mkdir(workspaceRoot, { recursive: true }); + const projectRegistry = new ProjectRegistry({ homeDir, logger: silentLogger }); + const project = await projectRegistry.add({ workspaceRoot, name }); + const service = createService(); + const resolveContext = mock(async () => ({ memory: service })); + const runtime = { + projectRegistry, + contextResolver: { resolve: resolveContext }, + getMemorySnapshot: mock(async () => service.snapshot()), + } as unknown as AgentRuntime; + const app = new Hono(); + const memoryLogs = options.captureLogs ? createInMemoryLogger() : undefined; + app.onError((error, context) => errorHandler(error, context, memoryLogs?.logger ?? silentLogger)); + app.route("/api/projects", createMemoryRoutes(runtime)); + return { app, project, service, resolveContext, logEntries: memoryLogs?.entries ?? [] }; +} + +describe("Memory routes", () => { + beforeEach(async () => { + await rm(tempRoot, { recursive: true, force: true }); + await mkdir(tempRoot, { recursive: true }); + }); + + afterAll(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + test("returns the project-scoped Memory snapshot", async () => { + const { app, project, service } = await createFixture("snapshot"); + + const response = await app.request(`/api/projects/${project.slug}/memory`); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual(snapshot); + expect(service.snapshot).toHaveBeenCalledTimes(1); + }); + + test("passes preferences and topic edits through strict CAS inputs", async () => { + const { app, project, service } = await createFixture("put"); + + const preferencesResponse = await app.request( + `/api/projects/${project.slug}/memory/preferences`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "Concise answers", expectedRevision: "old-pref" }), + }, + ); + const topicResponse = await app.request( + `/api/projects/${project.slug}/memory/topics/build_tools`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Build tools", + description: "Project build commands", + type: "project", + content: "Use Bun", + expectedRevision: null, + }), + }, + ); + + expect(preferencesResponse.status).toBe(200); + expect(topicResponse.status).toBe(200); + expect(service.putPreferences).toHaveBeenCalledWith({ + content: "Concise answers", + expectedRevision: "old-pref", + }); + expect(service.putTopic).toHaveBeenCalledWith({ + name: "build_tools", + title: "Build tools", + description: "Project build commands", + type: "project", + content: "Use Bun", + expectedRevision: null, + }); + }); + + test("rejects unknown edit fields before calling the service", async () => { + const { app, project, service } = await createFixture("strict-body"); + + const response = await app.request( + `/api/projects/${project.slug}/memory/preferences`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "text", expectedRevision: null, overwrite: true }), + }, + ); + + expect(response.status).toBe(400); + expect(service.putPreferences).not.toHaveBeenCalled(); + }); + + test("returns 409 without exposing Memory content or local paths", async () => { + const { app, project, service } = await createFixture("conflict"); + service.putPreferences.mockImplementationOnce(async () => { + throw new MemoryRevisionConflictError( + "/Users/private/preferences.md", + "stale-revision", + "current-revision", + ); + }); + + const response = await app.request( + `/api/projects/${project.slug}/memory/preferences`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content: "private body", expectedRevision: "stale-revision" }), + }, + ); + const text = await response.text(); + + expect(response.status).toBe(409); + expect(JSON.parse(text)).toEqual({ + error: { + code: "MEMORY_REVISION_CONFLICT", + message: "Memory changed. Reload it before saving.", + details: { + expectedRevision: "stale-revision", + actualRevision: "current-revision", + }, + }, + }); + expect(text).not.toContain("/Users/private"); + expect(text).not.toContain("private body"); + }); + + test("deletes with CAS and returns no body", async () => { + const { app, project, service } = await createFixture("delete"); + + const response = await app.request( + `/api/projects/${project.slug}/memory/topics/build_tools`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ expectedRevision: "topic-rev" }), + }, + ); + + expect(response.status).toBe(204); + expect(await response.text()).toBe(""); + expect(service.deleteTopic).toHaveBeenCalledWith({ + name: "build_tools", + expectedRevision: "topic-rev", + }); + }); + + test("maps capacity and secret failures without echoing submitted content", async () => { + const { app, project, service } = await createFixture("safe-domain-errors"); + service.putPreferences + .mockImplementationOnce(async () => { + throw new MemoryCapacityError("/private/preferences.md", 8193, 8192); + }) + .mockImplementationOnce(async () => { + throw new MemorySecretError(); + }); + const request = (content: string) => app.request( + `/api/projects/${project.slug}/memory/preferences`, + { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ content, expectedRevision: "preferences-rev" }), + }, + ); + + const capacityResponse = await request("capacity-private-body"); + const capacityText = await capacityResponse.text(); + expect(capacityResponse.status).toBe(422); + expect(JSON.parse(capacityText).error).toEqual({ + code: "MEMORY_CAPACITY_EXCEEDED", + message: "Memory capacity would be exceeded.", + details: { bytes: 8193, maxBytes: 8192 }, + }); + expect(capacityText).not.toContain("/private"); + expect(capacityText).not.toContain("capacity-private-body"); + + const secretResponse = await request("secret-private-body"); + const secretText = await secretResponse.text(); + expect(secretResponse.status).toBe(422); + expect(JSON.parse(secretText).error).toEqual({ + code: "MEMORY_SECRET_DETECTED", + message: "Memory content contains a potential secret.", + }); + expect(secretText).not.toContain("secret-private-body"); + }); + + test("maps unknown Memory failures to a safe response and safe structured log", async () => { + const { app, project, service, logEntries } = await createFixture( + "safe-unknown-error", + { captureLogs: true }, + ); + const sensitive = "secret-memory-body"; + service.readTopic.mockImplementationOnce(async () => { + throw new Error(`/Users/private/.archcode/runtime/memory/topic.md: ${sensitive}`); + }); + + const response = await app.request( + `/api/projects/${project.slug}/memory/topics/build_tools`, + ); + const responseText = await response.text(); + const serializedLogs = JSON.stringify(logEntries); + + expect(response.status).toBe(500); + expect(JSON.parse(responseText)).toEqual({ + error: { + code: "MEMORY_OPERATION_FAILED", + message: "Memory operation failed.", + }, + }); + expect(responseText).not.toContain("/Users/private"); + expect(responseText).not.toContain(sensitive); + expect(serializedLogs).not.toContain("/Users/private"); + expect(serializedLogs).not.toContain(sensitive); + expect(logEntries).toEqual([expect.objectContaining({ + event: "http.request.failed", + context: { + method: "GET", + path: `/api/projects/${project.slug}/memory/topics/build_tools`, + status: 500, + }, + meta: { + errorName: "ServerError", + errorCode: "MEMORY_OPERATION_FAILED", + }, + })]); + }); + + test("maps Memory context resolution failures before they reach the general error logger", async () => { + const { app, project, resolveContext, logEntries } = await createFixture( + "safe-context-error", + { captureLogs: true }, + ); + const sensitive = "resolver-secret-body"; + resolveContext.mockImplementationOnce(async () => { + throw new Error(`/Users/private/.archcode/runtime/memory: ${sensitive}`); + }); + + const response = await app.request( + `/api/projects/${project.slug}/memory/preferences`, + ); + const responseText = await response.text(); + const serializedLogs = JSON.stringify(logEntries); + + expect(response.status).toBe(500); + expect(JSON.parse(responseText)).toEqual({ + error: { + code: "MEMORY_OPERATION_FAILED", + message: "Memory operation failed.", + }, + }); + expect(responseText).not.toContain("/Users/private"); + expect(responseText).not.toContain(sensitive); + expect(serializedLogs).not.toContain("/Users/private"); + expect(serializedLogs).not.toContain(sensitive); + expect(logEntries).toEqual([expect.objectContaining({ + event: "http.request.failed", + context: { + method: "GET", + path: `/api/projects/${project.slug}/memory/preferences`, + status: 500, + }, + meta: { + errorName: "ServerError", + errorCode: "MEMORY_OPERATION_FAILED", + }, + })]); + }); +}); diff --git a/apps/server/src/routes/memory.ts b/apps/server/src/routes/memory.ts new file mode 100644 index 00000000..d1f0808d --- /dev/null +++ b/apps/server/src/routes/memory.ts @@ -0,0 +1,163 @@ +import { Hono } from "hono"; +import { + MemoryCapacityError, + MemoryRevisionConflictError, + MemorySecretError, + MemoryValidationError, + type AgentRuntime, +} from "@archcode/agent-core"; +import { z } from "zod/v4"; +import { readBoundedJsonBody } from "../request-body"; +import { resolveProject } from "../resolve"; +import { ServerError } from "../errors"; +import { zValidator } from "../validation"; + +const MemoryParamsSchema = z.strictObject({ slug: z.string().min(1) }); +const MemoryTopicParamsSchema = z.strictObject({ + slug: z.string().min(1), + name: z.string().min(1), +}); +const ExpectedRevisionSchema = z.string().min(1).nullable(); +const PutPreferencesSchema = z.strictObject({ + content: z.string(), + expectedRevision: ExpectedRevisionSchema, +}); +const PutTopicSchema = z.strictObject({ + title: z.string().trim().optional(), + description: z.string().trim(), + type: z.enum(["user", "feedback", "project", "reference"]), + content: z.string(), + expectedRevision: ExpectedRevisionSchema, +}); +const DeleteMemorySchema = z.strictObject({ + expectedRevision: ExpectedRevisionSchema, +}); +const MAX_MEMORY_HTTP_BODY_BYTES = 64 * 1024; + +export function createMemoryRoutes(runtime: AgentRuntime): Hono { + const app = new Hono(); + + app.get("/:slug/memory", zValidator("param", MemoryParamsSchema), async (c) => { + const project = await resolveProject(runtime, c.req.valid("param").slug); + return c.json(await runMemoryOperation(() => runtime.getMemorySnapshot(project.workspaceRoot))); + }); + + app.get("/:slug/memory/preferences", zValidator("param", MemoryParamsSchema), async (c) => { + const service = await resolveMemoryService(runtime, c.req.valid("param").slug); + return c.json(await runMemoryOperation(() => service.readPreferences())); + }); + + app.put("/:slug/memory/preferences", zValidator("param", MemoryParamsSchema), async (c) => { + const service = await resolveMemoryService(runtime, c.req.valid("param").slug); + const input = await parseBody(c.req.raw, PutPreferencesSchema); + return c.json(await runMemoryOperation(() => service.putPreferences(input))); + }); + + app.delete("/:slug/memory/preferences", zValidator("param", MemoryParamsSchema), async (c) => { + const service = await resolveMemoryService(runtime, c.req.valid("param").slug); + const input = await parseBody(c.req.raw, DeleteMemorySchema); + await runMemoryOperation(() => service.deletePreferences(input)); + return c.body(null, 204); + }); + + app.get("/:slug/memory/topics/:name", zValidator("param", MemoryTopicParamsSchema), async (c) => { + const { slug, name } = c.req.valid("param"); + const service = await resolveMemoryService(runtime, slug); + const item = await runMemoryOperation(() => service.readTopic(name)); + if (item === null) throw memoryNotFound("topic"); + return c.json(item); + }); + + app.put("/:slug/memory/topics/:name", zValidator("param", MemoryTopicParamsSchema), async (c) => { + const { slug, name } = c.req.valid("param"); + const service = await resolveMemoryService(runtime, slug); + const input = await parseBody(c.req.raw, PutTopicSchema); + return c.json(await runMemoryOperation(() => service.putTopic({ + name, + ...input, + title: input.title || name, + }))); + }); + + app.delete("/:slug/memory/topics/:name", zValidator("param", MemoryTopicParamsSchema), async (c) => { + const { slug, name } = c.req.valid("param"); + const service = await resolveMemoryService(runtime, slug); + const input = await parseBody(c.req.raw, DeleteMemorySchema); + await runMemoryOperation(() => service.deleteTopic({ name, ...input })); + return c.body(null, 204); + }); + + return app; +} + +async function resolveMemoryService(runtime: AgentRuntime, slug: string) { + const project = await resolveProject(runtime, slug); + return (await runMemoryOperation( + () => runtime.contextResolver.resolve(project.workspaceRoot), + )).memory; +} + +async function parseBody( + request: Request, + schema: Schema, +): Promise> { + const value = await readBoundedJsonBody(request, { + maxBytes: MAX_MEMORY_HTTP_BODY_BYTES, + label: "Memory request body", + }); + const result = schema.safeParse(value); + if (!result.success) { + throw new ServerError("BAD_REQUEST", "Invalid Memory request body", 400, { + issues: result.error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })), + }); + } + return result.data; +} + +async function runMemoryOperation(operation: () => Promise): Promise { + try { + return await operation(); + } catch (error) { + if (error instanceof MemoryRevisionConflictError) { + throw new ServerError( + "MEMORY_REVISION_CONFLICT", + "Memory changed. Reload it before saving.", + 409, + { + expectedRevision: error.expectedRevision, + actualRevision: error.actualRevision, + }, + ); + } + if (error instanceof MemoryCapacityError) { + throw new ServerError( + "MEMORY_CAPACITY_EXCEEDED", + "Memory capacity would be exceeded.", + 422, + { bytes: error.bytes, maxBytes: error.maxBytes }, + ); + } + if (error instanceof MemoryValidationError) { + throw new ServerError("MEMORY_INVALID_INPUT", error.message, 422); + } + if (error instanceof MemorySecretError) { + throw new ServerError( + "MEMORY_SECRET_DETECTED", + "Memory content contains a potential secret.", + 422, + ); + } + throw new ServerError( + "MEMORY_OPERATION_FAILED", + "Memory operation failed.", + 500, + ); + } +} + +function memoryNotFound(target: "topic"): ServerError { + return new ServerError("MEMORY_NOT_FOUND", `Memory ${target} was not found.`, 404); +} diff --git a/apps/web/src/api/config.test.ts b/apps/web/src/api/config.test.ts index 4184871c..a1e6652d 100644 --- a/apps/web/src/api/config.test.ts +++ b/apps/web/src/api/config.test.ts @@ -6,7 +6,7 @@ import { getModelRuntimeCatalog, getProviderAdapterCatalog, getServerConfig, sav const config: ServerConfig = { provider: {}, profiles: {} as ServerConfig["profiles"], - memory: { enabled: true, minMessages: 5, minContentLength: 1000, cooldownMs: 300000 }, + memory: { useMemory: true, autoLearning: true }, }; const adapterCatalog: ProviderAdapterCatalog = [{ diff --git a/apps/web/src/api/memory.test.ts b/apps/web/src/api/memory.test.ts new file mode 100644 index 00000000..8cc44701 --- /dev/null +++ b/apps/web/src/api/memory.test.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { + deleteMemoryTopic, + getMemorySnapshot, + putMemoryPreferences, + putMemoryTopic, +} from "./memory"; + +const snapshot = { + preferences: { content: "# Preferences\n", revision: "p1", capacity: { bytes: 14, maxBytes: 8192, state: "within-limit", mutationPolicy: "normal" }, availableForPrompt: true }, + topics: [], + index: { revision: "i1", bytes: 20, topicCount: { count: 0, max: 200, state: "within-limit", canCreate: true }, availableForPrompt: true }, + warnings: [], +} as const; + +describe("memory API", () => { + beforeEach(() => { + Object.defineProperty(globalThis, "document", { configurable: true, value: { cookie: "" } }); + }); + + test("keeps project Memory requests scoped and encodes topic names", async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + expect(String(input)).toBe("/api/projects/demo%2Fproject/memory"); + return Response.json(snapshot); + }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + + await expect(getMemorySnapshot("demo/project")).resolves.toEqual(snapshot); + }); + + test("sends revisions with preferences and topic writes", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ url: String(input), init }); + return Response.json({ ...snapshot.preferences, content: "updated", revision: "p2" }); + }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + + await putMemoryPreferences({ slug: "demo", content: "updated", expectedRevision: "p1" }); + await putMemoryTopic({ slug: "demo", name: "build tools", title: "Build Tools", description: "Commands", type: "project", content: "body", expectedRevision: null }); + + expect(JSON.parse(String(requests[0]?.init?.body))).toEqual({ content: "updated", expectedRevision: "p1" }); + expect(JSON.parse(String(requests[1]?.init?.body))).toEqual({ content: "body", expectedRevision: null, title: "Build Tools", description: "Commands", type: "project" }); + expect(requests[1]?.url).toBe("/api/projects/demo/memory/topics/build%20tools"); + }); + + test("preserves server revision conflicts", async () => { + Object.defineProperty(globalThis, "fetch", { + configurable: true, + value: mock(async () => Response.json({ error: { code: "MEMORY_REVISION_CONFLICT", message: "Memory changed" } }, { status: 409 })), + }); + + await expect(deleteMemoryTopic({ slug: "demo", name: "build-tools", expectedRevision: "stale" })).rejects.toMatchObject({ code: "MEMORY_REVISION_CONFLICT", status: 409 }); + }); +}); diff --git a/apps/web/src/api/memory.ts b/apps/web/src/api/memory.ts new file mode 100644 index 00000000..84fd3991 --- /dev/null +++ b/apps/web/src/api/memory.ts @@ -0,0 +1,96 @@ +import type { + MemoryCapacity, + MemoryIndexStatus, + MemoryPreferencesItem, + MemorySnapshot, + MemoryTopicItem, + MemoryTopicSummary, + MemoryWarningCode, +} from "@archcode/protocol"; +import { apiFetch } from "./client"; + +/** + * Memory is deliberately kept as a project-scoped resource in the Web API. + * These DTOs are local to the Web client so the settings surface does not + * couple itself to the server's file-manager/domain classes. + */ +export type { + MemoryCapacity, + MemoryIndexStatus, + MemoryPreferencesItem, + MemorySnapshot, + MemoryTopicItem, + MemoryTopicSummary, + MemoryWarningCode, +}; + +export interface PutPreferencesInput { + slug: string; + content: string; + expectedRevision: string | null; +} + +export interface DeletePreferencesInput { + slug: string; + expectedRevision: string | null; +} + +export interface PutTopicInput { + slug: string; + name: string; + content: string; + expectedRevision: string | null; + type: "user" | "feedback" | "project" | "reference"; + title: string; + description: string; +} + +export interface DeleteTopicInput { + slug: string; + name: string; + expectedRevision: string | null; +} + +function memoryPath(slug: string, suffix = ""): string { + return `/api/projects/${encodeURIComponent(slug)}/memory${suffix}`; +} + +export function getMemorySnapshot(slug: string): Promise { + return apiFetch(memoryPath(slug)); +} + +export function getMemoryPreferences(slug: string): Promise { + return apiFetch(memoryPath(slug, "/preferences")); +} + +export function putMemoryPreferences({ slug, content, expectedRevision }: PutPreferencesInput): Promise { + return apiFetch(memoryPath(slug, "/preferences"), { + method: "PUT", + body: { content, expectedRevision }, + }); +} + +export function deleteMemoryPreferences({ slug, expectedRevision }: DeletePreferencesInput): Promise { + return apiFetch(memoryPath(slug, "/preferences"), { + method: "DELETE", + body: { expectedRevision }, + }); +} + +export function getMemoryTopic(slug: string, name: string): Promise { + return apiFetch(memoryPath(slug, `/topics/${encodeURIComponent(name)}`)); +} + +export function putMemoryTopic({ slug, name, content, expectedRevision, type, title, description }: PutTopicInput): Promise { + return apiFetch(memoryPath(slug, `/topics/${encodeURIComponent(name)}`), { + method: "PUT", + body: { content, expectedRevision, type, title, description }, + }); +} + +export function deleteMemoryTopic({ slug, name, expectedRevision }: DeleteTopicInput): Promise { + return apiFetch(memoryPath(slug, `/topics/${encodeURIComponent(name)}`), { + method: "DELETE", + body: { expectedRevision }, + }); +} diff --git a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx index 14b09533..8bbc89f8 100644 --- a/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx +++ b/apps/web/src/components/composite/ExecutionWorkstream.interaction.tsx @@ -33,6 +33,10 @@ const binding: ExecutionModelBindingSummary = { resolution: "profile_default", modelRuntimeRevision: "m1", }; +const memoryPolicy = { + policy: { useMemory: true, autoLearning: true }, + epoch: { bootId: "test-memory-boot", generation: 0 }, +}; const usage = { inputTokens: 0, outputTokens: 0, @@ -44,6 +48,7 @@ const usage = { function completed(id = "execution"): SessionExecutionRecord { return { id, + memoryPolicy, startedAt: 0, origin: "user_message", maxSteps: 10, @@ -68,6 +73,7 @@ function completed(id = "execution"): SessionExecutionRecord { function running(id = "execution"): SessionExecutionRecord { return { id, + memoryPolicy, startedAt: 0, origin: "user_message", maxSteps: 10, diff --git a/apps/web/src/components/features/ChatHeader.test.tsx b/apps/web/src/components/features/ChatHeader.test.tsx index c702a057..5a72b51d 100644 --- a/apps/web/src/components/features/ChatHeader.test.tsx +++ b/apps/web/src/components/features/ChatHeader.test.tsx @@ -26,9 +26,14 @@ const binding: ExecutionModelBindingSummary = { resolution: "profile_default", modelRuntimeRevision: "r1", }; +const memoryPolicy = { + policy: { useMemory: true, autoLearning: true }, + epoch: { bootId: "test-memory-boot", generation: 0 }, +}; function suspended(): SessionExecutionRecord { return { id: "execution", + memoryPolicy, startedAt: 0, origin: "user_message", maxSteps: 10, diff --git a/apps/web/src/components/features/SettingsDialog.interaction.tsx b/apps/web/src/components/features/SettingsDialog.interaction.tsx index 404245b6..bbf1930b 100644 --- a/apps/web/src/components/features/SettingsDialog.interaction.tsx +++ b/apps/web/src/components/features/SettingsDialog.interaction.tsx @@ -145,7 +145,7 @@ describe("SettingsDialog interactions", () => { ["Models", "Providers and their model profiles"], ["Profiles", "Principal, deep, and fast model bindings"], ["MCP", "MCP servers"], - ["Memory", "Configure extraction thresholds"], + ["Memory", "Control prompt recall and background learning"], ["GitHub", "Optional GitHub integration settings"], ]; @@ -316,13 +316,13 @@ describe("SettingsDialog interactions", () => { test("reports live-applied Models separately from named restart sections", async () => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async () => Response.json({ - ...successfulSaveResponse(["mcp", "memory"]), + ...successfulSaveResponse(["mcp"]), })) }); act(() => root.render( {}} />)); click("Add provider"); await act(async () => { click("Save changes"); await Promise.resolve(); }); expect(container.textContent).toContain("Model and Profile changes applied live"); - expect(container.textContent).toContain("Restart required for: MCP, Memory"); + expect(container.textContent).toContain("Restart required for: MCP"); }); test("clears a prior live-applied notice before a failed follow-up save", async () => { @@ -365,17 +365,16 @@ describe("SettingsDialog interactions", () => { expect(container.textContent).not.toContain("Configuration saved. Retry Runtime to use the saved configuration."); }); - test("names restart-only sections without claiming a model live-apply", async () => { + test("saves Memory switches without exposing obsolete extraction thresholds", async () => { Object.defineProperty(globalThis, "fetch", { configurable: true, value: mock(async () => Response.json( - successfulSaveResponse(["memory"]), + successfulSaveResponse([]), )) }); act(() => root.render( {}} />)); click("Memory"); - const enabled = container.querySelector('input[aria-label="Memory extraction"]') as HTMLInputElement; + const enabled = container.querySelector('input[aria-label="Use Memory"]') as HTMLInputElement; act(() => enabled.click()); await act(async () => { click("Save changes"); await Promise.resolve(); }); - expect(container.textContent).toContain("Restart required for: Memory"); - expect(container.textContent).not.toContain("applied live"); + expect(container.textContent).not.toContain("Restart required for: Memory"); }); test("submits explicit delete mutations for configured API and header secrets", async () => { @@ -831,7 +830,7 @@ describe("SettingsDialog interactions", () => { ["Profiles", "Principal, deep, and fast model bindings"], ["Security", "Manage the one password"], ["MCP", "MCP servers"], - ["Memory", "Configure extraction thresholds"], + ["Memory", "Control prompt recall and background learning"], ["GitHub", "Optional GitHub integration settings"], ]; for (const [label, expected] of destinations) { diff --git a/apps/web/src/components/features/SettingsDialog.test.tsx b/apps/web/src/components/features/SettingsDialog.test.tsx index 5354df75..b0363366 100644 --- a/apps/web/src/components/features/SettingsDialog.test.tsx +++ b/apps/web/src/components/features/SettingsDialog.test.tsx @@ -64,7 +64,7 @@ const config: ServerConfig = { deep: { model: "local:demo-model" }, fast: { model: "local:demo-model" }, }, - memory: { enabled: true, minMessages: 5, minContentLength: 1000, cooldownMs: 300000 }, + memory: { useMemory: true, autoLearning: true }, }; const adapterCatalog: ProviderAdapterCatalog = [{ diff --git a/apps/web/src/components/features/SettingsDialog.tsx b/apps/web/src/components/features/SettingsDialog.tsx index e20bc098..c2e45445 100644 --- a/apps/web/src/components/features/SettingsDialog.tsx +++ b/apps/web/src/components/features/SettingsDialog.tsx @@ -6,7 +6,8 @@ import { getProviderAdapterCatalog, getServerConfig, saveServerConfig, toConfigD import { useMcpStatusStore } from "../../store/mcp-status-store"; import { DialogContent, DialogDescription, DialogRoot, DialogTitle } from "../ui/Dialog"; import { cloneConfig, hasConfigChanges, missingProfileVariants, toFieldErrors, type SettingsSection } from "./settings-helpers"; -import { SettingsProfilesPanel, SettingsGithubPanel, SettingsMcpPanel, SettingsMemoryPanel, SettingsModelsPanel, SettingsNavigation } from "./settings-panels"; +import { SettingsProfilesPanel, SettingsGithubPanel, SettingsMcpPanel, SettingsModelsPanel, SettingsNavigation } from "./settings-panels"; +import { SettingsMemoryPanel } from "./SettingsMemoryPanel"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import { SettingsRuntimeDataPanel } from "./SettingsRuntimeDataPanel"; import { SettingsUpdatesPanel } from "./SettingsUpdatesPanel"; @@ -17,7 +18,6 @@ type RestartRequiredSection = ServerConfigSnapshotView["restartRequiredSections" const restartSectionLabels: Record = { mcp: "MCP", - memory: "Memory", "integrations.github": "GitHub", }; @@ -35,7 +35,7 @@ export function SettingsCloseButton({ onClose }: { onClose: () => void }) { return ; } -export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runtime = { state: "ready" }, onRefreshRuntime = async () => {}, section: requestedSection = "models", onSectionChange, reloading = false, reloadError }: { snapshot: ServerConfigSnapshot; adapterCatalog: ProviderAdapterCatalog; servers: Record; onReload: () => Promise; runtime?: RuntimeStatus; onRefreshRuntime?: () => Promise; section?: SettingsSection; onSectionChange?: (section: SettingsSection) => void; reloading?: boolean; reloadError?: string }) { +export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runtime = { state: "ready" }, onRefreshRuntime = async () => {}, section: requestedSection = "models", onSectionChange, reloading = false, reloadError, projectSlug }: { snapshot: ServerConfigSnapshot; adapterCatalog: ProviderAdapterCatalog; servers: Record; onReload: () => Promise; runtime?: RuntimeStatus; onRefreshRuntime?: () => Promise; section?: SettingsSection; onSectionChange?: (section: SettingsSection) => void; reloading?: boolean; reloadError?: string; projectSlug?: string }) { const [section, setSection] = useState(requestedSection); const [draft, setDraft] = useState(() => cloneConfig(snapshot.config)); const [errors, setErrors] = useState>({}); @@ -128,7 +128,7 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt {section === "security" && } - +
{saveError || reloadError ?
{saveError ?? reloadError}
: {hasJsonErrors ? "Fix invalid JSON before saving" : dirty ? "Unsaved changes" : "All changes saved"}}
} @@ -136,8 +136,8 @@ export function SettingsBody({ snapshot, adapterCatalog, servers, onReload, runt ; } -export function SettingsDialog({ open, section = "models", onClose }: { open: boolean; section?: SettingsSection; onClose: () => void }) { - return { if (!next) onClose(); }}>SettingsConfigure ArchCode server settings, Runtime data, and application updates. {}} />; +export function SettingsDialog({ open, section = "models", onClose, projectSlug }: { open: boolean; section?: SettingsSection; onClose: () => void; projectSlug?: string }) { + return { if (!next) onClose(); }}>SettingsConfigure ArchCode server settings, Runtime data, and application updates. {}} projectSlug={projectSlug} />; } export function RuntimeRecoverySettings({ runtime, onRefreshRuntime }: { runtime: RuntimeStatus; onRefreshRuntime: () => Promise }) { @@ -148,7 +148,7 @@ export function RuntimeRecoverySettings({ runtime, onRefreshRuntime }: { runtime ; } -function SettingsWorkspace({ active, section, runtime, onRefreshRuntime }: { active: boolean; section: SettingsSection; runtime: RuntimeStatus; onRefreshRuntime: () => Promise }) { +function SettingsWorkspace({ active, section, runtime, onRefreshRuntime, projectSlug }: { active: boolean; section: SettingsSection; runtime: RuntimeStatus; onRefreshRuntime: () => Promise; projectSlug?: string }) { const servers = useMcpStatusStore((state) => state.servers); const [activeSection, setActiveSection] = useState(section); const [snapshot, setSnapshot] = useState(); @@ -199,7 +199,7 @@ function SettingsWorkspace({ active, section, runtime, onRefreshRuntime }: { act const hasConfigData = snapshot !== undefined && adapterCatalog !== undefined; return hasConfigData - ? + ? : activeSection === "updates" || activeSection === "runtime-data" ? : {error diff --git a/apps/web/src/components/features/SettingsMemoryPanel.test.tsx b/apps/web/src/components/features/SettingsMemoryPanel.test.tsx new file mode 100644 index 00000000..15ad8b6d --- /dev/null +++ b/apps/web/src/components/features/SettingsMemoryPanel.test.tsx @@ -0,0 +1,379 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { JSDOM } from "jsdom"; +import type { ServerConfig } from "../../api/config"; + +type MockDestructiveActionDialogProps = { + open: boolean; + title: string; + confirmLabel: string; + pendingLabel: string; + pending: boolean; + onConfirm: () => void; + onClose: () => void; +}; + +mock.module("./DestructiveActionDialog", () => ({ + DestructiveActionDialog: ({ open, title, confirmLabel, pendingLabel, pending, onConfirm, onClose }: MockDestructiveActionDialogProps) => open + ?

{title}

+ : null, +})); + +const { SettingsMemoryPanel } = await import("./SettingsMemoryPanel"); + +let dom: JSDOM; +let root: Root; +let container: HTMLDivElement; + +const config = { + provider: {}, + profiles: {}, + memory: { useMemory: true, autoLearning: true }, +} as ServerConfig; + +const snapshot = { + preferences: { content: "# Preferences\n", revision: "p1", capacity: { bytes: 14, maxBytes: 8192, state: "within-limit", mutationPolicy: "normal" }, availableForPrompt: true }, + topics: [{ name: "build-tools", title: "Build Tools", description: "Commands", type: "project", revision: "t1", capacity: { bytes: 48, maxBytes: 16384, state: "within-limit", mutationPolicy: "normal" } }], + index: { revision: "i1", bytes: 48, topicCount: { count: 1, max: 200, state: "within-limit", canCreate: true }, availableForPrompt: true }, + warnings: [], +} as const; + +function installDom() { + dom = new JSDOM("", { url: "http://localhost", pretendToBeVisual: true }); + for (const [name, value] of Object.entries({ + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + Node: dom.window.Node, + Element: dom.window.Element, + HTMLElement: dom.window.HTMLElement, + HTMLButtonElement: dom.window.HTMLButtonElement, + HTMLInputElement: dom.window.HTMLInputElement, + HTMLTextAreaElement: dom.window.HTMLTextAreaElement, + MutationObserver: dom.window.MutationObserver, + getComputedStyle: dom.window.getComputedStyle.bind(dom.window), + IS_REACT_ACT_ENVIRONMENT: true, + })) Object.defineProperty(globalThis, name, { configurable: true, value }); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +} + +function renderPanel(projectSlug?: string, onChange = () => {}) { + act(() => root.render()); +} + +function renderInactivePanel(projectSlug: string) { + act(() => root.render( {}} projectSlug={projectSlug} active={false} />)); +} + +function setControlledValue(element: HTMLInputElement | HTMLTextAreaElement, value: string) { + act(() => { + const prototype = element instanceof dom.window.HTMLTextAreaElement + ? dom.window.HTMLTextAreaElement.prototype + : dom.window.HTMLInputElement.prototype; + const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set; + setter?.call(element, value); + const propsKey = Object.keys(element).find((key) => key.startsWith("__reactProps$")); + const props = propsKey + ? (element as unknown as Record void }>)[propsKey] + : undefined; + props?.onChange?.({ target: element }); + }); +} + +function clickReactButton(button: HTMLButtonElement | null | undefined) { + if (!button) return; + const propsKey = Object.keys(button).find((key) => key.startsWith("__reactProps$")); + const props = propsKey + ? (button as unknown as Record void }>)[propsKey] + : undefined; + act(() => props?.onClick?.()); +} + +function capacityLabels() { + return [...container.querySelectorAll('[aria-label^="Memory capacity:"]')] + .map((element) => element.getAttribute("aria-label")); +} + +async function flush() { + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); +} + +beforeEach(() => installDom()); +afterEach(() => { act(() => root.unmount()); dom.window.close(); }); + +describe("Settings Memory panel", () => { + test("does not fetch project Memory on Home and keeps CRUD unavailable", () => { + const fetchMock = mock(async () => Response.json(snapshot)); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderPanel(); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Open a project to manage Memory"); + expect(container.querySelector('input[aria-label="Use Memory"]')).not.toBeNull(); + expect(container.querySelector('input[aria-label="Auto learning"]')).not.toBeNull(); + }); + + test("does not prefetch Memory while another Settings section is active", () => { + const fetchMock = mock(async () => Response.json(snapshot)); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderInactivePanel("demo"); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("loads the scoped snapshot and fetches a topic only when selected", async () => { + const fetchMock = mock(async (url: string) => url.endsWith("/memory/topics/build-tools") + ? Response.json({ ...snapshot.topics[0], content: "commands" }) + : Response.json(snapshot)); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderPanel("demo"); + await flush(); + + expect(fetchMock.mock.calls[0]?.[0]).toBe("/api/projects/demo/memory"); + expect(container.textContent).toContain("build-tools"); + expect(container.textContent).toContain("Index generated by server"); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const topicButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("build-tools")); + act(() => topicButton?.click()); + await flush(); + expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/projects/demo/memory/topics/build-tools"); + expect(container.textContent).toContain("commands"); + }); + + test("projects edited preferences and existing topic drafts", async () => { + const fetchMock = mock(async (url: string) => url.endsWith("/memory/topics/build-tools") + ? Response.json({ ...snapshot.topics[0], content: "commands" }) + : Response.json(snapshot)); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderPanel("demo"); + await flush(); + + setControlledValue(container.querySelector('textarea[aria-label="Personal Memory"]') as HTMLTextAreaElement, "偏好"); + expect(capacityLabels()).toContain("Memory capacity: 6 B of 8.0 KiB"); + + const topicButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("build-tools")); + act(() => topicButton?.click()); + await flush(); + setControlledValue(container.querySelector('textarea[aria-label="Topic Markdown content"]') as HTMLTextAreaElement, "😀"); + // The canonical frontmatter is 62 bytes; the emoji body is four UTF-8 bytes. + expect(capacityLabels()).toContain("Memory capacity: 66 B of 16 KiB"); + }); + + test("includes canonical frontmatter bytes for a new topic", async () => { + const fetchMock = mock(async () => Response.json(snapshot)); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderPanel("demo"); + await flush(); + + const newTopic = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("New topic")); + act(() => newTopic?.click()); + const nameInput = container.querySelector('section[aria-labelledby="project-memory-heading"] input[type="text"]') as HTMLInputElement; + setControlledValue(nameInput, "new-topic"); + + // `title` falls back to the normalized name and the empty body still has + // the complete canonical frontmatter document around it. + expect(capacityLabels()).toContain("Memory capacity: 52 B of 16 KiB"); + }); + + test("keeps an edited draft after a revision conflict and offers reload", async () => { + let call = 0; + const fetchMock = mock(async (_url: string, init?: RequestInit) => { + call += 1; + if (init?.method === "PUT") return Response.json({ error: { code: "MEMORY_REVISION_CONFLICT", message: "Memory changed" } }, { status: 409 }); + return Response.json(snapshot); + }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + renderPanel("demo"); + await flush(); + const textarea = container.querySelector('textarea[aria-label="Personal Memory"]') as HTMLTextAreaElement; + setControlledValue(textarea, "draft that must stay"); + const save = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Save Personal Memory")); + act(() => save?.click()); + await flush(); + expect(call).toBeGreaterThanOrEqual(2); + expect((container.querySelector('textarea[aria-label="Personal Memory"]') as HTMLTextAreaElement | null)?.value).toBe("draft that must stay"); + expect(container.textContent).toContain("changed elsewhere"); + expect(container.textContent).toContain("Reload latest"); + }); + + test("renders legacy capacity warnings, recovers, and completes CRUD through confirmation", async () => { + const legacyPreferences = "p".repeat(25 * 1024); + const topicHeader = "---\nname: Oversized\ndescription: legacy topic\ntype: project\n---\n"; + const oversizedTopicContent = "t".repeat(20 * 1024 - new TextEncoder().encode(topicHeader).byteLength); + const oversizedTopicSummary = { + ...snapshot.topics[0], + name: "oversized", + title: "Oversized", + description: "legacy topic", + capacity: { + bytes: 20 * 1024, + maxBytes: 16 * 1024, + state: "over-limit" as const, + mutationPolicy: "shrink-only" as const, + }, + }; + const oversizedTopic = { + ...oversizedTopicSummary, + content: oversizedTopicContent, + }; + const legacyTopics = Array.from({ length: 201 }, (_, index) => index === 0 + ? oversizedTopicSummary + : { + ...snapshot.topics[0], + name: `legacy_${index}`, + }); + const legacySnapshot = { + preferences: { + ...snapshot.preferences, + content: legacyPreferences, + capacity: { + bytes: 25 * 1024, + maxBytes: 8 * 1024, + state: "over-limit" as const, + mutationPolicy: "shrink-only" as const, + }, + availableForPrompt: false, + }, + topics: legacyTopics, + index: { + ...snapshot.index, + bytes: 201 * 48, + topicCount: { count: 201, max: 200, state: "over-limit" as const, canCreate: false }, + availableForPrompt: false, + }, + warnings: [ + { + code: "preferences_over_capacity" as const, + target: "preferences", + message: "Personal Memory is over 8 KiB and must be reduced before it can grow.", + }, + { + code: "topic_over_capacity" as const, + target: "oversized", + message: "Memory topic oversized is over 16 KiB and must be reduced before it can grow.", + }, + { + code: "topic_count_over_capacity" as const, + target: "project topics", + message: "Project Memory has 201 topics; reduce it to 200 before creating another topic.", + }, + ], + }; + const compliantPreferences = { + ...snapshot.preferences, + content: "compliant preferences", + capacity: { bytes: 22, maxBytes: 8 * 1024, state: "within-limit" as const, mutationPolicy: "normal" as const }, + availableForPrompt: true, + }; + const recoveredSnapshot = { + ...snapshot, + preferences: compliantPreferences, + topics: [], + index: { + ...snapshot.index, + bytes: 0, + topicCount: { count: 0, max: 200, state: "within-limit" as const, canCreate: true }, + }, + warnings: [], + }; + const createdTopic = { + ...snapshot.topics[0], + name: "new-topic", + title: "New Topic", + description: "Created from Settings", + content: "created body", + revision: "new-topic-revision", + }; + const createdSnapshot = { + ...recoveredSnapshot, + topics: [{ + name: createdTopic.name, + title: createdTopic.title, + description: createdTopic.description, + type: createdTopic.type, + revision: createdTopic.revision, + capacity: createdTopic.capacity, + }], + index: { + ...recoveredSnapshot.index, + bytes: 62, + topicCount: { count: 1, max: 200, state: "within-limit" as const, canCreate: true }, + }, + }; + const finalSnapshot = recoveredSnapshot; + const snapshots = [legacySnapshot, recoveredSnapshot, createdSnapshot, finalSnapshot]; + let snapshotIndex = 0; + const fetchMock = mock(async (url: string, init?: RequestInit) => { + if (init?.method === "PUT" && url.endsWith("/memory/preferences")) { + return Response.json(compliantPreferences); + } + if (init?.method === "PUT" && url.endsWith("/memory/topics/new-topic")) { + return Response.json(createdTopic); + } + if (init?.method === "DELETE") return new Response(null, { status: 204 }); + if (url.endsWith("/memory/topics/oversized")) return Response.json(oversizedTopic); + if (url.endsWith("/memory/topics/new-topic")) return Response.json(createdTopic); + return Response.json(snapshots[Math.min(snapshotIndex++, snapshots.length - 1)]); + }); + Object.defineProperty(globalThis, "fetch", { configurable: true, value: fetchMock }); + + renderPanel("demo"); + await flush(); + + expect(container.textContent).toContain("Personal Memory is over 8 KiB"); + expect(container.textContent).toContain("Memory topic oversized is over 16 KiB"); + expect(container.textContent).toContain("Project Memory has 201 topics"); + expect(container.textContent).toContain("201/200 topics"); + expect(capacityLabels()).toContain("Memory capacity: 25 KiB of 8.0 KiB"); + const blockedNewTopic = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("New topic")); + expect(blockedNewTopic?.disabled).toBe(true); + + const oversizedButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("oversized")); + act(() => oversizedButton?.click()); + await flush(); + expect(capacityLabels()).toContain("Memory capacity: 20 KiB of 16 KiB"); + + setControlledValue(container.querySelector('textarea[aria-label="Personal Memory"]') as HTMLTextAreaElement, "compliant preferences"); + const savePreferences = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Save Personal Memory")); + act(() => savePreferences?.click()); + await flush(); + expect(fetchMock.mock.calls.some(([url, init]) => url.endsWith("/memory/preferences") && init?.method === "PUT")).toBe(true); + expect(container.querySelector('[aria-label="Memory warnings"]')).toBeNull(); + expect(container.textContent).toContain("0/200 topics"); + + const newTopicButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("New topic")); + expect(newTopicButton?.disabled).toBe(false); + act(() => newTopicButton?.click()); + const topicInputs = container.querySelectorAll('section[aria-labelledby="project-memory-heading"] input[type="text"]'); + setControlledValue(topicInputs[0] as HTMLInputElement, "new-topic"); + setControlledValue(topicInputs[1] as HTMLInputElement, "New Topic"); + setControlledValue(topicInputs[2] as HTMLInputElement, "Created from Settings"); + setControlledValue(container.querySelector('textarea[aria-label="Topic Markdown content"]') as HTMLTextAreaElement, "created body"); + const createTopicButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Create topic")); + act(() => createTopicButton?.click()); + await flush(); + expect(fetchMock.mock.calls.some(([url, init]) => url.endsWith("/memory/topics/new-topic") && init?.method === "PUT")).toBe(true); + expect(container.textContent).toContain("new-topic"); + + const deleteTopicButton = [...container.querySelectorAll("button")].find((button) => button.textContent?.trim() === "Delete topic"); + expect(deleteTopicButton).not.toBeUndefined(); + expect(deleteTopicButton?.disabled).toBe(false); + clickReactButton(deleteTopicButton); + await flush(); + expect(document.body.textContent).toContain("Delete Memory topic?"); + expect(fetchMock.mock.calls.some(([, init]) => init?.method === "DELETE")).toBe(false); + const dialog = document.body.querySelector('[role="dialog"]'); + const confirmDelete = [...(dialog?.querySelectorAll("button") ?? [])].find((button) => button.textContent?.trim() === "Delete topic"); + act(() => confirmDelete?.click()); + await flush(); + const deleteCall = fetchMock.mock.calls.find(([url, init]) => url.endsWith("/memory/topics/new-topic") && init?.method === "DELETE"); + expect(deleteCall?.[1]?.method).toBe("DELETE"); + expect(JSON.parse(String(deleteCall?.[1]?.body))).toEqual({ expectedRevision: "new-topic-revision" }); + expect(container.textContent).toContain("0/200 topics"); + expect(container.querySelector('[aria-label="Memory warnings"]')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/features/SettingsMemoryPanel.tsx b/apps/web/src/components/features/SettingsMemoryPanel.tsx new file mode 100644 index 00000000..0665ee31 --- /dev/null +++ b/apps/web/src/components/features/SettingsMemoryPanel.tsx @@ -0,0 +1,400 @@ +import { useEffect, useMemo, useState } from "react"; +import { BookOpen, Check, Plus, RefreshCw, Save, Trash2, TriangleAlert } from "lucide-react"; +import type { ServerConfig } from "../../api/config"; +import { + deleteMemoryPreferences, + deleteMemoryTopic, + getMemorySnapshot, + getMemoryTopic, + putMemoryPreferences, + putMemoryTopic, + type MemoryCapacity, + type MemoryPreferencesItem, + type MemorySnapshot, + type MemoryTopicItem, + type MemoryTopicSummary, +} from "../../api/memory"; +import { ApiError } from "../../api/client"; +import { DestructiveActionDialog } from "./DestructiveActionDialog"; +import { Field, TextInput } from "./settings-fields"; +import { defaultMemoryConfig, type FieldErrors, withDraft } from "./settings-helpers"; + +type TopicType = "user" | "feedback" | "project" | "reference"; + +const buttonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-sm bg-brand px-3 text-[12px] font-medium text-bg-overlay transition-colors duration-[var(--motion-hover)] hover:bg-brand-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40 sm:min-h-0 sm:h-8"; +const secondaryButtonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-sm bg-bg-active px-3 text-[12px] font-medium text-text-secondary transition-colors duration-[var(--motion-hover)] hover:bg-bg-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40 sm:min-h-0 sm:h-8"; +const dangerButtonClass = "inline-flex min-h-11 items-center justify-center gap-2 rounded-sm px-2.5 text-[12px] font-medium text-error transition-colors duration-[var(--motion-hover)] hover:bg-error-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand disabled:cursor-not-allowed disabled:opacity-40 sm:min-h-0 sm:h-7"; +const selectClass = "h-9 w-full rounded-sm border border-border-control bg-bg-base px-3 text-[12px] text-text-primary outline-none transition-colors duration-[var(--motion-hover)] hover:border-text-secondary focus:border-brand focus:ring-2 focus:ring-brand-subtle sm:h-8"; + +interface SettingsMemoryPanelProps { + config: ServerConfig; + onChange: (config: ServerConfig) => void; + errors?: FieldErrors; + projectSlug?: string; + active?: boolean; +} + +interface TopicDraft { + name: string; + title: string; + type: TopicType; + description: string; + content: string; + revision: string | null; + isNew: boolean; +} + +export function SettingsMemoryPanel({ config, onChange, errors = {}, projectSlug, active = true }: SettingsMemoryPanelProps) { + const memory = { ...defaultMemoryConfig(), ...(config.memory as unknown as Partial> | undefined) }; + const [snapshot, setSnapshot] = useState(); + const [preferences, setPreferences] = useState(null); + const [preferencesDraft, setPreferencesDraft] = useState(""); + const [topicDraft, setTopicDraft] = useState(); + const [loading, setLoading] = useState(false); + const [loadingTopic, setLoadingTopic] = useState(false); + const [savingPreferences, setSavingPreferences] = useState(false); + const [savingTopic, setSavingTopic] = useState(false); + const [error, setError] = useState(); + const [conflict, setConflict] = useState(); + const [deleteTarget, setDeleteTarget] = useState<"preferences" | "topic" | null>(null); + const [selectedTopicName, setSelectedTopicName] = useState(); + + const loadSnapshot = async () => { + if (!projectSlug) return; + setLoading(true); + setError(undefined); + try { + const next = await getMemorySnapshot(projectSlug); + setSnapshot(next); + const nextPreferences = next.preferences; + setPreferences(nextPreferences); + setPreferencesDraft(nextPreferences?.content ?? ""); + if (selectedTopicName && !next.topics.some((topic) => topic.name === selectedTopicName)) { + setSelectedTopicName(undefined); + setTopicDraft(undefined); + } + } catch (cause) { + setError(toMemoryError(cause, "Unable to load Memory")); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (!active || !projectSlug) { + if (!projectSlug) { + setSnapshot(undefined); + setSelectedTopicName(undefined); + setTopicDraft(undefined); + } + return; + } + void loadSnapshot(); + // The settings dialog owns the active panel lifecycle. A project slug is + // intentionally the only key that triggers a project Memory request. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active, projectSlug]); + + const topicCount = snapshot?.topics.length ?? 0; + const sortedTopics = useMemo( + () => [...(snapshot?.topics ?? [])].sort((left, right) => left.name.localeCompare(right.name)), + [snapshot?.topics], + ); + + const patchMemoryConfig = (key: "useMemory" | "autoLearning", value: boolean) => { + onChange(withDraft(config, (draft) => { + draft.memory = { ...defaultMemoryConfig(), ...(draft.memory as unknown as Record | undefined), [key]: value } as ServerConfig["memory"]; + })); + }; + + const savePreferences = async () => { + if (!projectSlug) return; + setSavingPreferences(true); + setError(undefined); + setConflict(undefined); + try { + await putMemoryPreferences({ slug: projectSlug, content: preferencesDraft, expectedRevision: preferences?.revision ?? null }); + await loadSnapshot(); + } catch (cause) { + if (isRevisionConflict(cause)) setConflict("Personal Memory changed elsewhere. Your draft is still here; reload the latest version before saving."); + else setError(toMemoryError(cause, "Unable to save Personal Memory")); + } finally { + setSavingPreferences(false); + } + }; + + const clearPreferences = async () => { + if (!projectSlug) return; + setSavingPreferences(true); + setError(undefined); + setConflict(undefined); + try { + await deleteMemoryPreferences({ slug: projectSlug, expectedRevision: preferences?.revision ?? null }); + await loadSnapshot(); + setDeleteTarget(null); + } catch (cause) { + if (isRevisionConflict(cause)) setConflict("Personal Memory changed elsewhere. Reload before deleting it."); + else setError(toMemoryError(cause, "Unable to clear Personal Memory")); + } finally { + setSavingPreferences(false); + } + }; + + const selectTopic = async (summary: MemoryTopicSummary) => { + if (!projectSlug) return; + setSelectedTopicName(summary.name); + setLoadingTopic(true); + setError(undefined); + setConflict(undefined); + try { + const topic = await getMemoryTopic(projectSlug, summary.name); + setTopicDraft(toTopicDraft(topic, false)); + } catch (cause) { + setError(toMemoryError(cause, `Unable to load topic ${summary.name}`)); + setTopicDraft(undefined); + } finally { + setLoadingTopic(false); + } + }; + + const startNewTopic = () => { + setSelectedTopicName(undefined); + setConflict(undefined); + setTopicDraft({ name: "", title: "", type: "project", description: "", content: "", revision: null, isNew: true }); + }; + + const saveTopic = async () => { + if (!projectSlug || !topicDraft) return; + const name = topicDraft.name.trim(); + if (!name) { + setError("Topic name is required"); + return; + } + setSavingTopic(true); + setError(undefined); + setConflict(undefined); + try { + await putMemoryTopic({ + slug: projectSlug, + name, + content: topicDraft.content, + expectedRevision: topicDraft.revision, + type: topicDraft.type, + title: topicDraft.title, + description: topicDraft.description, + }); + setSelectedTopicName(name); + await loadSnapshot(); + const refreshed = await getMemoryTopic(projectSlug, name); + setTopicDraft(toTopicDraft(refreshed, false)); + } catch (cause) { + if (isRevisionConflict(cause)) setConflict(`Topic ${name} changed elsewhere. Your draft is still here; reload the latest version before saving.`); + else setError(toMemoryError(cause, `Unable to save topic ${name}`)); + } finally { + setSavingTopic(false); + } + }; + + const deleteTopic = async () => { + if (!projectSlug || !topicDraft) return; + setSavingTopic(true); + setError(undefined); + setConflict(undefined); + try { + await deleteMemoryTopic({ slug: projectSlug, name: topicDraft.name, expectedRevision: topicDraft.revision }); + setDeleteTarget(null); + setSelectedTopicName(undefined); + setTopicDraft(undefined); + await loadSnapshot(); + } catch (cause) { + if (isRevisionConflict(cause)) setConflict(`Topic ${topicDraft.name} changed elsewhere. Reload before deleting it.`); + else setError(toMemoryError(cause, `Unable to delete topic ${topicDraft.name}`)); + } finally { + setSavingTopic(false); + } + }; + + const reloadAfterConflict = async () => { + setConflict(undefined); + await loadSnapshot(); + if (projectSlug && selectedTopicName) { + const latest = await getMemoryTopic(projectSlug, selectedTopicName).catch(() => undefined); + if (latest) setTopicDraft(toTopicDraft(latest, false)); + } + }; + + return
+ +
+ patchMemoryConfig("useMemory", value)} label="Use Memory" description="Inject complete preferences and the current project index into new Execution prompts." /> + patchMemoryConfig("autoLearning", value)} label="Auto learning" description="After a successful root conversation is idle for 10 minutes, extract durable Memory in the background." /> +

These switches are saved with the global configuration. Auto learning never removes or changes explicit memory_write.

+
+ + {!projectSlug ? : <> + {loading && !snapshot &&

Loading Memory…

} + {error && {error}} + {conflict &&
{conflict}
} + {snapshot && <> + {snapshot.warnings.length > 0 && } + { void savePreferences(); }} + onClear={() => setDeleteTarget("preferences")} + saving={savingPreferences} + error={errors["memory.preferences"]} + /> + { void saveTopic(); }} + onDelete={() => setDeleteTarget("topic")} + /> + } + } + + setDeleteTarget(null)} + onConfirm={() => { void clearPreferences(); }} + /> + setDeleteTarget(null)} + onConfirm={() => { void deleteTopic(); }} + /> +
; +} + +function MemoryPanelHeader() { + return
+

Server settings

+

Memory

+

Control prompt recall and background learning, then manage durable Markdown Memory for the open project.

+
; +} + +function SettingsToggle({ checked, onChange, label, description }: { checked: boolean; onChange: (checked: boolean) => void; label: string; description: string }) { + return ; +} + +function UnavailableMemoryState() { + return
+
+
; +} + +function WarningList({ warnings }: { warnings: MemorySnapshot["warnings"] }) { + return
+ {warnings.map((warning, index) =>
)} +
; +} + +function InlineMessage({ tone, children }: { tone: "error" | "success"; children: string }) { + return
{children}
; +} + +function PersonalMemoryEditor({ preferences, draft, onDraftChange, onSave, onClear, saving, error }: { preferences: MemoryPreferencesItem | null; draft: string; onDraftChange: (value: string) => void; onSave: () => void; onClear: () => void; saving: boolean; error?: string }) { + const capacity = preferences?.capacity; + // Capacity is a projection of the current draft, not the last saved file. + // Preferences are stored as-is, so their UTF-8 byte count is sufficient. + const usedBytes = utf8ByteLength(draft); + const maxBytes = capacity?.maxBytes ?? 8 * 1024; + return
+

Personal Memory

User-global preferences and working style. Edit the Markdown body directly.

+