Skip to content

Commit 4db8fc6

Browse files
angusbezzinaclaude
andcommitted
feat(embedding): an annotation loop an agent can reproduce, locate, and react to (annotkit-op6)
A host that launches many isolated instances of one binary — an HMR session per branch, a gallery host, an inspect window per persona — could not say which world an instance was or where its notes belonged: there is one binary and N launches, and install() had no environment to read. - WORLD CONTEXT on every note. An opaque [String: String] the host registers once and AnnotKit snapshots PER CAPTURE, so toggling dark mode between two notes files them under two different worlds. It reaches the agent through the markdown Context line, the JSON store, and annotation_get_pending. - THE ENVIRONMENT IS THE CONFIGURATION SURFACE. AnnotationEnvironment (pure, takes the env as an argument) drives install(): ANNOTKIT_NOTES_MD / ANNOTKIT_NOTES / ANNOTKIT_EVENTS / ANNOTKIT_ROUTE / ANNOTKIT_CONTEXT*, fanned out through MultiSink. ANNOTKIT_NOTES names the JSON store because annotkit-mcp already reads that variable for that file — one variable, one file, both processes wired by the same env dict. Launcher context and the in-app provider merge with the provider winning: it is the newer measurement. - A WATCHABLE STREAM. NotesFileSink writes atomically, which is what stops a reader seeing half a document and exactly what makes tail -f go silent. So JSONLEventSink appends beside it, one line per capture/edit/delete. Events are DERIVED by diffing each flush, not pushed per keystroke, so a woken reader always finds the note already in the snapshot; the diff compares the ENCODED record, so a scroll moving pins on screen is not an edit; and lines go out in one O_APPEND write(2), so a fleet can share one log. - VALIDATED END TO END. AnnotKitEnvProbe is one embedding host with the human taken out (same Annotation.install path, capture driven in code). AgentLoopE2ETests launches two of them concurrently with nothing but an environment and asserts from outside the processes: context in both outputs, the MCP bridge serving it, a tail -F watcher woken under a second after capture, and no cross-writing. Also fixed: JSONFileSink appended rather than upserted by id, so a second press of Export put a second copy of every note in the store the agent reads. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 2cb514c commit 4db8fc6

23 files changed

Lines changed: 1640 additions & 25 deletions

.beads/interactions.jsonl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{"id":"int-af6f382a5977c5caf2d2dc9e0c48a14a","kind":"field_change","created_at":"2026-08-19T21:49:47.610879Z","actor":"Angus Bezzina","issue_id":"annotkit-op6.1","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented: world context on every note (provider + ANNOTKIT_CONTEXT*, per-capture snapshot, markdown/JSON/MCP), env-driven install and sink destinations (AnnotationEnvironment + MultiSink), append-only JSONL event stream (JSONLEventSink, O_APPEND, diff-per-flush), and end-to-end validation via AnnotKitEnvProbe + AgentLoopE2ETests. 264 tests pass."}}
2+
{"id":"int-441ae3d569178c4ef5d068ed035e2c07","kind":"field_change","created_at":"2026-08-19T21:49:47.97812Z","actor":"Angus Bezzina","issue_id":"annotkit-op6.2","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented: world context on every note (provider + ANNOTKIT_CONTEXT*, per-capture snapshot, markdown/JSON/MCP), env-driven install and sink destinations (AnnotationEnvironment + MultiSink), append-only JSONL event stream (JSONLEventSink, O_APPEND, diff-per-flush), and end-to-end validation via AnnotKitEnvProbe + AgentLoopE2ETests. 264 tests pass."}}
3+
{"id":"int-6f3d5195f734cd5d3614c41b1f8dac01","kind":"field_change","created_at":"2026-08-19T21:49:48.345784Z","actor":"Angus Bezzina","issue_id":"annotkit-op6.3","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented: world context on every note (provider + ANNOTKIT_CONTEXT*, per-capture snapshot, markdown/JSON/MCP), env-driven install and sink destinations (AnnotationEnvironment + MultiSink), append-only JSONL event stream (JSONLEventSink, O_APPEND, diff-per-flush), and end-to-end validation via AnnotKitEnvProbe + AgentLoopE2ETests. 264 tests pass."}}
4+
{"id":"int-f3a9554938910afe979932d09de7bc60","kind":"field_change","created_at":"2026-08-19T21:49:48.723087Z","actor":"Angus Bezzina","issue_id":"annotkit-op6.4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Implemented: world context on every note (provider + ANNOTKIT_CONTEXT*, per-capture snapshot, markdown/JSON/MCP), env-driven install and sink destinations (AnnotationEnvironment + MultiSink), append-only JSONL event stream (JSONLEventSink, O_APPEND, diff-per-flush), and end-to-end validation via AnnotKitEnvProbe + AgentLoopE2ETests. 264 tests pass."}}
5+
{"id":"int-7faec6a4cf272864b5eadba0de92b0c3","kind":"field_change","created_at":"2026-08-19T21:49:49.08739Z","actor":"Angus Bezzina","issue_id":"annotkit-op6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented: world context on every note (provider + ANNOTKIT_CONTEXT*, per-capture snapshot, markdown/JSON/MCP), env-driven install and sink destinations (AnnotationEnvironment + MultiSink), append-only JSONL event stream (JSONLEventSink, O_APPEND, diff-per-flush), and end-to-end validation via AnnotKitEnvProbe + AgentLoopE2ETests. 264 tests pass."}}

