Skip to content

v1.5.0

Choose a tag to compare

@54yyyu 54yyyu released this 07 Aug 01:58
· 14 commits to main since this release

Changelog

This file starts at 1.5.0. For anything earlier, git log is the record — the
commit messages in this repository are written as prose with the reasoning and
the measurements in them, and they are better than a summary of themselves would
be.

Numbers here are measured, not estimated. Where a figure came off the author's
own store it says what it counted; the store itself is nobody's business but its
owner's, so counts and timings travel and subject matter does not.


1.5.0 — unreleased

Twenty-three commits since 1.4.0. Two days of driving every operation against the
real applications instead of against stubs, which is most of the story: the tools
that nobody had ever run were the tools that were broken, and every one of them
reported success while being wrong.

Operations: 42 → 65. Checks: 268 → 1219.

The theme, if there is one, is that a wrong answer used to be indistinguishable
from a right one. A read cut at its limit printed like a complete read. A failed
MCP call returned isError: False. A search that could not run returned an empty
list and exit 0. Most of what follows is that class of defect, found by running
the thing and reading what came out.

If you parse --json, read the first two items below before upgrading.
Everything else is additive or a fix.


Breaking changes

1. --json now emits an envelope, not a bare array

Every listing command used to print a bare JSON array. It now prints an object
with the rows under results and the truth about the read beside them:

// before
[ {...}, {...} ]

// after
{ "results": [ {...}, {...} ], "total": 3631, "truncated": true, "more_hint": "limit=-1" }

Migration: jq '.[]' becomes jq '.results[]'. That is the whole change for
most callers.

This is the fix for the worst remaining case of a partial answer looking like a
whole one. TruncatedList is a list subclass, so json.dumps saw a list and
silently dropped every attribute on it — the truncated rows went out with nothing
saying they were truncated, to the callers most likely to act on a wrong count
without a human ever looking at it.

The row key is results on every command on every tool, rather than the
noun. messages read previously filed its rows under messages and
mail search-bodies under emails; following that convention would have meant
seven different row keys and a caller needing to know which tool it had called
before it could parse the reply. .results[] works everywhere.

Three details worth knowing:

  • The envelope is decided by the payload's own Python type at one choke point, so
    rows are wrapped and a single object is not. 21 commands carry an envelope;
    mail read, calendar coverage, notes view and every write return their
    object unwrapped, as before.
  • total may be null. That is a third state meaning not counted, and it is
    deliberate — an AppleScript fallback that asks for N rows and gets N back
    genuinely cannot say how many there were, and guessing len(results) there
    would manufacture exactly the false completeness this release exists to remove.
  • mail search-bodies has a fourth state: when the scan is cut by its own budget,
    total is null and more_hint switches to scan_limit=-1, because matches
    counts hits only among the messages the scan reached and raising limit past an
    early-stopped scan buys nothing.

2. On note records, truncated is renamed content_truncated

On a note, the row-level truncated meant this note's text was cut at the
character cap
. The envelope's truncated means the list was cut at the row
limit
. Those are different facts, and on notes view --json the note is the
payload — so the row flag sat at exactly an envelope's depth meaning the opposite
thing.

Mail records have always spelled it content_truncated; notes was the odd one
out.

Migration: if you read truncated off a note record, read content_truncated.

3. notes create no longer creates a folder named Claude

create_note carried DEFAULT_FOLDER = "Claude" and created that folder when it
was absent. A user who renamed their folder found that the next plain
notes create brought a fresh Claude into existence and filed there.

Two faults in one: a vendor's name planted in somebody else's Notes app, and a
folder created as a side effect of an operation whose job is to create a
note. It now files into the default folder of the default account — a real
property in Notes' scripting definition, which resolved on both account types
tested — and refuses, naming the real folders, when it cannot resolve one.

No path in the package creates a folder any more, which is what makes the side
effect idempotent. The result carries folder, account and folder_created.

This changes where your notes land if you relied on the old behaviour. If you
want the old folder, name it: notes create --folder ....

4. Eleven flag abbreviations that used to resolve now exit 2

A consequence of the 42 new alias spellings below, and reported rather than
hidden. argparse resolves an abbreviation by counting option strings, not
actions, so once --from gained the alias --from-address, maps directions --fro became "ambiguous — could match --from, --from-address": two names for
one flag. Deduplicating by action fixed 8 of the 19 affected abbreviations; the
remaining 11 now exit 2 naming both candidates.

Full spellings are unaffected, and an exit 2 naming the ambiguity is never a
silently wrong flag. Measured across all 66 CLI leaves against the previous
parser rather than estimated.


A partial answer no longer looks like a complete one

The headline defect, on all seven tools and all three surfaces:

$ pyapple calendar list --from-date 2022-01-01 --to-date 2026-12-31
10 events:

There were 3,631 events in that window. The same window with --limit -1
returns all 3,631 in 0.40s, so the data was there and only the report was wrong.
A tester read that output, concluded there were ten events, and filed the number
as a finding — the tool taught them something false and they believed it, because
nothing in the output suggested otherwise.

$ pyapple calendar list --from-date 2022-01-01 --to-date 2026-12-31
showing 10 of 3631 events (--limit -1 for all):

Every read operation in all seven tools was audited, not just the one that was
reported. The count reaches three surfaces rather than two: CLI text, --json
(above), and MCP text — MCP renders text and nothing else, so total and
truncated living on a return value are facts an MCP caller never sees.

The same rule the exit statuses already carried, one step along: a failed read
must never look like an empty one, and a partial read must never look like a
complete one.

--limit -1 returned almost nothing — three separate bugs

--limit -1 is documented package-wide as "all", and it is what the new
truncation notice tells people to reach for. It was the most truncated answer in
the package in three places:

  • messages search --limit -1 returned exactly one message. The candidate
    window became max(-5, 100) and the stop condition len(found) >= -1 fired on
    the first hit. Now 17,109 messages in 102s — the price of decoding the message
    blobs, paid only when asked for by name.
  • notes list --limit -1 returned one note where --limit 500 returned 58.
    The same >= limit stop condition, in the one module that was excluded from
    the first truncation audit because it belonged to a concurrent workstream at
    the time. The audit covered six modules, skipped the seventh, and every check
    that ran, passed.
  • maps search --limit -1 returned one place, from a max(1, int(limit)).
    Found by grepping the remaining limit arithmetic rather than by a report. A full
    sweep found no fourth instance.

Also fixed: messages conversations --limit -1 silently dropped the last
conversation
to a stray [:-1] slice.

MCP: isError now means what it says

A failed tools/call used to return isError: False with the reason under
content. A client branching on isError — which is what the field is for —
read a refusal as a success. Reproduced on three operations across three tools.

Every tool ended in except Exception: return f"Error accessing X: {e}", and
returning a string is returning a successful call whose text describes a
failure. The CLI had five exit statuses precisely so that a failed read could not
look like an empty one; MCP had one status for everything.

The classification is now mirrored rather than duplicated. A new
pyapple_mcp/outcomes.py holds the statuses and the single status_of() ladder;
the CLI's seven-clause except ladder collapses to one clause consulting it, and
the server's decorator consults the same function. The two front ends cannot
drift on what counts as a failure. Errors leave as fastmcp.exceptions.ToolError,
which is the one route FastMCP 3.0.0 documents as preserving the message text —
checked against the installed version and confirmed in the raw wire bytes.

The inverse mistake is the one worth guarding against, and it is in the table:
an empty result is a success.
A search that matched nothing, a mailbox with no
unread, a truncated read — all exit 0 and all stay isError: False.
mail read on a missing id is an error and notes view on a missing title is
not, because that is what the CLI already did; mirrored rather than harmonised,
since harmonising would be a behaviour change wearing a consistency argument.

