feat(memory_goals): long-term goals list + enrichment agent + Brain UI (#3948)

This commit is contained in:
Steven Enamakel
2026-06-23 08:58:04 -07:00
committed by GitHub
parent 66adce7f8f
commit 3c98c98158
47 changed files with 3114 additions and 0 deletions
@@ -0,0 +1,169 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { goalsApi } from '../../services/api/goalsApi';
import GoalsPanel from './GoalsPanel';
vi.mock('../../services/api/goalsApi', () => ({
goalsApi: { list: vi.fn(), add: vi.fn(), edit: vi.fn(), remove: vi.fn(), reflect: vi.fn() },
}));
const api = vi.mocked(goalsApi);
describe('<GoalsPanel />', () => {
beforeEach(() => vi.clearAllMocks());
it('renders the empty state once loading resolves', async () => {
api.list.mockResolvedValueOnce([]);
render(<GoalsPanel />);
expect(await screen.findByText(/No goals yet/)).toBeInTheDocument();
expect(screen.getByText('Long-term Goals')).toBeInTheDocument();
});
it('renders loaded goals', async () => {
api.list.mockResolvedValueOnce([
{ id: 'g1', text: 'Ship the desktop app' },
{ id: 'g2', text: 'Keep the Rust core authoritative' },
]);
render(<GoalsPanel />);
expect(await screen.findByText('Ship the desktop app')).toBeInTheDocument();
expect(screen.getByText('Keep the Rust core authoritative')).toBeInTheDocument();
});
it('adds a goal from the input and renders the updated list', async () => {
api.list.mockResolvedValueOnce([]);
api.add.mockResolvedValueOnce([{ id: 'g1', text: 'New goal' }]);
render(<GoalsPanel />);
await screen.findByText(/No goals yet/);
fireEvent.change(screen.getByPlaceholderText('Add a long-term goal…'), {
target: { value: 'New goal' },
});
fireEvent.click(screen.getByRole('button', { name: /Add/ }));
await waitFor(() => expect(api.add).toHaveBeenCalledWith('New goal'));
expect(await screen.findByText('New goal')).toBeInTheDocument();
});
it('deletes a goal', async () => {
api.list.mockResolvedValueOnce([{ id: 'g1', text: 'Doomed goal' }]);
api.remove.mockResolvedValueOnce([]);
render(<GoalsPanel />);
await screen.findByText('Doomed goal');
fireEvent.click(screen.getByRole('button', { name: 'Delete goal' }));
await waitFor(() => expect(api.remove).toHaveBeenCalledWith('g1'));
});
it('runs reflect and shows the agent summary', async () => {
api.list.mockResolvedValueOnce([]);
api.reflect.mockResolvedValueOnce({
ran: true,
summary: 'Added 2 goals from context',
items: [
{ id: 'g1', text: 'A' },
{ id: 'g2', text: 'B' },
],
});
render(<GoalsPanel />);
await screen.findByText(/No goals yet/);
fireEvent.click(screen.getByRole('button', { name: /Reflect/ }));
await waitFor(() => expect(api.reflect).toHaveBeenCalled());
expect(await screen.findByText('Added 2 goals from context')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
it('surfaces an action error when add fails', async () => {
api.list.mockResolvedValueOnce([]);
api.add.mockRejectedValueOnce(new Error('boom'));
render(<GoalsPanel />);
await screen.findByText(/No goals yet/);
fireEvent.change(screen.getByPlaceholderText('Add a long-term goal…'), {
target: { value: 'x' },
});
fireEvent.click(screen.getByRole('button', { name: /Add/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('boom');
});
it('edits a goal inline and saves', async () => {
api.list.mockResolvedValueOnce([{ id: 'g1', text: 'old text' }]);
api.edit.mockResolvedValueOnce([{ id: 'g1', text: 'new text' }]);
render(<GoalsPanel />);
await screen.findByText('old text');
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }));
const input = screen.getByDisplayValue('old text');
fireEvent.change(input, { target: { value: 'new text' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(api.edit).toHaveBeenCalledWith('g1', 'new text'));
expect(await screen.findByText('new text')).toBeInTheDocument();
});
it('cancels an inline edit without saving', async () => {
api.list.mockResolvedValueOnce([{ id: 'g1', text: 'keep me' }]);
render(<GoalsPanel />);
await screen.findByText('keep me');
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }));
fireEvent.change(screen.getByDisplayValue('keep me'), { target: { value: 'discarded' } });
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(api.edit).not.toHaveBeenCalled();
expect(screen.getByText('keep me')).toBeInTheDocument();
});
it('surfaces an action error when edit fails', async () => {
api.list.mockResolvedValueOnce([{ id: 'g1', text: 'x' }]);
api.edit.mockRejectedValueOnce(new Error('edit blew up'));
render(<GoalsPanel />);
await screen.findByText('x');
fireEvent.click(screen.getByRole('button', { name: 'Edit goal' }));
fireEvent.change(screen.getByDisplayValue('x'), { target: { value: 'y' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByRole('alert')).toHaveTextContent('edit blew up');
});
it('surfaces an action error when delete fails', async () => {
api.list.mockResolvedValueOnce([{ id: 'g1', text: 'x' }]);
api.remove.mockRejectedValueOnce(new Error('delete failed'));
render(<GoalsPanel />);
await screen.findByText('x');
fireEvent.click(screen.getByRole('button', { name: 'Delete goal' }));
expect(await screen.findByRole('alert')).toHaveTextContent('delete failed');
});
it('renders the load error branch when list rejects', async () => {
api.list.mockRejectedValueOnce(new Error('cannot load goals'));
render(<GoalsPanel />);
expect(await screen.findByText('cannot load goals')).toBeInTheDocument();
});
it('shows the summary when reflect reports ran=false', async () => {
api.list.mockResolvedValueOnce([]);
api.reflect.mockResolvedValueOnce({
ran: false,
summary: 'enrichment failed: no model',
items: [],
});
render(<GoalsPanel />);
await screen.findByText(/No goals yet/);
fireEvent.click(screen.getByRole('button', { name: /Reflect/ }));
expect(await screen.findByText('enrichment failed: no model')).toBeInTheDocument();
});
it('surfaces an action error when reflect throws', async () => {
api.list.mockResolvedValueOnce([]);
api.reflect.mockRejectedValueOnce(new Error('reflect crashed'));
render(<GoalsPanel />);
await screen.findByText(/No goals yet/);
fireEvent.click(screen.getByRole('button', { name: /Reflect/ }));
expect(await screen.findByRole('alert')).toHaveTextContent('reflect crashed');
});
});
@@ -0,0 +1,300 @@
/**
* GoalsPanel — Brain > Goals.
*
* Views and edits the agent's long-term goals list (`openhuman.memory_goals_*`)
* and triggers the turn-based enrichment agent ("Reflect"). The same list is
* curated automatically by the background goals agent when context is
* summarized; this panel is the manual surface.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { LuCheck, LuPencil, LuPlus, LuSparkles, LuTrash2, LuX } from 'react-icons/lu';
import { useT } from '../../lib/i18n/I18nContext';
import { type GoalItem, goalsApi } from '../../services/api/goalsApi';
import Button from '../ui/Button';
import Input from '../ui/Input';
const cardClass =
'rounded-lg border border-stone-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900';
export default function GoalsPanel() {
const { t } = useT();
const [goals, setGoals] = useState<GoalItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [newText, setNewText] = useState('');
const [adding, setAdding] = useState(false);
const [reflecting, setReflecting] = useState(false);
const [reflectSummary, setReflectSummary] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editText, setEditText] = useState('');
const [busyId, setBusyId] = useState<string | null>(null);
const mountedRef = useRef(true);
const load = useCallback(async () => {
setError(null);
try {
const list = await goalsApi.list();
if (mountedRef.current) setGoals(list);
} catch (err) {
if (mountedRef.current) setError(err instanceof Error ? err.message : String(err));
} finally {
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void load();
return () => {
mountedRef.current = false;
};
}, [load]);
const handleAdd = useCallback(async () => {
const text = newText.trim();
if (!text) return;
setActionError(null);
setAdding(true);
try {
const list = await goalsApi.add(text);
if (mountedRef.current) {
setError(null);
setGoals(list);
setNewText('');
}
} catch (err) {
if (mountedRef.current)
setActionError(err instanceof Error ? err.message : t('brain.goals.actionError'));
} finally {
if (mountedRef.current) setAdding(false);
}
}, [newText, t]);
const startEdit = useCallback((goal: GoalItem) => {
setActionError(null);
setEditingId(goal.id);
setEditText(goal.text);
}, []);
const cancelEdit = useCallback(() => {
setEditingId(null);
setEditText('');
}, []);
const saveEdit = useCallback(
async (id: string) => {
const text = editText.trim();
if (!text) return;
setActionError(null);
setBusyId(id);
try {
const list = await goalsApi.edit(id, text);
if (mountedRef.current) {
setError(null);
setGoals(list);
setEditingId(null);
setEditText('');
}
} catch (err) {
if (mountedRef.current)
setActionError(err instanceof Error ? err.message : t('brain.goals.actionError'));
} finally {
if (mountedRef.current) setBusyId(null);
}
},
[editText, t]
);
const handleDelete = useCallback(
async (id: string) => {
setActionError(null);
setBusyId(id);
try {
const list = await goalsApi.remove(id);
if (mountedRef.current) {
setError(null);
setGoals(list);
}
} catch (err) {
if (mountedRef.current)
setActionError(err instanceof Error ? err.message : t('brain.goals.actionError'));
} finally {
if (mountedRef.current) setBusyId(null);
}
},
[t]
);
const handleReflect = useCallback(async () => {
setActionError(null);
setReflectSummary(null);
setReflecting(true);
try {
const res = await goalsApi.reflect();
if (mountedRef.current) {
setError(null);
setGoals(res.items);
setReflectSummary(
res.ran
? res.summary || t('brain.goals.reflectDone')
: res.summary || t('brain.goals.actionError')
);
}
} catch (err) {
if (mountedRef.current)
setActionError(err instanceof Error ? err.message : t('brain.goals.actionError'));
} finally {
if (mountedRef.current) setReflecting(false);
}
}, [t]);
return (
<div className="space-y-3 animate-fade-up">
<div className={cardClass}>
{/* Header */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="text-sm font-semibold text-neutral-800 dark:text-neutral-100">
{t('brain.goals.title')}
</h2>
<p className="mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
{t('brain.goals.description')}
</p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleReflect}
disabled={reflecting}>
<LuSparkles className="mr-1.5 h-3.5 w-3.5" />
{reflecting ? t('brain.goals.reflecting') : t('brain.goals.reflect')}
</Button>
</div>
{/* Add row */}
<div className="mt-4 flex items-center gap-2">
<Input
inputSize="sm"
value={newText}
placeholder={t('brain.goals.addPlaceholder')}
onChange={e => setNewText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void handleAdd();
}}
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={handleAdd}
disabled={adding || !newText.trim()}>
<LuPlus className="mr-1 h-3.5 w-3.5" />
{t('brain.goals.add')}
</Button>
</div>
{/* Errors / reflect summary */}
{actionError && (
<div
className="mt-3 rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-xs text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300"
role="alert">
{actionError}
</div>
)}
{reflectSummary && (
<div className="mt-3 whitespace-pre-wrap rounded-lg border border-sage-200 bg-sage-50 px-3 py-2 text-xs text-sage-800 dark:border-sage-500/30 dark:bg-sage-500/10 dark:text-sage-200">
{reflectSummary}
</div>
)}
{/* List */}
<div className="mt-4">
{loading ? (
<div className="flex items-center justify-center py-8 text-neutral-400 dark:text-neutral-500">
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-ocean-500 border-t-transparent" />
<span className="text-sm">{t('common.loading')}</span>
</div>
) : error ? (
<div className="rounded-lg border border-coral-200 bg-coral-50 px-3 py-2 text-sm text-coral-700 dark:border-coral-500/30 dark:bg-coral-500/10 dark:text-coral-300">
{error}
</div>
) : goals.length === 0 ? (
<p className="py-6 text-center text-sm text-neutral-400 dark:text-neutral-500">
{t('brain.goals.empty')}
</p>
) : (
<ul className="divide-y divide-neutral-200 overflow-hidden rounded-xl border border-neutral-200 dark:divide-neutral-800 dark:border-neutral-800">
{goals.map(goal => (
<li key={goal.id} className="bg-white px-3 py-2.5 dark:bg-neutral-900">
{editingId === goal.id ? (
<div className="flex items-center gap-2">
<Input
inputSize="sm"
value={editText}
onChange={e => setEditText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void saveEdit(goal.id);
if (e.key === 'Escape') cancelEdit();
}}
autoFocus
/>
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => void saveEdit(goal.id)}
disabled={busyId === goal.id || !editText.trim()}
aria-label={t('common.save')}>
<LuCheck className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="xs"
onClick={cancelEdit}
aria-label={t('common.cancel')}>
<LuX className="h-3.5 w-3.5" />
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 flex-1 text-sm text-neutral-700 dark:text-neutral-200">
{goal.text}
</span>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="xs"
onClick={() => startEdit(goal)}
disabled={busyId === goal.id}
aria-label={t('brain.goals.editGoal')}>
<LuPencil className="h-3.5 w-3.5" />
</Button>
<Button
type="button"
variant="danger"
size="xs"
onClick={() => void handleDelete(goal.id)}
disabled={busyId === goal.id}
aria-label={t('brain.goals.deleteGoal')}>
<LuTrash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
</div>
</div>
);
}
+12
View File
@@ -135,6 +135,18 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'الذاكرة',
'brain.tabs.subconscious': 'اللاوعي',
'brain.tabs.graph': 'الرسم البياني',
'brain.tabs.goals': 'الأهداف',
'brain.goals.title': 'الأهداف طويلة المدى',
'brain.goals.description': 'أهداف الوكيل الدائمة للعمل معك. عدّلها هنا أو دع التأمل يحدّثها.',
'brain.goals.reflect': 'تأمّل',
'brain.goals.reflecting': 'جارٍ التأمل…',
'brain.goals.reflectDone': 'تم تحديث الأهداف.',
'brain.goals.add': 'إضافة',
'brain.goals.addPlaceholder': 'أضف هدفًا طويل المدى…',
'brain.goals.empty': 'لا توجد أهداف بعد. أضف هدفًا أو استخدم التأمل لملئها من السياق الأخير.',
'brain.goals.editGoal': 'تعديل الهدف',
'brain.goals.deleteGoal': 'حذف الهدف',
'brain.goals.actionError': 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
'brain.tabs.sources': 'المصادر',
'brain.tabs.sync': 'المزامنة',
'brain.empty': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'স্মৃতি',
'brain.tabs.subconscious': 'অবচেতন',
'brain.tabs.graph': 'গ্রাফ',
'brain.tabs.goals': 'লক্ষ্য',
'brain.goals.title': 'দীর্ঘমেয়াদী লক্ষ্য',
'brain.goals.description':
'আপনার সঙ্গে কাজ করার জন্য এজেন্টের স্থায়ী লক্ষ্য। এখানে সম্পাদনা করুন বা রিফ্লেক্ট দিয়ে আপডেট করতে দিন।',
'brain.goals.reflect': 'রিফ্লেক্ট',
'brain.goals.reflecting': 'রিফ্লেক্ট করা হচ্ছে…',
'brain.goals.reflectDone': 'লক্ষ্য আপডেট হয়েছে।',
'brain.goals.add': 'যোগ করুন',
'brain.goals.addPlaceholder': 'একটি দীর্ঘমেয়াদী লক্ষ্য যোগ করুন…',
'brain.goals.empty':
'এখনও কোনো লক্ষ্য নেই। একটি যোগ করুন, অথবা সাম্প্রতিক প্রসঙ্গ থেকে পূরণ করতে রিফ্লেক্ট ব্যবহার করুন।',
'brain.goals.editGoal': 'লক্ষ্য সম্পাদনা করুন',
'brain.goals.deleteGoal': 'লক্ষ্য মুছুন',
'brain.goals.actionError': 'কিছু ভুল হয়েছে। অনুগ্রহ করে আবার চেষ্টা করুন।',
'brain.tabs.sources': 'উৎস',
'brain.tabs.sync': 'সিঙ্ক',
'brain.empty': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
+14
View File
@@ -137,6 +137,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Gedächtnis',
'brain.tabs.subconscious': 'Unterbewusstsein',
'brain.tabs.graph': 'Graph',
'brain.tabs.goals': 'Ziele',
'brain.goals.title': 'Langfristige Ziele',
'brain.goals.description':
'Die dauerhaften Ziele des Agenten für die Zusammenarbeit mit dir. Bearbeite sie hier oder lass sie per Reflektieren aktualisieren.',
'brain.goals.reflect': 'Reflektieren',
'brain.goals.reflecting': 'Reflektiere…',
'brain.goals.reflectDone': 'Ziele aktualisiert.',
'brain.goals.add': 'Hinzufügen',
'brain.goals.addPlaceholder': 'Ein langfristiges Ziel hinzufügen…',
'brain.goals.empty':
'Noch keine Ziele. Füge eines hinzu oder nutze Reflektieren, um sie aus dem letzten Kontext zu erstellen.',
'brain.goals.editGoal': 'Ziel bearbeiten',
'brain.goals.deleteGoal': 'Ziel löschen',
'brain.goals.actionError': 'Etwas ist schiefgelaufen. Bitte versuche es erneut.',
'brain.tabs.sources': 'Quellen',
'brain.tabs.sync': 'Synchronisierung',
'brain.empty': 'Dein Gehirn ist noch leer verbinde eine Quelle, um Speicher aufzubauen.',
+14
View File
@@ -84,10 +84,24 @@ const en: TranslationMap = {
'brain.tabs.memory': 'Memory',
'brain.tabs.subconscious': 'Subconscious',
'brain.tabs.graph': 'Graph',
'brain.tabs.goals': 'Goals',
'brain.tabs.sources': 'Sources',
'brain.tabs.sync': 'Sync',
'brain.empty': 'Your brain is empty for now — connect a source to start building memory.',
'brain.error': "Couldn't load your brain. Please try again.",
'brain.goals.title': 'Long-term Goals',
'brain.goals.description':
"The agent's durable goals for working with you. Edit them here or let Reflect update them.",
'brain.goals.reflect': 'Reflect',
'brain.goals.reflecting': 'Reflecting…',
'brain.goals.reflectDone': 'Goals updated.',
'brain.goals.add': 'Add',
'brain.goals.addPlaceholder': 'Add a long-term goal…',
'brain.goals.empty':
'No goals yet. Add one, or use Reflect to populate them from recent context.',
'brain.goals.editGoal': 'Edit goal',
'brain.goals.deleteGoal': 'Delete goal',
'brain.goals.actionError': 'Something went wrong. Please try again.',
// Feedback board
'feedback.board': 'Feedback board',
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Memoria',
'brain.tabs.subconscious': 'Subconsciente',
'brain.tabs.graph': 'Gráfico',
'brain.tabs.goals': 'Objetivos',
'brain.goals.title': 'Objetivos a largo plazo',
'brain.goals.description':
'Los objetivos duraderos del agente para trabajar contigo. Edítalos aquí o deja que Reflexionar los actualice.',
'brain.goals.reflect': 'Reflexionar',
'brain.goals.reflecting': 'Reflexionando…',
'brain.goals.reflectDone': 'Objetivos actualizados.',
'brain.goals.add': 'Añadir',
'brain.goals.addPlaceholder': 'Añade un objetivo a largo plazo…',
'brain.goals.empty':
'Aún no hay objetivos. Añade uno o usa Reflexionar para generarlos a partir del contexto reciente.',
'brain.goals.editGoal': 'Editar objetivo',
'brain.goals.deleteGoal': 'Eliminar objetivo',
'brain.goals.actionError': 'Algo salió mal. Inténtalo de nuevo.',
'brain.tabs.sources': 'Fuentes',
'brain.tabs.sync': 'Sincronización',
'brain.empty':
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Mémoire',
'brain.tabs.subconscious': 'Subconscient',
'brain.tabs.graph': 'Graphe',
'brain.tabs.goals': 'Objectifs',
'brain.goals.title': 'Objectifs à long terme',
'brain.goals.description':
'Les objectifs durables de lagent pour travailler avec vous. Modifiez-les ici ou laissez lagent les mettre à jour.',
'brain.goals.reflect': 'Réfléchir',
'brain.goals.reflecting': 'Réflexion…',
'brain.goals.reflectDone': 'Objectifs mis à jour.',
'brain.goals.add': 'Ajouter',
'brain.goals.addPlaceholder': 'Ajouter un objectif à long terme…',
'brain.goals.empty':
'Aucun objectif pour linstant. Ajoutez-en un ou utilisez le bouton Réfléchir pour les générer à partir du contexte récent.',
'brain.goals.editGoal': 'Modifier lobjectif',
'brain.goals.deleteGoal': 'Supprimer lobjectif',
'brain.goals.actionError': 'Une erreur sest produite. Veuillez réessayer.',
'brain.tabs.sources': 'Sources',
'brain.tabs.sync': 'Synchronisation',
'brain.empty':
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'स्मृति',
'brain.tabs.subconscious': 'अवचेतन',
'brain.tabs.graph': 'ग्राफ़',
'brain.tabs.goals': 'लक्ष्य',
'brain.goals.title': 'दीर्घकालिक लक्ष्य',
'brain.goals.description':
'आपके साथ काम करने के लिए एजेंट के स्थायी लक्ष्य। इन्हें यहाँ संपादित करें या Reflect से अपडेट होने दें।',
'brain.goals.reflect': 'रिफ़्लेक्ट',
'brain.goals.reflecting': 'रिफ़्लेक्ट हो रहा है…',
'brain.goals.reflectDone': 'लक्ष्य अपडेट हो गए।',
'brain.goals.add': 'जोड़ें',
'brain.goals.addPlaceholder': 'एक दीर्घकालिक लक्ष्य जोड़ें…',
'brain.goals.empty':
'अभी कोई लक्ष्य नहीं है। एक जोड़ें, या हाल के संदर्भ से भरने के लिए Reflect का उपयोग करें।',
'brain.goals.editGoal': 'लक्ष्य संपादित करें',
'brain.goals.deleteGoal': 'लक्ष्य हटाएँ',
'brain.goals.actionError': 'कुछ गलत हो गया। कृपया पुनः प्रयास करें।',
'brain.tabs.sources': 'स्रोत',
'brain.tabs.sync': 'सिंक',
'brain.empty': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
+14
View File
@@ -135,6 +135,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Memori',
'brain.tabs.subconscious': 'Alam Bawah Sadar',
'brain.tabs.graph': 'Grafik',
'brain.tabs.goals': 'Tujuan',
'brain.goals.title': 'Tujuan jangka panjang',
'brain.goals.description':
'Tujuan tetap agen untuk bekerja dengan Anda. Edit di sini atau biarkan Refleksi memperbaruinya.',
'brain.goals.reflect': 'Refleksi',
'brain.goals.reflecting': 'Merefleksikan…',
'brain.goals.reflectDone': 'Tujuan diperbarui.',
'brain.goals.add': 'Tambah',
'brain.goals.addPlaceholder': 'Tambahkan tujuan jangka panjang…',
'brain.goals.empty':
'Belum ada tujuan. Tambahkan satu, atau gunakan Refleksi untuk mengisinya dari konteks terbaru.',
'brain.goals.editGoal': 'Edit tujuan',
'brain.goals.deleteGoal': 'Hapus tujuan',
'brain.goals.actionError': 'Terjadi kesalahan. Silakan coba lagi.',
'brain.tabs.sources': 'Sumber',
'brain.tabs.sync': 'Sinkronisasi',
'brain.empty': 'Otak Anda masih kosong — hubungkan sumber untuk mulai membangun memori.',
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Memoria',
'brain.tabs.subconscious': 'Subconscio',
'brain.tabs.graph': 'Grafico',
'brain.tabs.goals': 'Obiettivi',
'brain.goals.title': 'Obiettivi a lungo termine',
'brain.goals.description':
'Gli obiettivi duraturi dellagente per lavorare con te. Modificali qui o lascia che Rifletti li aggiorni.',
'brain.goals.reflect': 'Rifletti',
'brain.goals.reflecting': 'Riflessione…',
'brain.goals.reflectDone': 'Obiettivi aggiornati.',
'brain.goals.add': 'Aggiungi',
'brain.goals.addPlaceholder': 'Aggiungi un obiettivo a lungo termine…',
'brain.goals.empty':
'Ancora nessun obiettivo. Aggiungine uno o usa Rifletti per generarli dal contesto recente.',
'brain.goals.editGoal': 'Modifica obiettivo',
'brain.goals.deleteGoal': 'Elimina obiettivo',
'brain.goals.actionError': 'Qualcosa è andato storto. Riprova.',
'brain.tabs.sources': 'Fonti',
'brain.tabs.sync': 'Sincronizzazione',
'brain.empty':
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': '기억',
'brain.tabs.subconscious': '잠재의식',
'brain.tabs.graph': '그래프',
'brain.tabs.goals': '목표',
'brain.goals.title': '장기 목표',
'brain.goals.description':
'당신과 함께 일하기 위한 에이전트의 지속적인 목표입니다. 여기서 편집하거나 리플렉트로 업데이트하세요.',
'brain.goals.reflect': '리플렉트',
'brain.goals.reflecting': '리플렉트 중…',
'brain.goals.reflectDone': '목표가 업데이트되었습니다.',
'brain.goals.add': '추가',
'brain.goals.addPlaceholder': '장기 목표 추가…',
'brain.goals.empty':
'아직 목표가 없습니다. 하나를 추가하거나 리플렉트로 최근 컨텍스트에서 채워 보세요.',
'brain.goals.editGoal': '목표 편집',
'brain.goals.deleteGoal': '목표 삭제',
'brain.goals.actionError': '문제가 발생했습니다. 다시 시도해 주세요.',
'brain.tabs.sources': '소스',
'brain.tabs.sync': '동기화',
'brain.empty': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Pamięć',
'brain.tabs.subconscious': 'Podświadomość',
'brain.tabs.graph': 'Graf',
'brain.tabs.goals': 'Cele',
'brain.goals.title': 'Cele długoterminowe',
'brain.goals.description':
'Trwałe cele agenta we współpracy z Tobą. Edytuj je tutaj lub pozwól, aby Refleksja je zaktualizowała.',
'brain.goals.reflect': 'Refleksja',
'brain.goals.reflecting': 'Refleksja…',
'brain.goals.reflectDone': 'Cele zaktualizowane.',
'brain.goals.add': 'Dodaj',
'brain.goals.addPlaceholder': 'Dodaj cel długoterminowy…',
'brain.goals.empty':
'Brak celów. Dodaj jeden lub użyj Refleksji, aby utworzyć je na podstawie ostatniego kontekstu.',
'brain.goals.editGoal': 'Edytuj cel',
'brain.goals.deleteGoal': 'Usuń cel',
'brain.goals.actionError': 'Coś poszło nie tak. Spróbuj ponownie.',
'brain.tabs.sources': 'Źródła',
'brain.tabs.sync': 'Synchronizacja',
'brain.empty': 'Twój mózg jest na razie pusty — połącz źródło, aby zacząć budować pamięć.',
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Memória',
'brain.tabs.subconscious': 'Subconsciente',
'brain.tabs.graph': 'Gráfico',
'brain.tabs.goals': 'Objetivos',
'brain.goals.title': 'Objetivos de longo prazo',
'brain.goals.description':
'Os objetivos duradouros do agente para trabalhar com você. Edite-os aqui ou deixe o Refletir atualizá-los.',
'brain.goals.reflect': 'Refletir',
'brain.goals.reflecting': 'Refletindo…',
'brain.goals.reflectDone': 'Objetivos atualizados.',
'brain.goals.add': 'Adicionar',
'brain.goals.addPlaceholder': 'Adicione um objetivo de longo prazo…',
'brain.goals.empty':
'Ainda não há objetivos. Adicione um ou use Refletir para gerá-los a partir do contexto recente.',
'brain.goals.editGoal': 'Editar objetivo',
'brain.goals.deleteGoal': 'Excluir objetivo',
'brain.goals.actionError': 'Algo deu errado. Tente novamente.',
'brain.tabs.sources': 'Fontes',
'brain.tabs.sync': 'Sincronização',
'brain.empty':
+14
View File
@@ -136,6 +136,20 @@ const messages: TranslationMap = {
'brain.tabs.memory': 'Память',
'brain.tabs.subconscious': 'Подсознание',
'brain.tabs.graph': 'Граф',
'brain.tabs.goals': 'Цели',
'brain.goals.title': 'Долгосрочные цели',
'brain.goals.description':
'Постоянные цели агента для работы с вами. Измените их здесь или позвольте Рефлексии обновить их.',
'brain.goals.reflect': 'Рефлексия',
'brain.goals.reflecting': 'Рефлексия…',
'brain.goals.reflectDone': 'Цели обновлены.',
'brain.goals.add': 'Добавить',
'brain.goals.addPlaceholder': 'Добавьте долгосрочную цель…',
'brain.goals.empty':
'Целей пока нет. Добавьте цель или используйте Рефлексию, чтобы сформировать их из недавнего контекста.',
'brain.goals.editGoal': 'Изменить цель',
'brain.goals.deleteGoal': 'Удалить цель',
'brain.goals.actionError': 'Что-то пошло не так. Пожалуйста, попробуйте снова.',
'brain.tabs.sources': 'Источники',
'brain.tabs.sync': 'Синхронизация',
'brain.empty': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
+12
View File
@@ -135,6 +135,18 @@ const messages: TranslationMap = {
'brain.tabs.memory': '记忆',
'brain.tabs.subconscious': '潜意识',
'brain.tabs.graph': '图谱',
'brain.tabs.goals': '目标',
'brain.goals.title': '长期目标',
'brain.goals.description': '智能体与你协作的持久目标。在此编辑,或使用反思功能自动更新。',
'brain.goals.reflect': '反思',
'brain.goals.reflecting': '反思中…',
'brain.goals.reflectDone': '目标已更新。',
'brain.goals.add': '添加',
'brain.goals.addPlaceholder': '添加一个长期目标…',
'brain.goals.empty': '暂无目标。添加一个,或使用反思根据近期上下文生成。',
'brain.goals.editGoal': '编辑目标',
'brain.goals.deleteGoal': '删除目标',
'brain.goals.actionError': '出了点问题。请重试。',
'brain.tabs.sources': '来源',
'brain.tabs.sync': '同步',
'brain.empty': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
+10
View File
@@ -8,6 +8,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import GoalsPanel from '../components/intelligence/GoalsPanel';
import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab';
import { MemoryControls } from '../components/intelligence/MemoryControls';
import { MemoryGraph } from '../components/intelligence/MemoryGraph';
@@ -35,6 +36,7 @@ import Intelligence from './Intelligence';
type BrainTab =
| 'graph'
| 'goals'
| 'sources'
| 'sync'
| 'intelligence'
@@ -60,6 +62,7 @@ const navIcon = (d: string) => (
const BRAIN_TABS: readonly BrainTab[] = [
'graph',
'goals',
'sources',
'sync',
'intelligence',
@@ -158,6 +161,11 @@ export default function Brain() {
'M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z'
),
},
{
value: 'goals',
label: t('brain.tabs.goals'),
icon: navIcon('M5 3v18M5 3l13 4-13 4M5 13l9 3-9 3'),
},
{
value: 'sources',
label: t('brain.tabs.sources'),
@@ -279,6 +287,8 @@ export default function Brain() {
</div>
)}
{activeTab === 'goals' && <GoalsPanel />}
{activeTab === 'sources' && (
<div className="space-y-5 animate-fade-up">
<MemorySourcesRegistry onToast={addToast} />
+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../coreRpcClient';
import { goalsApi } from './goalsApi';
vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
const mockCall = vi.mocked(callCoreRpc);
describe('goalsApi', () => {
beforeEach(() => vi.clearAllMocks());
it('list returns items from the bare GoalsDoc shape', async () => {
mockCall.mockResolvedValueOnce({ items: [{ id: 'g1', text: 'ship it' }] });
const res = await goalsApi.list();
expect(mockCall).toHaveBeenCalledWith({ method: 'openhuman.memory_goals_list', params: {} });
expect(res).toEqual([{ id: 'g1', text: 'ship it' }]);
});
it('list tolerates a missing/garbage response', async () => {
mockCall.mockResolvedValueOnce(null);
expect(await goalsApi.list()).toEqual([]);
});
it('add unwraps the { result: { goals: { items } }, logs } envelope', async () => {
mockCall.mockResolvedValueOnce({
result: {
id: 'g2',
goals: {
items: [
{ id: 'g1', text: 'a' },
{ id: 'g2', text: 'b' },
],
},
},
logs: ['added goal g2'],
});
const res = await goalsApi.add('b');
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.memory_goals_add',
params: { text: 'b' },
});
expect(res.map(g => g.id)).toEqual(['g1', 'g2']);
});
it('edit unwraps the { result: { items }, logs } envelope', async () => {
mockCall.mockResolvedValueOnce({ result: { items: [{ id: 'g1', text: 'new' }] }, logs: [] });
const res = await goalsApi.edit('g1', 'new');
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.memory_goals_edit',
params: { id: 'g1', text: 'new' },
});
expect(res).toEqual([{ id: 'g1', text: 'new' }]);
});
it('remove sends the id and returns the updated list', async () => {
mockCall.mockResolvedValueOnce({ result: { items: [] }, logs: [] });
const res = await goalsApi.remove('g1');
expect(mockCall).toHaveBeenCalledWith({
method: 'openhuman.memory_goals_delete',
params: { id: 'g1' },
});
expect(res).toEqual([]);
});
it('reflect prunes undefined context and parses ran/summary/items', async () => {
mockCall.mockResolvedValueOnce({
result: { ran: true, summary: 'Added 1 goal', goals: { items: [{ id: 'g1', text: 'x' }] } },
logs: ['reflect complete'],
});
const res = await goalsApi.reflect();
expect(mockCall).toHaveBeenCalledWith(
expect.objectContaining({ method: 'openhuman.memory_goals_reflect', params: {} })
);
expect(res.ran).toBe(true);
expect(res.summary).toBe('Added 1 goal');
expect(res.items).toEqual([{ id: 'g1', text: 'x' }]);
});
it('reflect forwards a provided context string', async () => {
mockCall.mockResolvedValueOnce({ result: { ran: false, summary: '', goals: { items: [] } } });
await goalsApi.reflect('focus on shipping');
expect(mockCall).toHaveBeenCalledWith(
expect.objectContaining({ params: { context: 'focus on shipping' } })
);
});
it('reflect parses a bare (un-enveloped) response', async () => {
mockCall.mockResolvedValueOnce({ ran: true, summary: 'bare', goals: { items: [] } });
const res = await goalsApi.reflect();
expect(res.ran).toBe(true);
expect(res.summary).toBe('bare');
expect(res.items).toEqual([]);
});
it('reflect tolerates a null response', async () => {
mockCall.mockResolvedValueOnce(null);
const res = await goalsApi.reflect();
expect(res).toEqual({ ran: false, summary: '', items: [] });
});
it('list returns [] when the payload has neither items nor goals', async () => {
mockCall.mockResolvedValueOnce({ something: 'else' });
expect(await goalsApi.list()).toEqual([]);
});
it('extractItems drops malformed entries', async () => {
mockCall.mockResolvedValueOnce({
items: [{ id: 'g1', text: 'ok' }, { id: 5 }, null, { text: 'no id' }],
});
expect(await goalsApi.list()).toEqual([{ id: 'g1', text: 'ok' }]);
});
});
+114
View File
@@ -0,0 +1,114 @@
/**
* Frontend client for the long-term goals surface (`openhuman.memory_goals_*`).
*
* The Rust handlers persist an editable list of the agent's durable long-term
* goals to `<workspace>/MEMORY_GOALS.md`. The same list is curated by the
* background `goals_agent` (enrichment) — user edits here and agent edits stay
* in lock-step on the same file.
*
* Wire shapes: `list` returns the bare `GoalsDoc` ({ items }); the mutation
* methods wrap their value in `{ result, logs }` when logs are present. The
* {@link extractItems} helper normalises both shapes so callers always get a
* `GoalItem[]`.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('openhuman:goalsApi');
/** A single long-term goal item. */
export interface GoalItem {
id: string;
text: string;
}
/** Outcome of an enrichment (reflect) pass. */
export interface ReflectResult {
ran: boolean;
summary: string;
items: GoalItem[];
}
/** Drop `undefined` params so the wire payload stays clean. */
function pruneParams<T extends Record<string, unknown>>(params: T): Partial<T> {
const out: Partial<T> = {};
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) (out as Record<string, unknown>)[k] = v;
}
return out;
}
/** Pull the goal items out of any of the handler response shapes. */
function extractItems(res: unknown): GoalItem[] {
if (!res || typeof res !== 'object') return [];
// Unwrap the RpcOutcome `{ result, logs }` envelope when present.
const value =
'result' in (res as Record<string, unknown>) ? (res as { result: unknown }).result : res;
if (!value || typeof value !== 'object') return [];
const v = value as Record<string, unknown>;
// Direct GoalsDoc ({ items }) or AddResult/ReflectResult ({ goals: { items } }).
const items = Array.isArray(v.items)
? v.items
: Array.isArray((v.goals as { items?: unknown })?.items)
? (v.goals as { items: unknown[] }).items
: [];
return items.filter(
(i): i is GoalItem =>
!!i && typeof (i as GoalItem).id === 'string' && typeof (i as GoalItem).text === 'string'
);
}
export const goalsApi = {
list: async (): Promise<GoalItem[]> => {
log('list');
const res = await callCoreRpc<unknown>({ method: 'openhuman.memory_goals_list', params: {} });
return extractItems(res);
},
add: async (text: string): Promise<GoalItem[]> => {
log('add');
const res = await callCoreRpc<unknown>({
method: 'openhuman.memory_goals_add',
params: { text },
});
return extractItems(res);
},
edit: async (id: string, text: string): Promise<GoalItem[]> => {
log('edit id=%s', id);
const res = await callCoreRpc<unknown>({
method: 'openhuman.memory_goals_edit',
params: { id, text },
});
return extractItems(res);
},
remove: async (id: string): Promise<GoalItem[]> => {
log('delete id=%s', id);
const res = await callCoreRpc<unknown>({
method: 'openhuman.memory_goals_delete',
params: { id },
});
return extractItems(res);
},
reflect: async (context?: string): Promise<ReflectResult> => {
log('reflect hasContext=%s', Boolean(context));
const res = await callCoreRpc<unknown>({
method: 'openhuman.memory_goals_reflect',
params: pruneParams({ context }),
// Enrichment runs a full agent turn — give it room beyond the default.
timeoutMs: 180_000,
});
const envelope =
res && typeof res === 'object' && 'result' in (res as Record<string, unknown>)
? (res as { result: Record<string, unknown> }).result
: ((res as Record<string, unknown>) ?? {});
return {
ran: Boolean(envelope?.ran),
summary: typeof envelope?.summary === 'string' ? envelope.summary : '',
items: extractItems(res),
};
},
};
+7
View File
@@ -347,6 +347,13 @@ End-to-end coverage of the agent harness via the web-chat RPC surface against an
| 8.4.5 | Cross-Topic Contradiction Surfacing | RU | `src/openhuman/agent/tools/save_preference_tests.rs::save_surfaces_related_preference_for_contradiction_check` | ✅ | Related prefs surfaced in the tool result for the chat agent to resolve |
| 8.4.6 | vector_chunks Model-Signature Recall Guard | RU | `src/openhuman/memory/store/unified/query_tests.rs::vector_recall_excludes_other_model_signature` | ✅ | Excludes cross-model vectors; dim-guards legacy rows |
### 8.5 Long-term Goals
| ID | Feature | Test | Source / Test File | Status | Notes |
| ----- | ----------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------- |
| 8.5.1 | Goals CRUD (list/add/edit/delete) | RU+VU | `src/openhuman/memory_goals/store.rs`, `src/openhuman/memory_goals/ops.rs`, `src/openhuman/memory_goals/tools.rs`, `app/src/services/api/goalsApi.test.ts`, `app/src/components/intelligence/GoalsPanel.test.tsx` | ✅ | Editable `MEMORY_GOALS.md` list over `memory_goals_*` RPC + Brain > Goals UI |
| 8.5.2 | Goals enrichment (reflect) | RU+VU | `src/openhuman/memory_goals/enrich.rs`, `src/openhuman/memory_goals/schemas.rs`, `app/src/components/intelligence/GoalsPanel.test.tsx` | 🟡 | Turn-based `goals_agent` enrichment; prompt/registry/error paths unit-tested, live LLM run manual |
---
## 9. Automation Engine
+6
View File
@@ -24,6 +24,9 @@ Commands:
Inspect saved debug-log files. `last` shows the most recent.
harness-cache-audit [options]
Run live harness turns over JSON-RPC and summarize transcript token/cache deltas.
goals-live [options]
Live-test the memory_goals flow (list/add/edit/delete + reflect enrichment),
printing the goals_agent's thoughts, tool calls, token usage and cost.
Flags common to runners:
--verbose Stream full output to stdout in addition to the log file.
@@ -46,6 +49,9 @@ case "$cmd" in
harness-cache-audit)
exec node "$here/harness-cache-audit.mjs" "$@"
;;
goals-live)
exec node "$here/goals-live.mjs" "$@"
;;
*)
echo "[debug] unknown command: $cmd" >&2
usage >&2
+86
View File
@@ -0,0 +1,86 @@
# `goals-live` test cases
Live exercises for the `memory_goals` domain via `scripts/debug/goals-live.mjs`.
Run against a running core (attached) or let the script spawn one.
> The list/add/edit/delete cases are pure RPC and need **no model**. The
> `reflect` case runs the turn-based `goals_agent` and therefore needs a
> configured provider/model + credentials in the target workspace.
## Prereqs
- A built core, or `--spawn-core` (which runs `cargo run --bin openhuman-core`).
- For `reflect`: a workspace with provider creds (use your real workspace —
the default — not `--isolated-workspace`, which has none).
---
## Case 1 — CRUD only (no model needed)
Fastest smoke test of the list lifecycle and `MEMORY_GOALS.md` persistence.
```bash
pnpm debug goals-live --reset --case list --case add --case edit --case delete --case list-final
```
Expect: initial empty list → two goals added → first edited → last deleted →
final list shows one goal. Then `cat <workspace>/MEMORY_GOALS.md` to confirm
the on-disk markdown matches.
---
## Case 2 — Full flow against the running app
Uses your live core + real workspace, runs every case, and prints the
enrichment agent's reasoning + token/cost.
```bash
pnpm debug goals-live --show-thoughts
```
Expect: CRUD cases, then a `reflect` run with a `ran: true`, an agent summary,
the post-enrichment goals, a token/cost table, and the `goals_agent` thread
(its thoughts + `goals_list` / `goals_add` / … tool calls + tool results).
---
## Case 3 — Test a custom enrichment context (your prompt)
Feed the enrichment agent specific context and watch what it does. This is the
loop for iterating on `goals_agent/prompt.md`: edit the prompt, re-run, read
the thread.
```bash
pnpm debug goals-live --reset --case reflect --show-thoughts \
--context "The user keeps asking about shipping the desktop app, wants a daily standup habit, and mentioned learning Rust deeply. Treat these as durable goals."
```
Expect: first-run bootstrap (empty list) → the agent populates initial goals
from the supplied context. The thread shows it calling `goals_list` first, then
`goals_add` per inferred goal.
---
## Case 4 — Spawned isolated core (CRUD determinism)
No external dependencies; spins a throwaway workspace and core. `reflect` will
no-op/fail without creds — scope to CRUD.
```bash
pnpm debug goals-live --spawn-core --isolated-workspace --keep-workspace \
--case list --case add --case edit --case delete --case list-final
```
Expect: a fresh `MEMORY_GOALS.md` created under the kept temp workspace; the
path is printed at the end so you can inspect it.
---
## Reading the output
- **token usage / cost table** — per changed transcript session: input / output
/ cached input tokens, cache hit-rate, and charged USD (`charged_amount_usd`
from the transcript `_meta`).
- **goals_agent thread** (`--show-thoughts`) — each non-system message: the
assistant's content (thoughts + `<tool_call>` markers) and `tool` results.
System prompts are hidden (length only) to keep output readable.
+595
View File
@@ -0,0 +1,595 @@
#!/usr/bin/env node
// Live test harness for the memory_goals domain.
//
// Exercises the goal list lifecycle (list / add / edit / delete) and the
// turn-based enrichment agent (reflect) against a running core over JSON-RPC.
// Surfaces the goals_agent's thoughts + tool calls and per-session token
// usage (input / output / cached) and cost so you can iterate on the
// goals_agent prompt and watch what it actually does.
//
// Usage examples:
// pnpm debug goals-live --spawn-core
// node scripts/debug/goals-live.mjs # attach to running core
// node scripts/debug/goals-live.mjs --reset --show-thoughts
// node scripts/debug/goals-live.mjs --case reflect \
// --context "User keeps asking about shipping the desktop app and wants daily standups."
//
// No credential bodies are printed. Goal text and agent thoughts ARE printed
// (this is a debug tool) — point it at a scratch workspace if that matters.
import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { once } from "node:events";
import { mkdtemp, mkdir, readFile, readdir, rm } from "node:fs/promises";
import { createServer } from "node:net";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const DEFAULT_RPC_URL = "http://127.0.0.1:7788/rpc";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const ALL_CASES = ["list", "add", "edit", "delete", "reflect", "list-final"];
function usage() {
return `Usage: node scripts/debug/goals-live.mjs [options]
Live-tests the memory_goals flow (list/add/edit/delete + reflect enrichment)
and prints the goals_agent's thoughts, tool calls, token usage and cost.
Options:
--core-url <url> JSON-RPC endpoint (default: OPENHUMAN_CORE_RPC_URL or ${DEFAULT_RPC_URL})
--token <token> RPC bearer (default: OPENHUMAN_CORE_TOKEN or <workspace>/core.token)
--workspace <path> Workspace whose MEMORY_GOALS.md + transcripts are used
--spawn-core Start openhuman-core for the run (uses the real workspace unless --isolated-workspace)
--isolated-workspace With --spawn-core, use a throwaway temp workspace (needs provider creds in env to enrich)
--keep-workspace Do not delete the temp workspace afterwards
--case <name> Run only one case: ${ALL_CASES.join(", ")} (repeatable)
--reset Delete all existing goals before running
--context <text> Context/prompt handed to the reflect enrichment agent
--model <model> model_override for the reflect agent (forwarded if supported)
--show-thoughts Print the goals_agent transcript thread (thoughts + tool calls + results)
--rpc-timeout-ms <n> Per-RPC timeout in ms (default: 600000)
--verbose Stream spawned core logs
-h, --help Show this help
`;
}
function parseArgs(argv) {
const opts = {
coreUrl: process.env.OPENHUMAN_CORE_RPC_URL || DEFAULT_RPC_URL,
token: process.env.OPENHUMAN_CORE_TOKEN || "",
workspace: process.env.OPENHUMAN_WORKSPACE || "",
spawnCore: false,
isolatedWorkspace: false,
keepWorkspace: false,
cases: [],
reset: false,
context: "",
model: "",
showThoughts: false,
rpcTimeoutMs: 600_000,
verbose: false,
coreUrlExplicit: Boolean(process.env.OPENHUMAN_CORE_RPC_URL),
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) throw new Error(`missing value for ${arg}`);
return value;
};
switch (arg) {
case "--core-url":
opts.coreUrl = next();
opts.coreUrlExplicit = true;
break;
case "--token":
opts.token = next();
break;
case "--workspace":
opts.workspace = next();
break;
case "--spawn-core":
opts.spawnCore = true;
break;
case "--isolated-workspace":
opts.isolatedWorkspace = true;
break;
case "--keep-workspace":
opts.keepWorkspace = true;
break;
case "--case": {
const name = next();
if (!ALL_CASES.includes(name))
throw new Error(`unknown case '${name}' (valid: ${ALL_CASES.join(", ")})`);
opts.cases.push(name);
break;
}
case "--reset":
opts.reset = true;
break;
case "--context":
opts.context = next();
break;
case "--model":
opts.model = next();
break;
case "--show-thoughts":
opts.showThoughts = true;
break;
case "--rpc-timeout-ms":
opts.rpcTimeoutMs = Number(next());
break;
case "--verbose":
opts.verbose = true;
break;
case "-h":
case "--help":
console.log(usage());
process.exit(0);
// eslint-disable-next-line no-fallthrough
default:
throw new Error(`unknown option: ${arg}`);
}
}
if (opts.cases.length === 0) opts.cases = [...ALL_CASES];
return opts;
}
// ── workspace / token resolution (mirrors harness-cache-audit) ──────────────
function defaultOpenhumanDir() {
return process.env.OPENHUMAN_APP_ENV === "staging"
? path.join(homedir(), ".openhuman-staging")
: path.join(homedir(), ".openhuman");
}
async function defaultWorkspace() {
if (process.env.OPENHUMAN_WORKSPACE) return process.env.OPENHUMAN_WORKSPACE;
const dir = defaultOpenhumanDir();
try {
const active = await readFile(path.join(dir, "active_user.toml"), "utf8");
const match = active.match(/^\s*user_id\s*=\s*"([^"]+)"\s*$/m);
if (match?.[1]) return path.join(dir, "users", match[1], "workspace");
} catch {
// fall through
}
return dir;
}
async function readToken(opts) {
if (opts.token.trim()) return opts.token.trim();
const tokenPath = path.join(
opts.workspace || (await defaultWorkspace()),
"core.token",
);
try {
return (await readFile(tokenPath, "utf8")).trim();
} catch {
throw new Error(
`RPC token not found at ${tokenPath}. Pass --token or set OPENHUMAN_CORE_TOKEN.`,
);
}
}
async function rpc(coreUrl, token, method, params, timeoutMs = 600_000) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(coreUrl, {
method: "POST",
signal: controller.signal,
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({
jsonrpc: "2.0",
id: `goals-${Date.now()}-${Math.random().toString(16).slice(2)}`,
method,
params,
}),
});
} catch (err) {
if (err?.name === "AbortError")
throw new Error(`RPC ${method} timed out after ${timeoutMs}ms`);
throw err;
} finally {
clearTimeout(timer);
}
const text = await res.text();
let body;
try {
body = JSON.parse(text);
} catch {
throw new Error(`RPC ${method} returned non-JSON HTTP ${res.status}: ${text.slice(0, 200)}`);
}
if (!res.ok) throw new Error(`RPC ${method} HTTP ${res.status}`);
if (body.error)
throw new Error(`RPC ${method} error: ${body.error.message || JSON.stringify(body.error)}`);
return body.result;
}
// RpcOutcome serializes either as the bare value (no logs) or { result, logs }.
function unwrap(result) {
if (result && typeof result === "object" && "result" in result && "logs" in result) {
return { value: result.result, logs: result.logs || [] };
}
return { value: result, logs: [] };
}
function renderGoals(doc) {
const items = doc?.items || [];
if (items.length === 0) return " (no goals)";
return items.map((g) => ` - [${g.id}] ${g.text}`).join("\n");
}
// ── transcript auditing ─────────────────────────────────────────────────────
function num(v) {
return Number.isFinite(Number(v)) ? Number(v) : 0;
}
async function walkJsonl(dir) {
const out = [];
async function walk(cur) {
let entries;
try {
entries = await readdir(cur, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = path.join(cur, e.name);
if (e.isDirectory()) await walk(full);
else if (e.isFile() && e.name.endsWith(".jsonl")) out.push(full);
}
}
await walk(dir);
return out;
}
async function readTranscript(file) {
const data = await readFile(file, "utf8");
const lines = data.split(/\r?\n/).filter((l) => l.trim().length > 0);
const meta = JSON.parse(lines[0])._meta || {};
const messages = [];
for (const line of lines.slice(1)) {
try {
const obj = JSON.parse(line);
if (obj.role) messages.push(obj);
} catch {
// skip malformed line
}
}
return {
file,
agent: String(meta.agent || "(unknown)"),
input: num(meta.input_tokens),
output: num(meta.output_tokens),
cached: num(meta.cached_input_tokens),
charged: num(meta.charged_amount_usd),
messages,
};
}
async function snapshot(workspace) {
const files = await walkJsonl(path.join(workspace, "session_raw"));
const map = new Map();
await Promise.all(
files.map(async (f) => {
try {
map.set(f, await readTranscript(f));
} catch {
// ignore
}
}),
);
return map;
}
function changedTranscripts(before, after) {
const rows = [];
for (const [file, cur] of after.entries()) {
const prev = before.get(file);
if (!prev) {
rows.push(cur);
continue;
}
if (
cur.input !== prev.input ||
cur.output !== prev.output ||
cur.messages.length !== prev.messages.length
) {
rows.push(cur);
}
}
return rows;
}
function printThoughts(transcript) {
console.log(`\n ── goals_agent thread (${path.basename(transcript.file)}) ──`);
for (const msg of transcript.messages) {
const role = (msg.role || "?").toUpperCase();
const content = String(msg.content || "").trim();
if (role === "SYSTEM") {
console.log(` [system] (${content.length} chars of system prompt — hidden)`);
continue;
}
if (!content) continue;
const indented = content
.split("\n")
.map((l) => ` ${l}`)
.join("\n");
console.log(` [${role.toLowerCase()}]\n${indented}`);
}
}
function printUsageTable(rows) {
if (rows.length === 0) {
console.log(" (no transcript usage recorded — provider may not have emitted usage)");
return;
}
console.table(
rows.map((r) => ({
agent: r.agent,
input: r.input,
output: r.output,
cached: r.cached,
hit_rate: r.input > 0 ? `${((r.cached / r.input) * 100).toFixed(1)}%` : "0.0%",
cost_usd: `$${r.charged.toFixed(6)}`,
})),
);
}
// ── spawn-core (mirrors harness-cache-audit) ────────────────────────────────
async function pickFreePort() {
return await new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
const port = typeof addr === "object" && addr ? addr.port : 0;
server.close(() => resolve(port));
});
});
}
async function startCore(opts) {
const token = opts.token || `goals-${randomBytes(24).toString("hex")}`;
const env = { ...process.env, OPENHUMAN_CORE_TOKEN: token };
if (opts.workspace) env.OPENHUMAN_WORKSPACE = opts.workspace;
const port = new URL(opts.coreUrl).port || "7788";
env.OPENHUMAN_CORE_PORT = port;
env.OPENHUMAN_CORE_RPC_URL = opts.coreUrl;
const args = ["run", "--host", "127.0.0.1", "--port", port, "--jsonrpc-only"];
const child = spawn(
"cargo",
["run", "--quiet", "--bin", "openhuman-core", "--", ...args],
{
cwd: path.resolve(SCRIPT_DIR, "../.."),
env,
stdio: opts.verbose ? ["ignore", "inherit", "inherit"] : ["ignore", "ignore", "pipe"],
},
);
let stderr = "";
if (child.stderr) {
child.stderr.on("data", (c) => {
stderr += c.toString();
if (stderr.length > 8000) stderr = stderr.slice(-8000);
});
}
await waitForCore(opts.coreUrl, token, child, () => stderr);
return { child, token };
}
async function waitForCore(coreUrl, token, child, stderrFn) {
const deadline = Date.now() + 180_000;
while (Date.now() < deadline) {
if (child.exitCode !== null)
throw new Error(`spawned core exited with ${child.exitCode}\n${stderrFn()}`);
try {
await rpc(coreUrl, token, "core.ping", {}, 10_000);
return;
} catch {
await new Promise((r) => setTimeout(r, 750));
}
}
throw new Error(`timed out waiting for core at ${coreUrl}\n${stderrFn()}`);
}
async function stopChild(child) {
if (child.exitCode !== null || child.signalCode !== null) return;
child.kill("SIGTERM");
const exited = await Promise.race([
once(child, "exit").then(() => true),
new Promise((r) => setTimeout(() => r(false), 5000)),
]);
if (exited) return;
child.kill("SIGKILL");
await Promise.race([once(child, "exit"), new Promise((r) => setTimeout(r, 2000))]);
}
// ── cases ───────────────────────────────────────────────────────────────────
function call(opts, method, params = {}) {
return rpc(opts.coreUrl, opts.token, method, params, opts.rpcTimeoutMs);
}
async function resetGoals(opts) {
const { value } = unwrap(await call(opts, "openhuman.memory_goals_list"));
const items = value?.items || [];
for (const g of items) {
await call(opts, "openhuman.memory_goals_delete", { id: g.id });
}
if (items.length > 0) console.log(` reset: deleted ${items.length} existing goal(s)`);
}
async function caseList(opts, label = "list") {
console.log(`\n=== case: ${label} ===`);
const { value } = unwrap(await call(opts, "openhuman.memory_goals_list"));
console.log(` current goals:\n${renderGoals(value)}`);
return value;
}
async function caseAdd(opts) {
console.log("\n=== case: add ===");
const seeds = [
"Help the user ship the OpenHuman desktop app across macOS, Windows and Linux",
"Keep the Rust core the single source of truth for business logic",
];
for (const text of seeds) {
const { value, logs } = unwrap(
await call(opts, "openhuman.memory_goals_add", { text }),
);
console.log(` + ${logs[0] || "added"} -> ${value.id}`);
}
const { value } = unwrap(await call(opts, "openhuman.memory_goals_list"));
console.log(` goals now:\n${renderGoals(value)}`);
return value;
}
async function caseEdit(opts) {
console.log("\n=== case: edit ===");
const { value: before } = unwrap(await call(opts, "openhuman.memory_goals_list"));
const target = before?.items?.[0];
if (!target) {
console.log(" (no goal to edit — run the add case first)");
return before;
}
const { value, logs } = unwrap(
await call(opts, "openhuman.memory_goals_edit", {
id: target.id,
text: `${target.text} (edited live at ${new Date().toISOString()})`,
}),
);
console.log(` ~ ${logs[0] || "edited"}`);
console.log(` goals now:\n${renderGoals(value)}`);
return value;
}
async function caseDelete(opts) {
console.log("\n=== case: delete ===");
const { value: before } = unwrap(await call(opts, "openhuman.memory_goals_list"));
const target = before?.items?.at(-1);
if (!target) {
console.log(" (no goal to delete)");
return before;
}
const { value, logs } = unwrap(
await call(opts, "openhuman.memory_goals_delete", { id: target.id }),
);
console.log(` - ${logs[0] || "deleted"} (${target.id})`);
console.log(` goals now:\n${renderGoals(value)}`);
return value;
}
async function caseReflect(opts) {
console.log("\n=== case: reflect (turn-based enrichment agent) ===");
if (opts.context) console.log(` context: ${opts.context}`);
else console.log(" context: (default review nudge)");
const before = await snapshot(opts.workspace);
const params = {};
if (opts.context) params.context = opts.context;
if (opts.model) params.model_override = opts.model;
const started = Date.now();
let result;
try {
result = unwrap(await call(opts, "openhuman.memory_goals_reflect", params)).value;
} catch (err) {
console.error(` reflect RPC failed: ${err.message}`);
console.error(
" (enrichment needs a configured provider/model + the goals_agent definition)",
);
return;
}
const ms = Date.now() - started;
console.log(` ran: ${result.ran} (${ms}ms)`);
console.log(` agent summary: ${result.summary}`);
console.log(` goals after enrichment:\n${renderGoals(result.goals)}`);
const after = await snapshot(opts.workspace);
const changed = changedTranscripts(before, after);
const goalsRuns = changed.filter((r) => r.agent.includes("goals"));
console.log("\n token usage / cost (changed sessions):");
printUsageTable(changed.length ? changed : goalsRuns);
if (opts.showThoughts) {
const toShow = goalsRuns.length ? goalsRuns : changed;
if (toShow.length === 0)
console.log("\n (no goals_agent transcript found — run may not persist transcripts)");
for (const t of toShow) printThoughts(t);
} else if (goalsRuns.length) {
console.log("\n (re-run with --show-thoughts to see the agent's reasoning + tool calls)");
}
}
// ── main ────────────────────────────────────────────────────────────────────
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (!opts.workspace) opts.workspace = await defaultWorkspace();
let tempWorkspace = "";
let spawned;
if (opts.isolatedWorkspace) {
if (!opts.spawnCore) throw new Error("--isolated-workspace requires --spawn-core");
tempWorkspace = await mkdtemp(path.join(tmpdir(), "openhuman-goals-live-"));
opts.workspace = path.join(tempWorkspace, "workspace");
await mkdir(opts.workspace, { recursive: true });
}
if (opts.spawnCore) {
if (!opts.coreUrlExplicit) {
const port = await pickFreePort();
opts.coreUrl = `http://127.0.0.1:${port}/rpc`;
}
spawned = await startCore(opts);
opts.token = spawned.token;
} else {
opts.token = await readToken(opts);
}
console.log("[goals-live] starting");
console.log(` rpc: ${opts.coreUrl}`);
console.log(` workspace: ${opts.workspace}`);
console.log(` goals file: ${path.join(opts.workspace, "MEMORY_GOALS.md")}`);
console.log(` mode: ${opts.spawnCore ? "spawned-core" : "attached-core"}`);
console.log(` cases: ${opts.cases.join(", ")}`);
try {
if (opts.reset) {
console.log("\n=== reset ===");
await resetGoals(opts);
}
for (const name of opts.cases) {
if (name === "list") await caseList(opts, "list (initial)");
else if (name === "add") await caseAdd(opts);
else if (name === "edit") await caseEdit(opts);
else if (name === "delete") await caseDelete(opts);
else if (name === "reflect") await caseReflect(opts);
else if (name === "list-final") await caseList(opts, "list (final)");
}
} finally {
if (spawned?.child) await stopChild(spawned.child);
if (tempWorkspace && !opts.keepWorkspace) {
await rm(tempWorkspace, { recursive: true, force: true });
} else if (tempWorkspace) {
console.log(`\n[goals-live] kept temp workspace: ${opts.workspace}`);
}
}
console.log("\n[goals-live] done");
}
main().catch((err) => {
console.error(`[goals-live] ERROR: ${err.message}`);
process.exit(1);
});
+6
View File
@@ -215,6 +215,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::tool_registry::all_tool_registry_registered_controllers());
// Document and knowledge graph storage
controllers.extend(crate::openhuman::memory::all_memory_registered_controllers());
// Long-term goals list (editable list + turn-based enrichment agent)
controllers.extend(crate::openhuman::memory_goals::all_memory_goals_registered_controllers());
// Memory tree ingestion layer (#707 — canonicalised chunks with provenance)
controllers.extend(crate::openhuman::memory_tree::all_memory_tree_registered_controllers());
// Memory tree retrieval layer (#710 — LLM-callable read tools over the tree)
@@ -397,6 +399,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::tools::all_tools_controller_schemas());
schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas());
schemas.extend(crate::openhuman::memory::all_memory_controller_schemas());
schemas.extend(crate::openhuman::memory_goals::all_memory_goals_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_memory_tree_controller_schemas());
schemas.extend(crate::openhuman::memory_tree::all_retrieval_controller_schemas());
schemas.extend(
@@ -526,6 +529,9 @@ pub fn namespace_description(namespace: &str) -> Option<&'static str> {
"workflows" => Some("Discovered workflows (WORKFLOW.md/SKILL.md bundles) and their resources."),
"socket" => Some("Backend Socket.IO bridge controls."),
"memory" => Some("Document storage, vector search, key-value store, and knowledge graph."),
"memory_goals" => Some(
"The agent's long-term goals list for working with the user — editable items plus turn-based enrichment.",
),
"memory_tree" => Some(
"Canonical chunk ingestion, provenance capture, and chunk retrieval for source-grounded memory.",
),
+18
View File
@@ -360,6 +360,24 @@ pub(super) const CAPABILITIES: &[Capability] = &[
status: CapabilityStatus::Beta,
privacy: LOCAL_RAW,
},
Capability {
id: "intelligence.long_term_goals",
name: "Long-term Goals",
domain: "intelligence",
category: CapabilityCategory::Intelligence,
description: "An editable list of the assistant's durable long-term goals for working with \
you, stored locally in MEMORY_GOALS.md (capped ~500 tokens). A background goals agent \
keeps the list fresh: it runs when the conversation context is summarized, and on first \
run populates initial goals from context. Items can be added/edited/deleted explicitly \
via RPC or agent tools.",
how_to: "Automatic — refreshed on context summarization. Manage via \
memory_goals.list / memory_goals.add / memory_goals.edit / memory_goals.delete / \
memory_goals.reflect (RPC), or the goals_* agent tools.",
status: CapabilityStatus::Beta,
// Enrichment runs a cloud agentic model, so goal/context text can leave
// the device during a reflect pass (CRUD/storage stays local).
privacy: DERIVED_TO_BACKEND,
},
Capability {
id: "intelligence.memory_tree_retrieval",
name: "Memory Tree Retrieval (chat)",
@@ -410,6 +410,29 @@ impl ArchivistHook {
.await;
}
}
// ── Long-term goals enrichment (best-effort, background) ───────────
// When context is summarized we kick the turn-based `goals_agent` so
// the user's durable goals list stays fresh. Feed it the fresh recap
// as context. Detached + non-fatal: never blocks segment close.
if let Some(ref cfg) = self.config {
if cfg.learning.goals_enrichment_enabled && !summary.trim().is_empty() {
tracing::debug!(
"[memory_goals] segment closed — spawning goals enrichment \
session={session_id} segment={}",
segment.segment_id
);
let context = format!(
"Recent conversation recap (segment {}):\n\n{}",
segment.segment_id, summary
);
crate::openhuman::memory_goals::spawn_enrich_goals(
cfg.clone(),
cfg.workspace_dir.clone(),
context,
);
}
}
}
/// Embed `summary` for `segment_id` and write the per-model embedding row.
@@ -681,6 +681,11 @@ pub fn default_workspace_file_content(filename: &str) -> &'static str {
"HEARTBEAT.md" => {
"# Periodic Tasks\n\n# Add tasks below (one per line, starting with `- `)\n"
}
// The agent's long-term goals list. Header-only template so the file
// is discoverable in the workspace from first boot; items are managed
// by the memory_goals domain (RPC / tools / enrichment agent). Must
// match `GoalsDoc::render()` of an empty doc so parse↔render is stable.
"MEMORY_GOALS.md" => "# Long-term Goals\n\n",
_ => "",
}
}
+7
View File
@@ -274,6 +274,13 @@ impl PromptSection for IdentitySection {
inject_workspace_file(&mut prompt, ctx.workspace_dir, file);
}
// Seed MEMORY_GOALS.md to disk (header-only default) so the
// long-term goals list is discoverable in the workspace from first
// boot. Sync-only: the goals file is deliberately NOT injected into
// the system prompt — it is stored state managed by the memory_goals
// domain (RPC / tools / enrichment agent).
sync_workspace_file(ctx.workspace_dir, "MEMORY_GOALS.md");
// PROFILE.md / MEMORY.md injection lives in the dedicated
// `UserFilesSection` (below) so agents that strip the identity
// preamble (`omit_identity = true`) — welcome, orchestrator, the
@@ -0,0 +1,23 @@
id = "goals_agent"
display_name = "Goals Curator"
delegate_name = "curate_goals"
when_to_use = "Background — keeps the user's long-term goals list (MEMORY_GOALS.md) fresh from session context. Runs cheap and slow."
temperature = 0.3
max_iterations = 5
sandbox_mode = "none"
background = true
omit_identity = true
omit_memory_context = true
omit_safety_preamble = true
omit_skills_catalog = true
omit_memory_md = true
# Cloud "agentic" model like other background specialists (researcher,
# profile_memory_agent). NOT "local": the goals agent must run a real LLM turn
# to curate the list and has no heuristic fallback, so a local-only hint leaves
# enrichment blank in production where the local runtime is disabled.
[model]
hint = "agentic"
[tools]
named = ["goals_list", "goals_add", "goals_edit", "goals_delete", "memory_recall"]
@@ -0,0 +1 @@
pub mod prompt;
@@ -0,0 +1,32 @@
# Goals Curator
You are the **Goals Curator**. You run in the background to maintain a short,
durable list of the user's **long-term goals** for working with the assistant.
The list is small and high-signal — think aspirations and standing objectives,
not tasks. It lives in `MEMORY_GOALS.md` and is capped at ~8 items.
## How you work
1. **Always call `goals_list` first.** Never add or edit without seeing the
current list — this avoids duplicates and lets you address the right ids.
2. **Use `memory_recall`** when you need more context about the user's past
intentions before deciding what to change.
3. Apply the **minimal** set of changes justified by the provided context:
- `goals_add` — a genuinely new durable goal that isn't already captured.
- `goals_edit` — refine the wording of an existing goal as it evolves.
- `goals_delete` — remove a goal the user has completed or abandoned.
4. **First run (empty list):** populate an initial set of goals inferred from
the context. Be conservative — only add goals you're confident about.
## Rules
- **One concise sentence per goal.** Durable and long-term, not per-task.
- **Be selective.** Not every conversation changes the goals. Doing nothing is
a valid outcome — if nothing durable changed, make no calls.
- **Never store secrets or PII** (keys, tokens, passwords, sensitive personal
data).
- **Don't churn.** Leave still-valid goals untouched; prefer small edits over
rewrites.
When you finish, briefly summarize what you changed (or that nothing changed).
@@ -0,0 +1,72 @@
//! System prompt builder for the `goals_agent` built-in agent.
//!
//! Mirrors the `archivist` builder: emit the static archetype, then the
//! user-files, tools, and workspace sections so the returned string IS what
//! the LLM sees.
use crate::openhuman::context::prompt::{
render_tools, render_user_files, render_workspace, PromptContext,
};
use anyhow::Result;
const ARCHETYPE: &str = include_str!("prompt.md");
pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
let mut out = String::with_capacity(4096);
out.push_str(ARCHETYPE.trim_end());
out.push_str("\n\n");
let user_files = render_user_files(ctx)?;
if !user_files.trim().is_empty() {
out.push_str(user_files.trim_end());
out.push_str("\n\n");
}
let tools = render_tools(ctx)?;
if !tools.trim().is_empty() {
out.push_str(tools.trim_end());
out.push_str("\n\n");
}
let workspace = render_workspace(ctx)?;
if !workspace.trim().is_empty() {
out.push_str(workspace.trim_end());
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::context::prompt::{LearnedContextData, ToolCallFormat};
use std::collections::HashSet;
#[test]
fn build_returns_nonempty_body() {
let visible: HashSet<String> = HashSet::new();
let ctx = PromptContext {
workspace_dir: std::path::Path::new("."),
model_name: "test",
agent_id: "goals_agent",
tools: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
tool_call_format: ToolCallFormat::PFormat,
connected_integrations: &[],
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
};
let body = build(&ctx).unwrap();
assert!(body.contains("Goals Curator"));
}
}
@@ -168,6 +168,11 @@ pub const BUILTINS: &[BuiltinAgent] = &[
toml: include_str!("archivist/agent.toml"),
prompt_fn: super::archivist::prompt::build,
},
BuiltinAgent {
id: "goals_agent",
toml: include_str!("goals_agent/agent.toml"),
prompt_fn: super::goals_agent::prompt::build,
},
BuiltinAgent {
id: "trigger_triage",
toml: include_str!("trigger_triage/agent.toml"),
@@ -10,6 +10,7 @@ pub mod code_executor;
pub mod critic;
pub mod crypto_agent;
pub mod desktop_control_agent;
pub mod goals_agent;
pub mod help;
pub mod integrations_agent;
pub mod markets_agent;
+12
View File
@@ -141,6 +141,17 @@ pub struct LearningConfig {
/// to suppress all preference injection even for explicitly pinned entries.
#[serde(default = "default_true")]
pub explicit_preferences_enabled: bool,
/// Enable automatic enrichment of the long-term goals list
/// (`MEMORY_GOALS.md`) when the conversation context is summarized.
///
/// When `true` (the default), a best-effort background `goals_agent`
/// run is fired after a segment recap so the user's durable goals stay
/// fresh. Set to `false` (or
/// `OPENHUMAN_LEARNING_GOALS_ENRICHMENT_ENABLED=0`) to only update the
/// goals list via explicit RPC/tools.
#[serde(default = "default_true")]
pub goals_enrichment_enabled: bool,
}
fn default_rebuild_interval_secs() -> u64 {
@@ -177,6 +188,7 @@ impl Default for LearningConfig {
stm_recall_enabled: default_true(),
unified_compaction_enabled: default_true(),
explicit_preferences_enabled: default_true(),
goals_enrichment_enabled: default_true(),
}
}
}
@@ -495,6 +495,14 @@ impl Config {
_ => {}
}
}
if let Some(flag) = env.get("OPENHUMAN_LEARNING_GOALS_ENRICHMENT_ENABLED") {
let normalized = flag.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => self.learning.goals_enrichment_enabled = true,
"0" | "false" | "no" | "off" => self.learning.goals_enrichment_enabled = false,
_ => {}
}
}
if let Some(source) = env.get("OPENHUMAN_LEARNING_REFLECTION_SOURCE") {
let normalized = source.trim().to_ascii_lowercase();
match normalized.as_str() {
+6
View File
@@ -133,6 +133,12 @@ const RESOURCE_CATALOG: &[PromptResource] = &[
description: "Background worker that distils conversations into persistent memory.",
content: include_str!("../agent_registry/agents/archivist/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/goals_agent",
name: "goals_agent",
description: "Background curator that keeps the user's long-term goals list fresh.",
content: include_str!("../agent_registry/agents/goals_agent/prompt.md"),
},
PromptResource {
uri: "openhuman://prompts/agents/trigger_triage",
name: "trigger_triage",
+149
View File
@@ -0,0 +1,149 @@
//! Turn-based enrichment of the goals list.
//!
//! Enrichment is performed by a real multi-turn agent — the bundled
//! `goals_agent` definition (restricted to the `goals_*` tools +
//! `memory_recall`) — not a one-shot LLM call. The agent reads the current
//! list, considers the supplied context, and applies add/edit/delete over
//! several turns. On an empty list (first run) it bootstraps the list from
//! the context.
//!
//! This mirrors the standalone background-agent spawn pattern used by the
//! `subconscious` engine: build the agent from its registry definition, run
//! a single external turn (which drives the full internal tool loop) under a
//! `TrustedAutomation` turn origin.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use super::store;
use crate::openhuman::agent::harness::definition::AgentDefinitionRegistry;
use crate::openhuman::agent::turn_origin::{with_origin, AgentTurnOrigin, TrustedAutomationSource};
use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
/// Registry id of the bundled goals enrichment agent definition.
pub const GOALS_AGENT_ID: &str = "goals_agent";
/// Seconds since the Unix epoch (best-effort; 0 if the clock is before the
/// epoch). Used only to build unique-ish job ids for telemetry.
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Build the task prompt handed to the goals agent. `first_run` switches the
/// instruction between initial population and incremental maintenance.
fn build_prompt(context_input: &str, first_run: bool) -> String {
let mode = if first_run {
"The goals list is currently EMPTY. This is the first run — populate \
an initial set of the user's durable long-term goals (max ~8) from \
the context below. Start by calling goals_list to confirm, then use \
goals_add for each goal."
} else {
"Maintain the existing goals list. Call goals_list first, then make \
the MINIMAL set of changes (goals_add / goals_edit / goals_delete) \
justified by the context below. Do not churn goals that are still \
valid."
};
format!(
"{mode}\n\n\
Keep goals concise (one sentence each), durable (long-term, not \
per-task), and free of secrets or PII.\n\n\
## Context\n\n{context_input}\n"
)
}
/// Run the goals enrichment agent against `context_input` (typically a
/// session recap/summary, or an on-demand nudge). Returns the agent's final
/// text. Best-effort: the caller decides whether to ignore errors.
pub async fn enrich_goals(
config: &Config,
workspace_dir: &Path,
context_input: &str,
) -> Result<String, String> {
// Surface real storage failures instead of masking them as an empty
// first-run doc — `load` already maps a missing file to an empty doc.
let doc = store::load(workspace_dir)
.await
.map_err(|e| format!("goals load failed: {e}"))?;
let first_run = doc.is_empty();
log::info!(
"[memory_goals] enrich start (first_run={first_run}, existing_items={})",
doc.items.len()
);
let prompt = build_prompt(context_input, first_run);
// Ensure the agent definition registry is initialised. The full server
// startup does this, but one-shot contexts (the `openhuman call` CLI,
// cron, tests) may not — without it `from_config_for_agent` fails with
// "registry not initialised". `init_global` is idempotent (OnceLock).
if AgentDefinitionRegistry::global().is_none() {
if let Err(e) = AgentDefinitionRegistry::init_global(workspace_dir) {
log::warn!("[memory_goals] agent registry init failed: {e}");
}
}
let mut agent = Agent::from_config_for_agent(config, GOALS_AGENT_ID)
.map_err(|e| format!("goals agent init failed: {e}"))?;
let job_id = format!("memory_goals:enrich:{}", now_secs());
agent.set_event_context(job_id.clone(), "goals_enrichment");
let origin = AgentTurnOrigin::TrustedAutomation {
job_id,
// Internal curation of locally-stored goals — no external content
// is forwarded to external-effect tools, so the untainted source.
source: TrustedAutomationSource::Subconscious,
};
let response = with_origin(origin, agent.run_single(&prompt))
.await
.map_err(|e| format!("goals agent run failed: {e}"))?;
log::info!(
"[memory_goals] enrich complete (first_run={first_run}, response {} chars)",
response.chars().count()
);
Ok(response)
}
/// Spawn [`enrich_goals`] as a detached best-effort background task. Used by
/// the automatic summarization trigger, where we must not block the caller
/// and any failure is non-fatal.
pub fn spawn_enrich_goals(
config: Config,
workspace_dir: std::path::PathBuf,
context_input: String,
) {
tokio::spawn(async move {
match enrich_goals(&config, &workspace_dir, &context_input).await {
Ok(_) => log::debug!("[memory_goals] background enrich finished"),
Err(e) => log::warn!("[memory_goals] background enrich failed: {e}"),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_run_prompt_requests_initial_population() {
let p = build_prompt("user wants to learn rust", true);
assert!(p.contains("EMPTY"));
assert!(p.contains("first run"));
assert!(p.contains("user wants to learn rust"));
}
#[test]
fn maintenance_prompt_requests_minimal_changes() {
let p = build_prompt("user finished onboarding", false);
assert!(p.contains("MINIMAL"));
assert!(!p.contains("first run"));
assert!(p.contains("user finished onboarding"));
}
}
+30
View File
@@ -0,0 +1,30 @@
//! `memory_goals` — the agent's long-term goals when interacting with the
//! user.
//!
//! A deliberately small, high-level domain: it maintains a compact markdown
//! file (`MEMORY_GOALS.md`, ~200500 tokens) holding an editable **list** of
//! the user's durable goals. The list can be mutated three ways:
//!
//! - **Explicitly** — via RPC (`openhuman.memory_goals_{list,add,edit,delete}`)
//! or the matching agent tools (`goals_list` / `goals_add` / `goals_edit` /
//! `goals_delete`).
//! - **By reflection** — a turn-based [`enrich`]ment agent (`goals_agent`) that
//! reads context + memory and applies add/edit/delete over several turns. On
//! an empty list it performs an initial population.
//! - **Automatically** — the reflection agent is fired (best-effort) when the
//! conversation context is summarized; see the archivist segment-close hook.
//!
//! Persistence + cap enforcement live in [`store`]; the file is stored state,
//! not injected into the main system prompt.
pub mod enrich;
pub mod ops;
mod schemas;
pub mod store;
pub mod tools;
pub mod types;
pub use enrich::{enrich_goals, spawn_enrich_goals, GOALS_AGENT_ID};
pub use schemas::{all_memory_goals_controller_schemas, all_memory_goals_registered_controllers};
pub use tools::{GoalsAddTool, GoalsDeleteTool, GoalsEditTool, GoalsListTool};
pub use types::{GoalItem, GoalsDoc};
+147
View File
@@ -0,0 +1,147 @@
//! Business logic for the goals domain — thin handlers over [`super::store`]
//! plus the on-demand reflection entry point. Every function returns an
//! [`RpcOutcome`] so the RPC layer (and CLI) get a uniform shape with logs.
use std::path::Path;
use serde::Serialize;
use super::store;
use super::types::GoalsDoc;
use crate::openhuman::config::Config;
use crate::rpc::RpcOutcome;
/// Result of an add operation: the new id plus the full updated list.
#[derive(Debug, Serialize)]
pub struct AddResult {
pub id: String,
pub goals: GoalsDoc,
}
/// Result of the on-demand reflection trigger.
#[derive(Debug, Serialize)]
pub struct ReflectResult {
/// Whether the enrichment agent ran to completion.
pub ran: bool,
/// Short human-readable summary of what happened.
pub summary: String,
/// The goals list after enrichment.
pub goals: GoalsDoc,
}
/// List the current goals.
pub async fn list(workspace_dir: &Path) -> Result<RpcOutcome<GoalsDoc>, String> {
log::debug!("[memory_goals] rpc=list");
let doc = store::load(workspace_dir).await?;
Ok(RpcOutcome::new(doc, vec![]))
}
/// Add a goal and return the new id + updated list.
pub async fn add(workspace_dir: &Path, text: &str) -> Result<RpcOutcome<AddResult>, String> {
log::debug!("[memory_goals] rpc=add");
let (id, goals) = store::add(workspace_dir, text).await?;
Ok(RpcOutcome::single_log(
AddResult {
id: id.clone(),
goals,
},
format!("added goal {id}"),
))
}
/// Edit a goal's text and return the updated list.
pub async fn edit(
workspace_dir: &Path,
id: &str,
text: &str,
) -> Result<RpcOutcome<GoalsDoc>, String> {
log::debug!("[memory_goals] rpc=edit id={id}");
let goals = store::edit(workspace_dir, id, text).await?;
Ok(RpcOutcome::single_log(goals, format!("edited goal {id}")))
}
/// Delete a goal and return the updated list.
pub async fn delete(workspace_dir: &Path, id: &str) -> Result<RpcOutcome<GoalsDoc>, String> {
log::debug!("[memory_goals] rpc=delete id={id}");
let goals = store::delete(workspace_dir, id).await?;
Ok(RpcOutcome::single_log(goals, format!("deleted goal {id}")))
}
/// On-demand enrichment: run the turn-based goals agent now, then return the
/// resulting list. Unlike the automatic summarization trigger (which fires
/// best-effort in the background), this awaits the agent so the caller sees
/// the updated list in the response.
pub async fn reflect_now(
config: &Config,
context: Option<String>,
) -> Result<RpcOutcome<ReflectResult>, String> {
log::info!("[memory_goals] rpc=reflect — running goals agent on demand");
let workspace_dir = config.workspace_dir.clone();
let default_nudge = "Review the user's long-term goals against recent memory and the \
current conversation. Add, edit, or delete goals as needed.";
let nudge = context
.as_deref()
.map(str::trim)
.filter(|c| !c.is_empty())
.unwrap_or(default_nudge);
let summary = match super::enrich::enrich_goals(config, &workspace_dir, nudge).await {
Ok(s) => s,
Err(e) => {
log::warn!("[memory_goals] reflect failed: {e}");
let goals = store::load(&workspace_dir).await.unwrap_or_default();
return Ok(RpcOutcome::single_log(
ReflectResult {
ran: false,
summary: format!("enrichment failed: {e}"),
goals,
},
"reflect failed",
));
}
};
let goals = store::load(&workspace_dir).await.unwrap_or_default();
Ok(RpcOutcome::single_log(
ReflectResult {
ran: true,
summary,
goals,
},
"reflect complete",
))
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn list_add_edit_delete_flow() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
// Starts empty.
let listed = list(dir).await.unwrap();
assert!(listed.value.is_empty());
// Add returns an id and the updated list.
let added = add(dir, "ship the desktop app").await.unwrap();
let id = added.value.id.clone();
assert_eq!(added.value.goals.items.len(), 1);
// Edit by id.
let edited = edit(dir, &id, "ship the app to all platforms")
.await
.unwrap();
assert_eq!(edited.value.items[0].text, "ship the app to all platforms");
// Delete by id leaves the list empty.
let deleted = delete(dir, &id).await.unwrap();
assert!(deleted.value.is_empty());
// Unknown id is an error.
assert!(edit(dir, "nope", "x").await.is_err());
assert!(delete(dir, "nope").await.is_err());
}
}
+256
View File
@@ -0,0 +1,256 @@
//! Controller schemas + JSON-RPC handlers for the `memory_goals` namespace.
//!
//! Methods are exposed as `openhuman.memory_goals_<function>`:
//! `list`, `add`, `edit`, `delete`, `reflect`. Handlers load the active
//! config (for `workspace_dir`), delegate to [`super::ops`], and serialise
//! the [`RpcOutcome`] into the CLI-compatible JSON shape.
use serde::de::DeserializeOwned;
use serde_json::{Map, Value};
use super::ops;
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
/// All `memory_goals` controller schemas (advertised to CLI + RPC consumers).
pub fn all_memory_goals_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list"),
schemas("add"),
schemas("edit"),
schemas("delete"),
schemas("reflect"),
]
}
/// Registered `memory_goals` controllers (schema + handler pairs).
pub fn all_memory_goals_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
RegisteredController {
schema: schemas("add"),
handler: handle_add,
},
RegisteredController {
schema: schemas("edit"),
handler: handle_edit,
},
RegisteredController {
schema: schemas("delete"),
handler: handle_delete,
},
RegisteredController {
schema: schemas("reflect"),
handler: handle_reflect,
},
]
}
/// Schema definitions for every `memory_goals` function.
fn schemas(function: &str) -> ControllerSchema {
match function {
"list" => ControllerSchema {
namespace: "memory_goals",
function: "list",
description: "List the agent's long-term goals for working with the user.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "items",
ty: TypeSchema::Json,
comment: "The current goals as a bare document: { items: [{ id, text }] }.",
required: true,
}],
},
"add" => ControllerSchema {
namespace: "memory_goals",
function: "add",
description: "Add a new long-term goal item.",
inputs: vec![FieldSchema {
name: "text",
ty: TypeSchema::String,
comment: "The goal text — one concise sentence.",
required: true,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{ id, goals } — assigned id plus the updated list.",
required: true,
}],
},
"edit" => ControllerSchema {
namespace: "memory_goals",
function: "edit",
description: "Edit an existing long-term goal by id.",
inputs: vec![
FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "The goal id to edit (e.g. 'g1').",
required: true,
},
FieldSchema {
name: "text",
ty: TypeSchema::String,
comment: "The new goal text.",
required: true,
},
],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "CLI-envelope { result: { items }, logs } — the updated list.",
required: true,
}],
},
"delete" => ControllerSchema {
namespace: "memory_goals",
function: "delete",
description: "Delete a long-term goal by id.",
inputs: vec![FieldSchema {
name: "id",
ty: TypeSchema::String,
comment: "The goal id to delete (e.g. 'g1').",
required: true,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "CLI-envelope { result: { items }, logs } — the updated list.",
required: true,
}],
},
"reflect" => ControllerSchema {
namespace: "memory_goals",
function: "reflect",
description: "Run the goals enrichment agent now and return the updated list.",
inputs: vec![FieldSchema {
name: "context",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment:
"Optional context/prompt to enrich from (defaults to a generic review nudge).",
required: false,
}],
outputs: vec![FieldSchema {
name: "result",
ty: TypeSchema::Json,
comment: "{ ran, summary, goals } — outcome of the enrichment pass.",
required: true,
}],
},
other => panic!("unknown memory_goals function: {other}"),
}
}
// ── Handlers ─────────────────────────────────────────────────────────────
fn handle_list(_params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(ops::list(&config.workspace_dir).await?)
})
}
fn handle_add(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<AddParams>(Value::Object(params))?;
to_json(ops::add(&config.workspace_dir, &req.text).await?)
})
}
fn handle_edit(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<EditParams>(Value::Object(params))?;
to_json(ops::edit(&config.workspace_dir, &req.id, &req.text).await?)
})
}
fn handle_delete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<DeleteParams>(Value::Object(params))?;
to_json(ops::delete(&config.workspace_dir, &req.id).await?)
})
}
fn handle_reflect(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let req = parse_value::<ReflectParams>(Value::Object(params))?;
to_json(ops::reflect_now(&config, req.context).await?)
})
}
// ── Param structs + helpers ──────────────────────────────────────────────
#[derive(serde::Deserialize)]
struct AddParams {
text: String,
}
#[derive(serde::Deserialize)]
struct EditParams {
id: String,
text: String,
}
#[derive(serde::Deserialize)]
struct DeleteParams {
id: String,
}
#[derive(serde::Deserialize)]
struct ReflectParams {
#[serde(default)]
context: Option<String>,
}
fn parse_value<T: DeserializeOwned>(v: Value) -> Result<T, String> {
serde_json::from_value(v).map_err(|e| format!("invalid params: {e}"))
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registers_all_five_controllers() {
let controllers = all_memory_goals_registered_controllers();
assert_eq!(controllers.len(), 5);
let methods: Vec<String> = controllers
.iter()
.map(|c| format!("{}.{}", c.schema.namespace, c.schema.function))
.collect();
for expected in [
"memory_goals.list",
"memory_goals.add",
"memory_goals.edit",
"memory_goals.delete",
"memory_goals.reflect",
] {
assert!(
methods.contains(&expected.to_string()),
"missing {expected}"
);
}
}
#[test]
fn schemas_and_controllers_stay_in_sync() {
assert_eq!(
all_memory_goals_controller_schemas().len(),
all_memory_goals_registered_controllers().len()
);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Persistence for the long-term goals list.
//!
//! The goals list lives in a single compact markdown file,
//! `MEMORY_GOALS.md`, in the user's `workspace_dir` (the same root as
//! `MEMORY.md` / `PROFILE.md`). The file is intentionally tiny — capped at
//! ~500 tokens — so it stays cheap to read and easy for a human to edit.
//!
//! All mutations go through load → mutate → [`save`], which re-enforces the
//! size + item-count caps on every write. Trimming drops the *oldest* items
//! first (front of the list) and logs what it removed; it never silently
//! truncates.
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::sync::Mutex;
use super::types::GoalsDoc;
/// Serialises load→mutate→save sequences so concurrent callers (user edits via
/// RPC/tools and background `spawn_enrich_goals`) can't clobber each other.
fn goals_mutation_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
/// File name of the goals document inside `workspace_dir`.
pub const GOALS_FILE: &str = "MEMORY_GOALS.md";
/// Hard ceiling on the rendered file size. ~2000 chars ≈ ~500 tokens, which
/// keeps the document inside the "200500 token" budget from the spec.
pub const GOALS_FILE_MAX_CHARS: usize = 2000;
/// Maximum number of goal items. A long-term goals list should be short and
/// focused; beyond this we drop the oldest entries.
pub const GOALS_MAX_ITEMS: usize = 8;
/// Absolute path to `MEMORY_GOALS.md` within `workspace_dir`.
pub fn goals_path(workspace_dir: &Path) -> PathBuf {
workspace_dir.join(GOALS_FILE)
}
/// Verify that the resolved goals path stays inside `workspace_dir`,
/// defending against symlink-based escapes. Returns the validated path.
fn validate_within_workspace(workspace_dir: &Path) -> Result<PathBuf, String> {
let path = goals_path(workspace_dir);
// Canonicalize the parent (the workspace) — the file itself may not
// exist yet on first write.
let workspace_canon = workspace_dir
.canonicalize()
.unwrap_or_else(|_| workspace_dir.to_path_buf());
let parent = path.parent().unwrap_or(workspace_dir);
let parent_canon = parent
.canonicalize()
.unwrap_or_else(|_| parent.to_path_buf());
if !parent_canon.starts_with(&workspace_canon) {
return Err(format!(
"[memory_goals] goals path resolves outside workspace: {path:?}"
));
}
// If the file already exists as a symlink, ensure its target also stays
// inside the workspace — a symlinked MEMORY_GOALS.md could otherwise
// read/write outside the boundary even with a valid parent dir.
if let Ok(meta) = std::fs::symlink_metadata(&path) {
if meta.file_type().is_symlink() {
let resolved = path.canonicalize().map_err(|e| {
format!("[memory_goals] failed to resolve goals symlink {path:?}: {e}")
})?;
if !resolved.starts_with(&workspace_canon) {
return Err(format!(
"[memory_goals] goals symlink resolves outside workspace: {resolved:?}"
));
}
}
}
Ok(path)
}
/// Load the goals document from disk. Returns an empty document when the
/// file does not exist yet (first run).
pub async fn load(workspace_dir: &Path) -> Result<GoalsDoc, String> {
let path = validate_within_workspace(workspace_dir)?;
match tokio::fs::read_to_string(&path).await {
Ok(body) => {
let doc = GoalsDoc::parse(&body);
log::debug!(
"[memory_goals] loaded {} item(s) from {path:?}",
doc.items.len()
);
Ok(doc)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
log::debug!("[memory_goals] no goals file at {path:?} — starting empty");
Ok(GoalsDoc::default())
}
Err(e) => Err(format!("[memory_goals] failed to read {path:?}: {e}")),
}
}
/// Enforce the item-count and byte-size caps on `doc`, dropping the oldest
/// items as needed. Returns the list of dropped item ids (for logging).
fn enforce_caps(doc: &mut GoalsDoc) -> Vec<String> {
let mut dropped = Vec::new();
// 1. Item-count cap.
while doc.items.len() > GOALS_MAX_ITEMS {
let removed = doc.items.remove(0);
dropped.push(removed.id);
}
// 2. Byte-size cap — keep removing the oldest until the rendered file
// fits. Always leave at least one item if any remain so the file
// isn't pointlessly emptied by a single oversized entry.
while doc.render().len() > GOALS_FILE_MAX_CHARS && doc.items.len() > 1 {
let removed = doc.items.remove(0);
dropped.push(removed.id);
}
dropped
}
/// Persist `doc` to disk, enforcing caps first. The `doc` is mutated in
/// place to reflect any cap trimming so the caller's view matches disk.
pub async fn save(workspace_dir: &Path, doc: &mut GoalsDoc) -> Result<(), String> {
let path = validate_within_workspace(workspace_dir)?;
let dropped = enforce_caps(doc);
if !dropped.is_empty() {
log::warn!(
"[memory_goals] dropped {} oldest item(s) to fit caps (max_items={}, max_chars={}): {:?}",
dropped.len(),
GOALS_MAX_ITEMS,
GOALS_FILE_MAX_CHARS,
dropped
);
}
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| format!("[memory_goals] failed to create {parent:?}: {e}"))?;
}
let body = doc.render();
tokio::fs::write(&path, &body)
.await
.map_err(|e| format!("[memory_goals] failed to write {path:?}: {e}"))?;
log::debug!(
"[memory_goals] saved {} item(s) ({} bytes) to {path:?}",
doc.items.len(),
body.len()
);
Ok(())
}
/// Add a goal, persist, and return `(new_id, updated_doc)`.
pub async fn add(workspace_dir: &Path, text: &str) -> Result<(String, GoalsDoc), String> {
let _guard = goals_mutation_lock().lock().await;
let mut doc = load(workspace_dir).await?;
let id = doc.add(text)?;
save(workspace_dir, &mut doc).await?;
log::info!("[memory_goals] added goal id={id}");
Ok((id, doc))
}
/// Edit a goal's text, persist, and return the updated document.
pub async fn edit(workspace_dir: &Path, id: &str, text: &str) -> Result<GoalsDoc, String> {
let _guard = goals_mutation_lock().lock().await;
let mut doc = load(workspace_dir).await?;
doc.edit(id, text)?;
save(workspace_dir, &mut doc).await?;
log::info!("[memory_goals] edited goal id={id}");
Ok(doc)
}
/// Delete a goal, persist, and return the updated document.
pub async fn delete(workspace_dir: &Path, id: &str) -> Result<GoalsDoc, String> {
let _guard = goals_mutation_lock().lock().await;
let mut doc = load(workspace_dir).await?;
doc.delete(id)?;
save(workspace_dir, &mut doc).await?;
log::info!("[memory_goals] deleted goal id={id}");
Ok(doc)
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn load_empty_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let doc = load(tmp.path()).await.unwrap();
assert!(doc.is_empty());
}
#[tokio::test]
async fn add_edit_delete_round_trip_to_disk() {
let tmp = tempfile::tempdir().unwrap();
let (id, _) = add(tmp.path(), "ship the app").await.unwrap();
let reloaded = load(tmp.path()).await.unwrap();
assert_eq!(reloaded.items.len(), 1);
assert_eq!(reloaded.items[0].text, "ship the app");
edit(tmp.path(), &id, "ship the app to all platforms")
.await
.unwrap();
let reloaded = load(tmp.path()).await.unwrap();
assert_eq!(reloaded.items[0].text, "ship the app to all platforms");
delete(tmp.path(), &id).await.unwrap();
let reloaded = load(tmp.path()).await.unwrap();
assert!(reloaded.is_empty());
}
#[tokio::test]
async fn save_enforces_item_count_cap() {
let tmp = tempfile::tempdir().unwrap();
let mut doc = GoalsDoc::default();
for i in 0..(GOALS_MAX_ITEMS + 3) {
doc.add(&format!("goal number {i}")).unwrap();
}
save(tmp.path(), &mut doc).await.unwrap();
assert_eq!(doc.items.len(), GOALS_MAX_ITEMS);
// The oldest items (goal number 0..2) should have been dropped.
assert!(doc.items.iter().all(|i| i.text != "goal number 0"));
}
#[tokio::test]
async fn save_enforces_byte_cap() {
let tmp = tempfile::tempdir().unwrap();
let mut doc = GoalsDoc::default();
// Two large items that together exceed the byte cap.
let big = "x".repeat(GOALS_FILE_MAX_CHARS);
doc.add(&big).unwrap();
doc.add(&big).unwrap();
save(tmp.path(), &mut doc).await.unwrap();
// At least one item dropped; never fully emptied.
assert_eq!(doc.items.len(), 1);
}
}
+239
View File
@@ -0,0 +1,239 @@
//! Agent-facing tools for the long-term goals list.
//!
//! These are the tools the background `goals_agent` (and, when allowed, the
//! main agent) uses to read and mutate the goals list over multiple turns.
//! They are thin wrappers around [`super::store`] — all cap enforcement and
//! persistence live there. Each tool is sandboxed to a single `workspace_dir`
//! captured at construction time.
use std::path::PathBuf;
use async_trait::async_trait;
use serde_json::json;
use super::store;
use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult};
/// `goals_list` — read the current long-term goals list.
pub struct GoalsListTool {
workspace_dir: PathBuf,
}
impl GoalsListTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalsListTool {
fn name(&self) -> &str {
"goals_list"
}
fn description(&self) -> &str {
"List the user's current long-term goals. Returns each goal's id and \
text. Always call this before adding/editing/deleting so you address \
the right ids and avoid duplicates."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({ "type": "object", "properties": {} })
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::ReadOnly
}
async fn execute(&self, _args: serde_json::Value) -> anyhow::Result<ToolResult> {
log::debug!("[memory_goals] tool=goals_list");
let doc = match store::load(&self.workspace_dir).await {
Ok(doc) => doc,
Err(e) => return Ok(ToolResult::error(e)),
};
Ok(ToolResult::success(doc.render()))
}
}
/// `goals_add` — add a new long-term goal.
pub struct GoalsAddTool {
workspace_dir: PathBuf,
}
impl GoalsAddTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalsAddTool {
fn name(&self) -> &str {
"goals_add"
}
fn description(&self) -> &str {
"Add a new long-term goal (one concise sentence describing a durable \
objective for working with the user). Returns the assigned goal id."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string", "description": "The goal text — one concise sentence." }
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let Some(text) = args.get("text").and_then(|v| v.as_str()) else {
return Ok(ToolResult::error("Missing 'text' parameter"));
};
log::debug!("[memory_goals] tool=goals_add");
match store::add(&self.workspace_dir, text).await {
Ok((id, _)) => Ok(ToolResult::success(format!("Added goal '{id}'."))),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
/// `goals_edit` — replace the text of an existing goal.
pub struct GoalsEditTool {
workspace_dir: PathBuf,
}
impl GoalsEditTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalsEditTool {
fn name(&self) -> &str {
"goals_edit"
}
fn description(&self) -> &str {
"Edit an existing long-term goal by id, replacing its text. Use \
goals_list first to find the id."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["id", "text"],
"properties": {
"id": { "type": "string", "description": "The goal id to edit (e.g. 'g1')." },
"text": { "type": "string", "description": "The new goal text." }
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let Some(id) = args.get("id").and_then(|v| v.as_str()) else {
return Ok(ToolResult::error("Missing 'id' parameter"));
};
let Some(text) = args.get("text").and_then(|v| v.as_str()) else {
return Ok(ToolResult::error("Missing 'text' parameter"));
};
log::debug!("[memory_goals] tool=goals_edit id={id}");
match store::edit(&self.workspace_dir, id, text).await {
Ok(_) => Ok(ToolResult::success(format!("Edited goal '{id}'."))),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
/// `goals_delete` — remove a goal by id.
pub struct GoalsDeleteTool {
workspace_dir: PathBuf,
}
impl GoalsDeleteTool {
pub fn new(workspace_dir: PathBuf) -> Self {
Self { workspace_dir }
}
}
#[async_trait]
impl Tool for GoalsDeleteTool {
fn name(&self) -> &str {
"goals_delete"
}
fn description(&self) -> &str {
"Delete a long-term goal by id (e.g. when it is completed or no longer \
relevant). Use goals_list first to find the id."
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "string", "description": "The goal id to delete (e.g. 'g1')." }
}
})
}
fn permission_level(&self) -> PermissionLevel {
PermissionLevel::Write
}
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
let Some(id) = args.get("id").and_then(|v| v.as_str()) else {
return Ok(ToolResult::error("Missing 'id' parameter"));
};
log::debug!("[memory_goals] tool=goals_delete id={id}");
match store::delete(&self.workspace_dir, id).await {
Ok(_) => Ok(ToolResult::success(format!("Deleted goal '{id}'."))),
Err(e) => Ok(ToolResult::error(e)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn add_then_list_reflects_change() {
let tmp = tempfile::tempdir().unwrap();
let add = GoalsAddTool::new(tmp.path().to_path_buf());
let res = add
.execute(json!({ "text": "help ship the app" }))
.await
.unwrap();
assert!(!res.is_error);
let list = GoalsListTool::new(tmp.path().to_path_buf());
let res = list.execute(json!({})).await.unwrap();
assert!(res.text().contains("help ship the app"));
}
#[tokio::test]
async fn edit_and_delete_unknown_id_error() {
let tmp = tempfile::tempdir().unwrap();
let edit = GoalsEditTool::new(tmp.path().to_path_buf());
let res = edit
.execute(json!({ "id": "g9", "text": "x" }))
.await
.unwrap();
assert!(res.is_error);
let del = GoalsDeleteTool::new(tmp.path().to_path_buf());
let res = del.execute(json!({ "id": "g9" })).await.unwrap();
assert!(res.is_error);
}
}
+222
View File
@@ -0,0 +1,222 @@
//! Domain types for the agent's long-term goals list.
//!
//! Goals are a small, ordered list of durable objectives the agent holds
//! when interacting with the user. They are persisted as a compact markdown
//! document (`MEMORY_GOALS.md`) — see [`super::store`] — and surfaced over
//! RPC + agent tools. Each item carries a stable short id so edit/delete
//! operations can address a specific line without depending on ordering.
use serde::{Deserialize, Serialize};
/// A single long-term goal item.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoalItem {
/// Stable short id (e.g. `g1`). Used as the dedupe/address key for
/// `edit`/`delete`. Rendered inline in the markdown as `- [g1] …`.
pub id: String,
/// The goal text — one concise sentence.
pub text: String,
}
impl GoalItem {
/// Construct a goal item from an id + text, trimming surrounding
/// whitespace from the text.
pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
Self {
id: id.into(),
text: text.into().trim().to_string(),
}
}
}
/// The full goals document — an ordered list of [`GoalItem`]s.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct GoalsDoc {
/// Ordered goal items. Order is meaningful for rendering and cap
/// trimming (oldest = front).
pub items: Vec<GoalItem>,
}
/// Markdown header rendered at the top of `MEMORY_GOALS.md`.
const HEADER: &str = "# Long-term Goals";
impl GoalsDoc {
/// Parse a `MEMORY_GOALS.md` body into a [`GoalsDoc`].
///
/// Recognised item lines look like `- [g1] do the thing`. Lines that
/// don't match (the header, blank lines, free prose) are ignored so a
/// hand-edited file degrades gracefully rather than erroring.
pub fn parse(body: &str) -> Self {
let mut items = Vec::new();
for line in body.lines() {
let trimmed = line.trim();
// Strip the leading list marker, if present.
let rest = match trimmed.strip_prefix("- ") {
Some(r) => r.trim(),
None => continue,
};
// Expect `[id] text`.
let Some(after_open) = rest.strip_prefix('[') else {
continue;
};
let Some(close_idx) = after_open.find(']') else {
continue;
};
let id = after_open[..close_idx].trim();
let text = after_open[close_idx + 1..].trim();
if id.is_empty() || text.is_empty() {
continue;
}
items.push(GoalItem::new(id, text));
}
Self { items }
}
/// Render the document back to markdown suitable for `MEMORY_GOALS.md`.
pub fn render(&self) -> String {
let mut out = String::from(HEADER);
out.push_str("\n\n");
for item in &self.items {
out.push_str(&format!("- [{}] {}\n", item.id, item.text));
}
out
}
/// Whether the list currently has no items. Used to drive the
/// "first run / initial population" enrichment behaviour.
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
/// Allocate the next free `g<N>` id not already used in the list.
pub fn next_id(&self) -> String {
let mut n = self.items.len() + 1;
loop {
let candidate = format!("g{n}");
if !self.items.iter().any(|i| i.id == candidate) {
return candidate;
}
n += 1;
}
}
/// Append a new goal, returning the assigned id. Text is trimmed; an
/// empty text is rejected.
pub fn add(&mut self, text: &str) -> Result<String, String> {
let text = text.trim();
if text.is_empty() {
return Err("goal text must not be empty".to_string());
}
if text.contains('\n') || text.contains('\r') {
return Err("goal text must be a single line".to_string());
}
let id = self.next_id();
self.items.push(GoalItem::new(&id, text));
Ok(id)
}
/// Replace the text of the goal with `id`. Returns an error if the id
/// is unknown or the new text is empty.
pub fn edit(&mut self, id: &str, text: &str) -> Result<(), String> {
let text = text.trim();
if text.is_empty() {
return Err("goal text must not be empty".to_string());
}
if text.contains('\n') || text.contains('\r') {
return Err("goal text must be a single line".to_string());
}
let item = self
.items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| format!("no goal with id '{id}'"))?;
item.text = text.to_string();
Ok(())
}
/// Delete the goal with `id`. Returns an error if the id is unknown.
pub fn delete(&mut self, id: &str) -> Result<(), String> {
let before = self.items.len();
self.items.retain(|i| i.id != id);
if self.items.len() == before {
return Err(format!("no goal with id '{id}'"));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_round_trips_render() {
let mut doc = GoalsDoc::default();
doc.add("ship the desktop app").unwrap();
doc.add("keep the rust core authoritative").unwrap();
let rendered = doc.render();
let reparsed = GoalsDoc::parse(&rendered);
assert_eq!(doc, reparsed);
}
#[test]
fn parse_ignores_non_item_lines() {
let body = "# Long-term Goals\n\nsome stray prose\n- [g1] real goal\n- malformed line\n";
let doc = GoalsDoc::parse(body);
assert_eq!(doc.items.len(), 1);
assert_eq!(doc.items[0].id, "g1");
assert_eq!(doc.items[0].text, "real goal");
}
#[test]
fn add_assigns_unique_ids() {
let mut doc = GoalsDoc::default();
let a = doc.add("a").unwrap();
let b = doc.add("b").unwrap();
assert_ne!(a, b);
assert_eq!(doc.items.len(), 2);
}
#[test]
fn add_rejects_empty_text() {
let mut doc = GoalsDoc::default();
assert!(doc.add(" ").is_err());
}
#[test]
fn add_and_edit_reject_multiline_text() {
let mut doc = GoalsDoc::default();
// A newline-bearing goal would inject extra "- [..]" list lines on
// reload, corrupting the stored shape — reject it outright.
assert!(doc.add("line one\n- [x] injected").is_err());
let id = doc.add("legit goal").unwrap();
assert!(doc.edit(&id, "still\rinjected").is_err());
}
#[test]
fn edit_updates_known_id_and_rejects_unknown() {
let mut doc = GoalsDoc::default();
let id = doc.add("old").unwrap();
doc.edit(&id, "new").unwrap();
assert_eq!(doc.items[0].text, "new");
assert!(doc.edit("nope", "x").is_err());
}
#[test]
fn delete_removes_known_id_and_rejects_unknown() {
let mut doc = GoalsDoc::default();
let id = doc.add("x").unwrap();
doc.delete(&id).unwrap();
assert!(doc.is_empty());
assert!(doc.delete("nope").is_err());
}
#[test]
fn next_id_avoids_collision_with_custom_ids() {
let mut doc = GoalsDoc {
items: vec![GoalItem::new("g1", "a"), GoalItem::new("g2", "b")],
};
let id = doc.add("c").unwrap();
assert_eq!(id, "g3");
}
}
+1
View File
@@ -70,6 +70,7 @@ pub mod memory_archivist;
pub mod memory_conversations;
pub mod memory_diff;
pub mod memory_entities;
pub mod memory_goals;
pub mod memory_graph;
pub mod memory_queue;
pub mod memory_search;
+1
View File
@@ -31,6 +31,7 @@ pub use crate::openhuman::learning::tools::*;
pub use crate::openhuman::mcp_registry::tools::*;
pub use crate::openhuman::memory::tools::*;
pub use crate::openhuman::memory_diff::tools::*;
pub use crate::openhuman::memory_goals::tools::*;
pub use crate::openhuman::memory_search::*;
pub use crate::openhuman::monitor::tools::*;
pub use crate::openhuman::people::tools::*;
+19
View File
@@ -549,6 +549,25 @@ pub fn all_tools_with_runtime(
security.clone(),
)));
// Long-term goals list tools. Used primarily by the background
// `goals_agent` (which filters to these via its `[tools] named`
// allowlist); also available to the main agent for explicit edits.
{
let goals_dir = root_config.workspace_dir.clone();
tools.push(Box::new(
crate::openhuman::memory_goals::GoalsListTool::new(goals_dir.clone()),
));
tools.push(Box::new(crate::openhuman::memory_goals::GoalsAddTool::new(
goals_dir.clone(),
)));
tools.push(Box::new(
crate::openhuman::memory_goals::GoalsEditTool::new(goals_dir.clone()),
));
tools.push(Box::new(
crate::openhuman::memory_goals::GoalsDeleteTool::new(goals_dir),
));
}
if browser_config.enabled {
// Unified web-access allowlist (merge fetch + browser firewalls): the
// browser tool shares the single `http_request.allowed_domains` host