Skip to content

feat(capture,store): event-driven capture, AX tree diffs, and a Markdown summary card - #46

Merged
zxch3n merged 20 commits into
mainfrom
event-capture-v2
Aug 18, 2026
Merged

feat(capture,store): event-driven capture, AX tree diffs, and a Markdown summary card#46
zxch3n merged 20 commits into
mainfrom
event-capture-v2

Conversation

@zxch3n

@zxch3n zxch3n commented Aug 18, 2026

Copy link
Copy Markdown
Member

Stacked on #41. Rebuilds capture around two independent fact streams and rewrites what a summary card is. Every parameter here was measured — against a competing product's on-disk data, against this vault, or against four model tiers — and the measurements are in docs/event-capture-v2-plan.md beside the decisions they produced.

Why

#41 gave T1 input events so a card could say what the user did. Three things were still wrong, all measured on this vault:

  • Full AX trees every 10s. ~200 KB per frame, 12% of the vault, and no consumer read the structure.
  • OCR was whole-screen. On one WeChat frame, 58 of 123 regions (47%) lay outside the app window — menu bar, a weather widget, and text of background windows clipped mid-word (Conversatio, ter, • Gi). For apps whose AX tree is empty (WeChat: 196 nodes, 0 text nodes) OCR is the only text source, so that noise went straight into cards.
  • threads made every model write three bullets regardless of evidence, and a long Markdown body inside a JSON string field broke three of four model tiers outright.

What changed

Capture. AX trees now also ship as numbered indented text with a per-window keyframe/diff chain (+ / ~ / Removed element IDs: 97-100, 103-137). Measured live: ~1 KB diffs against 260–380 KB full trees, 250–350×. Event-driven capture with the 10s heartbeat as fallback; screenshots throttled to ≥10s and phase-aligned to interaction. root/digest still ship byte-identical — nothing downstream had to change yet.

Input vocabulary v2. Typing bursts carry the typed text and the field's value at the event instant; submits read the focused value; drags carry both endpoints. The field value is the primary content channel because a CJK user's keystream is pinyin fragments — measured on the reference product: 1,796 text-input events, zero Chinese. Secure fields contribute nothing, and an unresolvable focus counts as secure.

OCR window cropping with fragment filtering, every geometric uncertainty failing open. Verified live: weather-widget text last entered the vault at 21:02:13, the new daemon started 21:05:30, zero hits since.

Card v3 — YAML front matter + Markdown body, replacing threads/entities/decisions/category/confidence. Depth follows the evidence, with no target section count. Prompt budget derives from the model's probed context window composed with slot length, replacing a fixed 12k constant. Two tools instead of four, one audio-gated.

T2 waits for ASR while ASR is demonstrably alive (last success newer than last failure), capped at 30 minutes. This fixes a race caught on live data: the 18:10 card was written before its transcript existed and says so permanently, while the 22:10 card got the whole meeting by luck.

Trust model change — please read

CAP-005's keystroke-content prohibition is retired (decided 2026-08-18, recorded in docs/event-capture-v2-plan.md and amended into slot-summaries-and-ax-pipeline.md §7.1). Rationale: all processing is local, the vault is encrypted, export needs explicit approval. Typed characters and field values are now stored. The AXSecureTextField guard is the one thing kept, and it was implemented stricter than specified — it also catches password-looking labels in Electron/web fields, and fails closed when focus cannot be resolved.

A settled PoC belongs with this: a listen-only tap leaves no trace in System Settings → Privacy → Input Monitoring, even while demonstrably capturing keystrokes. Under the old policy that was a feature to advertise; now it means the OS will not tell the user AfterRay observes input, so disclosure is entirely the app's own obligation. The old advice to drop the Input Monitoring item from the Permission Center should be re-evaluated.

This is why the PR carries security — not because it is unsafe, but because a reviewer should see the change rather than find it inside twenty commits.

Verified

  • afterray-store 246/246, afterrayd 191/191, afterray-platform-macos 25/25, shim package 126/126, root Swift 467/467. Zero known failures anywhere. New IO tests 10/10 consecutive via make test-repeat.
  • Clippy adds nothing: the ~117 workspace warnings are pre-existing pedantic ones and none name a new symbol.
  • Live on the production vault: schema 21→26 migrated without loss, protocol 15 handshake, the OCR crop above, diff chains at their predicted size, submit values carrying the user's own sentences with the IME behaving as designed, and 226 lines of chat output the user only read — never touched — correctly labelled not_engaged instead of impersonating activity, which is the bug this whole program started from.
  • 20 v3 cards generated by the local model on real data, 9 of them citing frames; Markdown structure and identifiers intact.

