diff --git a/README.md b/README.md index 521446d..6a21d98 100644 --- a/README.md +++ b/README.md @@ -10,20 +10,35 @@ 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] + → 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] + → human triage: ask an agent to run + cricknote-voice-inbox [routing is always human-confirmed] → record processed id (no duplicates) ``` @@ -37,6 +52,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 +76,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 +117,85 @@ 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")` | +| `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). +## 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. + +## 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 @@ -106,6 +209,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 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/config.example.env b/config.example.env index 07a992f..62352bb 100644 --- a/config.example.env +++ b/config.example.env @@ -1,13 +1,33 @@ -# 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 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): # VOICE2NOTE_MODEL=/absolute/path/to/ggml-small.bin @@ -16,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/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. 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..c4577b1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-voice2note-cricknote-phase3.md @@ -0,0 +1,596 @@ +# 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. +> +> **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 +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. +- [ ] 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. + +#### 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. + +**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 +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, **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 **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. + +#### 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 + +- [ ] 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_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 (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 ` + (`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`. 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 + 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: + +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)" +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)" +trap 'rm -f "$CHECK_LOG" "$EMPTY_CFG" "$BEFORE" "$AFTER"' EXIT +MEMO_DIR="$REAL_VAULT/Memory/voiceMemo" + +# 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 || 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: + +- [ ] 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. 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..6b68791 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-voice2note-cricknote-integration-design.md @@ -0,0 +1,388 @@ +# voice2note → CrickNote Integration: classify recordings and route by type + +**Date:** 2026-06-18 +**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 +> `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 + +- **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 + 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. +- **r2** — hardened against data-loss modes (write-once, resumable routing, + provisional classification, mandatory reindex, etc.). +- **r1** — initial two-stage design. + +## 1. Goal + +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**, 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 (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. The handoff is the CrickNote CLI. +- **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 automatic re-analysis of transcript-only notes by voice2note (§5, §7). +- No speaker diarization in v1 (§5). + +## 3. Context (verified against code) + +**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` 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: + +- 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 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`) 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. 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 + (`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), 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) + analysis failed → transcript-only, status: draft, analysis: pending + + ── seam: notes in Memory/voiceMemo/, keyed by source_id ── + +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; 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 + 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) + cricknote-daily-review: surfaces "N voice records pending triage (50+ if capped)" +``` + +**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 (after verifying `source_id`) 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 is imperfect — therefore: + +- 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**. +- **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 `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 +--- +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: "" # YAML-escaped (§7) +recorded: 2026-06-18T14:32 +tags: [voice-memo, ] # each tag YAML-escaped; example shown unquoted for readability +--- +``` + +- 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. + +Body: `## Summary`, optional `## Possible to-dos` (plain bullets — **never** dated +`- [ ]` checkboxes), `## Topics`, `## Notable Details`, `## Transcript` +(collapsible). conversation/talk omit the to-dos section. + +## 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`:** + +``` +memos[source_id] = { note_rel, analysis: "complete" | "pending", recording_type, updated } +``` +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 (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 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`). + - 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. + +**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 §6 frontmatter via a **stdlib YAML double-quoted-scalar + escaper** (`title`, each `tag`; escape `\`, `"`, control chars, collapse + 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 **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 — 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) + +- **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 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 ``. 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 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. + 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 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. + +## 9. Risks & mitigations + +| 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 (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 — `` (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) | 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 | + +## 10. Configuration (new voice2note env) + +| Variable | Default | Meaning | +|---|---|---| +| `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` | + +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 + +- **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 → + re-parse to original values. + - **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`: 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 (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:** 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 (gated on `USE_CRICKNOTE`); drop checkboxes + daily block. unittest + throughout. +2. **CrickNote:** `cricknote-voice-inbox` skill (mandatory reindex, multi-route, + 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 + +- None blocking. KB mapping stays out of the voice path by decision (§2). +- 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). 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/scripts/smoke-cricknote-integration.sh b/scripts/smoke-cricknote-integration.sh new file mode 100755 index 0000000..593aaaa --- /dev/null +++ b/scripts/smoke-cricknote-integration.sh @@ -0,0 +1,305 @@ +#!/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() { printf '%b\n' "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' || 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] +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 + +# 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' || fail "state.json assertion failed" +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 + +# --------------------------------------------------------------------------- +# 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' || 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] +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 + +# --------------------------------------------------------------------------- +# 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' || 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 +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 diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 90adc56..570ae4b 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -1,12 +1,21 @@ import unittest from types import SimpleNamespace from voice2note import analyze +from voice2note.analyze import coerce_confidence 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) @@ -19,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): @@ -53,5 +70,47 @@ 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) + + 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): + 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/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_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/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() 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/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_notes.py b/tests/test_notes.py index f9f510c..c52a0b6 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -1,69 +1,87 @@ +# 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) + + 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__": 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/tests/test_run.py b/tests/test_run.py index d1def6f..fd610fb 100644 --- a/tests/test_run.py +++ b/tests/test_run.py @@ -1,38 +1,140 @@ -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, + 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") + + +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) + 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" + 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) + 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): + # 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 + +class TestCricknoteReadiness(unittest.TestCase): + def test_standalone_is_noop(self): + runmod.assert_cricknote_ready(SimpleNamespace(USE_CRICKNOTE=False)) -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")) + 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__": diff --git a/tests/test_state.py b/tests/test_state.py index c038a15..0714cde 100644 --- a/tests/test_state.py +++ b/tests/test_state.py @@ -1,27 +1,71 @@ -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_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") + 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/tests/test_vault_writer.py b/tests/test_vault_writer.py new file mode 100644 index 0000000..023f09d --- /dev/null +++ b/tests/test_vault_writer.py @@ -0,0 +1,75 @@ +# 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") + + +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/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/analyze.py b/voice2note/analyze.py index a66c956..2a09961 100644 --- a/voice2note/analyze.py +++ b/voice2note/analyze.py @@ -9,6 +9,9 @@ import os import subprocess import tempfile +from datetime import datetime + +from voice2note.routing import VALID_TYPES DEFAULT_MODEL = "gpt-5.5" DEFAULT_REASONING = "low" @@ -19,6 +22,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: \"\"\" @@ -26,9 +31,44 @@ \"\"\" """ +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: + 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 "") +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): @@ -40,11 +80,55 @@ 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")), + "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), } @@ -64,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 5dff6c8..6af65a8 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 @@ -37,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")) @@ -63,3 +62,49 @@ 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") + + +# --- 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: + 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")) 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() 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/notes.py b/voice2note/notes.py index 04873fe..b7775a3 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,50 @@ 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, apple_item=None): 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))}", + f"apple_action: {apple_item.get('kind') if apple_item else 'none'}", + "---", + ] + 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)"] + 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("") + return "\n".join(fm + [""] + body) 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) diff --git a/voice2note/run.py b/voice2note/run.py index d211e6b..e26cf85 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 @@ -10,7 +11,11 @@ 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 +from voice2note.apple_automation import has_cricknote_request, create_apple_item log = logging.getLogger("voice2note") @@ -24,6 +29,42 @@ def setup_logging(): ) +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 + 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 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 + + def check_access(): print(f"Vault: {config.VAULT}") print(f"Model: {config.WHISPER_MODEL_PATH}") @@ -37,59 +78,122 @@ 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) - if not materialize(memo.path, brctl_bin=config.BRCTL_BIN): - log.warning("Could not download %s from iCloud; will retry next run.", memo.id) +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: + 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 - 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" + + 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, cfg.WHISPER_MODEL_PATH, + 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=config.CODEX_BIN, model=config.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) + 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" + 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) 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 + assert_cricknote_ready(cfg) + assert_apple_automation_ready(cfg) + 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")) diff --git a/voice2note/state.py b/voice2note/state.py index 76e3df3..a6aa2ed 100644 --- a/voice2note/state.py +++ b/voice2note/state.py @@ -1,32 +1,74 @@ -"""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 +import tempfile +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): + # 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 mark_processed(self, memo_id): - self.processed_ids.add(memo_id) + def record(self, source_id, note_rel, analysis, recording_type): + 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: 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, - ), - encoding="utf-8", - ) + # 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 new file mode 100644 index 0000000..5d781ae --- /dev/null +++ b/voice2note/vault_writer.py @@ -0,0 +1,87 @@ +# 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() + + +# 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): + """Minimal frontmatter reader: returns scalar string fields from the leading + --- 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 {} + out = {} + for line in lines[1:]: + if line.strip() == "---": + break + m = _FM_LINE.match(line) + if m: + out[m.group(1)] = _scalar(m.group(2)) + return out 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) + "]"