# Notes Janitor

> Audits and reorganizes a user's Apple Notes archive on macOS - exports every note via Notes automation, classifies each one with a cheap model, produces a digest and a secrets inventory, and optionally re-sorts notes into folders. Use when the user asks to clean up, organize, audit, declutter, or "do something with" their Apple Notes / Notes.app archive.

- Skill: `olevasyliev/notes-janitor` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add olevasyliev/notes-janitor`
- Raw SKILL.md: https://api.skillmd.com/api/skills/olevasyliev/notes-janitor/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- License: MIT
- Author: olevasyliev (https://skillmd.com/u/olevasyliev)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/olevasyliev/notes-janitor

---


# Notes Janitor

Audits a macOS Notes archive end to end: export every note, classify each
one cheaply, hand back a human-readable digest plus a secrets inventory,
and - only if the user asks for it - re-file notes into category folders.
Everything runs locally except the classification calls themselves (those
go to whatever model provider is configured; note text leaves the machine
for that step only).

This skill has five phases. Phases 1-3 (export, classify, report) are the
core of "clean up my notes" and should run without extra confirmation once
the user has approved the plan. Phases 4 and 5 (re-sort, personal-context
extraction) are **optional and destructive-adjacent** (they move notes /
write to another file) - always ask the user explicitly before running
either, even if they asked for a general cleanup.

## Preflight

1. Confirm the host is macOS (`uname` = `Darwin`). This skill only works
   on macOS with Notes.app; there is no equivalent on other platforms.
2. Sanity-check Notes automation before doing anything else:
   ```
   osascript -e 'tell application "Notes" to count notes'
   ```
   - If this is the first time anything has automated Notes, macOS shows a
     **one-time permission prompt** ("X wants access to control Notes").
     Tell the user to click Allow, then re-run the command.
   - If the calling shell is sandboxed (e.g. Claude Code's Bash tool with
     its default sandbox), `osascript` cannot send Apple Events at all and
     the call will hang or fail with no useful error. Any command in this
     skill that shells out to `osascript` - `export_notes.js` directly, or
     `resort.py`'s `folders`/`execute`/`verify`/`rollback` actions, which
     call `osascript` internally - needs the sandbox disabled for that
     call (the no-sandbox / "dangerously skip sandbox" execution mode).
     `resort.py plan` and all the Python-only steps (`normalize.py`,
     `make_batches.py`, `merge_results.py`, `mask_titles.py`) do not touch
     Notes and never need this.
3. Pick a local work directory and keep every artifact in it - never
   scatter pipeline files across the user's projects. A dedicated
   directory outside any git repo is ideal, e.g. `~/notes-janitor-work/`.
   Export `WORKDIR` to that path (all scripts also accept `--workdir`
   explicitly). Nothing in this pipeline needs to be committed anywhere.

## Phase 1 - Export

```
mkdir -p "$WORKDIR"
osascript -l JavaScript scripts/export_notes.js "$WORKDIR"     # writes $WORKDIR/raw/folder_*.json
python3 scripts/normalize.py --workdir "$WORKDIR"              # -> $WORKDIR/notes.jsonl
python3 scripts/make_batches.py --workdir "$WORKDIR"           # -> $WORKDIR/batches/batch_*.json
```

- `export_notes.js` walks every folder in the default Notes account, skips
  **"Recently Deleted"**, and bulk-fetches id/title/body/created/modified
  per folder (see Known gotchas - this is deliberately not a
  note-by-note loop).
- `normalize.py` strips each note's HTML body to plain text and writes one
  JSON object per line to `notes.jsonl`, assigning each note a stable
  short id (`n0001`, `n0002`, ...) used everywhere downstream.
- `make_batches.py` packs `notes.jsonl` into `batches/batch_NNN.json`
  files (default: up to 40 notes or 70,000 chars per batch, whichever
  comes first, with each note's text truncated at 5,000 chars) - sized to
  fit comfortably in a single cheap-model call.
- All output stays under `$WORKDIR`. Nothing here leaves the machine.

Report the note count and batch count to the user before moving on.

## Phase 2 - Classify

For each file in `$WORKDIR/batches/`, spawn one subagent on the
**cheapest/fastest available model** (e.g. Haiku) to classify that batch.
This is a bounded, mechanical task - a strong reasoning model is not
needed and would just cost more. Launch multiple batch subagents in
parallel rather than one at a time.

Each subagent must read its batch file and write one JSONL line per note
to `$WORKDIR/results/<same-batch-filename-but-.jsonl>` (e.g.
`batch_007.json` -> `results/batch_007.jsonl`), with exactly these keys:

```json
{"n": "n0001", "cat": "project_idea", "gist": "...", "useful": false, "revive": false, "sensitive": false}
```

### Classification prompt template

```
You are classifying a batch of Apple Notes for an archive cleanup. You will
receive a JSON array of notes, each: {"n", "folder", "title", "created", "text"}.

