From d1375901f4dcbb9829ebe31f89fce3fee762d367 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:22:30 +0100 Subject: [PATCH 01/27] =?UTF-8?q?docs(spec):=20voice2note=20=E2=86=92=20Cr?= =?UTF-8?q?ickNote=20classify-and-route=20integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-stage design: voice2note classifies each recording (conversation | talk | record) and writes one note to Memory/voiceMemo/ via the CrickNote CLI; records land as status:draft (a queryable triage queue) while conversation/talk are finalized. A new cricknote-voice-inbox skill drains the queue with confirmation, routing records to an experiment append, a new Ideas/ note, or tasks. Feasibility verified against CrickNote's parser, vault tools, apply path, task scanning, and WAL storage. Co-Authored-By: Claude Opus 4.8 --- ...voice2note-cricknote-integration-design.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md diff --git a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md new file mode 100644 index 0000000..528629b --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md @@ -0,0 +1,262 @@ +# voice2note → CrickNote Integration: classify recordings and route by type + +**Date:** 2026-06-18 +**Status:** Proposed design, pending implementation plan +**Repos:** `voice2note` (engine changes) · `crickNote` (two skill additions) + +## 1. Goal + +Make voice2note classify each recording into one of three kinds and route it +accordingly into a CrickNote-managed Obsidian vault: + +- **conversation** (dialogue with others) → summary note, no tasks. +- **talk** (a presentation you attended) → summary note, no tasks. +- **record** (you dictating experiment notes, thoughts, or a new idea) → summary + note **queued for human triage**; later routed — with your confirmation — into + a related experiment, a new idea note, or tasks. + +voice2note stays an autonomous capture/transcribe/classify pipeline. CrickNote's +agent (Claude Code / Codex, run from the vault) does the context-aware routing +that needs the vault and a human in the loop. The two communicate only through +notes in the vault — no direct API between them. + +## 2. Non-Goals + +- No port of voice2note to TypeScript. It remains a standalone Python pipeline; + the handoff is the CrickNote CLI (`cricknote tool …`). +- No new CrickNote engine code (no new tools, DB columns, or migrations). The + CrickNote side is **two skill files**. +- No knowledge-base mapping in the voice path. A routed record can later be fed + to the existing `cricknote-kb-update` skill when a deliberate KB mapping is + wanted; it is not part of triage. +- No automatic task creation for conversation/talk. +- No two-device / vault-sync conflict resolution beyond what Obsidian + Nutstore + already do. + +## 3. Context (verified) + +**voice2note** — `launchd` every ~2h → find new Voice Memos +(`CloudRecordings.db`) → copy audio out of the protected folder → ffmpeg → 16 kHz +wav → `whisper-cli` (local) → `codex exec` returns JSON (`summary`, +`action_items`, `topics`, `notable_details`) → writes a per-memo note + a daily +block directly with Python. `state.json` records processed memo IDs; any +exception leaves a memo unprocessed for retry next run. + +**CrickNote** — a lab-notebook engine over the same Obsidian vault. You run an +agent in the vault; it calls `cricknote tool ''` for all serialized +writes. Verified facts that shape this design: + +- Vault: `/Users/le211/Nutstore Files/0 Obsidian/crickNote` (space in path, + Nutstore-synced). `~/.cricknote/config.json` holds `vaultPath`; the CLI + resolves the vault from it, independent of the caller's working directory. +- `Memory/` holds `Daily/`, `Weekly/`, `Monthly/`. **Every note under `Memory/` + is classified `noteType: diary`** (`src/ingestion/parser.ts:118`), which + requires frontmatter `date` and `type`, and validates `status` against + `{draft, in-progress, complete}` (warnings only — non-fatal). +- `vault_write` creates missing parent folders — `SafeWriter.atomicWrite` does + `fs.mkdirSync(dir,{recursive:true})` then tmp-write + rename + (`src/editing/safe-writer.ts:306`). +- `vault_list` reads the SQLite `note_metadata` table and filters on **fixed + columns only**: `date, experiment_type, project, status, project_id, series` + (`src/agent/tools/vault.ts:89`). There is **no** `triage`/`recording_type` + column. A `folder` value containing `/` matches by `path LIKE 'folder/%'`. +- The CLI dispatcher applies pending edits by default and **incrementally + indexes every file it writes** (`src/cli/apply-edit.ts:85`), so a note written + through `cricknote tool` is immediately searchable. +- `task_list` / `task_complete` scan **only** `Memory/Daily/` + (`src/agent/tools/tasks.ts:26`); `get_today_diary` / `get_week_plan` read only + the single `Memory/Daily/.md` / `Memory/Weekly/.md` file + (`src/agent/tools/context.ts`). Notes under `Memory/voiceMemo/` therefore do + not pollute task or diary logic. +- DB opens in **WAL** mode (`src/storage/database.ts:16`). +- `vault_append` errors if the target file does not exist + (`src/agent/tools/vault.ts:123`). + +## 4. Architecture: a two-stage pipeline + +``` +STAGE 1 — unattended (voice2note, ~every 2h) + capture → transcribe (whisper.cpp) → classify type (codex exec → JSON) + → render one note (summary + transcript) + → write to Memory/voiceMemo/ via `cricknote tool vault_write` + conversation / talk → status: complete (finalized, terminal) + record → status: draft (enters the triage queue) + + ── the seam: frontmatter in Memory/voiceMemo/ ── + +STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) + cricknote-voice-inbox skill: + vault_list {folder:"Memory/voiceMemo", status:"draft"} (the pending queue) + → for each record: read it, agent proposes ONE destination, you confirm: + • append to a related experiment (vault_search → vault_append) + • new idea note in Ideas/ (vault_write) + • tasks from to-dos (task_add, optional Reminders) + → flip the note to status: complete + add a backlink to where it went + cricknote-daily-review: surfaces "N voice records pending triage" +``` + +**Why this split:** classification needs only the transcript, so it runs +unattended in voice2note's existing `codex exec` call. Routing needs vault +context (which experiment? which concept?) and a confirmation step, which only +the in-vault agent has. Stage 1 never guesses a destination. + +**Stage 1 is strictly create-only.** It only ever *creates* new notes in +`Memory/voiceMemo/`. It never appends to or overwrites an existing note. This +keeps it safely inside CrickNote's "freeform notes may be written directly" rule +and means an unattended daemon can never corrupt serialized notes. + +## 5. Classification + +Done inside voice2note's `codex exec` analysis call (the only LLM change). + +- **conversation** — multiple speakers / back-and-forth dialogue. +- **talk** — single presenter; you are audience. +- **record** — you dictating: experiment notes, thoughts, a new idea. +- **Ambiguity rule:** if the model's confidence is below threshold (default 0.5) + or the returned type is missing/invalid, classify as **record**. A misfiled + record (silently summarized, never triaged) is the expensive error; a + conversation that lands in the queue is a one-second dismissal. Bias to triage. + +## 6. The data contract (the seam) + +Every voice note is written to `Memory/voiceMemo/-.md` +with this frontmatter. The fields are chosen to satisfy `Memory/` (diary) +validation with **zero warnings** and to make the triage queue a real, indexed, +one-call query: + +```yaml +--- +date: 2026-06-18 # required for Memory/ notes +type: voice-memo # required for Memory/ notes; not "daily-diary", so diary logic ignores it +recording_type: record # conversation | talk | record (descriptive) +status: draft # draft = pending triage · complete = finalized/triaged +classify_confidence: 0.62 # for auditing the ambiguity rule +source: voice-memo +title: "" +recorded: 2026-06-18T14:32 +tags: [voice-memo, ] +--- +``` + +- conversation / talk → written `status: complete` (never in the queue). +- record → written `status: draft`. +- **Pending queue = `vault_list {folder:"Memory/voiceMemo", status:"draft"}`** — + one call, no extra reads, no schema change, no validation warnings. + +Note body (rendered by voice2note, reusing today's structure): +`## Summary`, optionally `## Possible to-dos` (plain bullets — **never** dated +`- [ ]` checkboxes, so nothing leaks into the task system), `## Topics`, +`## Notable Details`, and `## Transcript` (collapsible callout). For +conversation/talk the to-dos section is omitted or rendered as plain +"Mentioned" bullets; tasks are deliberately not registered. + +## 7. Stage 1 — voice2note changes (Python) + +- **`voice2note/analyze.py`** — extend the prompt + `parse_analysis` to also + return `recording_type` ∈ {conversation, talk, record} and `confidence` + (0–1). Apply the ambiguity rule (default to `record`). No other analysis + change. +- **`voice2note/routing.py`** (new, small, pure) — map `recording_type` → + `(status, include_todos)`: conversation/talk → `complete`, no todos; record → + `draft`, todos included. Single source of truth for routing policy; unit-tested + in isolation. +- **`voice2note/notes.py`** — add the §6 frontmatter; render to-dos as plain + bullets (never checkboxes); drop the dated-checkbox action items and the + daily-note block. Replace the direct `path.write_text` with a writer that + shells out to `cricknote tool vault_write '{"path","content"}'` using a + **vault-relative** path (`Memory/voiceMemo/.md`). The CLI resolves the + vault and auto-applies + indexes. The note (multi-line, with quotes) is passed + as a single argv element built with `json.dumps` via `subprocess.run([...], + shell=False)` — never string-interpolated into a shell command. Typical memo + JSON is far under macOS `ARG_MAX`; pathological transcripts are the only + size concern and can fall back to the direct write. +- **`voice2note/config.py`** — add `VOICE2NOTE_USE_CRICKNOTE` (default on when a + cricknote bin is found), `VOICE2NOTE_CRICKNOTE_BIN` (absolute path), and + `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`). `VOICE2NOTE_VAULT` must + point at the same vault as `~/.cricknote/config.json`. When + `VOICE2NOTE_USE_CRICKNOTE` is off, fall back to today's direct file write so + voice2note still works standalone. +- **`voice2note/run.py`** — `process_memo` drops `append_to_daily`; writes the + single note via the new writer. The analysis-failed fallback writes a + transcript-only note as `recording_type: record`, `status: draft` so it + surfaces for attention. Retry semantics unchanged. + +## 8. Stage 2 — CrickNote changes (skills only) + +Sourced in the `crickNote` repo `skills/`, installed into the vault by +`cricknote setup` (copied into `.claude/skills/` and `.agents/skills/`). + +- **New `cricknote-voice-inbox` skill:** + 1. (optional) `cricknote reindex` to absorb hand-edits. + 2. `cricknote tool vault_list '{"folder":"Memory/voiceMemo","status":"draft"}'` + — the pending queue. + 3. For each record: `vault_read` it; the agent proposes **one** destination and + **waits for confirmation** (the existing KB confirm-gate pattern): + - **experiment** → `vault_search` for the related experiment, then + `vault_append` a timestamped entry to it. + - **idea** → `vault_write` a new freeform note in `Ideas/` (top-level; + classified `unknown`, so no required frontmatter). + - **tasks** → `task_add` for each accepted to-do (optional Reminders push + via `cricknote-reminders`). + 4. Flip the voice note to `status: complete` (read → replace the `status:` + line → `vault_write`) and add a backlink (e.g. `Routed to [[IL003]]`) so the + archive records where the content went. This removes it from the queue. +- **`cricknote-daily-review` hook:** add one step — count + `vault_list {folder:"Memory/voiceMemo", status:"draft"}` and surface + "N voice records pending triage." +- **Vault `CLAUDE.md`:** one line noting `Memory/voiceMemo/` is a voice2note-owned + freeform folder and `Ideas/` is the idea-note destination. + +## 9. Risks & mitigations + +| Risk | Mitigation | +|---|---| +| `SQLITE_BUSY` when the launchd write races an open agent session | WAL already serializes writers; voice2note retries the memo next run on any error. Hardening: add a `busy_timeout` pragma and one retry in voice2note's CLI-call wrapper. | +| launchd minimal PATH can't find `cricknote`/`node` | `VOICE2NOTE_CRICKNOTE_BIN` absolute path; set PATH in the LaunchAgent plist. Same pattern voice2note already uses for whisper/ffmpeg/codex. | +| Misclassification hides a record | Ambiguity rule biases low-confidence to `record`; conversation/talk are still listed in `Memory/voiceMemo/` so a wrong call is correctable by hand. | +| Hand-edits in Obsidian leave the index stale | Session-start `cricknote reindex` (already the daily-review convention). | +| Nutstore sync mid-write | Atomic tmp + rename; vault path never passed on a command line (CLI reads its own config), so the space in the path is irrelevant to shelling out. | +| Diary validation warnings on voice notes | Frontmatter includes `date` + `type` and a valid `status`, so no warnings. | + +## 10. Configuration (new voice2note env) + +| Variable | Default | Meaning | +|---|---|---| +| `VOICE2NOTE_USE_CRICKNOTE` | on if bin found | Route writes through `cricknote tool` vs. direct file write | +| `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CrickNote CLI | +| `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Vault-relative folder for voice notes | +| `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Below this, force `recording_type: record` | + +`VOICE2NOTE_VAULT` must equal the `vaultPath` in `~/.cricknote/config.json`. + +## 11. Testing + +- **voice2note (unittest):** `parse_analysis` extracts/defaults + `recording_type` + `confidence`; ambiguity rule forces `record`; `routing.py` + status/todos per type; `notes.py` renders the §6 frontmatter and never emits + `- [ ]` lines; the CLI-writer builds the correct `cricknote tool vault_write` + argv (subprocess mocked) and falls back to direct write when disabled. +- **CrickNote (vitest + temp vault):** a note written into `Memory/voiceMemo/` + with `status:draft` is returned by the queue query and not by `task_list`; + routing appends to an experiment / writes an `Ideas/` note / adds a task; the + status flip removes it from the queue. Skills validated from the vault dir with + both Claude Code and Codex. + +## 12. Phases + +1. **voice2note:** classification + ambiguity rule; §6 frontmatter; `routing.py`; + create-only `cricknote tool vault_write` path with standalone fallback; drop + daily block. Tests. +2. **CrickNote:** `cricknote-voice-inbox` skill + daily-review hook + CLAUDE.md + line; validate against the real vault. +3. **Hardening:** `busy_timeout` + CLI-call retry; LaunchAgent PATH/bin; README + updates in both repos. + +## 13. Open questions + +- None blocking. KB mapping stays out of the voice path by decision (§2); use + `cricknote-kb-update` on a routed record later if wanted. +- A future enhancement could add a one-line linked summary into + `Memory/Daily/.md`, but it is deferred to keep Stage 1 strictly + create-only (and because `vault_append` requires the daily note to already + exist). From 2852cd3ecef88985e0cf29ffa727f3064c75085b Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 08:46:15 +0100 Subject: [PATCH 02/27] =?UTF-8?q?docs(spec):=20r2=20=E2=80=94=20harden=20i?= =?UTF-8?q?ntegration=20against=20data-loss=20failure=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address code review (14 findings): - write-once + hash guard keyed on source_id (no overwrite-after-triage, no filename collisions) - resumable idempotent routing with per-route markers (no duplicate appends/tasks); status draft→in-progress→complete - provisional classification (no whisper diarization); bias-to-record; reclassify/dismiss actions - mandatory reindex before inbox listing (silent index failure can't hide drafts; file on disk is source of truth) - multi-destination routing; dismiss/archive disposition - no auto direct-write fallback (standalone-only); CLI error → retry - vault-path equality + MEMO_RELDIR validation - edit repo CLAUDE.md AND AGENTS.md templates (setup overwrites both) - drop busy_timeout to keep the no-engine-code non-goal; WAL + retry instead - stdlib YAML escaping + confidence coercion with hostile-input tests - explicit state model: captured→pending→in-progress→complete|dismissed Co-Authored-By: Claude Opus 4.8 --- ...voice2note-cricknote-integration-design.md | 458 ++++++++++-------- 1 file changed, 257 insertions(+), 201 deletions(-) diff --git a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md index 528629b..8cc6d70 100644 --- a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md +++ b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md @@ -1,262 +1,318 @@ # voice2note → CrickNote Integration: classify recordings and route by type **Date:** 2026-06-18 -**Status:** Proposed design, pending implementation plan -**Repos:** `voice2note` (engine changes) · `crickNote` (two skill additions) +**Status:** Proposed design (Revision 2), pending implementation plan +**Repos:** `voice2note` (engine changes) · `crickNote` (skills + agent-doc templates) + +## Revision history + +- **r2 (2026-06-18)** — hardened against data-loss/duplication failure modes + found in review: write-once with a hash guard (overwrite-after-triage), + resumable idempotent routing (duplicate appends/tasks), provisional + classification (no speaker labels), mandatory reindex (silent index failure), + dismiss/reclassify actions, multi-destination routing, explicit state model, + config validation, stdlib YAML escaping, and removal of the `busy_timeout` + engine change that contradicted the "no engine code" non-goal. See §13. +- **r1** — initial two-stage design. ## 1. Goal -Make voice2note classify each recording into one of three kinds and route it -accordingly into a CrickNote-managed Obsidian vault: +Make voice2note classify each recording and route it into a CrickNote-managed +Obsidian vault: - **conversation** (dialogue with others) → summary note, no tasks. - **talk** (a presentation you attended) → summary note, no tasks. - **record** (you dictating experiment notes, thoughts, or a new idea) → summary - note **queued for human triage**; later routed — with your confirmation — into - a related experiment, a new idea note, or tasks. + note **queued for human triage**, then routed — with your confirmation — into a + related experiment, a new idea note, and/or tasks. voice2note stays an autonomous capture/transcribe/classify pipeline. CrickNote's -agent (Claude Code / Codex, run from the vault) does the context-aware routing -that needs the vault and a human in the loop. The two communicate only through -notes in the vault — no direct API between them. +agent (run from the vault) does the context-aware routing that needs the vault +and a human in the loop. The two communicate only through notes in the vault. ## 2. Non-Goals -- No port of voice2note to TypeScript. It remains a standalone Python pipeline; - the handoff is the CrickNote CLI (`cricknote tool …`). -- No new CrickNote engine code (no new tools, DB columns, or migrations). The - CrickNote side is **two skill files**. -- No knowledge-base mapping in the voice path. A routed record can later be fed - to the existing `cricknote-kb-update` skill when a deliberate KB mapping is - wanted; it is not part of triage. +- No port of voice2note to TypeScript. The handoff is the CrickNote CLI. +- **No CrickNote engine code** — no new tools, DB columns, migrations, or pragmas. + The CrickNote side is skill files plus two agent-doc template edits. (This is + why `busy_timeout`, considered in r1, is out of scope — see §9.) +- No knowledge-base mapping in the voice path; run `cricknote-kb-update` on a + routed record later if wanted. - No automatic task creation for conversation/talk. -- No two-device / vault-sync conflict resolution beyond what Obsidian + Nutstore - already do. +- No speaker diarization in v1 (see §5). -## 3. Context (verified) +## 3. Context (verified against code) -**voice2note** — `launchd` every ~2h → find new Voice Memos -(`CloudRecordings.db`) → copy audio out of the protected folder → ffmpeg → 16 kHz -wav → `whisper-cli` (local) → `codex exec` returns JSON (`summary`, -`action_items`, `topics`, `notable_details`) → writes a per-memo note + a daily -block directly with Python. `state.json` records processed memo IDs; any -exception leaves a memo unprocessed for retry next run. +**voice2note** — `launchd` (~2h) → find new memos (`CloudRecordings.db`) → copy +audio out of the TCC-protected folder → ffmpeg → 16 kHz wav → `whisper-cli` +(local, `-otxt -nt -np`, **plain text, no diarization** — `transcribe.py:23`) → +`codex exec` → JSON → write note(s). `Memo.id` is the `.m4a` filename — a stable +per-recording id (`memos.py:14,63`). `state.json` records processed ids; any +exception leaves a memo for retry. -**CrickNote** — a lab-notebook engine over the same Obsidian vault. You run an -agent in the vault; it calls `cricknote tool ''` for all serialized -writes. Verified facts that shape this design: +**CrickNote** — lab-notebook engine over the vault; an agent calls +`cricknote tool ''`. Verified facts that shape this design: -- Vault: `/Users/le211/Nutstore Files/0 Obsidian/crickNote` (space in path, +- Vault `/Users/le211/Nutstore Files/0 Obsidian/crickNote` (space in path, Nutstore-synced). `~/.cricknote/config.json` holds `vaultPath`; the CLI - resolves the vault from it, independent of the caller's working directory. -- `Memory/` holds `Daily/`, `Weekly/`, `Monthly/`. **Every note under `Memory/` - is classified `noteType: diary`** (`src/ingestion/parser.ts:118`), which - requires frontmatter `date` and `type`, and validates `status` against - `{draft, in-progress, complete}` (warnings only — non-fatal). -- `vault_write` creates missing parent folders — `SafeWriter.atomicWrite` does - `fs.mkdirSync(dir,{recursive:true})` then tmp-write + rename - (`src/editing/safe-writer.ts:306`). -- `vault_list` reads the SQLite `note_metadata` table and filters on **fixed - columns only**: `date, experiment_type, project, status, project_id, series` - (`src/agent/tools/vault.ts:89`). There is **no** `triage`/`recording_type` - column. A `folder` value containing `/` matches by `path LIKE 'folder/%'`. -- The CLI dispatcher applies pending edits by default and **incrementally - indexes every file it writes** (`src/cli/apply-edit.ts:85`), so a note written - through `cricknote tool` is immediately searchable. -- `task_list` / `task_complete` scan **only** `Memory/Daily/` - (`src/agent/tools/tasks.ts:26`); `get_today_diary` / `get_week_plan` read only - the single `Memory/Daily/.md` / `Memory/Weekly/.md` file - (`src/agent/tools/context.ts`). Notes under `Memory/voiceMemo/` therefore do - not pollute task or diary logic. -- DB opens in **WAL** mode (`src/storage/database.ts:16`). -- `vault_append` errors if the target file does not exist - (`src/agent/tools/vault.ts:123`). + resolves the vault from it regardless of the caller's CWD. +- **Every note under `Memory/` is `noteType: diary`** (`parser.ts:118`), requiring + frontmatter `date` + `type` and validating `status` ∈ {draft, in-progress, + complete} (warnings only). +- `vault_write` creates missing parent folders (`safe-writer.ts:306`, + `mkdirSync(recursive)` then tmp-write+rename) and **overwrites** existing files + (`vault.ts:170`). +- `vault_list` reads the SQLite `note_metadata` table, filters on **fixed columns + only** (`date, experiment_type, project, status, project_id, series`), and + returns **at most 50 rows** (`vault.ts:96`). A `folder` containing `/` matches + `path LIKE 'folder/%'`. +- The CLI applies pending edits and incrementally indexes each write — **but an + index failure is logged and still reported as a successful write** + (`apply-edit.ts:84`). The file on disk is therefore the source of truth; the + index is derived and can lag. +- `vault_append` **errors if the target file does not exist** (`vault.ts:123`). +- `task_list`/`task_complete` scan only `Memory/Daily/` (`tasks.ts:26`); + `get_today_diary`/`get_week_plan` read only the single dated file + (`context.ts`). Notes under `Memory/voiceMemo/` do not pollute task/diary logic. +- DB is WAL (`database.ts:16`). +- `cricknote setup` **overwrites** the vault's `CLAUDE.md` *and* `AGENTS.md` from + `templates/agent-docs/`, and copies the repo `skills/` dir into the vault's + `.claude/skills/` + `.agents/skills/` (`install-agent-assets.ts:10-26`). Vault + files must therefore be edited via the repo templates/skills, not by hand. ## 4. Architecture: a two-stage pipeline ``` -STAGE 1 — unattended (voice2note, ~every 2h) - capture → transcribe (whisper.cpp) → classify type (codex exec → JSON) - → render one note (summary + transcript) +STAGE 1 — unattended (voice2note, ~2h) + capture → transcribe → classify (provisional) → render one note → write to Memory/voiceMemo/ via `cricknote tool vault_write` - conversation / talk → status: complete (finalized, terminal) - record → status: draft (enters the triage queue) + conversation / talk → status: complete (finalized) + record / uncertain → status: draft (triage queue) + analysis failed → transcript-only, status: draft, analysis: pending - ── the seam: frontmatter in Memory/voiceMemo/ ── + ── seam: notes in Memory/voiceMemo/, keyed by source_id ── STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) - cricknote-voice-inbox skill: - vault_list {folder:"Memory/voiceMemo", status:"draft"} (the pending queue) - → for each record: read it, agent proposes ONE destination, you confirm: - • append to a related experiment (vault_search → vault_append) - • new idea note in Ideas/ (vault_write) - • tasks from to-dos (task_add, optional Reminders) - → flip the note to status: complete + add a backlink to where it went - cricknote-daily-review: surfaces "N voice records pending triage" + cricknote-voice-inbox: + cricknote reindex (MANDATORY first) + vault_list {folder:"Memory/voiceMemo", status:"draft" | "in-progress"} + → per record: read; you pick one OR MORE of: + • append to related experiment (vault_search → vault_append + marker) + • new idea note in Ideas/ (vault_write) + • tasks from to-dos (task_add + marker, deduped) + …or reclassify, or dismiss. + → progress recorded in frontmatter as each route completes; + status: draft → in-progress → complete (disposition: routed | dismissed) + cricknote-daily-review: surfaces "N voice records pending triage (50+ if capped)" ``` -**Why this split:** classification needs only the transcript, so it runs -unattended in voice2note's existing `codex exec` call. Routing needs vault -context (which experiment? which concept?) and a confirmation step, which only -the in-vault agent has. Stage 1 never guesses a destination. - -**Stage 1 is strictly create-only.** It only ever *creates* new notes in -`Memory/voiceMemo/`. It never appends to or overwrites an existing note. This -keeps it safely inside CrickNote's "freeform notes may be written directly" rule -and means an unattended daemon can never corrupt serialized notes. +**Stage 1 is create-only and write-once** (§7). It only creates notes in +`Memory/voiceMemo/`, never touches an existing note's content beyond its own +pristine output (hash-guarded), and never writes serialized notes. -## 5. Classification +## 5. Classification (provisional) -Done inside voice2note's `codex exec` analysis call (the only LLM change). +Done in voice2note's `codex exec` call (the only LLM change). Whisper output has +**no speaker labels**, so type inference from content is imperfect — therefore: -- **conversation** — multiple speakers / back-and-forth dialogue. -- **talk** — single presenter; you are audience. -- **record** — you dictating: experiment notes, thoughts, a new idea. -- **Ambiguity rule:** if the model's confidence is below threshold (default 0.5) - or the returned type is missing/invalid, classify as **record**. A misfiled - record (silently summarized, never triaged) is the expensive error; a - conversation that lands in the queue is a one-second dismissal. Bias to triage. +- The **only behavior-changing distinction is record vs. not-record.** + conversation and talk are handled identically (summary, no tasks, + `status: complete`), so imprecise conv-vs-talk labeling is cosmetic. +- **Ambiguity rule:** confidence `< VOICE2NOTE_CONFIDENCE_MIN` (default 0.5), or a + missing/invalid type, → **record** (queued). Nothing is silently shelved. +- Classification is **provisional**: the inbox skill can **reclassify** or + **dismiss**. A record wrongly queued is a one-action dismiss (§8). +- Future option (not v1): whisper.cpp `tinydiarize` (`-tdrz`, `*-tdrz` model) + for crude speaker-turn markers to improve the conv/talk split. ## 6. The data contract (the seam) -Every voice note is written to `Memory/voiceMemo/-.md` -with this frontmatter. The fields are chosen to satisfy `Memory/` (diary) -validation with **zero warnings** and to make the triage queue a real, indexed, -one-call query: +Note path: `Memory/voiceMemo/--.md`, where `h8` = +first 8 hex of `sha256(source_id)`. The hash makes the filename **deterministic +and collision-free** (two memos in the same minute with the same title no longer +collide), and makes "does this memo already have a note?" a single-path check. ```yaml --- -date: 2026-06-18 # required for Memory/ notes -type: voice-memo # required for Memory/ notes; not "daily-diary", so diary logic ignores it -recording_type: record # conversation | talk | record (descriptive) -status: draft # draft = pending triage · complete = finalized/triaged -classify_confidence: 0.62 # for auditing the ambiguity rule +date: 2026-06-18 # required for Memory/ (diary) notes +type: voice-memo # required; NOT "daily-diary", so diary logic ignores it +source_id: "ABCD-1234.m4a" # stable Memo.id; the idempotency key +recording_type: record # conversation | talk | record (provisional) +classify_confidence: 0.62 # clamped float; audits the ambiguity rule +analysis: complete # complete | pending (pending = transcript-only) +status: draft # draft = pending triage · in-progress = routing · complete = done +disposition: # (only when complete) routed | dismissed +routed_to: [] # backlinks recorded as routing progresses source: voice-memo -title: "" +title: "" # YAML-escaped (§7) recorded: 2026-06-18T14:32 -tags: [voice-memo, ] +tags: [voice-memo, ] # each tag YAML-escaped --- ``` -- conversation / talk → written `status: complete` (never in the queue). -- record → written `status: draft`. -- **Pending queue = `vault_list {folder:"Memory/voiceMemo", status:"draft"}`** — - one call, no extra reads, no schema change, no validation warnings. - -Note body (rendered by voice2note, reusing today's structure): -`## Summary`, optionally `## Possible to-dos` (plain bullets — **never** dated -`- [ ]` checkboxes, so nothing leaks into the task system), `## Topics`, -`## Notable Details`, and `## Transcript` (collapsible callout). For -conversation/talk the to-dos section is omitted or rendered as plain -"Mentioned" bullets; tasks are deliberately not registered. - -## 7. Stage 1 — voice2note changes (Python) - -- **`voice2note/analyze.py`** — extend the prompt + `parse_analysis` to also - return `recording_type` ∈ {conversation, talk, record} and `confidence` - (0–1). Apply the ambiguity rule (default to `record`). No other analysis - change. -- **`voice2note/routing.py`** (new, small, pure) — map `recording_type` → - `(status, include_todos)`: conversation/talk → `complete`, no todos; record → - `draft`, todos included. Single source of truth for routing policy; unit-tested - in isolation. -- **`voice2note/notes.py`** — add the §6 frontmatter; render to-dos as plain - bullets (never checkboxes); drop the dated-checkbox action items and the - daily-note block. Replace the direct `path.write_text` with a writer that - shells out to `cricknote tool vault_write '{"path","content"}'` using a - **vault-relative** path (`Memory/voiceMemo/.md`). The CLI resolves the - vault and auto-applies + indexes. The note (multi-line, with quotes) is passed - as a single argv element built with `json.dumps` via `subprocess.run([...], - shell=False)` — never string-interpolated into a shell command. Typical memo - JSON is far under macOS `ARG_MAX`; pathological transcripts are the only - size concern and can fall back to the direct write. -- **`voice2note/config.py`** — add `VOICE2NOTE_USE_CRICKNOTE` (default on when a - cricknote bin is found), `VOICE2NOTE_CRICKNOTE_BIN` (absolute path), and - `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`). `VOICE2NOTE_VAULT` must - point at the same vault as `~/.cricknote/config.json`. When - `VOICE2NOTE_USE_CRICKNOTE` is off, fall back to today's direct file write so - voice2note still works standalone. -- **`voice2note/run.py`** — `process_memo` drops `append_to_daily`; writes the - single note via the new writer. The analysis-failed fallback writes a - transcript-only note as `recording_type: record`, `status: draft` so it - surfaces for attention. Retry semantics unchanged. - -## 8. Stage 2 — CrickNote changes (skills only) - -Sourced in the `crickNote` repo `skills/`, installed into the vault by -`cricknote setup` (copied into `.claude/skills/` and `.agents/skills/`). - -- **New `cricknote-voice-inbox` skill:** - 1. (optional) `cricknote reindex` to absorb hand-edits. - 2. `cricknote tool vault_list '{"folder":"Memory/voiceMemo","status":"draft"}'` - — the pending queue. - 3. For each record: `vault_read` it; the agent proposes **one** destination and - **waits for confirmation** (the existing KB confirm-gate pattern): - - **experiment** → `vault_search` for the related experiment, then - `vault_append` a timestamped entry to it. - - **idea** → `vault_write` a new freeform note in `Ideas/` (top-level; - classified `unknown`, so no required frontmatter). - - **tasks** → `task_add` for each accepted to-do (optional Reminders push - via `cricknote-reminders`). - 4. Flip the voice note to `status: complete` (read → replace the `status:` - line → `vault_write`) and add a backlink (e.g. `Routed to [[IL003]]`) so the - archive records where the content went. This removes it from the queue. -- **`cricknote-daily-review` hook:** add one step — count - `vault_list {folder:"Memory/voiceMemo", status:"draft"}` and surface - "N voice records pending triage." -- **Vault `CLAUDE.md`:** one line noting `Memory/voiceMemo/` is a voice2note-owned - freeform folder and `Ideas/` is the idea-note destination. +- conversation / talk → `status: complete` on write (never queued). +- record / uncertain / analysis-failed → `status: draft`. +- **Pending queue = `vault_list {folder:"Memory/voiceMemo", status:"draft"}`** + (plus `status:"in-progress"` to resume a crashed triage). One call, no schema + change, no validation warnings (`date`+`type` present, `status` valid). + +Body: `## Summary`, optional `## Possible to-dos` (plain bullets — **never** dated +`- [ ]` checkboxes, so nothing leaks into `task_list`), `## Topics`, +`## Notable Details`, `## Transcript` (collapsible). conversation/talk omit the +to-dos section. + +## 7. Stage 1 — voice2note changes (Python, stdlib only) + +**State model (`state.json`, v2), keyed by `source_id`:** + +``` +memos[source_id] = { + note_rel, written_hash, # sha256 of the exact content v2n last wrote + analysis: "complete" | "pending" | "abandoned", # abandoned = you took over or deleted it + recording_type, updated +} +``` +Old `state.json` (set of processed names) migrates to `analysis: complete`, no +hash (so never re-analyzed). A `source_id` present in `memos` is "seen". + +**Write-once with hash guard (the core safety property):** + +- **New memo:** transcribe → analyze. + - success → render full note; `cricknote tool vault_write`; store `written_hash`; + `analysis: complete`. + - failure → render transcript-only note (`analysis: pending`, `status: draft`, + `recording_type: record`); write; store `written_hash`; `analysis: pending`. + The memo is now visible and queued. +- **Re-analysis pass** (memos with `analysis: pending`): read the note from disk. + - file missing → user deleted it → set `analysis: abandoned`; never resurrect. + - on-disk hash ≠ `written_hash` → you/the agent edited or triaged it → set + `analysis: abandoned`; never overwrite. + - on-disk hash == `written_hash` → still pristine → re-run analysis; on success + rewrite (preserving `source_id`; conv/talk → `status: complete`, record → + keep `draft`), update `written_hash`, `analysis: complete`. On failure leave + pending. +- Transcription failure writes nothing (memo stays unseen) → safe retry. + +**Files:** + +- `analyze.py` — prompt + `parse_analysis` also return `recording_type` and + `confidence`; apply the ambiguity rule; coerce `confidence` to a clamped float + in [0,1] with a safe default (→ record) on garbage. +- `routing.py` (new, pure) — `recording_type → (status, include_todos)`. Single + source of routing policy; unit-tested in isolation. +- `notes.py` — emit the §6 frontmatter via a **stdlib YAML double-quoted-scalar + escaper** (`title`, each `tag`; escape `\`, `"`, control chars, collapse + newlines); render to-dos as plain bullets; drop dated checkboxes and the + daily-note block. +- `vault_writer.py` (new) — build `cricknote tool vault_write ''` with a + **vault-relative** path, JSON via `json.dumps`, `subprocess.run([...], + shell=False)`. A non-zero exit / error is a **failed write** → raise so the + memo is retried next run; **never silently fall back to a direct file write.** + Direct file write happens only when `VOICE2NOTE_USE_CRICKNOTE` is explicitly + off (standalone mode). +- `config.py` — `VOICE2NOTE_USE_CRICKNOTE`, `VOICE2NOTE_CRICKNOTE_BIN`, + `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`; **reject absolute paths + and `..`; must normalize within the vault**), `VOICE2NOTE_CONFIDENCE_MIN`. + **Validate vault equality:** canonicalize (`realpath`) `VOICE2NOTE_VAULT` and + `~/.cricknote/config.json`'s `vaultPath`; on mismatch, fail with a clear error + (in startup and `--check`). +- `run.py` — drive new-vs-pending passes; drop `append_to_daily`. + +## 8. Stage 2 — CrickNote changes (skills + templates, no engine code) + +- **New `cricknote-voice-inbox` skill** (in repo `skills/`, installed by setup): + 1. `cricknote reindex` — **mandatory**, so the queue reflects disk even if an + incremental index failed. + 2. `vault_list '{"folder":"Memory/voiceMemo","status":"draft"}'` (and + `"in-progress"` to resume). If exactly 50 returned, tell the user the list is + capped. + 3. Per record: `vault_read`; the agent proposes **one or more** destinations and + **waits for confirmation**: + - **experiment** → `vault_search`; `vault_append` a timestamped block ending + with an idempotency marker ``. Re-check via + `vault_read` and skip if the marker is already present. + - **idea** → `vault_write` a new note in top-level `Ideas/` (classified + `unknown`; no required frontmatter). + - **tasks** → `task_add` each accepted to-do with a trailing `[v2n:]` + marker; dedupe by searching first (as `cricknote-reminders` does). + - **reclassify** → update `recording_type` (to conv/talk → `status: + complete`; stays record → remains `draft`). + - **dismiss** → `status: complete`, `disposition: dismissed`, no routing. + 4. **Resumable progress:** set `status: in-progress` when routing starts; append + each finished target to `routed_to`; only after all chosen routes succeed set + `status: complete`, `disposition: routed`, with backlinks. A crash leaves + `in-progress` + partial `routed_to`; re-running resumes and markers prevent + duplicates. +- **`cricknote-daily-review`** — add a step counting the pending queue (after the + mandatory reindex it already does) and surfacing it ("50+" when capped). +- **Agent-doc templates** — edit repo `templates/agent-docs/CLAUDE.md` **and** + `AGENTS.md`: note `Memory/voiceMemo/` is voice2note-owned, `Ideas/` is the + idea-note destination, and `cricknote-voice-inbox` is the triage workflow. ## 9. Risks & mitigations | Risk | Mitigation | |---|---| -| `SQLITE_BUSY` when the launchd write races an open agent session | WAL already serializes writers; voice2note retries the memo next run on any error. Hardening: add a `busy_timeout` pragma and one retry in voice2note's CLI-call wrapper. | -| launchd minimal PATH can't find `cricknote`/`node` | `VOICE2NOTE_CRICKNOTE_BIN` absolute path; set PATH in the LaunchAgent plist. Same pattern voice2note already uses for whisper/ffmpeg/codex. | -| Misclassification hides a record | Ambiguity rule biases low-confidence to `record`; conversation/talk are still listed in `Memory/voiceMemo/` so a wrong call is correctable by hand. | -| Hand-edits in Obsidian leave the index stale | Session-start `cricknote reindex` (already the daily-review convention). | -| Nutstore sync mid-write | Atomic tmp + rename; vault path never passed on a command line (CLI reads its own config), so the space in the path is irrelevant to shelling out. | -| Diary validation warnings on voice notes | Frontmatter includes `date` + `type` and a valid `status`, so no warnings. | +| Overwrite of a triaged note on retry | Write-once + `written_hash` guard (§7); deterministic `source_id`-hashed filename; voice2note never overwrites content it didn't last write | +| Duplicate experiment append / tasks on retry | Per-route idempotency markers (``, `[v2n:h8]`) checked before acting; resumable `status`/`routed_to` progress (§8) | +| Imperfect type from speaker-less transcript | Only record-vs-not-record matters; bias-to-record; provisional + reclassify/dismiss in triage | +| Silent incremental-index failure hides a draft | File on disk is truth; **mandatory `reindex`** before listing; voice2note keys "written" on the file, not on indexing | +| `vault_list` 50-row cap undercounts | Show "50+" when capped; design assumes regular triage keeps the queue small | +| Auto direct-write bypasses indexing/audit | Direct write only in explicit standalone mode; CLI errors → retry, never silent fallback | +| Wrong vault (config drift) | Canonical-path equality check at startup/`--check`; fail safely | +| SQLITE_BUSY (launchd vs. open agent) | WAL serializes writers; voice2note retries next run. `busy_timeout` is an optional *separate* crickNote PR, not in scope (keeps "no engine code") | +| Malformed YAML drops frontmatter (→ misclassified/unqueued) | stdlib double-quoted-scalar escaper for title/tags; clamped-float confidence; tests for hostile inputs | +| Nutstore sync mid-write | Atomic tmp+rename; vault path never on a command line (CLI reads its own config) | ## 10. Configuration (new voice2note env) | Variable | Default | Meaning | |---|---|---| -| `VOICE2NOTE_USE_CRICKNOTE` | on if bin found | Route writes through `cricknote tool` vs. direct file write | -| `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CrickNote CLI | -| `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Vault-relative folder for voice notes | -| `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Below this, force `recording_type: record` | - -`VOICE2NOTE_VAULT` must equal the `vaultPath` in `~/.cricknote/config.json`. - -## 11. Testing - -- **voice2note (unittest):** `parse_analysis` extracts/defaults - `recording_type` + `confidence`; ambiguity rule forces `record`; `routing.py` - status/todos per type; `notes.py` renders the §6 frontmatter and never emits - `- [ ]` lines; the CLI-writer builds the correct `cricknote tool vault_write` - argv (subprocess mocked) and falls back to direct write when disabled. -- **CrickNote (vitest + temp vault):** a note written into `Memory/voiceMemo/` - with `status:draft` is returned by the queue query and not by `task_list`; - routing appends to an experiment / writes an `Ideas/` note / adds a task; the - status flip removes it from the queue. Skills validated from the vault dir with - both Claude Code and Codex. +| `VOICE2NOTE_USE_CRICKNOTE` | on if bin found | Route writes through `cricknote tool` vs. standalone direct write | +| `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CrickNote CLI (set in the LaunchAgent for launchd PATH) | +| `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Vault-relative folder; absolute/`..` rejected | +| `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Below this → force `recording_type: record` | + +`VOICE2NOTE_VAULT` must canonically equal `~/.cricknote/config.json`'s +`vaultPath` (validated; hard failure on mismatch). + +## 11. Testing (TDD; existing 26 tests updated where behavior changes) + +- **voice2note (unittest):** + - `parse_analysis` extracts/defaults `recording_type`; `confidence` coercion + (string, out-of-range, missing) → clamped float; ambiguity rule → record. + - `routing.py` status/todos per type. + - YAML escaper: titles with `"`, `:`, `\`, newlines, emoji; tag lists → output + re-parses to the original values. + - **Write-once/hash guard:** new→write; pending+pristine→re-analyze+update; + pending+modified→skip (no overwrite); missing file→no resurrect; filename + determinism + collision (same minute+title, different `source_id`). + - `vault_writer` builds correct argv (subprocess mocked); CLI error raises (no + direct-write); standalone mode writes directly. + - config: vault-equality mismatch fails; `MEMO_RELDIR` rejects absolute/`..`. + - `notes.py` emits no `- [ ]` lines; no daily block. +- **CrickNote (vitest + temp vault):** a `status:draft` note in `Memory/voiceMemo/` + is returned by the queue query and **not** by `task_list`; multi-destination + routing appends-with-marker / writes an `Ideas/` note / adds a deduped task; + re-running a partially-routed note does not duplicate; status/disposition + transitions; dismiss and reclassify leave the queue correctly. Skills validated + from the vault dir with Claude Code and Codex. ## 12. Phases -1. **voice2note:** classification + ambiguity rule; §6 frontmatter; `routing.py`; - create-only `cricknote tool vault_write` path with standalone fallback; drop - daily block. Tests. -2. **CrickNote:** `cricknote-voice-inbox` skill + daily-review hook + CLAUDE.md - line; validate against the real vault. -3. **Hardening:** `busy_timeout` + CLI-call retry; LaunchAgent PATH/bin; README - updates in both repos. +1. **voice2note:** classification + ambiguity rule; §6 contract + YAML escaper; + `routing.py`; `state.json` v2 + write-once/hash-guard + re-analysis pass; + create-only `vault_writer` (no silent fallback); config validation; drop daily + block. Tests. +2. **CrickNote:** `cricknote-voice-inbox` skill (mandatory reindex, multi-route, + idempotency markers, resumable progress, dismiss/reclassify) + daily-review + hook + `CLAUDE.md`/`AGENTS.md` template edits. Validate against the real vault. +3. **Hardening/docs:** LaunchAgent PATH/bin; README updates in both repos; + (optional, separate) crickNote `busy_timeout` PR if contention is observed. ## 13. Open questions -- None blocking. KB mapping stays out of the voice path by decision (§2); use - `cricknote-kb-update` on a routed record later if wanted. -- A future enhancement could add a one-line linked summary into - `Memory/Daily/.md`, but it is deferred to keep Stage 1 strictly - create-only (and because `vault_append` requires the daily note to already - exist). +- None blocking. KB mapping stays out of the voice path by decision (§2). +- A `Memory/Daily/` one-line linked summary is deferred (would break Stage 1's + create-only property; `vault_append` also requires the daily note to exist). +- Diarization (`tinydiarize`) deferred to a future iteration (§5). From 5856b24fa6c47cc13734978d730868e9a99a3d4c Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:04:03 +0100 Subject: [PATCH 03/27] =?UTF-8?q?docs(spec):=20r3=20=E2=80=94=20write-once?= =?UTF-8?q?=20(drop=20hash-guard=20TOCTOU),=20crash-safe=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review round: - drop hash-guarded re-analysis: the re-check→vault_write window is a real TOCTOU the CLI's snapshot-less write can't guard, and a compare-and-swap tool would be engine code. voice2note is now strictly write-once; transcript-only notes are summarized by the triage agent (human present). - crash-before-state-save: pre-write deterministic-path existence check (adopt-not-rewrite) + atomic state.json (temp + os.replace). - idea routing gets a deterministic path + source_id marker (idempotent). - USE_CRICKNOTE is an explicit boolean (default false); true + missing CLI hard-fails (no silent direct-write fallback). - hashes are 16 hex (collision-resistant, not "collision-free"). - testing split: unittest/Vitest for code; manual acceptance tests for the Markdown skill's routing/resume/idempotency (Vitest can't drive a skill). - add Implementation-status note: design only; names exact code touchpoints. Co-Authored-By: Claude Opus 4.8 --- ...voice2note-cricknote-integration-design.md | 323 ++++++++++-------- 1 file changed, 182 insertions(+), 141 deletions(-) diff --git a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md index 8cc6d70..e57020f 100644 --- a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md +++ b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md @@ -1,18 +1,32 @@ # voice2note → CrickNote Integration: classify recordings and route by type **Date:** 2026-06-18 -**Status:** Proposed design (Revision 2), pending implementation plan +**Status:** Proposed design (Revision 3), pending implementation plan **Repos:** `voice2note` (engine changes) · `crickNote` (skills + agent-doc templates) +> **Implementation status: not started.** This is a design document. No source in +> `voice2note/` or `crickNote/` has been modified. The code touchpoints this +> design will change during implementation: `voice2note/notes.py` (stop emitting +> dated `- [ ]` checkboxes, line ~35), `voice2note/run.py` (drop the daily-note +> block, new create-only flow, lines ~62-63), `voice2note/state.py` (v2 state + +> atomic save), and two new files `voice2note/routing.py` and +> `voice2note/vault_writer.py`. The CrickNote side adds skill files and edits +> agent-doc templates only — no engine code. + ## Revision history -- **r2 (2026-06-18)** — hardened against data-loss/duplication failure modes - found in review: write-once with a hash guard (overwrite-after-triage), - resumable idempotent routing (duplicate appends/tasks), provisional - classification (no speaker labels), mandatory reindex (silent index failure), - dismiss/reclassify actions, multi-destination routing, explicit state model, - config validation, stdlib YAML escaping, and removal of the `busy_timeout` - engine change that contradicted the "no engine code" non-goal. See §13. +- **r3 (2026-06-18)** — second review round. **Dropped hash-guarded + re-analysis** (its re-check→write window is a real TOCTOU that the CLI's + snapshot-less `vault_write` cannot guard, and a compare-and-swap tool would be + engine code): voice2note is now strictly **write-once**, and transcript-only + notes are summarized by the triage agent. Added a **pre-write existence check** + (adopt-not-rewrite) and **atomic `state.json`** for the crash-before-save + window. Idea routing gets a deterministic path + marker. `USE_CRICKNOTE` is an + explicit boolean (no auto-detect). Hashes are ≥16 hex (collision-resistant, not + "collision-free"). Testing split: unittest/Vitest for code, **manual acceptance + tests** for skill orchestration. See §13. +- **r2** — hardened against data-loss modes (write-once, resumable routing, + provisional classification, mandatory reindex, etc.). - **r1** — initial two-stage design. ## 1. Goal @@ -33,13 +47,14 @@ and a human in the loop. The two communicate only through notes in the vault. ## 2. Non-Goals - No port of voice2note to TypeScript. The handoff is the CrickNote CLI. -- **No CrickNote engine code** — no new tools, DB columns, migrations, or pragmas. - The CrickNote side is skill files plus two agent-doc template edits. (This is - why `busy_timeout`, considered in r1, is out of scope — see §9.) +- **No CrickNote engine code** — no new tools, DB columns, migrations, pragmas, or + a compare-and-swap write tool. The CrickNote side is skill files plus two + agent-doc template edits. - No knowledge-base mapping in the voice path; run `cricknote-kb-update` on a routed record later if wanted. - No automatic task creation for conversation/talk. -- No speaker diarization in v1 (see §5). +- No automatic re-analysis of transcript-only notes by voice2note (§5, §7). +- No speaker diarization in v1 (§5). ## 3. Context (verified against code) @@ -47,8 +62,10 @@ and a human in the loop. The two communicate only through notes in the vault. audio out of the TCC-protected folder → ffmpeg → 16 kHz wav → `whisper-cli` (local, `-otxt -nt -np`, **plain text, no diarization** — `transcribe.py:23`) → `codex exec` → JSON → write note(s). `Memo.id` is the `.m4a` filename — a stable -per-recording id (`memos.py:14,63`). `state.json` records processed ids; any -exception leaves a memo for retry. +per-recording id (`memos.py:14,63`). `state.json` today is a `processed_ids` set +written **non-atomically** (`state.py:26`); any exception leaves a memo for retry. +`notes.py:35` emits dated `- [ ]` checkboxes; `run.py:62-63` writes the memo note +and a daily-note block — both change in this design. **CrickNote** — lab-notebook engine over the vault; an agent calls `cricknote tool ''`. Verified facts that shape this design: @@ -59,33 +76,34 @@ exception leaves a memo for retry. - **Every note under `Memory/` is `noteType: diary`** (`parser.ts:118`), requiring frontmatter `date` + `type` and validating `status` ∈ {draft, in-progress, complete} (warnings only). -- `vault_write` creates missing parent folders (`safe-writer.ts:306`, - `mkdirSync(recursive)` then tmp-write+rename) and **overwrites** existing files - (`vault.ts:170`). -- `vault_list` reads the SQLite `note_metadata` table, filters on **fixed columns - only** (`date, experiment_type, project, status, project_id, series`), and - returns **at most 50 rows** (`vault.ts:96`). A `folder` containing `/` matches - `path LIKE 'folder/%'`. -- The CLI applies pending edits and incrementally indexes each write — **but an - index failure is logged and still reported as a successful write** - (`apply-edit.ts:84`). The file on disk is therefore the source of truth; the - index is derived and can lag. +- `vault_write` creates missing parent folders (`safe-writer.ts:306`) and + **overwrites** existing files (`vault.ts:170`). Through the CLI it uses a + **throwaway ConflictDetector with no read snapshot**, so it **cannot detect a + concurrent edit** — it overwrites unconditionally (`tool-dispatch.ts:43`). This + is why §7 never overwrites an existing note. +- `vault_list` reads SQLite `note_metadata`, filters on **fixed columns only** + (`date, experiment_type, project, status, project_id, series`), and returns **at + most 50 rows** (`vault.ts:96`). A `folder` with `/` matches `path LIKE 'f/%'`. +- The CLI applies edits and incrementally indexes each write — **but an index + failure is logged and still reported as success** (`apply-edit.ts:84`). The file + on disk is the source of truth; the index is derived. - `vault_append` **errors if the target file does not exist** (`vault.ts:123`). - `task_list`/`task_complete` scan only `Memory/Daily/` (`tasks.ts:26`); - `get_today_diary`/`get_week_plan` read only the single dated file - (`context.ts`). Notes under `Memory/voiceMemo/` do not pollute task/diary logic. + `get_today_diary`/`get_week_plan` read only the single dated file. Notes under + `Memory/voiceMemo/` do not pollute task/diary logic. - DB is WAL (`database.ts:16`). - `cricknote setup` **overwrites** the vault's `CLAUDE.md` *and* `AGENTS.md` from - `templates/agent-docs/`, and copies the repo `skills/` dir into the vault's - `.claude/skills/` + `.agents/skills/` (`install-agent-assets.ts:10-26`). Vault - files must therefore be edited via the repo templates/skills, not by hand. + `templates/agent-docs/`, and copies the repo `skills/` dir into the vault + (`install-agent-assets.ts:10-26`). Vault files must be edited via repo + templates/skills, not by hand. ## 4. Architecture: a two-stage pipeline ``` -STAGE 1 — unattended (voice2note, ~2h) +STAGE 1 — unattended (voice2note, ~2h), strictly create-once capture → transcribe → classify (provisional) → render one note - → write to Memory/voiceMemo/ via `cricknote tool vault_write` + → IF the deterministic note path already exists: adopt into state, do NOT write + → ELSE write to Memory/voiceMemo/ via `cricknote tool vault_write` conversation / talk → status: complete (finalized) record / uncertain → status: draft (triage queue) analysis failed → transcript-only, status: draft, analysis: pending @@ -96,9 +114,10 @@ STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) cricknote-voice-inbox: cricknote reindex (MANDATORY first) vault_list {folder:"Memory/voiceMemo", status:"draft" | "in-progress"} - → per record: read; you pick one OR MORE of: + → per record: read; if analysis: pending, summarize it first; then you pick + one OR MORE of: • append to related experiment (vault_search → vault_append + marker) - • new idea note in Ideas/ (vault_write) + • new idea note in Ideas/ (deterministic path + marker) • tasks from to-dos (task_add + marker, deduped) …or reclassify, or dismiss. → progress recorded in frontmatter as each route completes; @@ -106,14 +125,15 @@ STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) cricknote-daily-review: surfaces "N voice records pending triage (50+ if capped)" ``` -**Stage 1 is create-only and write-once** (§7). It only creates notes in -`Memory/voiceMemo/`, never touches an existing note's content beyond its own -pristine output (hash-guarded), and never writes serialized notes. +**voice2note never overwrites an existing note.** The only write is a *create* at +a deterministic, source-id-derived path; if that path exists, it is adopted into +state and left untouched. This eliminates overwrite-after-triage entirely, with no +dependency on conflict detection. ## 5. Classification (provisional) Done in voice2note's `codex exec` call (the only LLM change). Whisper output has -**no speaker labels**, so type inference from content is imperfect — therefore: +**no speaker labels**, so type inference is imperfect — therefore: - The **only behavior-changing distinction is record vs. not-record.** conversation and talk are handled identically (summary, no tasks, @@ -121,16 +141,21 @@ Done in voice2note's `codex exec` call (the only LLM change). Whisper output has - **Ambiguity rule:** confidence `< VOICE2NOTE_CONFIDENCE_MIN` (default 0.5), or a missing/invalid type, → **record** (queued). Nothing is silently shelved. - Classification is **provisional**: the inbox skill can **reclassify** or - **dismiss**. A record wrongly queued is a one-action dismiss (§8). -- Future option (not v1): whisper.cpp `tinydiarize` (`-tdrz`, `*-tdrz` model) - for crude speaker-turn markers to improve the conv/talk split. + **dismiss**. +- **Analysis failure** (codex down) writes a **transcript-only** note + (`analysis: pending`, queued). voice2note never re-analyzes it (the re-check→ + write TOCTOU is uncloseable without a CAS tool, §3). Instead the **triage agent + summarizes it** when you review — a human is present, so an overwrite is a + deliberate, visible edit, not a blind background one. +- Future option (not v1): whisper.cpp `tinydiarize` for crude speaker turns. ## 6. The data contract (the seam) -Note path: `Memory/voiceMemo/--.md`, where `h8` = -first 8 hex of `sha256(source_id)`. The hash makes the filename **deterministic -and collision-free** (two memos in the same minute with the same title no longer -collide), and makes "does this memo already have a note?" a single-path check. +Note path: `Memory/voiceMemo/--.md`, where `h16` = +first **16** hex of `sha256(source_id)`. The hash makes the path **deterministic +and collision-resistant** (a 64-bit tag; two memos in the same minute with the +same title no longer collide) and makes "does this memo already have a note?" a +single-path check. ```yaml --- @@ -146,7 +171,7 @@ routed_to: [] # backlinks recorded as routing progresses source: voice-memo title: "" # YAML-escaped (§7) recorded: 2026-06-18T14:32 -tags: [voice-memo, ] # each tag YAML-escaped +tags: [voice-memo, ] # each tag YAML-escaped; example shown unquoted for readability --- ``` @@ -154,44 +179,41 @@ tags: [voice-memo, ] # each tag YAML-escaped - record / uncertain / analysis-failed → `status: draft`. - **Pending queue = `vault_list {folder:"Memory/voiceMemo", status:"draft"}`** (plus `status:"in-progress"` to resume a crashed triage). One call, no schema - change, no validation warnings (`date`+`type` present, `status` valid). + change, no validation warnings. Body: `## Summary`, optional `## Possible to-dos` (plain bullets — **never** dated -`- [ ]` checkboxes, so nothing leaks into `task_list`), `## Topics`, -`## Notable Details`, `## Transcript` (collapsible). conversation/talk omit the -to-dos section. +`- [ ]` checkboxes), `## Topics`, `## Notable Details`, `## Transcript` +(collapsible). conversation/talk omit the to-dos section. ## 7. Stage 1 — voice2note changes (Python, stdlib only) **State model (`state.json`, v2), keyed by `source_id`:** ``` -memos[source_id] = { - note_rel, written_hash, # sha256 of the exact content v2n last wrote - analysis: "complete" | "pending" | "abandoned", # abandoned = you took over or deleted it - recording_type, updated -} +memos[source_id] = { note_rel, analysis: "complete" | "pending", recording_type, updated } ``` -Old `state.json` (set of processed names) migrates to `analysis: complete`, no -hash (so never re-analyzed). A `source_id` present in `memos` is "seen". - -**Write-once with hash guard (the core safety property):** - -- **New memo:** transcribe → analyze. - - success → render full note; `cricknote tool vault_write`; store `written_hash`; - `analysis: complete`. - - failure → render transcript-only note (`analysis: pending`, `status: draft`, - `recording_type: record`); write; store `written_hash`; `analysis: pending`. - The memo is now visible and queued. -- **Re-analysis pass** (memos with `analysis: pending`): read the note from disk. - - file missing → user deleted it → set `analysis: abandoned`; never resurrect. - - on-disk hash ≠ `written_hash` → you/the agent edited or triaged it → set - `analysis: abandoned`; never overwrite. - - on-disk hash == `written_hash` → still pristine → re-run analysis; on success - rewrite (preserving `source_id`; conv/talk → `status: complete`, record → - keep `draft`), update `written_hash`, `analysis: complete`. On failure leave - pending. -- Transcription failure writes nothing (memo stays unseen) → safe retry. +No hashes (write-once needs none). Old `state.json` (a `processed_ids` set) +migrates: each id → `{ analysis: "complete" }` (already handled; never revisited). +**`save()` becomes atomic** (write a temp file, `os.replace` over the target) so a +crash mid-save can't corrupt state. + +**Create-once flow, per memo each run:** + +1. Compute the deterministic note path from `source_id` (§6). +2. `source_id` already in `state.memos` → already handled → skip. +3. Else the note **file exists on disk** (a crash wrote it before state was saved) + → read its frontmatter, record it into `state.memos`, **do not rewrite**. +4. Else transcribe + analyze: + - success → render full note → `cricknote tool vault_write` (create) → + record state (`analysis: complete`). + - analysis failure → render transcript-only note (`analysis: pending`, + `status: draft`, `recording_type: record`) → write → record (`analysis: + pending`). The memo is now visible and queued; voice2note never revisits it. + - transcription failure → write nothing, record nothing → safe retry next run. +5. `state.save()` (atomic) after each memo, narrowing the unsaved window to one. + +voice2note assumes a single instance (launchd's interval serializes runs); a +PID/lock file is optional hardening. **Files:** @@ -200,23 +222,27 @@ hash (so never re-analyzed). A `source_id` present in `memos` is "seen". in [0,1] with a safe default (→ record) on garbage. - `routing.py` (new, pure) — `recording_type → (status, include_todos)`. Single source of routing policy; unit-tested in isolation. -- `notes.py` — emit the §6 frontmatter via a **stdlib YAML double-quoted-scalar +- `notes.py` — emit §6 frontmatter via a **stdlib YAML double-quoted-scalar escaper** (`title`, each `tag`; escape `\`, `"`, control chars, collapse - newlines); render to-dos as plain bullets; drop dated checkboxes and the - daily-note block. + newlines); render to-dos as plain bullets; **remove** dated checkboxes + (line ~35) and the daily-note block. - `vault_writer.py` (new) — build `cricknote tool vault_write ''` with a **vault-relative** path, JSON via `json.dumps`, `subprocess.run([...], - shell=False)`. A non-zero exit / error is a **failed write** → raise so the - memo is retried next run; **never silently fall back to a direct file write.** - Direct file write happens only when `VOICE2NOTE_USE_CRICKNOTE` is explicitly - off (standalone mode). -- `config.py` — `VOICE2NOTE_USE_CRICKNOTE`, `VOICE2NOTE_CRICKNOTE_BIN`, - `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`; **reject absolute paths - and `..`; must normalize within the vault**), `VOICE2NOTE_CONFIDENCE_MIN`. - **Validate vault equality:** canonicalize (`realpath`) `VOICE2NOTE_VAULT` and - `~/.cricknote/config.json`'s `vaultPath`; on mismatch, fail with a clear error - (in startup and `--check`). -- `run.py` — drive new-vs-pending passes; drop `append_to_daily`. + shell=False)`. A non-zero exit / error **raises** → the memo is retried next + run; **never** silently falls back to a direct file write. Also exposes a + read-existence/read-frontmatter helper (direct fs, since voice2note has vault + access) for steps 2-3. +- `config.py` — + - `VOICE2NOTE_USE_CRICKNOTE` — **explicit `true`/`false`, no auto-detect**; + default `false` (standalone direct write, today's behavior). When `true` and + the CLI is missing/unrunnable → **hard fail** at startup (no fallback). + - `VOICE2NOTE_CRICKNOTE_BIN`, `VOICE2NOTE_CONFIDENCE_MIN`. + - `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`; **reject absolute paths + and `..`; must normalize within the vault**). + - **Validate vault equality:** canonicalize (`realpath`) `VOICE2NOTE_VAULT` and + `~/.cricknote/config.json`'s `vaultPath`; on mismatch, fail clearly (startup + and `--check`). +- `run.py` — drive the create-once flow; drop `append_to_daily`. ## 8. Stage 2 — CrickNote changes (skills + templates, no engine code) @@ -224,16 +250,18 @@ hash (so never re-analyzed). A `source_id` present in `memos` is "seen". 1. `cricknote reindex` — **mandatory**, so the queue reflects disk even if an incremental index failed. 2. `vault_list '{"folder":"Memory/voiceMemo","status":"draft"}'` (and - `"in-progress"` to resume). If exactly 50 returned, tell the user the list is - capped. - 3. Per record: `vault_read`; the agent proposes **one or more** destinations and - **waits for confirmation**: + `"in-progress"` to resume). If exactly 50 rows return, tell the user the list + is capped (show "50+"). + 3. Per record: `vault_read`. **If `analysis: pending`** (transcript-only), + summarize it with the user first and `vault_write` the summary back (sets + `analysis: complete`). Then propose **one or more** destinations and **wait + for confirmation**: - **experiment** → `vault_search`; `vault_append` a timestamped block ending - with an idempotency marker ``. Re-check via - `vault_read` and skip if the marker is already present. - - **idea** → `vault_write` a new note in top-level `Ideas/` (classified - `unknown`; no required frontmatter). - - **tasks** → `task_add` each accepted to-do with a trailing `[v2n:]` + with ``. Re-read and skip if the marker is present. + - **idea** → create `Ideas/-.md` (deterministic, `unknown` note + type, no required frontmatter) carrying `source_id`; **check existence + first** and skip if present. + - **tasks** → `task_add` each accepted to-do with a trailing `[v2n:]` marker; dedupe by searching first (as `cricknote-reminders` does). - **reclassify** → update `recording_type` (to conv/talk → `status: complete`; stays record → remains `draft`). @@ -241,10 +269,10 @@ hash (so never re-analyzed). A `source_id` present in `memos` is "seen". 4. **Resumable progress:** set `status: in-progress` when routing starts; append each finished target to `routed_to`; only after all chosen routes succeed set `status: complete`, `disposition: routed`, with backlinks. A crash leaves - `in-progress` + partial `routed_to`; re-running resumes and markers prevent - duplicates. -- **`cricknote-daily-review`** — add a step counting the pending queue (after the - mandatory reindex it already does) and surfacing it ("50+" when capped). + `in-progress` + partial `routed_to`; re-running resumes and the markers + prevent duplicate appends/notes/tasks. +- **`cricknote-daily-review`** — add a step counting the pending queue and + surfacing it ("50+" when capped). - **Agent-doc templates** — edit repo `templates/agent-docs/CLAUDE.md` **and** `AGENTS.md`: note `Memory/voiceMemo/` is voice2note-owned, `Ideas/` is the idea-note destination, and `cricknote-voice-inbox` is the triage workflow. @@ -253,66 +281,79 @@ hash (so never re-analyzed). A `source_id` present in `memos` is "seen". | Risk | Mitigation | |---|---| -| Overwrite of a triaged note on retry | Write-once + `written_hash` guard (§7); deterministic `source_id`-hashed filename; voice2note never overwrites content it didn't last write | -| Duplicate experiment append / tasks on retry | Per-route idempotency markers (``, `[v2n:h8]`) checked before acting; resumable `status`/`routed_to` progress (§8) | -| Imperfect type from speaker-less transcript | Only record-vs-not-record matters; bias-to-record; provisional + reclassify/dismiss in triage | -| Silent incremental-index failure hides a draft | File on disk is truth; **mandatory `reindex`** before listing; voice2note keys "written" on the file, not on indexing | -| `vault_list` 50-row cap undercounts | Show "50+" when capped; design assumes regular triage keeps the queue small | -| Auto direct-write bypasses indexing/audit | Direct write only in explicit standalone mode; CLI errors → retry, never silent fallback | +| Overwrite of a triaged note on retry | voice2note is write-once; a *create* only happens when the deterministic path is absent (existence check, §7); existing notes are never rewritten | +| Crash after write, before `state.save` | Pre-write existence check adopts the on-disk note into state without rewriting; `state.save` is atomic (temp + `os.replace`) | +| Concurrent edit during background re-analysis (TOCTOU) | Eliminated: voice2note does **not** re-analyze; transcript-only notes are summarized by the triage agent with a human present | +| Duplicate experiment append / idea note / tasks on retry | Deterministic idea path; per-route markers (``, `[v2n:h16]`) checked before acting; resumable `status`/`routed_to` progress (§8) | +| Imperfect type from speaker-less transcript | Only record-vs-not-record matters; bias-to-record; provisional + reclassify/dismiss | +| Silent incremental-index failure hides a draft | File on disk is truth; **mandatory `reindex`** before listing | +| `vault_list` 50-row cap undercounts | Show "50+" when capped; design assumes regular triage | +| Auto direct-write bypasses indexing/audit | `USE_CRICKNOTE` is explicit; `true` + missing CLI fails; CLI errors → retry, never silent fallback | | Wrong vault (config drift) | Canonical-path equality check at startup/`--check`; fail safely | -| SQLITE_BUSY (launchd vs. open agent) | WAL serializes writers; voice2note retries next run. `busy_timeout` is an optional *separate* crickNote PR, not in scope (keeps "no engine code") | -| Malformed YAML drops frontmatter (→ misclassified/unqueued) | stdlib double-quoted-scalar escaper for title/tags; clamped-float confidence; tests for hostile inputs | -| Nutstore sync mid-write | Atomic tmp+rename; vault path never on a command line (CLI reads its own config) | +| SQLITE_BUSY (launchd vs. open agent) | WAL serializes writers; voice2note retries next run. `busy_timeout` is an optional *separate* crickNote PR, out of scope (keeps "no engine code") | +| Malformed YAML drops frontmatter | stdlib double-quoted-scalar escaper for title/tags; clamped-float confidence; tests for hostile inputs | +| Nutstore sync mid-write | Atomic tmp+rename; vault path never on a command line | ## 10. Configuration (new voice2note env) | Variable | Default | Meaning | |---|---|---| -| `VOICE2NOTE_USE_CRICKNOTE` | on if bin found | Route writes through `cricknote tool` vs. standalone direct write | -| `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CrickNote CLI (set in the LaunchAgent for launchd PATH) | +| `VOICE2NOTE_USE_CRICKNOTE` | `false` | Explicit `true`/`false`. `true` routes writes through `cricknote tool` (missing CLI → hard fail); `false` = standalone direct write | +| `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CLI (set in the LaunchAgent for launchd PATH) | | `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Vault-relative folder; absolute/`..` rejected | | `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Below this → force `recording_type: record` | `VOICE2NOTE_VAULT` must canonically equal `~/.cricknote/config.json`'s `vaultPath` (validated; hard failure on mismatch). -## 11. Testing (TDD; existing 26 tests updated where behavior changes) +## 11. Testing - **voice2note (unittest):** - - `parse_analysis` extracts/defaults `recording_type`; `confidence` coercion - (string, out-of-range, missing) → clamped float; ambiguity rule → record. - - `routing.py` status/todos per type. - - YAML escaper: titles with `"`, `:`, `\`, newlines, emoji; tag lists → output - re-parses to the original values. - - **Write-once/hash guard:** new→write; pending+pristine→re-analyze+update; - pending+modified→skip (no overwrite); missing file→no resurrect; filename - determinism + collision (same minute+title, different `source_id`). - - `vault_writer` builds correct argv (subprocess mocked); CLI error raises (no + - `parse_analysis`: extracts/defaults `recording_type`; `confidence` coercion + (string/out-of-range/missing) → clamped float; ambiguity rule → record. + - `routing.py`: status/todos per type. + - YAML escaper: titles with `"`, `:`, `\`, newlines, emoji; tag lists → + re-parse to original values. + - **Create-once idempotency:** path-exists → adopt into state, **no** write + (assert `vault_write` not called); filename determinism + collision (same + minute+title, different `source_id`). + - **Crash window:** note on disk but `source_id` absent from state → adopt, no + rewrite. + - **Atomic save:** interrupted save leaves the prior `state.json` intact + (temp + `os.replace`). + - `vault_writer`: correct argv (subprocess mocked); CLI error **raises** (no direct-write); standalone mode writes directly. - - config: vault-equality mismatch fails; `MEMO_RELDIR` rejects absolute/`..`. - - `notes.py` emits no `- [ ]` lines; no daily block. -- **CrickNote (vitest + temp vault):** a `status:draft` note in `Memory/voiceMemo/` - is returned by the queue query and **not** by `task_list`; multi-destination - routing appends-with-marker / writes an `Ideas/` note / adds a deduped task; - re-running a partially-routed note does not duplicate; status/disposition - transitions; dismiss and reclassify leave the queue correctly. Skills validated - from the vault dir with Claude Code and Codex. + - `config`: vault-equality mismatch fails; `MEMO_RELDIR` rejects absolute/`..`; + `USE_CRICKNOTE=true` + missing CLI fails. + - `notes.py`: emits no `- [ ]` lines; no daily block. +- **CrickNote:** no new engine code, so the existing **Vitest** suite is + regression-only — run it to confirm the baseline (it was interrupted in review, + so the baseline is currently unconfirmed). The `cricknote-voice-inbox` skill is + **Markdown orchestration that Vitest cannot exercise**; its behaviors + (multi-route, markers, resume from `in-progress`, dismiss, reclassify, + summarize-then-route for `analysis: pending`) are validated by **manual + acceptance tests**: scripted scenarios run through Claude Code and Codex against + a temp vault, with a written checklist (queue listing, idempotent re-run, no + duplicate append/task, status/disposition transitions). ## 12. Phases 1. **voice2note:** classification + ambiguity rule; §6 contract + YAML escaper; - `routing.py`; `state.json` v2 + write-once/hash-guard + re-analysis pass; - create-only `vault_writer` (no silent fallback); config validation; drop daily - block. Tests. + `routing.py`; `state.json` v2 + atomic save + create-once existence check; + `vault_writer.py` (no silent fallback; explicit `USE_CRICKNOTE`); config + validation; drop checkboxes + daily block. unittest throughout. 2. **CrickNote:** `cricknote-voice-inbox` skill (mandatory reindex, multi-route, - idempotency markers, resumable progress, dismiss/reclassify) + daily-review - hook + `CLAUDE.md`/`AGENTS.md` template edits. Validate against the real vault. -3. **Hardening/docs:** LaunchAgent PATH/bin; README updates in both repos; - (optional, separate) crickNote `busy_timeout` PR if contention is observed. + deterministic idea path, idempotency markers, resumable progress, + dismiss/reclassify, summarize-then-route) + daily-review hook + + `CLAUDE.md`/`AGENTS.md` template edits. Confirm the Vitest regression baseline; + run the manual acceptance tests against the real vault with both agents. +3. **Hardening/docs:** LaunchAgent PATH/bin; READMEs in both repos; optional + PID lock; (optional, separate) crickNote `busy_timeout` PR if contention is + observed. ## 13. Open questions - None blocking. KB mapping stays out of the voice path by decision (§2). -- A `Memory/Daily/` one-line linked summary is deferred (would break Stage 1's - create-only property; `vault_append` also requires the daily note to exist). -- Diarization (`tinydiarize`) deferred to a future iteration (§5). +- A `Memory/Daily/` one-line linked summary is deferred (would break the + create-once property; `vault_append` also requires the daily note to exist). +- Diarization (`tinydiarize`) deferred (§5). From 55c634b7df85ac2c4ea822021386d268b8b5cb24 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:12:41 +0100 Subject: [PATCH 04/27] =?UTF-8?q?docs(spec):=20r4=20=E2=80=94=20approved-p?= =?UTF-8?q?ending=20fixes=20(per-task=20markers,=20adopt=20verify,=20lock,?= =?UTF-8?q?=20gated=20config)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third review round (architecture approved pending these four): - per-task task marker [v2n::] so a partial routing retry doesn't skip a memo's still-unfinished tasks - note adoption verifies on-disk source_id; missing/mismatch is a hard error (no silent adopt, no overwrite) — applies to voice notes and idea notes - single-instance fcntl.flock lock is mandatory (manual + scheduled runs can otherwise both pass the existence check and double-write) - vault/config equality check runs only when USE_CRICKNOTE=true; standalone mode requires no CrickNote config Status: Approved for implementation. Co-Authored-By: Claude Opus 4.8 --- ...voice2note-cricknote-integration-design.md | 131 +++++++++++------- 1 file changed, 80 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md index e57020f..6b68791 100644 --- a/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md +++ b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md @@ -1,7 +1,7 @@ # voice2note → CrickNote Integration: classify recordings and route by type **Date:** 2026-06-18 -**Status:** Proposed design (Revision 3), pending implementation plan +**Status:** Approved for implementation (Revision 4) **Repos:** `voice2note` (engine changes) · `crickNote` (skills + agent-doc templates) > **Implementation status: not started.** This is a design document. No source in @@ -15,6 +15,12 @@ ## Revision history +- **r4 (2026-06-18)** — third review round (approved pending these four): + per-task marker `[v2n::]` so a partial retry doesn't skip a + memo's still-unfinished tasks; note adoption **verifies `source_id`** and errors + on missing/mismatch instead of silently adopting; a single-instance + `flock` lock is **mandatory** (not optional); the vault/config equality check + runs **only when `USE_CRICKNOTE=true`** (standalone needs no CrickNote config). - **r3 (2026-06-18)** — second review round. **Dropped hash-guarded re-analysis** (its re-check→write window is a real TOCTOU that the CLI's snapshot-less `vault_write` cannot guard, and a compare-and-swap tool would be @@ -24,7 +30,7 @@ window. Idea routing gets a deterministic path + marker. `USE_CRICKNOTE` is an explicit boolean (no auto-detect). Hashes are ≥16 hex (collision-resistant, not "collision-free"). Testing split: unittest/Vitest for code, **manual acceptance - tests** for skill orchestration. See §13. + tests** for skill orchestration. - **r2** — hardened against data-loss modes (write-once, resumable routing, provisional classification, mandatory reindex, etc.). - **r1** — initial two-stage design. @@ -100,9 +106,9 @@ and a daily-note block — both change in this design. ## 4. Architecture: a two-stage pipeline ``` -STAGE 1 — unattended (voice2note, ~2h), strictly create-once - capture → transcribe → classify (provisional) → render one note - → IF the deterministic note path already exists: adopt into state, do NOT write +STAGE 1 — unattended (voice2note, ~2h), single-instance, strictly create-once + acquire flock → capture → transcribe → classify (provisional) → render one note + → IF the deterministic note path already exists (source_id verified): adopt, no write → ELSE write to Memory/voiceMemo/ via `cricknote tool vault_write` conversation / talk → status: complete (finalized) record / uncertain → status: draft (triage queue) @@ -117,8 +123,8 @@ STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) → per record: read; if analysis: pending, summarize it first; then you pick one OR MORE of: • append to related experiment (vault_search → vault_append + marker) - • new idea note in Ideas/ (deterministic path + marker) - • tasks from to-dos (task_add + marker, deduped) + • new idea note in Ideas/ (deterministic path + source_id marker) + • tasks from to-dos (task_add + per-task marker, deduped) …or reclassify, or dismiss. → progress recorded in frontmatter as each route completes; status: draft → in-progress → complete (disposition: routed | dismissed) @@ -127,8 +133,8 @@ STAGE 2 — human-in-the-loop (you + CrickNote agent, on review) **voice2note never overwrites an existing note.** The only write is a *create* at a deterministic, source-id-derived path; if that path exists, it is adopted into -state and left untouched. This eliminates overwrite-after-triage entirely, with no -dependency on conflict detection. +state (after verifying `source_id`) and left untouched. This eliminates +overwrite-after-triage entirely, with no dependency on conflict detection. ## 5. Classification (provisional) @@ -187,6 +193,12 @@ Body: `## Summary`, optional `## Possible to-dos` (plain bullets — **never** d ## 7. Stage 1 — voice2note changes (Python, stdlib only) +**Single-instance lock (mandatory).** launchd's interval only serializes its own +scheduled runs; a manual `launchctl start` or `./run.sh` can run concurrently with +a scheduled run, and both could pass the existence check (step 3) for the same new +memo and write it twice. So at startup acquire an exclusive `fcntl.flock` on a lock +file; if it is already held, exit immediately. + **State model (`state.json`, v2), keyed by `source_id`:** ``` @@ -197,12 +209,15 @@ migrates: each id → `{ analysis: "complete" }` (already handled; never revisit **`save()` becomes atomic** (write a temp file, `os.replace` over the target) so a crash mid-save can't corrupt state. -**Create-once flow, per memo each run:** +**Create-once flow, per memo each run (under the lock):** 1. Compute the deterministic note path from `source_id` (§6). 2. `source_id` already in `state.memos` → already handled → skip. 3. Else the note **file exists on disk** (a crash wrote it before state was saved) - → read its frontmatter, record it into `state.memos`, **do not rewrite**. + → read its frontmatter and **verify `source_id` matches**: + - matches → record it into `state.memos`, **do not rewrite**. + - missing or different (stray file / hash collision at the path) → **stop with + an error**; neither adopt nor overwrite; leave the memo for the operator. 4. Else transcribe + analyze: - success → render full note → `cricknote tool vault_write` (create) → record state (`analysis: complete`). @@ -212,9 +227,6 @@ crash mid-save can't corrupt state. - transcription failure → write nothing, record nothing → safe retry next run. 5. `state.save()` (atomic) after each memo, narrowing the unsaved window to one. -voice2note assumes a single instance (launchd's interval serializes runs); a -PID/lock file is optional hardening. - **Files:** - `analyze.py` — prompt + `parse_analysis` also return `recording_type` and @@ -239,10 +251,11 @@ PID/lock file is optional hardening. - `VOICE2NOTE_CRICKNOTE_BIN`, `VOICE2NOTE_CONFIDENCE_MIN`. - `VOICE2NOTE_MEMO_RELDIR` (default `Memory/voiceMemo`; **reject absolute paths and `..`; must normalize within the vault**). - - **Validate vault equality:** canonicalize (`realpath`) `VOICE2NOTE_VAULT` and - `~/.cricknote/config.json`'s `vaultPath`; on mismatch, fail clearly (startup - and `--check`). -- `run.py` — drive the create-once flow; drop `append_to_daily`. + - **Validate vault equality — only when `USE_CRICKNOTE=true`:** canonicalize + (`realpath`) `VOICE2NOTE_VAULT` and `~/.cricknote/config.json`'s `vaultPath`; + on mismatch, fail clearly (startup and `--check`). In standalone mode + (`false`) CrickNote config is **not** read or required. +- `run.py` — acquire the lock; drive the create-once flow; drop `append_to_daily`. ## 8. Stage 2 — CrickNote changes (skills + templates, no engine code) @@ -259,10 +272,15 @@ PID/lock file is optional hardening. - **experiment** → `vault_search`; `vault_append` a timestamped block ending with ``. Re-read and skip if the marker is present. - **idea** → create `Ideas/-.md` (deterministic, `unknown` note - type, no required frontmatter) carrying `source_id`; **check existence - first** and skip if present. - - **tasks** → `task_add` each accepted to-do with a trailing `[v2n:]` - marker; dedupe by searching first (as `cricknote-reminders` does). + type, no required frontmatter) carrying `source_id`. **Check existence + first and verify the on-disk `source_id` matches** before treating it as + already-created (a different/missing `source_id` is a real collision → stop + and pick another slug, don't overwrite). + - **tasks** → `task_add` each accepted to-do with a trailing **per-task** + marker `[v2n::]` (`task-h8` = first 8 hex of + `sha256(normalized task text)`); dedupe by searching that **exact** marker + first (as `cricknote-reminders` does). A per-memo-only marker would make a + partial retry skip the memo's still-unfinished tasks. - **reclassify** → update `recording_type` (to conv/talk → `status: complete`; stays record → remains `draft`). - **dismiss** → `status: complete`, `disposition: dismissed`, no routing. @@ -282,14 +300,16 @@ PID/lock file is optional hardening. | Risk | Mitigation | |---|---| | Overwrite of a triaged note on retry | voice2note is write-once; a *create* only happens when the deterministic path is absent (existence check, §7); existing notes are never rewritten | -| Crash after write, before `state.save` | Pre-write existence check adopts the on-disk note into state without rewriting; `state.save` is atomic (temp + `os.replace`) | +| Crash after write, before `state.save` | Pre-write existence check adopts the on-disk note (after verifying its `source_id`) without rewriting; `state.save` is atomic (temp + `os.replace`) | +| Two concurrent voice2note runs race the existence check | Mandatory `fcntl.flock` single-instance lock at startup; a second run exits immediately (§7) | +| Adopting a stray/colliding file at the deterministic path | Step 3 verifies the on-disk `source_id`; missing/mismatch → hard error, no adopt and no overwrite | | Concurrent edit during background re-analysis (TOCTOU) | Eliminated: voice2note does **not** re-analyze; transcript-only notes are summarized by the triage agent with a human present | -| Duplicate experiment append / idea note / tasks on retry | Deterministic idea path; per-route markers (``, `[v2n:h16]`) checked before acting; resumable `status`/`routed_to` progress (§8) | +| Duplicate experiment append / idea note / tasks on retry | Deterministic idea path; per-route markers — `` (append), `[v2n:memo-h16:task-h8]` (per task) — checked before acting; resumable `status`/`routed_to` progress (§8) | | Imperfect type from speaker-less transcript | Only record-vs-not-record matters; bias-to-record; provisional + reclassify/dismiss | | Silent incremental-index failure hides a draft | File on disk is truth; **mandatory `reindex`** before listing | | `vault_list` 50-row cap undercounts | Show "50+" when capped; design assumes regular triage | | Auto direct-write bypasses indexing/audit | `USE_CRICKNOTE` is explicit; `true` + missing CLI fails; CLI errors → retry, never silent fallback | -| Wrong vault (config drift) | Canonical-path equality check at startup/`--check`; fail safely | +| Wrong vault (config drift) | When `USE_CRICKNOTE=true`, canonical-path equality check at startup/`--check`; fail safely. Standalone needs no CrickNote config | | SQLITE_BUSY (launchd vs. open agent) | WAL serializes writers; voice2note retries next run. `busy_timeout` is an optional *separate* crickNote PR, out of scope (keeps "no engine code") | | Malformed YAML drops frontmatter | stdlib double-quoted-scalar escaper for title/tags; clamped-float confidence; tests for hostile inputs | | Nutstore sync mid-write | Atomic tmp+rename; vault path never on a command line | @@ -298,13 +318,14 @@ PID/lock file is optional hardening. | Variable | Default | Meaning | |---|---|---| -| `VOICE2NOTE_USE_CRICKNOTE` | `false` | Explicit `true`/`false`. `true` routes writes through `cricknote tool` (missing CLI → hard fail); `false` = standalone direct write | +| `VOICE2NOTE_USE_CRICKNOTE` | `false` | Explicit `true`/`false`. `true` routes writes through `cricknote tool` (missing CLI → hard fail); `false` = standalone direct write, no CrickNote config required | | `VOICE2NOTE_CRICKNOTE_BIN` | `which cricknote` | Absolute path to the CLI (set in the LaunchAgent for launchd PATH) | | `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Vault-relative folder; absolute/`..` rejected | | `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Below this → force `recording_type: record` | -`VOICE2NOTE_VAULT` must canonically equal `~/.cricknote/config.json`'s -`vaultPath` (validated; hard failure on mismatch). +Only when `USE_CRICKNOTE=true`: `VOICE2NOTE_VAULT` must canonically equal +`~/.cricknote/config.json`'s `vaultPath` (validated; hard failure on mismatch). +Standalone mode requires no CrickNote configuration. ## 11. Testing @@ -314,42 +335,50 @@ PID/lock file is optional hardening. - `routing.py`: status/todos per type. - YAML escaper: titles with `"`, `:`, `\`, newlines, emoji; tag lists → re-parse to original values. - - **Create-once idempotency:** path-exists → adopt into state, **no** write - (assert `vault_write` not called); filename determinism + collision (same - minute+title, different `source_id`). + - **Create-once idempotency:** path-exists with matching `source_id` → adopt, + **no** write (assert `vault_write` not called); filename determinism + + collision (same minute+title, different `source_id`). + - **Adopt verification:** path-exists with missing/different `source_id` → + raises; neither adopts nor overwrites. - **Crash window:** note on disk but `source_id` absent from state → adopt, no rewrite. + - **Single-instance lock:** a second run with the lock held exits without + writing. - **Atomic save:** interrupted save leaves the prior `state.json` intact (temp + `os.replace`). - `vault_writer`: correct argv (subprocess mocked); CLI error **raises** (no direct-write); standalone mode writes directly. - - `config`: vault-equality mismatch fails; `MEMO_RELDIR` rejects absolute/`..`; - `USE_CRICKNOTE=true` + missing CLI fails. + - `config`: with `USE_CRICKNOTE=true`, vault-equality mismatch fails and missing + CLI fails; with `false`, `~/.cricknote/config.json` is not read; `MEMO_RELDIR` + rejects absolute/`..`. - `notes.py`: emits no `- [ ]` lines; no daily block. - **CrickNote:** no new engine code, so the existing **Vitest** suite is - regression-only — run it to confirm the baseline (it was interrupted in review, - so the baseline is currently unconfirmed). The `cricknote-voice-inbox` skill is - **Markdown orchestration that Vitest cannot exercise**; its behaviors - (multi-route, markers, resume from `in-progress`, dismiss, reclassify, - summarize-then-route for `analysis: pending`) are validated by **manual - acceptance tests**: scripted scenarios run through Claude Code and Codex against - a temp vault, with a written checklist (queue listing, idempotent re-run, no - duplicate append/task, status/disposition transitions). + regression-only (review confirmed a focused run green: 26 voice2note + 10 + CrickNote tests passed; run the full suite to confirm the complete baseline). + The `cricknote-voice-inbox` skill is **Markdown orchestration that Vitest cannot + exercise**; its behaviors (multi-route, markers, resume from `in-progress`, + dismiss, reclassify, summarize-then-route for `analysis: pending`) are validated + by **manual acceptance tests**: scripted scenarios run through Claude Code and + Codex against a temp vault, with a written checklist (queue listing, idempotent + re-run, no duplicate append/task, **partial-retry completes the remaining + tasks**, status/disposition transitions). ## 12. Phases -1. **voice2note:** classification + ambiguity rule; §6 contract + YAML escaper; - `routing.py`; `state.json` v2 + atomic save + create-once existence check; +1. **voice2note:** mandatory `flock` single-instance lock; classification + + ambiguity rule; §6 contract + YAML escaper; `routing.py`; `state.json` v2 + + atomic save + create-once existence check (with `source_id` verification); `vault_writer.py` (no silent fallback; explicit `USE_CRICKNOTE`); config - validation; drop checkboxes + daily block. unittest throughout. + validation (gated on `USE_CRICKNOTE`); drop checkboxes + daily block. unittest + throughout. 2. **CrickNote:** `cricknote-voice-inbox` skill (mandatory reindex, multi-route, - deterministic idea path, idempotency markers, resumable progress, - dismiss/reclassify, summarize-then-route) + daily-review hook + - `CLAUDE.md`/`AGENTS.md` template edits. Confirm the Vitest regression baseline; - run the manual acceptance tests against the real vault with both agents. -3. **Hardening/docs:** LaunchAgent PATH/bin; READMEs in both repos; optional - PID lock; (optional, separate) crickNote `busy_timeout` PR if contention is - observed. + deterministic idea path with `source_id` check, per-task idempotency markers, + resumable progress, dismiss/reclassify, summarize-then-route) + daily-review + hook + `CLAUDE.md`/`AGENTS.md` template edits. Confirm the Vitest regression + baseline; run the manual acceptance tests against the real vault with both + agents. +3. **Hardening/docs:** LaunchAgent PATH/bin; READMEs in both repos; (optional, + separate) crickNote `busy_timeout` PR if contention is observed. ## 13. Open questions From 6cae714567dc45f01ade33521c0ea3632773454e Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:19:58 +0100 Subject: [PATCH 05/27] docs(plan): phase-1 voice2note implementation plan (TDD, 10 tasks) Bite-sized test-first plan for the voice2note engine changes from the r4 spec: routing policy, stdlib YAML escaper, recording_type classification, deterministic note path + checkbox/daily-block removal, v2 atomic state, mandatory flock lock, cricknote CLI write bridge, gated config validation, and the create-once orchestration with adopt-verify + transcript-only fallback. Phases 2 (skill) and 3 (ops/docs) to be planned separately. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-18-voice2note-cricknote-phase1.md | 1330 +++++++++++++++++ 1 file changed, 1330 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-18-voice2note-cricknote-phase1.md diff --git a/docs/superpowers/plans/2026-06-18-voice2note-cricknote-phase1.md b/docs/superpowers/plans/2026-06-18-voice2note-cricknote-phase1.md new file mode 100644 index 0000000..677c53a --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-voice2note-cricknote-phase1.md @@ -0,0 +1,1330 @@ +# voice2note → CrickNote Phase 1 (voice2note engine) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make voice2note classify each Voice Memo (conversation | talk | record), write exactly one summary+transcript note into the vault's `Memory/voiceMemo/` folder (via the CrickNote CLI when enabled, else standalone), and queue `record`-type memos for later triage — all crash-safe and write-once. + +**Architecture:** Pure-policy modules (`routing.py`, `yamllite.py`) and a v2 `state.json` underpin a create-once flow in `run.py`: under a mandatory single-instance lock, each memo's note is created at a deterministic `source_id`-derived path; an existing path is adopted (after verifying `source_id`) rather than overwritten. Writes go through `cricknote tool vault_write` (or direct file write in standalone mode). No CrickNote engine code is touched in this phase. + +**Tech Stack:** Python 3 standard library only (no third-party packages). Tests use `unittest`. External tools (`whisper-cli`, `ffmpeg`, `codex`, `cricknote`) are invoked as subprocesses and mocked in tests. + +**Scope:** This plan is Phase 1 (the `voice2note` repo) from the r4 spec (`docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md`). Phase 2 (the `cricknote-voice-inbox` skill + agent-doc templates, validated by manual acceptance tests) and Phase 3 (LaunchAgent/docs) get their own plans. + +**Conventions:** +- Run all tests: `PYTHONPATH=. /usr/bin/python3 -m unittest discover -s tests -t . -v` +- Run one module: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_routing -v` +- Run one test: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_routing.TestRouteFor.test_record -v` + +--- + +### Task 1: Routing policy (`routing.py`) + +Pure functions mapping a recording type to its note disposition, and applying the bias-to-`record` ambiguity rule. No dependencies. + +**Files:** +- Create: `voice2note/routing.py` +- Test: `tests/test_routing.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_routing.py +import unittest +from voice2note.routing import route_for, effective_type, VALID_TYPES + + +class TestRouteFor(unittest.TestCase): + def test_record(self): + self.assertEqual(route_for("record"), ("draft", True)) + + def test_conversation(self): + self.assertEqual(route_for("conversation"), ("complete", False)) + + def test_talk(self): + self.assertEqual(route_for("talk"), ("complete", False)) + + def test_unknown_defaults_to_record_disposition(self): + self.assertEqual(route_for("weird"), ("draft", True)) + + +class TestEffectiveType(unittest.TestCase): + def test_keeps_confident_type(self): + self.assertEqual(effective_type("talk", 0.9, 0.5), "talk") + + def test_low_confidence_forces_record(self): + self.assertEqual(effective_type("talk", 0.4, 0.5), "record") + + def test_invalid_type_forces_record(self): + self.assertEqual(effective_type("podcast", 0.99, 0.5), "record") + + def test_valid_types(self): + self.assertEqual(VALID_TYPES, ("conversation", "talk", "record")) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_routing -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'voice2note.routing'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# voice2note/routing.py +"""Pure routing policy: recording_type → note disposition, plus the +bias-to-record ambiguity rule. No I/O, no dependencies.""" + +VALID_TYPES = ("conversation", "talk", "record") + + +def effective_type(recording_type, confidence, min_confidence): + """Apply the ambiguity rule: an invalid type or sub-threshold confidence + becomes 'record' (so it is queued for triage, never silently shelved).""" + if recording_type not in VALID_TYPES: + return "record" + if confidence < min_confidence: + return "record" + return recording_type + + +def route_for(recording_type): + """Return (status, include_todos) for a recording_type. + conversation/talk → finalized, no tasks; everything else → draft + todos.""" + if recording_type in ("conversation", "talk"): + return ("complete", False) + return ("draft", True) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_routing -v` +Expected: PASS (8 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/routing.py tests/test_routing.py +git commit -m "feat(routing): recording-type disposition + ambiguity rule" +``` + +--- + +### Task 2: YAML scalar escaper (`yamllite.py`) + +A minimal, stdlib-only emitter for YAML double-quoted scalars and flow sequences, so frontmatter `title`/`tags` can't break parsing. + +**Files:** +- Create: `voice2note/yamllite.py` +- Test: `tests/test_yamllite.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_yamllite.py +import unittest +from voice2note.yamllite import dq, seq + + +class TestDq(unittest.TestCase): + def test_plain(self): + self.assertEqual(dq("hello"), '"hello"') + + def test_colon_is_safe_when_quoted(self): + self.assertEqual(dq("a: b"), '"a: b"') + + def test_escapes_double_quote(self): + self.assertEqual(dq('say "hi"'), '"say \\"hi\\""') + + def test_escapes_backslash(self): + self.assertEqual(dq("a\\b"), '"a\\\\b"') + + def test_collapses_newline(self): + self.assertEqual(dq("line1\nline2"), '"line1\\nline2"') + + def test_control_char_hex(self): + self.assertEqual(dq("a\x01b"), '"a\\x01b"') + + def test_emoji_preserved(self): + self.assertEqual(dq("🎙 memo"), '"🎙 memo"') + + def test_none(self): + self.assertEqual(dq(None), '""') + + +class TestSeq(unittest.TestCase): + def test_flow_sequence(self): + self.assertEqual(seq(["voice-memo", "qpcr"]), '["voice-memo", "qpcr"]') + + def test_empty(self): + self.assertEqual(seq([]), "[]") + + def test_quotes_each_item(self): + self.assertEqual(seq(['a:b', 'c']), '["a:b", "c"]') + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_yamllite -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'voice2note.yamllite'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# voice2note/yamllite.py +"""Minimal YAML emitter (stdlib only): double-quoted scalars and flow +sequences, enough to serialize frontmatter values safely. We never parse +YAML here — CrickNote's gray-matter does — we only need valid output.""" + +_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r"} + + +def dq(value): + """Return value as a YAML double-quoted scalar, fully escaped.""" + s = "" if value is None else str(value) + out = [] + for ch in s: + if ch in _ESCAPES: + out.append(_ESCAPES[ch]) + elif ord(ch) < 0x20: + out.append("\\x%02x" % ord(ch)) + else: + out.append(ch) + return '"' + "".join(out) + '"' + + +def seq(values): + """Return a YAML flow sequence of double-quoted scalars.""" + return "[" + ", ".join(dq(v) for v in values) + "]" +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_yamllite -v` +Expected: PASS (11 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/yamllite.py tests/test_yamllite.py +git commit -m "feat(yamllite): stdlib YAML double-quoted scalar escaper" +``` + +--- + +### Task 3: Classification parsing (`analyze.py`) + +Extend the Codex prompt and `parse_analysis` to also return `recording_type` and a clamped `confidence`. Invalid types default to `record`; garbage confidence clamps to a safe default. + +**Files:** +- Modify: `voice2note/analyze.py` (PROMPT, `parse_analysis`; add `coerce_confidence`) +- Test: `tests/test_analyze.py` (extend) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_analyze.py`: + +```python +from voice2note.analyze import coerce_confidence + + +class TestRecordingType(unittest.TestCase): + def _parse(self, **extra): + import json + from voice2note.analyze import parse_analysis + payload = {"summary": "s", "action_items": [], "topics": [], + "notable_details": []} + payload.update(extra) + return parse_analysis(json.dumps(payload)) + + def test_extracts_recording_type(self): + self.assertEqual(self._parse(recording_type="talk")["recording_type"], "talk") + + def test_missing_type_defaults_record(self): + self.assertEqual(self._parse()["recording_type"], "record") + + def test_invalid_type_defaults_record(self): + self.assertEqual(self._parse(recording_type="podcast")["recording_type"], "record") + + def test_confidence_extracted(self): + self.assertEqual(self._parse(confidence=0.8)["confidence"], 0.8) + + +class TestCoerceConfidence(unittest.TestCase): + def test_numeric_string(self): + self.assertEqual(coerce_confidence("0.8"), 0.8) + + def test_garbage_defaults_zero(self): + self.assertEqual(coerce_confidence("high"), 0.0) + + def test_missing_defaults_zero(self): + self.assertEqual(coerce_confidence(None), 0.0) + + def test_clamps_high(self): + self.assertEqual(coerce_confidence(1.5), 1.0) + + def test_clamps_low(self): + self.assertEqual(coerce_confidence(-2), 0.0) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_analyze -v` +Expected: FAIL with `ImportError: cannot import name 'coerce_confidence'` + +- [ ] **Step 3: Write minimal implementation** + +In `voice2note/analyze.py`, update the PROMPT to request the new keys (add these two bullet lines before the `Transcript:` line): + +```python +PROMPT = """You are analyzing a personal voice memo. Read the transcript and respond \ +with ONLY a JSON object (no prose, no code fences) using exactly these keys: +- "summary": a 2-4 sentence summary of what was said. +- "action_items": array of concrete to-dos or follow-ups mentioned (empty if none). +- "topics": array of 2-6 short topic labels (single words or short phrases). +- "notable_details": array of specific facts, names, numbers, or decisions worth keeping (empty if none). +- "recording_type": one of "conversation" (a dialogue with other people), "talk" (a presentation you attended), or "record" (you dictating experiment notes, thoughts, or a new idea). +- "confidence": a number from 0 to 1 for how sure you are of "recording_type". + +Transcript: +\"\"\" +{transcript} +\"\"\" +""" +``` + +Add `coerce_confidence` and extend `parse_analysis`'s return dict: + +```python +from voice2note.routing import VALID_TYPES + + +def coerce_confidence(value): + try: + f = float(value) + except (TypeError, ValueError): + return 0.0 + return min(1.0, max(0.0, f)) +``` + +In `parse_analysis`, after the existing `as_list` definitions, compute and add the fields: + +```python + rtype = str(data.get("recording_type", "")).strip().lower() + if rtype not in VALID_TYPES: + rtype = "record" + + return { + "summary": str(data.get("summary", "")).strip(), + "action_items": as_list("action_items"), + "topics": as_list("topics"), + "notable_details": as_list("notable_details"), + "recording_type": rtype, + "confidence": coerce_confidence(data.get("confidence")), + } +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_analyze -v` +Expected: PASS (existing tests + 9 new) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/analyze.py tests/test_analyze.py +git commit -m "feat(analyze): classify recording_type + confidence" +``` + +--- + +### Task 4: Deterministic path + note rendering (`notes.py`) + +Add a `source_id`-derived deterministic note path and render the §6 frontmatter (via `yamllite`) and body — with no dated checkboxes. Remove the daily-note block helpers. + +**Files:** +- Modify: `voice2note/notes.py` (add `source_hash`, `memo_note_relpath`, `render_memo_note`; remove `render_daily_block`, `ensure_daily_note`, `append_to_daily`, and the old `write_memo_note`/`render_memo_note` checkbox logic) +- Test: `tests/test_notes.py` (replace daily/checkbox tests) + +- [ ] **Step 1: Write the failing test** + +Replace the body of `tests/test_notes.py` with: + +```python +# tests/test_notes.py +import unittest +from datetime import datetime +from types import SimpleNamespace + +from voice2note.notes import source_hash, memo_note_relpath, render_memo_note + + +def memo(title="Lysis ideas", mid="ABCD-1234.m4a"): + return SimpleNamespace(id=mid, title=title, + recorded_at=datetime(2026, 6, 18, 14, 32)) + + +ANALYSIS = {"summary": "Did the lysis.", "action_items": ["order ECL"], + "topics": ["western-blot"], "notable_details": ["pH 7.4"], + "recording_type": "record", "confidence": 0.9} + + +class TestPath(unittest.TestCase): + def test_hash_is_16_hex_and_stable(self): + h = source_hash("ABCD-1234.m4a") + self.assertEqual(len(h), 16) + self.assertEqual(h, source_hash("ABCD-1234.m4a")) + + def test_relpath_shape(self): + p = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, + "Lysis ideas", "ABCD-1234.m4a") + self.assertTrue(p.startswith("Memory/voiceMemo/2026-06-18-1432-lysis-ideas-")) + self.assertTrue(p.endswith(".md")) + + def test_same_minute_title_different_id_no_collision(self): + a = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "X", "a.m4a") + b = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "X", "b.m4a") + self.assertNotEqual(a, b) + + +class TestRender(unittest.TestCase): + def test_record_has_frontmatter_and_no_checkboxes(self): + out = render_memo_note(memo(), ANALYSIS, "raw transcript", + recording_type="record", confidence=0.9, + status="draft", analysis_status="complete", + include_todos=True) + self.assertIn("recording_type: record", out) + self.assertIn("status: draft", out) + self.assertIn("analysis: complete", out) + self.assertIn('source_id: "ABCD-1234.m4a"', out) + self.assertNotIn("- [ ]", out) + self.assertIn("## Possible to-dos", out) + self.assertIn("- order ECL", out) + self.assertIn("raw transcript", out) + + def test_conversation_omits_todos(self): + out = render_memo_note(memo(), ANALYSIS, "t", + recording_type="conversation", confidence=0.9, + status="complete", analysis_status="complete", + include_todos=False) + self.assertNotIn("## Possible to-dos", out) + + def test_title_with_quote_is_escaped(self): + out = render_memo_note(memo(title='He said "go"'), ANALYSIS, "t", + recording_type="record", confidence=0.9, + status="draft", analysis_status="complete", + include_todos=True) + self.assertIn('title: "He said \\"go\\""', out) + + def test_transcript_only_pending(self): + empty = {"summary": "", "action_items": [], "topics": [], + "notable_details": [], "recording_type": "record", "confidence": 0.0} + out = render_memo_note(memo(), empty, "just the words", + recording_type="record", confidence=0.0, + status="draft", analysis_status="pending", + include_todos=True) + self.assertIn("analysis: pending", out) + self.assertIn("just the words", out) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_notes -v` +Expected: FAIL with `ImportError: cannot import name 'source_hash'` + +- [ ] **Step 3: Write minimal implementation** + +Rewrite `voice2note/notes.py` to: + +```python +"""Render Obsidian notes for voice memos. Writes go through the caller +(run.py → vault_writer or direct), not from here.""" +import hashlib +import re + +from voice2note.yamllite import dq, seq + + +def slugify(text, maxlen=50): + text = (text or "").lower() + text = re.sub(r"[^\w\s-]", "", text) + text = re.sub(r"[\s_-]+", "-", text).strip("-") + return text[:maxlen].strip("-") or "memo" + + +def source_hash(source_id, n=16): + return hashlib.sha256((source_id or "").encode("utf-8")).hexdigest()[:n] + + +def memo_note_relpath(reldir, recorded_at, title, source_id): + stamp = recorded_at.strftime("%Y-%m-%d-%H%M") + return f"{reldir.rstrip('/')}/{stamp}-{slugify(title)}-{source_hash(source_id)}.md" + + +def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, + status, analysis_status, include_todos): + date = memo.recorded_at.strftime("%Y-%m-%d") + topics = analysis.get("topics", []) + fm = [ + "---", + f"date: {date}", + "type: voice-memo", + f"source_id: {dq(memo.id)}", + f"recording_type: {recording_type}", + f"classify_confidence: {confidence}", + f"analysis: {analysis_status}", + f"status: {status}", + "disposition:", + "routed_to: []", + "source: voice-memo", + f"title: {dq(memo.title)}", + f"recorded: {memo.recorded_at.isoformat(timespec='minutes')}", + f"tags: {seq(['voice-memo'] + list(topics))}", + "---", + ] + body = [f"# {memo.title}", "", "## Summary", analysis.get("summary") or "—"] + if include_todos and analysis.get("action_items"): + body += ["", "## Possible to-dos"] + body += [f"- {item}" for item in analysis["action_items"]] + topic_line = " ".join("#" + slugify(t, 40) for t in topics) or "—" + body += ["", "## Topics", topic_line, "", "## Notable Details"] + details = analysis.get("notable_details") or [] + body += [f"- {d}" for d in details] if details else ["- (none)"] + body += ["", "## Transcript", "> [!quote]- Full transcript"] + body += [f"> {line}" for line in (transcript or "").splitlines() or [""]] + body.append("") + return "\n".join(fm + [""] + body) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_notes -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/notes.py tests/test_notes.py +git commit -m "feat(notes): deterministic path + frontmatter render, no checkboxes/daily block" +``` + +--- + +### Task 5: v2 state with atomic save (`state.py`) + +Replace the `processed_ids` set with a `source_id → record` map (migrating old files), add `is_seen`/`record`, and make `save` atomic via `os.replace`. + +**Files:** +- Modify: `voice2note/state.py` +- Test: `tests/test_state.py` (extend/replace) + +- [ ] **Step 1: Write the failing test** + +Replace `tests/test_state.py` with: + +```python +# tests/test_state.py +import json +import os +import tempfile +import unittest +from pathlib import Path + +from voice2note.state import State + + +class TestState(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = Path(self.dir) / "state.json" + + def test_record_and_is_seen(self): + s = State(self.path) + self.assertFalse(s.is_seen("a.m4a")) + s.record("a.m4a", "Memory/voiceMemo/x.md", "complete", "record") + self.assertTrue(s.is_seen("a.m4a")) + + def test_migrates_processed_ids(self): + self.path.write_text(json.dumps({"processed_ids": ["a.m4a", "b.m4a"]}), + encoding="utf-8") + s = State(self.path) + self.assertTrue(s.is_seen("a.m4a")) + self.assertEqual(s.memos["a.m4a"]["analysis"], "complete") + + def test_save_roundtrip(self): + s = State(self.path) + s.record("a.m4a", "Memory/voiceMemo/x.md", "pending", "record") + s.save(last_run="2026-06-18T14:00:00") + again = State(self.path) + self.assertTrue(again.is_seen("a.m4a")) + self.assertEqual(again.last_run, "2026-06-18T14:00:00") + + def test_save_is_atomic_on_failure(self): + self.path.write_text(json.dumps({"memos": {"old.m4a": {"analysis": "complete"}}}), + encoding="utf-8") + s = State(self.path) + s.record("new.m4a", "Memory/voiceMemo/n.md", "complete", "record") + real_replace = os.replace + + def boom(src, dst): + raise OSError("simulated crash during replace") + + os.replace = boom + try: + with self.assertRaises(OSError): + s.save() + finally: + os.replace = real_replace + # Original file must be intact (not truncated/corrupted). + data = json.loads(self.path.read_text(encoding="utf-8")) + self.assertIn("old.m4a", data["memos"]) + self.assertNotIn("new.m4a", data["memos"]) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_state -v` +Expected: FAIL (`AttributeError: 'State' object has no attribute 'is_seen'`) + +- [ ] **Step 3: Write minimal implementation** + +Rewrite `voice2note/state.py`: + +```python +"""Tracks which memos have been handled (write-once), keyed by source_id. +Saved atomically so a crash mid-write can't corrupt the file.""" +import json +import os +from datetime import datetime +from pathlib import Path + + +class State: + def __init__(self, path): + self.path = Path(path) + self.memos = {} + self.last_run = None + if self.path.exists(): + data = json.loads(self.path.read_text(encoding="utf-8")) + if isinstance(data.get("memos"), dict): + self.memos = data["memos"] + elif isinstance(data.get("processed_ids"), list): # migrate v1 + self.memos = {mid: {"analysis": "complete"} for mid in data["processed_ids"]} + self.last_run = data.get("last_run") + + def is_seen(self, source_id): + return source_id in self.memos + + def record(self, source_id, note_rel, analysis, recording_type): + self.memos[source_id] = { + "note_rel": note_rel, + "analysis": analysis, + "recording_type": recording_type, + "updated": datetime.now().isoformat(timespec="seconds"), + } + + def save(self, last_run=None): + if last_run is not None: + self.last_run = last_run + self.path.parent.mkdir(parents=True, exist_ok=True) + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text( + json.dumps({"memos": self.memos, "last_run": self.last_run}, indent=2), + encoding="utf-8", + ) + os.replace(tmp, self.path) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_state -v` +Expected: PASS (4 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/state.py tests/test_state.py +git commit -m "feat(state): v2 source_id map, migration, atomic save" +``` + +--- + +### Task 6: Single-instance lock (`lock.py`) + +A mandatory `fcntl.flock` lock so a manual run can't race a scheduled one. + +**Files:** +- Create: `voice2note/lock.py` +- Test: `tests/test_lock.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_lock.py +import tempfile +import unittest +from pathlib import Path + +from voice2note.lock import SingleInstanceLock, AlreadyRunning + + +class TestLock(unittest.TestCase): + def setUp(self): + self.path = Path(tempfile.mkdtemp()) / "v2n.lock" + + def test_second_acquire_raises(self): + first = SingleInstanceLock(self.path) + first.acquire() + try: + with self.assertRaises(AlreadyRunning): + SingleInstanceLock(self.path).acquire() + finally: + first.release() + + def test_release_allows_reacquire(self): + a = SingleInstanceLock(self.path) + a.acquire() + a.release() + b = SingleInstanceLock(self.path) + b.acquire() # should not raise + b.release() + + def test_context_manager(self): + with SingleInstanceLock(self.path): + with self.assertRaises(AlreadyRunning): + SingleInstanceLock(self.path).acquire() + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_lock -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'voice2note.lock'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# voice2note/lock.py +"""Mandatory single-instance lock (fcntl.flock). launchd only serializes its +own scheduled runs; a manual run could otherwise race it and double-write.""" +import fcntl +from pathlib import Path + + +class AlreadyRunning(Exception): + pass + + +class SingleInstanceLock: + def __init__(self, path): + self.path = Path(path) + self._fd = None + + def acquire(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + fd = open(self.path, "w") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + fd.close() + raise AlreadyRunning(f"another voice2note instance holds {self.path}") + self._fd = fd + return self + + def release(self): + if self._fd is not None: + fcntl.flock(self._fd, fcntl.LOCK_UN) + self._fd.close() + self._fd = None + + def __enter__(self): + return self.acquire() + + def __exit__(self, *exc): + self.release() +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_lock -v` +Expected: PASS (3 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/lock.py tests/test_lock.py +git commit -m "feat(lock): mandatory fcntl single-instance lock" +``` + +--- + +### Task 7: CLI write bridge (`vault_writer.py`) + +Build/run the `cricknote tool vault_write` command (raising on failure, never falling back), and read an existing note's frontmatter for the adopt check. + +**Files:** +- Create: `voice2note/vault_writer.py` +- Test: `tests/test_vault_writer.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_vault_writer.py +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from voice2note.vault_writer import (build_write_cmd, write_note, note_exists, + read_frontmatter) + + +class TestBuildCmd(unittest.TestCase): + def test_argv_and_json(self): + cmd = build_write_cmd("cricknote", "Memory/voiceMemo/x.md", "body\nlines") + self.assertEqual(cmd[:3], ["cricknote", "tool", "vault_write"]) + payload = json.loads(cmd[3]) + self.assertEqual(payload, {"path": "Memory/voiceMemo/x.md", "content": "body\nlines"}) + + +class TestWriteNote(unittest.TestCase): + def test_raises_on_nonzero(self): + def runner(cmd, **kw): + return SimpleNamespace(returncode=1, stdout="", stderr="boom") + with self.assertRaises(RuntimeError): + write_note("cricknote", "Memory/voiceMemo/x.md", "c", runner=runner) + + def test_returns_stdout_on_success(self): + def runner(cmd, **kw): + return SimpleNamespace(returncode=0, stdout="ok", stderr="") + self.assertEqual(write_note("cricknote", "p", "c", runner=runner), "ok") + + +class TestFsHelpers(unittest.TestCase): + def setUp(self): + self.vault = Path(tempfile.mkdtemp()) + + def test_note_exists(self): + rel = "Memory/voiceMemo/x.md" + self.assertFalse(note_exists(self.vault, rel)) + (self.vault / "Memory/voiceMemo").mkdir(parents=True) + (self.vault / rel).write_text("---\nsource_id: \"a.m4a\"\n---\n", encoding="utf-8") + self.assertTrue(note_exists(self.vault, rel)) + + def test_read_frontmatter_source_id(self): + rel = "Memory/voiceMemo/x.md" + (self.vault / "Memory/voiceMemo").mkdir(parents=True) + (self.vault / rel).write_text( + "---\nsource_id: \"a.m4a\"\nanalysis: pending\nrecording_type: record\n---\nbody", + encoding="utf-8") + fm = read_frontmatter(self.vault, rel) + self.assertEqual(fm["source_id"], "a.m4a") + self.assertEqual(fm["analysis"], "pending") + self.assertEqual(fm["recording_type"], "record") + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_vault_writer -v` +Expected: FAIL with `ModuleNotFoundError: No module named 'voice2note.vault_writer'` + +- [ ] **Step 3: Write minimal implementation** + +```python +# voice2note/vault_writer.py +"""Bridge to the CrickNote CLI for vault writes, plus direct-fs helpers for the +create-once adopt check. Never silently falls back to a direct write.""" +import json +import re +import subprocess +from pathlib import Path + + +def build_write_cmd(cricknote_bin, rel_path, content): + args = json.dumps({"path": rel_path, "content": content}) + return [cricknote_bin, "tool", "vault_write", args] + + +def write_note(cricknote_bin, rel_path, content, runner=subprocess.run): + cmd = build_write_cmd(cricknote_bin, rel_path, content) + proc = runner(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError( + f"cricknote vault_write failed (exit {proc.returncode}): {(proc.stderr or '')[:300]}") + return proc.stdout + + +def note_exists(vault, rel_path): + return (Path(vault) / rel_path).exists() + + +_FM_LINE = re.compile(r'^([a-zA-Z_]+):\s*(.*)$') + + +def read_frontmatter(vault, rel_path): + """Minimal frontmatter reader: returns scalar string fields from the leading + --- block. Enough for the adopt check (source_id, analysis, recording_type).""" + text = (Path(vault) / rel_path).read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + out = {} + for line in lines[1:]: + if line.strip() == "---": + break + m = _FM_LINE.match(line) + if m: + out[m.group(1)] = m.group(2).strip().strip('"') + return out +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_vault_writer -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/vault_writer.py tests/test_vault_writer.py +git commit -m "feat(vault_writer): cricknote CLI write bridge + adopt-check fs helpers" +``` + +--- + +### Task 8: Config — env parsing, validation, vault equality (`config.py`) + +Add testable helpers for the new env vars, `MEMO_RELDIR` validation, and the (gated) vault-equality check. + +**Files:** +- Modify: `voice2note/config.py` (add helpers; wire module constants) +- Test: `tests/test_config.py` (new) + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_config.py +import os +import tempfile +import unittest +from pathlib import Path + +from voice2note.config import env_bool, validate_memo_reldir, assert_vault_match + + +class TestEnvBool(unittest.TestCase): + def test_true_values(self): + for v in ["1", "true", "TRUE", "yes", "on"]: + os.environ["V2N_T"] = v + self.assertTrue(env_bool("V2N_T", False)) + + def test_false_default_when_unset(self): + os.environ.pop("V2N_MISSING", None) + self.assertFalse(env_bool("V2N_MISSING", False)) + + +class TestRelDir(unittest.TestCase): + def test_ok(self): + self.assertEqual(validate_memo_reldir("Memory/voiceMemo"), "Memory/voiceMemo") + + def test_rejects_absolute(self): + with self.assertRaises(ValueError): + validate_memo_reldir("/etc/passwd") + + def test_rejects_traversal(self): + with self.assertRaises(ValueError): + validate_memo_reldir("Memory/../../etc") + + +class TestVaultMatch(unittest.TestCase): + def test_match_ok(self): + d = tempfile.mkdtemp() + assert_vault_match(d, d) # no raise + + def test_mismatch_raises(self): + a, b = tempfile.mkdtemp(), tempfile.mkdtemp() + with self.assertRaises(SystemExit): + assert_vault_match(a, b) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_config -v` +Expected: FAIL with `ImportError: cannot import name 'env_bool'` + +- [ ] **Step 3: Write minimal implementation** + +Add to `voice2note/config.py` (near the top, after imports; add `import json` and `from pathlib import PurePosixPath` if missing): + +```python +def env_bool(name, default=False): + v = os.environ.get(name) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + +def validate_memo_reldir(reldir): + p = PurePosixPath(reldir) + if p.is_absolute() or ".." in p.parts: + raise ValueError(f"VOICE2NOTE_MEMO_RELDIR must be relative without '..': {reldir!r}") + return str(p) + + +def cricknote_vault_path(): + """Read vaultPath from ~/.cricknote/config.json (raises if absent/malformed).""" + cfg = Path.home() / ".cricknote" / "config.json" + data = json.loads(cfg.read_text(encoding="utf-8")) + return data["vaultPath"] + + +def assert_vault_match(v2n_vault, cricknote_vault): + if os.path.realpath(v2n_vault) != os.path.realpath(cricknote_vault): + raise SystemExit( + "VOICE2NOTE_VAULT does not match ~/.cricknote/config.json vaultPath:\n" + f" voice2note: {v2n_vault}\n cricknote: {cricknote_vault}\n" + "Point both at the same vault, or set VOICE2NOTE_USE_CRICKNOTE=false.") +``` + +Then add the module-level constants (after the existing settings): + +```python +USE_CRICKNOTE = env_bool("VOICE2NOTE_USE_CRICKNOTE", False) +CRICKNOTE_BIN = os.environ.get("VOICE2NOTE_CRICKNOTE_BIN") or shutil.which("cricknote") or "cricknote" +MEMO_RELDIR = validate_memo_reldir(os.environ.get("VOICE2NOTE_MEMO_RELDIR", "Memory/voiceMemo")) +CONFIDENCE_MIN = float(os.environ.get("VOICE2NOTE_CONFIDENCE_MIN", "0.5")) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_config -v` +Expected: PASS (7 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/config.py tests/test_config.py +git commit -m "feat(config): explicit USE_CRICKNOTE, reldir validation, gated vault-equality check" +``` + +--- + +### Task 9: Orchestrate the create-once flow (`run.py`) + +Wire it together: acquire the lock, run the create-once flow per memo (skip seen, adopt-with-verify, else transcribe→analyze→write), atomic save per memo, gated vault check, drop the daily block. + +**Files:** +- Modify: `voice2note/run.py`, `voice2note/memos.py`, `voice2note/config.py` +- Test: `tests/test_run.py` (replace with mock-driven flow tests), `tests/test_memos.py` (update to v2 state API) + +- [ ] **Step 1: Write the failing test** + +Replace `tests/test_run.py` with: + +```python +# tests/test_run.py +import tempfile +import unittest +from datetime import datetime +from pathlib import Path +from types import SimpleNamespace + +from voice2note import run as runmod +from voice2note.state import State +from voice2note.notes import memo_note_relpath + + +def make_cfg(vault, use_cricknote=False): + return SimpleNamespace( + VAULT=Path(vault), MEMO_RELDIR="Memory/voiceMemo", + USE_CRICKNOTE=use_cricknote, CRICKNOTE_BIN="cricknote", + CONFIDENCE_MIN=0.5) + + +def memo(mid="a.m4a", title="Idea"): + return SimpleNamespace(id=mid, path=Path("/tmp/a.m4a"), title=title, + recorded_at=datetime(2026, 6, 18, 14, 32)) + + +class TestProcessMemo(unittest.TestCase): + def setUp(self): + self.vault = tempfile.mkdtemp() + self.state = State(Path(tempfile.mkdtemp()) / "state.json") + self.cfg = make_cfg(self.vault) + # Stub the heavy steps. + runmod.materialize = lambda *a, **k: True + runmod.transcribe = lambda *a, **k: "the transcript" + runmod.analyze = lambda *a, **k: { + "summary": "s", "action_items": [], "topics": ["t"], + "notable_details": [], "recording_type": "record", "confidence": 0.9} + + def test_creates_note_and_records_state(self): + runmod.process_memo(memo(), self.state, self.cfg) + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + self.assertTrue((Path(self.vault) / rel).exists()) + self.assertTrue(self.state.is_seen("a.m4a")) + + def test_skips_when_seen(self): + self.state.record("a.m4a", "x", "complete", "record") + called = {"n": 0} + runmod.transcribe = lambda *a, **k: called.__setitem__("n", called["n"] + 1) or "t" + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(called["n"], 0) + + def test_adopts_existing_matching_note_without_rewrite(self): + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + p = Path(self.vault) / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('---\nsource_id: "a.m4a"\nanalysis: complete\n' + 'recording_type: record\n---\nORIGINAL', encoding="utf-8") + runmod.transcribe = lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not run")) + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(p.read_text(encoding="utf-8").split("---")[-1].strip(), "ORIGINAL") + self.assertTrue(self.state.is_seen("a.m4a")) + + def test_adopt_mismatch_raises(self): + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + p = Path(self.vault) / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('---\nsource_id: "OTHER.m4a"\n---\nx', encoding="utf-8") + with self.assertRaises(RuntimeError): + runmod.process_memo(memo(), self.state, self.cfg) + + def test_analysis_failure_writes_transcript_only_draft(self): + runmod.analyze = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("codex down")) + runmod.process_memo(memo(), self.state, self.cfg) + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + out = (Path(self.vault) / rel).read_text(encoding="utf-8") + self.assertIn("analysis: pending", out) + self.assertIn("status: draft", out) + self.assertIn("the transcript", out) + + +if __name__ == "__main__": + unittest.main() +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_run -v` +Expected: FAIL (`process_memo` signature/behavior mismatch) + +- [ ] **Step 3: Write minimal implementation** + +In `voice2note/run.py`, replace the imports and `process_memo`, and write the note via a helper. Add imports: + +```python +from voice2note.notes import memo_note_relpath, render_memo_note +from voice2note.routing import effective_type, route_for +from voice2note.vault_writer import write_note, note_exists, read_frontmatter +``` + +Add a write helper and rewrite `process_memo`: + +```python +def write_memo(cfg, rel, content): + if cfg.USE_CRICKNOTE: + write_note(cfg.CRICKNOTE_BIN, rel, content) # raises → retried next run + else: + path = cfg.VAULT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def process_memo(memo, state, cfg): + if state.is_seen(memo.id): + return + rel = memo_note_relpath(cfg.MEMO_RELDIR, memo.recorded_at, memo.title, memo.id) + + # Create-once: adopt an existing note (after verifying source_id), never overwrite. + if note_exists(cfg.VAULT, rel): + fm = read_frontmatter(cfg.VAULT, rel) + if fm.get("source_id") != memo.id: + raise RuntimeError( + f"{rel} exists with source_id {fm.get('source_id')!r}, expected {memo.id!r}") + state.record(memo.id, rel, fm.get("analysis", "complete"), + fm.get("recording_type", "record")) + state.save() + return + + if not materialize(memo.path, brctl_bin=config.BRCTL_BIN): + log.warning("Could not download %s; will retry next run.", memo.id) + return + transcript = transcribe(memo.path, config.WHISPER_MODEL_PATH, + whisper_bin=config.WHISPER_CLI_BIN, + ffmpeg_bin=config.FFMPEG_BIN, + language=config.WHISPER_LANGUAGE) + try: + analysis = analyze(transcript, codex_bin=config.CODEX_BIN, model=config.CODEX_MODEL) + rtype = effective_type(analysis["recording_type"], analysis["confidence"], cfg.CONFIDENCE_MIN) + status, include_todos = route_for(rtype) + content = render_memo_note(memo, analysis, transcript, recording_type=rtype, + confidence=analysis["confidence"], status=status, + analysis_status="complete", include_todos=include_todos) + analysis_status = "complete" + except Exception as exc: + log.warning("Analysis failed for %s (%s); writing transcript-only draft.", memo.id, exc) + empty = {"summary": "", "action_items": [], "topics": [], "notable_details": []} + content = render_memo_note(memo, empty, transcript, recording_type="record", + confidence=0.0, status="draft", + analysis_status="pending", include_todos=False) + rtype, analysis_status = "record", "pending" + + write_memo(cfg, rel, content) + state.record(memo.id, rel, analysis_status, rtype) + state.save(last_run=datetime.now().isoformat(timespec="seconds")) +``` + +Update `run(once=False)` to: acquire the lock, do the gated vault check, and pass `config` as `cfg`. Replace the body of `run` with: + +```python +def run(once=False): + cfg = config + if str(cfg.VAULT) in ("", ".", "/"): + log.error("VOICE2NOTE_VAULT is not set. See README.") + return + if cfg.USE_CRICKNOTE: + config.assert_vault_match(str(cfg.VAULT), config.cricknote_vault_path()) + try: + with SingleInstanceLock(config.LOCK_PATH): + _run_locked(once) + except AlreadyRunning: + log.info("Another voice2note run is active; exiting.") + + +def _run_locked(once): + state = State(config.STATE_PATH) + try: + memos = find_new_memos(config.RECORDINGS_DIR, config.DB_PATH, state) + except PermissionError: + log.error("Full Disk Access not granted. See README step 1.") + return + except FileNotFoundError: + log.error("Recordings folder not found: %s", config.RECORDINGS_DIR) + return + if once: + memos = memos[-1:] + if not memos: + log.info("No new memos.") + return + log.info("%d new memo(s) to process.", len(memos)) + for memo in memos: + try: + process_memo(memo, state, config) + except Exception: + log.exception("Failed on %s; leaving for retry.", memo.id) + state.save(last_run=datetime.now().isoformat(timespec="seconds")) +``` + +Add imports at the top of `run.py`: `from voice2note.lock import SingleInstanceLock, AlreadyRunning`. Add `LOCK_PATH` to `config.py`: `LOCK_PATH = _path("VOICE2NOTE_LOCK", str(PROJECT_DIR / "voice2note.lock"))`. Note: `find_new_memos` uses `state.is_processed`; update `memos.py` to call `state.is_seen` — change `if state.is_processed(path.name):` to `if state.is_seen(path.name):`. Also **remove the now-invalid notes import** in `run.py` (`from voice2note.notes import write_memo_note, append_to_daily, memo_filename`) — those functions were deleted in Task 4. Finally, update `tests/test_memos.py:44`: the v2 `State` has no `mark_processed`; change `state.mark_processed("rec1.m4a")` to `state.record("rec1.m4a", "x", "complete", "record")` (the test still asserts only `rec2.m4a` is found). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_run -v` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/run.py voice2note/config.py voice2note/memos.py tests/test_run.py tests/test_memos.py +git commit -m "feat(run): create-once flow under lock; adopt-with-verify; transcript-only fallback" +``` + +--- + +### Task 10: Full suite + `--check` vault gating + +Confirm the whole suite is green and that `--check` validates the vault only when `USE_CRICKNOTE=true`. + +**Files:** +- Modify: `voice2note/run.py` (`check_access` — add gated vault check) +- Test: `tests/test_run.py` (add one test) + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_run.py`: + +```python +class TestCheckGating(unittest.TestCase): + def test_standalone_check_does_not_require_cricknote_config(self): + # USE_CRICKNOTE False → check_access must not raise even if no cricknote config. + import voice2note.config as config + orig = config.USE_CRICKNOTE + config.USE_CRICKNOTE = False + try: + self.assertIn(runmod.vault_check_ok(config), (True, False)) + finally: + config.USE_CRICKNOTE = orig +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_run.TestCheckGating -v` +Expected: FAIL (`AttributeError: module 'voice2note.run' has no attribute 'vault_check_ok'`) + +- [ ] **Step 3: Write minimal implementation** + +Add to `voice2note/run.py`: + +```python +def vault_check_ok(cfg): + """Return True if vault config is consistent. Only validates the cricknote + vault match when USE_CRICKNOTE is true; standalone needs no cricknote config.""" + if not cfg.USE_CRICKNOTE: + return True + cfg.assert_vault_match(str(cfg.VAULT), cfg.cricknote_vault_path()) + return True +``` + +Call it from `check_access` after the existing access print (before returning True): + +```python + if not vault_check_ok(config): + return False +``` + +- [ ] **Step 4: Run the full suite** + +Run: `PYTHONPATH=. /usr/bin/python3 -m unittest discover -s tests -t . -v` +Expected: PASS (all modules green) + +- [ ] **Step 5: Commit** + +```bash +git add voice2note/run.py tests/test_run.py +git commit -m "feat(run): gate vault-equality check behind USE_CRICKNOTE in --check" +``` + +--- + +## Self-review notes (author) + +- **Spec coverage:** classification + ambiguity (Tasks 1, 3); YAML escaping (Task 2); deterministic path/no-checkboxes/no-daily-block (Task 4); v2 state + atomic save (Task 5); mandatory lock (Task 6); CLI write bridge + no-fallback + adopt fs helpers (Task 7); explicit USE_CRICKNOTE + reldir + gated vault equality (Tasks 8, 10); create-once flow with adopt-verify + transcript-only fallback (Task 9). Phases 2 (skill) and 3 (ops/docs) are out of scope by design. +- **Manual step (not unit-tested):** end-to-end against the real vault with `VOICE2NOTE_USE_CRICKNOTE=true` is an acceptance check, run after Task 9 (`launchctl start com.voice2note.agent` then inspect `Memory/voiceMemo/`). +- **Not in this plan:** the per-task routing marker `[v2n::]` lives in the Phase 2 skill, not voice2note. From 7ba6e679cd98df047e1cb8713923b7270db80f74 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:24:00 +0100 Subject: [PATCH 06/27] feat(routing): recording-type disposition + ambiguity rule --- tests/test_routing.py | 35 +++++++++++++++++++++++++++++++++++ voice2note/routing.py | 23 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/test_routing.py create mode 100644 voice2note/routing.py diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..afda4bf --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,35 @@ +# tests/test_routing.py +import unittest +from voice2note.routing import route_for, effective_type, VALID_TYPES + + +class TestRouteFor(unittest.TestCase): + def test_record(self): + self.assertEqual(route_for("record"), ("draft", True)) + + def test_conversation(self): + self.assertEqual(route_for("conversation"), ("complete", False)) + + def test_talk(self): + self.assertEqual(route_for("talk"), ("complete", False)) + + def test_unknown_defaults_to_record_disposition(self): + self.assertEqual(route_for("weird"), ("draft", True)) + + +class TestEffectiveType(unittest.TestCase): + def test_keeps_confident_type(self): + self.assertEqual(effective_type("talk", 0.9, 0.5), "talk") + + def test_low_confidence_forces_record(self): + self.assertEqual(effective_type("talk", 0.4, 0.5), "record") + + def test_invalid_type_forces_record(self): + self.assertEqual(effective_type("podcast", 0.99, 0.5), "record") + + def test_valid_types(self): + self.assertEqual(VALID_TYPES, ("conversation", "talk", "record")) + + +if __name__ == "__main__": + unittest.main() diff --git a/voice2note/routing.py b/voice2note/routing.py new file mode 100644 index 0000000..fe3c1aa --- /dev/null +++ b/voice2note/routing.py @@ -0,0 +1,23 @@ +# voice2note/routing.py +"""Pure routing policy: recording_type → note disposition, plus the +bias-to-record ambiguity rule. No I/O, no dependencies.""" + +VALID_TYPES = ("conversation", "talk", "record") + + +def effective_type(recording_type, confidence, min_confidence): + """Apply the ambiguity rule: an invalid type or sub-threshold confidence + becomes 'record' (so it is queued for triage, never silently shelved).""" + if recording_type not in VALID_TYPES: + return "record" + if confidence < min_confidence: + return "record" + return recording_type + + +def route_for(recording_type): + """Return (status, include_todos) for a recording_type. + conversation/talk → finalized, no tasks; everything else → draft + todos.""" + if recording_type in ("conversation", "talk"): + return ("complete", False) + return ("draft", True) From bde39081134cc480d1ff080f42b565a10dcaa7ba Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:24:28 +0100 Subject: [PATCH 07/27] feat(yamllite): stdlib YAML double-quoted scalar escaper --- tests/test_yamllite.py | 44 ++++++++++++++++++++++++++++++++++++++++++ voice2note/yamllite.py | 25 ++++++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 tests/test_yamllite.py create mode 100644 voice2note/yamllite.py diff --git a/tests/test_yamllite.py b/tests/test_yamllite.py new file mode 100644 index 0000000..3e66401 --- /dev/null +++ b/tests/test_yamllite.py @@ -0,0 +1,44 @@ +# tests/test_yamllite.py +import unittest +from voice2note.yamllite import dq, seq + + +class TestDq(unittest.TestCase): + def test_plain(self): + self.assertEqual(dq("hello"), '"hello"') + + def test_colon_is_safe_when_quoted(self): + self.assertEqual(dq("a: b"), '"a: b"') + + def test_escapes_double_quote(self): + self.assertEqual(dq('say "hi"'), '"say \\"hi\\""') + + def test_escapes_backslash(self): + self.assertEqual(dq("a\\b"), '"a\\\\b"') + + def test_collapses_newline(self): + self.assertEqual(dq("line1\nline2"), '"line1\\nline2"') + + def test_control_char_hex(self): + self.assertEqual(dq("a\x01b"), '"a\\x01b"') + + def test_emoji_preserved(self): + self.assertEqual(dq("🎙 memo"), '"🎙 memo"') + + def test_none(self): + self.assertEqual(dq(None), '""') + + +class TestSeq(unittest.TestCase): + def test_flow_sequence(self): + self.assertEqual(seq(["voice-memo", "qpcr"]), '["voice-memo", "qpcr"]') + + def test_empty(self): + self.assertEqual(seq([]), "[]") + + def test_quotes_each_item(self): + self.assertEqual(seq(['a:b', 'c']), '["a:b", "c"]') + + +if __name__ == "__main__": + unittest.main() diff --git a/voice2note/yamllite.py b/voice2note/yamllite.py new file mode 100644 index 0000000..b75efe2 --- /dev/null +++ b/voice2note/yamllite.py @@ -0,0 +1,25 @@ +# voice2note/yamllite.py +"""Minimal YAML emitter (stdlib only): double-quoted scalars and flow +sequences, enough to serialize frontmatter values safely. We never parse +YAML here — CrickNote's gray-matter does — we only need valid output.""" + +_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\t": "\\t", "\r": "\\r"} + + +def dq(value): + """Return value as a YAML double-quoted scalar, fully escaped.""" + s = "" if value is None else str(value) + out = [] + for ch in s: + if ch in _ESCAPES: + out.append(_ESCAPES[ch]) + elif ord(ch) < 0x20: + out.append("\\x%02x" % ord(ch)) + else: + out.append(ch) + return '"' + "".join(out) + '"' + + +def seq(values): + """Return a YAML flow sequence of double-quoted scalars.""" + return "[" + ", ".join(dq(v) for v in values) + "]" From 11af119840808ea880425fc34da25632d3ecdfcc Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 09:24:53 +0100 Subject: [PATCH 08/27] feat(lock): mandatory fcntl single-instance lock --- tests/test_lock.py | 37 +++++++++++++++++++++++++++++++++++++ voice2note/lock.py | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 tests/test_lock.py create mode 100644 voice2note/lock.py diff --git a/tests/test_lock.py b/tests/test_lock.py new file mode 100644 index 0000000..6efb9a9 --- /dev/null +++ b/tests/test_lock.py @@ -0,0 +1,37 @@ +# tests/test_lock.py +import tempfile +import unittest +from pathlib import Path + +from voice2note.lock import SingleInstanceLock, AlreadyRunning + + +class TestLock(unittest.TestCase): + def setUp(self): + self.path = Path(tempfile.mkdtemp()) / "v2n.lock" + + def test_second_acquire_raises(self): + first = SingleInstanceLock(self.path) + first.acquire() + try: + with self.assertRaises(AlreadyRunning): + SingleInstanceLock(self.path).acquire() + finally: + first.release() + + def test_release_allows_reacquire(self): + a = SingleInstanceLock(self.path) + a.acquire() + a.release() + b = SingleInstanceLock(self.path) + b.acquire() # should not raise + b.release() + + def test_context_manager(self): + with SingleInstanceLock(self.path): + with self.assertRaises(AlreadyRunning): + SingleInstanceLock(self.path).acquire() + + +if __name__ == "__main__": + unittest.main() diff --git a/voice2note/lock.py b/voice2note/lock.py new file mode 100644 index 0000000..fcc7e16 --- /dev/null +++ b/voice2note/lock.py @@ -0,0 +1,38 @@ +# voice2note/lock.py +"""Mandatory single-instance lock (fcntl.flock). launchd only serializes its +own scheduled runs; a manual run could otherwise race it and double-write.""" +import fcntl +from pathlib import Path + + +class AlreadyRunning(Exception): + pass + + +class SingleInstanceLock: + def __init__(self, path): + self.path = Path(path) + self._fd = None + + def acquire(self): + self.path.parent.mkdir(parents=True, exist_ok=True) + fd = open(self.path, "w") + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError: + fd.close() + raise AlreadyRunning(f"another voice2note instance holds {self.path}") + self._fd = fd + return self + + def release(self): + if self._fd is not None: + fcntl.flock(self._fd, fcntl.LOCK_UN) + self._fd.close() + self._fd = None + + def __enter__(self): + return self.acquire() + + def __exit__(self, *exc): + self.release() From 86d054938841c0d92142b0f00467a15c74f856e1 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:47:33 +0100 Subject: [PATCH 09/27] feat(analyze): classify recording_type + confidence --- tests/test_analyze.py | 40 ++++++++++++++++++++++++++++++++++++++++ voice2note/analyze.py | 18 ++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 90adc56..a8885c3 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -1,6 +1,7 @@ import unittest from types import SimpleNamespace from voice2note import analyze +from voice2note.analyze import coerce_confidence class TestAnalyze(unittest.TestCase): @@ -53,5 +54,44 @@ def fake_runner(cmd, capture_output=True, text=True, timeout=None, **kwargs): analyze.analyze("hi", runner=fake_runner) +class TestRecordingType(unittest.TestCase): + def _parse(self, **extra): + import json + from voice2note.analyze import parse_analysis + payload = {"summary": "s", "action_items": [], "topics": [], + "notable_details": []} + payload.update(extra) + return parse_analysis(json.dumps(payload)) + + def test_extracts_recording_type(self): + self.assertEqual(self._parse(recording_type="talk")["recording_type"], "talk") + + def test_missing_type_defaults_record(self): + self.assertEqual(self._parse()["recording_type"], "record") + + def test_invalid_type_defaults_record(self): + self.assertEqual(self._parse(recording_type="podcast")["recording_type"], "record") + + def test_confidence_extracted(self): + self.assertEqual(self._parse(confidence=0.8)["confidence"], 0.8) + + +class TestCoerceConfidence(unittest.TestCase): + def test_numeric_string(self): + self.assertEqual(coerce_confidence("0.8"), 0.8) + + def test_garbage_defaults_zero(self): + self.assertEqual(coerce_confidence("high"), 0.0) + + def test_missing_defaults_zero(self): + self.assertEqual(coerce_confidence(None), 0.0) + + def test_clamps_high(self): + self.assertEqual(coerce_confidence(1.5), 1.0) + + def test_clamps_low(self): + self.assertEqual(coerce_confidence(-2), 0.0) + + if __name__ == "__main__": unittest.main() diff --git a/voice2note/analyze.py b/voice2note/analyze.py index a66c956..113a887 100644 --- a/voice2note/analyze.py +++ b/voice2note/analyze.py @@ -10,6 +10,8 @@ import subprocess import tempfile +from voice2note.routing import VALID_TYPES + DEFAULT_MODEL = "gpt-5.5" DEFAULT_REASONING = "low" @@ -19,6 +21,8 @@ - "action_items": array of concrete to-dos or follow-ups mentioned (empty if none). - "topics": array of 2-6 short topic labels (single words or short phrases). - "notable_details": array of specific facts, names, numbers, or decisions worth keeping (empty if none). +- "recording_type": one of "conversation" (a dialogue with other people), "talk" (a presentation you attended), or "record" (you dictating experiment notes, thoughts, or a new idea). +- "confidence": a number from 0 to 1 for how sure you are of "recording_type". Transcript: \"\"\" @@ -27,6 +31,14 @@ """ +def coerce_confidence(value): + try: + f = float(value) + except (TypeError, ValueError): + return 0.0 + return min(1.0, max(0.0, f)) + + def build_prompt(transcript): return PROMPT.replace("{transcript}", transcript or "") @@ -40,11 +52,17 @@ def parse_analysis(text): def as_list(key): return [str(x).strip() for x in data.get(key, []) if str(x).strip()] + rtype = str(data.get("recording_type", "")).strip().lower() + if rtype not in VALID_TYPES: + rtype = "record" + return { "summary": str(data.get("summary", "")).strip(), "action_items": as_list("action_items"), "topics": as_list("topics"), "notable_details": as_list("notable_details"), + "recording_type": rtype, + "confidence": coerce_confidence(data.get("confidence")), } From d0ee7780af6d7a90d164dc7f6047e1b2b470e559 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:24:09 +0100 Subject: [PATCH 10/27] feat(notes): deterministic path + frontmatter render, no checkboxes/daily block --- tests/test_notes.py | 134 +++++++++++++++++++++++--------------------- voice2note/notes.py | 118 ++++++++++++++------------------------ 2 files changed, 113 insertions(+), 139 deletions(-) diff --git a/tests/test_notes.py b/tests/test_notes.py index f9f510c..fec8c49 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -1,69 +1,77 @@ +# tests/test_notes.py import unittest from datetime import datetime from types import SimpleNamespace -from voice2note import notes - -MEMO = SimpleNamespace( - id="rec1.m4a", - title="Project ideas: Q3", - recorded_at=datetime(2026, 6, 15, 14, 32), -) -ANALYSIS = { - "summary": "Notes about Q3 planning.", - "action_items": ["Email Sam about budget"], - "topics": ["planning", "budget"], - "notable_details": ["Deadline is July 1"], -} - - -class TestNotes(unittest.TestCase): - def test_slugify(self): - self.assertEqual(notes.slugify("Project ideas: Q3"), "project-ideas-q3") - self.assertEqual(notes.slugify(""), "memo") - - def test_memo_filename(self): - self.assertEqual( - notes.memo_filename(MEMO.recorded_at, MEMO.title), - "2026-06-15-1432-project-ideas-q3.md", - ) - - def test_render_memo_note_has_all_sections(self): - md = notes.render_memo_note(MEMO, ANALYSIS, "hello world") - self.assertIn("## Summary", md) - self.assertIn("- [ ] Email Sam about budget 📅 2026-06-15", md) - self.assertIn("#planning", md) - self.assertIn("- Deadline is July 1", md) - self.assertIn("> hello world", md) - self.assertIn("source: voice-memo", md) - - def test_render_daily_block(self): - block = notes.render_daily_block(MEMO, ANALYSIS, "2026-06-15-1432-project-ideas-q3") - self.assertIn("**14:32 — Project ideas: Q3**", block) - self.assertIn("[[2026-06-15-1432-project-ideas-q3|full note]]", block) - self.assertIn(" - [ ] Email Sam about budget 📅 2026-06-15", block) - - -import tempfile -from pathlib import Path - - -class TestNoteWriting(unittest.TestCase): - def test_write_memo_note_creates_file(self): - with tempfile.TemporaryDirectory() as tmp: - path = notes.write_memo_note(MEMO, ANALYSIS, "hello", Path(tmp)) - self.assertTrue(path.exists()) - self.assertEqual(path.name, "2026-06-15-1432-project-ideas-q3.md") - self.assertIn("## Summary", path.read_text(encoding="utf-8")) - - def test_append_to_daily_adds_header_once(self): - with tempfile.TemporaryDirectory() as tmp: - daily = Path(tmp) - notes.append_to_daily(MEMO, ANALYSIS, "note-basename", daily) - notes.append_to_daily(MEMO, ANALYSIS, "note-basename", daily) - text = (daily / "2026-06-15.md").read_text(encoding="utf-8") - self.assertEqual(text.count("## Voice Memos"), 1) - self.assertEqual(text.count("**14:32 — Project ideas: Q3**"), 2) - self.assertIn("date: 2026-06-15", text) # frontmatter created + +from voice2note.notes import source_hash, memo_note_relpath, render_memo_note + + +def memo(title="Lysis ideas", mid="ABCD-1234.m4a"): + return SimpleNamespace(id=mid, title=title, + recorded_at=datetime(2026, 6, 18, 14, 32)) + + +ANALYSIS = {"summary": "Did the lysis.", "action_items": ["order ECL"], + "topics": ["western-blot"], "notable_details": ["pH 7.4"], + "recording_type": "record", "confidence": 0.9} + + +class TestPath(unittest.TestCase): + def test_hash_is_16_hex_and_stable(self): + h = source_hash("ABCD-1234.m4a") + self.assertEqual(len(h), 16) + self.assertEqual(h, source_hash("ABCD-1234.m4a")) + + def test_relpath_shape(self): + p = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, + "Lysis ideas", "ABCD-1234.m4a") + self.assertTrue(p.startswith("Memory/voiceMemo/2026-06-18-1432-lysis-ideas-")) + self.assertTrue(p.endswith(".md")) + + def test_same_minute_title_different_id_no_collision(self): + a = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "X", "a.m4a") + b = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "X", "b.m4a") + self.assertNotEqual(a, b) + + +class TestRender(unittest.TestCase): + def test_record_has_frontmatter_and_no_checkboxes(self): + out = render_memo_note(memo(), ANALYSIS, "raw transcript", + recording_type="record", confidence=0.9, + status="draft", analysis_status="complete", + include_todos=True) + self.assertIn("recording_type: record", out) + self.assertIn("status: draft", out) + self.assertIn("analysis: complete", out) + self.assertIn('source_id: "ABCD-1234.m4a"', out) + self.assertNotIn("- [ ]", out) + self.assertIn("## Possible to-dos", out) + self.assertIn("- order ECL", out) + self.assertIn("raw transcript", out) + + def test_conversation_omits_todos(self): + out = render_memo_note(memo(), ANALYSIS, "t", + recording_type="conversation", confidence=0.9, + status="complete", analysis_status="complete", + include_todos=False) + self.assertNotIn("## Possible to-dos", out) + + def test_title_with_quote_is_escaped(self): + out = render_memo_note(memo(title='He said "go"'), ANALYSIS, "t", + recording_type="record", confidence=0.9, + status="draft", analysis_status="complete", + include_todos=True) + self.assertIn('title: "He said \\"go\\""', out) + + def test_transcript_only_pending(self): + empty = {"summary": "", "action_items": [], "topics": [], + "notable_details": [], "recording_type": "record", "confidence": 0.0} + out = render_memo_note(memo(), empty, "just the words", + recording_type="record", confidence=0.0, + status="draft", analysis_status="pending", + include_todos=True) + self.assertIn("analysis: pending", out) + self.assertIn("just the words", out) if __name__ == "__main__": diff --git a/voice2note/notes.py b/voice2note/notes.py index 04873fe..0a077cc 100644 --- a/voice2note/notes.py +++ b/voice2note/notes.py @@ -1,7 +1,9 @@ -"""Render and write Obsidian notes from a memo + its analysis.""" +"""Render Obsidian notes for voice memos. Writes go through the caller +(run.py → vault_writer or direct), not from here.""" +import hashlib import re -from datetime import datetime -from pathlib import Path + +from voice2note.yamllite import dq, seq def slugify(text, maxlen=50): @@ -11,81 +13,45 @@ def slugify(text, maxlen=50): return text[:maxlen].strip("-") or "memo" -def tagify(topic): - return "#" + slugify(topic, maxlen=40) - - -def memo_filename(recorded_at, title): - return f"{recorded_at.strftime('%Y-%m-%d-%H%M')}-{slugify(title)}.md" +def source_hash(source_id, n=16): + return hashlib.sha256((source_id or "").encode("utf-8")).hexdigest()[:n] -def render_memo_note(memo, analysis, transcript): - date = memo.recorded_at.strftime("%Y-%m-%d") - time = memo.recorded_at.strftime("%H:%M") - safe_title = (memo.title or "Voice Memo").replace('"', "'") - fm_tags = ["voice-memo"] + [slugify(t, 40) for t in analysis["topics"]] - topic_line = " ".join(tagify(t) for t in analysis["topics"]) or "—" - - out = ["---", f"date: {date}", f'time: "{time}"', "source: voice-memo", - f'title: "{safe_title}"', "tags:"] - out += [f" - {t}" for t in fm_tags] - out += ["---", f"# {memo.title}", "", "## Summary", analysis["summary"] or "—", - "", "## Action Items"] - if analysis["action_items"]: - out += [f"- [ ] {item} 📅 {date}" for item in analysis["action_items"]] - else: - out.append("- (none)") - out += ["", "## Topics", topic_line, "", "## Notable Details"] - if analysis["notable_details"]: - out += [f"- {d}" for d in analysis["notable_details"]] - else: - out.append("- (none)") - out += ["", "## Transcript", "> [!quote]- Full transcript"] - out += [f"> {line}" for line in (transcript or "").splitlines() or [""]] - out.append("") - return "\n".join(out) +def memo_note_relpath(reldir, recorded_at, title, source_id): + stamp = recorded_at.strftime("%Y-%m-%d-%H%M") + return f"{reldir.rstrip('/')}/{stamp}-{slugify(title)}-{source_hash(source_id)}.md" -def render_daily_block(memo, analysis, note_basename): +def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, + status, analysis_status, include_todos): date = memo.recorded_at.strftime("%Y-%m-%d") - time = memo.recorded_at.strftime("%H:%M") - summary = analysis["summary"] or "voice memo" - lines = [f"- **{time} — {memo.title}** ([[{note_basename}|full note]]) — {summary}"] - lines += [f" - [ ] {item} 📅 {date}" for item in analysis["action_items"]] - return "\n".join(lines) - - -def write_memo_note(memo, analysis, transcript, memo_dir): - memo_dir = Path(memo_dir) - memo_dir.mkdir(parents=True, exist_ok=True) - path = memo_dir / memo_filename(memo.recorded_at, memo.title) - path.write_text(render_memo_note(memo, analysis, transcript), encoding="utf-8") - return path - - -def ensure_daily_note(daily_dir, date_str): - daily_dir = Path(daily_dir) - path = daily_dir / f"{date_str}.md" - if not path.exists(): - daily_dir.mkdir(parents=True, exist_ok=True) - pretty = datetime.strptime(date_str, "%Y-%m-%d").strftime("%B %-d, %Y") - path.write_text( - f"---\ndate: {date_str}\ntags:\n - daily-note\n---\n# Daily Note: {pretty}\n", - encoding="utf-8", - ) - return path - - -def append_to_daily(memo, analysis, note_basename, daily_dir): - date_str = memo.recorded_at.strftime("%Y-%m-%d") - path = ensure_daily_note(daily_dir, date_str) - text = path.read_text(encoding="utf-8") - if "## Voice Memos" not in text: - if not text.endswith("\n"): - text += "\n" - text += "\n## Voice Memos\n" - if not text.endswith("\n"): - text += "\n" - text += render_daily_block(memo, analysis, note_basename) + "\n" - path.write_text(text, encoding="utf-8") - return path + topics = analysis.get("topics", []) + fm = [ + "---", + f"date: {date}", + "type: voice-memo", + f"source_id: {dq(memo.id)}", + f"recording_type: {recording_type}", + f"classify_confidence: {confidence}", + f"analysis: {analysis_status}", + f"status: {status}", + "disposition:", + "routed_to: []", + "source: voice-memo", + f"title: {dq(memo.title)}", + f"recorded: {memo.recorded_at.isoformat(timespec='minutes')}", + f"tags: {seq(['voice-memo'] + list(topics))}", + "---", + ] + body = [f"# {memo.title}", "", "## Summary", analysis.get("summary") or "—"] + if include_todos and analysis.get("action_items"): + body += ["", "## Possible to-dos"] + body += [f"- {item}" for item in analysis["action_items"]] + topic_line = " ".join("#" + slugify(t, 40) for t in topics) or "—" + body += ["", "## Topics", topic_line, "", "## Notable Details"] + details = analysis.get("notable_details") or [] + body += [f"- {d}" for d in details] if details else ["- (none)"] + body += ["", "## Transcript", "> [!quote]- Full transcript"] + body += [f"> {line}" for line in (transcript or "").splitlines() or [""]] + body.append("") + return "\n".join(fm + [""] + body) From a4aed1e045b2684e03afea567246bb97b46213c5 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:29:18 +0100 Subject: [PATCH 11/27] fix(notes): quote recorded datetime; test(analyze): end-to-end confidence clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-ups from Batch B: quote the recorded timestamp so it is unambiguously a string for gray-matter, and cover the parse_analysis → coerce_confidence clamp path end-to-end. Co-Authored-By: Claude Opus 4.8 --- tests/test_analyze.py | 3 +++ voice2note/notes.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_analyze.py b/tests/test_analyze.py index a8885c3..d5f713e 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -75,6 +75,9 @@ def test_invalid_type_defaults_record(self): def test_confidence_extracted(self): self.assertEqual(self._parse(confidence=0.8)["confidence"], 0.8) + def test_confidence_clamped_end_to_end(self): + self.assertEqual(self._parse(confidence=1.5)["confidence"], 1.0) + class TestCoerceConfidence(unittest.TestCase): def test_numeric_string(self): diff --git a/voice2note/notes.py b/voice2note/notes.py index 0a077cc..20d5454 100644 --- a/voice2note/notes.py +++ b/voice2note/notes.py @@ -39,7 +39,7 @@ def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, "routed_to: []", "source: voice-memo", f"title: {dq(memo.title)}", - f"recorded: {memo.recorded_at.isoformat(timespec='minutes')}", + f'recorded: "{memo.recorded_at.isoformat(timespec="minutes")}"', f"tags: {seq(['voice-memo'] + list(topics))}", "---", ] From dedfa84b2d2962afbc980634d2da16a93f8591b2 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:30:22 +0100 Subject: [PATCH 12/27] feat(state): v2 source_id map, migration, atomic save --- tests/test_state.py | 70 +++++++++++++++++++++++++++++++++------------ voice2note/state.py | 34 ++++++++++++++-------- 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/tests/test_state.py b/tests/test_state.py index c038a15..ec04b3b 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,27 +1,59 @@ -import tempfile, unittest +# tests/test_state.py +import json +import os +import tempfile +import unittest from pathlib import Path + from voice2note.state import State class TestState(unittest.TestCase): - def test_roundtrip_and_dedup(self): - with tempfile.TemporaryDirectory() as tmp: - p = Path(tmp) / "state.json" - s = State(p) - self.assertFalse(s.is_processed("a.m4a")) - s.mark_processed("a.m4a") - s.save(last_run="2026-06-15T10:00:00") - # reload from disk - s2 = State(p) - self.assertTrue(s2.is_processed("a.m4a")) - self.assertFalse(s2.is_processed("b.m4a")) - self.assertEqual(s2.last_run, "2026-06-15T10:00:00") - - def test_missing_file_is_empty(self): - with tempfile.TemporaryDirectory() as tmp: - s = State(Path(tmp) / "nope.json") - self.assertFalse(s.is_processed("x")) - self.assertIsNone(s.last_run) + def setUp(self): + self.dir = tempfile.mkdtemp() + self.path = Path(self.dir) / "state.json" + + def test_record_and_is_seen(self): + s = State(self.path) + self.assertFalse(s.is_seen("a.m4a")) + s.record("a.m4a", "Memory/voiceMemo/x.md", "complete", "record") + self.assertTrue(s.is_seen("a.m4a")) + + def test_migrates_processed_ids(self): + self.path.write_text(json.dumps({"processed_ids": ["a.m4a", "b.m4a"]}), + encoding="utf-8") + s = State(self.path) + self.assertTrue(s.is_seen("a.m4a")) + self.assertEqual(s.memos["a.m4a"]["analysis"], "complete") + + def test_save_roundtrip(self): + s = State(self.path) + s.record("a.m4a", "Memory/voiceMemo/x.md", "pending", "record") + s.save(last_run="2026-06-18T14:00:00") + again = State(self.path) + self.assertTrue(again.is_seen("a.m4a")) + self.assertEqual(again.last_run, "2026-06-18T14:00:00") + + def test_save_is_atomic_on_failure(self): + self.path.write_text(json.dumps({"memos": {"old.m4a": {"analysis": "complete"}}}), + encoding="utf-8") + s = State(self.path) + s.record("new.m4a", "Memory/voiceMemo/n.md", "complete", "record") + real_replace = os.replace + + def boom(src, dst): + raise OSError("simulated crash during replace") + + os.replace = boom + try: + with self.assertRaises(OSError): + s.save() + finally: + os.replace = real_replace + # Original file must be intact (not truncated/corrupted). + data = json.loads(self.path.read_text(encoding="utf-8")) + self.assertIn("old.m4a", data["memos"]) + self.assertNotIn("new.m4a", data["memos"]) if __name__ == "__main__": diff --git a/voice2note/state.py b/voice2note/state.py index 76e3df3..a5bf0cc 100644 --- a/voice2note/state.py +++ b/voice2note/state.py @@ -1,32 +1,42 @@ -"""Tracks which memos have been processed, so reruns never duplicate work.""" +"""Tracks which memos have been handled (write-once), keyed by source_id. +Saved atomically so a crash mid-write can't corrupt the file.""" import json +import os +from datetime import datetime from pathlib import Path class State: def __init__(self, path): self.path = Path(path) - self.processed_ids = set() + self.memos = {} self.last_run = None if self.path.exists(): data = json.loads(self.path.read_text(encoding="utf-8")) - self.processed_ids = set(data.get("processed_ids", [])) + if isinstance(data.get("memos"), dict): + self.memos = data["memos"] + elif isinstance(data.get("processed_ids"), list): # migrate v1 + self.memos = {mid: {"analysis": "complete"} for mid in data["processed_ids"]} self.last_run = data.get("last_run") - def is_processed(self, memo_id): - return memo_id in self.processed_ids + def is_seen(self, source_id): + return source_id in self.memos - def mark_processed(self, memo_id): - self.processed_ids.add(memo_id) + def record(self, source_id, note_rel, analysis, recording_type): + self.memos[source_id] = { + "note_rel": note_rel, + "analysis": analysis, + "recording_type": recording_type, + "updated": datetime.now().isoformat(timespec="seconds"), + } def save(self, last_run=None): if last_run is not None: self.last_run = last_run self.path.parent.mkdir(parents=True, exist_ok=True) - self.path.write_text( - json.dumps( - {"processed_ids": sorted(self.processed_ids), "last_run": self.last_run}, - indent=2, - ), + tmp = self.path.with_name(self.path.name + ".tmp") + tmp.write_text( + json.dumps({"memos": self.memos, "last_run": self.last_run}, indent=2), encoding="utf-8", ) + os.replace(tmp, self.path) From d0a6c3be6469b299a40babcea130dae10d41c6ce Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:31:01 +0100 Subject: [PATCH 13/27] feat(vault_writer): cricknote CLI write bridge + adopt-check fs helpers --- tests/test_vault_writer.py | 57 ++++++++++++++++++++++++++++++++++++++ voice2note/vault_writer.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 tests/test_vault_writer.py create mode 100644 voice2note/vault_writer.py diff --git a/tests/test_vault_writer.py b/tests/test_vault_writer.py new file mode 100644 index 0000000..971d33c --- /dev/null +++ b/tests/test_vault_writer.py @@ -0,0 +1,57 @@ +# tests/test_vault_writer.py +import json +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from voice2note.vault_writer import (build_write_cmd, write_note, note_exists, + read_frontmatter) + + +class TestBuildCmd(unittest.TestCase): + def test_argv_and_json(self): + cmd = build_write_cmd("cricknote", "Memory/voiceMemo/x.md", "body\nlines") + self.assertEqual(cmd[:3], ["cricknote", "tool", "vault_write"]) + payload = json.loads(cmd[3]) + self.assertEqual(payload, {"path": "Memory/voiceMemo/x.md", "content": "body\nlines"}) + + +class TestWriteNote(unittest.TestCase): + def test_raises_on_nonzero(self): + def runner(cmd, **kw): + return SimpleNamespace(returncode=1, stdout="", stderr="boom") + with self.assertRaises(RuntimeError): + write_note("cricknote", "Memory/voiceMemo/x.md", "c", runner=runner) + + def test_returns_stdout_on_success(self): + def runner(cmd, **kw): + return SimpleNamespace(returncode=0, stdout="ok", stderr="") + self.assertEqual(write_note("cricknote", "p", "c", runner=runner), "ok") + + +class TestFsHelpers(unittest.TestCase): + def setUp(self): + self.vault = Path(tempfile.mkdtemp()) + + def test_note_exists(self): + rel = "Memory/voiceMemo/x.md" + self.assertFalse(note_exists(self.vault, rel)) + (self.vault / "Memory/voiceMemo").mkdir(parents=True) + (self.vault / rel).write_text("---\nsource_id: \"a.m4a\"\n---\n", encoding="utf-8") + self.assertTrue(note_exists(self.vault, rel)) + + def test_read_frontmatter_source_id(self): + rel = "Memory/voiceMemo/x.md" + (self.vault / "Memory/voiceMemo").mkdir(parents=True) + (self.vault / rel).write_text( + "---\nsource_id: \"a.m4a\"\nanalysis: pending\nrecording_type: record\n---\nbody", + encoding="utf-8") + fm = read_frontmatter(self.vault, rel) + self.assertEqual(fm["source_id"], "a.m4a") + self.assertEqual(fm["analysis"], "pending") + self.assertEqual(fm["recording_type"], "record") + + +if __name__ == "__main__": + unittest.main() diff --git a/voice2note/vault_writer.py b/voice2note/vault_writer.py new file mode 100644 index 0000000..3108915 --- /dev/null +++ b/voice2note/vault_writer.py @@ -0,0 +1,45 @@ +# voice2note/vault_writer.py +"""Bridge to the CrickNote CLI for vault writes, plus direct-fs helpers for the +create-once adopt check. Never silently falls back to a direct write.""" +import json +import re +import subprocess +from pathlib import Path + + +def build_write_cmd(cricknote_bin, rel_path, content): + args = json.dumps({"path": rel_path, "content": content}) + return [cricknote_bin, "tool", "vault_write", args] + + +def write_note(cricknote_bin, rel_path, content, runner=subprocess.run): + cmd = build_write_cmd(cricknote_bin, rel_path, content) + proc = runner(cmd, capture_output=True, text=True) + if proc.returncode != 0: + raise RuntimeError( + f"cricknote vault_write failed (exit {proc.returncode}): {(proc.stderr or '')[:300]}") + return proc.stdout + + +def note_exists(vault, rel_path): + return (Path(vault) / rel_path).exists() + + +_FM_LINE = re.compile(r'^([a-zA-Z_]+):\s*(.*)$') + + +def read_frontmatter(vault, rel_path): + """Minimal frontmatter reader: returns scalar string fields from the leading + --- block. Enough for the adopt check (source_id, analysis, recording_type).""" + text = (Path(vault) / rel_path).read_text(encoding="utf-8", errors="replace") + lines = text.splitlines() + if not lines or lines[0].strip() != "---": + return {} + out = {} + for line in lines[1:]: + if line.strip() == "---": + break + m = _FM_LINE.match(line) + if m: + out[m.group(1)] = m.group(2).strip().strip('"') + return out From 1c7971a2a1445b772dc5349c5ca01b5f6bd6a00a Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:44:38 +0100 Subject: [PATCH 14/27] fix(state,vault_writer): unique temp file + cleanup; guard read_frontmatter Batch C code-review follow-ups: state.save() now uses a unique tempfile.mkstemp name and unlinks it on any failure (no orphaned .tmp); read_frontmatter returns {} for a missing note and strips single quotes too. (Concurrent-writer and missing-file paths are already gated by the flock and note_exists at the call site; these make the utilities robust standalone.) Co-Authored-By: Claude Opus 4.8 --- voice2note/state.py | 20 ++++++++++++++------ voice2note/vault_writer.py | 11 ++++++++--- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/voice2note/state.py b/voice2note/state.py index a5bf0cc..3942df4 100644 --- a/voice2note/state.py +++ b/voice2note/state.py @@ -2,6 +2,7 @@ Saved atomically so a crash mid-write can't corrupt the file.""" import json import os +import tempfile from datetime import datetime from pathlib import Path @@ -34,9 +35,16 @@ def save(self, last_run=None): if last_run is not None: self.last_run = last_run self.path.parent.mkdir(parents=True, exist_ok=True) - tmp = self.path.with_name(self.path.name + ".tmp") - tmp.write_text( - json.dumps({"memos": self.memos, "last_run": self.last_run}, indent=2), - encoding="utf-8", - ) - os.replace(tmp, self.path) + # Unique temp name in the same dir (same filesystem → os.replace is atomic); + # clean up on any failure so a crashed save never orphans a .tmp file. + fd, tmp = tempfile.mkstemp(dir=self.path.parent, prefix=self.path.name + ".", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump({"memos": self.memos, "last_run": self.last_run}, fh, indent=2) + os.replace(tmp, self.path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise diff --git a/voice2note/vault_writer.py b/voice2note/vault_writer.py index 3108915..4529948 100644 --- a/voice2note/vault_writer.py +++ b/voice2note/vault_writer.py @@ -25,13 +25,18 @@ def note_exists(vault, rel_path): return (Path(vault) / rel_path).exists() +# Matches a top-level "key: value" frontmatter line (precompiled; scanned per line). _FM_LINE = re.compile(r'^([a-zA-Z_]+):\s*(.*)$') def read_frontmatter(vault, rel_path): """Minimal frontmatter reader: returns scalar string fields from the leading - --- block. Enough for the adopt check (source_id, analysis, recording_type).""" - text = (Path(vault) / rel_path).read_text(encoding="utf-8", errors="replace") + --- block. Enough for the adopt check (source_id, analysis, recording_type). + Returns {} if the note is missing or has no frontmatter block.""" + p = Path(vault) / rel_path + if not p.exists(): + return {} + text = p.read_text(encoding="utf-8", errors="replace") lines = text.splitlines() if not lines or lines[0].strip() != "---": return {} @@ -41,5 +46,5 @@ def read_frontmatter(vault, rel_path): break m = _FM_LINE.match(line) if m: - out[m.group(1)] = m.group(2).strip().strip('"') + out[m.group(1)] = m.group(2).strip().strip("'\"") return out From fc21d25048ef81b3283a1186fd2f349f4777c62f Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:46:19 +0100 Subject: [PATCH 15/27] feat(config): explicit USE_CRICKNOTE, reldir validation, gated vault-equality check --- tests/test_config.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ voice2note/config.py | 39 ++++++++++++++++++++++++++++++++++++- 2 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..bbd1fab --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,46 @@ +# tests/test_config.py +import os +import tempfile +import unittest +from pathlib import Path + +from voice2note.config import env_bool, validate_memo_reldir, assert_vault_match + + +class TestEnvBool(unittest.TestCase): + def test_true_values(self): + for v in ["1", "true", "TRUE", "yes", "on"]: + os.environ["V2N_T"] = v + self.assertTrue(env_bool("V2N_T", False)) + + def test_false_default_when_unset(self): + os.environ.pop("V2N_MISSING", None) + self.assertFalse(env_bool("V2N_MISSING", False)) + + +class TestRelDir(unittest.TestCase): + def test_ok(self): + self.assertEqual(validate_memo_reldir("Memory/voiceMemo"), "Memory/voiceMemo") + + def test_rejects_absolute(self): + with self.assertRaises(ValueError): + validate_memo_reldir("/etc/passwd") + + def test_rejects_traversal(self): + with self.assertRaises(ValueError): + validate_memo_reldir("Memory/../../etc") + + +class TestVaultMatch(unittest.TestCase): + def test_match_ok(self): + d = tempfile.mkdtemp() + assert_vault_match(d, d) # no raise + + def test_mismatch_raises(self): + a, b = tempfile.mkdtemp(), tempfile.mkdtemp() + with self.assertRaises(SystemExit): + assert_vault_match(a, b) + + +if __name__ == "__main__": + unittest.main() diff --git a/voice2note/config.py b/voice2note/config.py index 5dff6c8..6bcbe6e 100644 --- a/voice2note/config.py +++ b/voice2note/config.py @@ -5,9 +5,10 @@ $VOICE2NOTE_CONFIG). The only required setting is VOICE2NOTE_VAULT — the path to your Obsidian vault (or wherever notes should be written). """ +import json import os import shutil -from pathlib import Path +from pathlib import Path, PurePosixPath PROJECT_DIR = Path(__file__).resolve().parent.parent @@ -63,3 +64,39 @@ def _path(env, default): # --- iCloud download helper. --- BRCTL_BIN = os.environ.get("VOICE2NOTE_BRCTL") or shutil.which("brctl") or "/usr/bin/brctl" + + +def env_bool(name, default=False): + v = os.environ.get(name) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + +def validate_memo_reldir(reldir): + p = PurePosixPath(reldir) + if p.is_absolute() or ".." in p.parts: + raise ValueError(f"VOICE2NOTE_MEMO_RELDIR must be relative without '..': {reldir!r}") + return str(p) + + +def cricknote_vault_path(): + """Read vaultPath from ~/.cricknote/config.json (raises if absent/malformed).""" + cfg = Path.home() / ".cricknote" / "config.json" + data = json.loads(cfg.read_text(encoding="utf-8")) + return data["vaultPath"] + + +def assert_vault_match(v2n_vault, cricknote_vault): + if os.path.realpath(v2n_vault) != os.path.realpath(cricknote_vault): + raise SystemExit( + "VOICE2NOTE_VAULT does not match ~/.cricknote/config.json vaultPath:\n" + f" voice2note: {v2n_vault}\n cricknote: {cricknote_vault}\n" + "Point both at the same vault, or set VOICE2NOTE_USE_CRICKNOTE=false.") + + +USE_CRICKNOTE = env_bool("VOICE2NOTE_USE_CRICKNOTE", False) +CRICKNOTE_BIN = os.environ.get("VOICE2NOTE_CRICKNOTE_BIN") or shutil.which("cricknote") or "cricknote" +MEMO_RELDIR = validate_memo_reldir(os.environ.get("VOICE2NOTE_MEMO_RELDIR", "Memory/voiceMemo")) +CONFIDENCE_MIN = float(os.environ.get("VOICE2NOTE_CONFIDENCE_MIN", "0.5")) +LOCK_PATH = _path("VOICE2NOTE_LOCK", str(PROJECT_DIR / "voice2note.lock")) From 765e193d451381c5f867dc4835251346bac05e42 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:47:35 +0100 Subject: [PATCH 16/27] feat(run): create-once flow under lock; adopt-with-verify; transcript-only fallback --- tests/test_memos.py | 2 +- tests/test_run.py | 113 ++++++++++++++++++++++++++++++++------------ voice2note/memos.py | 2 +- voice2note/run.py | 94 ++++++++++++++++++++++++++++-------- 4 files changed, 158 insertions(+), 53 deletions(-) diff --git a/tests/test_memos.py b/tests/test_memos.py index fcee01b..0dc3693 100644 --- a/tests/test_memos.py +++ b/tests/test_memos.py @@ -41,7 +41,7 @@ def test_find_new_memos_skips_processed(self): (rec / "rec1.m4a").write_bytes(b"x") (rec / "rec2.m4a").write_bytes(b"x") state = State(rec / "state.json") - state.mark_processed("rec1.m4a") + state.record("rec1.m4a", "x", "complete", "record") found = memos.find_new_memos(rec, rec / "missing.db", state) self.assertEqual([m.id for m in found], ["rec2.m4a"]) diff --git a/tests/test_run.py b/tests/test_run.py index d1def6f..6df612a 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,38 +1,91 @@ -import tempfile, unittest +# tests/test_run.py +import tempfile +import unittest from datetime import datetime from pathlib import Path -from unittest.mock import patch -from voice2note import run -from voice2note.memos import Memo +from types import SimpleNamespace + +from voice2note import run as runmod from voice2note.state import State +from voice2note.notes import memo_note_relpath + + +def make_cfg(vault, use_cricknote=False): + return SimpleNamespace( + VAULT=Path(vault), MEMO_RELDIR="Memory/voiceMemo", + USE_CRICKNOTE=use_cricknote, CRICKNOTE_BIN="cricknote", + CONFIDENCE_MIN=0.5) + + +def memo(mid="a.m4a", title="Idea"): + return SimpleNamespace(id=mid, path=Path("/tmp/a.m4a"), title=title, + recorded_at=datetime(2026, 6, 18, 14, 32)) + + +class TestProcessMemo(unittest.TestCase): + def setUp(self): + self.vault = tempfile.mkdtemp() + self.state = State(Path(tempfile.mkdtemp()) / "state.json") + self.cfg = make_cfg(self.vault) + # Stub the heavy steps. + runmod.materialize = lambda *a, **k: True + runmod.transcribe = lambda *a, **k: "the transcript" + runmod.analyze = lambda *a, **k: { + "summary": "s", "action_items": [], "topics": ["t"], + "notable_details": [], "recording_type": "record", "confidence": 0.9} + + def test_creates_note_and_records_state(self): + runmod.process_memo(memo(), self.state, self.cfg) + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + self.assertTrue((Path(self.vault) / rel).exists()) + self.assertTrue(self.state.is_seen("a.m4a")) + + def test_skips_when_seen(self): + self.state.record("a.m4a", "x", "complete", "record") + called = {"n": 0} + runmod.transcribe = lambda *a, **k: called.__setitem__("n", called["n"] + 1) or "t" + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(called["n"], 0) + + def test_adopts_existing_matching_note_without_rewrite(self): + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + p = Path(self.vault) / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('---\nsource_id: "a.m4a"\nanalysis: complete\n' + 'recording_type: record\n---\nORIGINAL', encoding="utf-8") + runmod.transcribe = lambda *a, **k: (_ for _ in ()).throw(AssertionError("should not run")) + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(p.read_text(encoding="utf-8").split("---")[-1].strip(), "ORIGINAL") + self.assertTrue(self.state.is_seen("a.m4a")) + + def test_adopt_mismatch_raises(self): + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + p = Path(self.vault) / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text('---\nsource_id: "OTHER.m4a"\n---\nx', encoding="utf-8") + with self.assertRaises(RuntimeError): + runmod.process_memo(memo(), self.state, self.cfg) + + def test_analysis_failure_writes_transcript_only_draft(self): + runmod.analyze = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("codex down")) + runmod.process_memo(memo(), self.state, self.cfg) + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + out = (Path(self.vault) / rel).read_text(encoding="utf-8") + self.assertIn("analysis: pending", out) + self.assertIn("status: draft", out) + self.assertIn("the transcript", out) -class TestRun(unittest.TestCase): - @patch("voice2note.run.append_to_daily") - @patch("voice2note.run.write_memo_note") - @patch("voice2note.run.analyze", return_value={"summary": "s", "action_items": [], - "topics": [], "notable_details": []}) - @patch("voice2note.run.transcribe", return_value="hello") - @patch("voice2note.run.materialize", return_value=True) - def test_process_memo_marks_processed(self, *_mocks): - with tempfile.TemporaryDirectory() as tmp: - state = State(Path(tmp) / "state.json") - memo = Memo(id="a.m4a", path=Path("/x/a.m4a"), title="A", - recorded_at=datetime(2026, 6, 15, 14, 32)) - run.process_memo(memo, state) - self.assertTrue(state.is_processed("a.m4a")) - - @patch("voice2note.run.write_memo_note") - @patch("voice2note.run.analyze", side_effect=RuntimeError("boom")) - @patch("voice2note.run.transcribe", return_value="hello") - @patch("voice2note.run.materialize", return_value=True) - def test_analysis_failure_leaves_unprocessed(self, *_mocks): - with tempfile.TemporaryDirectory() as tmp: - state = State(Path(tmp) / "state.json") - memo = Memo(id="a.m4a", path=Path("/x/a.m4a"), title="A", - recorded_at=datetime(2026, 6, 15, 14, 32)) - run.process_memo(memo, state) - self.assertFalse(state.is_processed("a.m4a")) +class TestCheckGating(unittest.TestCase): + def test_standalone_check_does_not_require_cricknote_config(self): + # USE_CRICKNOTE False → check_access must not raise even if no cricknote config. + import voice2note.config as config + orig = config.USE_CRICKNOTE + config.USE_CRICKNOTE = False + try: + self.assertIn(runmod.vault_check_ok(config), (True, False)) + finally: + config.USE_CRICKNOTE = orig if __name__ == "__main__": diff --git a/voice2note/memos.py b/voice2note/memos.py index 560e3f0..93862a2 100644 --- a/voice2note/memos.py +++ b/voice2note/memos.py @@ -53,7 +53,7 @@ def find_new_memos(recordings_dir, db_path, state): meta = load_db_metadata(db_path) result = [] for path in list_m4a(recordings_dir): - if state.is_processed(path.name): + if state.is_seen(path.name): continue title, when = meta.get(path.name, (None, None)) if when is None: diff --git a/voice2note/run.py b/voice2note/run.py index d211e6b..a851fe7 100644 --- a/voice2note/run.py +++ b/voice2note/run.py @@ -10,7 +10,10 @@ from voice2note.memos import find_new_memos, materialize from voice2note.transcribe import transcribe from voice2note.analyze import analyze -from voice2note.notes import write_memo_note, append_to_daily, memo_filename +from voice2note.notes import memo_note_relpath, render_memo_note +from voice2note.routing import effective_type, route_for +from voice2note.vault_writer import write_note, note_exists, read_frontmatter +from voice2note.lock import SingleInstanceLock, AlreadyRunning log = logging.getLogger("voice2note") @@ -24,6 +27,15 @@ def setup_logging(): ) +def vault_check_ok(cfg): + """Return True if vault config is consistent. Only validates the cricknote + vault match when USE_CRICKNOTE is true; standalone needs no cricknote config.""" + if not cfg.USE_CRICKNOTE: + return True + cfg.assert_vault_match(str(cfg.VAULT), cfg.cricknote_vault_path()) + return True + + def check_access(): print(f"Vault: {config.VAULT}") print(f"Model: {config.WHISPER_MODEL_PATH}") @@ -37,59 +49,99 @@ def check_access(): return False n = len([x for x in names if x.lower().endswith(".m4a")]) print(f"OK: Recordings folder readable ({n} .m4a files found).") + if not vault_check_ok(config): + return False return True -def process_memo(memo, state): - log.info("Processing %s (%s)", memo.title, memo.id) +def write_memo(cfg, rel, content): + if cfg.USE_CRICKNOTE: + write_note(cfg.CRICKNOTE_BIN, rel, content) # raises → retried next run + else: + path = cfg.VAULT / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + + +def process_memo(memo, state, cfg): + if state.is_seen(memo.id): + return + rel = memo_note_relpath(cfg.MEMO_RELDIR, memo.recorded_at, memo.title, memo.id) + + # Create-once: adopt an existing note (after verifying source_id), never overwrite. + if note_exists(cfg.VAULT, rel): + fm = read_frontmatter(cfg.VAULT, rel) + if fm.get("source_id") != memo.id: + raise RuntimeError( + f"{rel} exists with source_id {fm.get('source_id')!r}, expected {memo.id!r}") + state.record(memo.id, rel, fm.get("analysis", "complete"), + fm.get("recording_type", "record")) + state.save() + return + if not materialize(memo.path, brctl_bin=config.BRCTL_BIN): - log.warning("Could not download %s from iCloud; will retry next run.", memo.id) + log.warning("Could not download %s; will retry next run.", memo.id) return transcript = transcribe(memo.path, config.WHISPER_MODEL_PATH, whisper_bin=config.WHISPER_CLI_BIN, ffmpeg_bin=config.FFMPEG_BIN, language=config.WHISPER_LANGUAGE) - note_basename = memo_filename(memo.recorded_at, memo.title)[:-3] # drop ".md" try: analysis = analyze(transcript, codex_bin=config.CODEX_BIN, model=config.CODEX_MODEL) + rtype = effective_type(analysis["recording_type"], analysis["confidence"], cfg.CONFIDENCE_MIN) + status, include_todos = route_for(rtype) + content = render_memo_note(memo, analysis, transcript, recording_type=rtype, + confidence=analysis["confidence"], status=status, + analysis_status="complete", include_todos=include_todos) + analysis_status = "complete" except Exception as exc: - log.warning("Analysis failed for %s (%s); writing transcript-only note, will retry.", - memo.id, exc) - fallback = {"summary": "(Analysis pending — transcript only.)", - "action_items": [], "topics": [], "notable_details": []} - write_memo_note(memo, fallback, transcript, config.MEMO_DIR) - return # not marked processed -> retried next run - write_memo_note(memo, analysis, transcript, config.MEMO_DIR) - append_to_daily(memo, analysis, note_basename, config.DAILY_DIR) - state.mark_processed(memo.id) + log.warning("Analysis failed for %s (%s); writing transcript-only draft.", memo.id, exc) + empty = {"summary": "", "action_items": [], "topics": [], "notable_details": []} + content = render_memo_note(memo, empty, transcript, recording_type="record", + confidence=0.0, status="draft", + analysis_status="pending", include_todos=False) + rtype, analysis_status = "record", "pending" + + write_memo(cfg, rel, content) + state.record(memo.id, rel, analysis_status, rtype) state.save(last_run=datetime.now().isoformat(timespec="seconds")) - log.info("Done: %s", memo.id) def run(once=False): - if str(config.VAULT) in ("", ".", "/"): - log.error("VOICE2NOTE_VAULT is not set — set it in voice2note.env or the LaunchAgent. See README.") + cfg = config + if str(cfg.VAULT) in ("", ".", "/"): + log.error("VOICE2NOTE_VAULT is not set. See README.") return + if cfg.USE_CRICKNOTE: + config.assert_vault_match(str(cfg.VAULT), config.cricknote_vault_path()) + try: + with SingleInstanceLock(config.LOCK_PATH): + _run_locked(once) + except AlreadyRunning: + log.info("Another voice2note run is active; exiting.") + + +def _run_locked(once): state = State(config.STATE_PATH) try: memos = find_new_memos(config.RECORDINGS_DIR, config.DB_PATH, state) except PermissionError: - log.error("Full Disk Access not granted — cannot read Voice Memos. See README step 1.") + log.error("Full Disk Access not granted. See README step 1.") return except FileNotFoundError: log.error("Recordings folder not found: %s", config.RECORDINGS_DIR) return if once: - memos = memos[-1:] # newest unprocessed only, for a quick test + memos = memos[-1:] if not memos: log.info("No new memos.") return log.info("%d new memo(s) to process.", len(memos)) for memo in memos: try: - process_memo(memo, state) + process_memo(memo, state, config) except Exception: - log.exception("Failed on %s; leaving unprocessed for retry.", memo.id) + log.exception("Failed on %s; leaving for retry.", memo.id) state.save(last_run=datetime.now().isoformat(timespec="seconds")) From 92d513c64f37857400cfcc8a3ffb62010a47bf08 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 14:03:59 +0100 Subject: [PATCH 17/27] refactor(run): thread all config through cfg; test fallback records state Batch D code-review follow-ups: process_memo now reads external-tool settings (brctl/whisper/ffmpeg/codex) via the injected cfg like the rest, so it's a single source of truth and fully unit-testable; add write_memo docstring; the analysis-failure test now asserts the draft is recorded in state (the core write-once/no-retry invariant). Co-Authored-By: Claude Opus 4.8 --- tests/test_run.py | 6 +++++- voice2note/run.py | 14 ++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/test_run.py b/tests/test_run.py index 6df612a..3c33bd8 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -14,7 +14,10 @@ def make_cfg(vault, use_cricknote=False): return SimpleNamespace( VAULT=Path(vault), MEMO_RELDIR="Memory/voiceMemo", USE_CRICKNOTE=use_cricknote, CRICKNOTE_BIN="cricknote", - CONFIDENCE_MIN=0.5) + CONFIDENCE_MIN=0.5, + BRCTL_BIN="brctl", WHISPER_MODEL_PATH="model.bin", + WHISPER_CLI_BIN="whisper-cli", FFMPEG_BIN="ffmpeg", + WHISPER_LANGUAGE="auto", CODEX_BIN="codex", CODEX_MODEL="gpt-5.5") def memo(mid="a.m4a", title="Idea"): @@ -74,6 +77,7 @@ def test_analysis_failure_writes_transcript_only_draft(self): self.assertIn("analysis: pending", out) self.assertIn("status: draft", out) self.assertIn("the transcript", out) + self.assertTrue(self.state.is_seen("a.m4a")) # recorded → not retried/overwritten class TestCheckGating(unittest.TestCase): diff --git a/voice2note/run.py b/voice2note/run.py index a851fe7..d52b3be 100644 --- a/voice2note/run.py +++ b/voice2note/run.py @@ -55,6 +55,8 @@ def check_access(): def write_memo(cfg, rel, content): + """Write content to rel inside the vault. Raises on failure in the cricknote + path, so the caller's state.record is skipped and the memo retries next run.""" if cfg.USE_CRICKNOTE: write_note(cfg.CRICKNOTE_BIN, rel, content) # raises → retried next run else: @@ -79,15 +81,15 @@ def process_memo(memo, state, cfg): state.save() return - if not materialize(memo.path, brctl_bin=config.BRCTL_BIN): + if not materialize(memo.path, brctl_bin=cfg.BRCTL_BIN): log.warning("Could not download %s; will retry next run.", memo.id) return - transcript = transcribe(memo.path, config.WHISPER_MODEL_PATH, - whisper_bin=config.WHISPER_CLI_BIN, - ffmpeg_bin=config.FFMPEG_BIN, - language=config.WHISPER_LANGUAGE) + transcript = transcribe(memo.path, cfg.WHISPER_MODEL_PATH, + whisper_bin=cfg.WHISPER_CLI_BIN, + ffmpeg_bin=cfg.FFMPEG_BIN, + language=cfg.WHISPER_LANGUAGE) try: - analysis = analyze(transcript, codex_bin=config.CODEX_BIN, model=config.CODEX_MODEL) + analysis = analyze(transcript, codex_bin=cfg.CODEX_BIN, model=cfg.CODEX_MODEL) rtype = effective_type(analysis["recording_type"], analysis["confidence"], cfg.CONFIDENCE_MIN) status, include_todos = route_for(rtype) content = render_memo_note(memo, analysis, transcript, recording_type=rtype, From 0fb116153888f53b7c872c1d3e5f6b5b811f8c28 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Thu, 18 Jun 2026 21:55:36 +0100 Subject: [PATCH 18/27] fix: hard-fail on missing cricknote CLI; unescape frontmatter; drop dead config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review follow-ups: - run.assert_cricknote_ready: when USE_CRICKNOTE=true, fail fast at startup if the cricknote CLI isn't runnable (spec §7/§10 hard-fail; was failing per-memo) - vault_writer.read_frontmatter now unescapes double-quoted scalars (\", \\, \xNN), so adopt-verify matches a source_id containing a quote/backslash; + round-trip test pairing notes.render_memo_note → read_frontmatter - remove unused config DAILY_DIR/MEMO_DIR (superseded by MEMO_RELDIR) Full suite: 80 tests passing. Co-Authored-By: Claude Opus 4.8 --- tests/test_run.py | 14 ++++++++++++++ tests/test_vault_writer.py | 18 ++++++++++++++++++ voice2note/config.py | 2 -- voice2note/run.py | 25 ++++++++++++++++++------ voice2note/vault_writer.py | 39 +++++++++++++++++++++++++++++++++++++- 5 files changed, 89 insertions(+), 9 deletions(-) diff --git a/tests/test_run.py b/tests/test_run.py index 3c33bd8..e845035 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -92,5 +92,19 @@ def test_standalone_check_does_not_require_cricknote_config(self): config.USE_CRICKNOTE = orig +class TestCricknoteReadiness(unittest.TestCase): + def test_standalone_is_noop(self): + runmod.assert_cricknote_ready(SimpleNamespace(USE_CRICKNOTE=False)) + + def test_missing_bin_hard_fails(self): + cfg = SimpleNamespace( + USE_CRICKNOTE=True, VAULT=Path("/tmp"), + CRICKNOTE_BIN="nonexistent-cricknote-bin-xyz", + assert_vault_match=lambda a, b: None, + cricknote_vault_path=lambda: "/tmp") + with self.assertRaises(SystemExit): + runmod.assert_cricknote_ready(cfg) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_vault_writer.py b/tests/test_vault_writer.py index 971d33c..023f09d 100644 --- a/tests/test_vault_writer.py +++ b/tests/test_vault_writer.py @@ -53,5 +53,23 @@ def test_read_frontmatter_source_id(self): self.assertEqual(fm["recording_type"], "record") +class TestRoundTrip(unittest.TestCase): + def test_source_id_with_quote_roundtrips(self): + from datetime import datetime + from voice2note.notes import render_memo_note + m = SimpleNamespace(id='A"B\\C.m4a', title="t", + recorded_at=datetime(2026, 6, 18, 14, 32)) + analysis = {"summary": "s", "action_items": [], "topics": [], + "notable_details": []} + content = render_memo_note(m, analysis, "x", recording_type="record", + confidence=0.0, status="draft", + analysis_status="pending", include_todos=False) + vault = Path(tempfile.mkdtemp()) + (vault / "Memory" / "voiceMemo").mkdir(parents=True) + rel = "Memory/voiceMemo/note.md" + (vault / rel).write_text(content, encoding="utf-8") + self.assertEqual(read_frontmatter(vault, rel)["source_id"], 'A"B\\C.m4a') + + if __name__ == "__main__": unittest.main() diff --git a/voice2note/config.py b/voice2note/config.py index 6bcbe6e..dfd78fa 100644 --- a/voice2note/config.py +++ b/voice2note/config.py @@ -38,8 +38,6 @@ def _path(env, default): # --- Where notes are written (your Obsidian vault). VOICE2NOTE_VAULT is required. --- VAULT = _path("VOICE2NOTE_VAULT", "") -DAILY_DIR = _path("VOICE2NOTE_DAILY_DIR", str(VAULT / "00_Journal" / "Daily")) -MEMO_DIR = _path("VOICE2NOTE_MEMO_DIR", str(VAULT / "00_Journal" / "VoiceMemos")) # --- Engine state/logs (kept inside the project by default). --- STATE_PATH = _path("VOICE2NOTE_STATE", str(PROJECT_DIR / "state.json")) diff --git a/voice2note/run.py b/voice2note/run.py index d52b3be..8f4a1f2 100644 --- a/voice2note/run.py +++ b/voice2note/run.py @@ -2,6 +2,7 @@ import argparse import logging import os +import shutil import sys from datetime import datetime @@ -27,12 +28,25 @@ def setup_logging(): ) -def vault_check_ok(cfg): - """Return True if vault config is consistent. Only validates the cricknote - vault match when USE_CRICKNOTE is true; standalone needs no cricknote config.""" +def assert_cricknote_ready(cfg): + """When routing through CrickNote, fail fast at startup (no silent fallback) + if the vault config is inconsistent or the cricknote CLI cannot be run. + No-op in standalone mode (which needs no CrickNote config at all).""" if not cfg.USE_CRICKNOTE: - return True + return cfg.assert_vault_match(str(cfg.VAULT), cfg.cricknote_vault_path()) + if shutil.which(cfg.CRICKNOTE_BIN) is None and not ( + os.path.isabs(cfg.CRICKNOTE_BIN) and os.access(cfg.CRICKNOTE_BIN, os.X_OK)): + raise SystemExit( + f"VOICE2NOTE_USE_CRICKNOTE=true but the cricknote CLI is not runnable: " + f"{cfg.CRICKNOTE_BIN!r}. Set VOICE2NOTE_CRICKNOTE_BIN to its absolute " + "path, or set VOICE2NOTE_USE_CRICKNOTE=false.") + + +def vault_check_ok(cfg): + """True if CrickNote routing is fully configured (or standalone). Raises + SystemExit when USE_CRICKNOTE but the vault config or CLI is not ready.""" + assert_cricknote_ready(cfg) return True @@ -114,8 +128,7 @@ def run(once=False): if str(cfg.VAULT) in ("", ".", "/"): log.error("VOICE2NOTE_VAULT is not set. See README.") return - if cfg.USE_CRICKNOTE: - config.assert_vault_match(str(cfg.VAULT), config.cricknote_vault_path()) + assert_cricknote_ready(cfg) try: with SingleInstanceLock(config.LOCK_PATH): _run_locked(once) diff --git a/voice2note/vault_writer.py b/voice2note/vault_writer.py index 4529948..5d781ae 100644 --- a/voice2note/vault_writer.py +++ b/voice2note/vault_writer.py @@ -27,6 +27,43 @@ def note_exists(vault, rel_path): # Matches a top-level "key: value" frontmatter line (precompiled; scanned per line). _FM_LINE = re.compile(r'^([a-zA-Z_]+):\s*(.*)$') +_DQ_SIMPLE = {"\\": "\\", '"': '"', "n": "\n", "t": "\t", "r": "\r"} + + +def _unescape_dq(body): + """Reverse yamllite.dq escaping for a double-quoted scalar body (without the + surrounding quotes): handles \\\\, \\", \\n, \\t, \\r, and \\xNN. Keeps this + reader in sync with how notes.py writes values, so the adopt-check source_id + comparison matches a value that contained a quote or backslash.""" + out = [] + i = 0 + while i < len(body): + ch = body[i] + if ch == "\\" and i + 1 < len(body): + nxt = body[i + 1] + if nxt == "x" and i + 3 < len(body): + try: + out.append(chr(int(body[i + 2:i + 4], 16))) + i += 4 + continue + except ValueError: + pass + out.append(_DQ_SIMPLE.get(nxt, nxt)) + i += 2 + continue + out.append(ch) + i += 1 + return "".join(out) + + +def _scalar(raw): + """Decode a frontmatter scalar: double-quoted (unescaped), single-quoted, or bare.""" + raw = raw.strip() + if len(raw) >= 2 and raw[0] == '"' and raw[-1] == '"': + return _unescape_dq(raw[1:-1]) + if len(raw) >= 2 and raw[0] == "'" and raw[-1] == "'": + return raw[1:-1].replace("''", "'") + return raw def read_frontmatter(vault, rel_path): @@ -46,5 +83,5 @@ def read_frontmatter(vault, rel_path): break m = _FM_LINE.match(line) if m: - out[m.group(1)] = m.group(2).strip().strip("'\"") + out[m.group(1)] = _scalar(m.group(2)) return out From 2636dfdb9883a3886fed5783c05f0ee5138f9422 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 08:21:34 +0100 Subject: [PATCH 19/27] docs(plan): Phase 3 hardening/docs plan (review round 2 revisions) Folds in five reviewer notes, all verified against code: FDA precondition for the real-vault --check gate; Memory/voiceMemo-scoped read-only manifest (avoids Nutstore sync noise); real empty-file config isolation instead of /dev/null; whisper stub .txt suffix; rg->grep in verification snippets. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-20-voice2note-cricknote-phase3.md | 505 ++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md diff --git a/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md b/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md new file mode 100644 index 0000000..a3e7269 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md @@ -0,0 +1,505 @@ +# Phase 3 — voice2note ↔ CrickNote hardening and documentation plan + +> **Status: Draft for review. Do not implement until the user approves this plan.** +> +> For implementation, execute the checked tasks in order. Keep changes small and +> verify each task before moving on. +> +> **Revisions (2026-06-20, review round 2):** folded in five reviewer notes, all +> verified against the code — (1) FDA precondition called out for the real-vault +> gate; (2) the §6.3 read-only manifest now scopes to `Memory/voiceMemo/` to avoid +> Nutstore sync-daemon noise; (3) §6.3 isolates config with a real empty file +> instead of `/dev/null` (which is not `is_file()`); (4) §5.2 whisper stub writes +> `<-of>.txt`; (5) `rg`→`grep` in verification snippets. Structure and scope +> unchanged. + +**Goal:** Make the already-built voice2note↔CrickNote integration installable, +understandable, and demonstrably safe: wire the LaunchAgent explicitly, discover +the CrickNote CLI during installation, refresh configuration examples and both +READMEs, prove the write path in an isolated temporary vault, and run a read-only +configuration check against the real vault with +`VOICE2NOTE_USE_CRICKNOTE=true`. + +**Architecture:** Phase 1 already supplies the integration engine in +`voice2note/config.py`, `voice2note/run.py`, and `voice2note/vault_writer.py`. +Phase 2 already supplies the human triage workflow in the CrickNote repo. Phase 3 +does not change either engine. It exposes the existing configuration through the +generated plist, documents the existing behavior, and adds an executable smoke +test around the public command-line seam. + +**Repositories:** + +- voice2note source: `/Users/le211/voice2note` +- CrickNote source: `/Users/le211/crickNote` +- configured real vault: `/Users/le211/Nutstore Files/0 Obsidian/crickNote` + +**Current evidence (2026-06-20):** + +- voice2note is on `feat/cricknote-voice-integration`; its 80 unit tests pass. +- `/Users/le211/.npm-global/bin/cricknote` is the currently discovered CLI. +- `~/.cricknote/config.json` points at the real vault above. +- CrickNote Phase 2 is present in `/Users/le211/crickNote` through commit + `2c9421b`, including `cricknote-voice-inbox` and its acceptance record. +- `voice2note.lock` is an existing untracked runtime file. Never add or remove it + as part of this phase. + +--- + +## Locked decisions + +1. **Installer-time discovery, runtime-time explicitness.** `install.sh` may + auto-detect `cricknote`, but it must write an explicit `true` or `false` into + the plist. `config.py` remains explicit and unchanged. +2. **Standalone remains safe.** If auto-discovery finds no CLI, installation + continues with `VOICE2NOTE_USE_CRICKNOTE=false` and prints a warning. If the + user explicitly requests `true` but the binary is unavailable, installation + fails instead of silently falling back. +3. **Real vault is read-only during Phase 3 verification.** Run `--check` against + it. Never run `--once` against the real vault for this phase. +4. **The write smoke uses a temporary vault.** It runs the real voice2note entry + point and the real installed CrickNote CLI. Only ffmpeg, Whisper, and Codex + are replaced with deterministic local stubs so the smoke is fast and does not + use audio, network access, or model credits. +5. **No `run.sh` behavior change.** It already adds the standard npm/local binary + directories to `PATH`, exports `PYTHONPATH`, and delegates to `config.py`. +6. **No CrickNote engine changes.** In particular, no SQLite configuration, + migration, pragma, or `busy_timeout` work belongs in this phase. + +--- + +## Files in scope + +| Repository | File | Action | +|---|---|---| +| voice2note | `com.voice2note.agent.plist.template` | Modify | +| voice2note | `scripts/install.sh` | Modify | +| voice2note | `tests/test_install.py` | Create | +| voice2note | `config.example.env` | Modify | +| voice2note | `README.md` | Modify | +| voice2note | `scripts/smoke-cricknote-integration.sh` | Create | +| voice2note | `docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md` | Plan only | +| CrickNote | `README.md` | Modify | + +Files explicitly **not** in scope include `voice2note/config.py`, +`voice2note/run.py`, every file under `/Users/le211/crickNote/src/`, and all +database/migration files. + +--- + +### Task 0: Establish clean execution branches and baselines + +**Files:** None. + +- [ ] In `/Users/le211/voice2note`, create the Phase 3 branch from the current + `feat/cricknote-voice-integration` tip. Do not stage `voice2note.lock`. +- [ ] Run: + + ```bash + PYTHONPATH=. /usr/bin/python3 -m unittest discover -s tests -t . -v + ``` + + Expected before changes: `Ran 80 tests` and `OK`. + +- [ ] In `/Users/le211/crickNote`, create a small documentation branch from the + current Phase 2 tip. Do not rewrite or squash the eight existing Phase 2 + commits on local `main`. +- [ ] Run the CrickNote baseline: + + ```bash + npm run build + npm test + ``` + +- [ ] Stop before editing if either baseline is red for a reason unrelated to + this phase. Record the failure instead of hiding it. + +**Acceptance criteria:** Both repositories' starting commits and test results are +recorded; no implementation file has changed; `voice2note.lock` remains untracked. + +--- + +### Task 1: Wire the plist and make `install.sh` discover CrickNote safely + +**Files:** + +- Modify: `com.voice2note.agent.plist.template` +- Modify: `scripts/install.sh` +- Create: `tests/test_install.py` + +#### 1.1 Write installer tests first + +Use Python's standard-library `unittest`, `tempfile`, `subprocess`, and +`plistlib`. Each test must give the installer a temporary `HOME` and a stub +`launchctl`, so tests never load or unload the user's real LaunchAgent. + +Cover these cases: + +- [ ] A fake executable `cricknote` on `PATH` produces a plist containing: + `VOICE2NOTE_USE_CRICKNOTE=true`, an absolute + `VOICE2NOTE_CRICKNOTE_BIN`, and + `VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo`. +- [ ] The directory containing the detected binary is present in the generated + plist `PATH`. +- [ ] No discoverable binary produces `VOICE2NOTE_USE_CRICKNOTE=false`, exits + successfully, and prints a standalone-mode warning. +- [ ] Explicit `VOICE2NOTE_USE_CRICKNOTE=true` with no runnable binary exits + nonzero and does not load a LaunchAgent. +- [ ] `--no-cricknote` wins even when a binary is discoverable. +- [ ] Values explicitly supplied in the process environment win over values in + the selected config file; values in that file win over auto/default values. +- [ ] Running the installer twice overwrites the generated plist cleanly and + leaves one valid plist, proving idempotent reinstall behavior. + +#### 1.2 Add the plist environment keys + +Under `EnvironmentVariables`, retain `PATH`, `PYTHONPATH`, and +`VOICE2NOTE_VAULT`, then add exactly: + +- `VOICE2NOTE_USE_CRICKNOTE` → `__USE_CRICKNOTE__` +- `VOICE2NOTE_CRICKNOTE_BIN` → `__CRICKNOTE_BIN__` +- `VOICE2NOTE_MEMO_RELDIR` → `__MEMO_RELDIR__` + +Do not add `busy_timeout` or any database variable. + +#### 1.3 Implement installer resolution rules + +Keep the existing vault argument working. Add `--no-cricknote` as an explicit +escape hatch. Read supported values from +`${VOICE2NOTE_CONFIG:-$PROJECT_DIR/voice2note.env}` without sourcing or executing +the file. This mirrors `config.py` and lets tests supply an isolated config file. + +Use this precedence: + +1. `--no-cricknote` forces standalone mode. +2. A value supplied in the installer process environment wins. +3. Otherwise, use the corresponding value from the selected config file when + present. +4. Otherwise, auto-detect `cricknote` with `command -v`. + +Concrete behavior: + +- [ ] Accept the same common boolean spellings as `config.py` for explicit true + values (`1`, `true`, `yes`, `on`), accept `0`, `false`, `no`, and `off` as + explicit false values, and reject other non-empty values with a clear error. +- [ ] Default `VOICE2NOTE_MEMO_RELDIR` to `Memory/voiceMemo`. +- [ ] Resolve a supplied command name through `command -v`; retain an absolute, + executable path in `VOICE2NOTE_CRICKNOTE_BIN`. +- [ ] When discovery succeeds and use was not explicitly disabled, set use to + `true` and prepend the binary's directory to the plist `PATH` if it is absent. +- [ ] When automatic discovery fails, set use to `false`, keep installation + successful, set the unused plist binary value to `cricknote`, and print how to + install/configure CrickNote later. +- [ ] When use is explicitly `true` but the selected binary cannot be resolved + to an executable, print an error and exit before writing/loading the plist. +- [ ] Replace all three new placeholders when generating the plist. +- [ ] Print the final mode, binary path (when enabled), memo folder, and plist + `PATH` alongside the existing vault/Python/interval summary. + +#### 1.4 Verify Task 1 + +Run: + +```bash +PYTHONPATH=. /usr/bin/python3 -m unittest tests.test_install -v +PYTHONPATH=. /usr/bin/python3 -m unittest discover -s tests -t . -v +``` + +Inspect a generated test plist with `plistlib`; no unresolved `__...__` +placeholder may remain. + +**Acceptance criteria:** All installer tests pass; tests do not touch the real +`~/Library/LaunchAgents`; missing auto-detection stays standalone; explicit +misconfiguration fails closed. + +--- + +### Task 2: Refresh `config.example.env` + +**File:** Modify `config.example.env`. + +- [ ] Remove the obsolete `VOICE2NOTE_MEMO_DIR` and + `VOICE2NOTE_DAILY_DIR` examples. Those settings no longer exist in + `config.py`. +- [ ] Add commented examples and beginner-friendly explanations for: + + ```dotenv + # VOICE2NOTE_USE_CRICKNOTE=true + # VOICE2NOTE_CRICKNOTE_BIN=/Users/you/.npm-global/bin/cricknote + # VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo + # VOICE2NOTE_CONFIDENCE_MIN=0.5 + ``` + +- [ ] Explain that `install.sh` auto-detects the CLI for the LaunchAgent, while + manual `./run.sh` runs use the values loaded by `config.py`. +- [ ] Explain that enabling integration requires `VOICE2NOTE_VAULT` to resolve + to the same path as `~/.cricknote/config.json`'s `vaultPath`. +- [ ] Keep `VOICE2NOTE_VAULT` as the only required setting for standalone mode. + +Verification: + +```bash +! grep -qE 'VOICE2NOTE_(MEMO_DIR|DAILY_DIR)' config.example.env +grep -qE 'VOICE2NOTE_(USE_CRICKNOTE|CRICKNOTE_BIN|MEMO_RELDIR|CONFIDENCE_MIN)' config.example.env +``` + +**Acceptance criteria:** The obsolete names are absent and all four current +Phase 1 settings are represented with their actual defaults. + +--- + +### Task 3: Correct and expand the voice2note README + +**File:** Modify `/Users/le211/voice2note/README.md`. + +- [ ] Replace the stale `00_Journal/VoiceMemos` and daily-note description with + the current single-note contract under `Memory/voiceMemo/`. +- [ ] State that conversation/talk notes are created as `complete`; record, + uncertain, and analysis-failed notes are created as `draft` for triage. +- [ ] Update the workflow diagram: deterministic create-once note → optional + `cricknote tool vault_write` → human `cricknote-voice-inbox` triage. Remove the + daily-note append step. +- [ ] Add CrickNote as optional for standalone mode and required when + `VOICE2NOTE_USE_CRICKNOTE=true`. +- [ ] In installation instructions, explain installer auto-discovery, the + generated plist values, and the `--no-cricknote` escape hatch. +- [ ] Add an **Integration with CrickNote** section explaining: + - the same-vault requirement; + - the `Memory/voiceMemo/` handoff folder; + - strict no-fallback behavior when integration is enabled; + - how to ask an agent in the vault to run `cricknote-voice-inbox`; + - that routing remains human-confirmed. +- [ ] Replace the configuration table's removed variables with + `VOICE2NOTE_USE_CRICKNOTE`, `VOICE2NOTE_CRICKNOTE_BIN`, + `VOICE2NOTE_MEMO_RELDIR`, and `VOICE2NOTE_CONFIDENCE_MIN` using the defaults + from `config.py:96-99`. +- [ ] Add troubleshooting entries for a missing/unrunnable CrickNote binary and + a vault mismatch. +- [ ] Keep the Full Disk Access explanation and standalone workflow accurate. + +Verification: + +```bash +! grep -qE '00_Journal/VoiceMemos|VOICE2NOTE_MEMO_DIR|VOICE2NOTE_DAILY_DIR|daily-note summary' README.md +grep -qE 'Memory/voiceMemo|VOICE2NOTE_USE_CRICKNOTE|cricknote-voice-inbox' README.md +``` + +**Acceptance criteria:** A beginner can follow either standalone or integrated +installation without knowing which values are implicit, and every documented +environment variable exists in `config.py`. + +--- + +### Task 4: Add the voice inbox workflow to the CrickNote README + +**File:** Modify `/Users/le211/crickNote/README.md` only. + +- [ ] Add a short **Voice inbox** section after basic usage. +- [ ] Explain that voice2note creates capture notes under + `Memory/voiceMemo/` and that `cricknote-voice-inbox` handles draft triage. +- [ ] Explain that `npm run setup` installs the skill into the configured vault. +- [ ] Give one beginner-facing action: open Claude Code or Codex in the vault and + ask, “Triage my voice memos.” +- [ ] State that the workflow reindexes first and asks for confirmation before + routing into experiments, ideas, or tasks, or dismissing/reclassifying. +- [ ] Link to the voice2note repository for capture-side setup. + +Do not edit `src/`, storage code, migrations, or skill behavior in this task. + +Verification: + +```bash +grep -qE 'Voice inbox|Memory/voiceMemo|cricknote-voice-inbox|Triage my voice memos' README.md +git diff --name-only +``` + +Expected in the CrickNote repository: only `README.md` is changed by Phase 3. + +**Acceptance criteria:** The CrickNote README tells a new user where voice notes +come from, how to invoke triage, and where to find capture setup. + +--- + +### Task 5: Add an isolated end-to-end integration smoke + +**File:** Create `scripts/smoke-cricknote-integration.sh`. + +The smoke must be safe to run repeatedly and must never read or write the real +vault. Use `mktemp -d` plus a cleanup trap. Capture the absolute real +`cricknote` binary path before redirecting `HOME`. + +#### 5.1 Build an isolated environment + +- [ ] Create a temporary `HOME`, CrickNote data directory, vault, recordings + directory, state file, log, and lock. +- [ ] Write the temporary CrickNote `config.json` with the temporary vault path. +- [ ] Export both `HOME` and `CRICKNOTE_DATA_DIR` so voice2note and the CrickNote + CLI resolve the same isolated config. +- [ ] Export: + + ```text + VOICE2NOTE_USE_CRICKNOTE=true + VOICE2NOTE_CRICKNOTE_BIN= + VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo + VOICE2NOTE_VAULT= + ``` + + Also redirect `VOICE2NOTE_RECORDINGS`, `VOICE2NOTE_STATE`, `VOICE2NOTE_LOG`, + and `VOICE2NOTE_LOCK` into the temporary root. + +#### 5.2 Stub only the expensive upstream tools + +- [ ] Seed one non-empty fake `.m4a` memo. +- [ ] Provide a fake ffmpeg executable that creates its requested WAV output. +- [ ] Provide a fake Whisper executable that writes a deterministic transcript + to `<-of value>.txt`. The real command is built as `-otxt -of ` + (`voice2note/transcribe.py:23-29`), so the transcript lands at `.txt`, + **not** at the bare `-of` path — the stub must add the `.txt` suffix. +- [ ] Provide a fake Codex executable that writes valid analysis JSON to the + requested `-o` path with `recording_type: "record"` and a confidence above + `0.5`. + +Do **not** stub the CrickNote CLI. + +#### 5.3 Run and assert the seam + +- [ ] Run the real entry point: + + ```bash + /usr/bin/python3 -m voice2note.run --once + ``` + +- [ ] Assert exactly one Markdown note exists under + `Memory/voiceMemo/`, its filename ends in the correct 16-hex source hash, and + its frontmatter contains the expected `source_id`, `recording_type: record`, + `analysis: complete`, and `status: draft`. +- [ ] Assert `state.json` records the memo. +- [ ] Run the real CLI's `cricknote reindex`, then call + `vault_list` for `folder=Memory/voiceMemo,status=draft`; parse the JSON response + and assert it returns the created path. +- [ ] Run voice2note `--once` again and assert the vault still contains exactly + one memo note, proving the smoke is idempotent. +- [ ] Print a single clear `PASS` line and remove the temporary directory on + success or failure. + +**Acceptance criteria:** The smoke passes with +`VOICE2NOTE_USE_CRICKNOTE=true`, exercises the real voice2note process and real +CrickNote CLI, proves the draft reaches CrickNote's indexed inbox, and leaves the +real vault unchanged. + +--- + +### Task 6: Final verification, including the real-vault read-only gate + +#### 6.1 Automated checks + +In `/Users/le211/voice2note`: + +```bash +PYTHONPATH=. /usr/bin/python3 -m unittest discover -s tests -t . -v +./scripts/smoke-cricknote-integration.sh +bash -n scripts/install.sh scripts/smoke-cricknote-integration.sh +``` + +In `/Users/le211/crickNote`: + +```bash +npm run build +npm test +``` + +Run stale-document checks against the two user-facing READMEs and +`config.example.env`. Those files must not contain `VOICE2NOTE_MEMO_DIR`, +`VOICE2NOTE_DAILY_DIR`, or the old `00_Journal/VoiceMemos` output path. (The +historical spec and this plan may name them when explaining their removal.) + +#### 6.2 Inspect a generated LaunchAgent + +- [ ] Run the installer test harness with a known fake CrickNote path. +- [ ] Parse the plist and confirm its `EnvironmentVariables` contain all three + integration keys with no unresolved placeholders. +- [ ] Confirm the binary directory is on the plist `PATH`. +- [ ] Confirm the test harness, not the real `launchctl`, handled loading. + +#### 6.3 Run the real-vault check without writing to the vault + +**Precondition (environment, not integration):** `--check` exits `0` only when +Full Disk Access is granted to `/usr/bin/python3`; otherwise `check_access` prints +`DENIED` and exits `1` (`voice2note/run.py:58-59`). If this gate fails, confirm FDA +**before** suspecting the integration wiring. + +Use the vault path already configured by CrickNote and isolate the log in a +temporary file. Create a before/after manifest so the read-only claim is checked, +not assumed. Snapshot **only `Memory/voiceMemo/`** — the single subtree voice2note +could ever write — because a full-vault scan of a Nutstore-synced vault reports +sync-daemon mtime/size churn that is noise, not voice2note activity: + +```bash +CRICKNOTE_BIN="$(command -v cricknote)" +REAL_VAULT="$(/usr/bin/python3 -c 'import json,pathlib; print(json.loads((pathlib.Path.home()/".cricknote/config.json").read_text())["vaultPath"])')" +CHECK_LOG="$(mktemp)" +EMPTY_CFG="$(mktemp)" # a real, EMPTY file — fully isolates from any project voice2note.env + # (VOICE2NOTE_CONFIG=/dev/null would NOT: /dev/null is not is_file(), + # so config.py would fall through to PROJECT_DIR/voice2note.env) +BEFORE="$(mktemp)" +AFTER="$(mktemp)" +MEMO_DIR="$REAL_VAULT/Memory/voiceMemo" +find "$MEMO_DIR" -type f -exec stat -f '%N|%z|%m' {} \; 2>/dev/null | LC_ALL=C sort > "$BEFORE" +VOICE2NOTE_CONFIG="$EMPTY_CFG" \ +VOICE2NOTE_USE_CRICKNOTE=true \ +VOICE2NOTE_CRICKNOTE_BIN="$CRICKNOTE_BIN" \ +VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo \ +VOICE2NOTE_VAULT="$REAL_VAULT" \ +VOICE2NOTE_LOG="$CHECK_LOG" \ +./run.sh --check +find "$MEMO_DIR" -type f -exec stat -f '%N|%z|%m' {} \; 2>/dev/null | LC_ALL=C sort > "$AFTER" +diff -u "$BEFORE" "$AFTER" && echo "READ-ONLY-OK: Memory/voiceMemo unchanged" +rm -f "$CHECK_LOG" "$EMPTY_CFG" "$BEFORE" "$AFTER" +``` + +Required evidence: + +- [ ] Command exits `0` (FDA precondition above satisfied). +- [ ] Output identifies the real vault. +- [ ] Output says the Voice Memos folder is readable. +- [ ] The `Memory/voiceMemo/` before/after `diff` is empty (`READ-ONLY-OK` printed); + no memo-queue file changed during the check. +- [ ] Do not substitute `--once`; this gate is intentionally read-only. + +#### 6.4 Scope audit + +Review both diffs and prove: + +- [ ] No `/Users/le211/crickNote/src/` file changed. +- [ ] No SQLite or migration file changed. +- [ ] `busy_timeout` appears only in this plan's out-of-scope statements, not in + implementation or user documentation. +- [ ] `voice2note/config.py` and `voice2note/run.py` remain unchanged. +- [ ] The real-vault check used `VOICE2NOTE_USE_CRICKNOTE=true`. + +--- + +## Definition of done + +Phase 3 is complete only when all of the following evidence exists: + +| Requirement | Proof | +|---|---| +| Plist integration variables | Parsed generated plist contains the three exact keys | +| CrickNote discovery | Installer tests cover found, missing, explicit true failure, and opt-out | +| Config example refresh | Current variables present; removed variables absent | +| voice2note README | Current folder, routing, install, config, and troubleshooting documented | +| CrickNote README | Voice inbox section and beginner invocation documented | +| Isolated write path | Temp-vault smoke passes through real CrickNote CLI and indexed draft queue | +| Real configuration | `./run.sh --check` exits 0 against the real vault with integration enabled | +| Real vault safety | Before/after evidence shows no vault file changed | +| Scope | No engine/database change; `busy_timeout` remains out of scope | + +--- + +## Review gate + +No implementation begins from this plan until the user chooses one of: + +- **Approved** — execute Tasks 0–6 as written. +- **Revise** — update this document, then request review again. +- **Stop** — leave both repositories unchanged except for this plan file. From ad876c013bde7c7872d47b10ebb45770f941d881 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 09:52:40 +0100 Subject: [PATCH 20/27] =?UTF-8?q?docs(plan):=20Phase=203=20plan=20?= =?UTF-8?q?=E2=80=94=20Codex-approved=20(review=20rounds=203-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 3: folded 7 Codex findings (stub env-var selection, unified CRICKNOTE_DATA_DIR config + vault-resolve assertion, fail-closed 6.3 gate, XML-safe plist generation, vault-match-gated auto-enable, added installer tests, {ok,result} envelope parsing). Round 4: 6.3 snapshot tolerant of a missing Memory/voiceMemo/ dir. Codex VERDICT: APPROVED. Co-Authored-By: Claude Opus 4.8 --- .../2026-06-20-voice2note-cricknote-phase3.md | 125 +++++++++++++++--- 1 file changed, 108 insertions(+), 17 deletions(-) diff --git a/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md b/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md index a3e7269..c4577b1 100644 --- a/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md +++ b/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md @@ -12,6 +12,23 @@ > instead of `/dev/null` (which is not `is_file()`); (4) §5.2 whisper stub writes > `<-of>.txt`; (5) `rg`→`grep` in verification snippets. Structure and scope > unchanged. +> +> **Revisions (2026-06-20, Codex review round 3):** seven Codex findings folded in — +> §5.2 force-selects stubs via `VOICE2NOTE_FFMPEG`/`WHISPER_CLI`/`CODEX` env vars +> (no fallthrough to real Codex/Whisper); §5.1 unifies config readers +> (`CRICKNOTE_DATA_DIR="$HOME/.cricknote"`) + asserts both resolve the temp vault; +> §6.3 made fail-closed (`set -euo pipefail`, trap, explicit `check_status`/`diff_status`); +> §1.2 plist generated XML-safely (`plistlib`/escaping); §1.3 auto-enable gated on a +> vault-match (mismatch → standalone+warn; explicit `true`+mismatch → fail); §1.1 +> adds tests (explicit-false-with-binary, invalid boolean, `MEMO_RELDIR` `..`/abs, +> vault-mismatch, special-char escaping); §5.3 parses the `{ok,result}` envelope. +> Structure and scope still unchanged. +> +> **Revisions (2026-06-20, Codex review round 4):** §6.3 snapshot is now tolerant of +> a missing `Memory/voiceMemo/` (it does not exist on the real vault yet) — a +> `snapshot_memo_dir` helper records `DIR|missing`/`DIR|exists` so `set -euo pipefail` +> no longer aborts on `find` of an absent directory, and an unexpected directory +> creation by `--check` would be caught. Codex confirmed the round-3 fixes adequate. **Goal:** Make the already-built voice2note↔CrickNote integration installable, understandable, and demonstrably safe: wire the LaunchAgent explicitly, discover @@ -147,6 +164,20 @@ Cover these cases: - [ ] `--no-cricknote` wins even when a binary is discoverable. - [ ] Values explicitly supplied in the process environment win over values in the selected config file; values in that file win over auto/default values. +- [ ] Explicit `false` (from env or config) with a discoverable binary still + produces `VOICE2NOTE_USE_CRICKNOTE=false` (auto-detect does not override it). +- [ ] An invalid boolean (e.g. `maybe`) for `VOICE2NOTE_USE_CRICKNOTE` exits + nonzero with a clear error and writes no plist. +- [ ] An absolute or `..`-containing `VOICE2NOTE_MEMO_RELDIR` is rejected + (mirrors `validate_memo_reldir`, `config.py:74-78`) — exits nonzero, no plist. +- [ ] **Auto-detect with a vault mismatch installs standalone:** a discoverable + `cricknote` whose `~/.cricknote/config.json` `vaultPath` differs (by realpath) + from `VOICE2NOTE_VAULT` yields `VOICE2NOTE_USE_CRICKNOTE=false` + a warning + (not a broken enabled agent). With explicit `VOICE2NOTE_USE_CRICKNOTE=true` + and the same mismatch, the installer exits nonzero before writing the plist. +- [ ] **Special characters are escaped:** a `VOICE2NOTE_VAULT` (and binary path) + containing spaces and `&` produces a well-formed plist that `plistlib.load` + parses, with the value round-tripping exactly (no XML/`sed` corruption). - [ ] Running the installer twice overwrites the generated plist cleanly and leaves one valid plist, proving idempotent reinstall behavior. @@ -161,6 +192,14 @@ Under `EnvironmentVariables`, retain `PATH`, `PYTHONPATH`, and Do not add `busy_timeout` or any database variable. +**Generate the plist XML-safely.** The current `sed` substitution into XML +corrupts the plist if any value (vault path, binary path) contains `&`, `<`, `>`, +or a `sed` replacement metacharacter. Either (a) XML-escape every substituted +value before the `sed` pass (`&`→`&`, `<`→`<`, `>`→`>`) **and** escape +`sed` replacement specials, or (b) preferred — build the `EnvironmentVariables` +dict and write the plist with Python `plistlib` so escaping is automatic. The +escaping test in §1.1 (path with spaces + `&`) gates this. + #### 1.3 Implement installer resolution rules Keep the existing vault argument working. Add `--no-cricknote` as an explicit @@ -184,13 +223,21 @@ Concrete behavior: - [ ] Default `VOICE2NOTE_MEMO_RELDIR` to `Memory/voiceMemo`. - [ ] Resolve a supplied command name through `command -v`; retain an absolute, executable path in `VOICE2NOTE_CRICKNOTE_BIN`. -- [ ] When discovery succeeds and use was not explicitly disabled, set use to - `true` and prepend the binary's directory to the plist `PATH` if it is absent. +- [ ] When discovery succeeds and use was not explicitly disabled, **gate + auto-enable on a vault match:** read `~/.cricknote/config.json` `vaultPath` and + compare it by realpath to `VOICE2NOTE_VAULT`. If they match, set use to `true` + and prepend the binary's directory to the plist `PATH` if absent. If they do + **not** match (or the CrickNote config is unreadable), set use to `false`, + install standalone, and warn that integration was skipped because the vaults + differ — this avoids generating an agent that would fail at runtime via + `assert_vault_match` (`run.py:37`). - [ ] When automatic discovery fails, set use to `false`, keep installation successful, set the unused plist binary value to `cricknote`, and print how to install/configure CrickNote later. - [ ] When use is explicitly `true` but the selected binary cannot be resolved - to an executable, print an error and exit before writing/loading the plist. + to an executable **or the CrickNote config vault does not match + `VOICE2NOTE_VAULT`**, print an error and exit before writing/loading the plist + (no silent downgrade when the user explicitly asked for integration). - [ ] Replace all three new placeholders when generating the plist. - [ ] Print the final mode, binary path (when enabled), memo folder, and plist `PATH` alongside the existing vault/Python/interval summary. @@ -329,16 +376,26 @@ vault. Use `mktemp -d` plus a cleanup trap. Capture the absolute real #### 5.1 Build an isolated environment -- [ ] Create a temporary `HOME`, CrickNote data directory, vault, recordings - directory, state file, log, and lock. -- [ ] Write the temporary CrickNote `config.json` with the temporary vault path. -- [ ] Export both `HOME` and `CRICKNOTE_DATA_DIR` so voice2note and the CrickNote - CLI resolve the same isolated config. +- [ ] Capture the absolute real `cricknote` path with `command -v cricknote` + **before** redirecting `HOME` (so discovery still works once `HOME` is temp). +- [ ] Create a temporary `HOME`, vault, recordings directory, state file, log, + and lock under one `mktemp -d` root with a cleanup `trap`. +- [ ] **Unify the two config readers.** voice2note reads the CrickNote vault from + `~/.cricknote/config.json` only — hardcoded `Path.home()/".cricknote"` + (`config.py:81-85`) — while the CrickNote CLI honors `CRICKNOTE_DATA_DIR` + (`crickNote/src/storage/database.ts:34`). So set + `CRICKNOTE_DATA_DIR="$HOME/.cricknote"` (with the temp `HOME`) and write a + single `config.json` there whose `vaultPath` is the temp vault — one file + serves both. Export `HOME` and `CRICKNOTE_DATA_DIR`. +- [ ] **Assert both resolve the same vault before `--once`:** confirm + `cricknote`'s configured vault and voice2note's `cricknote_vault_path()` both + equal the temp vault (e.g. compare realpaths); abort the smoke if not. This + proves the isolation rather than assuming it. - [ ] Export: ```text VOICE2NOTE_USE_CRICKNOTE=true - VOICE2NOTE_CRICKNOTE_BIN= + VOICE2NOTE_CRICKNOTE_BIN= VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo VOICE2NOTE_VAULT= ``` @@ -348,7 +405,13 @@ vault. Use `mktemp -d` plus a cleanup trap. Capture the absolute real #### 5.2 Stub only the expensive upstream tools -- [ ] Seed one non-empty fake `.m4a` memo. +- [ ] Seed one non-empty fake `.m4a` memo (no `.icloud` placeholder, so + `materialize` skips `brctl` — `memos.py:71-75`). +- [ ] **Select the stubs explicitly, not via `PATH`.** `config.py:54-60` resolves + each tool as `os.environ.get("VOICE2NOTE_*") or shutil.which(...)`, so export + the absolute stub paths — `VOICE2NOTE_FFMPEG`, `VOICE2NOTE_WHISPER_CLI`, + `VOICE2NOTE_CODEX` — to guarantee the fakes are used and the real Codex/Whisper + (network, model credits) can never be reached even if present on `PATH`. - [ ] Provide a fake ffmpeg executable that creates its requested WAV output. - [ ] Provide a fake Whisper executable that writes a deterministic transcript to `<-of value>.txt`. The real command is built as `-otxt -of ` @@ -374,8 +437,10 @@ Do **not** stub the CrickNote CLI. `analysis: complete`, and `status: draft`. - [ ] Assert `state.json` records the memo. - [ ] Run the real CLI's `cricknote reindex`, then call - `vault_list` for `folder=Memory/voiceMemo,status=draft`; parse the JSON response - and assert it returns the created path. + `vault_list` for `folder=Memory/voiceMemo,status=draft`. The CLI returns the + envelope `{"ok": true, "result": [ ... ]}` (a wrapper, **not** a bare array) — + assert `ok === true`, then search `result[]` for an entry whose `path` is the + created note. Fail closed if `ok` is not `true`. - [ ] Run voice2note `--once` again and assert the vault still contains exactly one memo note, proving the smoke is idempotent. - [ ] Print a single clear `PASS` line and remove the temporary directory on @@ -433,7 +498,13 @@ not assumed. Snapshot **only `Memory/voiceMemo/`** — the single subtree voice2 could ever write — because a full-vault scan of a Nutstore-synced vault reports sync-daemon mtime/size churn that is noise, not voice2note activity: +The snippet must **fail closed**: `set -euo pipefail`, a cleanup `trap` (so temp +files are removed even on early exit), and explicit capture of both the `--check` +exit status and the `diff` status — never let the trailing cleanup become the +final (success) exit. + ```bash +set -euo pipefail CRICKNOTE_BIN="$(command -v cricknote)" REAL_VAULT="$(/usr/bin/python3 -c 'import json,pathlib; print(json.loads((pathlib.Path.home()/".cricknote/config.json").read_text())["vaultPath"])')" CHECK_LOG="$(mktemp)" @@ -442,18 +513,38 @@ EMPTY_CFG="$(mktemp)" # a real, EMPTY file — fully isolates from any project # so config.py would fall through to PROJECT_DIR/voice2note.env) BEFORE="$(mktemp)" AFTER="$(mktemp)" +trap 'rm -f "$CHECK_LOG" "$EMPTY_CFG" "$BEFORE" "$AFTER"' EXIT MEMO_DIR="$REAL_VAULT/Memory/voiceMemo" -find "$MEMO_DIR" -type f -exec stat -f '%N|%z|%m' {} \; 2>/dev/null | LC_ALL=C sort > "$BEFORE" + +# Snapshot tolerant of a MISSING dir: Memory/voiceMemo/ may not exist yet on the +# real vault, and a bare `find` on a missing path exits nonzero — which, under +# `set -euo pipefail`, would abort before --check ever runs. Recording existence +# also catches --check unexpectedly *creating* the directory. +snapshot_memo_dir() { + if [ -d "$MEMO_DIR" ]; then + { printf 'DIR|exists\n'; find "$MEMO_DIR" -type f -exec stat -f '%N|%z|%m' {} \; 2>/dev/null; } | LC_ALL=C sort + else + printf 'DIR|missing\n' + fi +} +snapshot_memo_dir > "$BEFORE" + +check_status=0 VOICE2NOTE_CONFIG="$EMPTY_CFG" \ VOICE2NOTE_USE_CRICKNOTE=true \ VOICE2NOTE_CRICKNOTE_BIN="$CRICKNOTE_BIN" \ VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo \ VOICE2NOTE_VAULT="$REAL_VAULT" \ VOICE2NOTE_LOG="$CHECK_LOG" \ -./run.sh --check -find "$MEMO_DIR" -type f -exec stat -f '%N|%z|%m' {} \; 2>/dev/null | LC_ALL=C sort > "$AFTER" -diff -u "$BEFORE" "$AFTER" && echo "READ-ONLY-OK: Memory/voiceMemo unchanged" -rm -f "$CHECK_LOG" "$EMPTY_CFG" "$BEFORE" "$AFTER" +./run.sh --check || check_status=$? + +snapshot_memo_dir > "$AFTER" +diff_status=0 +diff -u "$BEFORE" "$AFTER" || diff_status=$? + +if [ "$check_status" -ne 0 ]; then echo "FAIL: --check exited $check_status"; exit 1; fi +if [ "$diff_status" -ne 0 ]; then echo "FAIL: Memory/voiceMemo changed during --check"; exit 1; fi +echo "READ-ONLY-OK: --check exited 0 and Memory/voiceMemo unchanged" ``` Required evidence: From e116ee0f60669dc647dd3ce6c9735a954b802283 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 10:56:44 +0100 Subject: [PATCH 21/27] feat(install): wire cricknote plist keys + safe discovery (Task 1) Add VOICE2NOTE_USE_CRICKNOTE / _CRICKNOTE_BIN / _MEMO_RELDIR to the LaunchAgent template and generate the plist via plistlib (safe XML escaping) instead of sed-into-XML. install.sh now resolves CrickNote integration with precedence --no-cricknote > process env > config file (parsed, not sourced) > auto-detect. Auto-enable is gated on a vaultPath realpath match against ~/.cricknote/config.json. Explicit misconfiguration (invalid boolean, unresolvable binary, vault mismatch, bad MEMO_RELDIR) fails closed before any plist is written or launchctl is invoked. Adds tests/test_install.py (17 tests) running the installer in an isolated subprocess with a temp HOME and stub launchctl/cricknote. Co-Authored-By: Claude Opus 4.8 --- com.voice2note.agent.plist.template | 6 + scripts/install.sh | 245 ++++++++++++++++++++-- tests/test_install.py | 307 ++++++++++++++++++++++++++++ 3 files changed, 540 insertions(+), 18 deletions(-) create mode 100644 tests/test_install.py diff --git a/com.voice2note.agent.plist.template b/com.voice2note.agent.plist.template index c91d24c..281b646 100644 --- a/com.voice2note.agent.plist.template +++ b/com.voice2note.agent.plist.template @@ -22,6 +22,12 @@ __PROJECT_DIR__ VOICE2NOTE_VAULT __VAULT__ + VOICE2NOTE_USE_CRICKNOTE + __USE_CRICKNOTE__ + VOICE2NOTE_CRICKNOTE_BIN + __CRICKNOTE_BIN__ + VOICE2NOTE_MEMO_RELDIR + __MEMO_RELDIR__ StartInterval __INTERVAL__ diff --git a/scripts/install.sh b/scripts/install.sh index b0e6a28..a7a57ea 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,7 +1,14 @@ #!/bin/bash # Generate the voice2note LaunchAgent from the template and load it. -# Usage: ./scripts/install.sh [/path/to/ObsidianVault] -# Vault resolution order: argument > $VOICE2NOTE_VAULT > voice2note.env +# Usage: ./scripts/install.sh [/path/to/ObsidianVault] [--no-cricknote] +# Vault resolution order: argument > $VOICE2NOTE_VAULT > config file. +# +# CrickNote integration resolution (VOICE2NOTE_USE_CRICKNOTE): +# 1. --no-cricknote -> forced standalone (false) +# 2. value in the process env -> wins +# 3. value in the selected config -> next +# 4. otherwise auto-detect `cricknote` on PATH, gated on a vault match. +# Config file is parsed (KEY=VALUE), never sourced/executed. set -e PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" TEMPLATE="$PROJECT_DIR/com.voice2note.agent.plist.template" @@ -10,34 +17,236 @@ PLIST="$LA_DIR/com.voice2note.agent.plist" PYTHON="${VOICE2NOTE_PYTHON:-/usr/bin/python3}" INTERVAL="${VOICE2NOTE_INTERVAL:-7200}" PATH_VALUE="$HOME/.npm-global/bin:$HOME/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" +CONFIG_FILE="${VOICE2NOTE_CONFIG:-$PROJECT_DIR/voice2note.env}" -VAULT="${1:-${VOICE2NOTE_VAULT:-}}" -if [ -z "$VAULT" ] && [ -f "$PROJECT_DIR/voice2note.env" ]; then - VAULT="$(grep -E '^VOICE2NOTE_VAULT=' "$PROJECT_DIR/voice2note.env" | head -1 | cut -d= -f2-)" -fi +die() { echo "ERROR: $*" >&2; exit 1; } + +# --- Parse args (vault positional + optional --no-cricknote). --- +NO_CRICKNOTE=0 +VAULT_ARG="" +for arg in "$@"; do + case "$arg" in + --no-cricknote) NO_CRICKNOTE=1 ;; + -*) die "unknown option: $arg" ;; + *) [ -z "$VAULT_ARG" ] && VAULT_ARG="$arg" ;; + esac +done + +# --- Read a KEY from the config file without sourcing it (strip surrounding quotes). --- +config_get() { + local key="$1" + [ -f "$CONFIG_FILE" ] || return 0 + local line + line="$(grep -E "^[[:space:]]*${key}=" "$CONFIG_FILE" | head -1 || true)" + [ -z "$line" ] && return 0 + local val="${line#*=}" + # strip leading/trailing whitespace + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + # strip one layer of matching quotes + case "$val" in + \"*\") val="${val%\"}"; val="${val#\"}" ;; + \'*\') val="${val%\'}"; val="${val#\'}" ;; + esac + printf '%s' "$val" +} + +# --- Normalize a boolean to true/false; die on invalid non-empty value. --- +# Mirrors config.py spellings; stricter (rejects unknown values). +normalize_bool() { + local raw="$1" + local v + v="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]')" + case "$v" in + 1|true|yes|on) echo "true" ;; + 0|false|no|off) echo "false" ;; + *) die "invalid boolean for VOICE2NOTE_USE_CRICKNOTE: '$raw' (use 1/true/yes/on or 0/false/no/off)" ;; + esac +} + +# --- Vault resolution: arg > env > config. --- +VAULT="${VAULT_ARG:-${VOICE2NOTE_VAULT:-}}" +[ -z "$VAULT" ] && VAULT="$(config_get VOICE2NOTE_VAULT)" if [ -z "$VAULT" ]; then - echo "ERROR: vault path not set." - echo " ./scripts/install.sh \"\$HOME/path/to/ObsidianVault\"" - echo " (or set VOICE2NOTE_VAULT, or add it to voice2note.env)" + echo "ERROR: vault path not set." >&2 + echo " ./scripts/install.sh \"\$HOME/path/to/ObsidianVault\"" >&2 + echo " (or set VOICE2NOTE_VAULT, or add it to the config file)" >&2 exit 1 fi +# --- MEMO_RELDIR: env > config > default; validate (reject absolute or '..'). --- +MEMO_RELDIR="${VOICE2NOTE_MEMO_RELDIR:-}" +[ -z "$MEMO_RELDIR" ] && MEMO_RELDIR="$(config_get VOICE2NOTE_MEMO_RELDIR)" +[ -z "$MEMO_RELDIR" ] && MEMO_RELDIR="Memory/voiceMemo" +case "$MEMO_RELDIR" in + /*) die "VOICE2NOTE_MEMO_RELDIR must be relative (got absolute): '$MEMO_RELDIR'" ;; +esac +case "/$MEMO_RELDIR/" in + */../*) die "VOICE2NOTE_MEMO_RELDIR must not contain '..': '$MEMO_RELDIR'" ;; +esac + +# --- CrickNote binary name/path: env > config > default 'cricknote'. --- +CRICKNOTE_BIN_RAW="${VOICE2NOTE_CRICKNOTE_BIN:-}" +[ -z "$CRICKNOTE_BIN_RAW" ] && CRICKNOTE_BIN_RAW="$(config_get VOICE2NOTE_CRICKNOTE_BIN)" + +# --- USE_CRICKNOTE resolution. --- +# Determine the explicit setting source (env > config); empty means "auto". +USE_RAW="" +USE_EXPLICIT=0 +if [ -n "${VOICE2NOTE_USE_CRICKNOTE+x}" ] && [ -n "$VOICE2NOTE_USE_CRICKNOTE" ]; then + USE_RAW="$VOICE2NOTE_USE_CRICKNOTE" + USE_EXPLICIT=1 +else + cfg_use="$(config_get VOICE2NOTE_USE_CRICKNOTE)" + if [ -n "$cfg_use" ]; then + USE_RAW="$cfg_use" + USE_EXPLICIT=1 + fi +fi + +# Resolve a command/path to an absolute executable; echoes path or empty. +resolve_bin() { + local name="${1:-cricknote}" + local p + p="$(command -v "$name" 2>/dev/null || true)" + [ -n "$p" ] && { (cd "$(dirname "$p")" && printf '%s/%s' "$(pwd)" "$(basename "$p")"); return 0; } + return 1 +} + +# Compare VOICE2NOTE_VAULT against ~/.cricknote/config.json vaultPath by realpath. +# Returns 0 on match, 1 otherwise (also 1 if config unreadable). +vault_matches_cricknote() { + "$PYTHON" - "$VAULT" "$HOME/.cricknote/config.json" <<'PYEOF' +import json, os, sys +v2n, cfg = sys.argv[1], sys.argv[2] +try: + data = json.loads(open(cfg, encoding="utf-8").read()) + ck = data["vaultPath"] +except Exception: + sys.exit(1) +sys.exit(0 if os.path.realpath(v2n) == os.path.realpath(ck) else 1) +PYEOF +} + +USE_CRICKNOTE="false" +CRICKNOTE_BIN="cricknote" # default placeholder value when disabled +BIN_DIR="" + +if [ "$NO_CRICKNOTE" -eq 1 ]; then + USE_CRICKNOTE="false" + echo "CrickNote integration disabled via --no-cricknote (standalone mode)." +elif [ "$USE_EXPLICIT" -eq 1 ]; then + USE_CRICKNOTE="$(normalize_bool "$USE_RAW")" + if [ "$USE_CRICKNOTE" = "true" ]; then + # Fail closed: binary must resolve AND vault must match. + if ! abs="$(resolve_bin "${CRICKNOTE_BIN_RAW:-cricknote}")"; then + die "VOICE2NOTE_USE_CRICKNOTE=true but cricknote binary not found/executable: '${CRICKNOTE_BIN_RAW:-cricknote}'" + fi + if ! vault_matches_cricknote; then + die "VOICE2NOTE_USE_CRICKNOTE=true but ~/.cricknote/config.json vaultPath does not match VOICE2NOTE_VAULT ($VAULT)" + fi + CRICKNOTE_BIN="$abs" + BIN_DIR="$(dirname "$abs")" + fi +else + # Auto-detect, gated on vault match. + if abs="$(resolve_bin "${CRICKNOTE_BIN_RAW:-cricknote}")"; then + if vault_matches_cricknote; then + USE_CRICKNOTE="true" + CRICKNOTE_BIN="$abs" + BIN_DIR="$(dirname "$abs")" + else + USE_CRICKNOTE="false" + echo "CrickNote found but vault differs from ~/.cricknote/config.json; installing standalone (integration skipped)." + fi + else + USE_CRICKNOTE="false" + echo "CrickNote not detected; installing standalone." + echo " To enable later: install CrickNote, point ~/.cricknote/config.json vaultPath at this vault," + echo " then re-run with VOICE2NOTE_USE_CRICKNOTE=true (or let auto-detect pick it up)." + fi +fi + +# When enabled, prepend the binary's directory to the plist PATH if absent. +if [ "$USE_CRICKNOTE" = "true" ] && [ -n "$BIN_DIR" ]; then + case ":$PATH_VALUE:" in + *":$BIN_DIR:"*) ;; + *) PATH_VALUE="$BIN_DIR:$PATH_VALUE" ;; + esac +fi + command -v whisper-cli >/dev/null || echo "WARN: whisper-cli not found -> brew install whisper-cpp" command -v ffmpeg >/dev/null || echo "WARN: ffmpeg not found -> brew install ffmpeg" command -v codex >/dev/null || echo "WARN: codex not found -> npm i -g @openai/codex && codex login" [ -f "$PROJECT_DIR/models/ggml-small.bin" ] || echo "WARN: model missing -> ./scripts/download-model.sh" mkdir -p "$LA_DIR" "$PROJECT_DIR/logs" -sed -e "s|__PYTHON__|$PYTHON|g" \ - -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ - -e "s|__PATH__|$PATH_VALUE|g" \ - -e "s|__VAULT__|$VAULT|g" \ - -e "s|__INTERVAL__|$INTERVAL|g" \ - "$TEMPLATE" > "$PLIST" + +# --- Generate the plist with plistlib so all values are XML-escaped safely. --- +# The template provides the structure; structural path placeholders are substituted, +# then EnvironmentVariables is rebuilt from resolved values. No __...__ may remain. +V2N_TEMPLATE="$TEMPLATE" \ +V2N_OUT="$PLIST" \ +V2N_PYTHON="$PYTHON" \ +V2N_PROJECT_DIR="$PROJECT_DIR" \ +V2N_PATH="$PATH_VALUE" \ +V2N_VAULT="$VAULT" \ +V2N_USE_CRICKNOTE="$USE_CRICKNOTE" \ +V2N_CRICKNOTE_BIN="$CRICKNOTE_BIN" \ +V2N_MEMO_RELDIR="$MEMO_RELDIR" \ +V2N_INTERVAL="$INTERVAL" \ +"$PYTHON" - <<'PYEOF' +import os, plistlib + +env = os.environ +template = env["V2N_TEMPLATE"] +out = env["V2N_OUT"] + +# Structural placeholders (paths/integers) are safe to substitute as text because +# they go through plistlib re-parse + re-serialize below, which re-escapes everything. +text = open(template, encoding="utf-8").read() +subs = { + "__PYTHON__": env["V2N_PYTHON"], + "__PROJECT_DIR__": env["V2N_PROJECT_DIR"], + "__INTERVAL__": env["V2N_INTERVAL"], + # Provide harmless values for env placeholders; the dict is rebuilt below anyway. + "__PATH__": "", + "__VAULT__": "", + "__USE_CRICKNOTE__": "", + "__CRICKNOTE_BIN__": "", + "__MEMO_RELDIR__": "", +} +for k, v in subs.items(): + text = text.replace(k, v) + +data = plistlib.loads(text.encode("utf-8")) + +# Rebuild EnvironmentVariables from resolved values (plistlib escapes on write). +data["EnvironmentVariables"] = { + "PATH": env["V2N_PATH"], + "PYTHONPATH": env["V2N_PROJECT_DIR"], + "VOICE2NOTE_VAULT": env["V2N_VAULT"], + "VOICE2NOTE_USE_CRICKNOTE": env["V2N_USE_CRICKNOTE"], + "VOICE2NOTE_CRICKNOTE_BIN": env["V2N_CRICKNOTE_BIN"], + "VOICE2NOTE_MEMO_RELDIR": env["V2N_MEMO_RELDIR"], +} + +with open(out, "wb") as fh: + plistlib.dump(data, fh) +PYEOF + echo "Wrote $PLIST" -echo " vault: $VAULT" -echo " python: $PYTHON" -echo " interval: ${INTERVAL}s" +echo " vault: $VAULT" +echo " python: $PYTHON" +echo " interval: ${INTERVAL}s" +if [ "$USE_CRICKNOTE" = "true" ]; then + echo " mode: cricknote (integration enabled)" + echo " cricknote: $CRICKNOTE_BIN" +else + echo " mode: standalone" +fi +echo " memo folder: $MEMO_RELDIR" +echo " plist PATH: $PATH_VALUE" echo echo ">> Grant Full Disk Access to $PYTHON before this can read Voice Memos:" echo " System Settings > Privacy & Security > Full Disk Access > +" diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..48ca7e2 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,307 @@ +# tests/test_install.py +"""Tests for scripts/install.sh — CrickNote plist wiring + safe discovery (Task 1). + +Each test runs install.sh in an isolated subprocess with: + - a temporary HOME (so ~/Library/LaunchAgents and ~/.cricknote are sandboxed), + - a stub `launchctl` early on PATH (so the real LaunchAgent is never touched), + - a stub `cricknote` (or none) to drive auto-detection, + - VOICE2NOTE_CONFIG pointing at a temp config file (so the repo's real + voice2note.env is never read), + - a controlled environment (no inherited VOICE2NOTE_* vars). +""" +import json +import os +import plistlib +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +PROJECT_DIR = Path(__file__).resolve().parent.parent +INSTALL_SH = PROJECT_DIR / "scripts" / "install.sh" + + +def _make_exec(path: Path, body: str = "#!/bin/bash\nexit 0\n"): + path.write_text(body, encoding="utf-8") + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return path + + +class InstallTestBase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.tmp = Path(self._tmp.name) + self.home = self.tmp / "home" + self.home.mkdir() + # Stub directory placed early on PATH. + self.stubdir = self.tmp / "stubs" + self.stubdir.mkdir() + # Stub launchctl records its invocations so tests can assert load/no-load. + self.launchctl_log = self.tmp / "launchctl.log" + _make_exec( + self.stubdir / "launchctl", + f'#!/bin/bash\necho "$@" >> "{self.launchctl_log}"\nexit 0\n', + ) + self.config = self.tmp / "voice2note.env" + self.config.write_text("", encoding="utf-8") + self.plist = self.home / "Library" / "LaunchAgents" / "com.voice2note.agent.plist" + + def tearDown(self): + self._tmp.cleanup() + + def make_cricknote(self, name="cricknote"): + """Create a discoverable, executable stub binary; return its absolute path.""" + return _make_exec(self.stubdir / name) + + def write_cricknote_config(self, vault_path): + d = self.home / ".cricknote" + d.mkdir(parents=True, exist_ok=True) + (d / "config.json").write_text( + json.dumps({"vaultPath": str(vault_path)}), encoding="utf-8" + ) + + def run_install(self, vault="/tmp/Vault", args=None, env=None): + """Run install.sh with a clean, controlled environment. Returns CompletedProcess.""" + full_env = { + "HOME": str(self.home), + # Stub dir first so launchctl/cricknote stubs win; keep system bins for sed/grep. + "PATH": f"{self.stubdir}:/usr/bin:/bin:/usr/sbin:/sbin", + "VOICE2NOTE_CONFIG": str(self.config), + "VOICE2NOTE_PYTHON": "/usr/bin/python3", + } + if env: + full_env.update(env) + argv = [str(INSTALL_SH)] + if vault is not None: + argv.append(vault) + if args: + argv += args + return subprocess.run( + argv, env=full_env, capture_output=True, text=True + ) + + def load_plist(self): + with open(self.plist, "rb") as fh: + return plistlib.load(fh) + + def env_vars(self, data): + return data["EnvironmentVariables"] + + def assert_launchctl_loaded(self): + self.assertTrue(self.launchctl_log.exists(), "launchctl was never invoked") + self.assertIn("load", self.launchctl_log.read_text()) + + def assert_launchctl_not_loaded(self): + if self.launchctl_log.exists(): + self.assertNotIn("load", self.launchctl_log.read_text(), + "launchctl load should not have run") + + +class TestAutoDetectEnable(InstallTestBase): + def test_cricknote_on_path_enables_with_matching_vault(self): + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "true") + bin_path = env["VOICE2NOTE_CRICKNOTE_BIN"] + self.assertTrue(os.path.isabs(bin_path), bin_path) + self.assertTrue(os.access(bin_path, os.X_OK), bin_path) + self.assertEqual(env["VOICE2NOTE_MEMO_RELDIR"], "Memory/voiceMemo") + + def test_binary_dir_in_plist_path(self): + vault = self.tmp / "Vault" + vault.mkdir() + binpath = self.make_cricknote() + self.write_cricknote_config(vault) + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertIn(str(binpath.parent), env["PATH"].split(":")) + + +class TestAutoDetectDisable(InstallTestBase): + def test_no_binary_standalone(self): + r = self.run_install(vault="/tmp/Vault") + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + combined = (r.stdout + r.stderr).lower() + self.assertIn("standalone", combined) + + def test_vault_mismatch_installs_standalone(self): + vault = self.tmp / "Vault" + vault.mkdir() + other = self.tmp / "OtherVault" + other.mkdir() + self.make_cricknote() + self.write_cricknote_config(other) # mismatch + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + self.assert_launchctl_loaded() + self.assertIn("vault", (r.stdout + r.stderr).lower()) + + +class TestExplicitTrue(InstallTestBase): + def test_explicit_true_no_binary_fails_closed(self): + r = self.run_install( + vault="/tmp/Vault", env={"VOICE2NOTE_USE_CRICKNOTE": "true"} + ) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.plist.exists(), "no plist should be written") + self.assert_launchctl_not_loaded() + + def test_explicit_true_vault_mismatch_fails_closed(self): + vault = self.tmp / "Vault" + vault.mkdir() + other = self.tmp / "OtherVault" + other.mkdir() + self.make_cricknote() + self.write_cricknote_config(other) + r = self.run_install( + vault=str(vault), env={"VOICE2NOTE_USE_CRICKNOTE": "true"} + ) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.plist.exists()) + self.assert_launchctl_not_loaded() + + +class TestNoCricknoteFlag(InstallTestBase): + def test_flag_wins_over_discovery(self): + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) # would otherwise enable + r = self.run_install(vault=str(vault), args=["--no-cricknote"]) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + + +class TestPrecedence(InstallTestBase): + def test_env_wins_over_config(self): + # config says true, env says false -> false + self.config.write_text("VOICE2NOTE_USE_CRICKNOTE=true\n", encoding="utf-8") + vault = self.tmp / "Vault" + vault.mkdir() + r = self.run_install( + vault=str(vault), env={"VOICE2NOTE_USE_CRICKNOTE": "false"} + ) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + + def test_config_wins_over_auto(self): + # config says false; a binary is discoverable -> still false + self.config.write_text("VOICE2NOTE_USE_CRICKNOTE=false\n", encoding="utf-8") + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + + def test_explicit_false_with_binary_stays_false(self): + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) + r = self.run_install( + vault=str(vault), env={"VOICE2NOTE_USE_CRICKNOTE": "false"} + ) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_USE_CRICKNOTE"], "false") + + def test_memo_reldir_config_value(self): + self.config.write_text("VOICE2NOTE_MEMO_RELDIR=Foo/bar\n", encoding="utf-8") + vault = self.tmp / "Vault" + vault.mkdir() + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + env = self.env_vars(self.load_plist()) + self.assertEqual(env["VOICE2NOTE_MEMO_RELDIR"], "Foo/bar") + + +class TestValidation(InstallTestBase): + def test_invalid_boolean_fails_closed(self): + r = self.run_install( + vault="/tmp/Vault", env={"VOICE2NOTE_USE_CRICKNOTE": "maybe"} + ) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.plist.exists()) + self.assert_launchctl_not_loaded() + + def test_absolute_memo_reldir_rejected(self): + r = self.run_install( + vault="/tmp/Vault", env={"VOICE2NOTE_MEMO_RELDIR": "/etc/passwd"} + ) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.plist.exists()) + + def test_traversal_memo_reldir_rejected(self): + r = self.run_install( + vault="/tmp/Vault", env={"VOICE2NOTE_MEMO_RELDIR": "Memory/../../etc"} + ) + self.assertNotEqual(r.returncode, 0) + self.assertFalse(self.plist.exists()) + + +class TestEscaping(InstallTestBase): + def test_special_chars_round_trip(self): + vault = self.tmp / "My Vault & Notes" + vault.mkdir() + binpath = self.make_cricknote("crick & note") + self.write_cricknote_config(vault) + r = self.run_install( + vault=str(vault), + env={"VOICE2NOTE_CRICKNOTE_BIN": str(binpath), + "VOICE2NOTE_USE_CRICKNOTE": "true"}, + ) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + # plistlib.load must parse without error and values round-trip exactly. + data = self.load_plist() + env = self.env_vars(data) + self.assertEqual(env["VOICE2NOTE_VAULT"], str(vault)) + self.assertEqual(env["VOICE2NOTE_CRICKNOTE_BIN"], str(binpath)) + + +class TestIdempotent(InstallTestBase): + def test_reinstall_overwrites_cleanly(self): + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) + r1 = self.run_install(vault=str(vault)) + self.assertEqual(r1.returncode, 0, r1.stderr + r1.stdout) + first = self.plist.read_bytes() + r2 = self.run_install(vault=str(vault)) + self.assertEqual(r2.returncode, 0, r2.stderr + r2.stdout) + # Still exactly one valid plist, parseable. + data = self.load_plist() + self.assertEqual(data["Label"], "com.voice2note.agent") + self.assertEqual(self.plist.read_bytes(), first) + + +class TestNoUnresolvedPlaceholders(InstallTestBase): + def test_no_double_underscore_tokens(self): + vault = self.tmp / "Vault" + vault.mkdir() + self.make_cricknote() + self.write_cricknote_config(vault) + r = self.run_install(vault=str(vault)) + self.assertEqual(r.returncode, 0, r.stderr + r.stdout) + raw = self.plist.read_text(encoding="utf-8") + self.assertNotIn("__", raw, "unresolved placeholder remains in plist") + + +if __name__ == "__main__": + unittest.main() From afe7da47c4573a7c65f989b2700b02c40bd3b254 Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:01:38 +0100 Subject: [PATCH 22/27] docs(config): refresh example env for cricknote integration (Task 2) Remove obsolete VOICE2NOTE_MEMO_DIR and VOICE2NOTE_DAILY_DIR examples. Add commented examples and explanations for VOICE2NOTE_USE_CRICKNOTE, VOICE2NOTE_CRICKNOTE_BIN, VOICE2NOTE_MEMO_RELDIR, and VOICE2NOTE_CONFIDENCE_MIN, including notes on install.sh auto-detection vs manual ./run.sh PATH and the vault-path alignment requirement. Co-Authored-By: Claude Opus 4.8 --- config.example.env | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/config.example.env b/config.example.env index 07a992f..6c813ad 100644 --- a/config.example.env +++ b/config.example.env @@ -1,13 +1,31 @@ -# Copy this file to `voice2note.env` and edit. Only VOICE2NOTE_VAULT is required. +# Copy this file to `voice2note.env` and edit. Only VOICE2NOTE_VAULT is required +# for standalone (no-CrickNote) mode. # (The installer also writes VOICE2NOTE_VAULT into the LaunchAgent.) # Path to your Obsidian vault — where notes are written. VOICE2NOTE_VAULT=/Users/you/path/to/ObsidianVault # --- Optional overrides (defaults shown) --- -# Per-memo notes folder and the daily-note folder (default: under the vault): -# VOICE2NOTE_MEMO_DIR=/Users/you/path/to/ObsidianVault/00_Journal/VoiceMemos -# VOICE2NOTE_DAILY_DIR=/Users/you/path/to/ObsidianVault/00_Journal/Daily + +# CrickNote integration — set to true to hand off each voice memo to CrickNote +# after transcription and analysis. Requires the cricknote CLI to be installed. +# When enabled, VOICE2NOTE_VAULT must resolve to the same path as the +# `vaultPath` in ~/.cricknote/config.json. +# VOICE2NOTE_USE_CRICKNOTE=true + +# Path to the cricknote CLI binary. By default, config.py auto-discovers it via +# shutil.which("cricknote"). install.sh also auto-detects it when writing the +# LaunchAgent plist. Set this only if your binary is not on PATH for launchd +# (launchd has a minimal PATH; manual ./run.sh runs inherit your shell PATH). +# VOICE2NOTE_CRICKNOTE_BIN=/Users/you/.npm-global/bin/cricknote + +# Folder for per-recording voice-memo notes, relative to the vault root. +# Must be a relative path with no leading slash and no '..'. +# VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo + +# Minimum transcription confidence (0.0–1.0) required to trigger CrickNote +# hand-off. Segments below this threshold are still saved as plain transcripts. +# VOICE2NOTE_CONFIDENCE_MIN=0.5 # whisper.cpp model file (default: ./models/ggml-small.bin): # VOICE2NOTE_MODEL=/absolute/path/to/ggml-small.bin From 9874c4cebfbd5513e6b10733adf57838cd2faf4b Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 11:03:01 +0100 Subject: [PATCH 23/27] docs(config): correct CONFIDENCE_MIN description (Task 2 fixup) Co-Authored-By: Claude Opus 4.8 --- config.example.env | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config.example.env b/config.example.env index 6c813ad..f65b528 100644 --- a/config.example.env +++ b/config.example.env @@ -23,8 +23,10 @@ VOICE2NOTE_VAULT=/Users/you/path/to/ObsidianVault # Must be a relative path with no leading slash and no '..'. # VOICE2NOTE_MEMO_RELDIR=Memory/voiceMemo -# Minimum transcription confidence (0.0–1.0) required to trigger CrickNote -# hand-off. Segments below this threshold are still saved as plain transcripts. +# Minimum classification confidence (0.0–1.0). The analyzer guesses each memo's +# type (conversation/talk/record) with a confidence score; if it's below this +# threshold the memo is treated as "record" and the note is created as a draft +# for you to triage (bias-to-record). A note is always written either way. # VOICE2NOTE_CONFIDENCE_MIN=0.5 # whisper.cpp model file (default: ./models/ggml-small.bin): From f861f5a8841086253fe7391b460706fb16e1cdfb Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:21:19 +0100 Subject: [PATCH 24/27] docs(readme): current note contract + cricknote integration (Task 3) Co-Authored-By: Claude Opus 4.8 --- README.md | 96 ++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 85 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 521446d..86fdbf7 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,34 @@ hours, with **no API key** (it uses your existing Codex/ChatGPT login). ## What you get, per memo -- A full note in your vault (e.g. `00_Journal/VoiceMemos/`): summary, action items - as dated checkboxes, topic tags, notable details, and the complete transcript. -- A one-line linked summary + tasks appended under `## Voice Memos` in that day's - daily note. +- **One Markdown note** per recording in `Memory/voiceMemo/` (configurable via + `VOICE2NOTE_MEMO_RELDIR`). +- Filename: `--<16-hex-source-hash>.md`. +- Notes are **created once and never overwritten** (create-once / idempotent). If a + note file already exists for a memo, voice2note verifies its `source_id` and adopts + it; on a mismatch it raises rather than silently clobbering. +- **`status: complete`** — set for `conversation` and `talk` recordings (finalized, + no to-dos generated). +- **`status: draft`** — set for `record` recordings, ambiguous classifications, any + classification whose confidence is below `VOICE2NOTE_CONFIDENCE_MIN` (default 0.5), + and any memo where analysis fails entirely (written as a transcript-only draft with + `analysis: pending`). Draft notes wait in `Memory/voiceMemo/` for human triage via + the `cricknote-voice-inbox` skill (see Integration with CrickNote below). ## How it works ``` launchd (every ~2h) → python -m voice2note.run - find new memos (CloudRecordings.db) [Full Disk Access] - → copy audio out of the protected folder [Python has FDA; ffmpeg doesn't] - → ffmpeg → 16 kHz wav → whisper-cli [local transcription] - → codex exec (analysis → JSON) [your Codex login, no API key] - → write per-memo note + daily-note summary [into your Obsidian vault] + find new memos (CloudRecordings.db) [Full Disk Access] + → copy audio out of the protected folder [Python has FDA; ffmpeg doesn't] + → ffmpeg → 16 kHz wav → whisper-cli [local transcription] + → codex exec (analysis → JSON) [your Codex login, no API key] + → classify type + confidence [complete vs. draft status] + → write single note (create-once, idempotent) + standalone: direct file write into vault + cricknote: cricknote tool vault_write [when USE_CRICKNOTE=true] + → human triage: ask an agent to run + cricknote-voice-inbox [routing is always human-confirmed] → record processed id (no duplicates) ``` @@ -37,6 +51,9 @@ Everything is local except the analysis step, which sends the **transcript text* - **whisper.cpp**: `brew install whisper-cpp` - **ffmpeg**: `brew install ffmpeg` - **Codex CLI**, logged in: `npm install -g @openai/codex` then `codex login` +- **CrickNote CLI** (optional for standalone; required when `VOICE2NOTE_USE_CRICKNOTE=true`): + install from the [CrickNote project](https://github.com/hele211/cricknote) and ensure + `cricknote` is on your `PATH`. ## Install @@ -58,6 +75,20 @@ PYTHONPATH=. /usr/bin/python3 -m voice2note.run --check ./scripts/install.sh ``` +The installer (`scripts/install.sh`) auto-discovers the `cricknote` CLI and writes +explicit values into the generated LaunchAgent plist: + +- It compares `VOICE2NOTE_VAULT` against `~/.cricknote/config.json` `vaultPath` + (by realpath). If they match **and** the CLI is runnable, it installs with + `VOICE2NOTE_USE_CRICKNOTE=true` and records `VOICE2NOTE_CRICKNOTE_BIN` and + `VOICE2NOTE_MEMO_RELDIR` in the plist. +- If the vaults do **not** match, or the CLI is missing, it installs in standalone + mode (`VOICE2NOTE_USE_CRICKNOTE=false`) and prints a warning. +- Pass `--no-cricknote` to force standalone even when a CrickNote CLI is discoverable. +- If you explicitly set `VOICE2NOTE_USE_CRICKNOTE=true` in your env but the CLI is + not runnable or the vaults do not match, the installer **fails** — there is no + silent downgrade. + ### Grant Full Disk Access (required, one-time) macOS protects the Voice Memos folder. The agent runs **Python directly** so the @@ -85,14 +116,49 @@ Set in `voice2note.env` (or as environment variables). Only the vault is require | Variable | Default | Meaning | |---|---|---| | `VOICE2NOTE_VAULT` | _(required)_ | Path to your Obsidian vault | -| `VOICE2NOTE_MEMO_DIR` | `/00_Journal/VoiceMemos` | Per-memo notes folder | -| `VOICE2NOTE_DAILY_DIR` | `/00_Journal/Daily` | Daily-notes folder | +| `VOICE2NOTE_MEMO_RELDIR` | `Memory/voiceMemo` | Per-memo notes folder, relative to vault | | `VOICE2NOTE_MODEL` | `./models/ggml-small.bin` | whisper.cpp model file | | `VOICE2NOTE_LANGUAGE` | `auto` | Transcription language (`auto`, `en`, `zh`, …) | | `VOICE2NOTE_CODEX_MODEL` | `gpt-5.5` | Codex model for analysis | +| `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Minimum classification confidence; below this the note is downgraded to `draft` | +| `VOICE2NOTE_USE_CRICKNOTE` | `false` | Route note writes through the CrickNote CLI instead of a direct file write | +| `VOICE2NOTE_CRICKNOTE_BIN` | _(auto-discovered)_ | Absolute path to the `cricknote` binary; defaults to `shutil.which("cricknote")` | Schedule frequency: `VOICE2NOTE_INTERVAL= ./scripts/install.sh` (default 7200). +## Integration with CrickNote + +CrickNote is an optional vault-management layer. When enabled, voice2note writes +notes through the CrickNote CLI rather than directly to the filesystem; in every +other way the notes are identical — same file, same frontmatter, same location under +`Memory/voiceMemo/`. + +**Same-vault requirement.** `VOICE2NOTE_VAULT` must point to the same vault as +`~/.cricknote/config.json` `vaultPath` (compared by realpath). voice2note checks this +at startup and raises `SystemExit` if they differ. + +**Strict no-fallback when integration is enabled.** If `VOICE2NOTE_USE_CRICKNOTE=true` +and the `cricknote` binary is not runnable, startup fails immediately. A per-memo +write failure also raises, so the memo is retried on the next run and never silently +dropped. + +**Human triage via `cricknote-voice-inbox`.** Draft notes accumulate in +`Memory/voiceMemo/`. To triage them, open a Claude Code (or Codex) session inside +your Obsidian vault and ask the agent to run the `cricknote-voice-inbox` skill. The +skill reindexes the vault, shows you each draft, and asks for confirmation before +routing it to experiments, ideas, or tasks — or dismissing / reclassifying it. +**Routing is always human-confirmed; voice2note never routes automatically.** + +**Enabling manually.** Add these lines to `voice2note.env`: + +``` +VOICE2NOTE_USE_CRICKNOTE=true +VOICE2NOTE_CRICKNOTE_BIN=/usr/local/bin/cricknote # or wherever your binary lives +``` + +Then re-run `--check` to confirm the vault match and binary path are correct before +starting the agent. + ## Troubleshooting - **`--check` says DENIED even after granting access.** The grant must be on the @@ -106,6 +172,14 @@ Schedule frequency: `VOICE2NOTE_INTERVAL= ./scripts/install.sh` (defaul ` with `stdin` detached. `--ignore-user-config` avoids an invalid `service_tier` in some `~/.codex/config.toml` files; set `VOICE2NOTE_CODEX_MODEL` to a model your account supports. +- **`cricknote CLI is not runnable` at startup.** `VOICE2NOTE_USE_CRICKNOTE=true` but + the binary cannot be found or executed. Either install the CrickNote CLI and ensure + it is on your `PATH`, set `VOICE2NOTE_CRICKNOTE_BIN` to its absolute path, or set + `VOICE2NOTE_USE_CRICKNOTE=false` to revert to standalone mode. +- **`VOICE2NOTE_VAULT does not match ~/.cricknote/config.json vaultPath`.** Both + tools must point at the same vault directory (compared by realpath, so symlinks are + resolved). Update `VOICE2NOTE_VAULT` in `voice2note.env` or reconfigure CrickNote + so both agree, then re-run `--check`. ## Development From cc1418c07ec2ba21af516376c97ea9e1cff3056f Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:29:21 +0100 Subject: [PATCH 25/27] test(smoke): isolated cricknote integration smoke (Task 5) Co-Authored-By: Claude Opus 4.8 --- scripts/smoke-cricknote-integration.sh | 309 +++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100755 scripts/smoke-cricknote-integration.sh diff --git a/scripts/smoke-cricknote-integration.sh b/scripts/smoke-cricknote-integration.sh new file mode 100755 index 0000000..9961752 --- /dev/null +++ b/scripts/smoke-cricknote-integration.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# +# smoke-cricknote-integration.sh — isolated end-to-end integration smoke (Task 5). +# +# Proves the voice2note → CrickNote seam end-to-end: +# * runs the REAL voice2note process (`python3 -m voice2note.run --once`) +# * drives the REAL installed `cricknote` CLI for the vault write + index + list +# * stubs ONLY the expensive upstream tools (ffmpeg / whisper / codex) +# +# SAFETY: everything happens under a single mktemp -d root with a throwaway HOME +# and a throwaway vault. The script NEVER reads or writes the user's real Obsidian +# vault or the real ~/.cricknote — it only references the temp root plus the +# absolute path of the real cricknote binary (captured before HOME is redirected). +# The temp root is removed on success AND failure via a trap. +# +set -euo pipefail + +# --------------------------------------------------------------------------- +# Resolve the project dir (this script lives in /scripts/). +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +PY=/usr/bin/python3 + +fail() { echo "FAIL: $*" >&2; exit 1; } + +# --------------------------------------------------------------------------- +# 5.1 — Capture the REAL cricknote path BEFORE redirecting HOME. +# --------------------------------------------------------------------------- +CRICKNOTE_BIN="$(command -v cricknote || true)" +[ -n "$CRICKNOTE_BIN" ] || fail "cricknote CLI not found on PATH; cannot run integration smoke." +[ -x "$CRICKNOTE_BIN" ] || fail "cricknote at $CRICKNOTE_BIN is not executable." +echo "Real cricknote: $CRICKNOTE_BIN" + +# --------------------------------------------------------------------------- +# 5.1 — One temp root; cleanup trap removes the WHOLE root on EXIT. +# We capture the script's intended exit status explicitly so the trap's own +# `rm` success never masks a failure and turns it into a 0 exit. +# --------------------------------------------------------------------------- +TROOT="$(mktemp -d)" +SMOKE_RC=1 # default to failure; set to 0 only after we explicitly PASS +cleanup() { + rm -rf "$TROOT" 2>/dev/null || true + exit "$SMOKE_RC" +} +trap cleanup EXIT + +# Throwaway HOME + vault + recordings + state/log/lock, all under the temp root. +export HOME="$TROOT/home" +export CRICKNOTE_DATA_DIR="$HOME/.cricknote" +VAULT_DIR="$TROOT/vault" +RECORDINGS_DIR="$TROOT/recordings" +STATE_PATH="$TROOT/state.json" +LOG_PATH="$TROOT/run.log" +LOCK_PATH="$TROOT/voice2note.lock" +STUB_DIR="$TROOT/stubs" +mkdir -p "$HOME" "$CRICKNOTE_DATA_DIR" "$VAULT_DIR" "$RECORDINGS_DIR" "$STUB_DIR" + +# Use the realpath of the temp vault everywhere (macOS /var → /private/var symlink), +# so voice2note's realpath() vault-match assertion holds. +VAULT_REAL="$(cd "$VAULT_DIR" && pwd -P)" +echo "Temp root: $TROOT" +echo "Temp vault: $VAULT_REAL" + +# One config.json serves BOTH readers: voice2note reads it via Path.home(); the +# cricknote CLI honors CRICKNOTE_DATA_DIR (= $HOME/.cricknote, same file). +printf '{"vaultPath": %s}\n' "$($PY -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$VAULT_REAL")" \ + > "$CRICKNOTE_DATA_DIR/config.json" + +# Isolate config loading: an empty real file so the repo's voice2note.env is never read. +CONFIG_FILE="$TROOT/empty.env" +: > "$CONFIG_FILE" + +export CRICKNOTE_DATA_DIR +export VOICE2NOTE_CONFIG="$CONFIG_FILE" +export VOICE2NOTE_USE_CRICKNOTE=true +export VOICE2NOTE_CRICKNOTE_BIN="$CRICKNOTE_BIN" +export VOICE2NOTE_MEMO_RELDIR="Memory/voiceMemo" +export VOICE2NOTE_VAULT="$VAULT_REAL" +export VOICE2NOTE_RECORDINGS="$RECORDINGS_DIR" +export VOICE2NOTE_STATE="$STATE_PATH" +export VOICE2NOTE_LOG="$LOG_PATH" +export VOICE2NOTE_LOCK="$LOCK_PATH" +# A model path that need not exist (whisper is stubbed and ignores it). +export VOICE2NOTE_MODEL="$TROOT/fake-model.bin" +export VOICE2NOTE_LANGUAGE="en" + +# --------------------------------------------------------------------------- +# Assert BEFORE --once that BOTH readers resolve the SAME vault. +# --------------------------------------------------------------------------- +# (a) voice2note's config.cricknote_vault_path() realpath-equals the temp vault. +V2N_VAULT="$(PYTHONPATH="$PROJECT_DIR" $PY -c \ + 'import os; from voice2note import config; print(os.path.realpath(config.cricknote_vault_path()))')" +[ "$V2N_VAULT" = "$VAULT_REAL" ] \ + || fail "voice2note cricknote_vault_path ($V2N_VAULT) != temp vault ($VAULT_REAL)" +echo "voice2note resolves vault: $V2N_VAULT" + +# (b) The cricknote CLI is configured for that same vault. `reindex` prints the +# vault path it is operating on; assert it is our temp vault (proves the CLI is +# reading our temp config, not the real ~/.cricknote). +REINDEX_OUT="$($CRICKNOTE_BIN reindex 2>&1)" || fail "cricknote reindex failed:\n$REINDEX_OUT" +echo "$REINDEX_OUT" | grep -qF "$VAULT_REAL" \ + || fail "cricknote reindex not operating on temp vault. Output:\n$REINDEX_OUT" +echo "cricknote configured for temp vault (confirmed via reindex)." + +# --------------------------------------------------------------------------- +# 5.2 — Seed one non-empty fake .m4a (no .icloud placeholder). +# --------------------------------------------------------------------------- +M4A_NAME="smoke-memo.m4a" +M4A_PATH="$RECORDINGS_DIR/$M4A_NAME" +printf 'fake-audio-bytes-not-real\n' > "$M4A_PATH" +[ -s "$M4A_PATH" ] || fail "seeded .m4a is empty" + +# --------------------------------------------------------------------------- +# 5.2 — Stub ONLY ffmpeg / whisper / codex. Force-selected via absolute paths. +# --------------------------------------------------------------------------- +# ffmpeg: WAV output is the LAST argument; create it (non-empty) and exit 0. +cat > "$STUB_DIR/ffmpeg" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +out="" +for a in "$@"; do out="$a"; done # last arg = wav output +printf 'RIFFfake-wav' > "$out" +exit 0 +STUB + +# whisper-cli: write transcript to .txt (whisper.cpp adds .txt). +cat > "$STUB_DIR/whisper-cli" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +of="" +prev="" +for a in "$@"; do + if [ "$prev" = "-of" ]; then of="$a"; fi + prev="$a" +done +[ -n "$of" ] || { echo "whisper stub: no -of argument" >&2; exit 2; } +printf 'This is a deterministic smoke-test transcript for the integration seam.\n' > "${of}.txt" +exit 0 +STUB + +# codex: write valid analysis JSON to ; record type, confidence 0.9. +cat > "$STUB_DIR/codex" <<'STUB' +#!/usr/bin/env bash +set -euo pipefail +out="" +prev="" +for a in "$@"; do + if [ "$prev" = "-o" ]; then out="$a"; fi + prev="$a" +done +[ -n "$out" ] || { echo "codex stub: no -o argument" >&2; exit 2; } +cat > "$out" <<'JSON' +{ + "summary": "A deterministic smoke-test memo dictated for the integration seam.", + "action_items": ["Verify the draft reaches CrickNote's indexed inbox."], + "topics": ["integration", "smoke-test"], + "notable_details": ["This analysis came from a local stub, not the real model."], + "recording_type": "record", + "confidence": 0.9 +} +JSON +exit 0 +STUB + +chmod +x "$STUB_DIR/ffmpeg" "$STUB_DIR/whisper-cli" "$STUB_DIR/codex" +export VOICE2NOTE_FFMPEG="$STUB_DIR/ffmpeg" +export VOICE2NOTE_WHISPER_CLI="$STUB_DIR/whisper-cli" +export VOICE2NOTE_CODEX="$STUB_DIR/codex" +# Deliberately do NOT stub cricknote — VOICE2NOTE_CRICKNOTE_BIN is the real CLI. + +# --------------------------------------------------------------------------- +# 5.3 — Run the REAL entry point. +# --------------------------------------------------------------------------- +echo "=== voice2note run --once (#1) ===" +( cd "$PROJECT_DIR" && PYTHONPATH="$PROJECT_DIR" $PY -m voice2note.run --once ) \ + || fail "voice2note run --once (#1) exited nonzero" + +# Compute expected source hash = sha256()[:16]. +SRC_HASH="$($PY -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest()[:16])' "$M4A_NAME")" +echo "Expected source hash: $SRC_HASH" + +MEMO_GLOB_DIR="$VAULT_REAL/Memory/voiceMemo" + +# Assert EXACTLY ONE markdown note exists. +count_notes() { + find "$MEMO_GLOB_DIR" -maxdepth 1 -type f -name '*.md' 2>/dev/null | wc -l | tr -d ' ' +} +N="$(count_notes)" +[ "$N" = "1" ] || fail "expected exactly 1 note under $MEMO_GLOB_DIR, found $N" + +NOTE_PATH="$(find "$MEMO_GLOB_DIR" -maxdepth 1 -type f -name '*.md')" +NOTE_BASE="$(basename "$NOTE_PATH")" +NOTE_REL="Memory/voiceMemo/$NOTE_BASE" +echo "Note: $NOTE_REL" + +# Filename must contain the 16-hex source hash. +case "$NOTE_BASE" in + *"$SRC_HASH"*) : ;; + *) fail "note filename $NOTE_BASE does not contain source hash $SRC_HASH" ;; +esac + +# Frontmatter must contain the expected scalars. Parse via the engine's own reader +# (vault_writer.read_frontmatter) so we test exactly what voice2note would read. +PYTHONPATH="$PROJECT_DIR" $PY - "$VAULT_REAL" "$NOTE_REL" "$M4A_NAME" <<'PYEOF' +import sys +from voice2note.vault_writer import read_frontmatter +vault, rel, m4a = sys.argv[1], sys.argv[2], sys.argv[3] +fm = read_frontmatter(vault, rel) +exp = {"source_id": m4a, "recording_type": "record", "analysis": "complete", "status": "draft"} +for k, v in exp.items(): + got = fm.get(k) + if got != v: + sys.exit(f"frontmatter {k}={got!r}, expected {v!r}") +print("Frontmatter OK:", {k: fm.get(k) for k in exp}) +PYEOF +[ $? -eq 0 ] || fail "frontmatter assertions failed" + +# state.json must record the memo id (the .m4a filename). +[ -f "$STATE_PATH" ] || fail "state.json not written" +PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' +import json, sys +state = json.load(open(sys.argv[1], encoding="utf-8")) +mid = sys.argv[2] +if mid not in state.get("memos", {}): + sys.exit(f"state.json memos missing {mid!r}; keys={list(state.get('memos', {}))}") +print("state.json records memo:", mid) +PYEOF +[ $? -eq 0 ] || fail "state.json assertion failed" + +# --------------------------------------------------------------------------- +# 5.3 — Reindex with the REAL CLI, then vault_list and verify the draft is indexed. +# --------------------------------------------------------------------------- +echo "=== cricknote reindex (post-write) ===" +$CRICKNOTE_BIN reindex 2>&1 | sed 's/^/ /' + +echo "=== cricknote vault_list (draft inbox) ===" +LIST_FILE="$TROOT/vault_list.json" +$CRICKNOTE_BIN tool vault_list '{"folder":"Memory/voiceMemo","status":"draft"}' > "$LIST_FILE" 2>"$TROOT/vault_list.err" \ + || { cat "$TROOT/vault_list.err" >&2; fail "cricknote vault_list exited nonzero"; } + +# Parse the ENVELOPE {"ok": true, "result": [...]}; fail closed if ok != true, +# then search result[] for the created note by relpath or basename. +PYTHONPATH="$PROJECT_DIR" $PY - "$LIST_FILE" "$NOTE_REL" "$NOTE_BASE" <<'PYEOF' +import json, os, sys +data = json.load(open(sys.argv[1], encoding="utf-8")) +rel, base = sys.argv[2], sys.argv[3] +if not isinstance(data, dict): + sys.exit(f"vault_list output is not an envelope object: {type(data).__name__}") +if data.get("ok") is not True: + sys.exit(f"vault_list envelope ok is not true: {data.get('ok')!r}") +result = data.get("result") +if not isinstance(result, list): + sys.exit(f"vault_list result is not a list: {type(result).__name__}") +for entry in result: + p = entry.get("path", "") + if p == rel or os.path.basename(p) == base: + print("vault_list contains the draft:", p, "status:", entry.get("status")) + break +else: + sys.exit(f"created note {rel!r} not found in vault_list result ({len(result)} entries)") +PYEOF +[ $? -eq 0 ] || fail "vault_list did not contain the indexed draft" + +# --------------------------------------------------------------------------- +# 5.3 — Run --once AGAIN: must remain idempotent (exactly one note). +# With state.json intact this exercises the state `is_seen` short-circuit. +# --------------------------------------------------------------------------- +echo "=== voice2note run --once (#2, idempotency via state) ===" +( cd "$PROJECT_DIR" && PYTHONPATH="$PROJECT_DIR" $PY -m voice2note.run --once ) \ + || fail "voice2note run --once (#2) exited nonzero" + +N2="$(count_notes)" +[ "$N2" = "1" ] || fail "after second run expected exactly 1 note, found $N2 (not idempotent)" +NOTE_PATH2="$(find "$MEMO_GLOB_DIR" -maxdepth 1 -type f -name '*.md')" +[ "$NOTE_PATH2" = "$NOTE_PATH" ] || fail "second run changed the note path ($NOTE_PATH -> $NOTE_PATH2)" +echo "Idempotent (state short-circuit): still exactly one note ($NOTE_BASE)." + +# --------------------------------------------------------------------------- +# 5.3 — Run --once a THIRD time with state.json removed: the note exists in the +# vault but not in state, so this drives the create-once ADOPT branch +# (note_exists → verify source_id → state.record, never overwrite). +# --------------------------------------------------------------------------- +echo "=== voice2note run --once (#3, create-once adopt path) ===" +rm -f "$STATE_PATH" +( cd "$PROJECT_DIR" && PYTHONPATH="$PROJECT_DIR" $PY -m voice2note.run --once ) \ + || fail "voice2note run --once (#3, adopt) exited nonzero" + +N3="$(count_notes)" +[ "$N3" = "1" ] || fail "after adopt run expected exactly 1 note, found $N3 (adopt overwrote/duplicated)" +NOTE_PATH3="$(find "$MEMO_GLOB_DIR" -maxdepth 1 -type f -name '*.md')" +[ "$NOTE_PATH3" = "$NOTE_PATH" ] || fail "adopt run changed the note path ($NOTE_PATH -> $NOTE_PATH3)" +# State must have been re-recorded by the adopt branch. +PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' +import json, sys +state = json.load(open(sys.argv[1], encoding="utf-8")) +if sys.argv[2] not in state.get("memos", {}): + sys.exit("adopt branch did not re-record memo in state.json") +print("adopt branch re-recorded memo in state.json:", sys.argv[2]) +PYEOF +[ $? -eq 0 ] || fail "adopt-path state assertion failed" +echo "Idempotent (create-once adopt): still exactly one note ($NOTE_BASE)." + +# --------------------------------------------------------------------------- +# Done. Single clear PASS line. Trap removes the temp root. +# --------------------------------------------------------------------------- +echo "PASS: voice2note -> cricknote integration smoke (real process + real CLI; draft indexed; idempotent)." +SMOKE_RC=0 From 87aaa2a946bb3f06c50cc7500268640e8f36815f Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Sun, 21 Jun 2026 12:35:00 +0100 Subject: [PATCH 26/27] test(smoke): fire assertion failure messages; printf in fail() (Task 5 polish) Co-Authored-By: Claude Opus 4.8 --- scripts/smoke-cricknote-integration.sh | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/scripts/smoke-cricknote-integration.sh b/scripts/smoke-cricknote-integration.sh index 9961752..593aaaa 100755 --- a/scripts/smoke-cricknote-integration.sh +++ b/scripts/smoke-cricknote-integration.sh @@ -23,7 +23,7 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd -P)" PY=/usr/bin/python3 -fail() { echo "FAIL: $*" >&2; exit 1; } +fail() { printf '%b\n' "FAIL: $*" >&2; exit 1; } # --------------------------------------------------------------------------- # 5.1 — Capture the REAL cricknote path BEFORE redirecting HOME. @@ -203,7 +203,7 @@ esac # Frontmatter must contain the expected scalars. Parse via the engine's own reader # (vault_writer.read_frontmatter) so we test exactly what voice2note would read. -PYTHONPATH="$PROJECT_DIR" $PY - "$VAULT_REAL" "$NOTE_REL" "$M4A_NAME" <<'PYEOF' +PYTHONPATH="$PROJECT_DIR" $PY - "$VAULT_REAL" "$NOTE_REL" "$M4A_NAME" <<'PYEOF' || fail "frontmatter assertions failed" import sys from voice2note.vault_writer import read_frontmatter vault, rel, m4a = sys.argv[1], sys.argv[2], sys.argv[3] @@ -215,11 +215,10 @@ for k, v in exp.items(): sys.exit(f"frontmatter {k}={got!r}, expected {v!r}") print("Frontmatter OK:", {k: fm.get(k) for k in exp}) PYEOF -[ $? -eq 0 ] || fail "frontmatter assertions failed" # state.json must record the memo id (the .m4a filename). [ -f "$STATE_PATH" ] || fail "state.json not written" -PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' +PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' || fail "state.json assertion failed" import json, sys state = json.load(open(sys.argv[1], encoding="utf-8")) mid = sys.argv[2] @@ -227,7 +226,6 @@ if mid not in state.get("memos", {}): sys.exit(f"state.json memos missing {mid!r}; keys={list(state.get('memos', {}))}") print("state.json records memo:", mid) PYEOF -[ $? -eq 0 ] || fail "state.json assertion failed" # --------------------------------------------------------------------------- # 5.3 — Reindex with the REAL CLI, then vault_list and verify the draft is indexed. @@ -242,7 +240,7 @@ $CRICKNOTE_BIN tool vault_list '{"folder":"Memory/voiceMemo","status":"draft"}' # Parse the ENVELOPE {"ok": true, "result": [...]}; fail closed if ok != true, # then search result[] for the created note by relpath or basename. -PYTHONPATH="$PROJECT_DIR" $PY - "$LIST_FILE" "$NOTE_REL" "$NOTE_BASE" <<'PYEOF' +PYTHONPATH="$PROJECT_DIR" $PY - "$LIST_FILE" "$NOTE_REL" "$NOTE_BASE" <<'PYEOF' || fail "vault_list did not contain the indexed draft" import json, os, sys data = json.load(open(sys.argv[1], encoding="utf-8")) rel, base = sys.argv[2], sys.argv[3] @@ -261,7 +259,6 @@ for entry in result: else: sys.exit(f"created note {rel!r} not found in vault_list result ({len(result)} entries)") PYEOF -[ $? -eq 0 ] || fail "vault_list did not contain the indexed draft" # --------------------------------------------------------------------------- # 5.3 — Run --once AGAIN: must remain idempotent (exactly one note). @@ -292,14 +289,13 @@ N3="$(count_notes)" NOTE_PATH3="$(find "$MEMO_GLOB_DIR" -maxdepth 1 -type f -name '*.md')" [ "$NOTE_PATH3" = "$NOTE_PATH" ] || fail "adopt run changed the note path ($NOTE_PATH -> $NOTE_PATH3)" # State must have been re-recorded by the adopt branch. -PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' +PYTHONPATH="$PROJECT_DIR" $PY - "$STATE_PATH" "$M4A_NAME" <<'PYEOF' || fail "adopt-path state assertion failed" import json, sys state = json.load(open(sys.argv[1], encoding="utf-8")) if sys.argv[2] not in state.get("memos", {}): sys.exit("adopt branch did not re-record memo in state.json") print("adopt branch re-recorded memo in state.json:", sys.argv[2]) PYEOF -[ $? -eq 0 ] || fail "adopt-path state assertion failed" echo "Idempotent (create-once adopt): still exactly one note ($NOTE_BASE)." # --------------------------------------------------------------------------- From fb5493aab6b7926db4a366883f251af7813ef5ea Mon Sep 17 00:00:00 2001 From: hele211 <59284360+hele211@users.noreply.github.com> Date: Mon, 20 Jul 2026 21:30:14 +0100 Subject: [PATCH 27/27] feat: add Apple reminders and calendar automation --- README.md | 37 ++++++++ config.example.env | 13 +++ tests/test_analyze.py | 16 ++++ tests/test_apple_automation.py | 79 ++++++++++++++++ tests/test_notes.py | 10 +++ tests/test_run.py | 31 +++++++ tests/test_state.py | 12 +++ voice2note/analyze.py | 76 +++++++++++++++- voice2note/apple_automation.py | 159 +++++++++++++++++++++++++++++++++ voice2note/config.py | 10 +++ voice2note/notes.py | 7 +- voice2note/run.py | 45 +++++++++- voice2note/state.py | 30 ++++++- 13 files changed, 513 insertions(+), 12 deletions(-) create mode 100644 tests/test_apple_automation.py create mode 100644 voice2note/apple_automation.py diff --git a/README.md b/README.md index 86fdbf7..6a21d98 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ launchd (every ~2h) → python -m voice2note.run → ffmpeg → 16 kHz wav → whisper-cli [local transcription] → codex exec (analysis → JSON) [your Codex login, no API key] → classify type + confidence [complete vs. draft status] + → if you said “CrickNote please”: create a macOS Reminder or Calendar event → write single note (create-once, idempotent) standalone: direct file write into vault cricknote: cricknote tool vault_write [when USE_CRICKNOTE=true] @@ -123,6 +124,9 @@ Set in `voice2note.env` (or as environment variables). Only the vault is require | `VOICE2NOTE_CONFIDENCE_MIN` | `0.5` | Minimum classification confidence; below this the note is downgraded to `draft` | | `VOICE2NOTE_USE_CRICKNOTE` | `false` | Route note writes through the CrickNote CLI instead of a direct file write | | `VOICE2NOTE_CRICKNOTE_BIN` | _(auto-discovered)_ | Absolute path to the `cricknote` binary; defaults to `shutil.which("cricknote")` | +| `VOICE2NOTE_APPLE_AUTOMATION` | `false` | Enable explicitly spoken Apple Reminders/Calendar commands | +| `VOICE2NOTE_APPLE_REMINDERS_LIST` | default list | Optional exact Apple Reminders list name | +| `VOICE2NOTE_APPLE_CALENDAR` | first writable calendar | Optional exact Apple Calendar name | Schedule frequency: `VOICE2NOTE_INTERVAL= ./scripts/install.sh` (default 7200). @@ -159,6 +163,39 @@ VOICE2NOTE_CRICKNOTE_BIN=/usr/local/bin/cricknote # or wherever your binary li Then re-run `--check` to confirm the vault match and binary path are correct before starting the agent. +## Apple Reminders and Calendar commands + +Voice2Note can create items in the built-in **Apple Reminders** and **Apple +Calendar** apps. This is separate from CrickNote's Obsidian task list and is +off by default. To enable it, add this to `voice2note.env`: + +```bash +VOICE2NOTE_APPLE_AUTOMATION=true +# Optional: choose specific destinations instead of the defaults. +# VOICE2NOTE_APPLE_REMINDERS_LIST=Reminders +# VOICE2NOTE_APPLE_CALENDAR=Calendar +``` + +Then speak the deliberate trigger **“CrickNote please”** in a Voice Memo. The +phrase is case-insensitive; “Crick Note please” and the common transcription +mistake “Cricket Note please” work too. Examples: + +- “CrickNote please, remind me to email Sarah tomorrow at 9.” → a Reminder. +- “CrickNote please, meeting with Sarah this Friday at 2 pm for one hour.” → a + Calendar event. + +The analysis uses the meaning of the memo: tasks and follow-ups become +Reminders; appointments and meetings with a clear date and time become Calendar +events. It will not invent a time. A task without a stated time is still added +to Reminders, but without a timed notification. A date without a time is also a +Reminder, rather than an all-day Calendar event. + +The first command causes macOS to ask for permission to control Reminders or +Calendar. Allow it. Each memo's created Apple item ID is saved before Voice2Note +marks the memo finished, so if the vault write is retried it will not create a +duplicate Apple item. The corresponding voice-memo note also shows the item +created under **Apple action**. + ## Troubleshooting - **`--check` says DENIED even after granting access.** The grant must be on the diff --git a/config.example.env b/config.example.env index f65b528..62352bb 100644 --- a/config.example.env +++ b/config.example.env @@ -36,3 +36,16 @@ VOICE2NOTE_VAULT=/Users/you/path/to/ObsidianVault # Codex model used for analysis: # VOICE2NOTE_CODEX_MODEL=gpt-5.5 + +# --- Apple Reminders and Calendar (optional) --- +# Enable this only if you want spoken "CrickNote please" commands to create items +# in the built-in macOS Reminders and Calendar apps. Normal recordings are never +# sent to either app. On the first command, macOS asks permission for Python to +# control each app. +# VOICE2NOTE_APPLE_AUTOMATION=true + +# Optional destination names. Leave either blank to use your default Reminders +# list or the first writable Calendar. Names must match exactly what you see in +# the Apple apps. +# VOICE2NOTE_APPLE_REMINDERS_LIST=Reminders +# VOICE2NOTE_APPLE_CALENDAR=Calendar diff --git a/tests/test_analyze.py b/tests/test_analyze.py index d5f713e..570ae4b 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -8,6 +8,14 @@ class TestAnalyze(unittest.TestCase): def test_build_prompt_includes_transcript(self): self.assertIn("buy milk", analyze.build_prompt("buy milk")) + def test_apple_prompt_adds_current_time_only_when_requested(self): + plain = analyze.build_prompt("buy milk") + automated = analyze.build_prompt("buy milk", apple_automation_requested=True, + current_time="2026-07-20T09:00+01:00") + self.assertNotIn('"apple_action"', plain) + self.assertIn('"apple_action"', automated) + self.assertIn("2026-07-20T09:00+01:00", automated) + def test_parse_analysis_with_code_fence(self): text = '```json\n{"summary":"s","action_items":["a"],"topics":["t"],"notable_details":["n"]}\n```' out = analyze.parse_analysis(text) @@ -20,6 +28,14 @@ def test_parse_analysis_missing_keys_default_empty(self): out = analyze.parse_analysis('{"summary":"only"}') self.assertEqual(out["summary"], "only") self.assertEqual(out["action_items"], []) + self.assertEqual(out["apple_action"]["kind"], "none") + + def test_parse_analysis_extracts_apple_action(self): + out = analyze.parse_analysis( + '{"summary":"s", "apple_action":{"kind":"calendar",' + '"title":"Team meeting", "start_at":"2026-07-21T10:00"}}') + self.assertEqual(out["apple_action"]["kind"], "calendar") + self.assertEqual(out["apple_action"]["title"], "Team meeting") def test_parse_analysis_no_json_raises(self): with self.assertRaises(ValueError): diff --git a/tests/test_apple_automation.py b/tests/test_apple_automation.py new file mode 100644 index 0000000..32e5cc6 --- /dev/null +++ b/tests/test_apple_automation.py @@ -0,0 +1,79 @@ +import json +import unittest +from types import SimpleNamespace + +from voice2note.apple_automation import ( + AppleAutomationError, + CALENDAR_SCRIPT, + REMINDERS_SCRIPT, + create_apple_item, + has_cricknote_request, + prepare_action, +) + + +class TestTrigger(unittest.TestCase): + def test_accepts_spoken_variants(self): + self.assertTrue(has_cricknote_request("CrickNote please, call Mum.")) + self.assertTrue(has_cricknote_request("crick note please call Mum")) + self.assertTrue(has_cricknote_request("Cricket Note, please: call Mum")) + + def test_requires_deliberate_phrase(self): + self.assertFalse(has_cricknote_request("Please call Mum.")) + self.assertFalse(has_cricknote_request("CrickNote call Mum.")) + + +class TestPrepareAction(unittest.TestCase): + def test_reminder_without_time_is_valid(self): + kind, payload = prepare_action( + {"kind": "reminder", "title": "Call Mum", "notes": "Tomorrow"}, "memo.m4a") + self.assertEqual(kind, "reminder") + self.assertIsNone(payload["remind_at"]) + self.assertIn("memo.m4a", payload["notes"]) + + def test_calendar_adds_one_hour_when_end_missing(self): + kind, payload = prepare_action( + {"kind": "calendar", "title": "Team meeting", + "start_at": "2026-07-21T10:00", "end_at": None}, "memo.m4a") + self.assertEqual(kind, "calendar") + self.assertIn("T10:00", payload["start_at"]) + self.assertIn("T11:00", payload["end_at"]) + + def test_calendar_rejects_date_without_time(self): + with self.assertRaises(AppleAutomationError): + prepare_action({"kind": "calendar", "title": "Team meeting", + "start_at": "2026-07-21"}, "memo.m4a") + + +class TestCreateAppleItem(unittest.TestCase): + def _runner(self, item_id): + def runner(command, **kwargs): + self.command = command + return SimpleNamespace(returncode=0, stdout=json.dumps({"id": item_id}), stderr="") + return runner + + def test_creates_reminder_with_json_payload(self): + runner = self._runner("reminder-id") + result = create_apple_item( + {"kind": "reminder", "title": 'Call "Mum"', + "remind_at": "2026-07-21T09:30", "notes": "Use the \"new\" number."}, + "memo.m4a", reminders_list="Personal", runner=runner) + self.assertEqual(result, {"kind": "reminder", "id": "reminder-id", "title": 'Call "Mum"'}) + self.assertIn(REMINDERS_SCRIPT, self.command) + payload = json.loads(self.command[-1]) + self.assertEqual(payload["list"], "Personal") + self.assertIn('Call "Mum"', payload["title"]) + + def test_creates_calendar_event(self): + runner = self._runner("calendar-id") + result = create_apple_item( + {"kind": "calendar", "title": "Team meeting", + "start_at": "2026-07-21T10:00", "end_at": "2026-07-21T11:30"}, + "memo.m4a", calendar_name="Work", runner=runner) + self.assertEqual(result["kind"], "calendar") + self.assertEqual(result["id"], "calendar-id") + self.assertIn(CALENDAR_SCRIPT, self.command) + self.assertEqual(json.loads(self.command[-1])["calendar"], "Work") + + def test_empty_model_decision_creates_nothing(self): + self.assertIsNone(create_apple_item({"kind": "none"}, "memo.m4a")) diff --git a/tests/test_notes.py b/tests/test_notes.py index fec8c49..c52a0b6 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -73,6 +73,16 @@ def test_transcript_only_pending(self): self.assertIn("analysis: pending", out) self.assertIn("just the words", out) + def test_apple_item_is_visible_in_note(self): + out = render_memo_note(memo(), ANALYSIS, "t", + recording_type="record", confidence=0.9, + status="draft", analysis_status="complete", + include_todos=True, + apple_item={"kind": "reminder", "id": "r1", "title": "Call Mum"}) + self.assertIn("apple_action: reminder", out) + self.assertIn("## Apple action", out) + self.assertIn("Created reminder: Call Mum", out) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_run.py b/tests/test_run.py index e845035..fd610fb 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -15,6 +15,8 @@ def make_cfg(vault, use_cricknote=False): VAULT=Path(vault), MEMO_RELDIR="Memory/voiceMemo", USE_CRICKNOTE=use_cricknote, CRICKNOTE_BIN="cricknote", CONFIDENCE_MIN=0.5, + APPLE_AUTOMATION_ENABLED=False, APPLE_REMINDERS_LIST="", + APPLE_CALENDAR="", OSASCRIPT_BIN="/usr/bin/osascript", BRCTL_BIN="brctl", WHISPER_MODEL_PATH="model.bin", WHISPER_CLI_BIN="whisper-cli", FFMPEG_BIN="ffmpeg", WHISPER_LANGUAGE="auto", CODEX_BIN="codex", CODEX_MODEL="gpt-5.5") @@ -30,6 +32,8 @@ def setUp(self): self.vault = tempfile.mkdtemp() self.state = State(Path(tempfile.mkdtemp()) / "state.json") self.cfg = make_cfg(self.vault) + original_create_apple_item = runmod.create_apple_item + self.addCleanup(setattr, runmod, "create_apple_item", original_create_apple_item) # Stub the heavy steps. runmod.materialize = lambda *a, **k: True runmod.transcribe = lambda *a, **k: "the transcript" @@ -79,6 +83,33 @@ def test_analysis_failure_writes_transcript_only_draft(self): self.assertIn("the transcript", out) self.assertTrue(self.state.is_seen("a.m4a")) # recorded → not retried/overwritten + def test_triggered_apple_action_is_created_and_saved(self): + self.cfg.APPLE_AUTOMATION_ENABLED = True + runmod.transcribe = lambda *a, **k: "CrickNote please, remind me to call Mum." + runmod.analyze = lambda *a, **k: { + "summary": "s", "action_items": [], "topics": ["t"], "notable_details": [], + "recording_type": "record", "confidence": 0.9, + "apple_action": {"kind": "reminder", "title": "Call Mum", "remind_at": None}} + calls = [] + runmod.create_apple_item = lambda *a, **k: calls.append((a, k)) or { + "kind": "reminder", "id": "r1", "title": "Call Mum"} + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(len(calls), 1) + self.assertEqual(self.state.automation_for("a.m4a")["id"], "r1") + rel = memo_note_relpath("Memory/voiceMemo", memo().recorded_at, "Idea", "a.m4a") + self.assertIn("Created reminder: Call Mum", + (Path(self.vault) / rel).read_text(encoding="utf-8")) + + def test_retry_after_apple_item_does_not_create_duplicate(self): + self.cfg.APPLE_AUTOMATION_ENABLED = True + self.state.record_automation("a.m4a", {"kind": "reminder", "id": "r1", "title": "Call Mum"}) + calls = [] + runmod.transcribe = lambda *a, **k: "CrickNote please, remind me to call Mum." + runmod.create_apple_item = lambda *a, **k: calls.append(True) + runmod.process_memo(memo(), self.state, self.cfg) + self.assertEqual(calls, []) + self.assertTrue(self.state.is_seen("a.m4a")) + class TestCheckGating(unittest.TestCase): def test_standalone_check_does_not_require_cricknote_config(self): diff --git a/tests/test_state.py b/tests/test_state.py index ec04b3b..0714cde 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -34,6 +34,18 @@ def test_save_roundtrip(self): self.assertTrue(again.is_seen("a.m4a")) self.assertEqual(again.last_run, "2026-06-18T14:00:00") + def test_pending_automation_is_retried_for_note_but_item_is_preserved(self): + s = State(self.path) + item = {"kind": "reminder", "id": "r-1", "title": "Call Mum"} + s.record_automation("a.m4a", item) + s.save() + again = State(self.path) + self.assertFalse(again.is_seen("a.m4a")) + self.assertEqual(again.automation_for("a.m4a"), item) + again.record("a.m4a", "Memory/voiceMemo/x.md", "complete", "record") + self.assertTrue(again.is_seen("a.m4a")) + self.assertEqual(again.automation_for("a.m4a"), item) + def test_save_is_atomic_on_failure(self): self.path.write_text(json.dumps({"memos": {"old.m4a": {"analysis": "complete"}}}), encoding="utf-8") diff --git a/voice2note/analyze.py b/voice2note/analyze.py index 113a887..2a09961 100644 --- a/voice2note/analyze.py +++ b/voice2note/analyze.py @@ -9,6 +9,7 @@ import os import subprocess import tempfile +from datetime import datetime from voice2note.routing import VALID_TYPES @@ -30,6 +31,29 @@ \"\"\" """ +APPLE_ACTION_PROMPT = """ + +This recording contains the spoken command "CrickNote please". Add exactly one +additional key: +- "apple_action": an object with "kind", "title", "remind_at", "start_at", + "end_at", and "notes". + +Choose "kind": +- "reminder" for a task, follow-up, or thing to do. Set "remind_at" to an ISO + date and time only when the speaker stated a time; otherwise use null. +- "calendar" only for an appointment, meeting, or event with a clear date AND + time. Use ISO date-times for "start_at" and (when stated) "end_at". If no end + time was stated, use null; the app will make it one hour long. +- "none" if there is no clear request to create either item. Set every other + field to null in that case. + +Never invent a date, time, title, or duration. If a date is stated without a +time, use a reminder rather than a calendar event. The control phrase itself is +not part of the memo's subject. + +Current local date and time: {current_time} +""" + def coerce_confidence(value): try: @@ -39,8 +63,12 @@ def coerce_confidence(value): return min(1.0, max(0.0, f)) -def build_prompt(transcript): - return PROMPT.replace("{transcript}", transcript or "") +def build_prompt(transcript, apple_automation_requested=False, current_time=None): + prompt = PROMPT.replace("{transcript}", transcript or "") + if apple_automation_requested: + now = current_time or datetime.now().astimezone().isoformat(timespec="minutes") + prompt += APPLE_ACTION_PROMPT.replace("{current_time}", str(now)) + return prompt def parse_analysis(text): @@ -63,6 +91,44 @@ def as_list(key): "notable_details": as_list("notable_details"), "recording_type": rtype, "confidence": coerce_confidence(data.get("confidence")), + "apple_action": parse_apple_action(data.get("apple_action")), + } + + +def parse_apple_action(value): + """Return a small, predictable action object from model output. + + The operating-system integration validates date-times again before creating + anything. This parser deliberately treats malformed or incomplete model + output as "none", rather than guessing what the speaker intended. + """ + empty = { + "kind": "none", "title": None, "remind_at": None, + "start_at": None, "end_at": None, "notes": None, + } + if not isinstance(value, dict): + return empty + kind = str(value.get("kind", "none")).strip().lower() + if kind not in ("none", "reminder", "calendar"): + return empty + + def optional_text(key, max_length=2000): + item = value.get(key) + if item is None: + return None + text = str(item).strip() + return text[:max_length] if text else None + + title = optional_text("title", 300) + if kind != "none" and not title: + return empty + return { + "kind": kind, + "title": title, + "remind_at": optional_text("remind_at", 80), + "start_at": optional_text("start_at", 80), + "end_at": optional_text("end_at", 80), + "notes": optional_text("notes", 4000), } @@ -82,12 +148,14 @@ def build_codex_cmd(prompt, out_path, codex_bin="codex", model=DEFAULT_MODEL, ] -def analyze(transcript, codex_bin="codex", model=None, runner=subprocess.run): +def analyze(transcript, codex_bin="codex", model=None, runner=subprocess.run, + apple_automation_requested=False, current_time=None): model = model or DEFAULT_MODEL fd, out_path = tempfile.mkstemp(suffix=".json") os.close(fd) try: - cmd = build_codex_cmd(build_prompt(transcript), out_path, codex_bin, model) + prompt = build_prompt(transcript, apple_automation_requested, current_time) + cmd = build_codex_cmd(prompt, out_path, codex_bin, model) # stdin=DEVNULL is essential: `codex exec` reads piped stdin if present, so # without a TTY (detached / launchd) it would block forever waiting for EOF. proc = runner(cmd, capture_output=True, text=True, errors="replace", timeout=300, diff --git a/voice2note/apple_automation.py b/voice2note/apple_automation.py new file mode 100644 index 0000000..079641c --- /dev/null +++ b/voice2note/apple_automation.py @@ -0,0 +1,159 @@ +"""Create explicitly requested items in the macOS Reminders and Calendar apps. + +This module is intentionally small and only talks to Apple applications through +the system ``osascript`` program. It has no network dependency and receives all +user-supplied text as JSON command-line data, never as executable script text. +""" +import json +import re +import subprocess +from datetime import datetime, timedelta + + +# Whisper commonly inserts a space in "CrickNote". Accept that and the likely +# "Cricket Note" mis-transcription, but keep "please" as the deliberate trigger. +CRICKNOTE_REQUEST = re.compile(r"\b(?:crick|cricket)\s*note\s*,?\s*please\b", re.IGNORECASE) + + +REMINDERS_SCRIPT = r'''function run(argv) { + const data = JSON.parse(argv[0]); + const app = Application("Reminders"); + const list = data.list ? app.lists.byName(data.list) : app.defaultList(); + if (!list.exists()) { + throw new Error("Reminders list not found: " + data.list); + } + const item = app.Reminder({name: data.title, body: data.notes || ""}); + list.reminders.push(item); + if (data.remind_at) { + item.remindMeDate = new Date(data.remind_at); + } + return JSON.stringify({id: item.id()}); +}''' + + +CALENDAR_SCRIPT = r'''function run(argv) { + const data = JSON.parse(argv[0]); + const app = Application("Calendar"); + let calendar; + if (data.calendar) { + calendar = app.calendars.byName(data.calendar); + } else { + const writable = app.calendars.whose({writable: true})(); + if (!writable.length) { + throw new Error("No writable Calendar was found."); + } + calendar = writable[0]; + } + if (!calendar.exists()) { + throw new Error("Calendar not found: " + data.calendar); + } + const item = app.Event({ + summary: data.title, + startDate: new Date(data.start_at), + endDate: new Date(data.end_at), + description: data.notes || "" + }); + calendar.events.push(item); + return JSON.stringify({id: item.uid()}); +}''' + + +class AppleAutomationError(RuntimeError): + """The user-requested item could not be created in a macOS app.""" + + +def has_cricknote_request(transcript): + """Whether the transcript contains the explicit Apple-action trigger.""" + return bool(CRICKNOTE_REQUEST.search(transcript or "")) + + +def _as_local_iso(value, field, allow_none=True): + """Validate an ISO date-time and return one with the local UTC offset.""" + if value is None or not str(value).strip(): + if allow_none: + return None + raise AppleAutomationError(f"Apple action is missing {field}.") + text = str(value).strip() + # A date alone has no notification/event time. It is acceptable for a + # reminder with no alert, but never enough to create a Calendar event. + if "T" not in text and " " not in text: + if allow_none: + return None + raise AppleAutomationError(f"Calendar action needs a date and time for {field}.") + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise AppleAutomationError(f"Apple action has an invalid {field}: {text!r}") from exc + if parsed.tzinfo is None: + parsed = parsed.astimezone() + return parsed.isoformat(timespec="minutes") + + +def _memo_notes(notes, source_id): + prefix = f"Created by Voice2Note from Voice Memo {source_id}." + return prefix if not notes else f"{prefix}\n\n{notes}" + + +def prepare_action(action, source_id): + """Validate a parsed model action and make a payload for AppleScript.""" + if not isinstance(action, dict): + return None + kind = str(action.get("kind", "none")).lower() + if kind == "none": + return None + title = str(action.get("title") or "").strip() + if kind not in ("reminder", "calendar") or not title: + raise AppleAutomationError("Apple action is missing a valid type or title.") + + payload = { + "title": title[:300], + "notes": _memo_notes(str(action.get("notes") or "").strip(), source_id), + } + if kind == "reminder": + payload["remind_at"] = _as_local_iso(action.get("remind_at"), "remind_at") + else: + start = _as_local_iso(action.get("start_at"), "start_at", allow_none=False) + end = _as_local_iso(action.get("end_at"), "end_at") + start_dt = datetime.fromisoformat(start) + end_dt = datetime.fromisoformat(end) if end else start_dt + timedelta(hours=1) + if end_dt <= start_dt: + raise AppleAutomationError("Calendar action ends before it starts.") + payload["start_at"] = start_dt.isoformat(timespec="minutes") + payload["end_at"] = end_dt.isoformat(timespec="minutes") + return kind, payload + + +def _run_script(script, payload, osascript_bin="/usr/bin/osascript", runner=subprocess.run): + """Run static JXA and return its JSON response; user text stays in ``payload``.""" + command = [osascript_bin, "-l", "JavaScript", "-e", script, "--", json.dumps(payload)] + try: + proc = runner(command, capture_output=True, text=True, errors="replace", timeout=30) + except OSError as exc: + raise AppleAutomationError(f"Could not run osascript: {exc}") from exc + if proc.returncode != 0: + detail = (proc.stderr or proc.stdout or "unknown error").strip() + raise AppleAutomationError(f"macOS Apple action failed: {detail[:500]}") + try: + response = json.loads((proc.stdout or "").strip()) + except json.JSONDecodeError as exc: + raise AppleAutomationError("macOS Apple action returned an invalid response.") from exc + item_id = str(response.get("id") or "").strip() if isinstance(response, dict) else "" + if not item_id: + raise AppleAutomationError("macOS Apple action did not return an item ID.") + return item_id + + +def create_apple_item(action, source_id, *, reminders_list=None, calendar_name=None, + osascript_bin="/usr/bin/osascript", runner=subprocess.run): + """Create the requested Apple item and return durable, human-readable metadata.""" + prepared = prepare_action(action, source_id) + if prepared is None: + return None + kind, payload = prepared + if kind == "reminder": + payload["list"] = reminders_list or None + item_id = _run_script(REMINDERS_SCRIPT, payload, osascript_bin, runner) + else: + payload["calendar"] = calendar_name or None + item_id = _run_script(CALENDAR_SCRIPT, payload, osascript_bin, runner) + return {"kind": kind, "id": item_id, "title": payload["title"]} diff --git a/voice2note/config.py b/voice2note/config.py index dfd78fa..6af65a8 100644 --- a/voice2note/config.py +++ b/voice2note/config.py @@ -71,6 +71,16 @@ def env_bool(name, default=False): return v.strip().lower() in ("1", "true", "yes", "on") +# --- Optional, explicitly-triggered macOS Apple app automation. --- +# Disabled by default so upgrading voice2note never creates external items until +# the owner enables it in voice2note.env. The phrase "CrickNote please" is a +# second gate before either app is contacted. +APPLE_AUTOMATION_ENABLED = env_bool("VOICE2NOTE_APPLE_AUTOMATION", False) +APPLE_REMINDERS_LIST = os.environ.get("VOICE2NOTE_APPLE_REMINDERS_LIST", "").strip() +APPLE_CALENDAR = os.environ.get("VOICE2NOTE_APPLE_CALENDAR", "").strip() +OSASCRIPT_BIN = os.environ.get("VOICE2NOTE_OSASCRIPT", "/usr/bin/osascript") + + def validate_memo_reldir(reldir): p = PurePosixPath(reldir) if p.is_absolute() or ".." in p.parts: diff --git a/voice2note/notes.py b/voice2note/notes.py index 20d5454..b7775a3 100644 --- a/voice2note/notes.py +++ b/voice2note/notes.py @@ -23,7 +23,7 @@ def memo_note_relpath(reldir, recorded_at, title, source_id): def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, - status, analysis_status, include_todos): + status, analysis_status, include_todos, apple_item=None): date = memo.recorded_at.strftime("%Y-%m-%d") topics = analysis.get("topics", []) fm = [ @@ -41,6 +41,7 @@ def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, f"title: {dq(memo.title)}", f'recorded: "{memo.recorded_at.isoformat(timespec="minutes")}"', f"tags: {seq(['voice-memo'] + list(topics))}", + f"apple_action: {apple_item.get('kind') if apple_item else 'none'}", "---", ] body = [f"# {memo.title}", "", "## Summary", analysis.get("summary") or "—"] @@ -51,6 +52,10 @@ def render_memo_note(memo, analysis, transcript, *, recording_type, confidence, body += ["", "## Topics", topic_line, "", "## Notable Details"] details = analysis.get("notable_details") or [] body += [f"- {d}" for d in details] if details else ["- (none)"] + if apple_item: + kind = apple_item.get("kind", "item") + title = apple_item.get("title", "") + body += ["", "## Apple action", f"- Created {kind}: {title}"] body += ["", "## Transcript", "> [!quote]- Full transcript"] body += [f"> {line}" for line in (transcript or "").splitlines() or [""]] body.append("") diff --git a/voice2note/run.py b/voice2note/run.py index 8f4a1f2..e26cf85 100644 --- a/voice2note/run.py +++ b/voice2note/run.py @@ -15,6 +15,7 @@ from voice2note.routing import effective_type, route_for from voice2note.vault_writer import write_note, note_exists, read_frontmatter from voice2note.lock import SingleInstanceLock, AlreadyRunning +from voice2note.apple_automation import has_cricknote_request, create_apple_item log = logging.getLogger("voice2note") @@ -43,10 +44,24 @@ def assert_cricknote_ready(cfg): "path, or set VOICE2NOTE_USE_CRICKNOTE=false.") +def assert_apple_automation_ready(cfg): + """Fail early if the optional Apple-app integration cannot run at all.""" + if not cfg.APPLE_AUTOMATION_ENABLED: + return + if sys.platform != "darwin": + raise SystemExit("VOICE2NOTE_APPLE_AUTOMATION=true requires macOS.") + binary = cfg.OSASCRIPT_BIN + if shutil.which(binary) is None and not (os.path.isabs(binary) and os.access(binary, os.X_OK)): + raise SystemExit( + "VOICE2NOTE_APPLE_AUTOMATION=true but osascript is not runnable: " + f"{binary!r}.") + + def vault_check_ok(cfg): """True if CrickNote routing is fully configured (or standalone). Raises SystemExit when USE_CRICKNOTE but the vault config or CLI is not ready.""" assert_cricknote_ready(cfg) + assert_apple_automation_ready(cfg) return True @@ -102,13 +117,14 @@ def process_memo(memo, state, cfg): whisper_bin=cfg.WHISPER_CLI_BIN, ffmpeg_bin=cfg.FFMPEG_BIN, language=cfg.WHISPER_LANGUAGE) + apple_requested = has_cricknote_request(transcript) + apple_item = state.automation_for(memo.id) try: - analysis = analyze(transcript, codex_bin=cfg.CODEX_BIN, model=cfg.CODEX_MODEL) + analysis = analyze(transcript, codex_bin=cfg.CODEX_BIN, model=cfg.CODEX_MODEL, + apple_automation_requested=apple_requested, + current_time=datetime.now().astimezone().isoformat(timespec="minutes")) rtype = effective_type(analysis["recording_type"], analysis["confidence"], cfg.CONFIDENCE_MIN) status, include_todos = route_for(rtype) - content = render_memo_note(memo, analysis, transcript, recording_type=rtype, - confidence=analysis["confidence"], status=status, - analysis_status="complete", include_todos=include_todos) analysis_status = "complete" except Exception as exc: log.warning("Analysis failed for %s (%s); writing transcript-only draft.", memo.id, exc) @@ -117,6 +133,26 @@ def process_memo(memo, state, cfg): confidence=0.0, status="draft", analysis_status="pending", include_todos=False) rtype, analysis_status = "record", "pending" + analysis = empty + + # The trigger and environment setting are two independent gates. Persist a + # successful Apple creation before the vault write; that way a write retry + # cannot create a duplicate reminder/event. + if apple_requested and cfg.APPLE_AUTOMATION_ENABLED and not apple_item: + apple_item = create_apple_item( + analysis.get("apple_action"), memo.id, + reminders_list=cfg.APPLE_REMINDERS_LIST, + calendar_name=cfg.APPLE_CALENDAR, + osascript_bin=cfg.OSASCRIPT_BIN) + if apple_item: + state.record_automation(memo.id, apple_item) + state.save() + + if analysis_status == "complete": + content = render_memo_note(memo, analysis, transcript, recording_type=rtype, + confidence=analysis["confidence"], status=status, + analysis_status="complete", include_todos=include_todos, + apple_item=apple_item) write_memo(cfg, rel, content) state.record(memo.id, rel, analysis_status, rtype) @@ -129,6 +165,7 @@ def run(once=False): log.error("VOICE2NOTE_VAULT is not set. See README.") return assert_cricknote_ready(cfg) + assert_apple_automation_ready(cfg) try: with SingleInstanceLock(config.LOCK_PATH): _run_locked(once) diff --git a/voice2note/state.py b/voice2note/state.py index 3942df4..a6aa2ed 100644 --- a/voice2note/state.py +++ b/voice2note/state.py @@ -21,15 +21,39 @@ def __init__(self, path): self.last_run = data.get("last_run") def is_seen(self, source_id): - return source_id in self.memos + # An Apple item can be written before its vault note. Such entries are + # deliberately retried for the note write, but must not create the Apple + # item a second time. Old/migrated entries remain seen as before. + return source_id in self.memos and not self.memos[source_id].get("automation_pending") def record(self, source_id, note_rel, analysis, recording_type): - self.memos[source_id] = { + entry = dict(self.memos.get(source_id, {})) + entry.update({ "note_rel": note_rel, "analysis": analysis, "recording_type": recording_type, "updated": datetime.now().isoformat(timespec="seconds"), - } + }) + entry.pop("automation_pending", None) + self.memos[source_id] = entry + + def automation_for(self, source_id): + """Return a previously-created Apple item, if this memo has one.""" + item = self.memos.get(source_id, {}).get("apple_item") + return dict(item) if isinstance(item, dict) else None + + def record_automation(self, source_id, item): + """Persist an external Apple-item creation before writing the note. + + This is the durable half of the retry protocol: after a later vault + write failure, the next run sees this record and skips the external + creation call. + """ + entry = dict(self.memos.get(source_id, {})) + entry["apple_item"] = dict(item) + entry["automation_pending"] = True + entry["updated"] = datetime.now().isoformat(timespec="seconds") + self.memos[source_id] = entry def save(self, last_run=None): if last_run is not None: