mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(memory_goals): long-term goals list + enrichment agent + Brain UI (#3948)
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.',
|
||||
|
||||
@@ -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': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 l’agent pour travailler avec vous. Modifiez-les ici ou laissez l’agent 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 l’instant. Ajoutez-en un ou utilisez le bouton Réfléchir pour les générer à partir du contexte récent.',
|
||||
'brain.goals.editGoal': 'Modifier l’objectif',
|
||||
'brain.goals.deleteGoal': 'Supprimer l’objectif',
|
||||
'brain.goals.actionError': 'Une erreur s’est produite. Veuillez réessayer.',
|
||||
'brain.tabs.sources': 'Sources',
|
||||
'brain.tabs.sync': 'Synchronisation',
|
||||
'brain.empty':
|
||||
|
||||
@@ -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': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 dell’agente 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':
|
||||
|
||||
@@ -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': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.',
|
||||
|
||||
@@ -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ęć.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.',
|
||||
|
||||
@@ -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': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。',
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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' }]);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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.",
|
||||
),
|
||||
|
||||
@@ -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",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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`, ~200–500 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};
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 "200–500 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user