For EVERY note in the array, output exactly one JSON object per line (JSONL -
no markdown fences, no commentary, no summary) with these keys:

  n         - copy the note's "n" id unchanged
  cat       - exactly one of these 15 categories:
                project_idea        - an idea/pitch/plan for something not yet built
                work                - work tasks, meetings, analytics, client/business notes
                personal_journal    - diary-style reflection, feelings, personal events
                reference           - factual reference material, how-tos, notes-to-self
                book_media          - notes on books/movies/shows/podcasts, reading lists, quotes
                humor               - jokes, funny quotes, memes as text
                health_fitness      - health, medical, fitness, workout notes
                travel              - trip planning, itineraries, travel logs
                finance             - budgets, spending, non-secret financial notes
                credentials_sensitive - passwords, API keys, tokens, card/account/ID numbers,
                                      private keys, seed phrases, or other real secrets/PII
                list_todo           - checklists, to-do lists, shopping lists
                learning            - study notes, course notes, language learning
                junk_obsolete       - stale scratch notes, test notes, no-longer-relevant clutter
                empty               - blank or effectively blank
                other               - doesn't fit any category above
  gist      - <=120 chars, English (translate if the note is in another
              language), describing what the note is about
  useful    - true if this is still relevant / worth keeping visible today,
              false if it's stale/superseded/no longer needed
  revive    - true ONLY for a small, selective subset of useful notes that
              are worth actively resurfacing (an unfinished idea worth
              restarting, an open task, something genuinely worth acting on
              soon). This is a shortlist, not a synonym for useful - most
              useful notes should NOT be revive=true.
  sensitive - true if the note contains real secret/PII material anywhere,
              INCLUDING if the note's title itself is the secret (Apple
              Notes uses the first line as the title, so titles often leak
              keys/passwords/tokens). When in doubt, mark sensitive=true.

CRITICAL RULE for sensitive notes: NEVER copy any part of an actual secret
(key, token, password, card number, seed word, address) into the gist.
Describe it instead - e.g. "Contains a Stripe API key and a client's card
number", never the key or number itself. This rule applies even if the
secret is short or looks harmless.