Two faults found while doing it: three contacts refusals returned their message
unguarded, so an ambiguous name reported success on both front ends; and
maps search had a nested try/except swallowing the failure its own branch
raised.


Notes

  • No note in a subfolder could be edited. folders of account returns every
    folder in an account, nested ones included — undocumented — and the walk seeded
    from the account and then descended, visiting each nested folder twice. So
    search returned two identical records for such a note, and every write refuses
    on more than one match. Appending, renaming, editing and deleting were all
    impossible, and the refusal told the caller the note was ambiguous. Nothing they
    could do would clear an ambiguity that did not exist.

  • notes folder-delete is new, and its contract is unusual because what it
    does is unusual. It refuses four things, each measured rather than assumed:

    • A folder holding notes, unless you pass their exact count, re-checked
      against the live store inside the delete script. A boolean can be set by a
      model that never read the notes; a number cannot be guessed, so supplying one
      is evidence of having looked.
    • Any folder with subfolders, with no override. Deleting a parent does not
      delete its children: the child and its note survive and the child's container
      still answers the dead parent's id, raising -1700. That is a broken store
      rather than a loss anyone can authorise.
    • The trash, and any account's default folder — read off default folder,
      never matched by name.
    • The notes it deletes are destroyed, not moved to Recently Deleted.
      Verified: a folder with 2 notes deleted, trash count 3 before and 3 after,
      and a search that does reach the trash matched 0 of the two bodies. The
      refusal says so and names notes delete as the recoverable route.

    It also reports may_return: True, because a deleted folder came back.
    Reproduced four times: deleted, absent at 11s and 31s, back at 51s with both
    notes and the same folder id, no restart. Three other deletes stayed gone, so it
    is intermittent. It happens on both account types tested and reliably on
    neither — in one trial one account's folder was back inside 15 seconds while the
    other's stayed gone five minutes; in an identical second trial neither returned.
    The caveat is therefore universal rather than naming a provider, which would
    have pointed at the slower of the two.

  • folders, folderCreate and folderRename are new. Renaming a folder
    persists, unlike the note case, verified from fresh processes.

  • notes create refuses an unknown folder rather than making one out of a typo.

  • notes search had no --limit on either front end while its own hint told
    callers to pass one. It has one now.

  • Every read in the module carries a true total. notes list --limit 5 used to
    report total: 5, truncated: false against 58 notes — not a missing count but a
    false claim of completeness.

  • A folder dropped from a listing because its child was unresolvable used to be
    visible only in a log line, and is now counted.

