mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
fix: improved memory tools (#495)
* fix: new prompt update tool * fix: memory search tool * fix: all review comments * chore: remove dead code
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -28,10 +28,20 @@ async function loadMemoryEntries(): Promise<MemoryEntry[]> {
|
||||
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}`
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -289,7 +289,7 @@ describe('mode-aware framing', () => {
|
||||
// The agent shouldn't even see memory tool instructions.
|
||||
const prompt = buildChatMode()
|
||||
expect(prompt).not.toContain('<memory_and_identity>')
|
||||
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', () => {
|
||||
|
||||
Reference in New Issue
Block a user