Not verified

  • The ASR wait is proven in tests only — no card has been watched waiting and then arriving with its transcript.
  • ASR_ALIVE_STALENESS_MS (7 days) is a judgement call; only the 30-minute cap has a measurement behind it.
  • A card already written is never re-run when its transcript lands late — the 18:10 card stays incomplete forever. Only slot backfill can redo it, and that overwrites a card the user may have read. Recorded in crates/afterrayd/AGENTS.md.
  • #el<N> element citations round-trip only in unit tests; element highlight in the UI is future work.
  • The corpus regression (blind-scored, ≥20 slots) has not run — it needs a day of accumulated event data.
  • Deviations from the approved design are recorded per workstream in the plan doc's 实现偏差 sections rather than smoothed over.

Confidence

High on the deterministic layers and on capture, which ran a full day on real data. Medium on the summarisation contract: the format is validated across four model tiers and 20 real cards, but its quality against the old contract has not been blind-scored yet.

🤖 Generated with Claude Code

zxch3n and others added 20 commits August 18, 2026 15:01
Records the reverse-engineering evidence (144 segments, 207 output
docs) and the decisions it produced: event-driven capture with a 10s
heartbeat fallback, per-kind tree attachment, numbered-text AX encoding
with keyframe+diff (median 913B vs our 200KB full trees), screenshot
throttling with citability marking, text_input carrying the composed
field value (the keystream is pinyin fragments for a CJK user — 1796
events, zero Chinese), drag with both endpoints, element-level
citations anchored to a single frame, and the T2 card v3 contract
(frontmatter+Markdown; JSON demonstrably breaks three model tiers).

CAP-005's keystroke-content prohibition is retired under the local
trust model (all processing local, encrypted vault, export only with
user approval) — decided 2026-08-18. The secure-field guard stays.
R3 edge snapshots are absorbed into the keyframe policy.

Model: claude-fable-5
Harness: lody
WS1 of docs/event-capture-v2-plan.md §4, pure and in AfterRayCapturePolicy
only — nothing here is wired into main.swift yet.

Three pieces. CaptureTreeNode is the shim's AccessibilityNode as a value
type, so the encoding is testable without live Accessibility permissions.
TreeText renders it to numbered indented lines with a humanized role
vocabulary, inline URL/Document/Description/Value, whitespace collapsed
(a newline in a title would otherwise forge a tree line) and a 300-char
clip that announces itself. Container chains carrying no text below
collapse to one `(collapsed)` line that keeps the best label; a named
AXGroup is chrome and does not by itself hold a subtree open, which is
what makes Electron's container soup collapse at all. TreeDiff aligns two
rendered trees by (role, label) in sibling order, falls back to role alone
so a rename reads as one `~` instead of a removal plus an addition, keeps
every ancestor of a change as `~` context, and coalesces removals into a
leading `Removed element IDs: 97-100, 103-137`. KeyframePolicy answers
skip / keyframe / diff: a window switch always re-bases (the previous tree
describes another window, so it is not a valid diff base), chains cap at
30, an unchanged fingerprint emits nothing.

Numbers count emitted lines only, so a collapsed-away node takes no number
and every `#el<N>` citation resolves inside its own frame.

Verified: `swift test --package-path apps/AfterRayCaptureShim` green, 87
tests (30 before, 57 new), five consecutive runs identical; `make
capture-shim` exits 0. Not verified: behavior against a real AX tree —
no walk feeds this yet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Model: claude-opus-5
Harness: lody
WS1 built the encoding; nothing emitted it. Every accessibility artifact
the shim writes — heartbeat and edge walk alike — now carries a
`tree_text` envelope beside the untouched `root` and `digest`: the whole
numbered tree, a diff against this window's previous one, or `unchanged`
when the fingerprint says the screen did not move.

Chains are keyed per (pid, window title, walk root). The walk root is
part of the identity because the heartbeat walks the application element
while an attached walk starts at a window; diffing one against the other
aligns AXApplication with AXWindow and produces a "delete everything,
add everything" diff larger than the keyframe it replaced.