Calendar

  • A window longer than four years silently lost the newest years. The cap is
    1,461 days — four calendar years — from the window start, found by binary
    search on the end date rather than taken from documentation: a window opened in
    one year returned the same 3,086 events whether it asked for 48 months, 54
    months or 120. Reads are now chunked into 1,400-day segments, 61 days inside
    the cap. On the store it was measured against: 3,086 events in 0.33s before,
    3,631 in 0.40s after
    — 18% more data for about 15% more time, because two
    narrow queries are nearly as cheap as one wide one. Past 64 segments (roughly
    245 years) it raises rather than returning a short list.
  • The obvious fix would have introduced a worse bug. Deduplicating across
    segment boundaries wants a key, and the obvious key is the record's id. Over
    3,086 occurrences, 47 shared a bare identifier with another event while
    reporting no recurrence rule
    — detached occurrences, which answer
    hasRecurrenceRules() false but keep the series' identifier. An id-keyed merge
    would have dropped all 47. Worse, it meant duplicate ids were already being
    emitted for those 47, and every write resolves an ambiguous id to the first
    occurrence. Ambiguous ids: 47 under the old rule, 0 under the new one.
  • calendar coverage is new: per calendar, occurrences, the distinct series
    behind them, all-day count, real earliest and latest, writability, and how many
    events sit outside the scanned window. Empty calendars are listed, because a
    report built from what came back would omit them and "absent" reads as "does not
    exist". It now hands back the extent its probes actually reached rather than a
    suggestion, and reach_exhaustive is false whenever a probe found anything,
    because a non-empty probe is never evidence of an edge.
  • Recurrence, alarms and attendees. recurrence / recurrence_interval /
    recurrence_days / recurrence_week / recurrence_count / recurrence_until
    are explicit fields rather than a natural-language string, because parsing a
    sentence puts the misreading at the point where it is indistinguishable from a
    correct reading and lands as a series on the wrong days in somebody's diary.
    What it cannot build it refuses by name, including the dangerous shape — a
    recurrence field with no frequency, which would otherwise create a one-off and
    report success. alarms is its own operation; alarm_minutes_before is
    unsigned, because the underlying API takes a negative interval and that sign is
    exactly what a caller gets backwards. include_attendees is opt-in: it costs
    about 1.7x on a read and sprays addresses into transcripts that did not ask for
    them.
    has_attendees is always on and costs 0.002s.
  • series_id groups occurrences into series. A one-off is a series of one, so
    grouping needs no special case.
  • calendar move never existedAttributeError on every call, while three
    modules declared it.
  • Deleting a repeating event removed the wrong occurrence. Every occurrence
    shares one identifier and the loop deleted the first it walked past; asked for
    the 4th of a 5-occurrence series, it removed the 1st.
  • "No such event" took 60.06s and returned success: False on what was
    actually a timeout. Now 0.002s.
  • Timezone offsets were discarded on create: a time given as +00:00 was stored
    as that clock time in local time, up to 13 hours out.
  • A single named day returned nothing, because from == to is a zero-width window.
  • calendar free is new and returns gaps rather than events. It ignores all-day
    entries deliberately: on the machine it was built against, 211 of the next 365
    days carry one, so counting them busy books every day solid.

Mail

  • --mailbox INBOX answered about mail nobody has in an inbox. Where a
    provider stores each message once and records mailbox membership in a separate
    labels table, the INBOX mailboxes hold zero rows. Measured: unread --mailbox INBOX reported 2 unread to a user with 6. The predicate follows labels
    now and reports which one matched.
  • --account was accepted, documented, and dropped, so scoping to one account
    returned all of them. Mailbox URLs are keyed by the account's uuid and the
    name→uuid resolution was never done. An unknown account now raises rather than
    silently widening.
  • 6.5% of downloaded bodies read as empty — a single-part HTML message hit a
    branch handling only plain text. 26 of 400 sampled, 26 of 26 empty, which is
    indistinguishable from a body that was never downloaded. The 500 newest messages
    went from 447 bodies to 500.
  • HTML bodies were 55% layout: stylesheet contents survived tag-stripping as
    prose, so against the character cap you paid for indentation and lost the end of
    the message.
  • mail search-bodies crashed on every invocation — an argument-count
    mismatch that raised after doing all the work. Not "returns empty": it never ran
    a single time for anybody.
  • 13 of 36 mailboxes were unmatchable because mailbox URLs are percent-encoded
    and --mailbox was not. Both spellings match now.
  • Every mail read moved off Apple Events--account used to resolve through
    a tell application "Mail", which starts Mail, for what is otherwise a local
    SQLite read. Proven against a runner that raises on any Apple Event.
    (mailboxes and unread --mark-read still drive Mail; the earlier claim that
    every read had moved was too broad and is corrected.)
  • mail read printed no body — the renderer read content where the handler
    returns body: 199 bytes of output against a 1,061-character message. And
    mail read on a nonexistent id exited 0 printing From: None, because it
    tested a dict for truthiness when absence is signalled by a found key.
  • New: readMessage (one message whole by id, 3ms warm), coverage (what
    each account can actually answer about), searchBodies (bodies are not in
    the index, so this can only ever be partial and the result says by how much),
    and mailbox-level coverage, which answers whether a mailbox reporting no
    unread is empty or simply unindexed.
  • An empty result now says why it is empty. "No results" and "nothing to
    search" used to print identically. A new scope description answers four facts in
    priority order — the named mailbox is not in the index, --since reaches
    further back than this Mac holds, this scope has nothing indexed at all,
    otherwise what the scope does hold. It speaks on any empty result, is silent on
    a successful one (verified: 439 bytes to stdout, zero to stderr), goes to
    stderr prefixed note: so --json is untouched, and is pinned under 300
    characters because a note that long gets skipped.
  • Coverage reporting corrected a measurement of its own: an aggregate reading
    "32% of the store" was the mean of one account at 0% of 31,143 messages and two
    at 100%. A number taken across the thing that works, hiding a zero in the thing
    the user has — which is the whole reason per-account coverage exists.
  • A drafted reply is now its own operation, replyDraft, in the draft class.
    Previously the gate saw reply whatever flags followed it, so mail reply --draft cost the send grant while two docstrings claimed otherwise. Both CLI
    spellings reach it: the new mail reply-draft, and mail reply --draft, which
    shipped in 1.4.0 and is remapped rather than removed.