DECISIONS.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,57 @@ selected by their rect's CENTRE falling inside the scroller's viewport, so a fra
424424
drawn slightly proud of a card still travels with it while chrome outside the
425425
scroller stays put. macOS-only: `UIScrollView` is not intercepted on iOS.
426426

427+
## The agent-grade embedding loop (annotkit-op6)
428+
429+
An embedding host that launches many isolated instances of one binary — an HMR
430+
session per branch, a gallery host, an inspect window per persona — cannot express
431+
"this instance is Ada's dark-mode world and its notes go here" in the app's source.
432+
There is one binary and N launches. Three decisions follow from that.
433+
434+
**The environment is the configuration surface.** `Annotation.install()` reads
435+
`ANNOTKIT_NOTES_MD` / `ANNOTKIT_NOTES` / `ANNOTKIT_EVENTS` / `ANNOTKIT_ROUTE` /
436+
`ANNOTKIT_CONTEXT*` and builds its own sinks, so a launcher configures each
437+
instance without touching a call site (`docs/embedding.md`). `ANNOTKIT_NOTES` names
438+
the JSON store rather than the markdown one because `annotkit-mcp` already reads
439+
that variable for exactly that file: one variable, one file, both halves of the
440+
product pointed at it by the same env dict. Parsing lives in a pure
441+
`AnnotationEnvironment` taking the environment as an argument — the process env is
442+
neither thread-safe to mutate nor reversible between tests.
443+
444+
**World context is opaque and snapshotted per note.** `[String: String]`, host
445+
vocabulary, no schema: the moment AnnotKit knows what a persona is it stops being
446+
embeddable in the next host. The provider is registered once at install and called
447+
once per capture, not once per session — appearance, window size and persona all
448+
change while a session is open, and a note has to record the world it was made in
449+
rather than the one the app booted into. Launcher context and provider context are
450+
merged with **the provider winning**, because it is the newer measurement.
451+
452+
**The snapshot is unwatchable on purpose, so the stream is separate.**
453+
`NotesFileSink` writes atomically (rename), which is what stops a reader ever
454+
seeing half a document — and is exactly what makes `tail -f` go silent, since it
455+
follows the inode it opened. Rather than give that up, `ANNOTKIT_EVENTS` adds an
456+
append-only JSONL log beside it. Two rules keep the pair honest:
457+
458+
- Events are DERIVED by diffing each flush, not pushed from each capture. Both
459+
files are written by the same flush, so a watcher woken by a line always finds
460+
the note already in the snapshot — the stream cannot promise something the
461+
snapshot has not been given. A note typed but not sent produces no line.
462+
- The diff compares the ENCODED record, not the struct. The overlay's window-local
463+
rects move on every scroll, and a note that merely moved on screen must not read
464+
as edited. What the agent can see changed, or nothing changed.
465+
466+
Lines are appended with a single `O_APPEND` `write(2)`, so a fleet of instances can
467+
share one log without tearing each other's lines and each line names its own
468+
world's snapshot — one `tail -F` covers the fleet. The stream is never a
469+
substitute for the snapshot: replayed from the beginning it would double-count
470+
edits and resurrect deletes, and the `process-agentation-notes` skill still reads
471+
the markdown.
472+
473+
`AnnotKitEnvProbe` is one such instance with the human taken out — same
474+
`Annotation.install` path, capture driven in code — and `AgentLoopE2ETests` runs
475+
two of them at once to assert reproduce/locate/react/isolate from outside the
476+
processes.
477+
427478
## IP hygiene (carried into the F7 legal gate)
428479

