Files
homeclaw/src/tools/memory.ts
T
2026-04-29 11:45:59 +09:00

141 lines
5.5 KiB
TypeScript

import { ToolResult } from '../types.js';
import { loadMemory, updateMemory } from '../config/soul-loader.js';
import { queryFactRecords } from '../gateway/fact-store.js';
import { sanitizeMemoryText } from './memory-utils.js';
// MEMORY_WRITE: model appends or replaces a bullet in memory.md
export async function executeMemoryWrite(args: { fact: string; action?: 'append' | 'replace_all' | 'upsert'; key?: string; reference?: string; source_tool?: string; source_output?: string; actor?: 'agent' | 'user' | 'system' }): Promise<ToolResult> {
if (!args.fact?.trim()) return { success: false, error: 'fact is required' };
const action = args.action ?? 'append';
try {
const fact = sanitizeMemoryText(args.fact.trim());
const actor = args.actor || 'agent';
const reference = args.reference ? sanitizeMemoryText(args.reference) : undefined;
const source_tool = args.source_tool ? sanitizeMemoryText(args.source_tool) : undefined;
const source_output = args.source_output ? sanitizeMemoryText(args.source_output) : undefined;
const key = args.key ? sanitizeMemoryText(args.key) : undefined;
// Build bullet with metadata
const metaParts: string[] = [];
metaParts.push(`[${actor}]`);
if (key) metaParts.push(`[key=${key}]`);
if (reference) metaParts.push(`[ref=${reference}]`);
if (source_tool) metaParts.push(`[src=${source_tool}]`);
const meta = metaParts.join('');
const bullet = `- ${meta} ${fact}`;
if (action === 'replace_all') {
updateMemory(`# Memory\n\n${bullet}\n`);
} else if (action === 'upsert') {
const current = loadMemory();
const lines = current ? current.split(/\r?\n/) : [];
const hasHeader = lines.some(l => /^#\s*memory\b/i.test(l.trim()));
const keyPattern = key ? new RegExp(`\\[key=${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\]`) : null;
const filtered = lines.filter(line => {
const t = line.trim();
if (!t) return true;
if (/^#\s*memory\b/i.test(t)) return true;
if (!t.startsWith('-')) return true;
if (keyPattern && keyPattern.test(t)) return false;
return true;
});
const out = [];
if (hasHeader) out.push(...filtered);
else out.push('# Memory', '', ...filtered.filter(l => l.trim() !== '# Memory'));
if (out.length > 0 && out[out.length - 1].trim() !== '') out.push('');
out.push(bullet);
out.push('');
updateMemory(out.join('\n'));
} else {
const current = loadMemory();
// Remove placeholder line if present
const cleaned = current.replace(/- First run: no facts stored yet\.|\n?/, '').trim();
const bullets = cleaned ? `${cleaned}\n${bullet}\n` : `# Memory\n\n${bullet}\n`;
updateMemory(bullets);
}
return { success: true, stdout: `Memory updated: ${fact}` };
} catch (err: any) {
return { success: false, error: `Memory write failed: ${err.message}` };
}
}
export const memoryWriteTool = {
name: 'memory_write',
description: 'Persist a fact to long-term memory (survives restarts)',
execute: executeMemoryWrite,
schema: {
fact: 'string (required) - The fact to remember (e.g. "User prefers Python 3.12")',
action: 'string (optional) - "append" (default) adds a new bullet, "upsert" replaces bullet with same key, "replace_all" clears and rewrites',
key: 'string (optional) - unique key for upsert (e.g., "fact:us-attorney-general")',
reference: 'string (optional) - job id or session reference to associate with this fact',
source_tool: 'string (optional) - tool that produced this fact (e.g., web_search)',
source_output: 'string (optional) - raw tool output or snippet',
actor: 'string (optional) - who added the fact: agent|user|system'
},
};
// MEMORY_SEARCH: semantic lookup over typed memory facts
export async function executeMemorySearch(args: { query: string; session_id?: string; max?: number }): Promise<ToolResult> {
const query = String(args?.query || '').trim();
if (!query) return { success: false, error: 'query is required' };
try {
const sessionId = String(args?.session_id || '').trim() || undefined;
const maxRaw = Number(args?.max ?? 5);
const max = Number.isFinite(maxRaw) ? Math.min(Math.max(Math.floor(maxRaw), 1), 25) : 5;
const matches = queryFactRecords({
query,
session_id: sessionId,
includeGlobal: true,
max,
includeStale: false,
});
const results = matches.map((m) => ({
key: m.key,
value: m.value,
scope: m.scope,
session_id: m.session_id,
type: m.type,
confidence: m.confidence,
source_tool: m.source_tool,
source_url: m.source_url,
updated_at: m.updated_at,
verified_at: m.verified_at,
expires_at: m.expires_at,
actor: m.actor,
}));
const stdout = results.length
? results.map((r, i) => `${i + 1}. [${r.key}] ${r.value}`).join('\n')
: 'No memory matches found.';
return {
success: true,
stdout,
data: {
query,
session_id: sessionId,
count: results.length,
results,
},
};
} catch (err: any) {
return { success: false, error: `Memory search failed: ${err.message}` };
}
}
export const memorySearchTool = {
name: 'memory_search',
description: 'Search long-term memory for relevant facts',
execute: executeMemorySearch,
schema: {
query: 'string (required) - what to look for',
session_id: 'string (optional) - narrow to session scope',
max: 'number (optional, default 5) - max results',
},
};