Messages

  • A page of a conversation was indistinguishable from a whole one.
    messages read returned the most recent N with nothing to say how much was
    behind them, so twenty messages out of eleven thousand rendered exactly like a
    complete thread of twenty. Reads now carry total, truncated and next_until,
    and take until.
  • until rather than an offset, deliberately: an offset is counted from the
    end of a live conversation, so a message arriving mid-walk shifts every later
    page and the caller silently repeats and skips. The cursor comes from the raw
    nanosecond column rather than the rendered second-resolution timestamp, because
    people send four things inside one second. Verified on a real store: two pages
    of twenty overlapped by exactly one message on each of three threads.
  • total is counted with the same WHERE clause as the page, not a looser one:
    the page filter drops tapbacks, and a count including them would send a caller
    hunting for messages that were never going to be shown. 3.0–4.7ms on the three
    busiest threads tested, against reads of 40–400ms.
  • New: message body search, tapbacks folded onto the message they were aimed at
    rather than rendered as literal transcript lines, delivery state, attachment
    names, and send --chat-id — the only way to reach a group, since sending to
    one participant's number starts a separate one-to-one and nothing in the result
    says the group never saw it.
  • schedule cannot work and refuses, with a check pinning that it never
    quietly sends immediately instead. The scripting definition declares three
    commands and none takes a date. It stays on the surface rather than being
    removed, because a model asked to send at 7am with no such operation is likely
    to reach for send.

Contacts

  • Contacts writes stopped launching Contacts.app. The three writes were still
    Apple Events, and an Apple Event to Contacts launches it — including on calls
    that were about to be refused for a bad argument, because the reachability check
    was itself the trigger. The permission shrinks rather than trading: the
    framework path is gated by the Contacts privacy grant the reads already hold,
    where Apple Events need a separate "control Contacts" Automation grant. Wall
    clock: add 0.86–1.12s → 0.06–0.77s, addTo 0.69–0.80s → 0.19–0.28s, delete
    0.79–1.21s → 0.27–0.37s.
  • contacts rename is new — correcting or completing a name on an existing
    card. A part left out is left alone, and clearing a name is not offered: over
    MCP "not given" and "set to empty" are the same value, and guessing wrong wipes
    a field nobody asked to wipe.
  • 20 of 179 real cards refuse every framework write with Cocoa error 134092 —
    11.2%, and stable per card rather than intermittent (5 of 5 attempts fail on an
    affected card, 0 of 5 on each control). Nothing the API exposes separates the two
    groups, so it cannot be pre-flighted and retrying is pointless. The failure is
    total, never partial: every affected card was byte-identical afterwards.
    Nothing ever claimed a write that did not happen — what was wrong was the
    message, which now names the card, the cause, and the fact that the Contacts app
    can still edit it.
  • A no-op save reports success. Writing back a value already stored returns
    true and writes nothing, which is why the first two attempts to measure the
    above reported 0 failures across 20 cards. Any verification built on a no-op
    proves nothing — and it is why rename refuses a change that would not change
    anything.
  • An ambiguous name now names the candidates instead of saying "3 contacts
    match, please be more specific", which tells a caller it failed and leaves them
    guessing a longer string. Names and not ids, deliberately: no contacts operation
    accepts an id, so printing one would be a handle to nothing.
  • contacts search by name called the phone-number lookup, so "what is their
    email address?" was unanswerable through the MCP tool while the address sat on
    the card.

Maps

  • Maps was fiction. Maps.app ships no scripting definition at all, so all
    seven operations were written against a vocabulary that does not exist. Three
    were AppleScript syntax errors that had never executed a line for anybody; the
    other four launched the app, did nothing, and returned success: True.
  • search and directions now go through MapKit: 2.68s → 0.75s and 2.23s →
    1.36s, returning addresses, coordinates, turn-by-turn steps and arrival times
    where they previously returned an apology.
  • A threading fact that would have made this silently broken: the search API may
    be started anywhere but delivers on the main queue, and FastMCP runs a sync
    tool on a worker thread — so a naive in-process implementation hangs to its
    timeout and reports "no results", which is the failed-read-looks-empty bug one
    level down. Hence the helper subprocess, and a check asserting a worker thread
    cannot pump the main runloop.
  • Favourites and Guides are permanently unimplementable — no API, and nothing
    readable on disk. Those four operations report failure and hand back a durable
    maps.apple.com link that opens on any Apple device.
  • maps search printed Found 5 place(s) counting the survivors of filtering
    rather than the matches.

Reminders

  • complete and delete did not exist, which is half of what anybody asks of
    a reminders app, and neither did enumerating lists — so "add these to my
    shopping list" could not find the list.
  • create was broken three ways at once: it reported failure while having
    created the reminder
    , it dropped the due date entirely, and the line meant to
    apply the date would have set it to now.
  • Due dates rendered in UTC, so an evening reminder printed as the following
    day and an assistant asked "what is due today" answered about tomorrow.
  • open took 15.2s, and 15.3s to report no match. Now 2.6s, and it actually
    reveals the reminder rather than merely activating the app while reporting
    "Found and opened".
  • A text query matching more than one reminder is refused with the candidates
    named
    rather than resolved, because completing the wrong reminder makes it
    vanish with nothing to explain why, and "first match wins" makes that a coin
    flip on iteration order.
  • reminders list --list-name <typo> exited 3, meaning "the store could not
    be read", and sent the reader to Privacy & Security — while the message
    disproved itself on its face by naming the real lists. Now exit 2.