Because the chains are per window, a diff's base is not in general the
previous artifact in time, so the envelope names it: `chain` + `seq`,
beyond the plan's {mode, text}. A diff whose base cannot be named is not
decodable.

Staging is split from committing: the foreground can move between the
walk and the write, and the screenshot path deletes an accessibility
artifact it could not pair. Only a caller that actually emitted the
artifact advances the chain — otherwise the next diff would be taken
against a tree the consumer never received.

Verified: swift test --package-path apps/AfterRayCaptureShim, 100 tests
green (87 pre-existing + 13 new chain tests); make capture-shim builds.
Unverified: the chains against real windows — that needs a signed dev run.

Model: claude-opus-5
Harness: lody
…vault

OCR reads the whole screen but a moment is about one window. On a measured
WeChat frame 58 of 123 regions (47%) were the menu bar, a weather widget, or
background windows clipped at the window edge; WeChat's own accessibility
tree has zero text nodes, so for apps like it that noise *was* the text
evidence. docs/event-capture-v2-plan.md §7.

ocr_crop.rs maps each Vision box onto screen points with the shim's `Ready`
display size (now kept in `AppState::capture_display`, the daemon's only
source of a screenshot's dimensions), flips Y, and drops regions whose centre
falls outside the frame of the first window node in the moment's paired AX
snapshot. Survivors lose two more classes of junk: regions with no letter, no
digit and no common CJK/kana/Hangul character, and regions under 8 characters
that also touch the window boundary — clipped neighbours like "Conversatio".
Short text away from the edge ("Issues", "19") stays: length alone says
nothing, it is length plus position that identifies a fragment.

Every geometric uncertainty fails open and keeps all regions byte-identical:
no snapshot (AX is attached after the screenshot lands), no window node, an
unmeasurable frame, no display size, or a window frame that misses the
assumed display bounds. `text` and `layout_json` are rebuilt from the kept
regions together, and only when something was actually dropped.

Verified: 19 new unit tests cover the crop, the Y flip, both fragment rules
and every fail-open branch. `cargo test -p afterrayd` — 147 passed, 2 failed,
both of which also fail on this base (`packer_encodes_closed_gop_and_serves_poster`,
and the live-Ollama `live_ollama_answers_from_a_stored_tool_result`, which
failed 3 of 4 runs on the unmodified base). `cargo test -p afterray-store` —
219 passed. Clippy names no new symbol. Not verified: no daemon was run, so
the crop has never seen a real frame.

Model: claude-opus-5
Harness: lody
Event vocabulary v2 in the shim's tap (docs/event-capture-v2-plan.md §2,
§3). CAP-005 is retired, so a typing run now carries the characters it
typed and the composed value of the field they went into — the value
being the primary channel, since a CJK user's keystream is pinyin
fragments and 1,796 measured text_input events held no Chinese at all
while 451 target values did.

The guard that remains is absolute and fails closed: SecureInputGuard
answers from the subrole, the ancestors' subroles, and a label that looks
like a secret (Electron and web apps render password boxes as plain text
fields), and a focus that cannot be resolved counts as secret too. A
guarded run keeps its count and loses its content.

New records: `drag`, with both ends resolved — the source at mouse-down,
the destination at mouse-up — because a drag is a causal edge and one end
of it says nothing; and `window_changed`, promoting the frontmost poll
from something that merely armed a walk into the event a chronicle reads
as "the user moved to X".

Kinds `burst` and `command` keep their names though the plan calls them
text_input and submit: the store's act join matches on those strings, and
this workstream is additive by contract. Everything new is an optional
field an old consumer ignores.

Tree attachment follows §3 through one `requestTreeWalk`: click, drag,
window_changed and submit ask for a walk; typing and scrolling never do;
shortcuts ask every sixth time (~17%). Asking is all it is — the R3
invariants hold, pacing still refuses, walks still spend only through
fire(nowMs:walk:), and no attached walk ever takes a screenshot.

Dragged events join the tap mask but never reach the worker queue: the
callback answers them with one distance comparison, and only the Bool
travels — the coordinates die on the tap thread as they always have.

Verified: swift test --package-path apps/AfterRayCaptureShim, 122 tests
green (87 pre-existing + 35 new); make capture-shim builds. Unverified,
and needing a signed dev run: live typed-character extraction, IME
behaviour, how well both drag ends resolve in real apps.

