feat(curator): additive-only tool scope; transcript shows User/Assistant only

Two related tightenings to the curator's behavior, both driven by user
questions about scope (2026-05-23):

1. **Tighten the prompt to extract beats only from User: lines.**

The transcript shows each message prefixed with role (User: / Assistant:).
The previous prompt instructed the model to capture beats but didn't
explicitly forbid using Assistant: content as a source. A small or
medium model could read 'It sounds like you had coffee with Sarah'
from an Assistant: line and turn it into a moment, even though that's
the assistant paraphrasing the user — not a user statement.

New prompt explicitly: Only User: lines are journal entries. Assistant:
lines are context for disambiguation only. Never create a record from
content that appears only in Assistant: text.

2. **Additive-only tool allowlist for the curator.**

The curator previously had access to the full journal tool set —
including update_*, delete_*, create_event, set_rag_scope, etc. The
architecture removed tools from the chat for exactly the reason that
confidently-wrong tool calls corrupt user data; the curator faces
the same risk async. Filtering the tool list at curator-time keeps
the boundary tight even if the system prompt fails to dissuade the
model from hallucinated tool names.

New _CURATOR_ALLOWED_TOOLS frozenset includes:
- Additive primary work: record_moment, create_note (handles both
  notes and tasks via status), log_work (appends to existing task
  timeline — additive on its own row), save_person, save_place,
  create_project, create_milestone.
- Read-only helpers needed for entity resolution: search_notes,
  search_projects, search_journal, list_tasks, list_projects,
  list_milestones, read_note, get_project, get_profile.

Explicitly excluded: every update_*, every delete_*, create_event
(calendar events need explicit user intent, not curator inference),
set_rag_scope, lookup/research_topic/search_images (different
surface entirely).

Two-layer enforcement: the system prompt lists what's available and
forbids the rest, AND the actual tools list passed to Ollama is
filtered to the allowlist. So even if the model hallucinates a
forbidden tool name, the call can't fire — execute_tool returns
'Unknown tool: <name>'.