Batch to classify:
<insert the batch JSON array here>
```

After all batches finish, verify coverage (every batch produced exactly
one result file, one line per input note) before moving to Phase 3.

## Phase 3 - Report

```
python3 scripts/merge_results.py --workdir "$WORKDIR"   # -> digest_input.md, sensitive_notes.md (RAW, unmasked)
python3 scripts/mask_titles.py --workdir "$WORKDIR"      # -> sensitive_notes_masked.md
```

- `merge_results.py` validates coverage (every note has exactly one
  result), prints category/flag counts, and writes `digest_input.md`
  (stats + per-category useful listings + a revive shortlist, with
  sensitive notes excluded from all listings there) and a **raw**
  `sensitive_notes.md` inventory.
- `sensitive_notes.md` is raw and unmasked - it still has real note
  titles, and titles can themselves be secrets. **Never show, quote, or
  persist `sensitive_notes.md` anywhere.** Always run `mask_titles.py`
  next; it rebuilds the same listing with secret-looking titles replaced
  by a placeholder (bare emails/phones get partial masking instead). This
  is a fail-closed heuristic, not a guarantee - skim
  `sensitive_notes_masked.md` yourself before showing it to the user, and
  if anything still looks like a raw secret, redact it by hand before
  sharing.
- From `digest_input.md` and `sensitive_notes_masked.md`, synthesize a
  human digest for the user covering: total notes, category breakdown,
  useful vs stale split, a curated revive shortlist (don't just dump the
  whole revive list verbatim - pick out the ones actually worth
  mentioning), and a security section (count of sensitive notes found, by
  general type - e.g. "12 notes with API keys, 40 with card/account
  numbers" - never any actual values). Recommend the user move sensitive
  notes' contents to a password manager and delete the notes afterward
  (this skill does not delete anything itself).

This is where phases 1-3 end for a plain "audit my notes" request. Present
the digest and stop unless the user asks for re-sorting.

## Phase 4 (optional, ask first) - Re-sort

Ask the user explicitly before running this phase, even if they asked for
a general cleanup - it moves real notes around, even though it never
deletes anything.

```
python3 scripts/resort.py plan --workdir "$WORKDIR"                # move_plan.tsv, no changes to Notes
python3 scripts/resort.py folders --workdir "$WORKDIR"             # creates missing target folders
python3 scripts/resort.py execute --workdir "$WORKDIR"             # moves notes, writes rollback_<ts>.tsv
python3 scripts/resort.py verify --workdir "$WORKDIR"              # run again later (iCloud sync can revert moves)
```

Show the user the `plan` output (planned move counts per target folder)
before running `execute`. `resort.py`'s default category-to-folder mapping
routes `junk_obsolete`/`empty` to "🗑 Review & delete" and
`credentials_sensitive` to "🔐 Secure & delete" for manual review/deletion
by the user - it never deletes a note itself. See the top of
`scripts/resort.py` for the full mapping and how to customize folder
names.

`execute` moves in chunks (default 40 notes per AppleScript call), retries
failures once, and appends every successful move to a timestamped rollback
TSV - `resort.py rollback --rollback-file <that file>` undoes it later if
needed, and `resort.py verify` re-checks (and quietly re-applies) any
moves iCloud sync reverted after the fact. Run `verify` again a few
minutes after `execute`, and once more in a later session, to catch drift.

## Phase 5 (optional, ask first) - Personal-context extraction

Ask the user explicitly before running this phase.

Build a subset of notes worth mining for personal context: notes
classified `personal_journal`, `health_fitness`, `travel`, `book_media`,
`humor`, or `finance`, EXCLUDING anything `sensitive=true`. Batch that
subset the same way as Phase 2 (small JSON arrays, capped size), and spawn
cheap-model subagents to extract discrete facts (values, tastes,
recurring themes, biographical details) from each batch as structured
JSON - not verbatim quotes. Then synthesize the extracted facts into
whatever context file the user wants updated (e.g. a personal-context
document), writing prose, not a raw fact dump. This phase has no
dedicated script in `scripts/` - build the batches with the same
truncation/size discipline as `make_batches.py` and reuse its patterns.

## Safety rules

- Everything runs locally except the classification (and, if run,
  personal-context extraction) API calls - tell the user this up front.
  Note text for the batches being classified leaves the machine for that
  call only; nothing else does.
- Never quote, echo, or persist actual secret material anywhere - not in
  chat, not in any file. Always describe, never quote.
- Never delete or edit a note. This skill only ever reads, classifies, and
  (in Phase 4, with consent) moves notes between folders.
- Every move Phase 4 makes is logged to a rollback TSV before the user
  needs it - don't skip writing it "to save time."
- Classification is a cheap model doing bulk categorization - expect
  roughly 95% accuracy, not perfection. Tell the user to expect a handful
  of misfiled notes and that `resort.py rollback` exists if a batch of
  moves needs undoing.

## Known platform gotchas (macOS Notes automation)

- **NoteStore.sqlite is off-limits.** Reading Notes' underlying SQLite
  database directly is blocked by macOS TCC even with Full Disk Access
  granted to the calling process. Notes.app automation (AppleScript/JXA)
  is the only reliable way to get note content out - there is no
  file-level shortcut.
- **`container of note` throws error -1728.** Querying a note's current
  folder via its own `container` property fails unpredictably. Don't rely
  on it. This pipeline avoids it entirely: the export step records each
  note's folder once (from the folder loop, not from the note), and
  `resort.py` verifies a move by listing the *destination* folder's
  contents afterward (`id of every note of folder X`) rather than asking
  the note where it lives.
- **Avoid `whose` clauses.** `whose`-filtered AppleScript queries against
  `notes` are slow and flaky at real-archive scale (thousands of notes).
  Nothing in this pipeline uses one - folders are iterated directly and
  properties are bulk-fetched.
- **Bulk-fetch properties per folder, not per note.** `export_notes.js`
  fetches `id`, `name`, `body`, `creationDate`, `modificationDate` as
  parallel arrays off the folder's notes specifier in one call, falling
  back to smaller chunks and finally one-note-at-a-time only if the bulk
  call fails. One-note-at-a-time from the start is dramatically slower
  against a large archive.
- **Move/delete by note id works and is stable.** All of `resort.py`'s
  automation addresses notes as `note id "<coredata-id>"`, which is
  reliable across a session; that id is exactly what `normalize.py`
  carries through from the export.
- **iCloud sync can revert a move minutes later.** A move can succeed,
  verify as successful, and then silently bounce back to its original
  folder once iCloud finishes syncing. This is why `resort.py` has a
  separate `verify` action instead of assuming `execute` succeeding once
  is the end of the story - re-run `verify` after some time has passed
  (and again in a later session) and let it re-apply anything that
  drifted.
- **Note bodies embed images as base64.** A note's HTML `body` can contain
  inline base64-encoded image data. Raw per-folder JSON in `raw/` being
  many megabytes for a modest note count is normal, not a bug -
  `normalize.py` strips it down to plain text on the way into
  `notes.jsonl`.