The command line

  • 42 alias flag spellings across 31 leaves, so that a reasonable wrong guess
    works. Aliases rather than renames: argparse takes several strings per action,
    so no existing caller breaks and --help still leads with the documented
    spelling. Every one was driven through the real entry point against recording
    handlers and compared to the canonical spelling — identical call, exit code and
    stdout, 0 mismatches. The cost is item 4 under Breaking changes, and it is
    reported rather than buried.

  • Handler messages now spell flags on the CLI and parameters over MCP. A
    handler built a message naming its own Python parameters and both front ends
    printed it verbatim, so shell users were told to type things that are not
    typable — (pass limit=25 or more), pass delete_notes=1, pass account= or mailbox=. The parameter is now marked in the handler with two control
    characters and rendered by whichever front end knows how its caller writes.
    One fact, built once where the fact is known. Found by an AST walk over every
    string literal in the package rather than by grep: 21 sites across five modules.

  • The logger was a third exit nobody had rendered, so pyapple mail unread --mark-read put raw marker bytes on stderr. The marker design assumed two
    exits and there are three, across 115 call sites. Fixing it needed the filter
    attached per logger rather than on the package root — an ancestor logger's
    filters never run, only its handlers — and it formats tracebacks and
    interpolated arguments too, since a message-only filter misses both.

  • Three ambiguity refusals in contacts and every notes write used to give a
    count and nothing to act on. Only reminders gave ids.

  • A malformed PYAPPLE_PERMS or --perms now exits 2 with a usage line
    instead of 1 with a traceback.
    Failing closed was always right; the status
    was not — exit 1 means the operation ran and failed, and a policy that will not
    parse means it never started. The policy is parsed at import, so the error
    escaped from an import statement: past the boundary try on the CLI, and
    past server.py's module import over MCP, where the client saw only "server
    disconnected". The message now also names which input was wrong, which the
    parse error alone never said, and the MCP failure names the config's env
    block.

  • Two control characters cannot be stored, and the package now says when it
    removed them.
    U+001E and U+001F are the markers above, so caller text
    containing them could otherwise forge a flag into a rendered message. They were
    already being stripped on the AppleScript routes — but only as a side effect of
    the escaper having no literal form for a C0 control character, and the
    EventKit, Contacts and MapKit writes had no scrub at all
    : a reminder name
    carrying the markers was stored raw and rendered as Created reminder 'shop --account now', a flag this tool has never offered. The scrub is now at the two
    front-end boundaries — the argparse namespace on the CLI, the keyword arguments
    over MCP — so it covers every write path and every read argument by
    construction rather than per handler. Silently altering what was stored would
    be the same fault as the rest of this release, so it is reported: a note: on
    stderr, removed_control_characters under --json, and a sentence appended
    over MCP.

  • pyapple perms now names the six tools an unscoped policy leaves untouched.
    Two testers in a row expected PYAPPLE_PERMS=mail=read to disarm the other six
    tools; it does not, deliberately — a user who writes mail=read has said
    something about mail and not about their calendar. The design stands and the
    documentation was the problem:

    No '*' entry, so the 6 tools the policy does not name keep
    every operation: contacts, notes, messages, reminders, calendar, maps.
    Add '*=read' to narrow them, or '*=none' to remove them entirely.
    
  • notes search --json used to print an error to stderr and [] to stdout
    and exit 0. Underneath was a second bug: a search matching nothing returns
    the empty string, which is falsy, so every empty search took the failure path
    and reported an error that did not exist.

Performance

Calendar reads got faster by building fewer records, not by reading less:

list   5yr limit 10   387.1ms -> 217.4ms   -44%
list   1yr limit 10    57.3ms ->  32.1ms   -44%
search 5yr limit 10   390.7ms -> 242.6ms   -38%
search 1yr limit 10    58.3ms ->  36.1ms   -38%
list   5yr limit -1   387.5ms -> 385.6ms    -0%

A read returning ten rows used to build a full record for every occurrence in the
window first — profiled over five years, that was 438ms of a 1,289ms read, all
but ten records of it thrown away. The count and the slice now happen on the raw
occurrences and records are built only for the rows that survive. Search matches
through three property reads instead of about fifteen: 25.4ms against 160.9ms
over 3,631 occurrences.

limit=-1 is unchanged and should be — every record it builds is one it returns.
The remaining time is 89% fetch and irreducible.

Equivalence was measured rather than assumed: 62 behavioural keys captured from a
real store before and after, all 62 identical, including full 3,631-record dumps
and orderings across roughly 30 daylight-saving transitions each way.

One reported regression turned out not to exist. Calendar honesty was reported as
costing 58ms → 102ms on a one-year window; the relevant function was
byte-identical across the two commits and best-of-5 on both trees gives 57.2ms
and 57.3ms. The original measurement had no warm-up, so a best-of-3 still carried
a cold framework. Recorded because the wrong number is what commissioned the work
— which was worth doing anyway.