Bonus cleanup: _format_transcript now skips system and tool-role
messages. They were noise for the curator's task (system prompts
are instructions, tool results are JSON from prior calls). The
narrowed transcript matches the contract the prompt enforces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 22:11:25 -04:00
parent 37596ce31c
commit d76f52b578
+80 -17
View File
@@ -42,28 +42,74 @@ logger = logging.getLogger(__name__)
# fixed transcript, not respond to evolving conversation).
_MAX_TOOL_ROUNDS = 4
# Curator tool allowlist. Additive operations only — no updates, no
# deletes. Risk model: the curator can be confidently wrong, and the
# user is not in the loop when it runs. Adds are easily undone by the
# user (delete the moment, delete the task). Updates and deletes are
# not — a curator that confidently overwrites a task title with the
# wrong value is worse than a curator that creates a duplicate task.
#
# Plus a small set of read-only helpers the curator needs for entity
# resolution before it can safely link names → ids on the additive
# calls.
_CURATOR_ALLOWED_TOOLS: frozenset[str] = frozenset({
# Additive — primary curator work
"record_moment",
"create_note", # also creates tasks (status='todo')
"log_work", # appends to a task's work-log timeline
"save_person",
"save_place",
"create_project",
"create_milestone",
# Read-only — supporting lookups for entity resolution
"search_notes",
"search_projects",
"search_journal",
"list_tasks",
"list_projects",
"list_milestones",
"read_note",
"get_project",
"get_profile",
})
_CURATOR_SYSTEM_PROMPT = """You are a curator reading a fragment of the user's journal conversation. Your job is to capture meaningful beats as structured records using the tools provided. You do NOT respond to the user — your only output is tool calls.
Beats worth recording:
- Events that happened ("went grocery shopping", "finished the network restage")
TRANSCRIPT FORMAT:
The transcript shows lines prefixed with either `User:` or `Assistant:`. Only the `User:` lines are journal entries. The `Assistant:` lines are context — they tell you what was said back, so you can disambiguate references ("which Sarah?") — but they are NEVER journal beats themselves. NEVER create a moment or any other record based on content that appears only in an `Assistant:` line. If a user line is short and the meaning depends on the assistant's question, capture the user's intent in your own concise phrasing, not by quoting the assistant.
WHAT TO CAPTURE (from `User:` lines only):
- Events that happened ("I went grocery shopping", "finished the network restage")
- Encounters with people ("had coffee with Sarah", "called Mom")
- Decisions ("going to switch jobs", "won't pursue the contract")
- Observations about the user's state or world ("the new place is loud", "feeling tired")
- Plans and commitments ("watching a show tonight", "dentist Thursday")
- Small accomplishments or changes the user made ("installed the new AP", "shipped the migration")
- New tasks or todos the user wants to track ("I need to call mom tomorrow", "remind me to renew the domain")
- Knowledge worth saving as a note ("the dhcp issue at Bedford was caused by stale leases")
Rules:
- Use record_moment to capture each distinct beat. One tool call per beat — do not collapse multiple beats into one.
- When linking to entities (people, places, tasks, notes), use the *_names parameters and let the server resolve. Never invent ids.
- Before linking a task by title, call search_notes to confirm it exists. If you have not searched, do not pass task_titles.
- If the user explicitly references an existing task by name, prefer update_task to mark progress. If they describe finishing something, set status=done.
- If the user mentions a person or place you do not already know about, you may call save_person or save_place to create the entry. Otherwise skip new-entity creation — better to omit a link than to invent the wrong one.
- Skip meta-conversational fragments ("ok", "thanks", "got it") — those are not journal beats.
- Match the user's voice when writing moment content. First-person or imperative. Never "the user mentioned…" / "user reports…" framing.
TOOL USE — additive only.
You have these tools available; nothing else exists. The architecture intentionally excludes update and delete operations from your reach — a confidently-wrong update is worse than a duplicate create that the user can prune.
- `record_moment` — for journal beats. One tool call per beat; do not collapse multiple beats. Write content in the user's voice (first-person or imperative); NEVER "the user mentioned…" framing.
- `create_note` — create a task (set status='todo' plus due_date/priority if mentioned) OR a knowledge note (omit status). Use this when the user states a new commitment with a clear scope, or shares a chunk of reusable knowledge.
- `log_work` — append a progress entry to an existing task. Use this when the user describes work they did on a task that already exists. ALWAYS call search_notes first to confirm the task exists by title or keyword; if no match, prefer create_note with status='todo' instead, or skip.
- `save_person` / `save_place` — create a new entry only when the user mentions someone or somewhere you have no prior reference for. Otherwise skip — better to omit a link on a moment than to invent the wrong entity.
- `create_project` / `create_milestone` — only when the user is clearly starting a NEW project or milestone they intend to track. Don't infer projects from passing mentions.
ENTITY LINKING (on record_moment):
- Use the *_names parameters (person_names, place_names, task_titles, note_titles). The server resolves names → ids.
- Before passing task_titles or note_titles, call search_notes to confirm the title exists. Don't invent titles.
WHAT NOT TO DO:
- Don't capture content from `Assistant:` lines.
- Don't try to update or delete anything (you have no tools for it).
- Don't fire tool calls for purely meta-conversational fragments ("ok", "thanks", "got it").
After the tool calls, you may emit one short summary sentence (≤ 20 words) describing what you captured. The summary is shown back to the chat model in subsequent turns so it stays aware of recent topics; it is NOT shown to the user directly. Examples:
- "Captured network restage progress and a coffee mention with Sarah."
- "Recorded plan for tonight; nothing else stood out."
- "Logged work on the Famous Supply task; recorded a tired but accomplished feeling."
- "Created a task for the domain renewal next month."
- "" (empty if nothing was captured — perfectly fine).
"""
@@ -126,17 +172,23 @@ class CuratorRunResult:
def _format_transcript(messages: list[Message]) -> str:
"""Render a list of Message rows as a plain transcript the curator can read.
Tool-call messages and previous assistant content are included so the
curator has full context, but the curator's own focus is on extracting
beats from user messages.
Only user + assistant messages are included. System messages (instructions
the chat model received) and tool-result messages (JSON noise from prior
tool calls) are noise for the curator's task; including them risked the
curator extracting from system text. The system prompt explicitly tells
the curator to extract ONLY from `User:` lines and to treat `Assistant:`
lines as context, so the format mirrors that contract.
"""
lines: list[str] = []
for m in messages:
if not m.content:
continue
role = (m.role or "").lower()
if role not in ("user", "assistant"):
continue
ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??"
role = m.role.capitalize() if m.role else "Unknown"
lines.append(f"[{ts}] {role}: {m.content.strip()}")
role_label = role.capitalize()
lines.append(f"[{ts}] {role_label}: {m.content.strip()}")
return "\n".join(lines)
@@ -211,7 +263,18 @@ async def run_curator_for_conversation(
# Falls back to OLLAMA_MODEL if no background model is configured.
model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL
tools = await get_tools_for_user(user_id, conversation_type="journal")
# Filter the journal tool set down to the curator's additive-only
# allowlist (see _CURATOR_ALLOWED_TOOLS comment). Belt-and-suspenders
# with the system prompt: if the prompt fails to dissuade the model
# from update/delete, the tool list literally doesn't include them
# so the call can't fire even if hallucinated. execute_tool also
# treats unknown names as errors, so the filter is the canonical
# boundary for what the curator can actually do.
all_tools = await get_tools_for_user(user_id, conversation_type="journal")
tools = [
t for t in all_tools
if (t.get("function") or {}).get("name") in _CURATOR_ALLOWED_TOOLS
]
transcript = _format_transcript(messages)
user_prompt = (