Model: claude-opus-5
Harness: lody
The shim now sends a typing run's characters and the field's composed
value, a drag's two ends, and explicit window changes. All of it lands as
optional fields with serde defaults: the daemon can be newer than the
helper it spawns during an update, so a pre-v2 batch has to keep parsing,
and a test pins that.

`kind` stays a plain string — the vault keeps it uninterpreted and a
newer shim's vocabulary must round-trip through an older daemon — so
`drag` and `window_changed` need no enum here.

`subrole`, `value` and `secure` join InputTargetRef (and `subrole`
InputAncestorRef) because the daemon serializes targets verbatim into
`target_json`; nothing between the shim and the vault re-derives them, so
a round-trip test guards the shape.

The secure guard is not re-checked here and cannot be: by the time a
record reaches a parser the password is already absent, and a parser that
tried to judge a field it never saw would be guessing. The test says so
where a reader will look for it.

Verified: cargo test -p afterray-platform-macos, 22 passed; cargo check
--workspace clean; cargo clippy -p afterray-platform-macos --all-targets
reports the same 5 pre-existing pedantic warnings as before this change,
none naming a new symbol.

Model: claude-opus-5
Harness: lody
New article context/event-capture-v2.md: the tree_text envelope and its
per-window chains, the v2 input vocabulary with the plan's names mapped
onto the kinds the code actually emits, the secure guard, and the
attachment tiers. The shim's AGENTS.md was already 6.3k against a ~4000
budget before this change, so the depth went to the article and the
AGENTS.md came out slightly smaller than it started while covering more —
the remaining overflow is logged in CONTEXT-GAPS with what should move
next.

The plan doc gains a status column (WS1 e871ca3, WS2 11c401e + cf5e01d,
WS3 partial — its screenshot-throttle half is daemon work this workstream
was not allowed to touch) and a 实现偏差 section recording all ten places
the code diverged from the design and why, in the doc that would
otherwise quietly become wrong.

Verified: swift test 122 green, cargo test -p afterray-platform-macos 22
green, make capture-shim builds.

Model: claude-opus-5
Harness: lody
The WS6 agent's isolation worktree was cut from the main line, so its
work landed on a side branch based on this one; only the plan doc's WS
table conflicted (both sides recorded their own completion). Kept both:
WS1/2/3 status from HEAD, WS6 marked done at 1a7ed62.

Model: claude-fable-5
Harness: lody
Schema 25, additive: `input_events` gains `text` (the typed run the shim
coalesced) and `extra_json` (the record's remaining fields —
`application_name`, `window_title`, `source`, `destination` — as one object
holding only the keys that are present). The target keeps flowing into
`target_json` through the platform crate's own `Serialize`, which now carries
`value` / `secure` / `subrole` with no mapping code here.

Two columns rather than five because the vault stores the input vocabulary
without modelling it: the fields readers filter on have columns, the rest
travel together, and the next field the shim invents costs a mapping line in
the daemon instead of a migration. A schema-24 row reads back with both NULL,
which is exactly what the shim that wrote it sent.

CAP-005's content ban lapsed with the local trust model
(docs/event-capture-v2-plan.md §信任模型变更); the one guard left is the
shim's secure guard, at the source, and nothing here re-checks it because by
this point the content is already absent.

Verified: `cargo test -p afterray-store --lib` 221 pass (219 pre-existing + a
v24→v25 migration test that keeps its rows and a v2-batch round trip);
`cargo test -p afterrayd` 148 pass (147 + the record-to-row mapping test)
with only the two known failures (`packer_encodes_closed_gop_and_serves_poster`,
the live-Ollama stream test).

Model: claude-opus-5
Harness: lody
…their era

The 48h channel existed because the event stream was the sharpest thing the
vault held and had to be the shortest-lived. The trust model changed
(docs/event-capture-v2-plan.md §信任模型变更), so the rule that came with it
goes too: observations and `edge_snapshots` are captured content and now expire
inside `enforce_retention`'s oldest-first sweep, measured against the retention
horizon — the oldest frame the vault still holds. What the user did in a stretch
survives exactly as long as what was on screen during it.

Two decisions the plan left open, both written down in
context/event-capture-v2.md §3b:

- The horizon is the oldest surviving moment, mirroring how orphaned audio is
  swept: content whose surrounding frames are gone has nothing to attach to.
  It is not a second clock — under the size limit nothing expires, like frames.
- No frames left means no horizon and no sweep. "Everything is older than
  nothing" would take live events off a vault that had simply never captured a
  frame. The cost: edge-tree artifacts on a frameless vault are not reclaimable
  by retention; `delete_history` still reaches them.

