From 3c98c9815846ca481f6f59a95b33183838f5659e Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:58:04 -0700 Subject: [PATCH] feat(memory_goals): long-term goals list + enrichment agent + Brain UI (#3948) --- .../intelligence/GoalsPanel.test.tsx | 169 +++++ .../components/intelligence/GoalsPanel.tsx | 300 +++++++++ app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 14 + app/src/lib/i18n/de.ts | 14 + app/src/lib/i18n/en.ts | 14 + app/src/lib/i18n/es.ts | 14 + app/src/lib/i18n/fr.ts | 14 + app/src/lib/i18n/hi.ts | 14 + app/src/lib/i18n/id.ts | 14 + app/src/lib/i18n/it.ts | 14 + app/src/lib/i18n/ko.ts | 14 + app/src/lib/i18n/pl.ts | 14 + app/src/lib/i18n/pt.ts | 14 + app/src/lib/i18n/ru.ts | 14 + app/src/lib/i18n/zh-CN.ts | 12 + app/src/pages/Brain.tsx | 10 + app/src/services/api/goalsApi.test.ts | 113 ++++ app/src/services/api/goalsApi.ts | 114 ++++ docs/TEST-COVERAGE-MATRIX.md | 7 + scripts/debug/cli.sh | 6 + scripts/debug/goals-live-cases.md | 86 +++ scripts/debug/goals-live.mjs | 595 ++++++++++++++++++ src/core/all.rs | 6 + src/openhuman/about_app/catalog_data.rs | 18 + .../agent/harness/archivist/lifecycle.rs | 23 + src/openhuman/agent/prompts/render_helpers.rs | 5 + src/openhuman/agent/prompts/sections.rs | 7 + .../agents/goals_agent/agent.toml | 23 + .../agent_registry/agents/goals_agent/mod.rs | 1 + .../agents/goals_agent/prompt.md | 32 + .../agents/goals_agent/prompt.rs | 72 +++ src/openhuman/agent_registry/agents/loader.rs | 5 + src/openhuman/agent_registry/agents/mod.rs | 1 + src/openhuman/config/schema/learning.rs | 12 + .../config/schema/load/env_overlay.rs | 8 + src/openhuman/mcp_server/resources.rs | 6 + src/openhuman/memory_goals/enrich.rs | 149 +++++ src/openhuman/memory_goals/mod.rs | 30 + src/openhuman/memory_goals/ops.rs | 147 +++++ src/openhuman/memory_goals/schemas.rs | 256 ++++++++ src/openhuman/memory_goals/store.rs | 239 +++++++ src/openhuman/memory_goals/tools.rs | 239 +++++++ src/openhuman/memory_goals/types.rs | 222 +++++++ src/openhuman/mod.rs | 1 + src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 19 + 47 files changed, 3114 insertions(+) create mode 100644 app/src/components/intelligence/GoalsPanel.test.tsx create mode 100644 app/src/components/intelligence/GoalsPanel.tsx create mode 100644 app/src/services/api/goalsApi.test.ts create mode 100644 app/src/services/api/goalsApi.ts create mode 100644 scripts/debug/goals-live-cases.md create mode 100644 scripts/debug/goals-live.mjs create mode 100644 src/openhuman/agent_registry/agents/goals_agent/agent.toml create mode 100644 src/openhuman/agent_registry/agents/goals_agent/mod.rs create mode 100644 src/openhuman/agent_registry/agents/goals_agent/prompt.md create mode 100644 src/openhuman/agent_registry/agents/goals_agent/prompt.rs create mode 100644 src/openhuman/memory_goals/enrich.rs create mode 100644 src/openhuman/memory_goals/mod.rs create mode 100644 src/openhuman/memory_goals/ops.rs create mode 100644 src/openhuman/memory_goals/schemas.rs create mode 100644 src/openhuman/memory_goals/store.rs create mode 100644 src/openhuman/memory_goals/tools.rs create mode 100644 src/openhuman/memory_goals/types.rs diff --git a/app/src/components/intelligence/GoalsPanel.test.tsx b/app/src/components/intelligence/GoalsPanel.test.tsx new file mode 100644 index 000000000..47e614048 --- /dev/null +++ b/app/src/components/intelligence/GoalsPanel.test.tsx @@ -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('', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders the empty state once loading resolves', async () => { + api.list.mockResolvedValueOnce([]); + render(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + await screen.findByText(/No goals yet/); + + fireEvent.click(screen.getByRole('button', { name: /Reflect/ })); + expect(await screen.findByRole('alert')).toHaveTextContent('reflect crashed'); + }); +}); diff --git a/app/src/components/intelligence/GoalsPanel.tsx b/app/src/components/intelligence/GoalsPanel.tsx new file mode 100644 index 000000000..041325576 --- /dev/null +++ b/app/src/components/intelligence/GoalsPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + + const [newText, setNewText] = useState(''); + const [adding, setAdding] = useState(false); + const [reflecting, setReflecting] = useState(false); + const [reflectSummary, setReflectSummary] = useState(null); + + const [editingId, setEditingId] = useState(null); + const [editText, setEditText] = useState(''); + const [busyId, setBusyId] = useState(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 ( +
+
+ {/* Header */} +
+
+

+ {t('brain.goals.title')} +

+

+ {t('brain.goals.description')} +

+
+ +
+ + {/* Add row */} +
+ setNewText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void handleAdd(); + }} + /> + +
+ + {/* Errors / reflect summary */} + {actionError && ( +
+ {actionError} +
+ )} + {reflectSummary && ( +
+ {reflectSummary} +
+ )} + + {/* List */} +
+ {loading ? ( +
+
+ {t('common.loading')} +
+ ) : error ? ( +
+ {error} +
+ ) : goals.length === 0 ? ( +

+ {t('brain.goals.empty')} +

+ ) : ( +
    + {goals.map(goal => ( +
  • + {editingId === goal.id ? ( +
    + setEditText(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') void saveEdit(goal.id); + if (e.key === 'Escape') cancelEdit(); + }} + autoFocus + /> + + +
    + ) : ( +
    + + {goal.text} + +
    + + +
    +
    + )} +
  • + ))} +
+ )} +
+
+
+ ); +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index d205a9483..393d202b8 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -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': 'دماغك فارغ في الوقت الحالي — قم بربط مصدر لبدء بناء الذاكرة.', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 684f04127..b14158b8f 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -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': 'আপনার ব্রেইন এখন খালি — মেমরি তৈরি শুরু করতে একটি উৎস সংযুক্ত করুন।', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 4a3a98577..885db709d 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -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.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b9094f288..01ae6dd38 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -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', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 81be41660..ddff5829c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -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': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 19123b03c..0d416bb6a 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -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': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index e7e9dea8c..6702e2589 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -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': 'आपका ब्रेन अभी खाली है — मेमोरी बनाना शुरू करने के लिए कोई स्रोत कनेक्ट करें।', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index f4a1c5613..0a0ec5946 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -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.', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 36b02447f..4bda9cf33 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -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': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 48676be0b..a53081415 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -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': '아직 브레인이 비어 있습니다 — 소스를 연결하여 메모리를 만들어 보세요.', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9fefb0036..1c553c67a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -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ęć.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index f06c01049..e9efe16a2 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -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': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 1a641ac5b..4f966ae2c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -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': 'Ваш мозг пока пуст — подключите источник, чтобы начать формировать память.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index e29ad9665..8c4cb48ad 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -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': '你的大脑暂时是空的——连接一个来源即可开始构建记忆。', diff --git a/app/src/pages/Brain.tsx b/app/src/pages/Brain.tsx index ef73f846d..c3022987b 100644 --- a/app/src/pages/Brain.tsx +++ b/app/src/pages/Brain.tsx @@ -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() {
)} + {activeTab === 'goals' && } + {activeTab === 'sources' && (
diff --git a/app/src/services/api/goalsApi.test.ts b/app/src/services/api/goalsApi.test.ts new file mode 100644 index 000000000..f07b7361f --- /dev/null +++ b/app/src/services/api/goalsApi.test.ts @@ -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' }]); + }); +}); diff --git a/app/src/services/api/goalsApi.ts b/app/src/services/api/goalsApi.ts new file mode 100644 index 000000000..ed099943f --- /dev/null +++ b/app/src/services/api/goalsApi.ts @@ -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 `/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>(params: T): Partial { + const out: Partial = {}; + for (const [k, v] of Object.entries(params)) { + if (v !== undefined) (out as Record)[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) ? (res as { result: unknown }).result : res; + if (!value || typeof value !== 'object') return []; + const v = value as Record; + // 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 => { + log('list'); + const res = await callCoreRpc({ method: 'openhuman.memory_goals_list', params: {} }); + return extractItems(res); + }, + + add: async (text: string): Promise => { + log('add'); + const res = await callCoreRpc({ + method: 'openhuman.memory_goals_add', + params: { text }, + }); + return extractItems(res); + }, + + edit: async (id: string, text: string): Promise => { + log('edit id=%s', id); + const res = await callCoreRpc({ + method: 'openhuman.memory_goals_edit', + params: { id, text }, + }); + return extractItems(res); + }, + + remove: async (id: string): Promise => { + log('delete id=%s', id); + const res = await callCoreRpc({ + method: 'openhuman.memory_goals_delete', + params: { id }, + }); + return extractItems(res); + }, + + reflect: async (context?: string): Promise => { + log('reflect hasContext=%s', Boolean(context)); + const res = await callCoreRpc({ + 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) + ? (res as { result: Record }).result + : ((res as Record) ?? {}); + return { + ran: Boolean(envelope?.ran), + summary: typeof envelope?.summary === 'string' ? envelope.summary : '', + items: extractItems(res), + }; + }, +}; diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index e861ff257..8cc00b94f 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -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 diff --git a/scripts/debug/cli.sh b/scripts/debug/cli.sh index 42fa1b3c4..c29f8988e 100755 --- a/scripts/debug/cli.sh +++ b/scripts/debug/cli.sh @@ -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 diff --git a/scripts/debug/goals-live-cases.md b/scripts/debug/goals-live-cases.md new file mode 100644 index 000000000..1d44d1cd0 --- /dev/null +++ b/scripts/debug/goals-live-cases.md @@ -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 /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 + `` markers) and `tool` results. + System prompts are hidden (length only) to keep output readable. diff --git a/scripts/debug/goals-live.mjs b/scripts/debug/goals-live.mjs new file mode 100644 index 000000000..b12ff694a --- /dev/null +++ b/scripts/debug/goals-live.mjs @@ -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 JSON-RPC endpoint (default: OPENHUMAN_CORE_RPC_URL or ${DEFAULT_RPC_URL}) + --token RPC bearer (default: OPENHUMAN_CORE_TOKEN or /core.token) + --workspace 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 Run only one case: ${ALL_CASES.join(", ")} (repeatable) + --reset Delete all existing goals before running + --context Context/prompt handed to the reflect enrichment agent + --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 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); +}); diff --git a/src/core/all.rs b/src/core/all.rs index 4e1898e4c..15881dbf3 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -215,6 +215,8 @@ fn build_registered_controllers() -> Vec { 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 { 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.", ), diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 445f01905..a25e433bc 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -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)", diff --git a/src/openhuman/agent/harness/archivist/lifecycle.rs b/src/openhuman/agent/harness/archivist/lifecycle.rs index 845803cdc..3298b9b2c 100644 --- a/src/openhuman/agent/harness/archivist/lifecycle.rs +++ b/src/openhuman/agent/harness/archivist/lifecycle.rs @@ -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. diff --git a/src/openhuman/agent/prompts/render_helpers.rs b/src/openhuman/agent/prompts/render_helpers.rs index c3c00bada..21bc61d43 100644 --- a/src/openhuman/agent/prompts/render_helpers.rs +++ b/src/openhuman/agent/prompts/render_helpers.rs @@ -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", _ => "", } } diff --git a/src/openhuman/agent/prompts/sections.rs b/src/openhuman/agent/prompts/sections.rs index 0634a862e..e42915c94 100644 --- a/src/openhuman/agent/prompts/sections.rs +++ b/src/openhuman/agent/prompts/sections.rs @@ -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 diff --git a/src/openhuman/agent_registry/agents/goals_agent/agent.toml b/src/openhuman/agent_registry/agents/goals_agent/agent.toml new file mode 100644 index 000000000..dea1ce4b8 --- /dev/null +++ b/src/openhuman/agent_registry/agents/goals_agent/agent.toml @@ -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"] diff --git a/src/openhuman/agent_registry/agents/goals_agent/mod.rs b/src/openhuman/agent_registry/agents/goals_agent/mod.rs new file mode 100644 index 000000000..8bf84783c --- /dev/null +++ b/src/openhuman/agent_registry/agents/goals_agent/mod.rs @@ -0,0 +1 @@ +pub mod prompt; diff --git a/src/openhuman/agent_registry/agents/goals_agent/prompt.md b/src/openhuman/agent_registry/agents/goals_agent/prompt.md new file mode 100644 index 000000000..cf5d3ced2 --- /dev/null +++ b/src/openhuman/agent_registry/agents/goals_agent/prompt.md @@ -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). diff --git a/src/openhuman/agent_registry/agents/goals_agent/prompt.rs b/src/openhuman/agent_registry/agents/goals_agent/prompt.rs new file mode 100644 index 000000000..db08fe67f --- /dev/null +++ b/src/openhuman/agent_registry/agents/goals_agent/prompt.rs @@ -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 { + 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 = 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")); + } +} diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index d50200ce6..b9d2da8e3 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -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"), diff --git a/src/openhuman/agent_registry/agents/mod.rs b/src/openhuman/agent_registry/agents/mod.rs index 71ab41e1a..6c0f25c43 100644 --- a/src/openhuman/agent_registry/agents/mod.rs +++ b/src/openhuman/agent_registry/agents/mod.rs @@ -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; diff --git a/src/openhuman/config/schema/learning.rs b/src/openhuman/config/schema/learning.rs index 92c8779b3..d91ec684b 100644 --- a/src/openhuman/config/schema/learning.rs +++ b/src/openhuman/config/schema/learning.rs @@ -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(), } } } diff --git a/src/openhuman/config/schema/load/env_overlay.rs b/src/openhuman/config/schema/load/env_overlay.rs index 8e2c84b40..927fb5814 100644 --- a/src/openhuman/config/schema/load/env_overlay.rs +++ b/src/openhuman/config/schema/load/env_overlay.rs @@ -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() { diff --git a/src/openhuman/mcp_server/resources.rs b/src/openhuman/mcp_server/resources.rs index b0977b508..e529884e3 100644 --- a/src/openhuman/mcp_server/resources.rs +++ b/src/openhuman/mcp_server/resources.rs @@ -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", diff --git a/src/openhuman/memory_goals/enrich.rs b/src/openhuman/memory_goals/enrich.rs new file mode 100644 index 000000000..38f7fd780 --- /dev/null +++ b/src/openhuman/memory_goals/enrich.rs @@ -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 { + // 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")); + } +} diff --git a/src/openhuman/memory_goals/mod.rs b/src/openhuman/memory_goals/mod.rs new file mode 100644 index 000000000..64011ac8d --- /dev/null +++ b/src/openhuman/memory_goals/mod.rs @@ -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}; diff --git a/src/openhuman/memory_goals/ops.rs b/src/openhuman/memory_goals/ops.rs new file mode 100644 index 000000000..627d1316a --- /dev/null +++ b/src/openhuman/memory_goals/ops.rs @@ -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, 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, 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, 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, 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, +) -> Result, 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()); + } +} diff --git a/src/openhuman/memory_goals/schemas.rs b/src/openhuman/memory_goals/schemas.rs new file mode 100644 index 000000000..4294240fc --- /dev/null +++ b/src/openhuman/memory_goals/schemas.rs @@ -0,0 +1,256 @@ +//! Controller schemas + JSON-RPC handlers for the `memory_goals` namespace. +//! +//! Methods are exposed as `openhuman.memory_goals_`: +//! `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 { + 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 { + 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) -> 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) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let req = parse_value::(Value::Object(params))?; + to_json(ops::add(&config.workspace_dir, &req.text).await?) + }) +} + +fn handle_edit(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let req = parse_value::(Value::Object(params))?; + to_json(ops::edit(&config.workspace_dir, &req.id, &req.text).await?) + }) +} + +fn handle_delete(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let req = parse_value::(Value::Object(params))?; + to_json(ops::delete(&config.workspace_dir, &req.id).await?) + }) +} + +fn handle_reflect(params: Map) -> ControllerFuture { + Box::pin(async move { + let config = config_rpc::load_config_with_timeout().await?; + let req = parse_value::(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, +} + +fn parse_value(v: Value) -> Result { + serde_json::from_value(v).map_err(|e| format!("invalid params: {e}")) +} + +fn to_json(outcome: RpcOutcome) -> Result { + 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 = 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() + ); + } +} diff --git a/src/openhuman/memory_goals/store.rs b/src/openhuman/memory_goals/store.rs new file mode 100644 index 000000000..6ce5dcc15 --- /dev/null +++ b/src/openhuman/memory_goals/store.rs @@ -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> = 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 { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/openhuman/memory_goals/tools.rs b/src/openhuman/memory_goals/tools.rs new file mode 100644 index 000000000..aefd81680 --- /dev/null +++ b/src/openhuman/memory_goals/tools.rs @@ -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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/openhuman/memory_goals/types.rs b/src/openhuman/memory_goals/types.rs new file mode 100644 index 000000000..55a3d9b4e --- /dev/null +++ b/src/openhuman/memory_goals/types.rs @@ -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, text: impl Into) -> 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, +} + +/// 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` 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 { + 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"); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 8611156ad..a4c4bda89 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -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; diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index b7b677dd9..f1476727d 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -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::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index e3492e54e..f0dd59f7e 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -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