429480
- Do not copy original Agentation source (PolyForm Shield 1.0.0, non-compete). Only the `AGENTATION_NOTES.md` file format is reused, reimplemented clean-room.

PARITY.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ row; each asymmetry is closed by code or has a tracked mitigation.
2626
| Pin CLICK-TO-EDIT in both tools (VRT-u209) | shared: `SelectionGesture.resolve(…pins:)` consults `PinAttentionRule.pressedNote` on a press that did not travel, and the shared catcher in `OverlayView` opens the editor for `.editNote` | same | none — the whole decision is a pure rule over the notes' stored window-local anchors, so both hosts route a tap identically, and iOS gets it from the one catcher it already shares. The TRAVEL gate is the only platform-sensitive part and it is already parameterized (`SelectionGesture.minimumTravel` is 10 on iOS vs 6 on macOS, because a finger rolls several points on a deliberate tap) — so a touch that would have been a sloppy tap still opens the pin's editor rather than being read as a drag. Verification is the same asymmetry as the row above: probe 11c is macOS-only; iOS relies on the shared unit tests |
2727
| Recall survives a scroll | `KeyablePanel.scrollWheel` measures the translation it applied to the host's `NSScrollView` and feeds `AnnotationSession.translateNotes(by:within:)` | none | ASYMMETRIC BY THE MECHANISM: the correction is affordable on macOS only because the overlay panel already intercepts every wheel event and drives the clip itself, so the exact translation is known at the moment it is applied. iOS does not intercept `UIScrollView`, so nothing there knows a scroll happened; a note's rects stay where they were captured, which is the pre-existing behaviour `AnnotationPins` has always accepted for the pin. The RECEIVING side is shared and unit-tested (`translateNotes` is pure bookkeeping over `pending`), so an iOS host that later observes its own scrollers can adopt it without re-deriving anything |
2828
| Overlay excluded from element lookup | AX window identifier (`AXIntrospection.overlayWindowIdentifier`) filtered out of every `kAXWindows` read | `PassThroughWindow` TYPE identity filtered out of `IOSElementSource.windows()` | ASYMMETRIC BY NECESSITY — the hosts are different window kinds. macOS's overlay is a separate `NSPanel` matched by the identifier the controller stamps on it; iOS's is a `UIWindow` in the HOST's scene sharing its pid, so no pid/scene filter separates it and a type check (internal to the module) cannot drift the way an identifier convention can. Both filter in the shared window lookup, so snapshot / hit-test / region-anchor / marquee agree; leaving it in would let a marquee bind the user's note to AnnotKit's own UI |
29+
| Env-configured embedding (destinations, world context, event stream) | `Annotation.install` reads `AnnotationEnvironment` and builds `NotesFileSink`/`JSONFileSink`/`JSONLEventSink` | same | none in the code — the environment parse, the sink fan-out, the per-capture context snapshot and the JSONL diff are all platform-free, and both `install` overloads go through one `makeSession`. ASYMMETRIC VERIFICATION, recorded rather than closed: `AnnotKitEnvProbe` (and the `AgentLoopE2ETests` that launch two of it) mounts a real overlay on an `NSWindow`, so it is macOS-only for the same reason `AnnotKitProbe` is. What that leaves unverified on iOS is only the MOUNT; every file the contract produces is written by shared code covered by `AnnotationEnvironmentTests`, `EventStreamTests` and `SinkTests` |
2930
| Coordinate space | Cocoa bottom-left to AX top-left flip | UIKit top-left native | iOS needs no flip; shared `ScreenSpace` used only on macOS |
3031
| Screenshot | ScreenCaptureKit / `cacheDisplay` | `UIGraphicsImageRenderer` + `drawHierarchy` | both capture own hierarchy only; no cross-window or secure overlays |
3132
| Overlay host | resizing `NSPanel` (toolbar corner idle, full screen annotating) | pass-through `UIWindow` | both interactive; selection via the shared SwiftUI catcher, not a global monitor |

