feat: add private notes note-taking agent#231
feat: add private notes note-taking agent#231Ju-usc wants to merge 14 commits intoopenhome-dev:devfrom
Conversation
Introduce a WebUI-compatible ability that relays coding tasks to a Codex webhook with confirmation/cancel flow and conversational summaries. Document setup, trigger words, and a minimal Codex-focused /run webhook contract for contributors.
Replace explicit register_capability boilerplate with the template register tag and remove raw open() usage so the ability matches updated validation requirements.
- Rename folder, class, log tag, and voice prompts to be agent-agnostic - Add _refine_prompt to clean up STT transcription before sending to agent - README shows Claude Code and Codex equally with unified webhook example - End-to-end tested via OpenHome + ngrok + Claude Code webhook
✅ Community PR Path Check — PassedAll changed files are inside the |
🔀 Branch Merge CheckPR direction: ✅ Passed — |
✅ Ability Validation Passed |
🔍 Lint Results🔧 Auto-formattedSome files were automatically cleaned and formatted with
✅
|
There was a problem hiding this comment.
Pull request overview
Adds a new voice-first, file-persisted private note-taking ability using an LLM-driven tool loop, plus introduces a separate “coding agent runner” webhook ability.
Changes:
- Added
private-notesability with CRUD note tools, confirmation gates, and JSON persistence. - Added documentation for
private-notes(README + SPEC). - Added a new
coding-agent-runnerability (webhook-driven coding task execution) and docs.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| community/private-notes/main.py | Implements tool-loop note CRUD + persistence for private notes. |
| community/private-notes/README.md | Documents usage, UX, and storage model for private notes. |
| community/private-notes/SPEC.md | Defines architecture, data model, tools, and safety rules for private notes. |
| community/private-notes/init.py | Package marker for the new ability. |
| community/coding-agent-runner/main.py | Adds a webhook-based coding task execution ability. |
| community/coding-agent-runner/README.md | Documents setup and webhook contract for coding agent runner. |
| community/coding-agent-runner/init.py | Package marker for the new ability. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| notes = sorted( | ||
| notebook["notes"], key=lambda n: n.get("updated_at", ""), reverse=True | ||
| ) | ||
| note_index = { | ||
| "note_count": len(notes), | ||
| "notes": [ | ||
| {"id": n.get("id"), "title": n.get("title"), "updated_at": n.get("updated_at")} | ||
| for n in notes | ||
| ], | ||
| } |
There was a problem hiding this comment.
The note index included in the initial context is unbounded (it includes every note's id/title/updated_at). As the user accumulates notes, this can exceed the model context window and make the ability fail or behave unpredictably. Consider capping the index to a fixed number of most-recent notes (and/or adding a search/list tool to retrieve ids on demand).
| notebook = await self._load_notebook() | ||
|
|
||
| # Capture time once so the context prefix stays identical across turns (LLM caching). | ||
| now = datetime.now(ZoneInfo(self.capability_worker.get_timezone())) |
There was a problem hiding this comment.
ZoneInfo(self.capability_worker.get_timezone()) can raise if the timezone string is missing/invalid. Right now that would abort the whole ability via the outer except. Consider wrapping this in a small try/except and falling back to UTC or naive datetime.now() so the ability can still function.
| now = datetime.now(ZoneInfo(self.capability_worker.get_timezone())) | |
| try: | |
| tz = ZoneInfo(self.capability_worker.get_timezone()) | |
| except Exception: | |
| tz = None | |
| now = datetime.now(tz) if tz is not None else datetime.now() |
| if not existing: | ||
| return {"ok": False, "notes_changed": False, "error": "note not found"} | ||
|
|
||
| if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): |
There was a problem hiding this comment.
Overwrite confirmation is driven by args.get("confirmation", ""), which can become an empty string if the model omits/returns null for confirmation. That risks a confusing/blank confirmation prompt (or unexpected behavior inside run_confirmation_loop). Consider validating that a non-empty confirmation prompt was provided for overwrites, and returning a structured error (or forcing an ask_followup) if it's missing.
| if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): | |
| confirmation_prompt = args.get("confirmation") | |
| if not isinstance(confirmation_prompt, str) or not confirmation_prompt.strip(): | |
| return { | |
| "ok": False, | |
| "notes_changed": False, | |
| "error": "missing or empty confirmation prompt for overwrite", | |
| } | |
| if not await self.capability_worker.run_confirmation_loop(confirmation_prompt): |
|
|
||
| if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): |
There was a problem hiding this comment.
Delete confirmation is also taken from args.get("confirmation", ""), which can be empty/null if the model misbehaves. Since delete is destructive, consider requiring a non-empty confirmation prompt and returning an error/asking follow-up if it’s missing rather than calling run_confirmation_loop with an empty string.
| if not await self.capability_worker.run_confirmation_loop(args.get("confirmation", "")): | |
| confirmation = (args.get("confirmation") or "").strip() | |
| # Require a non-empty confirmation prompt before running the confirmation loop. | |
| if not confirmation: | |
| return { | |
| "ok": False, | |
| "notes_changed": False, | |
| "deleted_count": 0, | |
| "status": "missing_confirmation", | |
| } | |
| if not await self.capability_worker.run_confirmation_loop(confirmation): |
| ## Validation | ||
|
|
||
| ``` | ||
| python3 -m py_compile abilities/community/private-notes/main.py | ||
| python3 abilities/validate_ability.py abilities/community/private-notes | ||
| ``` |
There was a problem hiding this comment.
The validation commands reference abilities/community/private-notes/..., but in this repo the path is community/private-notes/... (and validate_ability.py is at the repo root). This makes the instructions in the spec non-runnable as written; please update the paths to match the repository layout.
| """OpenHome ability – voice-triggered coding task execution via webhook. | ||
|
|
||
| Flow: ask → confirm → refine prompt → call webhook → speak result. | ||
| """ | ||
|
|
||
| import asyncio | ||
|
|
||
| import requests | ||
| from src.agent.capability import MatchingCapability | ||
| from src.main import AgentWorker | ||
| from src.agent.capability_worker import CapabilityWorker | ||
|
|
There was a problem hiding this comment.
PR metadata describes only adding the private-notes ability, but this change set also introduces a full new coding-agent-runner ability. If this is intentional, it should be called out explicitly in the PR title/description; otherwise, consider splitting into a separate PR to keep review and release scope clear.
| system_prompt=SYSTEM_PROMPT, | ||
| ) | ||
| # LLMs sometimes wrap JSON in markdown fences | ||
| tool_call = json.loads(llm_response.replace("```json", "").replace("```", "").strip()) |
There was a problem hiding this comment.
llm_response is parsed with json.loads(...) without handling JSONDecodeError. If the model returns non-JSON (or extra text), this will jump to the broad exception handler and abort the entire ability. Consider catching json.JSONDecodeError here, logging the raw response (careful about PII), and either retrying the LLM call or prompting the user/LLM with ask_followup to restate the request/tool call format.
| tool_call = json.loads(llm_response.replace("```json", "").replace("```", "").strip()) | |
| sanitized_response = llm_response.replace("```json", "").replace("```", "").strip() | |
| try: | |
| tool_call = json.loads(sanitized_response) | |
| except json.JSONDecodeError: | |
| # Log the raw LLM response for debugging, being careful about PII. | |
| self.worker.editor_logging_handler.warning( | |
| "[PrivateNotes] Failed to parse LLM tool call JSON. Raw response (sanitized): %r", | |
| sanitized_response, | |
| ) | |
| await self.capability_worker.speak( | |
| "I had trouble understanding that notes request. Could you say it again?" | |
| ) | |
| followup = await self._get_user_input( | |
| "I didn't catch anything, so I didn't change your notes." | |
| ) | |
| if not followup: | |
| return | |
| history.append({"role": "user", "content": followup}) | |
| continue |
|
Closing this PR because it was opened from the wrong branch and included unrelated coding-agent-runner history. It is superseded by #232, which contains only the private-notes changes based directly on dev. |
Summary
Validation