WARNING THIS IS A VIBE-CODED PROTOTYPE
A macOS menu-bar app (Swift) that runs on the owner's Mac and feeds learner-server, which parses, dedupes and stores everything and hosts the Learner web app. The desktop app:
- Coaches talk time on calls. An on-device speech detector (Silero VAD on Core ML,
menu-bar-app/Speech.swift, ~1 % of a core) hears the owner start talking on the microphone within about a third of a second; screenpipe's transcripts still supply other speakers and word counts. It tracks how long the owner has talked without yielding, and at 30 s and 60 s gives a cue for the session's randomised treatment arm (a screen toast in every arm, plus sound, a Pavlok vibration or shock). Every turn is a trial row sent to the server. - Archives ChatGPT and Claude web chats. The ai-vault CLI exports them hourly; the app fills in ChatGPT voice-chat transcripts that ai-vault leaves out and sends every changed conversation to the server.
- Reports its health. A heartbeat every 5 minutes, and a tray icon that turns red, with the reason, when anything is stuck: screenpipe, the server, the device key, an expired ai-vault login, the trial database, the CLI uploader.
Codex CLI and Claude Code sessions are captured by learner-server's clients/uploader
(learner-upload), not by this app (yet): it tails ~/.codex/sessions and
~/.claude/projects and uploads the raw session files with the same device key and client id.
Everything goes to learner-server over HTTPS as POST ${LEARNER_API_URL}/rpc/<name> with body
{"data": <input>} and the device key in x-api-key. The wire shapes are pinned in
contract/desktop.ts and contract/contract.json (see contract/PINNED_COMMIT).
Learner Desktop and learner-server are always installed together, on one machine or two
(learner-server docs/specs/server-desktop-pairing.md):
- Pairing payload. The server's installer (same machine) or its operator (remote, with
mint-api-key.ts --device <id> --pairing <file>) leaves{apiUrl, clientId, apiKey}, mode 600, at~/Library/Application Support/Learner/desktop-pairing.json(LEARNER_PAIRING_FILEoverrides). At launch the app checks it, checks thatGET <apiUrl>/api/healthanswersproduct: learner-server, contract: 1, puts the key in the Keychain (servicelearner-server, account the client id), pins the URL and client id, and deletes the file. A file it refuses stays put and is logged. - Server and client id.
LEARNER_API_URL/LEARNER_CLIENT_ID, else the pinned ones, else this install's originalhttps://dev.biztoolz.app/learner-apiandben-mbp. The client id must not change: the server keys every thread on it. - Device key.
LEARNER_API_KEY, else the Keychain item for the client id. It is only ever sent to the pinned URL. - What the server advertises.
getPairingEndpointssays where its pieces are: the topology (which turns the CLI uploader on forremote), the GrowthBook link and the web UI link (each a menu line only when published). A heartbeat reply whoseendpointsVersiondiffers from the one held refetches them. If the server advertises a different API URL, the menu offers to switch (it asks first: switching sends the key there). - What it sends:
recordHabitEvents(trial rows),archiveConversationDocuments(chats, always the full conversation; the server dedupes),recordAppHeartbeat(with a pairing report: the API URL in use, the endpoints version held, and screenpipe's local URL).
One Swift app (menu-bar-app/*.swift, built with SwiftPM) running a few workflows, all on
the shared primitives in Core.swift — Log (levels and a prefix per workflow), Job
(recurring work: one run at a time, backoff, schedule and outcome kept across restarts,
problems shown in the tray), ChildProcess (every program the app runs has a deadline and
a SIGKILL fallback), Scheduler (one 1 s clock) and Shutdown (bounded, logged quit):
screenpipe :3030 ──poll──▶ Speaking coach (Coach*.swift) ──▶ Pavlok / sound / toast
│ trial rows
▼
behavior_experiments.sqlite ──Outbox job (8 s)──▶ recordHabitEvents
ai-vault CLI ──Export job (hourly)──▶ ~/ai-vault-data ──Voice──▶ ──Archive job──▶ archiveConversationDocuments
Status job (8 s) ──▶ one AppStatus ──▶ tray + menu Heartbeat job (5 min) ──▶ recordAppHeartbeat
Live trial rows are metadata.measurement = "voiced": a turn is a run of voiced segments and
its duration the summed voiced audio (median ~7.5 s). They also carry gap_duration_sec, the
wall-clock span of the turn. Historical baseline rows on learner-server were measured
differently; see the server repo.
Requires macOS 14+, Xcode (Swift 6), screenpipe, and for chats the ai-vault CLI (npm i -g ai-vault).
security add-generic-password -s learner-server -a ben-mbp -w # store the device key (prompts)
scripts/install-hooks.sh # test lanes on commit and push
scripts/build-app.sh # build and install Learner Desktopbuild-app.sh builds with swift build -c release, installs ~/Applications/Learner Desktop.app and its LaunchAgent (com.benpollack.learner-desktop), then restarts it.
BUNDLE_ONLY=1 OUT_DIR=<dir> builds and signs the bundle without installing it.
Everything runs inside the app; the only helper process is learner-server's CLI uploader,
when the server runs on another machine (supervised by ManagedHelper: restarted with
backoff, stopped with its children at quit, an orphan from a crash reaped on the next
launch). There is no SSH tunnel: GrowthBook is installed with learner-server and the menu
opens it at that server's origin + /growthbook/ (GrowthBookLink; GROWTHBOOK_URL
overrides).
Chats: the Export job runs ai-vault backup hourly (and on demand), stopping a run that
prints nothing for 10 minutes; ChatGPT voice transcripts that ai-vault leaves out are
filled in (Voice.swift); the Archive job sends every changed conversation.md with
archiveConversationDocuments, ≤50 per call. A local ledger
(~/.ai-aggregator/sync-ledger.sqlite, table archive_ledger) resends a conversation only
when its content hash changes. A 422 parks that one document; 401/403 blocks until the key
is fixed; 413 halves the batch; 429/5xx back off. The parser matches the earlier TypeScript
parser byte for byte (Tests/Fixtures/parity), so the server's deduplication keys stay stable.
Helpers used to be separate /bin/bash LaunchAgents. macOS lists those in System
Settings → Login Items as "bash" from an unknown developer, and once they were switched
off there, launchd silently stopped starting them at login: they stayed down for ten days
after the 2026-10-11 reboot. Now the app's single entry is all that has to be allowed,
and the tray shows 🧠 🔴 LOGIN if it is not.
The bundle identifier is still com.benpollack.ai-aggregator-menubar: the Bluetooth
permission and the saved Pavlok settings are keyed to it, so renaming it would reset both.
One-shot maintenance tools are subcommands of the app binary (menu-bar-app/Tools*.swift).
They print to the terminal and exit; run them from this repository:
swift run LearnerDesktop tool start-capture-phase --dry-run # new experiment phase after a screenpipe update
swift run LearnerDesktop tool growthbook-design # print the Speaking Coach GrowthBook design
swift run LearnerDesktop tool growthbook-design --apply # ...or write it over `ssh ovh` (refuses to overwrite)
swift run LearnerDesktop tool vault-report [root] # ai-vault conversation counts, never textThe installed app answers the same (".../Learner Desktop.app/Contents/MacOS/learner-desktop" tool ...).
Everything is Swift (Swift Testing, swift test). Every workflow — the speaking coach,
the trial outbox, the chat export → voice transcripts → archive, the helpers, status and
shutdown, the owner tools — has end-to-end tests in three lanes:
scripts/test.sh light # fixtures and stand-ins only; under 60 s (pre-commit hook)
scripts/test.sh medium # real child processes, the built binary, local HTTP stand-ins; 60-180 s
scripts/test.sh heavy # the live machine (screenpipe, ai-vault, learner-server); read-only
scripts/test.sh all
scripts/install-hooks.sh # pre-commit runs light (+ gitleaks); pre-push runs medium + heavy- Light (
Tests/LightTests) runs in parallel against fixtures, injected clocks and in-process fakes. No network, no child processes, no Core ML model, no Bluetooth (the app never starts its radio under test). It builds the real tray menu and clicks every line in-process (TrayMenuClickTests): every action has a handler, every link and folder opens the right place, every setting flips and flips back. - Medium (
Tests/MediumTests) runs one test at a time: fakeai-vaultandclaudescripts (including ones that hang and ignore SIGTERM), the real capture scripts in a stubbed sandbox,tool preflightand the coach poller againstHTTPStandInservers, and Silero on speech synthesized withsay(SpeechModelTests). Spawned app binaries get the sandboxed data folders explicitly (appEnv()). - Light and medium point the app's data folders at a throwaway directory
(
LEARNER_DATA_DIR,AI_VAULT_DATA), so they never touch~/.ai-aggregatoror~/ai-vault-data. No test waits on a real schedule; each finishes within 2 minutes. - Heavy (
Tests/HeavyTests,LEARNER_HEAVY=1) needs the real world: screenpipe on:3030(read-only queries), the real export and ledger, one ChatGPT read, learner-server health, and withLEARNER_REMOTE_E2E=1the deployed GrowthBook design overssh ovhand the server's pairing endpoints for this device (both read-only; SQL from GrowthBook runs as one read-only statement). It also builds the release binary and checks the signed bundle as it ships, and runs the audio benchmark: synthesized speech through screenpipe's speech-to-text endpoint into the coach, timing the 30 s warning (it checks that screenpipe stored none of it; any row left behind is taggedlearner-heavy-testand fails the test;LEARNER_HEAVY_AUDIO=0skips it; a push runs one speaking rate,=fullboth). The bundle's microphone self-test is skipped during a meeting or on a Bluetooth input. Each suite skips, with the reason, when its dependency is missing. The live wristband test needsLEARNER_HEAVY_PAVLOK=1. About 3–4 minutes.
Pairing end to end (LEARNER_HEAVY=1 LEARNER_PAIRING_E2E=1, in no hook): starts
learner-server's own harness on ovh (its test database, a throwaway device its teardown
revokes) behind one ssh -L 3019 tunnel, and pairs with it through the app's real import,
endpoints and heartbeat. The test key stays in memory. The remote-topology case also needs
127.0.0.1 learner-pairing.test in /etc/hosts.
UI tests (UITests/, XCUITest; scripts/ui-test.sh, needs XcodeGen and the app
running) click the installed app's real menu-bar item: how long the menu takes to open
against an ordinary AppKit menu, every submenu opening (the cue panels too), and two
settings flipped through the real menu and back. They move the pointer, so they are in no
hook; run them when the Mac is free. They never click anything that acts on data, devices or
accounts (that is covered in-process by the light lane).
The heavy destructive suite really does stop and restart the capture stack and the installed app, and stays skipped even in the heavy lane unless you opt in a second time:
LEARNER_HEAVY_DESTRUCTIVE=1 scripts/test.sh heavy --filter DestructiveNothing is recorded while they run. Do not run them during a call.
Transcription comes from the installed screenpipe desktop app (mediar-ai/screenpipe, tested with 2.7.116 / engine 0.4.52). It is not vendored; the update-check agent re-verifies it after each screenpipe update.
- The app reads
audio_transcriptionsviaPOST http://localhost:3030/raw_sql. - Transcription latency drives warning latency. screenpipe cannot write a transcription
until its audio chunk completes, so
audioChunkDurationsets a floor on how late the on-screen warning can fire. When lag is high, several segments land with one writetimestampand the app's wall-clock turn timing is distorted — turns merge or split wrongly. Every turn recordsmetadata.ingest_lag_sec_max; anything abovemaxTrustedIngestLagSec(10 s) is taggedmetadata.data_quality = "suspect_ingest_lag". Filter those out of analysis. - Settings this experiment depends on are captured in
config/screenpipe-settings.json.
Since 2.7.34 screenpipe will not start recording or its API until someone signs in to a
screenpipe account, unless SCREENPIPE_SKIP_ONBOARDING=1 is in its environment.
Learner Desktop sets that in launchd's user environment at launch, and relaunches a
screenpipe that started without it (at login it can start first). This replaced the
separate com.screenpipe.env LaunchAgent, which macOS switched off in Login Items:
after the 2026-10-11 reboot screenpipe sat signed out and recorded nothing for ten days.
In 2.7.116, Quit does not exit screenpipe while its server is running: it stops capture and keeps serving saved history ("search-only"). Opening the app again resumes recording.
When the installed screenpipe version changes and holds still for 10 minutes, Learner
Desktop starts a fresh headless Claude Code session (claude -p) in this repo. It follows
docs/screenpipe-update-runbook.md with only the
commands allowed in config/screenpipe-update-agent.json: it checks the whole system,
re-applies the sign-in flag, resumes capture, and on a version-only change starts the new
phase with learner-desktop tool start-capture-phase (new GrowthBook phase, re-pinned config,
committed). Runs are capped at $5 and 30 minutes, once per version. The menu has "Run
screenpipe Update Check Now".
Every run is logged in ~/.ai-aggregator/logs/screenpipe-update-checks/: the full session
(.jsonl), its report (.md, with the session id to resume it), and one line per run in
index.log.
Unattended runs need a Claude login that does not lapse. Create a long-lived token once and save it where the app reads it (it is passed to each run, never logged):
claude setup-token
stty -echo; (umask 077 && cat > ~/.ai-aggregator/claude-oauth-token); stty echo; echo " saved"The second line waits for the token: paste it (nothing is echoed), press Return, then
Ctrl-D. It reads what you paste rather than the clipboard (copying the command itself would
overwrite that), and accepts a token that wrapped onto several lines; the app strips the
line breaks. A run with a bad token logs STATUS: RUN FAILED (… 401 …). Without a token, runs use the interactive
claude auth login, which is stored per $USER and has expired between updates before.
screenpipe is the experiment's measuring instrument. Changing it mid-phase makes any shift in turn counts or durations impossible to attribute to the intervention, so:
- Auto-update cannot be turned off. screenpipe pushes a remote control policy
(
settings.remoteControlPolicy.autoUpdate.forceEnabled = true) that re-enables it on every launch and greys out the toggle. SettingautoUpdate: falseinstore.binis reverted within seconds. So the guard is detection, not prevention: the preflight pins both the app and engine versions and fails when either moves. - Every turn records
metadata.screenpipe_version, read from/healthby the app. An instrument change therefore shows up as a column you can segment on rather than as an invisible confound. - Treat a version change as a phase boundary in GrowthBook: end the phase, upgrade, re-run the preflight, start the next phase. Analyses should not span versions.
Run the preflight before a session that matters:
swift run LearnerDesktop tool preflightIt compares the live ~/.screenpipe/store.bin against config/screenpipe-settings.json and
checks that /raw_sql still returns the columns the app polls. It exists because
TranscriptionMode::Batch is screenpipe's #[default]: a settings reset, migration or
reinstall silently reintroduces up to two minutes of deferred transcription — which is what
corrupted the 2026-09-08 data — and nothing errors when it happens. Exit codes: 0 fine,
1 drift, 2 screenpipe unreachable.
When a settings change is deliberate, re-capture the expectations so it lands in git:
swift run LearnerDesktop tool preflight # shows exactly what drifted
# ...then update config/screenpipe-settings.json and commit itNote: screenpipe rewrites store.bin from memory while it is running, so editing that
file with the app up does not stick. Stop screenpipe first, or change the setting in its UI.
Four arms, assigned per session (treatment_arm): baseline, caw_audio, pavlok_shock,
haptic_vibe. The visual toast shows in every arm, including baseline. The arms differ
only in the stimulus added on top of it, so the experiment measures what audio, shock or
vibration adds over the visual cue — and the coach keeps working in every meeting. Each
toast is recorded in metadata.interventions[] with modality: "toast".
- Warning at 30 s, hard stop at 60 s.
breached_30s/breached_60sare strict (> 30,> 60).- A turn of 5–30 s (a clean yield) earns a reward vibration only in the wristband arms;
was_rewardedrecords whether one was actually delivered. warning_fired_at/shock_fired_athold the streak length when the stimulus actually fired, not the threshold constant.latency_to_yield_secis the time from the warning firing to the speaker yielding. This is the primary behavioural outcome.metadata.interventions[]records every stimulus attempt with the modality and whether the device or sound confirmed delivery, so a failed dispatch is distinguishable from a delivered one.metadata.intervention_confirmedsummarises it.
Synthetic data never reaches production metrics: simulated turns are written with
call_category = 'demo' and metadata.simulated = true, which the server filters out.
Past Call Transcripts/anddata/are gitignored: they would hold real meeting transcripts. Nothing in them is used by the app.- App data lives in
~/.ai-aggregator(logs, job state, the trial database, the archive ledger); ai-vault's export in~/ai-vault-data.
Enable Keep Bluetooth Connected under the menu's ⚡ Pavlok submenu, then run
"~/Applications/Learner Desktop.app/Contents/MacOS/learner-desktop" --pavlok-test-fire <vibe|beep|zap> [intensity].
Vibration, beep, and zap each keep an independent saved 0–100% test intensity; 0% disables
that test. These settings do not change the automatic 30s/60s coaching levels.
Each run sends one pulse over direct Bluetooth, waits for the selected characteristic and its write acknowledgement, and prints the result. Cloud backup is excluded from manual connection tests. An acknowledgement means the device accepted the command; it does not measure the physical sensation. For Pavlok 2/3, beep uses the same intensity field as vibration, as implemented in PyPav2.
If Bluetooth is not ready, charge/wake the band and choose Scan & Connect via Bluetooth. For Pavlok 3, USB-C power turns it on automatically. Temporarily disconnecting its phone Bluetooth connection can help isolate connection issues.
Validation: scripts/test.sh light (the Pavlok suites) checks
menu wiring and executes the production packet encoders. The opt-in live test
in Tests/HeavyTests (Pavlok live suite) sends one 25% vibration and requires
a Bluetooth acknowledgement; it never tests zap automatically.
Pavlok requests and results are also persisted in the existing habit_events
behavior database as habit_type=pavlok_intervention, using the existing remote
sync path. Metadata links lifecycle records with operation_id and includes
modality, requested percentage, phase, result/error, device identity, battery,
transport, elapsed time, and explicit device_acknowledged. Manual tests use
manual_test=true and treatment_arm=manual_test; they are excluded from the
menu's speaking-trial count. Raw diagnostic output remains in
~/.ai-aggregator/logs/menubar.log. A cloud HTTP success is never marked as a
Bluetooth device acknowledgement.
The menu-bar app owns the persistent Bluetooth connection. Keep Bluetooth
Connected is independent of automatic wearable feedback; it can stay enabled
while automatic coaching stimuli remain off. The app remembers the last connected
band, reconnects after link loss, and retains the link between manual tests.
When the signed app is running, --pavlok-test-fire forwards a single validated
request to that app instead of opening another Bluetooth connection or changing
saved intensity/feedback settings. A forwarding timeout is an unknown outcome and
is not automatically retried.