Package.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ let package = Package(
7474
.swiftLanguageMode(.v6)
7575
]
7676
),
77+
// One isolated embedding host, configured from its environment alone and
78+
// driven in code: mounts via `Annotation.install`, captures a note with
79+
// host world context, and exports to whatever destinations the launch env
80+
// named. `AgentLoopE2ETests` runs two of these side by side to assert an
81+
// agent can reproduce the world, find the notes, and be woken by them.
82+
.executableTarget(
83+
name: "AnnotKitEnvProbe",
84+
dependencies: ["AnnotKit"],
85+
swiftSettings: [
86+
.swiftLanguageMode(.v6)
87+
]
88+
),
7789
// Off-screen overlay diagnostic harness. Unlike AnnotKitProbe (which only
7890
// exercises the IDLE corner panel), this one calls `Annotation.install()`
7991
// then `Annotation.start()` so the child panel EXPANDS to the full host

README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ ContentView()
3737
Annotation.install(sink: ClipboardSink(format: .json))
3838
```
3939

40+
Register a **world-context provider** and every captured note snapshots it, so an
41+
agent can put back the world the note was made in instead of guessing:
42+
43+
```swift
44+
Annotation.install(
45+
context: { ["persona": currentPersona, "appearance": appearanceName] }
46+
)
47+
```
48+
4049
## How it works
4150

4251
- **macOS** queries the app's own accessibility tree (the only strategy that
@@ -68,13 +77,40 @@ swift run annotkit-mcp path/to/AGENTATION_NOTES.json
6877

6978
Tools: `annotation_get_pending`, `annotation_resolve`.
7079

80+
## Multi-instance hosts
81+
82+
A host that launches many isolated instances of one binary — a design loop with an
83+
HMR session per branch, a gallery, an inspect window per persona — configures each
84+
of them from the launch environment alone, with no change to the app's call site:
85+
86+
```sh
87+
ANNOTKIT_NOTES_MD=$WORLD/notes.md \
88+
ANNOTKIT_NOTES=$WORLD/notes.json \
89+
ANNOTKIT_EVENTS=$WORLD/events.jsonl \
90+
ANNOTKIT_CONTEXT_PERSONA=ada \
91+
./MyApp
92+
```
93+
94+
`ANNOTKIT_EVENTS` adds an append-only JSONL log beside the snapshot — one line per
95+
capture, edit and delete — because the snapshot itself is written atomically and
96+
so cannot be followed with `tail -f`. An agent watches the stream and reads the
97+
snapshot the line names:
98+
99+
```sh
100+
tail -F "$WORLD/events.jsonl" | while read -r line; dodone
101+
```
102+
103+
Full contract, including how launcher context and the in-app provider merge:
104+
[`docs/embedding.md`](docs/embedding.md).
105+
71106
## Build
72107

73108
```sh
74109
swift build
75110
swift test
76-
swift run AnnotKitProbe # live macOS smoke test
77-
swift run AnnotKitDemo # interactive demo app (overlay mounted; good for recordings)
111+
swift run AnnotKitProbe # live macOS smoke test
112+
swift run AnnotKitDemo # interactive demo app (overlay mounted; good for recordings)
113+
swift run AnnotKitEnvProbe # one env-configured embedding host, driven in code
78114
```
79115

80116
Swift 6 (strict concurrency), macOS 15+, iOS 17+.

0 commit comments

Comments
 (0)