From e80c9131da08c1f3a3472d83ecca6183d0ea3bec Mon Sep 17 00:00:00 2001 From: rank-Yu Date: Thu, 30 Jul 2026 21:19:42 -0700 Subject: [PATCH 01/47] =?UTF-8?q?feat(core,server,web):=20Workspace=20Memo?= =?UTF-8?q?ry=20=E2=80=94=20long-term=20notes=20an=20agent=20keeps=20betwe?= =?UTF-8?q?en=20Sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `agent_state/memory/`: Markdown notes an agent maintains itself with the file tools, kept per Workspace under one shared index. Only the index enters the context; topic bodies are read on demand. Scope is Project + Agent + Workspace. Core: state/memory.ts derives a workspace key (`-<8 hex sha256 of the real path>`), so symlinks to one directory collapse to one key while a moved directory becomes a new Workspace. A temporary Workspace gets no Memory — the test is the directory's location (under an agent's `workspaces/`), not whether the caller passed a Workspace, because a subagent inherits its parent's as an explicit argument. A `.workspace` marker records the path a key stands for, which the Web App needs since the key itself is a hash. system_config gains `memory.enabled` / `memory.prompt`, and the default template gains `{{MEMORY}}`, rendered with `{{MEMORY_DIR}}` and `{{MEMORY_AGENTS_MD}}` at Session creation and empty when Memory is off or the Workspace is temporary. Server: MemoryService + /api/projects/:p/agents/:a/memory (overview, index, per- Workspace file listing, file read/write/delete, rename). No route accepts a path — a file is addressed by agent, workspace key and a name inside that Workspace, each pattern-checked and re-checked for containment after resolution. Web: a Memory tab between Prompt and Runtime — the switch, a Workspace selector, the shared index pinned above that Workspace's topic files, and a Markdown editor with create / rename / delete. Renaming or deleting a topic file also repoints or removes the exact `](/)` index links, so the index never lists a file that is gone. Existing agents are untouched: no `memory` section and no `{{MEMORY}}` placeholder means nothing is injected until they adopt it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01KvVGoQVVDWzVPbHCnY645t --- .../unreleased/2026-07-30-workspace-memory.md | 44 ++ changelog/unreleased/README.md | 2 + packages/core/src/agent.ts | 15 +- packages/core/src/state/agent-state.ts | 41 +- packages/core/src/state/default-config.ts | 48 ++ packages/core/src/state/index.ts | 1 + packages/core/src/state/memory.ts | 263 ++++++++ packages/core/src/state/paths.ts | 21 +- packages/core/test/memory.test.ts | 277 +++++++++ packages/docs/content/configuration.en.md | 39 ++ packages/docs/content/configuration.zh.md | 39 ++ packages/docs/content/server-api.en.md | 5 + packages/docs/content/server-api.zh.md | 5 + .../docs/content/sessions-and-traces.en.md | 4 +- .../docs/content/sessions-and-traces.zh.md | 4 +- packages/docs/content/web-app.en.md | 3 + packages/docs/content/web-app.zh.md | 3 + packages/server/src/api/types.ts | 82 +++ packages/server/src/app.ts | 6 + packages/server/src/http/routes/memory.ts | 87 +++ .../src/services/agent-config-service.ts | 13 + .../server/src/services/memory-service.ts | 327 ++++++++++ packages/server/test/memory.test.ts | 188 ++++++ packages/web/src/api/endpoints.ts | 72 +++ .../features/agents/agent-settings-page.tsx | 17 +- .../web/src/features/agents/memory-tab.tsx | 562 ++++++++++++++++++ packages/web/src/lib/strings-en.ts | 47 ++ packages/web/src/lib/strings.ts | 43 ++ packages/web/test/memory-tab.test.ts | 98 +++ 29 files changed, 2346 insertions(+), 10 deletions(-) create mode 100644 changelog/unreleased/2026-07-30-workspace-memory.md create mode 100644 packages/core/src/state/memory.ts create mode 100644 packages/core/test/memory.test.ts create mode 100644 packages/server/src/http/routes/memory.ts create mode 100644 packages/server/src/services/memory-service.ts create mode 100644 packages/server/test/memory.test.ts create mode 100644 packages/web/src/features/agents/memory-tab.tsx create mode 100644 packages/web/test/memory-tab.test.ts diff --git a/changelog/unreleased/2026-07-30-workspace-memory.md b/changelog/unreleased/2026-07-30-workspace-memory.md new file mode 100644 index 00000000..d616bd56 --- /dev/null +++ b/changelog/unreleased/2026-07-30-workspace-memory.md @@ -0,0 +1,44 @@ +# Workspace Memory: what an agent keeps between Sessions + +An agent now has a long-term store it maintains itself: Markdown notes under `agent_state/memory/`, kept per Workspace, with a shared index that enters the context and topic bodies read on demand. It covers what a later Session cannot re-derive from the Workspace — standing user feedback, project decisions with their reasons, conventions, entry points into external systems. + +Memory is not context compaction. Compaction preserves one Session's short-term working state; Memory is what survives the Session ending. + +## Scope and layout + +The scope is `Project + Agent + Workspace`. Sessions of one agent in one Workspace share a Memory; different Workspaces of that agent keep their topic files apart but share a single index; different agents never share Memory, even in the same Workspace. + +```text +agent_state/memory/ +├── AGENTS.md # the shared index, grouped by workspace key +└── my-app-a81f32c4/ + ├── .workspace # the Workspace path this key stands for + ├── feedback_testing.md + └── project_release.md +``` + +The workspace key is `-<8 hex of the real path's sha256>`. Identity is the directory itself, with no dependence on Git: two symlinks to one directory resolve to a single key, and moving or renaming a directory makes it a new Workspace — the old Memory stays on disk under the old key rather than following a path that no longer exists. + +A **temporary** Workspace gets no Memory at all. The test is the directory's location — anything under an agent's `workspaces/` — rather than whether the caller passed a Workspace explicitly, because a subagent inherits its parent's Workspace as an explicit argument, temporary ones included. + +A topic file is a semantic subject, not one per Task, Session or date, and declares `name` / `description` / `type` / `updated_at` in frontmatter. `type` is `feedback`, `project` or `reference`. There is deliberately no `user` type: a Project is a multi-user boundary and Agent State is readable by every member who can reach the agent, so personal data about one person does not belong here — nor do credentials, task progress, unconfirmed guesses, or facts the code and Git history already state. + +## What reaches the model + +Only the index. At Session creation the Harness makes sure the Workspace's directory exists, reads `memory/AGENTS.md`, and renders the agent's own `memory.prompt` into the new `{{MEMORY}}` placeholder, substituting `{{MEMORY_DIR}}` (this Workspace's directory) and `{{MEMORY_AGENTS_MD}}` (the whole index). Every word of that block comes from `system_config.yaml`; the assembly layer adds nothing but a short "nothing saved yet" note in place of an index that does not exist. `{{MEMORY}}` expands to an empty string when Memory is off or the Session has a temporary Workspace, so the model is never told about a directory it has no reason to write to. + +Reading, writing and deduplicating are the model's own work through the ordinary file tools — the Harness decides where Memory lives and keeps writes inside it, nothing more. + +## Managing it in the Web App + +Agent settings gain a **Memory** tab between Prompt and Runtime: the agent-level switch, a Workspace selector, the shared index pinned above that Workspace's topic files, and a Markdown editor, plus create / rename / delete. Opening the index parks the caret on the selected Workspace's group heading, since one index covers them all. + +Renaming or deleting a topic file also repoints or removes the index links that named it, so the index never lists a file that is gone. The link form is exact (`](/)`), keeping this a mechanical edit that never rewrites prose the model wrote. + +Turning Memory off keeps every file and leaves the tab fully usable; it only stops Memory from entering the context and from preparing directories for new Sessions. The tab also warns when an agent is enabled but its prompt template carries no `{{MEMORY}}` placeholder — enabled, yet injecting nothing. + +The API is under `/api/projects/:p/agents/:a/memory` and never accepts a path: a file is addressed by agent, workspace key and a name inside that Workspace, each validated and then re-checked for containment after resolution. + +## Existing agents + +Nothing migrates. An agent runs with its on-disk `system_config.yaml` verbatim, and an existing one has neither a `memory` section nor a `{{MEMORY}}` placeholder — so Memory reaches **newly created** agents only. An existing agent opts in by inserting the placeholder on the Prompt tab, or by restoring the default configuration from Overview. diff --git a/changelog/unreleased/README.md b/changelog/unreleased/README.md index f1ebe534..096d8267 100644 --- a/changelog/unreleased/README.md +++ b/changelog/unreleased/README.md @@ -11,3 +11,5 @@ - [2026-08-06] Models: Thinking Machines Lab's Inkling joins on OpenRouter and Fireworks AI, Fireworks AI gains DeepSeek V4 Flash 0731, and the OpenRouter + SiliconFlow GLM-5.1 gateway listings are delisted (Z.AI direct stays; existing Project configs unaffected); agenthub-models skill v11. ([details](2026-08-06-model-catalog-inkling-dsv4-flash-0731.md)) - [2026-08-06] Release tooling: repo versions realigned with the shipped 0.2.1, and the release workflow now refuses a tag push whose version does not match `package.json` (the drift that made every dev build nag about updates); the bump is documented as a release-prep step. ([details](2026-08-06-release-version-guard.md)) + +- [2026-07-30] Workspace Memory: an agent keeps long-term notes between Sessions under `agent_state/memory/` — topic files per Workspace and one shared index, with only the index entering the context and bodies read on demand. Scope is Project + Agent + Workspace, a temporary Workspace gets none, and the Web App gains a Memory tab that keeps the index in step with a rename or delete. Existing agents are unchanged until they adopt the `{{MEMORY}}` placeholder. ([details](2026-07-30-workspace-memory.md)) diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 64d7c8cc..63d59a65 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -22,6 +22,7 @@ import { loadProjectConfig, projectDir, goalFilePath, + resolveSessionMemory, resolveModelRef, sessionScratchpadDir, systemConfigPath, @@ -269,12 +270,23 @@ export class Agent { this.state.projectId, this.state.agentId, ); + // Workspace Memory for this Session: null when the Agent has Memory off or the Workspace is a + // temporary one (nothing worth remembering outlives it), which also means no directory is + // created for it. Reads the current index every time, like the vault and Skills above. + const memory = await resolveSessionMemory({ + root: this.state.root, + projectId: this.state.projectId, + agentId: this.state.agentId, + workspaceDir, + enabled: this.state.systemConfig.memory?.enabled !== false, + }); // The assembled system prompt goes both to the LLM and into session_meta (so the // Trace can audit the actual effective value). The vault only injects **key names** // into the prompt (so the model knows which API keys are available); values only // go into the subprocess environment. Skills only inject metadata (name and - // description); the model reads the body on demand via shell. + // description); the model reads the body on demand via shell. Memory likewise injects + // only its index; topic bodies are read on demand. const systemPrompt = assembleSystemPrompt( this.state, sessionEnvironment(workspaceDir, sessionId, { @@ -285,6 +297,7 @@ export class Agent { }), Object.keys(vault), installedSkills, + memory, ); const rt = await this.buildRuntime({ diff --git a/packages/core/src/state/agent-state.ts b/packages/core/src/state/agent-state.ts index f3a95e8a..82500b6c 100644 --- a/packages/core/src/state/agent-state.ts +++ b/packages/core/src/state/agent-state.ts @@ -29,6 +29,10 @@ import { PROVIDER_PLACEHOLDER, MODEL_ID_PLACEHOLDER, DATE_PLACEHOLDER, + MEMORY_PLACEHOLDER, + MEMORY_DIR_PLACEHOLDER, + MEMORY_AGENTS_MD_PLACEHOLDER, + MEMORY_INDEX_EMPTY_NOTE, agentStateVersion, defaultAgentsMd, defaultSystemConfig, @@ -40,6 +44,7 @@ import { type SystemConfig, } from "./default-config.js"; import { builtinProjectAgentPresets, type AgentPreset } from "./builtin-agents.js"; +import type { SessionMemory } from "./memory.js"; import { provisionExampleBenchmark } from "./example-benchmark.js"; import { agentsMdPath, @@ -269,6 +274,30 @@ function vaultKeysList(keys: string[]): string { return keys.map((key) => `- ${key}`).join("\n"); } +/** + * The `{{MEMORY}}` replacement value: the Agent's own `memory.prompt` with its Workspace + * directory and the shared index substituted in, or an empty string when this Session has no + * Memory (disabled, or a temporary Workspace) or the config carries no Memory prompt. + * + * Every word of the block comes from `system_config.yaml`; the only text this function can add + * is `MEMORY_INDEX_EMPTY_NOTE`, standing in for an index that does not exist yet so the model + * reads "nothing saved" instead of a blank line. Topic bodies are never injected — the index + * says what exists, and the model opens what it needs. + */ +function memorySection( + prompt: string | undefined, + memory: SessionMemory | null | undefined, +): string { + if (!prompt || !memory) return ""; + const index = memory.index.trim(); + return prompt + .split(MEMORY_DIR_PLACEHOLDER) + .join(memory.dir) + .split(MEMORY_AGENTS_MD_PLACEHOLDER) + .join(index.length > 0 ? index : MEMORY_INDEX_EMPTY_NOTE) + .trim(); +} + /** * Installs a Skill into the target Agent: writes `skills//SKILL.md` verbatim (the full * SKILL.md content including frontmatter, ensuring a trailing newline); if the directory @@ -436,9 +465,12 @@ function withShellLineFallback( * `{{VAULT_KEYS}}` is replaced with the vault key-name list (an empty string if empty/not * provided): this lets the model know which APIs requiring a key it can call; values are never * injected. `{{SKILL_METADATA}}` is replaced with the installed Skills' metadata lines (an empty - * string if empty/not provided). A custom template that removes a placeholder gets no - * corresponding content injected. `{{PROJECT_DIR}}` resolves to the Project directory — - * the app data root the default prompt labels "App Data Dir". + * string if empty/not provided). `{{MEMORY}}` expands to the rendered `memory.prompt` block when + * this Session has Memory (enabled + a persistent Workspace), and to an empty string otherwise — + * only that block's own `{{MEMORY_DIR}}` / `{{MEMORY_AGENTS_MD}}` carry Memory content, and topic + * bodies are always read on demand rather than injected. A custom template that removes a + * placeholder gets no corresponding content injected. `{{PROJECT_DIR}}` resolves to the Project + * directory — the app data root the default prompt labels "App Data Dir". * Docs: /docs/configuration § "System prompt placeholders". */ export function assembleSystemPrompt( @@ -446,11 +478,14 @@ export function assembleSystemPrompt( sessionEnvironment?: SessionEnvironmentValues, vaultKeys?: string[], skillMetadata?: SkillMetadata[], + memory?: SessionMemory | null, ): string { const template = state.systemConfig.system_prompt; const assembled = template .split(AGENTS_MD_PLACEHOLDER) .join(state.agentsMd.trim()) + .split(MEMORY_PLACEHOLDER) + .join(memorySection(state.systemConfig.memory?.prompt, memory)) .split(VAULT_KEYS_PLACEHOLDER) .join(vaultKeysList(vaultKeys ?? [])) .split(SKILL_METADATA_PLACEHOLDER) diff --git a/packages/core/src/state/default-config.ts b/packages/core/src/state/default-config.ts index 5242cf93..0a17084f 100644 --- a/packages/core/src/state/default-config.ts +++ b/packages/core/src/state/default-config.ts @@ -36,6 +36,12 @@ export const OS_VERSION_PLACEHOLDER = "{{OS_VERSION}}"; /** The shell exec_command runs (`bash` on POSIX; on Windows whatever shell.ts resolved), so the model knows which command syntax to write. */ export const SHELL_PLACEHOLDER = "{{SHELL}}"; export const DATE_PLACEHOLDER = "{{DATE}}"; +/** Expands to the whole rendered `memory.prompt` block, or to nothing when Memory is off or the Session has no persistent Workspace. */ +export const MEMORY_PLACEHOLDER = "{{MEMORY}}"; +/** Inside `memory.prompt` only: the current Workspace's Memory directory. */ +export const MEMORY_DIR_PLACEHOLDER = "{{MEMORY_DIR}}"; +/** Inside `memory.prompt` only: the full content of the shared Memory index (`memory/AGENTS.md`). */ +export const MEMORY_AGENTS_MD_PLACEHOLDER = "{{MEMORY_AGENTS_MD}}"; /** * Context compaction config (the `compaction` section of `system_config.yaml`). @@ -52,6 +58,17 @@ export interface CompactionConfig { prompt?: string; } +/** + * Workspace Memory config (the `memory` section of `system_config.yaml`). + * Docs: /docs/configuration § "Workspace Memory". + */ +export interface MemoryConfig { + /** Whether Memory enters the model context and Workspace Memory directories are prepared; defaults to true. */ + enabled?: boolean; + /** The `{{MEMORY}}` block: how the model should use Memory, plus the `{{MEMORY_DIR}}` / `{{MEMORY_AGENTS_MD}}` injection points; defaults to the built-in value (editable config, not hardcoded). */ + prompt?: string; +} + /** * System-level config for Agent State, serialized as `system_config.yaml`. * Docs: /docs/configuration § "Agent config". @@ -74,6 +91,8 @@ export interface SystemConfig { }; /** Context compaction (enabled by default, max_context_length 128k, mode summarize). */ compaction?: CompactionConfig; + /** Workspace Memory (enabled by default; only reaches the prompt through the template's `{{MEMORY}}` placeholder). */ + memory?: MemoryConfig; tools?: { /** Built-in system tool configuration (per-entry fields incl. the `call_description` toggle live on ToolDefinitionConfig). */ builtin?: ToolDefinitionConfig[]; @@ -143,6 +162,8 @@ The vault holds this agent's per-agent secrets (agent_state/.vault.toml). Each e Skills are reusable instruction packages at /agents//agent_state/skills//SKILL.md. When a task matches one below, or the user asks for one (the message may start with a [use_skills] block naming them), read that SKILL.md in full with read_file, then follow it. If a request names a skill without a concrete task, ask the user what they need first. {{SKILL_METADATA}} +{{MEMORY}} + # Environment - Platform: {{PLATFORM}} - OS Version: {{OS_VERSION}} @@ -155,6 +176,29 @@ Skills are reusable instruction packages at /agents//age - Model ID: {{MODEL_ID}} - Session ID: {{SESSION_ID}}`; +/** + * Built-in default Memory Prompt: the body of the `{{MEMORY}}` block. It states what Memory is + * for, what must never be written to it, and the mechanics of one save (read the index, prefer + * updating an existing topic, create a topic file with frontmatter, refresh the index entry); + * the two placeholders inside it are the only Memory content the Harness injects. + * + * Rendered only when Memory is enabled and the Session has a persistent Workspace, so the model + * is never told about a Memory directory it has no reason to write to. + */ +export const DEFAULT_MEMORY_PROMPT = `# Memory +Memory is your long-term record across sessions in this workspace: Markdown files you maintain yourself with the file tools. Keep in it what you could not re-derive from the workspace later — the user's standing feedback and preferences, project decisions and constraints together with their reasons, and stable entry points into external systems. + +Save only what is specific, durable, and worth having in a later session. Before writing, read the index below and any topic file it lists for this workspace; then update an existing topic instead of opening a near-duplicate. A genuinely new subject becomes \`.md\` in the directory below, with frontmatter \`name\`, \`description\`, \`type\` (feedback | project | reference) and \`updated_at\`, plus a one-line entry in the index under this workspace's heading. Update the index in the same round as the file, so the two never disagree. + +Never save what the code, config or git history already states; short-lived task progress or debugging notes; credentials, tokens or other secrets; guesses you have not confirmed; or long stretches of transcript. Memory is shared with everyone who can reach this agent, so personal data about one person does not belong in it. Entries under another workspace's heading are that workspace's facts, not this one's. + +Current workspace memory directory: {{MEMORY_DIR}} +The shared index is \`AGENTS.md\` in that directory's parent, and its links are relative to that parent; this workspace's heading is the directory name above. +{{MEMORY_AGENTS_MD}}`; + +/** Stands in for `{{MEMORY_AGENTS_MD}}` when the index file does not exist yet or is blank — the model is told the store is empty rather than being handed nothing. */ +export const MEMORY_INDEX_EMPTY_NOTE = "(the index is empty — nothing has been saved yet)"; + /** * Built-in default compaction Prompt (summarize mode): tells the model the summary will * replace the transcript as its only record (so it must include everything needed to @@ -504,6 +548,10 @@ export function defaultSystemConfig(): SystemConfig { mode: "summarize", prompt: DEFAULT_COMPACTION_PROMPT, }, + memory: { + enabled: true, + prompt: DEFAULT_MEMORY_PROMPT, + }, tools: { builtin: defaultBuiltinTools(), mcpServers: [], diff --git a/packages/core/src/state/index.ts b/packages/core/src/state/index.ts index aacd1c3a..64bedf97 100644 --- a/packages/core/src/state/index.ts +++ b/packages/core/src/state/index.ts @@ -10,6 +10,7 @@ export * from "./model-catalog.js"; export * from "./project-config.js"; export * from "./agent-state.js"; export * from "./agent-vault.js"; +export * from "./memory.js"; export * from "./example-benchmark.js"; // Skill library types and frontmatter parser (from the skills package; server reuses the same implementation via core). diff --git a/packages/core/src/state/memory.ts b/packages/core/src/state/memory.ts new file mode 100644 index 00000000..9dcfa218 --- /dev/null +++ b/packages/core/src/state/memory.ts @@ -0,0 +1,263 @@ +/** + * Workspace Memory: the Agent's long-term notes across Sessions. + * + * Memory keeps what cannot be re-derived from the current Workspace or its code history — + * user feedback, project decisions, working conventions, entry points into external systems. + * It is **not** context compaction: compaction preserves one Session's short-term working + * state, Memory preserves what later Sessions need. + * + * Scope is `Project + Agent + Workspace`: Sessions of the same Agent in the same Workspace + * share one Memory; different Workspaces of one Agent keep their topic files apart but share + * a single index; different Agents never share Memory even in the same Workspace. Memory lives + * in Agent State, so it travels with export / import / snapshots and is visible to every + * Project member who can reach the Agent — which is why there is no `user` topic type here. + * + * On-disk layout (`agent_state/memory/`): + * + * memory/ + * ├── AGENTS.md # the single index, grouped by workspace key + * └── / + * ├── .workspace # the Workspace path this key stands for + * └── .md # frontmatter + body, semantic topics (not per Task/date) + * + * The Harness only decides *where* Memory lives, keeps writes inside that directory, and + * injects the index into the prompt; the model owns the semantics — what is worth keeping, + * how topics are split, and how the index is maintained — using the ordinary file tools. + * Docs: /docs/configuration § "Workspace Memory". + */ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { agentsDir, memoryIndexPath, workspaceMemoryDir } from "./paths.js"; + +/** Topic types a Memory file may declare in its frontmatter. `user` is deliberately absent: a Project is a multi-user boundary, and personal data written here would be readable by every member. */ +export const MEMORY_TOPIC_TYPES = ["feedback", "project", "reference"] as const; +export type MemoryTopicType = (typeof MEMORY_TOPIC_TYPES)[number]; + +/** The index file name, used both for `memory/AGENTS.md` and to exclude it from a Workspace's topic listing. */ +export const MEMORY_INDEX_FILENAME = "AGENTS.md"; + +/** + * Marker file inside a Workspace Memory directory recording the Workspace path the key was + * derived from. The key alone is a hash and cannot be read back into a path, so this is what + * lets the Web App label a Workspace; it is a dotfile so it never shows up as a topic. + */ +export const WORKSPACE_MARKER_FILENAME = ".workspace"; + +/** Length of the path hash suffix in a workspace key: 32 bits of a sha256, enough that two Workspaces on one machine practically never collide. */ +const KEY_HASH_LENGTH = 8; + +/** Cap on the readable part of a workspace key, so a deeply named directory can't produce an unwieldy path. */ +const KEY_BASE_MAX_LENGTH = 40; + +/** Frontmatter of one Memory topic file. */ +export interface MemoryTopicMetadata { + /** Display name; falls back to the file name when the frontmatter omits it. */ + name: string; + /** One line telling a reader whether the body is worth opening. */ + description: string; + /** Topic type; `undefined` when the file declares none or an unknown one. */ + type?: MemoryTopicType; + /** Last-updated date as written in the file (`YYYY-MM-DD` by convention, not parsed). */ + updatedAt?: string; +} + +/** + * The Memory binding of one Session: the Workspace's own topic directory plus the shared + * index content, as resolved at Session creation. + */ +export interface SessionMemory { + /** Workspace key — the `memory/` subdirectory name and the index's group heading. */ + key: string; + /** Absolute path of the Workspace's topic directory (the `{{MEMORY_DIR}}` value). */ + dir: string; + /** Full content of `memory/AGENTS.md` (empty string when it does not exist yet). */ + index: string; +} + +/** + * Turns a Workspace directory name into the readable half of its key: everything outside + * `[A-Za-z0-9_-]` collapses to a hyphen, and the result is lowercased and truncated. A + * directory whose name survives as empty (a filesystem root, a name made only of separators + * or CJK characters) falls back to `workspace` — the hash half still keeps the key unique. + */ +function safeKeyBase(dirName: string): string { + const cleaned = dirName + .replace(/[^A-Za-z0-9_-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, KEY_BASE_MAX_LENGTH) + .toLowerCase(); + return cleaned.length > 0 ? cleaned : "workspace"; +} + +/** + * The workspace key for an already-canonical absolute path: `-<8 hex of the + * path's sha256>`. + * + * Identity is the directory itself and has nothing to do with Git: two symlinks pointing at + * one directory resolve to the same real path and therefore the same key, while moving or + * renaming a directory makes it a new Workspace (its old Memory stays on disk under the old + * key). Prefer `workspaceMemoryKey`, which canonicalizes first. + */ +export function workspaceMemoryKeyForRealPath(realPath: string): string { + const hash = createHash("sha256").update(realPath).digest("hex").slice(0, KEY_HASH_LENGTH); + return `${safeKeyBase(path.basename(realPath))}-${hash}`; +} + +/** + * The workspace key for a Workspace directory: resolves symlinks and `..` first so every + * route to one directory produces one key. A path that cannot be canonicalized (already + * deleted, or not readable) falls back to `path.resolve`, which still yields a stable key + * rather than failing Session creation. + */ +export async function workspaceMemoryKey(workspaceDir: string): Promise { + return workspaceMemoryKeyForRealPath(await realPathOrResolve(workspaceDir)); +} + +async function realPathOrResolve(dir: string): Promise { + try { + return await fs.realpath(dir); + } catch { + return path.resolve(dir); + } +} + +/** + * Whether a Workspace is one PenguinHarness created for a Session itself, i.e. it sits under + * some Agent's `workspaces/` directory (`/agents//workspaces/tmp-xxxxxxxx`). + * + * Temporary Workspaces get no Memory: their contents are per-Session and the directory is + * gone by the time anything could be recalled. The check is by location rather than by "did + * the caller pass a workspaceDir", because a subagent inherits its parent's Workspace as an + * explicit argument — including when that Workspace is the parent's temporary one. + */ +export async function isTemporaryWorkspace( + root: string, + projectId: string, + workspaceDir: string, +): Promise { + const real = await realPathOrResolve(workspaceDir); + const base = await realPathOrResolve(agentsDir(root, projectId)); + const rel = path.relative(base, real); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return false; + const segments = rel.split(path.sep); + // /workspaces/: anything shallower is the Agent directory itself. + return segments.length >= 3 && segments[1] === "workspaces"; +} + +/** + * Creates a Workspace's Memory directory if needed and records the Workspace path in its + * `.workspace` marker (rewritten when the path changed, e.g. the directory was reached + * through a different symlink). Never touches the index or any topic file — those are the + * model's to create on the first save. + */ +export async function ensureWorkspaceMemoryDir(args: { + root: string; + projectId: string; + agentId: string; + workspaceKey: string; + workspacePath: string; +}): Promise { + const dir = workspaceMemoryDir(args.root, args.projectId, args.agentId, args.workspaceKey); + await fs.mkdir(dir, { recursive: true }); + const marker = path.join(dir, WORKSPACE_MARKER_FILENAME); + const line = `${args.workspacePath}\n`; + try { + if ((await fs.readFile(marker, "utf8")) === line) return dir; + } catch { + // No marker yet (or unreadable): fall through and write it. + } + await fs.writeFile(marker, line, "utf8"); + return dir; +} + +/** Reads the Workspace path recorded in a Memory directory's marker; `undefined` when the marker is missing or empty. */ +export async function readWorkspaceMarker(dir: string): Promise { + try { + const raw = (await fs.readFile(path.join(dir, WORKSPACE_MARKER_FILENAME), "utf8")).trim(); + return raw.length > 0 ? raw : undefined; + } catch { + return undefined; + } +} + +/** Reads the shared Memory index (`memory/AGENTS.md`); an empty string when it does not exist yet. */ +export async function readMemoryIndex( + root: string, + projectId: string, + agentId: string, +): Promise { + try { + return await fs.readFile(memoryIndexPath(root, projectId, agentId), "utf8"); + } catch { + return ""; + } +} + +/** + * Resolves the Memory a Session should run with, creating its Workspace directory as a side + * effect. Returns `null` — meaning nothing is injected into the prompt and no directory is + * created — when Memory is disabled for the Agent or the Session runs in a temporary + * Workspace. Failures to prepare the directory are also `null`: Memory is an enhancement, and + * an unwritable Agent State should not take down Session creation. + */ +export async function resolveSessionMemory(args: { + root: string; + projectId: string; + agentId: string; + workspaceDir: string; + enabled: boolean; +}): Promise { + if (!args.enabled) return null; + try { + if (await isTemporaryWorkspace(args.root, args.projectId, args.workspaceDir)) return null; + const workspacePath = await realPathOrResolve(args.workspaceDir); + const key = workspaceMemoryKeyForRealPath(workspacePath); + const dir = await ensureWorkspaceMemoryDir({ + root: args.root, + projectId: args.projectId, + agentId: args.agentId, + workspaceKey: key, + workspacePath, + }); + return { key, dir, index: await readMemoryIndex(args.root, args.projectId, args.agentId) }; + } catch { + return null; + } +} + +/** + * Parses a Memory topic file's frontmatter, in the same line-oriented way as Skill + * frontmatter (values are plain scalars, not full YAML). Returns `null` when the file has no + * frontmatter block at all; individual missing fields are simply left out, so a hand-edited + * file never fails to list. `fallbackName` (normally the file name) stands in for a missing + * `name`. + */ +export function parseMemoryFrontmatter( + content: string, + fallbackName: string, +): MemoryTopicMetadata | null { + // Strip a possible UTF-8 BOM (editors add one when a file is edited by hand); CRLF is handled by \r?\n. + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content.replace(/^/, "")); + if (!match) return null; + const fields: Record = {}; + for (const line of match[1]!.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx <= 0) continue; + const key = line.slice(0, idx).trim(); + if (key) fields[key] = line.slice(idx + 1).trim(); + } + const type = fields["type"]; + const updatedAt = fields["updated_at"]; + return { + name: fields["name"] || fallbackName, + description: fields["description"] ?? "", + ...(isMemoryTopicType(type) ? { type } : {}), + ...(updatedAt ? { updatedAt } : {}), + }; +} + +/** Whether a string is one of the supported topic types. */ +export function isMemoryTopicType(value: unknown): value is MemoryTopicType { + return typeof value === "string" && (MEMORY_TOPIC_TYPES as readonly string[]).includes(value); +} diff --git a/packages/core/src/state/paths.ts b/packages/core/src/state/paths.ts index 25bf0099..5bf86037 100644 --- a/packages/core/src/state/paths.ts +++ b/packages/core/src/state/paths.ts @@ -124,11 +124,30 @@ export function toolsDir(root: string, projectId: string, agentId: string): stri return path.join(agentStateDir(root, projectId, agentId), "tools"); } -/** `/memory`. */ +/** `/memory`, the Workspace Memory root (one subdirectory per Workspace, see state/memory.ts). */ export function memoryDir(root: string, projectId: string, agentId: string): string { return path.join(agentStateDir(root, projectId, agentId), "memory"); } +/** + * `/memory/AGENTS.md`, the single Memory index shared by every Workspace + * (grouped by workspace key). Distinct from `agent_state/AGENTS.md`, which holds the + * human-edited Agent instructions. + */ +export function memoryIndexPath(root: string, projectId: string, agentId: string): string { + return path.join(memoryDir(root, projectId, agentId), "AGENTS.md"); +} + +/** `/memory/`, one Workspace's topic-file directory. */ +export function workspaceMemoryDir( + root: string, + projectId: string, + agentId: string, + workspaceKey: string, +): string { + return path.join(memoryDir(root, projectId, agentId), workspaceKey); +} + /** `/skills`. */ export function skillsDir(root: string, projectId: string, agentId: string): string { return path.join(agentStateDir(root, projectId, agentId), "skills"); diff --git a/packages/core/test/memory.test.ts b/packages/core/test/memory.test.ts new file mode 100644 index 00000000..8bde4459 --- /dev/null +++ b/packages/core/test/memory.test.ts @@ -0,0 +1,277 @@ +/** + * Workspace Memory: key derivation, temporary-Workspace exclusion, directory preparation, + * frontmatter parsing, and the `{{MEMORY}}` prompt block. + */ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + DEFAULT_AGENT_ID, + DEFAULT_PROJECT_ID, + createAgent, + MEMORY_INDEX_EMPTY_NOTE, + MEMORY_PLACEHOLDER, + WORKSPACE_MARKER_FILENAME, + assembleSystemPrompt, + ensureWorkspaceMemoryDir, + isTemporaryWorkspace, + loadOrInitAgentState, + memoryDir, + memoryIndexPath, + parseMemoryFrontmatter, + readMemoryIndex, + readWorkspaceMarker, + resolveSessionMemory, + workspaceMemoryDir, + workspaceMemoryKey, + workspaceMemoryKeyForRealPath, + type AgentState, +} from "../src/index.js"; +import { stubProviderKeys } from "./provider-keys.js"; + +let root: string; +let workspace: string; +let restoreKeys: () => void; + +beforeEach(async () => { + root = await fs.mkdtemp(path.join(os.tmpdir(), "penguin-memory-")); + workspace = path.join(root, "projects", "my-app"); + await fs.mkdir(workspace, { recursive: true }); + restoreKeys = stubProviderKeys(); +}); + +afterEach(async () => { + restoreKeys(); + await fs.rm(root, { recursive: true, force: true }); +}); + +/** Loads a default Agent State under the temp root (creates it on first call). */ +async function agentState(): Promise { + return loadOrInitAgentState({ root }); +} + +/** The effective system prompt a created Session recorded in its session_meta. */ +function sessionPrompt(session: { metaMessage: { payload: unknown } }): string { + return (session.metaMessage.payload as { system_prompt: string }).system_prompt; +} + +describe("workspace key", () => { + it("is a safe basename plus a path hash, and is stable across calls", () => { + const key = workspaceMemoryKeyForRealPath("/home/u/work/My App!"); + expect(key).toMatch(/^my-app-[0-9a-f]{8}$/); + expect(workspaceMemoryKeyForRealPath("/home/u/work/My App!")).toBe(key); + }); + + it("separates two Workspaces that share a directory name", () => { + expect(workspaceMemoryKeyForRealPath("/a/site")).not.toBe( + workspaceMemoryKeyForRealPath("/b/site"), + ); + }); + + it("falls back to a generic base when the name has nothing key-safe in it", () => { + expect(workspaceMemoryKeyForRealPath("/srv/项目")).toMatch(/^workspace-[0-9a-f]{8}$/); + }); + + it("resolves symlinks, so two routes to one directory share a key", async () => { + const link = path.join(root, "link-to-app"); + await fs.symlink(workspace, link, "dir"); + expect(await workspaceMemoryKey(link)).toBe(await workspaceMemoryKey(workspace)); + }); + + it("keeps a stable key for a directory that no longer exists (realpath fails)", async () => { + const gone = path.join(root, "deleted"); + expect(await workspaceMemoryKey(gone)).toBe(workspaceMemoryKeyForRealPath(path.resolve(gone))); + }); +}); + +describe("temporary Workspace detection", () => { + it("recognizes an Agent's own workspaces/ directory", async () => { + const tmp = path.join( + root, + DEFAULT_PROJECT_ID, + "agents", + DEFAULT_AGENT_ID, + "workspaces", + "tmp-1234abcd", + ); + await fs.mkdir(tmp, { recursive: true }); + expect(await isTemporaryWorkspace(root, DEFAULT_PROJECT_ID, tmp)).toBe(true); + }); + + it("treats a user directory — including one elsewhere under the Agent — as persistent", async () => { + const stateSubdir = path.join(root, DEFAULT_PROJECT_ID, "agents", DEFAULT_AGENT_ID, "traces"); + await fs.mkdir(stateSubdir, { recursive: true }); + expect(await isTemporaryWorkspace(root, DEFAULT_PROJECT_ID, workspace)).toBe(false); + expect(await isTemporaryWorkspace(root, DEFAULT_PROJECT_ID, stateSubdir)).toBe(false); + }); +}); + +describe("directory preparation", () => { + it("creates the Workspace directory and records the Workspace path in its marker", async () => { + const key = await workspaceMemoryKey(workspace); + const dir = await ensureWorkspaceMemoryDir({ + root, + projectId: DEFAULT_PROJECT_ID, + agentId: DEFAULT_AGENT_ID, + workspaceKey: key, + workspacePath: workspace, + }); + expect(dir).toBe(workspaceMemoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, key)); + expect(await readWorkspaceMarker(dir)).toBe(workspace); + // Idempotent: a second call neither fails nor rewrites a different path. + await ensureWorkspaceMemoryDir({ + root, + projectId: DEFAULT_PROJECT_ID, + agentId: DEFAULT_AGENT_ID, + workspaceKey: key, + workspacePath: workspace, + }); + expect(await fs.readdir(dir)).toEqual([WORKSPACE_MARKER_FILENAME]); + }); + + it("reads an absent index as an empty string, and no topic file is preprovisioned", async () => { + await agentState(); + expect(await readMemoryIndex(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID)).toBe(""); + expect(await fs.readdir(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toEqual([]); + }); +}); + +describe("resolveSessionMemory", () => { + const resolve = (opts: { workspaceDir: string; enabled: boolean }) => + resolveSessionMemory({ + root, + projectId: DEFAULT_PROJECT_ID, + agentId: DEFAULT_AGENT_ID, + ...opts, + }); + + it("prepares the directory and returns the index for a persistent Workspace", async () => { + await agentState(); + await fs.writeFile( + memoryIndexPath(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID), + "# Memory\n\n## group\n", + "utf8", + ); + const memory = await resolve({ workspaceDir: workspace, enabled: true }); + expect(memory?.key).toBe(await workspaceMemoryKey(workspace)); + expect(memory?.index).toContain("## group"); + await expect(fs.stat(memory!.dir)).resolves.toBeTruthy(); + }); + + it("returns null and creates nothing when Memory is disabled", async () => { + await agentState(); + expect(await resolve({ workspaceDir: workspace, enabled: false })).toBeNull(); + expect(await fs.readdir(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toEqual([]); + }); + + it("returns null for a temporary Workspace", async () => { + await agentState(); + const tmp = path.join( + root, + DEFAULT_PROJECT_ID, + "agents", + DEFAULT_AGENT_ID, + "workspaces", + "tmp-cafebabe", + ); + await fs.mkdir(tmp, { recursive: true }); + expect(await resolve({ workspaceDir: tmp, enabled: true })).toBeNull(); + expect(await fs.readdir(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toEqual([]); + }); +}); + +describe("frontmatter", () => { + it("reads name / description / type / updated_at", () => { + const parsed = parseMemoryFrontmatter( + "---\nname: Testing conventions\ndescription: how tests run\ntype: feedback\nupdated_at: 2026-07-30\n---\n\n- body\n", + "feedback_testing.md", + ); + expect(parsed).toEqual({ + name: "Testing conventions", + description: "how tests run", + type: "feedback", + updatedAt: "2026-07-30", + }); + }); + + it("falls back to the file name and drops an unknown type", () => { + const parsed = parseMemoryFrontmatter("---\ntype: user\n---\nbody\n", "notes.md"); + expect(parsed).toEqual({ name: "notes.md", description: "" }); + }); + + it("returns null when the file has no frontmatter", () => { + expect(parseMemoryFrontmatter("just a note\n", "notes.md")).toBeNull(); + }); +}); + +describe("{{MEMORY}} injection", () => { + it("renders the configured block with the directory and index substituted", async () => { + const state = await agentState(); + const prompt = assembleSystemPrompt(state, undefined, undefined, undefined, { + key: "my-app-12345678", + dir: "/data/memory/my-app-12345678", + index: + "# Memory\n\n## my-app-12345678\n\n- [Testing](my-app-12345678/feedback_testing.md) — how tests run", + }); + expect(prompt).toContain("/data/memory/my-app-12345678"); + expect(prompt).toContain("- [Testing](my-app-12345678/feedback_testing.md) — how tests run"); + expect(prompt).not.toContain(MEMORY_PLACEHOLDER); + expect(prompt).not.toContain(MEMORY_INDEX_EMPTY_NOTE); + }); + + it("states the store is empty when the index has no content yet", async () => { + const state = await agentState(); + const prompt = assembleSystemPrompt(state, undefined, undefined, undefined, { + key: "my-app-12345678", + dir: "/data/memory/my-app-12345678", + index: " \n", + }); + expect(prompt).toContain(MEMORY_INDEX_EMPTY_NOTE); + }); + + it("injects nothing at all when the Session has no Memory", async () => { + const state = await agentState(); + const prompt = assembleSystemPrompt(state); + expect(prompt).not.toContain(MEMORY_PLACEHOLDER); + expect(prompt).not.toContain("Current workspace memory directory"); + }); + + it("reaches the Session's system prompt for a persistent Workspace, but not a temporary one", async () => { + const agent = await createAgent({ root }); + const withWorkspace = await agent.createSession({ workspaceDir: workspace }); + const key = await workspaceMemoryKey(workspace); + expect(sessionPrompt(withWorkspace)).toContain( + workspaceMemoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID, key), + ); + // No Workspace given: the SDK allocates a temporary one, which gets no Memory. + const temporary = await agent.createSession(); + expect(sessionPrompt(temporary)).not.toContain("Current workspace memory directory"); + expect(await fs.readdir(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toEqual([key]); + }); + + it("is left out of the Session prompt when the Agent config disables Memory", async () => { + const agent = await createAgent({ root }); + agent.state.systemConfig.memory = { ...agent.state.systemConfig.memory, enabled: false }; + const session = await agent.createSession({ workspaceDir: workspace }); + expect(sessionPrompt(session)).not.toContain("Current workspace memory directory"); + expect(await fs.readdir(memoryDir(root, DEFAULT_PROJECT_ID, DEFAULT_AGENT_ID))).toEqual([]); + }); + + it("injects nothing when the Agent's template carries no {{MEMORY}} placeholder", async () => { + const state = await agentState(); + const stripped: AgentState = { + ...state, + systemConfig: { + ...state.systemConfig, + system_prompt: state.systemConfig.system_prompt.split(MEMORY_PLACEHOLDER).join(""), + }, + }; + const prompt = assembleSystemPrompt(stripped, undefined, undefined, undefined, { + key: "my-app-12345678", + dir: "/data/memory/my-app-12345678", + index: "# Memory\n", + }); + expect(prompt).not.toContain("/data/memory/my-app-12345678"); + }); +}); diff --git a/packages/docs/content/configuration.en.md b/packages/docs/content/configuration.en.md index 08f64e64..4fe8d5df 100644 --- a/packages/docs/content/configuration.en.md +++ b/packages/docs/content/configuration.en.md @@ -106,6 +106,8 @@ Edit this file via the CLI (`penguin config model …`) or the Web Models page | `compaction.max_session_turns` | `-1` | Cumulative Session turn threshold (`-1` = unlimited) | | `compaction.mode` | `summarize` | `summarize` / `discard` | | `compaction.prompt` | built-in template | Prompt used for summarize compaction | +| `memory.enabled` | `true` | Whether Workspace Memory enters the context and Memory directories are prepared | +| `memory.prompt` | built-in template | The `{{MEMORY}}` block: how to use Memory, plus `{{MEMORY_DIR}}` / `{{MEMORY_AGENTS_MD}}` | | `tools.builtin` | full default toolset when omitted | Tool entries: `name` / `description` / `parameters` / `permission` (`r` or `rw`) / `forModel` / `timeoutMs` / `maxOutputLength` / `call_description` (per-tool toggle for the `description` call argument, required while on; missing = kept); once written it replaces the default list wholesale | | `tools.mcpServers` | `[]` | MCP Server configuration (`name` + `config`); reserved for the MCP adapter layer | @@ -150,6 +152,7 @@ An existing Agent always runs with its on-disk config verbatim — newer code de | `{{AGENTS_MD}}` | Full text of `AGENTS.md` | | `{{VAULT_KEYS}}` | List of Vault key names (names only) | | `{{SKILL_METADATA}}` | Metadata of installed Skills | +| `{{MEMORY}}` | The rendered `memory.prompt` block; empty when Memory is off or the Session has a temporary Workspace | | `{{PLATFORM}}` | Runtime platform | | `{{OS_VERSION}}` | Operating system version | | `{{DATE}}` | Current date | @@ -166,6 +169,42 @@ On Windows, `{{PROJECT_DIR}}` and `{{CWD}}` are injected with forward slashes `agent_state/AGENTS.md` is the developer-editable instruction file, injected via `{{AGENTS_MD}}` and empty by default — it is also the file an optimizer edits most (see [Self-Improvement](/self-improvement)). +## Workspace Memory + +`agent_state/memory/` is what the Agent remembers between Sessions: user feedback, project decisions, working conventions and entry points into external systems — the things that cannot be re-derived from the Workspace or its code history. It is not context compaction, which preserves one Session's short-term state. + +Memory is scoped to **Project + Agent + Workspace**. Sessions of one Agent in one Workspace share a Memory; different Workspaces keep their topic files apart but share one index; different Agents never share Memory even in the same Workspace. Because Memory lives in Agent State it travels with export / import and snapshots, and every Project member who can reach the Agent can read it — so personal data about one person does not belong in it (there is no `user` topic type). + +```text +agent_state/memory/ +├── AGENTS.md # the shared index, grouped by workspace key +└── my-app-a81f32c4/ # one Workspace + ├── .workspace # the Workspace path this key stands for + ├── feedback_testing.md + └── project_release.md +``` + +The workspace key is `-<8 hex of the real path's sha256>`. Identity is the directory itself and has nothing to do with Git: two symlinks to one directory resolve to one key, while moving or renaming a directory makes it a new Workspace (the old Memory stays on disk under the old key). A temporary Workspace — one PenguinHarness allocated under `agents//workspaces/` — gets no Memory directory at all, including when a subagent inherits it. + +A topic file is a semantic subject, not one per Task, Session or date, and carries frontmatter: + +```markdown +--- +name: Testing conventions +description: the project's test environment and verification rules +type: feedback +updated_at: 2026-07-30 +--- + +- Integration tests connect to a real database; no mock repositories. +``` + +`type` is `feedback` (standing user feedback and preferences), `project` (decisions, constraints and plans with their reasons) or `reference` (stable entry points into external systems, documents and services). What must never be saved: facts the code, config or Git history already states; short-lived task progress and debugging notes; credentials, tokens or secrets; unconfirmed guesses; long transcript excerpts. + +Only the index reaches the context. At Session creation the Harness ensures the Workspace's directory exists, reads `memory/AGENTS.md`, and renders the Agent's `memory.prompt` into `{{MEMORY}}` with `{{MEMORY_DIR}}` (this Workspace's directory) and `{{MEMORY_AGENTS_MD}}` (the whole index) substituted; topic bodies are read on demand by the model. The Harness only decides where Memory lives and keeps writes inside it — deciding what is worth keeping, splitting topics and maintaining the index is the model's job, done with the ordinary file tools. + +Existing Agents are not migrated: their `system_config.yaml` carries no `memory` section and no `{{MEMORY}}` placeholder, so nothing is injected. Add the placeholder on the settings page's Prompt tab, or restore the default configuration. + ## Vault `agent_state/.vault.toml` is the Agent-level environment-variable vault: a hidden file written with mode 0600. diff --git a/packages/docs/content/configuration.zh.md b/packages/docs/content/configuration.zh.md index 77780145..65ee3271 100644 --- a/packages/docs/content/configuration.zh.md +++ b/packages/docs/content/configuration.zh.md @@ -106,6 +106,8 @@ output = 0.857143 | `compaction.max_session_turns` | `-1` | Session 累计轮数阈值(`-1` 不限制) | | `compaction.mode` | `summarize` | `summarize` / `discard` | | `compaction.prompt` | 内置模板 | summarize 压缩使用的 Prompt | +| `memory.enabled` | `true` | Workspace 记忆是否进入上下文、是否为持久 Workspace 准备记忆目录 | +| `memory.prompt` | 内置模板 | `{{MEMORY}}` 区块:记忆使用说明,含 `{{MEMORY_DIR}}` / `{{MEMORY_AGENTS_MD}}` | | `tools.builtin` | 缺省时为完整默认工具集 | 工具条目:`name` / `description` / `parameters` / `permission`(`r` 或 `rw`)/ `forModel` / `timeoutMs` / `maxOutputLength` / `call_description`(条目级开关:控制 `description` 调用参数,开启时为必填,缺省保留);一旦写出即整体替换默认列表 | | `tools.mcpServers` | `[]` | MCP Server 配置(`name` + `config`),预留给 MCP 适配层 | @@ -150,6 +152,7 @@ compaction: | `{{AGENTS_MD}}` | `AGENTS.md` 的全文 | | `{{VAULT_KEYS}}` | Vault 的键名列表(仅键名) | | `{{SKILL_METADATA}}` | 已安装 Skill 的元数据 | +| `{{MEMORY}}` | 渲染后的 `memory.prompt` 区块;关闭记忆或使用临时 Workspace 时为空 | | `{{PLATFORM}}` | 运行平台 | | `{{OS_VERSION}}` | 操作系统版本 | | `{{DATE}}` | 当前日期 | @@ -166,6 +169,42 @@ Windows 上注入的 `{{PROJECT_DIR}}` 与 `{{CWD}}` 统一使用正斜杠—— `agent_state/AGENTS.md` 是开发者可编辑的指令文件,经 `{{AGENTS_MD}}` 注入系统提示词,缺省为空——它也是优化器最常改动的文件(见[自我进化](/self-improvement))。 +## Workspace 记忆 + +`agent_state/memory/` 保存 Agent 跨 Session 的长期记忆:用户反馈、项目决策、协作约定与外部系统入口——这些无法从 Workspace 或代码历史可靠重新推导。它不是上下文压缩:压缩保存单个 Session 的短期工作状态。 + +记忆的作用域是 **Project + Agent + Workspace**。同一 Agent、同一 Workspace 的多个 Session 共享一份记忆;不同 Workspace 的主题文件相互隔离,但共用一份索引;不同 Agent 即使使用同一 Workspace 也各自维护。记忆位于 Agent State,因此随导出、导入与快照一同流转,Project 内有权访问该 Agent 的成员都能读到——所以只属于某个成员的隐私信息不应写入(也因此不提供 `user` 类型)。 + +```text +agent_state/memory/ +├── AGENTS.md # 统一索引,按 workspace key 分组 +└── my-app-a81f32c4/ # 单个 Workspace + ├── .workspace # 该 key 对应的 Workspace 路径 + ├── feedback_testing.md + └── project_release.md +``` + +workspace key 为 `<安全 basename>-<真实路径 sha256 的 8 位十六进制>`。身份只由实际目录决定,与 Git 无关:指向同一目录的两个软链接得到同一 key;目录移动或重命名后视为新的 Workspace(旧记忆仍以旧 key 留在磁盘上)。PenguinHarness 自动创建的临时 Workspace(位于 `agents//workspaces/` 下)不创建记忆目录,子 Agent 继承该临时 Workspace 时同样不创建。 + +主题文件按语义划分,不按 Task、Session 或日期划分,并带 frontmatter: + +```markdown +--- +name: Testing conventions +description: 项目的测试环境和验证规则 +type: feedback +updated_at: 2026-07-30 +--- + +- 集成测试必须连接真实数据库,不使用 mock repository。 +``` + +`type` 取 `feedback`(用户明确给出、未来应持续遵守的反馈与偏好)、`project`(无法仅从代码推导的决策、约束与计划及其理由)或 `reference`(外部系统、文档与服务的稳定入口)。不应保存:可从代码、配置或 Git 历史直接获得的事实;短期任务进度与调试流水;凭据、Token 等敏感值;未经确认的推测;大段对话原文。 + +进入上下文的只有索引。创建 Session 时,Harness 确保当前 Workspace 的记忆目录存在、读取 `memory/AGENTS.md`,并把 Agent 的 `memory.prompt` 渲染进 `{{MEMORY}}`,其中 `{{MEMORY_DIR}}` 替换为该 Workspace 的目录、`{{MEMORY_AGENTS_MD}}` 替换为完整索引;主题正文由模型按需读取。Harness 只负责确定记忆位置并限制写入边界,判断什么值得保存、如何划分主题、如何维护索引都由模型用现有文件工具完成。 + +存量 Agent 不会自动迁移:其 `system_config.yaml` 既无 `memory` 段也无 `{{MEMORY}}` 占位符,因此不会注入任何内容。需要时可在设置页 Prompt 标签插入该占位符,或还原为默认配置。 + ## Vault `agent_state/.vault.toml` 是 Agent 级的环境变量保险库:隐藏文件,落盘权限 0600。 diff --git a/packages/docs/content/server-api.en.md b/packages/docs/content/server-api.en.md index a65faac2..b92dd337 100644 --- a/packages/docs/content/server-api.en.md +++ b/packages/docs/content/server-api.en.md @@ -123,6 +123,11 @@ The paths below omit the `/api/projects/:projectId` prefix. | DELETE | /agents/:agentId | Delete an Agent | | GET / PUT | /agents/:agentId/config | Read / write config (AGENTS.md + system_config.yaml; PUT preserves YAML comments) | | GET / PUT | /agents/:agentId/vault | Vault environment variables (values masked; PUT is a full replace) | +| GET | /agents/:agentId/memory | Workspace Memory overview: the switch, the shared index, one entry per Workspace | +| GET / PUT | /agents/:agentId/memory/index | Read / write the shared index `memory/AGENTS.md` | +| GET | /agents/:agentId/memory/workspaces/:key/files | List one Workspace's topic files (frontmatter + stats) | +| GET / PUT / DELETE | /agents/:agentId/memory/workspaces/:key/files/:name | Read / write / delete one topic file | +| POST | /agents/:agentId/memory/workspaces/:key/files/:name/rename | Rename a topic file within its Workspace | | GET | /agents/:agentId/export | Export the Agent State snapshot (tar.gz download) | | POST | /agents/:agentId/import | Import a snapshot: `{dataBase64, confirm?}`; 409 on version conflict without confirm | | GET / POST | /agents/:agentId/skills | List / install installed Skills | diff --git a/packages/docs/content/server-api.zh.md b/packages/docs/content/server-api.zh.md index c9b31ebe..cad50c87 100644 --- a/packages/docs/content/server-api.zh.md +++ b/packages/docs/content/server-api.zh.md @@ -123,6 +123,11 @@ curl -c cookies.txt -H "Content-Type: application/json" \ | DELETE | /agents/:agentId | 删除 Agent | | GET / PUT | /agents/:agentId/config | 读写配置(AGENTS.md + system_config.yaml,PUT 保留 YAML 注释) | | GET / PUT | /agents/:agentId/vault | Vault 环境变量(值掩码显示;PUT 全表替换) | +| GET | /agents/:agentId/memory | Workspace 记忆总览:开关、统一索引、各 Workspace 条目 | +| GET / PUT | /agents/:agentId/memory/index | 读写统一索引 `memory/AGENTS.md` | +| GET | /agents/:agentId/memory/workspaces/:key/files | 列出单个 Workspace 的主题文件(frontmatter + 文件信息) | +| GET / PUT / DELETE | /agents/:agentId/memory/workspaces/:key/files/:name | 读取 / 写入 / 删除单个主题文件 | +| POST | /agents/:agentId/memory/workspaces/:key/files/:name/rename | 在所属 Workspace 内重命名主题文件 | | GET | /agents/:agentId/export | 导出 Agent State 快照(tar.gz 下载) | | POST | /agents/:agentId/import | 导入快照:`{dataBase64, confirm?}`;版本冲突且未确认时返回 409 | | GET / POST | /agents/:agentId/skills | 已安装 Skill 列表 / 安装 | diff --git a/packages/docs/content/sessions-and-traces.en.md b/packages/docs/content/sessions-and-traces.en.md index cc7bf75f..2df6361a 100644 --- a/packages/docs/content/sessions-and-traces.en.md +++ b/packages/docs/content/sessions-and-traces.en.md @@ -30,7 +30,9 @@ The data root is the `PENGUIN_HOME` environment variable, defaulting to `~/.peng └── agents/ └── / ├── agent_state/ # system_config.yaml, AGENTS.md, .vault.toml, - │ # tools/, memory/, skills/, schedule/ + │ # tools/, skills/, schedule/ + │ └── memory/ # Workspace Memory: the shared AGENTS.md index plus + │ # one directory of topic files per Workspace ├── traces/ │ └── /_.jsonl ├── scratchpad/ # temp files, one subdirectory per Session id (e.g. pasted images) diff --git a/packages/docs/content/sessions-and-traces.zh.md b/packages/docs/content/sessions-and-traces.zh.md index ab9e0025..b3d5eae7 100644 --- a/packages/docs/content/sessions-and-traces.zh.md +++ b/packages/docs/content/sessions-and-traces.zh.md @@ -30,7 +30,9 @@ PenguinHarness 的全部运行数据都落在本地文件系统:配置是可 └── agents/ └── / ├── agent_state/ # system_config.yaml、AGENTS.md、.vault.toml、 - │ # tools/、memory/、skills/、schedule/ + │ # tools/、skills/、schedule/ + │ └── memory/ # Workspace 记忆:统一索引 AGENTS.md, + │ # 以及每个 Workspace 一个主题文件目录 ├── traces/ │ └── /_.jsonl ├── scratchpad/ # 临时文件,按 Session id 建子目录(如粘贴的图片) diff --git a/packages/docs/content/web-app.en.md b/packages/docs/content/web-app.en.md index e0cfb73a..e6997058 100644 --- a/packages/docs/content/web-app.en.md +++ b/packages/docs/content/web-app.en.md @@ -68,11 +68,14 @@ The list page creates and deletes Agents; clicking through opens the `/agents/:a | --- | --- | | Overview | Basic info, export / import of Agent State snapshots, and restoring the default configuration (overwrites customizations, keeping only name/description) | | Prompt | AGENTS.md and system_prompt | +| Memory | Workspace Memory: the Agent-level switch, a Workspace selector, the shared `memory/AGENTS.md` index pinned above that Workspace's topic files, and a Markdown editor | | Runtime | Runtime parameters such as max_turns, model.*, compaction.* | | Tools | Built-in tool table (incl. per-tool call_description switches) and MCP server JSON configuration | | Vault | Environment-variable entries with masked values | | Schedule | Scheduled tasks (TOML-defined): create, edit, toggle, delete | +The Memory tab writes the same files the agent maintains itself. Renaming or deleting a topic file also repoints or removes the index links that named it, so the index never lists a file that is gone. Turning Memory off keeps every file and leaves the tab usable — it only stops Memory from entering the agent's context and from preparing directories for new Sessions. + Scheduled tasks fire on a fixed period (minimum 5 minutes) and run only while the service is running. ## Skill Library (/skills) diff --git a/packages/docs/content/web-app.zh.md b/packages/docs/content/web-app.zh.md index 0efd506a..fc46f7a9 100644 --- a/packages/docs/content/web-app.zh.md +++ b/packages/docs/content/web-app.zh.md @@ -67,11 +67,14 @@ penguin web | --- | --- | | Overview | 基本信息、Agent State 快照的导出 / 导入,以及还原为默认配置(覆盖自定义内容,仅保留名称与描述) | | Prompt | AGENTS.md 与 system_prompt | +| Memory | Workspace 记忆:Agent 级开关、Workspace 选择器、置顶的统一索引 `memory/AGENTS.md` 与该 Workspace 的主题文件列表,以及 Markdown 编辑器 | | Runtime | max_turns、model.*、compaction.* 等运行参数 | | Tools | 内置工具表格(含条目级 call_description 开关)与 MCP Server 的 JSON 配置 | | Vault | 环境变量条目,值以掩码显示 | | Schedule | 定时任务(TOML 定义):创建、编辑、启停、删除 | +Memory 标签页写入的就是 Agent 自己维护的那批文件。重命名或删除主题文件时,索引中指向它的链接会一并改写或清除,索引不会列出已不存在的文件。关闭记忆不会删除任何文件,该页仍可正常管理——只是记忆不再进入 Agent 上下文,也不再为新 Session 准备目录。 + 定时任务按固定周期触发(最短 5 分钟),且仅在服务运行期间执行。 ## Skill 库(/skills) diff --git a/packages/server/src/api/types.ts b/packages/server/src/api/types.ts index d7b4551e..05d6b994 100644 --- a/packages/server/src/api/types.ts +++ b/packages/server/src/api/types.ts @@ -510,6 +510,11 @@ export interface AgentCompactionConfigDto { prompt?: string; } +/** Workspace Memory switch. Reported as the effective value (a config with no `memory` section reads as enabled, matching core). */ +export interface AgentMemoryConfigDto { + enabled: boolean; +} + /** Structured view of system_config.yaml (for the edit form). */ export interface AgentConfigDto { name?: string; @@ -520,6 +525,7 @@ export interface AgentConfigDto { maxTurns?: number; model?: AgentModelConfigDto; compaction?: AgentCompactionConfigDto; + memory: AgentMemoryConfigDto; toolsBuiltin: ToolDefinitionConfig[]; mcpServers: MCPServerConfig[]; } @@ -544,11 +550,87 @@ export interface AgentConfigUpdateRequest { maxTurns?: number; model?: AgentModelConfigDto; compaction?: AgentCompactionConfigDto; + memory?: Partial; toolsBuiltin?: ToolDefinitionConfig[]; mcpServers?: MCPServerConfig[]; }; } +// --------------------------------------------------------------------------- +// Workspace Memory +// --------------------------------------------------------------------------- + +/** One Workspace's Memory directory (`agent_state/memory//`). */ +export interface MemoryWorkspaceInfo { + /** Directory name under `memory/`, and the group heading used in the index. */ + workspaceKey: string; + /** Workspace path the key was derived from, read from the directory's `.workspace` marker; unset for a directory written before the marker existed or edited by hand. */ + workspacePath?: string; + /** Number of Markdown topic files in the directory. */ + fileCount: number; + /** Most recent topic-file mtime in the directory (ISO 8601); unset when the directory holds no topic file. */ + updatedAt?: string; +} + +/** One Memory topic file, as listed (frontmatter only — the body is fetched per file). */ +export interface MemoryFileInfo { + /** File name inside the Workspace Memory directory, e.g. `feedback_testing.md`. */ + name: string; + /** Frontmatter `name`; falls back to the file name. */ + title: string; + /** Frontmatter `description`; empty when the file declares none. */ + description: string; + /** Frontmatter `type`; unset when missing or not one of feedback / project / reference. */ + type?: "feedback" | "project" | "reference"; + /** Frontmatter `updated_at`, verbatim. */ + updatedAt?: string; + /** File size in bytes. */ + size: number; + /** File mtime (ISO 8601). */ + modifiedAt: string; +} + +/** GET …/memory — the tab's landing payload: the switch, the shared index, and every Workspace group. */ +export interface MemoryOverviewResponse { + /** Whether Memory reaches the model context (the Agent-level switch). */ + enabled: boolean; + /** Whether this Agent's prompt template still carries the `{{MEMORY}}` placeholder — an Agent created before Memory shipped has none, so nothing is injected even while enabled. */ + templateInjects: boolean; + /** Absolute path of `agent_state/memory/`. */ + memoryDir: string; + /** Content of the shared index `memory/AGENTS.md` (empty string when it does not exist yet). */ + index: string; + workspaces: MemoryWorkspaceInfo[]; +} + +/** GET …/memory/workspaces/:key/files */ +export interface MemoryFilesResponse { + workspaceKey: string; + files: MemoryFileInfo[]; +} + +/** GET …/memory/workspaces/:key/files/:name */ +export interface MemoryFileResponse { + workspaceKey: string; + file: MemoryFileInfo; + content: string; +} + +/** PUT bodies for a topic file and for the shared index. */ +export interface MemoryFileUpdateRequest { + content: string; +} + +/** POST …/memory/workspaces/:key/files/:name/rename */ +export interface MemoryFileRenameRequest { + name: string; +} + +/** GET|PUT …/memory/index */ +export interface MemoryIndexResponse { + content: string; +} + // --------------------------------------------------------------------------- // Session // --------------------------------------------------------------------------- diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts index bcbffef5..6546f9d6 100644 --- a/packages/server/src/app.ts +++ b/packages/server/src/app.ts @@ -43,6 +43,7 @@ import { membersRoutes } from "./http/routes/members.js"; import { modelsRoutes } from "./http/routes/models.js"; import { chatDefaultsRoutes } from "./http/routes/chat-defaults.js"; import { vaultRoutes } from "./http/routes/vault.js"; +import { memoryRoutes } from "./http/routes/memory.js"; import { scheduleRoutes } from "./http/routes/schedules.js"; import { benchmarksRoutes } from "./http/routes/benchmarks.js"; import { agentSkillsRoutes, skillLibraryRoutes } from "./http/routes/skills.js"; @@ -67,6 +68,7 @@ import { AdminService } from "./services/admin-service.js"; import { DesktopService } from "./services/desktop-service.js"; import { desktopRoutes } from "./http/routes/desktop.js"; import { AgentConfigService } from "./services/agent-config-service.js"; +import { MemoryService } from "./services/memory-service.js"; import { AgentService } from "./services/agent-service.js"; import { BenchmarkService } from "./services/benchmark-service.js"; import { SnapshotService } from "./services/snapshot-service.js"; @@ -103,6 +105,7 @@ export interface AppDeps { projectConfigService: ProjectConfigService; agentService: AgentService; agentConfigService: AgentConfigService; + memoryService: MemoryService; sessionService: SessionService; traceService: TraceService; /** Trace-file index (derived cache + reconciler); routes use it for delete-time coherence. */ @@ -169,6 +172,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides const projectConfigService = new ProjectConfigService(config.root); const agentConfigService = new AgentConfigService(config.root); const agentService = new AgentService(config.root, agentsRepo, agentConfigService); + const memoryService = new MemoryService(config.root, agentConfigService); // Session-origin registry: session_meta is the single source of truth (no DB column); // shared by the manager (subagent registration), the loader (self-heal rebuild), // SessionService (creation / adoption / lazy list resolution), and the Trace index / @@ -295,6 +299,7 @@ export function buildAppDeps(config: ServerConfig, overrides: BuildDepsOverrides projectConfigService, agentService, agentConfigService, + memoryService, sessionService, traceService, traceIndex, @@ -416,6 +421,7 @@ export function createApp(deps: AppDeps): Hono { app.route("/api/projects/:projectId/dirs", dirsRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/config", agentConfigRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/vault", vaultRoutes(deps)); + app.route("/api/projects/:projectId/agents/:agentId/memory", memoryRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/schedules", scheduleRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/benchmarks", benchmarksRoutes(deps)); app.route("/api/projects/:projectId/agents/:agentId/skills", agentSkillsRoutes(deps)); diff --git a/packages/server/src/http/routes/memory.ts b/packages/server/src/http/routes/memory.ts new file mode 100644 index 00000000..ca239973 --- /dev/null +++ b/packages/server/src/http/routes/memory.ts @@ -0,0 +1,87 @@ +/** + * Workspace Memory routes (`agent_state/memory/`), all Project-member operations: + * GET /api/projects/:p/agents/:a/memory # switch, index, Workspace groups + * GET|PUT /api/projects/:p/agents/:a/memory/index # the shared memory/AGENTS.md + * GET /api/projects/:p/agents/:a/memory/workspaces/:key/files # one Workspace's topic files + * GET|PUT|DELETE …/memory/workspaces/:key/files/:name # one topic file + * POST …/memory/workspaces/:key/files/:name/rename # rename within the Workspace + * + * No route accepts an absolute path: a file is addressed by `agentId` + `workspaceKey` + a name + * inside that Workspace, and MemoryService re-checks that the resolved path stayed inside the + * Agent's Memory directory. + */ +import { Hono } from "hono"; +import type { Context } from "hono"; +import type { AppEnv } from "../../auth/middleware.js"; +import type { AppDeps } from "../../app.js"; +import { pathParam, readJson, requireString, requireValidId } from "../validate.js"; + +export function memoryRoutes(deps: AppDeps): Hono { + const app = new Hono(); + + /** Shared preamble: id validation before any path construction (FD-4), then the Project membership check. */ + const scope = (c: Context) => { + const projectId = requireValidId(c, "projectId"); + const agentId = requireValidId(c, "agentId"); + deps.projectService.requireProjectAccess(c.var.user.userId, projectId); + return { projectId, agentId }; + }; + + app.get("/", async (c) => { + const { projectId, agentId } = scope(c); + return c.json(await deps.memoryService.overview(projectId, agentId)); + }); + + app.get("/index", async (c) => { + const { projectId, agentId } = scope(c); + return c.json(await deps.memoryService.readIndex(projectId, agentId)); + }); + + app.put("/index", async (c) => { + const { projectId, agentId } = scope(c); + const body = await readJson(c); + const content = requireString(body, "content", { minLen: 0, label: "content" }); + return c.json(await deps.memoryService.writeIndex(projectId, agentId, content)); + }); + + app.get("/workspaces/:workspaceKey/files", async (c) => { + const { projectId, agentId } = scope(c); + const key = pathParam(c, "workspaceKey"); + return c.json(await deps.memoryService.listFiles(projectId, agentId, key)); + }); + + app.get("/workspaces/:workspaceKey/files/:fileName", async (c) => { + const { projectId, agentId } = scope(c); + const key = pathParam(c, "workspaceKey"); + const name = pathParam(c, "fileName"); + return c.json(await deps.memoryService.readFile(projectId, agentId, key, name)); + }); + + app.put("/workspaces/:workspaceKey/files/:fileName", async (c) => { + const { projectId, agentId } = scope(c); + const key = pathParam(c, "workspaceKey"); + const name = pathParam(c, "fileName"); + const body = await readJson(c); + const content = requireString(body, "content", { minLen: 0, label: "content" }); + return c.json(await deps.memoryService.writeFile(projectId, agentId, key, name, content)); + }); + + app.post("/workspaces/:workspaceKey/files/:fileName/rename", async (c) => { + const { projectId, agentId } = scope(c); + const key = pathParam(c, "workspaceKey"); + const name = pathParam(c, "fileName"); + const body = await readJson(c); + const next = requireString(body, "name", { minLen: 1, maxLen: 200, label: "name" }); + return c.json(await deps.memoryService.renameFile(projectId, agentId, key, name, next)); + }); + + app.delete("/workspaces/:workspaceKey/files/:fileName", async (c) => { + const { projectId, agentId } = scope(c); + const key = pathParam(c, "workspaceKey"); + const name = pathParam(c, "fileName"); + await deps.memoryService.deleteFile(projectId, agentId, key, name); + return c.body(null, 204); + }); + + return app; +} diff --git a/packages/server/src/services/agent-config-service.ts b/packages/server/src/services/agent-config-service.ts index 432a1867..aa5cabbb 100644 --- a/packages/server/src/services/agent-config-service.ts +++ b/packages/server/src/services/agent-config-service.ts @@ -30,6 +30,7 @@ import type { AgentConfigUpdateRequest, AgentModelConfigDto, AgentCompactionConfigDto, + AgentMemoryConfigDto, VaultEntryInfo, VaultResponse, VaultUpdateRequest, @@ -112,6 +113,7 @@ export class AgentConfigService { const parsed = asRecord(parseYaml(systemConfigYaml)); const model = asRecord(parsed.model); const compaction = asRecord(parsed.compaction); + const memory = asRecord(parsed.memory); const tools = asRecord(parsed.tools); let agentsMd = ""; @@ -140,6 +142,11 @@ export class AgentConfigService { : {}), ...(typeof compaction.prompt === "string" ? { prompt: compaction.prompt } : {}), }; + // Memory's effective state, not the literal YAML: core treats anything but an explicit + // `false` as on, so a config predating the section reports enabled — matching what its + // Sessions actually do (whether anything reaches the prompt still depends on the + // template carrying {{MEMORY}}, which the Memory tab reports separately). + const memoryDto: AgentMemoryConfigDto = { enabled: memory.enabled !== false }; const config: AgentConfigDto = { ...(typeof parsed.name === "string" ? { name: parsed.name } : {}), ...(typeof parsed.description === "string" ? { description: parsed.description } : {}), @@ -148,6 +155,7 @@ export class AgentConfigService { ...(typeof parsed.max_turns === "number" ? { maxTurns: parsed.max_turns } : {}), ...(Object.keys(modelDto).length > 0 ? { model: modelDto } : {}), ...(Object.keys(compactionDto).length > 0 ? { compaction: compactionDto } : {}), + memory: memoryDto, toolsBuiltin: Array.isArray(tools.builtin) ? (tools.builtin as ToolDefinitionConfig[]) : [], mcpServers: Array.isArray(tools.mcpServers) ? (tools.mcpServers as MCPServerConfig[]) : [], }; @@ -248,6 +256,11 @@ export class AgentConfigService { setIfProvided(["compaction", "mode"], optionalEnum(compaction, "mode", COMPACTION_MODES)); setIfProvided(["compaction", "prompt"], optionalString(compaction, "prompt")); } + if (cfg.memory !== undefined) { + // The toggle only decides whether Memory reaches the context and whether Workspace + // directories are prepared; existing Memory files are never touched by it. + setIfProvided(["memory", "enabled"], optionalBoolean(asRecord(cfg.memory), "enabled")); + } if (cfg.toolsBuiltin !== undefined) { doc.setIn(["tools", "builtin"], validateToolsBuiltin(cfg.toolsBuiltin)); } diff --git a/packages/server/src/services/memory-service.ts b/packages/server/src/services/memory-service.ts new file mode 100644 index 00000000..b5653c0a --- /dev/null +++ b/packages/server/src/services/memory-service.ts @@ -0,0 +1,327 @@ +/** + * Workspace Memory management (`agent_state/memory/`): the Web App's read/write access to + * what the Agent remembers between Sessions. + * + * The layout is core's (see core's state/memory.ts): one shared index at `memory/AGENTS.md`, + * one directory per Workspace holding Markdown topic files. This service never invents paths + * from client input — a request names an `agentId`, a `workspaceKey` and a file name inside + * that Workspace, each validated against a character rule and then re-checked for containment + * after resolution, so neither `..` nor a symlink can reach outside the Agent's Memory + * directory. + * + * Files are the source of truth and are read fresh on every request (they are small, requests + * are rare, and the model edits the same files from its side). + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { + MEMORY_INDEX_FILENAME, + MEMORY_PLACEHOLDER, + memoryDir, + memoryIndexPath, + parseMemoryFrontmatter, + readWorkspaceMarker, + workspaceMemoryDir, +} from "@prismshadow/penguin-core"; +import type { + MemoryFileInfo, + MemoryFilesResponse, + MemoryFileResponse, + MemoryIndexResponse, + MemoryOverviewResponse, + MemoryWorkspaceInfo, +} from "../api/types.js"; +import { HttpError } from "../http/errors.js"; +import { badRequest } from "../http/validate.js"; +import type { AgentConfigService } from "./agent-config-service.js"; + +/** Workspace directory names: what core's key generator produces, plus the leeway of a hand-made directory. Excludes `.`/`..` and any separator, so the name can never climb out of `memory/`. */ +const WORKSPACE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +/** Topic file names: a Markdown file, no leading dot (dotfiles like `.workspace` are the Harness's, not topics). */ +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/; + +/** Write cap for one Memory file: Memory is an index plus short topics, and a file this size is already far past what belongs in a prompt-adjacent store. */ +export const MAX_MEMORY_FILE_BYTES = 256 * 1024; + +export class MemoryService { + constructor( + private readonly root: string, + private readonly agentConfigService: AgentConfigService, + ) {} + + /** The tab's landing payload: the Agent-level switch, the shared index, and one entry per Workspace directory. */ + async overview(projectId: string, agentId: string): Promise { + const view = await this.agentConfigService.getConfig(projectId, agentId); + return { + enabled: view.config.memory.enabled, + templateInjects: view.config.systemPrompt.includes(MEMORY_PLACEHOLDER), + memoryDir: memoryDir(this.root, projectId, agentId), + index: await this.readIndexContent(projectId, agentId), + workspaces: await this.listWorkspaces(projectId, agentId), + }; + } + + /** Workspace directories under `memory/`, newest activity first (a directory with no topic file yet sorts last, by key). */ + async listWorkspaces(projectId: string, agentId: string): Promise { + const base = memoryDir(this.root, projectId, agentId); + let entries: string[]; + try { + entries = (await fs.readdir(base, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + // No memory/ directory yet (never initialized, or an Agent that has only run in temporary Workspaces). + return []; + } + const workspaces = await Promise.all( + entries.map((key) => this.workspaceInfo(path.join(base, key), key)), + ); + return workspaces.sort((a, b) => { + if (a.updatedAt && b.updatedAt) return b.updatedAt.localeCompare(a.updatedAt); + if (a.updatedAt) return -1; + if (b.updatedAt) return 1; + return a.workspaceKey.localeCompare(b.workspaceKey); + }); + } + + private async workspaceInfo(dir: string, key: string): Promise { + const files = await this.topicFileNames(dir); + let latest = 0; + for (const name of files) { + try { + const stat = await fs.stat(path.join(dir, name)); + latest = Math.max(latest, stat.mtimeMs); + } catch { + // Raced with a delete: leave it out of the timestamp. + } + } + const workspacePath = await readWorkspaceMarker(dir); + return { + workspaceKey: key, + ...(workspacePath !== undefined ? { workspacePath } : {}), + fileCount: files.length, + ...(latest > 0 ? { updatedAt: new Date(latest).toISOString() } : {}), + }; + } + + /** Topic files of one Workspace: Markdown only, so the `.workspace` marker and any stray directory stay out. */ + private async topicFileNames(dir: string): Promise { + try { + return (await fs.readdir(dir, { withFileTypes: true })) + .filter((e) => e.isFile() && FILE_NAME_PATTERN.test(e.name)) + .map((e) => e.name) + .sort((a, b) => a.localeCompare(b)); + } catch { + return []; + } + } + + async listFiles( + projectId: string, + agentId: string, + workspaceKey: string, + ): Promise { + const dir = await this.requireWorkspaceDir(projectId, agentId, workspaceKey); + const names = await this.topicFileNames(dir); + const files: MemoryFileInfo[] = []; + for (const name of names) { + const info = await this.fileInfo(dir, name); + if (info) files.push(info); + } + return { workspaceKey, files }; + } + + async readFile( + projectId: string, + agentId: string, + workspaceKey: string, + fileName: string, + ): Promise { + const dir = await this.requireWorkspaceDir(projectId, agentId, workspaceKey); + const target = this.resolveFile(dir, fileName); + let content: string; + try { + content = await fs.readFile(target, "utf8"); + } catch { + throw new HttpError(404, "memory_file_not_found", `Memory file not found: ${fileName}`); + } + const stat = await fs.stat(target); + return { + workspaceKey, + file: this.describe(fileName, content, stat.size, stat.mtime), + content, + }; + } + + /** Creates or overwrites a topic file. The Workspace directory must already exist — Workspaces come from Sessions, not from this API. */ + async writeFile( + projectId: string, + agentId: string, + workspaceKey: string, + fileName: string, + content: string, + ): Promise { + const dir = await this.requireWorkspaceDir(projectId, agentId, workspaceKey); + const target = this.resolveFile(dir, fileName); + this.assertWritableContent(content); + await fs.writeFile(target, content, "utf8"); + return this.readFile(projectId, agentId, workspaceKey, fileName); + } + + /** Renames a topic file within its Workspace; refuses to clobber an existing name. */ + async renameFile( + projectId: string, + agentId: string, + workspaceKey: string, + fileName: string, + nextName: string, + ): Promise { + const dir = await this.requireWorkspaceDir(projectId, agentId, workspaceKey); + const from = this.resolveFile(dir, fileName); + const to = this.resolveFile(dir, nextName); + if (from === to) return this.readFile(projectId, agentId, workspaceKey, fileName); + if (await pathExists(to)) { + throw new HttpError(409, "memory_file_exists", `Memory file already exists: ${nextName}`); + } + try { + await fs.rename(from, to); + } catch { + throw new HttpError(404, "memory_file_not_found", `Memory file not found: ${fileName}`); + } + return this.readFile(projectId, agentId, workspaceKey, nextName); + } + + /** Deletes a topic file. The index entry pointing at it is the caller's to clean up (the client edits the index in the same flow). */ + async deleteFile( + projectId: string, + agentId: string, + workspaceKey: string, + fileName: string, + ): Promise { + const dir = await this.requireWorkspaceDir(projectId, agentId, workspaceKey); + const target = this.resolveFile(dir, fileName); + try { + await fs.unlink(target); + } catch { + throw new HttpError(404, "memory_file_not_found", `Memory file not found: ${fileName}`); + } + } + + async readIndex(projectId: string, agentId: string): Promise { + await this.agentConfigService.requireExists(projectId, agentId); + return { content: await this.readIndexContent(projectId, agentId) }; + } + + /** Writes the shared index, creating `memory/` if this Agent has never had one. */ + async writeIndex( + projectId: string, + agentId: string, + content: string, + ): Promise { + await this.agentConfigService.requireExists(projectId, agentId); + this.assertWritableContent(content); + await fs.mkdir(memoryDir(this.root, projectId, agentId), { recursive: true }); + await fs.writeFile(memoryIndexPath(this.root, projectId, agentId), content, "utf8"); + return { content }; + } + + private async readIndexContent(projectId: string, agentId: string): Promise { + try { + return await fs.readFile(memoryIndexPath(this.root, projectId, agentId), "utf8"); + } catch { + return ""; + } + } + + private async fileInfo(dir: string, name: string): Promise { + try { + const [content, stat] = await Promise.all([ + fs.readFile(path.join(dir, name), "utf8"), + fs.stat(path.join(dir, name)), + ]); + return this.describe(name, content, stat.size, stat.mtime); + } catch { + // Raced with a delete, or not readable: leave it out of the listing rather than failing the request. + return null; + } + } + + /** One listing entry: frontmatter (falling back to the file name for an unparsed file) plus file stats. */ + private describe(name: string, content: string, size: number, mtime: Date): MemoryFileInfo { + const meta = parseMemoryFrontmatter(content, name); + return { + name, + title: meta?.name ?? name, + description: meta?.description ?? "", + ...(meta?.type !== undefined ? { type: meta.type } : {}), + ...(meta?.updatedAt !== undefined ? { updatedAt: meta.updatedAt } : {}), + size, + modifiedAt: mtime.toISOString(), + }; + } + + private assertWritableContent(content: string): void { + if (Buffer.byteLength(content, "utf8") > MAX_MEMORY_FILE_BYTES) { + throw badRequest(`Memory content exceeds ${MAX_MEMORY_FILE_BYTES} bytes.`); + } + } + + /** + * Validates a workspace key and returns its directory, 404 if it does not exist. The key is + * only ever a single directory name — a Workspace directory is created by a Session, so this + * API never creates one. + */ + private async requireWorkspaceDir( + projectId: string, + agentId: string, + workspaceKey: string, + ): Promise { + await this.agentConfigService.requireExists(projectId, agentId); + if (!WORKSPACE_KEY_PATTERN.test(workspaceKey)) { + throw badRequest("workspaceKey is invalid."); + } + const dir = workspaceMemoryDir(this.root, projectId, agentId, workspaceKey); + try { + if ((await fs.stat(dir)).isDirectory()) return dir; + } catch { + // Fall through to the 404 below. + } + throw new HttpError( + 404, + "memory_workspace_not_found", + `Workspace has no Memory directory: ${workspaceKey}`, + ); + } + + /** + * The absolute path of a topic file inside a Workspace Memory directory. The name must pass + * the character rule, must not be the reserved index name, and the joined path is checked + * for containment — belt and braces, since the rule already excludes separators. + */ + private resolveFile(dir: string, fileName: string): string { + if (!FILE_NAME_PATTERN.test(fileName)) { + throw badRequest("File name must be a Markdown file (letters, digits, . _ - and .md)."); + } + if (fileName === MEMORY_INDEX_FILENAME) { + throw badRequest( + `${MEMORY_INDEX_FILENAME} is the shared Memory index; edit it through the index endpoint.`, + ); + } + const target = path.join(dir, fileName); + const rel = path.relative(dir, target); + if (rel !== fileName || rel.includes(path.sep)) { + throw badRequest("File name must not contain a path."); + } + return target; + } +} + +async function pathExists(target: string): Promise { + try { + await fs.access(target); + return true; + } catch { + return false; + } +} diff --git a/packages/server/test/memory.test.ts b/packages/server/test/memory.test.ts new file mode 100644 index 00000000..ab441b52 --- /dev/null +++ b/packages/server/test/memory.test.ts @@ -0,0 +1,188 @@ +/** + * Integration tests for the Workspace Memory routes (agent_state/memory/): the overview + * reports the Agent-level switch and one entry per Workspace directory, topic files can be + * listed / read / written / renamed / deleted, the shared index is edited through its own + * endpoint, path traversal in a workspace key or file name is rejected, the switch round-trips + * through the Agent config without touching any file, and non-members see 404. + */ +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { memoryDir, workspaceMemoryDir } from "@prismshadow/penguin-core"; +import type { + AgentConfigResponse, + MemoryFileResponse, + MemoryFilesResponse, + MemoryIndexResponse, + MemoryOverviewResponse, + ProjectCreateResponse, +} from "../src/api/types.js"; +import { apiClient, createTestApp, provisionUser } from "./helpers.js"; +import type { TestApp } from "./helpers.js"; + +const WORKSPACE_KEY = "my-app-a81f32c4"; +const TOPIC = `--- +name: Testing conventions +description: how tests are run here +type: feedback +updated_at: 2026-07-30 +--- + +- Integration tests talk to a real database. +`; + +describe("memory api", () => { + let t: TestApp; + let owner: ReturnType; + let outsider: ReturnType; + let projectId: string; + let memoryPath: string; + let configPath: string; + /** The Workspace Memory directory a Session would have created. */ + let wsDir: string; + + beforeEach(async () => { + t = await createTestApp(); + const a = await provisionUser(t.app, "owner_a"); + const c = await provisionUser(t.app, "outsider_c"); + owner = apiClient(t.app, a.cookie); + outsider = apiClient(t.app, c.cookie); + const created = (await ( + await owner.post("/api/projects", { projectId: "owner_a-memory", name: "memory project" }) + ).json()) as ProjectCreateResponse; + projectId = created.project.projectId; + memoryPath = `/api/projects/${projectId}/agents/default_agent/memory`; + configPath = `/api/projects/${projectId}/agents/default_agent/config`; + wsDir = workspaceMemoryDir(t.root, projectId, "default_agent", WORKSPACE_KEY); + await fs.mkdir(wsDir, { recursive: true }); + await fs.writeFile(path.join(wsDir, ".workspace"), "/home/dev/my-app\n", "utf8"); + }); + + afterEach(async () => { + await t.cleanup(); + }); + + const filesPath = (key = WORKSPACE_KEY) => `${memoryPath}/workspaces/${key}/files`; + + it("overview reports the switch, the index, and one entry per Workspace directory", async () => { + await fs.writeFile(path.join(wsDir, "feedback_testing.md"), TOPIC, "utf8"); + await fs.writeFile( + path.join(memoryDir(t.root, projectId, "default_agent"), "AGENTS.md"), + `# Memory\n\n## ${WORKSPACE_KEY}\n\n- [Testing](${WORKSPACE_KEY}/feedback_testing.md) — how tests are run here\n`, + "utf8", + ); + + const body = (await (await owner.get(memoryPath)).json()) as MemoryOverviewResponse; + expect(body.enabled).toBe(true); + // A freshly created Agent gets the current default template, which carries {{MEMORY}}. + expect(body.templateInjects).toBe(true); + expect(body.memoryDir).toBe(memoryDir(t.root, projectId, "default_agent")); + expect(body.index).toContain(`## ${WORKSPACE_KEY}`); + expect(body.workspaces).toHaveLength(1); + expect(body.workspaces[0]).toMatchObject({ + workspaceKey: WORKSPACE_KEY, + workspacePath: "/home/dev/my-app", + fileCount: 1, + }); + expect(body.workspaces[0]?.updatedAt).toBeTruthy(); + }); + + it("lists and reads topic files with their frontmatter, ignoring the .workspace marker", async () => { + await fs.writeFile(path.join(wsDir, "feedback_testing.md"), TOPIC, "utf8"); + + const list = (await (await owner.get(filesPath())).json()) as MemoryFilesResponse; + expect(list.files).toHaveLength(1); + expect(list.files[0]).toMatchObject({ + name: "feedback_testing.md", + title: "Testing conventions", + description: "how tests are run here", + type: "feedback", + updatedAt: "2026-07-30", + }); + + const read = (await ( + await owner.get(`${filesPath()}/feedback_testing.md`) + ).json()) as MemoryFileResponse; + expect(read.content).toBe(TOPIC); + }); + + it("writes, renames and deletes a topic file", async () => { + const created = await owner.put(`${filesPath()}/project_release.md`, { content: TOPIC }); + expect(created.status).toBe(200); + expect(((await created.json()) as MemoryFileResponse).file.title).toBe("Testing conventions"); + + const renamed = await owner.post(`${filesPath()}/project_release.md/rename`, { + name: "project_release_process.md", + }); + expect(renamed.status).toBe(200); + expect(((await renamed.json()) as MemoryFileResponse).file.name).toBe( + "project_release_process.md", + ); + expect(await fs.readdir(wsDir)).toContain("project_release_process.md"); + + // Renaming onto an existing name is refused rather than clobbering it. + await owner.put(`${filesPath()}/other.md`, { content: "# other\n" }); + const clash = await owner.post(`${filesPath()}/other.md/rename`, { + name: "project_release_process.md", + }); + expect(clash.status).toBe(409); + + expect((await owner.delete(`${filesPath()}/project_release_process.md`)).status).toBe(204); + expect(await fs.readdir(wsDir)).not.toContain("project_release_process.md"); + expect((await owner.delete(`${filesPath()}/project_release_process.md`)).status).toBe(404); + }); + + it("edits the shared index through its own endpoint, creating memory/ when absent", async () => { + await fs.rm(memoryDir(t.root, projectId, "default_agent"), { recursive: true, force: true }); + expect( + ((await (await owner.get(`${memoryPath}/index`)).json()) as MemoryIndexResponse).content, + ).toBe(""); + + const put = await owner.put(`${memoryPath}/index`, { content: "# Memory\n" }); + expect(put.status).toBe(200); + expect( + await fs.readFile( + path.join(memoryDir(t.root, projectId, "default_agent"), "AGENTS.md"), + "utf8", + ), + ).toBe("# Memory\n"); + }); + + it("rejects traversal and non-Markdown names, and an unknown Workspace", async () => { + // A key or file name that could climb out of the Memory directory never reaches the filesystem + // (the separator is percent-encoded, so it arrives as one path segment and is ours to reject). + expect((await owner.get(filesPath("..%2Fescape"))).status).toBe(400); + expect((await owner.get(`${filesPath()}/..%2Fescape.md`)).status).toBe(400); + expect((await owner.put(`${filesPath()}/notes.txt`, { content: "x" })).status).toBe(400); + // AGENTS.md inside a Workspace directory would shadow the shared index in the UI. + expect((await owner.put(`${filesPath()}/AGENTS.md`, { content: "x" })).status).toBe(400); + expect((await owner.get(filesPath("never-seen-0badc0de"))).status).toBe(404); + + const escaped = path.join(path.dirname(wsDir), "escape.md"); + await expect(fs.access(escaped)).rejects.toThrow(); + }); + + it("toggles the Agent-level switch through the config route without touching any file", async () => { + await fs.writeFile(path.join(wsDir, "feedback_testing.md"), TOPIC, "utf8"); + + const off = await owner.put(configPath, { config: { memory: { enabled: false } } }); + expect(off.status).toBe(200); + expect(((await off.json()) as AgentConfigResponse).config.memory.enabled).toBe(false); + + // Turning Memory off keeps the files and the management API working; it only stops Memory + // from reaching the model's context. + const body = (await (await owner.get(memoryPath)).json()) as MemoryOverviewResponse; + expect(body.enabled).toBe(false); + expect(body.workspaces[0]?.fileCount).toBe(1); + + const on = await owner.put(configPath, { config: { memory: { enabled: true } } }); + expect(((await on.json()) as AgentConfigResponse).config.memory.enabled).toBe(true); + }); + + it("404s for a non-member on every Memory route", async () => { + expect((await outsider.get(memoryPath)).status).toBe(404); + expect((await outsider.get(`${memoryPath}/index`)).status).toBe(404); + expect((await outsider.get(filesPath())).status).toBe(404); + expect((await outsider.put(`${filesPath()}/x.md`, { content: "x" })).status).toBe(404); + }); +}); diff --git a/packages/web/src/api/endpoints.ts b/packages/web/src/api/endpoints.ts index ee75f6dc..300c9181 100644 --- a/packages/web/src/api/endpoints.ts +++ b/packages/web/src/api/endpoints.ts @@ -35,6 +35,12 @@ import type { MemberAddRequest, MemberAddResponse, MembersResponse, + MemoryFileRenameRequest, + MemoryFileResponse, + MemoryFilesResponse, + MemoryFileUpdateRequest, + MemoryIndexResponse, + MemoryOverviewResponse, MessagesResponse, ModelsResponse, ModelsUpdateRequest, @@ -204,6 +210,72 @@ export const putVault = (projectId: string, agentId: string, body: VaultUpdateRe { method: "PUT", body }, ); +// Workspace Memory (Agent-level, agent_state/memory/) --------------------------------------- + +/** Base path of an Agent's Memory API; the workspace key and file name are single path segments (never a path). */ +const memoryBase = (projectId: string, agentId: string) => + `/api/projects/${encodeURIComponent(projectId)}/agents/${encodeURIComponent(agentId)}/memory`; + +const memoryFilesBase = (projectId: string, agentId: string, workspaceKey: string) => + `${memoryBase(projectId, agentId)}/workspaces/${encodeURIComponent(workspaceKey)}/files`; + +export const getMemoryOverview = (projectId: string, agentId: string) => + apiFetch(memoryBase(projectId, agentId)); + +export const putMemoryIndex = (projectId: string, agentId: string, content: string) => + apiFetch(`${memoryBase(projectId, agentId)}/index`, { + method: "PUT", + body: { content } satisfies MemoryFileUpdateRequest, + }); + +export const getMemoryFiles = (projectId: string, agentId: string, workspaceKey: string) => + apiFetch(memoryFilesBase(projectId, agentId, workspaceKey)); + +export const getMemoryFile = ( + projectId: string, + agentId: string, + workspaceKey: string, + name: string, +) => + apiFetch( + `${memoryFilesBase(projectId, agentId, workspaceKey)}/${encodeURIComponent(name)}`, + ); + +export const putMemoryFile = ( + projectId: string, + agentId: string, + workspaceKey: string, + name: string, + content: string, +) => + apiFetch( + `${memoryFilesBase(projectId, agentId, workspaceKey)}/${encodeURIComponent(name)}`, + { method: "PUT", body: { content } satisfies MemoryFileUpdateRequest }, + ); + +export const renameMemoryFile = ( + projectId: string, + agentId: string, + workspaceKey: string, + name: string, + nextName: string, +) => + apiFetch( + `${memoryFilesBase(projectId, agentId, workspaceKey)}/${encodeURIComponent(name)}/rename`, + { method: "POST", body: { name: nextName } satisfies MemoryFileRenameRequest }, + ); + +export const deleteMemoryFile = ( + projectId: string, + agentId: string, + workspaceKey: string, + name: string, +) => + apiFetch( + `${memoryFilesBase(projectId, agentId, workspaceKey)}/${encodeURIComponent(name)}`, + { method: "DELETE" }, + ); + // Agent & its configuration ---------------------------------------------------------------- export const listAgents = (projectId: string) => diff --git a/packages/web/src/features/agents/agent-settings-page.tsx b/packages/web/src/features/agents/agent-settings-page.tsx index 822b1581..6e44dc18 100644 --- a/packages/web/src/features/agents/agent-settings-page.tsx +++ b/packages/web/src/features/agents/agent-settings-page.tsx @@ -1,8 +1,8 @@ /** - * Agent settings page: seven tabs — + * Agent settings page: eight tabs — * Overview (name/description/State path/active count/State version + snapshot * export-import + restore default configuration), Prompt (AGENTS.md and system_prompt editors + placeholder - * reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in + * reference), Memory (memory-tab.tsx), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in * tools table, MCP Server read-only JSON), Skills (skills-tab.tsx), Vault * (vault-tab.tsx), Schedule (schedules-tab.tsx). * Save = PUT config (sends only the changed keys; YAML comments are preserved @@ -34,11 +34,20 @@ import { Switch } from "../../components/ui/switch"; import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal"; import { Skeleton } from "../../components/ui/skeleton"; import { SkillsTab } from "./skills-tab"; +import { MemoryTab } from "./memory-tab"; import { VaultTab } from "./vault-tab"; import { SchedulesTab } from "./schedules-tab"; import { thinkingLevelOptionsFor } from "../chat/thinking-level"; -type TabKey = "overview" | "prompt" | "runtime" | "tools" | "skills" | "vault" | "schedules"; +type TabKey = + | "overview" + | "prompt" + | "memory" + | "runtime" + | "tools" + | "skills" + | "vault" + | "schedules"; /** * Dropdown rows from a dictionary's [value, description] pairs (exported for unit tests). @@ -91,6 +100,7 @@ export function AgentSettingsPage() { const TABS = [ { key: "overview", label: S.agent.tabOverview }, { key: "prompt", label: S.agent.tabPrompt }, + { key: "memory", label: S.agent.tabMemory }, { key: "runtime", label: S.agent.tabRuntime }, { key: "tools", label: S.agent.tabTools }, { key: "skills", label: S.agent.tabSkills }, @@ -232,6 +242,7 @@ export function AgentSettingsPage() { /> )} {tab === "prompt" && } + {tab === "memory" && } {tab === "runtime" && } {tab === "tools" && } {tab === "skills" && } diff --git a/packages/web/src/features/agents/memory-tab.tsx b/packages/web/src/features/agents/memory-tab.tsx new file mode 100644 index 00000000..ab3729af --- /dev/null +++ b/packages/web/src/features/agents/memory-tab.tsx @@ -0,0 +1,562 @@ +/** + * Agent settings page "Memory" tab: Workspace Memory (agent_state/memory/) — the Agent-level + * switch, a Workspace selector, that Workspace's topic files, and a Markdown editor. + * + * The shared index (`memory/AGENTS.md`) is pinned above the topic files and opens with the + * caret parked on the selected Workspace's heading, since one index covers every Workspace. + * Renaming or deleting a topic file also rewrites the index links that pointed at it — the + * link form is exact (`](/)`), so this stays a mechanical edit and never + * rewrites prose the model wrote. + * + * Turning Memory off keeps every file and leaves this page fully usable; it only stops Memory + * from entering the Agent's context and from preparing directories for new Sessions. + */ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + MemoryFileInfo, + MemoryOverviewResponse, + MemoryWorkspaceInfo, +} from "@prismshadow/penguin-server/api"; +import * as api from "../../api/endpoints"; +import { S } from "../../lib/strings"; +import { apiErrorText } from "../../lib/api-error"; +import { useProject } from "../../state/project"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; +import { Input, Textarea } from "../../components/ui/input"; +import { Modal } from "../../components/ui/modal"; +import { Select } from "../../components/ui/select"; +import { Switch } from "../../components/ui/switch"; +import { ConfirmModal } from "../../components/ui/confirm-modal"; +import { SkeletonList } from "../../components/ui/skeleton"; +import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast"; + +/** Topic file names, matching the server's rule (a Markdown file, no path, no leading dot). */ +const FILE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\.md$/; + +/** The pinned index entry is selected as `null`; a string selects that topic file. */ +type Selection = { kind: "index" } | { kind: "file"; name: string }; + +const INDEX_SELECTION: Selection = { kind: "index" }; + +/** Frontmatter skeleton for a new topic file, so a hand-created file starts out listable. */ +function newFileTemplate(fileName: string): string { + const title = fileName.replace(/\.md$/, "").replace(/[_-]+/g, " "); + const today = new Date().toISOString().slice(0, 10); + return `---\nname: ${title}\ndescription: \ntype: project\nupdated_at: ${today}\n---\n\n`; +} + +/** Topic types a file may declare (mirrors core's MEMORY_TOPIC_TYPES; `user` is deliberately not one). */ +const TOPIC_TYPES = ["feedback", "project", "reference"] as const; + +/** + * Checks a topic file's frontmatter before it is saved (exported for unit tests), so the file stays listable — a file + * whose `name` or `type` is missing shows up in the list as a bare file name with no type, and + * is that much harder for the model to judge from the index. Returns the problem to show next + * to the editor, or undefined when the frontmatter is complete. The shared index is exempt: + * it is not a topic file and carries no frontmatter. + */ +export function frontmatterProblem(content: string): string | undefined { + const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(content.replace(/^/, "")); + if (!match) return S.memory.frontmatterMissing; + const fields = new Map(); + for (const line of match[1]!.split(/\r?\n/)) { + const idx = line.indexOf(":"); + if (idx > 0) fields.set(line.slice(0, idx).trim(), line.slice(idx + 1).trim()); + } + if (!fields.get("name")) return S.memory.frontmatterNameRequired; + const type = fields.get("type"); + if (!type || !(TOPIC_TYPES as readonly string[]).includes(type)) { + return S.memory.frontmatterTypeInvalid; + } + return undefined; +} + +/** Index links to one topic file are exactly `](/)`; used to keep the index in step with a rename or delete. */ +function indexLink(workspaceKey: string, fileName: string): string { + return `](${workspaceKey}/${fileName})`; +} + +/** Drops every index line pointing at a deleted topic file (exported for unit tests). */ +export function indexWithoutFile(index: string, workspaceKey: string, fileName: string): string { + const link = indexLink(workspaceKey, fileName); + return index + .split("\n") + .filter((line) => !line.includes(link)) + .join("\n"); +} + +/** Repoints every index link from an old topic file name to its new one (exported for unit tests). */ +export function indexWithRenamedFile( + index: string, + workspaceKey: string, + fileName: string, + nextName: string, +): string { + return index.split(indexLink(workspaceKey, fileName)).join(indexLink(workspaceKey, nextName)); +} + +/** Offset of a Workspace's group heading in the index, so opening the index lands on the right group; -1 when the index has no heading for it yet (exported for unit tests). */ +export function headingOffset(index: string, workspaceKey: string): number { + const match = new RegExp(`^#{1,6}\\s+${workspaceKey}\\s*$`, "m").exec(index); + return match ? match.index : -1; +} + +export function MemoryTab({ agentId }: { agentId: string }) { + const { currentProject } = useProject(); + const projectId = currentProject?.projectId ?? null; + + const [overview, setOverview] = useState(null); + // Tab-level error is the initial load failure only; every later action reports via toast. + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const [workspaceKey, setWorkspaceKey] = useState(null); + const [files, setFiles] = useState(null); + const [selection, setSelection] = useState(INDEX_SELECTION); + /** Editor buffer and the content it was loaded from (their difference is the unsaved change). */ + const [draft, setDraft] = useState(""); + /** Frontmatter problem blocking the current save (cleared as soon as the buffer changes). */ + const [draftError, setDraftError] = useState(undefined); + const [loaded, setLoaded] = useState(""); + const editorRef = useRef(null); + + const [creating, setCreating] = useState(false); + const [nameInput, setNameInput] = useState(""); + const [nameError, setNameError] = useState(undefined); + const [renaming, setRenaming] = useState(false); + const [deleting, setDeleting] = useState(null); + + const load = useCallback(async () => { + if (!projectId || !agentId) return; + setOverview(null); + setError(null); + try { + const res = await api.getMemoryOverview(projectId, agentId); + setOverview(res); + setWorkspaceKey(res.workspaces[0]?.workspaceKey ?? null); + setSelection(INDEX_SELECTION); + setDraft(res.index); + setLoaded(res.index); + } catch (e) { + setError(apiErrorText(e)); + } + }, [projectId, agentId]); + + useEffect(() => { + void load(); + }, [load]); + + // Workspace switch: reload its topic files (the pinned index stays selected). + useEffect(() => { + if (!projectId || !agentId || workspaceKey === null) { + setFiles(null); + return; + } + let cancelled = false; + setFiles(null); + api + .getMemoryFiles(projectId, agentId, workspaceKey) + .then((res) => { + if (!cancelled) setFiles(res.files); + }) + .catch((e: unknown) => { + if (!cancelled) toastError(apiErrorText(e)); + }); + return () => { + cancelled = true; + }; + }, [projectId, agentId, workspaceKey]); + + const dirty = draft !== loaded; + + /** Opens the shared index, parking the caret on the selected Workspace's group heading. */ + const openIndex = (content: string) => { + setSelection(INDEX_SELECTION); + setDraft(content); + setLoaded(content); + const at = workspaceKey ? headingOffset(content, workspaceKey) : -1; + if (at < 0) return; + requestAnimationFrame(() => { + const el = editorRef.current; + if (!el) return; + el.focus(); + el.setSelectionRange(at, at); + // Approximate scroll: put the heading near the top of the visible area. + const lineHeight = el.scrollHeight / Math.max(1, el.value.split("\n").length); + el.scrollTop = content.slice(0, at).split("\n").length * lineHeight - lineHeight * 2; + }); + }; + + const openFile = async (name: string) => { + if (!projectId || !workspaceKey) return; + try { + const res = await api.getMemoryFile(projectId, agentId, workspaceKey, name); + setSelection({ kind: "file", name }); + setDraft(res.content); + setLoaded(res.content); + } catch (e) { + toastError(apiErrorText(e)); + } + }; + + /** Refreshes the current Workspace's file list (after a create / rename / delete). */ + const reloadFiles = async (): Promise => { + if (!projectId || !workspaceKey) return []; + const res = await api.getMemoryFiles(projectId, agentId, workspaceKey); + setFiles(res.files); + return res.files; + }; + + /** Writes the index and keeps the local copy in step (the overview caches it for the pinned entry). */ + const persistIndex = async (content: string): Promise => { + if (!projectId) return; + await api.putMemoryIndex(projectId, agentId, content); + setOverview((prev) => (prev ? { ...prev, index: content } : prev)); + }; + + const save = async () => { + if (!projectId || !dirty) { + toastInfo(S.common.noChangesToSave); + return; + } + // A topic file must stay listable: the index is what the model reads, and it is built from + // these fields. The shared index itself carries no frontmatter and is exempt. + const problem = selection.kind === "file" ? frontmatterProblem(draft) : undefined; + setDraftError(problem); + if (problem) return; + setBusy(true); + try { + if (selection.kind === "index") { + await persistIndex(draft); + } else { + if (!workspaceKey) return; + await api.putMemoryFile(projectId, agentId, workspaceKey, selection.name, draft); + await reloadFiles(); + } + setLoaded(draft); + toastSuccess(S.common.saved); + } catch (e) { + toastError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + /** Validates a topic file name for the create / rename dialogs; returns the error message, or undefined when valid. */ + const nameProblem = (name: string): string | undefined => { + if (!name) return S.common.requiredField; + if (!FILE_NAME_PATTERN.test(name)) return S.memory.fileNameInvalid; + if (files?.some((f) => f.name === name)) return S.memory.fileNameTaken; + return undefined; + }; + + const createFile = async () => { + const name = nameInput.trim(); + const problem = nameProblem(name); + if (problem || !projectId || !workspaceKey) { + setNameError(problem); + return; + } + setBusy(true); + try { + const content = newFileTemplate(name); + await api.putMemoryFile(projectId, agentId, workspaceKey, name, content); + await reloadFiles(); + setSelection({ kind: "file", name }); + setDraft(content); + setLoaded(content); + setCreating(false); + toastSuccess(S.common.saved); + } catch (e) { + setNameError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + const renameFile = async () => { + const next = nameInput.trim(); + const problem = nameProblem(next); + if (problem || !projectId || !workspaceKey || selection.kind !== "file") { + setNameError(problem); + return; + } + const from = selection.name; + setBusy(true); + try { + await api.renameMemoryFile(projectId, agentId, workspaceKey, from, next); + // Keep the index pointing at the file under its new name. + const index = overview?.index ?? ""; + const rewritten = indexWithRenamedFile(index, workspaceKey, from, next); + if (rewritten !== index) await persistIndex(rewritten); + await reloadFiles(); + setSelection({ kind: "file", name: next }); + setRenaming(false); + toastSuccess(S.common.saved); + } catch (e) { + setNameError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + const confirmDelete = async () => { + if (!projectId || !workspaceKey || deleting === null) return; + setBusy(true); + try { + await api.deleteMemoryFile(projectId, agentId, workspaceKey, deleting); + // Drop the index entries that pointed at it, so the index never lists a missing file. + const index = overview?.index ?? ""; + const rewritten = indexWithoutFile(index, workspaceKey, deleting); + if (rewritten !== index) await persistIndex(rewritten); + const remaining = await reloadFiles(); + if (selection.kind === "file" && selection.name === deleting) { + openIndex(rewritten); + } + setDeleting(null); + toastSuccess(S.memory.deleteDone); + if (remaining.length === 0) toastInfo(S.memory.noFiles); + } catch (e) { + toastError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + const toggleEnabled = async (next: boolean) => { + if (!projectId) return; + setBusy(true); + try { + const res = await api.putAgentConfig(projectId, agentId, { + config: { memory: { enabled: next } }, + }); + setOverview((prev) => (prev ? { ...prev, enabled: res.config.memory.enabled } : prev)); + toastSuccess(S.common.saved); + } catch (e) { + toastError(apiErrorText(e)); + } finally { + setBusy(false); + } + }; + + if (!projectId) return null; + if (error) return

{error}

; + if (!overview) return ; + + const workspace = overview.workspaces.find((w) => w.workspaceKey === workspaceKey) ?? null; + + return ( +
+

{S.memory.desc}

+ + {/* Agent-level switch: content management below stays available either way. */} +
+
+

{S.memory.enable}

+

+ {overview.enabled ? S.memory.enabledHint : S.memory.disabledHint} +

+ {overview.enabled && !overview.templateInjects && ( +

+ {S.memory.templateMissingHint} +

+ )} +
+ void toggleEnabled(next)} + aria-label={S.memory.enable} + /> +
+ +

{overview.memoryDir}

+ + {overview.workspaces.length === 0 ? ( +

{S.memory.noWorkspaces}

+ ) : ( + + )} + + {/* File list: the shared index pinned on top, then this Workspace's topic files. */} +
+ + + {files === null ? ( +
+ +
+ ) : files.length === 0 ? ( +

{S.memory.noFiles}

+ ) : ( + files.map((file) => ( + + )) + )} +
+ + {workspace && ( +
+ + {selection.kind === "file" && ( + <> + + + + )} +
+ )} + +