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