`prune_input_events` becomes `prune_input_events_before(horizon)`;
`prune_edge_snapshots` becomes `prune_edge_snapshots_before(horizon)`;
`INPUT_EVENT_RETENTION_MS` becomes `SIGNAL_MARKER_RETENTION_MS`, feeding
`prune_signal_gaps` alone. That is all the daemon's ungated sweeper tick and
`Vault::open` still run on a clock — a marker's whole meaning is a deadline.
R3 trees left the events' 48h because that rule assumed the events were the
shortest-lived thing here; following them now would make the trees the longest.

Verified: `cargo test -p afterray-store --lib` 224 pass (219 pre-existing + 5),
including a sweep test that pins events and trees to the surviving frames'
horizon, the frameless fail-safe, marker-only clock expiry, and `delete_history`
now asserting no content column outlives its window — checked in SQL as well as
through the reader. `cargo test -p afterrayd` 148 pass with only the two known
failures. Clippy names none of the new symbols.

Model: claude-opus-5
Harness: lody
The heartbeat used to be the only thing that could take a frame, so a frame
landed wherever the timer's phase happened to fall — up to ten seconds away
from the click that made the screen worth looking at. An `input_events` batch
can now pull the next capture forward (`event_capture_is_due`), throttled to
`max(EVENT_CAPTURE_MIN_INTERVAL_MS 10s, the configured interval)` since the
last request. The cadence is unchanged; only its phase follows the user
(docs/event-capture-v2-plan.md §1).

The scheduler stops being a `tokio::time::interval` and sleeps until
`last_capture_ms + interval` instead. That is what makes the heartbeat a
fallback rather than a second clock: any capture, from either path, re-phases
it, and the atomic every tick already writes is the whole handshake between the
two tasks — no channel, nothing to keep in step.

