diff --git a/packages/browseros-agent/apps/server/src/agent/prompt.ts b/packages/browseros-agent/apps/server/src/agent/prompt.ts index cde9a4737..fbd59767d 100644 --- a/packages/browseros-agent/apps/server/src/agent/prompt.ts +++ b/packages/browseros-agent/apps/server/src/agent/prompt.ts @@ -415,8 +415,10 @@ You remember things across sessions using two tiers: **Core memory** (\`CORE.md\`) — permanent facts about the user that persist forever. Use for: name, job, location, preferences, relationships, recurring projects, important dates. - \`memory_read_core\` → read all permanent facts -- \`memory_save_core\` → save permanent facts - **IMPORTANT**: \`memory_save_core\` overwrites the entire file. Always call \`memory_read_core\` first, merge new facts into existing content, then save the full result. +- \`memory_update_core\` → add or remove facts from core memory + Pass \`additions\` (array of new facts) and/or \`removals\` (array of facts to remove by substring match). + This tool handles merging internally — you never need to rewrite the full file. + Do NOT use \`memory_save_core\` — it is deprecated and risks overwriting all existing memories. **Daily memory** — short-lived notes stored in daily files (\`YYYY-MM-DD.md\`). Auto-expire after 30 days. Use for: what the user worked on today, transient context, meeting notes, draft ideas, things to follow up on. diff --git a/packages/browseros-agent/apps/server/src/tools/memory/build-toolset.ts b/packages/browseros-agent/apps/server/src/tools/memory/build-toolset.ts index 76a6f74e9..8f1b2a24f 100644 --- a/packages/browseros-agent/apps/server/src/tools/memory/build-toolset.ts +++ b/packages/browseros-agent/apps/server/src/tools/memory/build-toolset.ts @@ -1,8 +1,8 @@ import type { ToolSet } from 'ai' import { createReadCoreTool } from './read-core' import { createSoulReadTool } from './read-soul' -import { createSaveCoreTool } from './save-core' import { createMemorySearchTool } from './search' +import { createUpdateCoreTool } from './update-core' import { createSoulUpdateTool } from './update-soul' import { createMemoryWriteTool } from './write' @@ -11,7 +11,7 @@ export function buildMemoryToolSet(): ToolSet { memory_search: createMemorySearchTool(), memory_write: createMemoryWriteTool(), memory_read_core: createReadCoreTool(), - memory_save_core: createSaveCoreTool(), + memory_update_core: createUpdateCoreTool(), soul_read: createSoulReadTool(), soul_update: createSoulUpdateTool(), } diff --git a/packages/browseros-agent/apps/server/src/tools/memory/save-core.ts b/packages/browseros-agent/apps/server/src/tools/memory/save-core.ts deleted file mode 100644 index 8a985e5f5..000000000 --- a/packages/browseros-agent/apps/server/src/tools/memory/save-core.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' -import { getCoreMemoryPath } from '../../lib/browseros-dir' -import { executeWithMetrics, toModelOutput } from '../filesystem/utils' - -const TOOL_NAME = 'memory_save_core' - -export function createSaveCoreTool() { - return tool({ - description: - 'Write or update core memories. IMPORTANT: This overwrites the entire core memory file. Always call memory_read_core first, merge your changes into the existing content, then save the full result.', - inputSchema: z.object({ - content: z.string().describe('The full core memory content to save'), - }), - execute: (params) => - executeWithMetrics(TOOL_NAME, async () => { - await Bun.write(getCoreMemoryPath(), params.content) - return { text: 'Core memories updated.' } - }), - toModelOutput, - }) -} diff --git a/packages/browseros-agent/apps/server/src/tools/memory/search.ts b/packages/browseros-agent/apps/server/src/tools/memory/search.ts index 1a417bd3e..2cf10e90d 100644 --- a/packages/browseros-agent/apps/server/src/tools/memory/search.ts +++ b/packages/browseros-agent/apps/server/src/tools/memory/search.ts @@ -28,10 +28,20 @@ async function loadMemoryEntries(): Promise { for (const file of mdFiles) { try { const content = await readFile(join(memoryDir, file), 'utf-8') + + // Section-level entries (## delimited blocks) const sections = content.split(/^## /m).filter(Boolean) for (const section of sections) { entries.push({ source: file, content: `## ${section}`.trim() }) } + + // Line-level entries (individual non-empty lines) + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (trimmed && !trimmed.startsWith('#')) { + entries.push({ source: file, content: trimmed }) + } + } } catch { // skip unreadable files } @@ -84,11 +94,28 @@ export function createMemorySearchTool() { } } - const sorted = [...bestScores.entries()] - .sort((a, b) => a[1].score - b[1].score) - .slice(0, 10) + const sorted = [...bestScores.entries()].sort( + (a, b) => a[1].score - b[1].score, + ) - const formatted = sorted + // Deduplicate: skip line-level hits already covered by a + // higher-ranked section from the same file. + const seenSections: Array<{ source: string; content: string }> = [] + const deduped: typeof sorted = [] + for (const item of sorted) { + const { source, content } = item[0] + const coveredBySection = seenSections.some( + (s) => s.source === source && s.content.includes(content), + ) + if (coveredBySection) continue + if (content.startsWith('## ')) { + seenSections.push({ source, content }) + } + deduped.push(item) + } + + const formatted = deduped + .slice(0, 10) .map(([entry, { score }]) => { const relevance = (1 - score).toFixed(2) return `[${entry.source}] (relevance: ${relevance})\n${entry.content}` diff --git a/packages/browseros-agent/apps/server/src/tools/memory/update-core.ts b/packages/browseros-agent/apps/server/src/tools/memory/update-core.ts new file mode 100644 index 000000000..06c72287b --- /dev/null +++ b/packages/browseros-agent/apps/server/src/tools/memory/update-core.ts @@ -0,0 +1,74 @@ +import { tool } from 'ai' +import { z } from 'zod' +import { getCoreMemoryPath } from '../../lib/browseros-dir' +import { executeWithMetrics, toModelOutput } from '../filesystem/utils' + +const TOOL_NAME = 'memory_update_core' + +export function createUpdateCoreTool() { + return tool({ + description: + 'Add or remove facts from core memory. Handles merging internally — you never need to rewrite the full file. Pass additions to store new facts and/or removals to delete facts by substring match.', + inputSchema: z.object({ + additions: z + .array(z.string()) + .optional() + .describe('New facts to add to core memory. Each string is one fact.'), + removals: z + .array(z.string()) + .optional() + .describe( + 'Facts to remove from core memory. Each string is matched as a case-insensitive substring against existing lines.', + ), + }), + execute: (params) => + executeWithMetrics(TOOL_NAME, async () => { + const corePath = getCoreMemoryPath() + const file = Bun.file(corePath) + + let existing = '' + if (await file.exists()) { + existing = await file.text() + } + + let lines = existing.split('\n') + + // Remove matching entries + let removedCount = 0 + if (params.removals?.length) { + for (const removal of params.removals) { + const lower = removal.toLowerCase() + const before = lines.length + lines = lines.filter((line) => !line.toLowerCase().includes(lower)) + removedCount += before - lines.length + } + } + + // Append new facts (skip duplicates) + let addedCount = 0 + if (params.additions?.length) { + if (lines.length > 0 && lines[lines.length - 1].trim() !== '') { + lines.push('') + } + for (const fact of params.additions) { + const factLower = fact.toLowerCase() + const alreadyExists = lines.some((l) => + l.toLowerCase().includes(factLower), + ) + if (!alreadyExists) { + lines.push(`- ${fact}`) + addedCount++ + } + } + } + + const result = `${lines.join('\n').trim()}\n` + await Bun.write(corePath, result) + + return { + text: `Core memory updated. ${addedCount} fact(s) added, ${removedCount} line(s) removed.`, + } + }), + toModelOutput, + }) +} diff --git a/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts b/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts index e9d680c10..e0ff94eac 100644 --- a/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts +++ b/packages/browseros-agent/apps/server/tests/agent/prompt.test.ts @@ -289,7 +289,7 @@ describe('mode-aware framing', () => { // The agent shouldn't even see memory tool instructions. const prompt = buildChatMode() expect(prompt).not.toContain('') - expect(prompt).not.toContain('memory_save_core') + expect(prompt).not.toContain('memory_update_core') expect(prompt).not.toContain('soul_update') }) @@ -687,17 +687,25 @@ describe('memory and identity', () => { const prompt = buildRegular() expect(prompt).toContain('memory_search') expect(prompt).toContain('memory_write') - expect(prompt).toContain('memory_save_core') + expect(prompt).toContain('memory_update_core') expect(prompt).toContain('memory_read_core') }) - it('includes critical overwrite warning for memory_save_core', () => { - // Why: memory_save_core overwrites the entire file. Without the - // "read first, merge, then save" instruction, the agent will - // silently destroy existing memories when saving new ones. + it('discourages use of deprecated memory_save_core', () => { + // Why: memory_save_core overwrites the entire file and risks data loss. + // The prompt must steer the agent to memory_update_core instead. const prompt = buildRegular() - expect(prompt).toContain('memory_save_core` overwrites the entire file') - expect(prompt).toContain('Always call `memory_read_core` first') + expect(prompt).toContain('Do NOT use `memory_save_core`') + expect(prompt).toContain('deprecated') + }) + + it('explains memory_update_core merge-based API', () => { + // Why: The agent must understand that memory_update_core handles merging + // internally via additions/removals — it should never rewrite the full file. + const prompt = buildRegular() + expect(prompt).toContain('additions') + expect(prompt).toContain('removals') + expect(prompt).toContain('handles merging internally') }) it('explains two-tier memory model with core and daily distinction', () => {