Documentation

  • README and the skill document described a package that had moved under them.
    Operation counts of 58 and 60, both wrong, now 65 verified off the policy table.
    Ten false claims corrected, three of which misled about capability rather than
    merely being stale.
  • CLAUDE.md, which ships in the sdist, claimed AppleScript was "the primary
    method for application automation". It is now a three-route table: native
    frameworks for contacts, reminders, calendar and maps, where the app never
    launches and no Automation grant is needed; read-only SQLite for mail and
    messages reads; Apple Events for all of notes, mail writes and messages send.
  • The skill document's "known bugs" section listed three, all three already
    fixed
    , so a reader worked around problems that no longer existed while the
    genuinely sharp edge in that build was not listed. It now says it is empty and
    says why an unpruned list is worse than none.

Known limitations, reported rather than fixed

  • mail --limit 0 returns nothing, where notes, messages, calendar and maps
    return everything. Mail spells unbounded as a large sentinel rather than testing
    for -1.
  • A failed MCP tool call is noisy on the server's stderr. FastMCP calls
    logger.exception for every tool error, so a failure now writes about 4.4KB and
    54 extra lines of traceback — including for an ordinary missing parameter, which
    previously never raised. 1,812 bytes on a successful call against 6,186 on a
    usage error. There is no hook, and quieting a third-party logger would also hide
    real faults, so it is left alone rather than reconfigured unilaterally.
  • Every mail send leaves a copy in the sender's Drafts. Mail autosaves any
    outgoing message it can attribute to an account — measured by building a message
    that was never saved and never sent, which appeared in Drafts within 8s and
    stayed. The copy appears asynchronously, so an in-script cleanup runs too early
    and a later one would mean this package deleting from your Drafts by subject
    match.
  • Notes' HTML serialiser and its HTML parser do not agree, so the body it
    hands you is not a body it will take back: images and attachments are dropped,
    link URLs are lost, headings become bold spans. Every edit is a read-modify-write
    because Notes offers nothing else, so a note carrying any of that is refused.
    On the store this was measured against, 0 of 56 existing notes pass the gate.
    That is the honest number, and it beats an editor that works on plain notes and
    eats the rest. Notes this tool creates always pass.
  • An .emlx body is filed under its account, not its mailbox, so body search
    is account-level and cannot be otherwise.
  • A mail message id is good now and not later. Two ids printed by one command
    were absent from the database entirely about sixty seconds later — not flagged
    deleted, gone, because Mail had reindexed. Do not retry a stale id.
  • A Notes deletion verified by an immediate listing is not verified, per
    may_return above. This applies to notes delete as well as folder-delete.

Verification

  • 1219 checks, up from 268 at 1.4.0. They run without a mailbox, a TCC grant
    or a network, and they do not read the machine they run on: the timezone in the
    reminders fixtures is pinned deliberately so an offset assertion cannot pass by
    accident of geography.
  • Every check added in this release was made to fail before being trusted —
    delete the line it covers, confirm red, restore. Over 500 mutations across the
    release, by the commit messages' own counts. The passes that are worth reading about are the ones where a
    mutation came back green, because every one of those was a check that was
    not watching what it claimed to: a guard tested against a three-card fixture
    that refused as ambiguous rather than for the reason under test; checks
    parametrised over the very table they were meant to protect, so deleting an
    entry deleted its own check; a check asserting only truncated is False, which
    stays green when total is never computed at all.
  • The mutation harness itself was found to be lying. Mutations rewrite the same
    file several times inside one second, and CPython validates a cached .pyc
    against the source mtime in whole seconds — so a run could import bytecode
    compiled from the previous mutation. One entry was deterministically red alone
    and deterministically green when another ran first. A false green under-reports
    coverage; a false red certifies that a check catches a bug when it does not,
    which is the only claim the harness makes. Both were happening.
    PYTHONDONTWRITEBYTECODE=1 on the child makes it deterministic.