Skip to content

Commit f501067

Browse files
authored
Zerm 2.7.0 — honest metrics, working enhancement, real docs (#277)
* Store usage statistics so the dashboard stops resetting Every dashboard number was recomputed by scanning surviving Transcription rows, and TranscriptionAutoCleanupService hard-deletes those rows on a retention timer defaulting to 1440 minutes. Lifetime totals were therefore only ever a view of whatever history had not been swept yet, which is why the dashboard appeared to reset itself daily. Aggregates now live in their own usage.store, alongside the existing transcript and dictionary stores. The separation is the point: transcript retention cannot reach it, and it holds counts and durations only — no transcript text — so it is safe to keep even under zero retention. Existing history is backfilled once; anything an earlier sweep already deleted is unrecoverable, and the docs say so rather than implying otherwise. Also fixed in the same area: - sweepOldTranscriptions used max(retentionMinutes, 0) and never read the declared defaultRetentionMinutes, so an unset key read as 0 and set the cutoff to now, deleting the entire history at launch. An explicit 0 is still honoured as "Immediately". - AudioFileTranscriptionService created records without a status, leaving them pending forever and invisible to the dashboard while still showing in History. - Added Reset Statistics: clearing transcripts used to clear the numbers as a side effect, and making them durable removed the only way to erase them. The dashboard is rebuilt around a range selector and charts, and drops DashboardPromotionsSection (both flags hardcoded false, unreachable) and MetricsSetupView (no references). * Run AI enhancement after the paste, not before it AI enhancement was dead product-wide while its toggle read ON. InstantTranscriptionMode was a UserDefaults flag with no UI anywhere, defaulted true and force-set true for every existing install by a migration. It disabled enhancement three separate ways: clearing isEnhancementEnabled on every launch (and persisting that through didSet, so the user's choice was erased between sessions), gating the pipeline branch so a toggle reading ON never enhanced, and clearing it again on every Power Mode switch. The same migration pinned the enhancement timeout to 2 seconds with retry off, so anything that did run timed out and silently pasted the raw transcript. Enhancement was only slow because it sat before the paste. It now sits after. DictationOutputMode replaces the flag with Instant, Instant + Refine, and Enhanced. In Instant + Refine the raw transcript is pasted at the existing latency, the enhancement runs concurrently, and the inserted range is replaced in place through the Accessibility API once it returns. The paste path gains no work: the anchor read runs concurrently with the clipboard write and resolves inside the pre-paste delay that already existed. Measured hotkey to audio-unit-running still reaches 85 ms, inside the documented 79-86 ms band. Replacement is gated hard and fails closed, so it can fail to improve the text but cannot corrupt it. It genuinely works only in native AppKit text views; Chromium and WebKit implement no working AXSelectedText setter, and terminals are on a deny-list, so for Electron apps, browser content and terminals the refined text is offered instead. That fallback is the usual path, not an edge case. Notable interactions: auto-send is incompatible and degrades to Enhanced; the selected-text read posts a synthetic Cmd+C and is suppressed for refine, which would otherwise fire while the user is typing; refine yields to Read Aloud, since both queue on the same on-device model actor; and the prompt is pinned for the request, because dismissing the recorder ends the Power Mode session and restores the global selection mid-refine. Power Mode enhancement becomes a tri-state. The old bool could not express "leave it alone", so every Power Mode — including the seeded default that matches when nothing else does — forced enhancement off on every recording. * Replace the unregistered docs domain with a real documentation site All seventeen in-app links pointed at tryzerm.com, a domain that is not registered — NXDOMAIN, no WHOIS record. It was a find/replace artifact from upstream VoiceInk's tryvoiceink.com. The real site is arcusis.github.io/Zerm, which had no /docs/ subtree at all, so even a corrected hostname would have 404'd. Links.swift is the URL registry the app never had; every link was an inline string literal, which is how this drifted unnoticed. Sixteen documentation pages are authored as markdown under site-content/docs and generated into docs/docs by the existing site builder, with screenshots of the app. Also removed: the leftover VoiceInk monetization routes (/buy, /affiliate, /social-share), which Zerm has no equivalent for. The support flow opened a mail draft addressed to the upstream maintainer's personal address, so every Zerm support report — including the attached system information — was being sent to an unrelated person. It now opens the project's own issue tracker. announcements.json was never published, so the feed the app fetches on every launch returned 404, and its contents announced a "Zerm iOS App" that does not exist. It is now published, mirrored from the repo root on every build. * Explain every setting in place Roughly forty user-facing options across the ten panes had no explanation at all, including the whole of Power Mode's trigger and enhancement sections, Audio Input, Dictionary, Read Aloud and most of Settings. InfoTip is rebuilt on a Button rather than a tap gesture, so it now has a hover cursor, keyboard activation and a VoiceOver label. Its "Learn more" affordance is a discrete Link instead of a tap handler attached to the whole popover body, which previously meant tapping anywhere in the popover opened the URL and the popover never dismissed. Copy notes: Power Mode website matching is a substring match on a cleaned URL, which nothing in the interface said; the Power Mode empty state was grammatically broken VoiceInk-era text; and the enhancement and dictionary settings buttons were bare gear glyphs in section headers, identified only by a tooltip. The recorder's enhancement indicator distinguished on from off by a 40% opacity step on a black pill, which is not a state anyone could read at a glance. It is now a filled accent capsule. * Run the test suite, and guard the docs in CI The scheme has always had a TestAction wired to ZermTests, but nothing ever invoked it — no make target and no CI step — so the suite had never run. Debug configuration is required, not incidental: @testable import needs ENABLE_TESTABILITY, which Release turns off, and the tests fail to compile without it. That is why this was never noticed. The website lint gains three checks, each covering a failure that actually shipped: every Links.Doc slug must resolve to a published page and back, every screenshot a docs page embeds must exist, and no file may reference the unregistered domain again. * Release 2.7.0 Version 2.7.0, build 270. Notebook: two new notes covering the subsystems added here — the refine-in- place design, including where accessibility replacement cannot work and why the fallback is the usual path, and the durable usage store, including why it is a separate store and why clearing it had to be added explicitly. * Sign the 2.7.0 appcast entry Signed with the Sparkle EdDSA key; enclosure length verified against the uploaded zip. Release notes match what actually shipped. * Stop CI reporting success for builds that failed The Swift job piped xcodebuild into xcpretty with a `|| xcodebuild ... | tail` fallback, so the step's exit code came from the pipeline tail rather than from xcodebuild. Every run since the job was written has failed on missing native XCFrameworks and reported success — the app has never actually been built in CI, and the unit test step I just added inherited the same flaw. `make setup` stages whisper and sherpa but there is no target for llama, which is built out of band and is not mentioned in BUILDING.md, so a clean checkout cannot compile the app at all. Until that is fixed this job cannot honestly claim to build anything, so it now asserts the frameworks are present and fails with an explanatory message instead of passing silently. set -o pipefail on both steps so a failure can never be masked again.
1 parent cd8ceaa commit f501067

127 files changed

Lines changed: 7666 additions & 865 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 123 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,35 @@ jobs:
3434
-clonedSourcePackagesDirPath .ci-packages \
3535
2>&1 | tail -5
3636
37+
# `make setup` stages whisper and sherpa. There is no target for llama, which is
38+
# built out of band, so the app cannot currently be compiled on a clean machine —
39+
# see the tracking issue. Until that is fixed this job cannot honestly claim to
40+
# build the app, so it asserts the frameworks are present and fails loudly rather
41+
# than reporting success on a build that never happened.
42+
- name: Check native dependencies are available
43+
id: deps
44+
run: |
45+
set -euo pipefail
46+
MISSING=0
47+
for fw in \
48+
"$HOME/Zerm-Dependencies/whisper.cpp/build-apple/whisper.xcframework" \
49+
"$HOME/Zerm-Dependencies/sherpa-onnx/build-swift-macos/sherpa-onnx.xcframework" \
50+
"$HOME/Zerm-Dependencies/sherpa-onnx/build-swift-macos/onnxruntime.xcframework" \
51+
"$HOME/Zerm-Dependencies/llama/build-apple/llama.xcframework"; do
52+
if [ ! -d "$fw" ]; then echo "missing: $fw"; MISSING=1; fi
53+
done
54+
if [ $MISSING -eq 1 ]; then
55+
echo "::error::Native XCFrameworks are absent, so the app cannot be built or tested here."
56+
echo "::error::This job previously piped xcodebuild into xcpretty/tail, which discarded the"
57+
echo "::error::exit code and reported success for a build that failed on every run."
58+
exit 1
59+
fi
60+
61+
# set -o pipefail is load-bearing: without it the pipeline takes xcpretty's exit
62+
# code and a failed build reports success.
3763
- name: Build (no signing)
3864
run: |
65+
set -euo pipefail
3966
xcodebuild \
4067
-project Zerm.xcodeproj \
4168
-scheme Zerm \
@@ -45,16 +72,24 @@ jobs:
4572
CODE_SIGNING_REQUIRED=NO \
4673
CODE_SIGNING_ALLOWED=YES \
4774
DEVELOPMENT_TEAM="" \
48-
build \
49-
2>&1 | xcpretty || xcodebuild \
50-
-project Zerm.xcodeproj \
51-
-scheme Zerm \
52-
-configuration Debug \
53-
CODE_SIGN_IDENTITY="" \
54-
CODE_SIGNING_REQUIRED=NO \
55-
CODE_SIGNING_ALLOWED=YES \
56-
DEVELOPMENT_TEAM="" \
57-
build 2>&1 | tail -20
75+
build 2>&1 | xcpretty
76+
77+
# The scheme has always had a TestAction wired to ZermTests, but nothing ever
78+
# invoked it, so the suite never ran. Debug is required: `@testable import Zerm`
79+
# needs ENABLE_TESTABILITY, which Release turns off.
80+
- name: Unit tests
81+
run: |
82+
set -euo pipefail
83+
xcodebuild test \
84+
-project Zerm.xcodeproj \
85+
-scheme Zerm \
86+
-configuration Debug \
87+
-destination 'platform=macOS' \
88+
-only-testing:ZermTests \
89+
-clonedSourcePackagesDirPath .ci-packages \
90+
CODE_SIGN_IDENTITY="" \
91+
CODE_SIGNING_REQUIRED=NO \
92+
CODE_SIGNING_ALLOWED=NO 2>&1 | xcpretty
5893
5994
# ── Website lint ─────────────────────────────────────────────────────────
6095
website-lint:
@@ -81,6 +116,84 @@ jobs:
81116
fi
82117
echo "No GitHub Releases redirect found — OK"
83118
119+
- name: Check every in-app doc link has a published page
120+
run: |
121+
# Zerm/Resources/Links.swift is the single registry of URLs the app opens.
122+
# Every Links.Doc slug must have a generated page under docs/docs/, or the
123+
# app ships a link to a 404 — which is exactly how it ended up pointing at
124+
# an unregistered domain for seventeen call sites.
125+
REGISTRY=Zerm/Resources/Links.swift
126+
test -f "$REGISTRY" || (echo "$REGISTRY missing" && exit 1)
127+
128+
SLUGS=$(sed -n 's/^ *case [A-Za-z]* = "\([a-z0-9-]*\)"$/\1/p' "$REGISTRY")
129+
test -n "$SLUGS" || (echo "FAIL: no doc slugs parsed out of $REGISTRY" && exit 1)
130+
131+
MISSING=0
132+
while IFS= read -r slug; do
133+
if [ ! -f "docs/docs/$slug.html" ]; then
134+
echo "FAIL: Links.Doc \"$slug\" has no page at docs/docs/$slug.html"
135+
MISSING=1
136+
fi
137+
done <<< "$SLUGS"
138+
139+
# And the other way round, so a page never quietly stops being linked
140+
for page in docs/docs/*.html; do
141+
slug=$(basename "$page" .html)
142+
[ "$slug" = "index" ] && continue
143+
if ! echo "$SLUGS" | grep -qx "$slug"; then
144+
echo "FAIL: docs/docs/$slug.html has no matching Links.Doc case"
145+
MISSING=1
146+
fi
147+
done
148+
149+
[ "$MISSING" -eq 0 ] || exit 1
150+
echo "All $(echo "$SLUGS" | wc -l | tr -d ' ') doc links resolve to a published page — OK"
151+
152+
- name: Check every docs screenshot exists
153+
run: |
154+
# The docs pages embed screenshots of the app. A missing file renders as a
155+
# broken image on a published page, and nothing else in the pipeline notices.
156+
MISSING=0
157+
for page in docs/docs/*.html; do
158+
for img in $(grep -oE 'src="img/[^"]+"' "$page" | sed 's/src="//; s/"$//'); do
159+
if [ ! -s "docs/docs/$img" ]; then
160+
echo "FAIL: $page references docs/docs/$img, which is missing or empty"
161+
MISSING=1
162+
fi
163+
done
164+
done
165+
[ $MISSING -eq 0 ] || exit 1
166+
echo "All docs screenshots resolve — OK"
167+
168+
- name: Check the announcements feed is published and parses
169+
run: |
170+
# AnnouncementsService fetches docs/announcements.json on every launch.
171+
# An unpublished or malformed feed is a silent failure in the app, so it
172+
# fails the PR instead. `make site` mirrors the repo-root source into docs/.
173+
test -f docs/announcements.json || (echo "FAIL: docs/announcements.json missing — run 'make site'" && exit 1)
174+
175+
node -e '
176+
const fs = require("fs");
177+
const feed = JSON.parse(fs.readFileSync("docs/announcements.json", "utf8"));
178+
if (!Array.isArray(feed)) throw new Error("announcements feed must be a JSON array");
179+
const source = fs.readFileSync("announcements.json", "utf8");
180+
if (source !== fs.readFileSync("docs/announcements.json", "utf8")) {
181+
throw new Error("docs/announcements.json has drifted from the repo-root source — run \"make site\"");
182+
}
183+
console.log(`Announcements feed OK — ${feed.length} entries`);
184+
'
185+
186+
- name: Check nothing points at the unregistered domain
187+
run: |
188+
# tryzerm.com is a find/replace artefact inherited from upstream VoiceInk's
189+
# tryvoiceink.com. It has never been registered, so every one of these was a
190+
# link to nowhere. Zerm/Resources/Links.swift is the replacement.
191+
if git grep -n "tryzerm\.com" -- "*.swift" "*.json" "*.html" "*.md"; then
192+
echo "FAIL: tryzerm.com is not a registered domain — use Links.swift"
193+
exit 1
194+
fi
195+
echo "No links to the unregistered domain — OK"
196+
84197
# ── Security scan ────────────────────────────────────────────────────────
85198
# Secret detection is handled by the GitGuardian app check and CodeQL, which
86199
# run on every PR. This job only covers the repo-specific home-path check.

Makefile

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ SHERPA_XCFRAMEWORK := $(SHERPA_BUILD)/sherpa-onnx.xcframework
1212
ONNX_XCFRAMEWORK := $(SHERPA_BUILD)/onnxruntime.xcframework
1313
LOCAL_DERIVED_DATA := $(CURDIR)/.local-build
1414

15-
.PHONY: all clean whisper sherpa setup build local check healthcheck help dev run install reset-permissions release
15+
.PHONY: all clean whisper sherpa setup build test local check healthcheck help dev run install reset-permissions release site
1616

1717
# Default target
1818
all: check build
@@ -162,13 +162,25 @@ clean:
162162
@rm -rf $(DEPS_DIR)
163163
@echo "Clean complete"
164164

165+
# Run the unit tests. The scheme has always had a TestAction wired to ZermTests, but
166+
# nothing invoked it — no make target and no CI step — so the suite never ran.
167+
# Debug is required, not incidental: `@testable import Zerm` needs ENABLE_TESTABILITY,
168+
# which Release turns off, and the tests fail to compile without it.
169+
test: setup
170+
xcodebuild test -project Zerm.xcodeproj -scheme Zerm \
171+
-configuration Debug \
172+
-destination 'platform=macOS' \
173+
-only-testing:ZermTests \
174+
CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO
175+
165176
# Help
166177
help:
167178
@echo "Available targets:"
168179
@echo " check/healthcheck Check if required CLI tools are installed"
169180
@echo " whisper Clone and build whisper.cpp XCFramework"
170181
@echo " setup Copy whisper XCFramework to Zerm project"
171182
@echo " build Build the Zerm Xcode project"
183+
@echo " test Run the ZermTests unit suite"
172184
@echo " local Build for local use (no Apple Developer certificate needed)"
173185
@echo " release Build Developer ID signed + notarized release DMG"
174186
@echo " install Build, install to /Applications, and reset Launchpad"
@@ -178,6 +190,8 @@ help:
178190
@echo " all Run full build process (default)"
179191
@echo " clean Remove build artifacts"
180192
@echo " help Show this help message"
181-
# Regenerate the derived site pages (changelog, notice, license, building)
193+
# Regenerate the derived site pages: changelog, notice, license, building,
194+
# verification, and the docs/ section from site-content/docs/*.md.
195+
# Needs an authenticated `gh` — markdown is rendered through the GitHub API.
182196
site:
183197
node scripts/build-site.mjs
File renamed without changes.

Notebook/Zerm Refine In Place.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Zerm Refine In Place
2+
3+
How Zerm delivers instant dictation *and* AI enhancement at the same time, added 2026-07-29.
4+
5+
## The problem it replaces
6+
7+
Enhancement used to sit **before** the paste (`TranscriptionPipeline`, the `await enhancementService.enhance(...)` call), so turning it on added the full LLM round-trip to every dictation. Rather than fix that, a hidden flag had been introduced to switch enhancement off entirely:
8+
9+
`InstantTranscriptionMode` — no UI anywhere, defaulted `true`, and force-set `true` for every existing install by the `ZermFastDefaultsVersion < 1` migration. It disabled enhancement in three independent places:
10+
11+
1. `AIEnhancementService.init` cleared `isEnhancementEnabled` on every launch, and the property's `didSet` **persisted** the change — so the user's choice was erased between sessions.
12+
2. `TranscriptionPipeline` gated the enhancement branch on it, so a toggle reading ON still never enhanced.
13+
3. `PowerModeSessionManager.applyConfiguration` cleared it again on every Power Mode switch. The seeded default config matched when nothing else did, so this fired on essentially every recording.
14+
15+
The same migration also pinned `EnhancementTimeoutSeconds = 2` with retry off. Two seconds is shorter than almost any LLM round-trip, so anything that did run timed out and silently pasted the raw transcript.
16+
17+
**Net effect: AI enhancement was dead product-wide while its toggle read ON.**
18+
19+
## The design
20+
21+
`DictationOutputMode` replaces the flag with three explicit cases:
22+
23+
| Mode | Behaviour |
24+
|---|---|
25+
| `instant` | Paste raw, never enhance. Byte-for-byte the old fast path. |
26+
| `instantRefine` | Paste raw immediately, then replace it in place when the enhancement returns. |
27+
| `enhanced` | Wait for the enhancement, paste once. |
28+
29+
Enhancement is slow only because of *where* it sat. Moving it after the paste costs the paste path nothing.
30+
31+
## Refine mechanism
32+
33+
`Zerm/Services/TextReplacement/` — modelled on `AutoLearnVocabularyService`, which had the same AX shape and (worth knowing) **has never had a single call site**, so it was a style reference, not a proven one.
34+
35+
- `AXTextAnchor` — captures the focused element and caret *before* the paste, then confirms where the text landed by polling `kAXSelectedTextRange` and reading the range back.
36+
- `TargetAppCapabilities` — decides whether in-place replacement is even possible; caches the verdict per bundle ID.
37+
- `AXTextReplacer` — the gates, and the two-call selection-then-write replacement.
38+
- `RefineInPlaceCoordinator` — owns the lifecycle: value-change observer, app-switch bail-out, hard 10 s deadline, fallback.
39+
40+
### Non-negotiable details
41+
42+
- **`AXUIElementSetMessagingTimeout(element, 0.15)` on every handle.** Accessibility reads are synchronous IPC; the default timeout is six seconds, which against a wedged app would freeze Zerm. Capture also runs off the main actor.
43+
- **All range arithmetic in UTF-16 code units**, never `String.count`. An emoji is one Character but two code units and a flag is four; measuring with `count` leaves the replacement range short and overwrites the wrong span. Covered by `ZermTests/RefineInPlaceTests.swift`.
44+
- **`CFEqual` to compare `AXUIElement`, not `==`** — the latter compares references. Some Chromium/WebKit hosts return a fresh wrapper per query and compare unequal, which is a false negative, i.e. the safe direction.
45+
- **The decisive gate** is that the string still at the recorded range is byte-identical to what was pasted. AX ranges are absolute: an edit *before* the range shifts it and the check fails; an edit *after* leaves the offsets valid and replacing is still correct.
46+
- **Minimum 12 UTF-16 units.** Below that an accidental match on a shifted range stops being far-fetched.
47+
48+
### Where it actually works
49+
50+
| Class | In-place replacement |
51+
|---|---|
52+
| Native AppKit text (TextEdit, Notes, Mail, Xcode) | Yes — the happy path |
53+
| Electron (Slack, VS Code, Cursor, Discord, Notion) | **No.** Chromium exposes no working `AXSelectedText` setter, and contenteditable exposes nothing settable |
54+
| Browser web content | **No**, beyond simple form controls |
55+
| Terminals / TUIs | **Hard deny-list.** The shell owns the line buffer; AX mirrors a read-only screen |
56+
| Secure fields, or any time `IsSecureEventInputEnabled()` | Never read, never write |
57+
58+
The fallback is therefore the *usual* path, not an edge case: the refined text is persisted to the record and offered via a notification with a Copy action. It is never placed on the clipboard unasked.
59+
60+
**Every gate fails closed. The design can fail to improve the text; it cannot corrupt it.**
61+
62+
## Interactions that bite
63+
64+
- **Auto-send is incompatible.** The field is submitted ~500 ms after the paste, so there is nothing left to refine. When a Power Mode has an auto-send key, the mode degrades to `.enhanced` — decided at mode-resolution time, not at paste time.
65+
- **`SelectedTextService.fetchSelectedText()` posts a synthetic ⌘C** and is called from `getSystemMessage`. During a background refine that would fire while the user is typing. `EnhancementContextPolicy.minimal` suppresses it, and screen capture, for refine.
66+
- **`LlamaEngine` is an `actor`**, so a refine serialises against a Read Aloud rewrite. `RefineInPlaceCoordinator.shouldYield` cancels the refine when Read Aloud starts — Read Aloud is user-initiated, refine is speculative.
67+
- **`scheduleWhisperIdleUnload`** only guarded on `recordingState == .idle`, which is true during a refine. It now also checks `isRefining`.
68+
- **Power Mode enhancement** is now a tri-state (`PowerModeEnhancementOverride`: inherit / on / off). The old bool could not express "leave it alone", which is what made every Power Mode clobber the global toggle. Legacy configs decode `true → .on` and `false → .inherit`, because a `false` was almost always the seeded default rather than a deliberate choice.
69+
70+
## Budgets
71+
72+
- Anchor capture runs concurrently with the clipboard write and resolves inside the pre-paste delay that already existed. **Zero added time on the paste path.**
73+
- The on-device model is pre-warmed at record start (`LocalLLMModelManager.prewarm()`, split out from the Read-Aloud-gated `prewarmIfNeeded()`), so it is resident when transcription ends.
74+
- Refine timeout is a fixed 4 s and deliberately not user-configurable — a refinement landing after the user has moved on is worthless. `EnhancementTimeoutSeconds` (now defaulting to 15) applies only to `.enhanced`, where someone is actually waiting.
75+
76+
Related: [[Zerm Latency Budget]], [[Zerm On-Device LLM]], [[Zerm Auto Paste]], [[Zerm Usage Statistics]], [[Zerm Native Writing Layer Verification]]

Notebook/Zerm Usage Statistics.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Zerm Usage Statistics
2+
3+
The durable metrics store behind the Dashboard, added 2026-07-29.
4+
5+
## Why it exists
6+
7+
The Dashboard appeared to reset itself every day. It was not a display bug.
8+
9+
Every number was recomputed on the fly by scanning surviving `Transcription` rows (`MetricsContent.loadMetricsEfficiently`). Nothing was ever stored. `TranscriptionAutoCleanupService` hard-deletes those rows at launch and on **every** `.transcriptionCompleted`, with a default retention of 1440 minutes — exactly one day. Lifetime totals were therefore only ever a view of whatever history had not been swept yet.
10+
11+
Two further defects in the same area:
12+
13+
- `sweepOldTranscriptions` used `max(retentionMinutes, 0)` and never read the declared `defaultRetentionMinutes`. A retention of `0` set the cutoff to `now`, deleting the **entire** history at launch.
14+
- `AudioFileTranscriptionService` created `Transcription` records without a `transcriptionStatus`, leaving them `.pending` forever. Dropped-audio transcriptions were invisible to the dashboard predicate while still appearing in History. Legacy rows with a `nil` status were excluded the same way.
15+
16+
## The store
17+
18+
`UsageDay` in its own **`usage.store`**, a third `ModelConfiguration` alongside `default.store` (transcripts) and `dictionary.store` (CloudKit).
19+
20+
The separation is the whole design:
21+
22+
- Transcript retention cannot reach it, so clearing history no longer erases the record of use.
23+
- It holds counts and durations only — **no transcript text** — so retaining it is safe even under zero-retention.
24+
25+
One row per day: sessions, words, enhanced sessions, recorded/transcribe/enhance seconds, and the Read Aloud counters.
26+
27+
## Things to know
28+
29+
- **Backfill runs once** on first launch after upgrade, bucketing existing `Transcription` rows by `startOfDay`. Anything an earlier retention sweep already deleted is **unrecoverable** — the docs say so rather than implying the history is complete.
30+
- Pre-upgrade Read Aloud totals land on the upgrade date. The all-time figure is right; that day's breakdown is not real.
31+
- **Refine-in-place records in two parts.** In `instantRefine` the session is counted at paste time, before the enhancement exists, so `recordDeferredEnhancement(seconds:)` adds the enhancement to that day separately rather than as a second session.
32+
- **`resetAll()` had to be added deliberately.** While metrics were derived from transcripts, clearing history cleared them as a side effect; making them durable silently removed the only way to erase them. It also clears the two legacy `TTSSettings` Read Aloud counters. The backfill marker is left set on purpose — re-backfilling would repopulate from surviving transcripts, the opposite of what "clear" means.
33+
34+
## Dead weight removed with it
35+
36+
- `DashboardPromotionsSection.swift` — both `shouldShow*` hardcoded `false`; 160 lines of unreachable "Zerm Pro" and affiliate upsell inherited from VoiceInk.
37+
- `MetricsSetupView.swift` — no references anywhere.
38+
39+
Related: [[Zerm Runtime Privacy Model]], [[Zerm Refine In Place]], [[Zerm Architecture]]

0 commit comments

Comments
 (0)