From 1a947a21d6e6aa7d10da1c0783947971f8514c87 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 13:29:54 -0600 Subject: [PATCH] fix: support memory.md in bootstrap files (#2318) (thanks @czekaj) --- CHANGELOG.md | 1 + src/agents/workspace.test.ts | 171 +++++++---------------------------- src/agents/workspace.ts | 43 ++++++++- 3 files changed, 73 insertions(+), 142 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffcd26721..4ce49a181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts index 8c4f5a0de..ff589a193 100644 --- a/src/agents/workspace.test.ts +++ b/src/agents/workspace.test.ts @@ -1,152 +1,49 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; import { describe, expect, it } from "vitest"; -import { runCommandWithTimeout } from "../process/exec.js"; -import type { WorkspaceBootstrapFile } from "./workspace.js"; + import { - DEFAULT_AGENTS_FILENAME, - DEFAULT_BOOTSTRAP_FILENAME, - DEFAULT_HEARTBEAT_FILENAME, - DEFAULT_IDENTITY_FILENAME, - DEFAULT_SOUL_FILENAME, - DEFAULT_TOOLS_FILENAME, - DEFAULT_USER_FILENAME, - ensureAgentWorkspace, - filterBootstrapFilesForSession, + DEFAULT_MEMORY_ALT_FILENAME, + DEFAULT_MEMORY_FILENAME, + loadWorkspaceBootstrapFiles, } from "./workspace.js"; +import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js"; -describe("ensureAgentWorkspace", () => { - it("creates directory and bootstrap files when missing", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); - const nested = path.join(dir, "nested"); - const result = await ensureAgentWorkspace({ - dir: nested, - ensureBootstrapFiles: true, - }); - expect(result.dir).toBe(path.resolve(nested)); - expect(result.agentsPath).toBe(path.join(path.resolve(nested), "AGENTS.md")); - expect(result.agentsPath).toBeDefined(); - if (!result.agentsPath) throw new Error("agentsPath missing"); - const content = await fs.readFile(result.agentsPath, "utf-8"); - expect(content).toContain("# AGENTS.md"); +describe("loadWorkspaceBootstrapFiles", () => { + it("includes MEMORY.md when present", async () => { + const tempDir = await makeTempWorkspace("clawdbot-workspace-"); + await writeWorkspaceFile({ dir: tempDir, name: "MEMORY.md", content: "memory" }); - const identity = path.join(path.resolve(nested), "IDENTITY.md"); - const user = path.join(path.resolve(nested), "USER.md"); - const heartbeat = path.join(path.resolve(nested), "HEARTBEAT.md"); - const bootstrap = path.join(path.resolve(nested), "BOOTSTRAP.md"); - await expect(fs.stat(identity)).resolves.toBeDefined(); - await expect(fs.stat(user)).resolves.toBeDefined(); - await expect(fs.stat(heartbeat)).resolves.toBeDefined(); - await expect(fs.stat(bootstrap)).resolves.toBeDefined(); + const files = await loadWorkspaceBootstrapFiles(tempDir); + const memoryEntries = files.filter((file) => + [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), + ); + + expect(memoryEntries).toHaveLength(1); + expect(memoryEntries[0]?.missing).toBe(false); + expect(memoryEntries[0]?.content).toBe("memory"); }); - it("initializes a git repo for brand-new workspaces when git is available", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); - const nested = path.join(dir, "nested"); - const gitAvailable = await runCommandWithTimeout(["git", "--version"], { timeoutMs: 2_000 }) - .then((res) => res.code === 0) - .catch(() => false); - if (!gitAvailable) return; + it("includes memory.md when MEMORY.md is absent", async () => { + const tempDir = await makeTempWorkspace("clawdbot-workspace-"); + await writeWorkspaceFile({ dir: tempDir, name: "memory.md", content: "alt" }); - await ensureAgentWorkspace({ - dir: nested, - ensureBootstrapFiles: true, - }); + const files = await loadWorkspaceBootstrapFiles(tempDir); + const memoryEntries = files.filter((file) => + [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), + ); - await expect(fs.stat(path.join(nested, ".git"))).resolves.toBeDefined(); + expect(memoryEntries).toHaveLength(1); + expect(memoryEntries[0]?.missing).toBe(false); + expect(memoryEntries[0]?.content).toBe("alt"); }); - it("does not initialize git when workspace already exists", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); - await fs.writeFile(path.join(dir, "AGENTS.md"), "custom", "utf-8"); + it("omits memory entries when no memory files exist", async () => { + const tempDir = await makeTempWorkspace("clawdbot-workspace-"); - await ensureAgentWorkspace({ - dir, - ensureBootstrapFiles: true, - }); + const files = await loadWorkspaceBootstrapFiles(tempDir); + const memoryEntries = files.filter((file) => + [DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME].includes(file.name), + ); - await expect(fs.stat(path.join(dir, ".git"))).rejects.toBeDefined(); - }); - - it("does not overwrite existing AGENTS.md", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); - const agentsPath = path.join(dir, "AGENTS.md"); - await fs.writeFile(agentsPath, "custom", "utf-8"); - await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); - expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom"); - }); - - it("does not recreate BOOTSTRAP.md once workspace exists", async () => { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-")); - const agentsPath = path.join(dir, "AGENTS.md"); - const bootstrapPath = path.join(dir, "BOOTSTRAP.md"); - - await fs.writeFile(agentsPath, "custom", "utf-8"); - await fs.rm(bootstrapPath, { force: true }); - - await ensureAgentWorkspace({ dir, ensureBootstrapFiles: true }); - - await expect(fs.stat(bootstrapPath)).rejects.toBeDefined(); - }); -}); - -describe("filterBootstrapFilesForSession", () => { - const files: WorkspaceBootstrapFile[] = [ - { - name: DEFAULT_AGENTS_FILENAME, - path: "/tmp/AGENTS.md", - content: "agents", - missing: false, - }, - { - name: DEFAULT_SOUL_FILENAME, - path: "/tmp/SOUL.md", - content: "soul", - missing: false, - }, - { - name: DEFAULT_TOOLS_FILENAME, - path: "/tmp/TOOLS.md", - content: "tools", - missing: false, - }, - { - name: DEFAULT_IDENTITY_FILENAME, - path: "/tmp/IDENTITY.md", - content: "identity", - missing: false, - }, - { - name: DEFAULT_USER_FILENAME, - path: "/tmp/USER.md", - content: "user", - missing: false, - }, - { - name: DEFAULT_HEARTBEAT_FILENAME, - path: "/tmp/HEARTBEAT.md", - content: "heartbeat", - missing: false, - }, - { - name: DEFAULT_BOOTSTRAP_FILENAME, - path: "/tmp/BOOTSTRAP.md", - content: "bootstrap", - missing: false, - }, - ]; - - it("keeps full bootstrap set for non-subagent sessions", () => { - const result = filterBootstrapFilesForSession(files, "agent:main:session:abc"); - expect(result.map((file) => file.name)).toEqual(files.map((file) => file.name)); - }); - - it("limits bootstrap files for subagent sessions", () => { - const result = filterBootstrapFilesForSession(files, "agent:main:subagent:abc"); - expect(result.map((file) => file.name)).toEqual([ - DEFAULT_AGENTS_FILENAME, - DEFAULT_TOOLS_FILENAME, - ]); + expect(memoryEntries).toHaveLength(0); }); }); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts index 8e5fb8035..8692977eb 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -27,6 +27,7 @@ export const DEFAULT_USER_FILENAME = "USER.md"; export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md"; export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md"; export const DEFAULT_MEMORY_FILENAME = "MEMORY.md"; +export const DEFAULT_MEMORY_ALT_FILENAME = "memory.md"; const TEMPLATE_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -63,7 +64,8 @@ export type WorkspaceBootstrapFileName = | typeof DEFAULT_USER_FILENAME | typeof DEFAULT_HEARTBEAT_FILENAME | typeof DEFAULT_BOOTSTRAP_FILENAME - | typeof DEFAULT_MEMORY_FILENAME; + | typeof DEFAULT_MEMORY_FILENAME + | typeof DEFAULT_MEMORY_ALT_FILENAME; export type WorkspaceBootstrapFile = { name: WorkspaceBootstrapFileName; @@ -186,6 +188,39 @@ export async function ensureAgentWorkspace(params?: { }; } +async function resolveMemoryBootstrapEntries(resolvedDir: string): Promise< + Array<{ name: WorkspaceBootstrapFileName; filePath: string }> +> { + const candidates: WorkspaceBootstrapFileName[] = [ + DEFAULT_MEMORY_FILENAME, + DEFAULT_MEMORY_ALT_FILENAME, + ]; + const entries: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; + for (const name of candidates) { + const filePath = path.join(resolvedDir, name); + try { + await fs.access(filePath); + entries.push({ name, filePath }); + } catch { + // optional + } + } + if (entries.length <= 1) return entries; + + const seen = new Set(); + const deduped: Array<{ name: WorkspaceBootstrapFileName; filePath: string }> = []; + for (const entry of entries) { + let key = entry.filePath; + try { + key = await fs.realpath(entry.filePath); + } catch {} + if (seen.has(key)) continue; + seen.add(key); + deduped.push(entry); + } + return deduped; +} + export async function loadWorkspaceBootstrapFiles(dir: string): Promise { const resolvedDir = resolveUserPath(dir); @@ -221,12 +256,10 @@ export async function loadWorkspaceBootstrapFiles(dir: string): Promise