Both paths go through `fire_capture_tick`, the only door to `capture_screen`:
`capture_paused`, `capture_busy` (now a compare-exchange, since the two callers
genuinely race where the heartbeat's own spacing used to be the only guard) and
`recording_active`. A held tick moves `last_capture_ms` nowhere, so the caller
decides when to ask again — that is why the scheduler waits one interval after
a hold instead of spinning.

`event_capture_is_due` is a pure function of (batch size, last-capture age,
configured interval) and is tested as one: the throttle boundary, a longer
configured interval winning over the 10s floor, a shorter one losing to it, and
"nothing captured yet" being past any throttle without depending on what the
epoch makes the subtraction.

Verified: `cargo test -p afterrayd` 149 pass (147 pre-existing + the throttle
decision + the record mapping) with only the two known failures. Clippy names
none of the new symbols. **Not verified:** the wiring itself — a live shim is
needed to see a batch actually produce a frame, and this session may not touch
a running daemon. Reasoned, not measured: `capture_screen` only writes a
command and never awaits an event, so calling it from the event consumer cannot
deadlock against the stream it is reading.

Model: claude-opus-5
Harness: lody
Plan WS table: WS4 ✅ (schema 25 + retention unification), WS3 now says the
screenshot throttle and the heartbeat demotion landed daemon-side while
`screenshot_id` / citable marking are **deferred to WS5** — their only consumer
is the T2 prompt, so shipping the field before the thing that reads it would
ship something unverifiable.

New "WS4 实现偏差" section for the decisions the plan left open: the horizon is
the oldest surviving frame rather than a second clock, a frameless vault is not
swept, `INPUT_EVENT_RETENTION_MS` was renamed rather than deleted because a
marker's whole meaning is a deadline, no separate keyframe/diff artifact type,
and the event throttle is `max(10s, configured interval)`. The `capture_paused`
× throttle interaction moves from "open PoC" to "same gate in code, unverified
on a live shim".

`context/event-capture-v2.md` gains §3b (what the vault stores and for how
long) and §3c (when the screenshot lands); its "the daemon drops these fields"
note is now false and says so. `acts-join` invariant 6 changes from "never
holds typed characters" — retired with CAP-005 — to "acts carry no content",
which is what the join still guarantees; the retention wording throughout drops
the 48h claim.

Two budget entries in CONTEXT-GAPS, and one real gap logged: "how long does the
vault keep things" had no answer anywhere, because there is no time-based
retention at all — `enforce_retention` is size-driven, and under the limit
nothing expires. That is now a sentence in the store's AGENTS.md.

Verified: `cargo test -p afterray-store --lib` 224, `-p afterray-platform-macos`
22, `-p afterrayd` 149 (two known failures only); `make test-repeat N=10` on the
three IO-touching new tests = 10/10 consecutive green (30 test runs). Clippy
names none of the new symbols.

Model: claude-opus-5
Harness: lody
Persisted summary schema 3: `title` / `description` / `details`, where
`details` is one Markdown document with `afterray://moment/<id>` citations.
The carrier is measured, not stylistic — a long body inside a JSON string
broke three of four model tiers, frontmatter + Markdown came back valid 4/4
(docs/event-capture-v2-plan.md §5) — and threads/entities/decisions/
category/confidence go with it: threads forced three bullets whatever the
evidence held, and a self-reported confidence was wrong at 0.95.

`parse_t2_card_v3` is deliberately tolerant of the three ways the measured
models missed the shape (no FINAL line, a code fence, no frontmatter at
all); a recovered header is flagged `low_trust` rather than thrown away.
`ground_t2_details` strips every citation this slot cannot show and keeps
the label — one bad id is not worth the card around it.

Three card shapes now live in `slot_summaries`, told apart by
`schema_version` and never by null columns. The gates that meant "at least
v2" now say so (`V2_SLOT_SUMMARY_SCHEMA_VERSION`); bumping the current
constant in `find_slot_mentions` alone would have un-indexed every stored
card. A v3 body stays searchable through `details_sections`, and a mention
answers with the matching section, clipped, not the whole document.

Verified: `cargo test -p afterray-store --lib` 230 pass / 0 fail (224
before, +6 new: v3 parse, tolerance, grounding, sections, a v3/v2
round-trip, and the schema-25→26 upgrade). One old-contract assertion
updated — `slot_summary_export_is_structured…` writes a v2 card, so it now
expects the v2 version constant rather than "the newest shape". Clippy
warning count unchanged from HEAD (21, all pre-existing).

Model: claude-opus-5
Harness: lody
The v3 prompt contract, its budget, and the tool catalog land together
because the renderer's signature couples them.

Prompt: `T2_SYSTEM_PROMPT_V3` asks for frontmatter + a Markdown document,
gives the five-part shape as advice rather than schema ("depth follows the
evidence, and nothing else"), and states the citation rules once —
`![label](afterray://moment/<id>)`, standalone renders the frame and inline
stays a link, `#el<N>` anchors belong to the frame that numbered them, and
ids never need fetching because they are already in the input.

Budget: `PROMPT_LINES_BUDGET_CHARS` is gone. `render_t2_prompt` takes the
budget and the daemon derives it from `resolve_context_budget()` — the same
probe chat uses. That asymmetry was a bug: T2 planned against a 16k default
while chat measured the real window, so on a 256k model the summariser wrote
from a twelfth of its context and nothing said so. Conservative 2.5 B/token,
floored at the old constant (a small window buys fewer rounds, not a worse
card) and capped at 4× it until the corpus eval lifts it.

Catalog: `get_run_text` and `get_prev_cards` are removed from the host and
the prompt — measured, the small models never called either and the large
one burned rounds on them. `get_transcript` is named only when the slot
recorded audio. Prev cards are injected with their descriptions instead.

Acts content: the join now carries what the user *wrote*. `ActContent` sits
beside `Acts`, never inside it, so `slot_summaries.acts_json` keeps its
counts-and-labels shape and needs no version; content lives exactly as long
as the events do and is never materialised. The field's value beats the
keystream (a CJK keystream is pinyin fragments), a sentence composed and
then sent appears once, and a field the shim called secure contributes
nothing even if a value rode along with the row.

The fail-open pin was NOT regenerated: everything v3 adds is omitted rather
than emitted empty, so a zero-event slot still produces the pre-acts card
and prompt byte-for-byte. Its comment now says so.

Verified: `cargo test -p afterray-store --lib` 236 pass / 0 fail (+6 over
M1: wrote-blocks, unframed counts, the audio-gated catalog, budget movement,
the secure guard, the content caps). `cargo test -p afterrayd` 151 pass, 1
fail — `packer_encodes_closed_gop_and_serves_poster`, the known GOP failure
on this machine, unrelated. No new clippy warnings in the changed files.

Model: claude-opus-5
Harness: lody
Day panel: a card with `details` renders its Markdown body on expand,
split by `StreamingMarkdown` — the same parser the chat uses, so the two
surfaces cannot disagree on what a heading or a citation is. The panel is
one AppKit text view, so inline syntax resolves to what it says:
`[label](afterray://moment/id)` renders as `label`, a standalone citation
becomes its own line, and an `#el<N>` fragment stays text (it is outside the
media regex by design). Loading the frame and highlighting the element are
the chat's today and this panel's later. v1 bullets and v2 threads render
exactly as before — three shapes decode at once and `schema_version` says
which one a row is.

The agent's day view reads a v3 body as its own sections too, clipped to
200 characters each: the derived bullet list is only the headings, and a day
of headings answers "what did I do today" with a table of contents.

PROTOCOL_VERSION 14 → 15 (both ends). The new field is additive, but a
client that cannot read it draws a v3 card as a title with nothing under it,
and a silent empty panel is what the strict handshake exists to turn loud.

Docs: WS5 marked done in the plan with its ten deviations (why the version
constant split in two, why acts content is a sibling of `Acts`, why
citability landed as `unframed_lines`, why the fail-open pin was not
regenerated); `context/acts-join.md` invariant 6 restated; `agent-tools.md`
gains the T2 slot surface and the numbers behind cutting it to two tools;
`event-capture-v2.md` no longer says nothing reads the new fields; store,
daemon and AfterRayRecall anchors updated.

Verified: `swift test` 343 pass / 0 fail (340 before, +3: v3 decode and
sections, citation rendering, v1+v2 still rendering).
`swift test --package-path apps/AfterRayCaptureShim` 122 pass — untouched.
`cargo test -p afterray-store --lib` 236 pass. `cargo test -p afterrayd` 151
pass, 1 fail (`packer_encodes_closed_gop_and_serves_poster`, the known GOP
failure) plus the live-Ollama tests, which fail only under a parallel
workspace run and pass on their own. Clippy warning counts back at HEAD's.

Model: claude-opus-5
Harness: lody
Brings main's chat rework, compute governor, OCR text selection, the
microphone consent fixes, and its slot-length budget scaling into the
v2 branch. Nine files conflicted; two were real design reconciliations:

- slot.rs: main scaled the prompt budget with slot length (capped at a
  pessimistic 24k because "the window is routinely 4096"); WS5 derives
  the budget from the model's probed window. These solve different
  variables, so they compose: `prompt_budget_for(duration, window)` —
  duration decides what a slot deserves, the probed window is the
  absolute ceiling, the per-run cap keeps its share. Main's 24k
  constant is superseded by the probe. The floor stays the daemon's
  (`t2_prompt_budget_chars`): flooring again in slot.rs made a
  deliberately narrow budget silently wide, which a WS5 test caught.
- PROTOCOL_VERSION: main reached 14, v3's `details` stays 15; merged
  the version history docs on both sides of the wire.

One semantic conflict git could not flag: WS5's day-panel v3 rendering
was written against the old StreamingMarkdown block vocabulary, which
main's MarkdownUI rework collapsed to markdown/momentImage/code.
`markdownSections` now splits headings on the raw lines instead.

The remaining six were two-sided doc/test additions, resolved by
keeping both (compute-governor anchors + v3 anchors; the mic-TCC
invariant; both new daemon tests, third time in this shape).

Verified on the merged tree: afterray-store 242/242, afterrayd
181/181, platform-macos 25/25, shim package 126/126 and builds, root
Swift 467/467. Zero known failures left anywhere — main's d4a573d
fixed the GOP assertion and the flaky live-Ollama test passed.

Model: claude-fable-5
Harness: lody
…itoring PoC

Everything measured live against the production vault on the first
night the v2 build ran: the 21->26 migration, the OCR crop stopping
widget junk at the exact restart instant, the per-window diff chains at
their predicted ~1KB against 260-380KB full trees, submit values
carrying the user's sentences verbatim with the IME behaving exactly as
designed, and 226 untouched chat lines correctly labelled not_engaged —
the bug this whole program set out to fix, fixed on real data.

The three-day-old §7.3 PoC closes: a listen-only tap leaves no trace in
System Settings > Input Monitoring even while demonstrably capturing
keystrokes. Recorded with its meaning inverted post-CAP-005: the OS
will not tell the user AfterRay observes input, so disclosure is the
app's own obligation now, and the old advice to drop the Permission
Center item deserves re-evaluation.

Model: claude-fable-5
Harness: lody
…SR is alive

Two read-only queries the T2 summariser needs before sealing a card it can
never revise: `has_untranscribed_audio_between` (per window) and `asr_health`
(global, one snapshot).

Both read through a new `AUDIO_UNTRANSCRIBED_PREDICATE`, a documented strict
superset of `AUDIO_CLAIMABLE_PREDICATE`: it drops the retry-backoff clause and
adds `running`, because "may the sweeper claim this now" and "is a transcript
still coming" are different questions. It keeps the state list rather than the
`NOT EXISTS` alone — `complete_audio_transcription` marks silence `done` with
no evidence row, so the `NOT EXISTS` half alone would call every quiet segment
untranscribed forever.

There is no retry cap in this codebase: a failed segment retries forever, with
the daemon's delay saturating at `1 << min(attempts, 6)` minutes. That
saturation point is therefore what `exhausted_segments` counts against, now a
shared `AUDIO_BACKOFF_SATURATION_ATTEMPTS` const rather than a fresh cap.

Verified: `cargo test -p afterray-store --lib` 246 passed (242 pre-existing +
4 new), 0 failed; clippy clean for the new symbols. Reader pool only, no
writer lock taken.

Model: claude-opus-5
Harness: lody
…alive

The T2 sweeper had three gates — the settle window, the `ocr_in_flight()`
yield and the governor — and none of them asked whether this slot's audio had
been transcribed. Measured on a live vault: with ten ASR jobs backlogged
(~50 minutes of audio) one card was written before its transcript existed and
says so in `not_captured` forever, while a later card swept after its
transcript landed carries the whole meeting. Which one you get was luck.

`asr_wait_verdict` is the decision, pure and testable: wait only while
something is actually pending, ASR is demonstrably alive, and the cap has not
elapsed. Every other outcome is a named `AsrProceed` branch, because each is a
different way a card would otherwise never arrive — never succeeded (cold
start, model absent, broken worker), failing more recently than succeeding,
every pending segment run out to backoff saturation, cap elapsed. Liveness
compares the last success against the last *failure*, never the clock alone: a
machine that slept eight hours has a stale success and a healthy worker. The
clock is only a one-week backstop for a worker that stopped succeeding without
recording a failure.

Wiring: the sweeper takes `slots_ready_for_t2`, which skips a waiting slot
without touching its state — it stays `Degraded`, the only state
`due_slot_windows` ever picks back up, so waiting is "skip this round". One
`asr_health` per sweep (global) short-circuits the per-slot query entirely
when nothing anywhere owes a transcript. `slot_backfill` keeps the ungated
`slots_awaiting_t2`: it is an explicit request to fill history, where audio
either transcribed long ago or never will. Held-back slots also keep a "run
now" override alive rather than reporting a drained backlog.

Two fixes carried along: the sweep now goes through `run_store` (it was
calling `Vault` synchronously from a tokio worker), and `fail_claimed_audio`
takes its backoff saturation point from the store const rather than a
hand-copied 6.

Verified: `cargo test -p afterrayd` 191 passed (181 pre-existing + 10 new), 0
failed; `cargo test -p afterray-store --lib` 246 passed. Clippy names none of
the new symbols. Two of the new tests drive a real vault end to end: the
sweeper holds the slot, backfill does not, the state stays `Degraded`, and the
slot is swept once the transcript lands or the cap expires.

Model: claude-opus-5
Harness: lody
The T2 anchor in the daemon's AGENTS.md gains the fourth gate and its rule;
the store's gains the two queries, the predicate they share and why its state
list is load-bearing. §2.3 of the slot-summaries doc had the requirement
("rather two minutes late than a summary that missed the meeting") with only
its OCR half implemented — it now says what "worth waiting for" means, names
the 30-minute cap, and carries the measurement behind it.

The known gap is written down in both the daemon's "Watch out" and §2.3: a
card already written is not re-run when its transcript lands late. Only
`slot backfill` can redo it, and that overwrites a card the user may have
read.

Verified: text only, no code touched.

Model: claude-opus-5
Harness: lody
@zxch3n zxch3n added risk:middle Medium correctness risk security High security risk — needs security review labels Aug 18, 2026 — with LodyAI
@zxch3n
zxch3n merged commit ab5e790 into main Aug 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk:middle Medium correctness risk security High security risk — needs security review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant