margin 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- margin/__init__.py +0 -0
- margin/batch.py +41 -0
- margin/claude.py +89 -0
- margin/cli.py +294 -0
- margin/config.py +188 -0
- margin/fold.py +750 -0
- margin/images.py +66 -0
- margin/inbox.py +335 -0
- margin/outline.py +282 -0
- margin/patches.py +243 -0
- margin/pinch_check.py +363 -0
- margin/skills/margin/SKILL.md +95 -0
- margin/store.py +186 -0
- margin/sync.py +666 -0
- margin-0.2.1.dist-info/METADATA +113 -0
- margin-0.2.1.dist-info/RECORD +20 -0
- margin-0.2.1.dist-info/WHEEL +5 -0
- margin-0.2.1.dist-info/entry_points.txt +2 -0
- margin-0.2.1.dist-info/licenses/LICENSE +21 -0
- margin-0.2.1.dist-info/top_level.txt +1 -0
margin/pinch_check.py
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""`margin pinch-check`: run a fold page's pinch on a real Android phone and judge it by measurement.
|
|
2
|
+
|
|
3
|
+
A fold page moves one level for each pinch and keeps the paragraph under the fingers in place
|
|
4
|
+
(fold.page). Whether it does that on a phone was a question for a person until 2026-10-26, and a page
|
|
5
|
+
sent to a person to try by hand is the wrong way round: the phone can be driven over adb. This
|
|
6
|
+
serves the page to the phone only (`adb reverse`, the phone's own localhost), opens it in the phone's
|
|
7
|
+
Chromium browser, pinches it with two fingers through the browser's own synthetic gesture
|
|
8
|
+
(claude-human's `android_browser.pinch`, which feeds touch events into the browser's view, since a
|
|
9
|
+
phone with SELinux enforcing refuses the shell a write to its touch screen) behind the same checks as
|
|
10
|
+
a tap (the app has focus, no banner or call, nobody touched the phone for a few seconds), reads the
|
|
11
|
+
page after each pinch through the browser's debugging socket and judges four limits:
|
|
12
|
+
|
|
13
|
+
- the level moves by exactly one, in the pinch's direction (none at the end of the scale);
|
|
14
|
+
- the point of the paragraph that was under the fingers is less than 24 CSS pixels from where it was;
|
|
15
|
+
- the browser's own zoom stays at 1, during the gesture and after it;
|
|
16
|
+
- no frame takes longer than 50 ms from the gesture's start until the change has settled.
|
|
17
|
+
|
|
18
|
+
It records the screen meanwhile (screen captures twice a second, made into an mp4 with ffmpeg) and writes a table (markdown) and the readings (JSON) beside the
|
|
19
|
+
recording. Exit 0 only when every pinch passed.
|
|
20
|
+
|
|
21
|
+
margin pinch-check page.html --serial HOST:PORT [--package com.brave.browser] [--out DIR] [--wait-unlock S]
|
|
22
|
+
|
|
23
|
+
The test never unlocks the phone (a PIN or a fingerprint is the person's own). With --wait-unlock it
|
|
24
|
+
waits, up to that many seconds, until the phone is awake, unlocked and untouched for a minute, so it
|
|
25
|
+
can run detached and start by itself when the person has the phone unlocked but is not using it.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import http.server
|
|
30
|
+
import json
|
|
31
|
+
import socket
|
|
32
|
+
import subprocess
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
from functools import partial
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
|
|
38
|
+
#: the four limits
|
|
39
|
+
MAX_SHIFT_PX, MAX_FRAME_MS = 24.0, 50.0
|
|
40
|
+
#: the levels a fold page has
|
|
41
|
+
LOWEST, HIGHEST = 1, 5
|
|
42
|
+
#: where on the page each round of pinches happens, as a share of how far it scrolls
|
|
43
|
+
PLACES = (0.1, 0.5, 0.85)
|
|
44
|
+
|
|
45
|
+
#: what the page reports at the start of each two-finger gesture, and the frames it draws
|
|
46
|
+
LISTENER = r"""(() => {
|
|
47
|
+
if (window.__pc) return 'ready';
|
|
48
|
+
const pc = window.__pc = {starts: [], frames: [], maxScale: 1};
|
|
49
|
+
let last = 0;
|
|
50
|
+
const tick = t => { if (last) pc.frames.push([t, t - last]); last = t;
|
|
51
|
+
if (pc.frames.length > 6000) pc.frames.splice(0, 3000); requestAnimationFrame(tick); };
|
|
52
|
+
requestAnimationFrame(tick);
|
|
53
|
+
const pick = (at, next, x, y) => {
|
|
54
|
+
const a = at && ((next < 5 && at.closest('.u')) || at.closest('section'));
|
|
55
|
+
if (!a) return null;
|
|
56
|
+
if (!a.dataset.pc) a.dataset.pc = 'a' + Math.random().toString(36).slice(2);
|
|
57
|
+
const r = a.getBoundingClientRect();
|
|
58
|
+
return {id: a.dataset.pc, top: r.top, height: r.height,
|
|
59
|
+
frac: r.height ? Math.min(1, Math.max(0, (y - r.top) / r.height)) : 0};
|
|
60
|
+
};
|
|
61
|
+
addEventListener('touchstart', e => {
|
|
62
|
+
if (e.touches.length !== 2) return;
|
|
63
|
+
const x = (e.touches[0].clientX + e.touches[1].clientX) / 2, y = (e.touches[0].clientY + e.touches[1].clientY) / 2;
|
|
64
|
+
const at = document.elementFromPoint(x, y), level = +document.body.dataset.l;
|
|
65
|
+
pc.maxScale = visualViewport.scale;
|
|
66
|
+
pc.starts.push({t: performance.now(), x, y, level, pinches: window.pinches || 0,
|
|
67
|
+
up: pick(at, level - 1, x, y), down: pick(at, level + 1, x, y)});
|
|
68
|
+
}, {capture: true, passive: true});
|
|
69
|
+
visualViewport.addEventListener('resize', () => { pc.maxScale = Math.max(pc.maxScale, visualViewport.scale); });
|
|
70
|
+
return 'ready';
|
|
71
|
+
})()"""
|
|
72
|
+
|
|
73
|
+
#: what happened since the last gesture began
|
|
74
|
+
READ = r"""(() => {
|
|
75
|
+
const pc = window.__pc; if (!pc || !pc.starts.length) return null;
|
|
76
|
+
const s = pc.starts[pc.starts.length - 1], level = +document.body.dataset.l;
|
|
77
|
+
const which = level > s.level ? s.down : level < s.level ? s.up : null;
|
|
78
|
+
let after = null;
|
|
79
|
+
if (which) { const a = document.querySelector(`[data-pc="${which.id}"]`);
|
|
80
|
+
if (a && a.offsetParent) { const r = a.getBoundingClientRect(); after = {top: r.top, height: r.height}; } }
|
|
81
|
+
const gaps = pc.frames.filter(([t]) => t >= s.t && t <= s.t + 1500).map(([, d]) => d);
|
|
82
|
+
return {before: s.level, level, y: s.y, anchor: which, after, scale: visualViewport.scale,
|
|
83
|
+
max_scale: pc.maxScale, max_frame_ms: gaps.length ? Math.max(...gaps) : null,
|
|
84
|
+
starts: pc.starts.length, pinches: window.pinches || 0};
|
|
85
|
+
})()"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def expected(before: int, direction: str) -> int:
|
|
89
|
+
"""Pure. The level a pinch should end on: in folds one more, out opens one, within the scale."""
|
|
90
|
+
return min(HIGHEST, max(LOWEST, before + (1 if direction == "in" else -1)))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def judge(reading: dict | None, direction: str) -> list[str]:
|
|
94
|
+
"""Pure. Why this pinch failed, one reason per limit it broke; [] when it passed."""
|
|
95
|
+
if not reading:
|
|
96
|
+
return ["the page reported no two-finger gesture"]
|
|
97
|
+
why = []
|
|
98
|
+
want = expected(reading["before"], direction)
|
|
99
|
+
if reading["level"] != want:
|
|
100
|
+
why.append(f"level {reading['before']} went to {reading['level']}, not {want}")
|
|
101
|
+
if reading["level"] != reading["before"]:
|
|
102
|
+
a, after = reading.get("anchor"), reading.get("after")
|
|
103
|
+
if not a or not after:
|
|
104
|
+
why.append("the paragraph under the fingers could not be found after the change")
|
|
105
|
+
else:
|
|
106
|
+
shift = abs(after["top"] + a["frac"] * after["height"] - reading["y"])
|
|
107
|
+
if shift >= MAX_SHIFT_PX:
|
|
108
|
+
why.append(f"the paragraph under the fingers moved {shift:.0f} px")
|
|
109
|
+
zoom = max(reading.get("scale") or 1, reading.get("max_scale") or 1)
|
|
110
|
+
if abs(zoom - 1) > 0.001:
|
|
111
|
+
why.append(f"the browser zoomed to {zoom:.2f}")
|
|
112
|
+
frame = reading.get("max_frame_ms")
|
|
113
|
+
if frame is None:
|
|
114
|
+
why.append("no frame was drawn while it changed")
|
|
115
|
+
elif frame > MAX_FRAME_MS:
|
|
116
|
+
why.append(f"a frame took {frame:.0f} ms")
|
|
117
|
+
return why
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def table(rows: list[dict]) -> str:
|
|
121
|
+
"""Pure. The results as a markdown table, one row a pinch."""
|
|
122
|
+
out = ["| # | place | pinch | speed | level | shift px | zoom | slowest frame ms | result |",
|
|
123
|
+
"|---|---|---|---|---|---|---|---|---|"]
|
|
124
|
+
for i, r in enumerate(rows, 1):
|
|
125
|
+
rd = r.get("reading") or {}
|
|
126
|
+
a, after = rd.get("anchor"), rd.get("after")
|
|
127
|
+
shift = (f"{abs(after['top'] + a['frac'] * after['height'] - rd['y']):.0f}" if a and after else "")
|
|
128
|
+
zoom = max(rd.get("scale") or 1, rd.get("max_scale") or 1) if rd else ""
|
|
129
|
+
frame = rd.get("max_frame_ms")
|
|
130
|
+
out.append(f"| {i} | {r['place']:.0%} | {r['direction']} | {'slow' if r['slow'] else 'quick'} | "
|
|
131
|
+
f"{rd.get('before', '')} to {rd.get('level', '')} | {shift} | {zoom if zoom == '' else f'{zoom:.2f}'} | "
|
|
132
|
+
f"{'' if frame is None else f'{frame:.0f}'} | "
|
|
133
|
+
f"{'pass' if not r['why'] else 'FAIL ' + '; '.join(r['why'])} |")
|
|
134
|
+
return "\n".join(out)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def plan() -> list[tuple[float, str, bool]]:
|
|
138
|
+
"""Pure. Each pinch to make, as (place, direction, slow): at each place two in and two out quick,
|
|
139
|
+
then one in and one out slow; first of all one out at level 1, which must change nothing."""
|
|
140
|
+
steps = [(PLACES[0], "out", False)]
|
|
141
|
+
for place in PLACES:
|
|
142
|
+
steps += [(place, "in", False), (place, "in", False), (place, "out", False), (place, "out", False),
|
|
143
|
+
(place, "in", True), (place, "out", True)]
|
|
144
|
+
return steps
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
#: the phone's wakefulness and its last input, as dumpsys power prints them
|
|
148
|
+
WAKEFULNESS = "dumpsys power | grep -E 'mWakefulness=|mLastUserActivityTime|ms ago'"
|
|
149
|
+
#: seconds nobody may have touched the phone before the test takes its screen
|
|
150
|
+
QUIET = 60.0
|
|
151
|
+
#: and after an attempt the person interrupted (another app came to the front), since they are
|
|
152
|
+
#: using the phone: five minutes untouched before the next attempt
|
|
153
|
+
QUIET_AFTER_LOSS = 300.0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def ready(power: str, locked: bool, idle: float | None, quiet: float = QUIET) -> bool:
|
|
157
|
+
"""Pure. Whether the phone can take the test now: awake, past its lock screen, and untouched for
|
|
158
|
+
`quiet` seconds (`idle`, from claude_human.android.idle_s), so the browser does not take the
|
|
159
|
+
screen from a person who is using the phone."""
|
|
160
|
+
return "mWakefulness=Awake" in (power or "") and not locked and idle is not None and idle >= quiet
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class Page:
|
|
164
|
+
"""The page in the phone's browser, through its debugging socket forwarded over adb. When the port
|
|
165
|
+
stops answering (adb's connection to the phone dropped and came back, which removes its forwards),
|
|
166
|
+
`eval` forwards it again and tries once more. `ab` is claude_human.android_browser."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, ab, serial: str, package: str, url: str, run_adb):
|
|
169
|
+
self.ab, self.serial, self.package, self.url, self.run = ab, serial, package, url, run_adb
|
|
170
|
+
self.port = ab.forward(serial, package, run_adb)
|
|
171
|
+
|
|
172
|
+
def eval(self, js: str):
|
|
173
|
+
try:
|
|
174
|
+
return self.ab.evaluate(self.port, self.url, js)
|
|
175
|
+
except self.ab.BrowserError as e:
|
|
176
|
+
if "did not answer" not in str(e):
|
|
177
|
+
raise
|
|
178
|
+
self.ab.unforward(self.serial, self.port, self.run)
|
|
179
|
+
self.port = self.ab.forward(self.serial, self.package, self.run)
|
|
180
|
+
return self.ab.evaluate(self.port, self.url, js)
|
|
181
|
+
|
|
182
|
+
def pinch(self, x: float, y: float, scale: float, speed: float) -> None:
|
|
183
|
+
try:
|
|
184
|
+
return self.ab.pinch(self.port, self.url, x, y, scale, speed=speed)
|
|
185
|
+
except self.ab.BrowserError as e:
|
|
186
|
+
if "did not answer" not in str(e):
|
|
187
|
+
raise
|
|
188
|
+
self.ab.unforward(self.serial, self.port, self.run)
|
|
189
|
+
self.port = self.ab.forward(self.serial, self.package, self.run)
|
|
190
|
+
return self.ab.pinch(self.port, self.url, x, y, scale, speed=speed)
|
|
191
|
+
|
|
192
|
+
def close(self) -> None:
|
|
193
|
+
self.ab.unforward(self.serial, self.port, self.run)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _free_port() -> int:
|
|
197
|
+
with socket.socket() as s:
|
|
198
|
+
s.bind(("127.0.0.1", 0))
|
|
199
|
+
return s.getsockname()[1]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class PhoneLost(RuntimeError):
|
|
203
|
+
"""The phone left adb, or its browser's debugging socket went, in the middle of a run."""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run(page: Path, serial: str, *, package: str = "com.brave.browser", out: Path | None = None,
|
|
207
|
+
adb: str = "adb", wait_unlock: float = 0.0, attempts: int = 8, sleep=time.sleep,
|
|
208
|
+
clock=time.monotonic) -> int:
|
|
209
|
+
"""Serve `page` to the phone, pinch it as plan() says, judge each pinch, and write the results.
|
|
210
|
+
A phone that is lost in the middle (it left adb, its browser closed) is waited for again, within
|
|
211
|
+
`wait_unlock`, and the run starts over, up to `attempts` times. Exit 0 when every pinch passed,
|
|
212
|
+
1 when one failed, 2 when the test could not run."""
|
|
213
|
+
from claude_human import android, android_browser as ab # noqa: PLC0415
|
|
214
|
+
out = Path(out or Path("/tmp/margin-pinch-check"))
|
|
215
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
216
|
+
run_adb = android._runner(adb, None)
|
|
217
|
+
end = clock() + wait_unlock
|
|
218
|
+
|
|
219
|
+
def wait_ready(quiet: float = QUIET) -> bool:
|
|
220
|
+
said = False
|
|
221
|
+
while True:
|
|
222
|
+
power = android._read(serial, run_adb, WAKEFULNESS) or ""
|
|
223
|
+
if ready(power, android.locked(serial, run_adb), android.idle_s(power), quiet):
|
|
224
|
+
break
|
|
225
|
+
if clock() >= end:
|
|
226
|
+
return False
|
|
227
|
+
if not said:
|
|
228
|
+
print(f"{time.strftime('%H:%M')} waiting for the phone to be awake, unlocked and untouched for "
|
|
229
|
+
f"{quiet:g} s", flush=True)
|
|
230
|
+
said = True
|
|
231
|
+
sleep(30)
|
|
232
|
+
return True
|
|
233
|
+
|
|
234
|
+
port = _free_port()
|
|
235
|
+
handler = partial(http.server.SimpleHTTPRequestHandler, directory=str(page.parent))
|
|
236
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
237
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
238
|
+
url = f"http://127.0.0.1:{port}/{page.name}"
|
|
239
|
+
rows: list[dict] | None = None
|
|
240
|
+
try:
|
|
241
|
+
for n in range(1, max(1, attempts) + 1):
|
|
242
|
+
if not wait_ready(QUIET if n == 1 else QUIET_AFTER_LOSS):
|
|
243
|
+
print("REFUSED: the phone is locked, asleep or off adb; it is never unlocked for the test "
|
|
244
|
+
"(--wait-unlock waits)")
|
|
245
|
+
return 2
|
|
246
|
+
print(f"{time.strftime('%H:%M')} the phone is awake, unlocked and idle: attempt {n}", flush=True)
|
|
247
|
+
try:
|
|
248
|
+
rows = _attempt(serial, package, url, port, out, adb, run_adb)
|
|
249
|
+
break
|
|
250
|
+
except (PhoneLost, ab.BrowserError) as e:
|
|
251
|
+
print(f"{time.strftime('%H:%M')} attempt {n} lost the phone ({str(e)[:160]}); waiting for it again",
|
|
252
|
+
flush=True)
|
|
253
|
+
rows = None
|
|
254
|
+
if rows is None:
|
|
255
|
+
print(f"REFUSED: the phone was lost in each of {attempts} attempts")
|
|
256
|
+
return 2
|
|
257
|
+
finally:
|
|
258
|
+
server.shutdown()
|
|
259
|
+
(out / "readings.json").write_text(json.dumps(rows, indent=1))
|
|
260
|
+
(out / "results.md").write_text(table(rows) + "\n")
|
|
261
|
+
print(table(rows))
|
|
262
|
+
failed = [r for r in rows if r["why"]]
|
|
263
|
+
print(f"\n{len(rows) - len(failed)} of {len(rows)} pinches passed; results in {out}")
|
|
264
|
+
return 1 if failed or not rows else 0
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
#: how far a pinch moves the fingers (their end distance over their start) and how fast (CSS px/s)
|
|
268
|
+
SCALE = {"in": 0.4, "out": 2.5}
|
|
269
|
+
SPEED = {False: 800.0, True: 200.0}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class Frames:
|
|
273
|
+
"""The phone's screen, captured about twice a second while the test runs (screencap works where
|
|
274
|
+
screenrecord may not write a file), and made into an mp4 with ffmpeg when it stops."""
|
|
275
|
+
|
|
276
|
+
def __init__(self, adb: str, serial: str, folder: Path, gap: float = 0.5):
|
|
277
|
+
self.adb, self.serial, self.folder, self.gap = adb, serial, folder, gap
|
|
278
|
+
self.folder.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
self.stop_flag = threading.Event()
|
|
280
|
+
self.n = 0
|
|
281
|
+
self.thread = threading.Thread(target=self._loop, daemon=True)
|
|
282
|
+
self.thread.start()
|
|
283
|
+
|
|
284
|
+
def _loop(self) -> None:
|
|
285
|
+
while not self.stop_flag.is_set():
|
|
286
|
+
r = subprocess.run([self.adb, "-s", self.serial, "exec-out", "screencap", "-p"], capture_output=True,
|
|
287
|
+
timeout=30)
|
|
288
|
+
if r.returncode == 0 and r.stdout.startswith(b"\x89PNG"):
|
|
289
|
+
self.n += 1
|
|
290
|
+
(self.folder / f"{self.n:04d}.png").write_bytes(r.stdout)
|
|
291
|
+
self.stop_flag.wait(self.gap)
|
|
292
|
+
|
|
293
|
+
def stop(self, mp4: Path) -> bool:
|
|
294
|
+
self.stop_flag.set()
|
|
295
|
+
self.thread.join(timeout=40)
|
|
296
|
+
if not self.n:
|
|
297
|
+
return False
|
|
298
|
+
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", "2", "-i",
|
|
299
|
+
str(self.folder / "%04d.png"), "-vf", "scale=540:-2", "-pix_fmt", "yuv420p", str(mp4)],
|
|
300
|
+
capture_output=True)
|
|
301
|
+
return r.returncode == 0 and mp4.exists()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _attempt(serial: str, package: str, url: str, port: int, out: Path, adb: str, run_adb) -> list[dict]:
|
|
305
|
+
"""One run of the plan on the phone, each pinch made through the browser (android_browser.pinch)
|
|
306
|
+
after the same checks as a tap (android.why_not: the browser has focus, no banner or call, nobody
|
|
307
|
+
touched the phone for 3 s). Raises PhoneLost when the phone or its browser goes."""
|
|
308
|
+
from claude_human import android, android_browser as ab # noqa: PLC0415
|
|
309
|
+
rows: list[dict] = []
|
|
310
|
+
if run_adb(["-s", serial, "reverse", f"tcp:{port}", f"tcp:{port}"])[0] != 0:
|
|
311
|
+
raise PhoneLost("adb could not reverse the port to the phone")
|
|
312
|
+
frames = None
|
|
313
|
+
try:
|
|
314
|
+
if not ab.open_url(serial, package, url, run_adb):
|
|
315
|
+
raise PhoneLost(f"could not open the page in {package}")
|
|
316
|
+
page = Page(ab, serial, package, url, run_adb)
|
|
317
|
+
try:
|
|
318
|
+
for _ in range(30):
|
|
319
|
+
try:
|
|
320
|
+
if page.eval("document.readyState") == "complete":
|
|
321
|
+
break
|
|
322
|
+
except ab.BrowserError:
|
|
323
|
+
pass
|
|
324
|
+
time.sleep(1)
|
|
325
|
+
page.eval(LISTENER)
|
|
326
|
+
page.eval("set('1')")
|
|
327
|
+
view = page.eval("({w: innerWidth, h: innerHeight, dpr: devicePixelRatio})")
|
|
328
|
+
x, y = view["w"] / 2, view["h"] * 0.45
|
|
329
|
+
frames = Frames(adb, serial, out / "frames")
|
|
330
|
+
last_place = None
|
|
331
|
+
for place, direction, slow in plan():
|
|
332
|
+
if place != last_place:
|
|
333
|
+
page.eval(f"scrollTo(0, {place} * (document.documentElement.scrollHeight - innerHeight))")
|
|
334
|
+
time.sleep(1)
|
|
335
|
+
last_place = place
|
|
336
|
+
end = time.monotonic() + 120
|
|
337
|
+
said = None
|
|
338
|
+
while (why := android.why_not(serial, package, int(y * view["dpr"]), run_adb, idle=3.0,
|
|
339
|
+
still=False)) is not None:
|
|
340
|
+
if "focus" in why or "locked" in why:
|
|
341
|
+
raise PhoneLost(f"not pinched: {why}")
|
|
342
|
+
if why != said:
|
|
343
|
+
print(f"{time.strftime('%H:%M')} waiting to pinch: {why}", flush=True)
|
|
344
|
+
said = why
|
|
345
|
+
if time.monotonic() > end:
|
|
346
|
+
break
|
|
347
|
+
time.sleep(1)
|
|
348
|
+
if why is not None:
|
|
349
|
+
rows.append({"place": place, "direction": direction, "slow": slow, "reading": None,
|
|
350
|
+
"why": [f"not pinched: {why}"]})
|
|
351
|
+
continue
|
|
352
|
+
page.pinch(x, y, SCALE[direction], SPEED[slow])
|
|
353
|
+
time.sleep(1.2)
|
|
354
|
+
reading = page.eval(READ)
|
|
355
|
+
rows.append({"place": place, "direction": direction, "slow": slow, "reading": reading,
|
|
356
|
+
"why": judge(reading, direction)})
|
|
357
|
+
finally:
|
|
358
|
+
page.close()
|
|
359
|
+
finally:
|
|
360
|
+
if frames is not None:
|
|
361
|
+
frames.stop(out / "pinch.mp4")
|
|
362
|
+
run_adb(["-s", serial, "reverse", "--remove", f"tcp:{port}"])
|
|
363
|
+
return rows
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: margin
|
|
3
|
+
description: Use whenever you write or change documentation, a plan, a spec, a design, a report, notes or any markdown a person is meant to read, and whenever a message says "new comment(s) in Outline". margin publishes repository markdown to Outline, where the person reads it and comments on it, and brings the comments back to you. Covers where a document goes, publishing it, giving the person its link, and answering comments.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# margin
|
|
7
|
+
|
|
8
|
+
Documentation lives as markdown files in the repository that owns the subject. margin publishes
|
|
9
|
+
those files to Outline, where the person reads them in a browser or on a phone and comments on a
|
|
10
|
+
piece of text. Each repository is one Outline collection, its folders are parent pages, and a
|
|
11
|
+
folder's `README.md` is that folder's page. The comments come back to you in batches.
|
|
12
|
+
|
|
13
|
+
This is the default way to give the person any document. Do not deliver a plan, a spec or a
|
|
14
|
+
design only as chat text, an artifact or a file outside a published repository, unless the person
|
|
15
|
+
asks for that.
|
|
16
|
+
|
|
17
|
+
## Where a document goes
|
|
18
|
+
|
|
19
|
+
The folder structure in the repository is the structure the person sees in Outline, so it has to
|
|
20
|
+
stay right.
|
|
21
|
+
|
|
22
|
+
1. **Pick the repository that owns the subject.** That is the repository whose code or decision it
|
|
23
|
+
is about. When a subject spans several repositories, it goes in the one that owns the decision,
|
|
24
|
+
and the others link to it. When no repository owns it, use the notes collection if the
|
|
25
|
+
configuration has one.
|
|
26
|
+
2. **Follow that repository's own conventions.** Read its `CLAUDE.md` and its `README.md` first.
|
|
27
|
+
Put the file next to documents of the same kind (plans with plans, specs with specs). Never
|
|
28
|
+
start a second docs tree beside the one that exists.
|
|
29
|
+
3. **Name and title it properly.** The file name is lowercase words joined by dashes, with a date
|
|
30
|
+
prefix when the folder already uses one. The first `# Heading` becomes the page title, so make
|
|
31
|
+
it a real name for the document.
|
|
32
|
+
4. **A new folder needs a reason.** When you do create one, give it a `README.md` that says what
|
|
33
|
+
the folder holds, because that file is the folder's page.
|
|
34
|
+
|
|
35
|
+
## Publishing, and giving the person the link
|
|
36
|
+
|
|
37
|
+
After you create or change a document, publish it and hand over the link.
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
margin link path/to/doc.md [more files...]
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It publishes those files at once and prints one Markdown link per file. A file in a git
|
|
44
|
+
worktree of a published repository works too: margin finds the repository through git, and keeps
|
|
45
|
+
publishing the worktree's version (the sync pass does not put the main folder's older text back)
|
|
46
|
+
until the main folder has the same text, or the worktree file is gone. **Put every link it
|
|
47
|
+
prints in your reply to the person**, so they can open each document you wrote or changed. Use only
|
|
48
|
+
links margin printed. Never build an Outline address yourself. If margin prints "not published",
|
|
49
|
+
tell the person which file and why, and fix it (move the file into a published repository, or ask
|
|
50
|
+
for the repository to be added to the configuration). "not published: ... is conflict" (or
|
|
51
|
+
`cannot-patch`, `drift`, `outline-edited`) means the document still has an older text, and `margin
|
|
52
|
+
link` exits non-zero: the document is not published until the conflict is settled (below).
|
|
53
|
+
|
|
54
|
+
To show a picture, save it next to the document (for example in a `shots/` folder beside it) and
|
|
55
|
+
refer to it by a relative path, ``. margin uploads it to the
|
|
56
|
+
document and keeps it in sync; a changed picture republishes the document. Never paste an
|
|
57
|
+
Outline attachment address into a file.
|
|
58
|
+
|
|
59
|
+
Do not put a link or a code span inside bold text, as in `**A follow-up, [#581](url).**`. Outline
|
|
60
|
+
rewrites it as `**A follow-up,** [**#581**](url)**.**`, so the document can never equal the file and
|
|
61
|
+
every update of it fails. Put the link after the bold label: `**A follow-up.** [#581](url)`.
|
|
62
|
+
|
|
63
|
+
## Answering a batch of comments
|
|
64
|
+
|
|
65
|
+
1. Read the whole batch first. Several comments are often about one change.
|
|
66
|
+
2. For each comment, decide what it asks for. It can be an answer, a change to the document, or both.
|
|
67
|
+
3. If it asks for a change, edit the repository file (the path is under the document's title),
|
|
68
|
+
then run `margin link` on it. Never edit the document in Outline directly.
|
|
69
|
+
4. Answer every comment in its own thread, even when the answer is only "changed it", so the person
|
|
70
|
+
can see what happened without asking. Use `margin reply <comment-id> "..."`.
|
|
71
|
+
5. Run `margin resolve <comment-id>` only when the person's question is fully settled. When you are
|
|
72
|
+
not sure, leave it open.
|
|
73
|
+
|
|
74
|
+
Use `margin thread <comment-id>` to read the whole thread when a comment is a reply.
|
|
75
|
+
|
|
76
|
+
## Rules
|
|
77
|
+
|
|
78
|
+
- Your replies are posted as the agent's own Outline user. Write them as yourself, in plain words.
|
|
79
|
+
- Keep edits small. A comment attached to text you rewrite loses its highlight. margin patches only
|
|
80
|
+
the words that changed, so a comment on words you keep stays highlighted, even in a paragraph you
|
|
81
|
+
edited. Change only what the comment is about.
|
|
82
|
+
- Never replace a whole document, never delete or move one, and never share one publicly. Sharing
|
|
83
|
+
is the person's decision. Moving a page is done by moving its file.
|
|
84
|
+
- If `margin sync` or `margin link` reports `cannot-patch` or `drift`, nobody edited the document:
|
|
85
|
+
margin could not patch it into the file's text. Run `margin accept-file <file>`, which publishes
|
|
86
|
+
the file (it checks that only margin has edited the document), and do not ask the person about it.
|
|
87
|
+
- If `margin sync` or `margin link` reports `conflict` or `outline-edited`, run
|
|
88
|
+
`margin accept-file <file>`. It lets the file win only when nobody but margin edited the
|
|
89
|
+
document since the last sync and it has no comments, and otherwise it refuses and names who
|
|
90
|
+
edited it. When it refuses, the person changed the document in Outline: do not force your
|
|
91
|
+
version. Read the document (Outline's MCP `fetch`), copy their edit into the file, and run
|
|
92
|
+
`margin accept-merged <file>`. It publishes only when every line they added or changed is in
|
|
93
|
+
the file and no line they removed is, and otherwise refuses and names the line. When their
|
|
94
|
+
edit and yours cannot both stand, tell them which document it is and ask.
|
|
95
|
+
- Say a document is published only when `margin link` printed its link in this turn.
|
margin/store.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""margin's state: one SQLite file. What was synced, which comments arrived, which were delivered.
|
|
2
|
+
|
|
3
|
+
Nothing here is a second copy of the documents. `doc` keeps, per file, the text both sides had at the
|
|
4
|
+
last sync, which is exactly what is needed to tell "only the repo changed" from "both changed".
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
9
|
+
import fcntl
|
|
10
|
+
import sqlite3
|
|
11
|
+
import threading
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SCHEMA = """
|
|
16
|
+
CREATE TABLE IF NOT EXISTS doc (
|
|
17
|
+
repo TEXT NOT NULL, path TEXT NOT NULL, -- path relative to the repo, '' for the repo root
|
|
18
|
+
doc_id TEXT NOT NULL, collection_id TEXT NOT NULL,
|
|
19
|
+
base_repo TEXT, -- the repo file's text at the last sync
|
|
20
|
+
base_outline TEXT, -- Outline's text right after the last sync
|
|
21
|
+
status TEXT NOT NULL DEFAULT 'ok', -- ok | conflict | cannot-patch | missing-file
|
|
22
|
+
detail TEXT, synced_at REAL,
|
|
23
|
+
PRIMARY KEY (repo, path)
|
|
24
|
+
);
|
|
25
|
+
CREATE UNIQUE INDEX IF NOT EXISTS doc_by_id ON doc(doc_id);
|
|
26
|
+
CREATE TABLE IF NOT EXISTS comment (
|
|
27
|
+
id TEXT PRIMARY KEY, document_id TEXT NOT NULL, parent_id TEXT,
|
|
28
|
+
author_id TEXT, author TEXT, text TEXT, anchor TEXT,
|
|
29
|
+
created_at TEXT NOT NULL, received_at REAL NOT NULL,
|
|
30
|
+
mentions_agent INTEGER NOT NULL DEFAULT 0,
|
|
31
|
+
route TEXT, -- NULL when the document is not one margin syncs
|
|
32
|
+
via TEXT NOT NULL, -- webhook | poll
|
|
33
|
+
delivered_at REAL, batch_id TEXT,
|
|
34
|
+
resolved_at TEXT, -- when the thread was resolved, if it was
|
|
35
|
+
anchor_before TEXT, -- the words before the passage in its paragraph
|
|
36
|
+
anchor_lost INTEGER NOT NULL DEFAULT 0 -- 1 when its passage has no mark left in the text
|
|
37
|
+
);
|
|
38
|
+
CREATE INDEX IF NOT EXISTS comment_pending ON comment(route) WHERE delivered_at IS NULL;
|
|
39
|
+
CREATE TABLE IF NOT EXISTS kv (k TEXT PRIMARY KEY, v TEXT);
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _Locked:
|
|
44
|
+
"""The connection's execute family, each call under the store's lock."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, db, lock):
|
|
47
|
+
self._d, self._l = db, lock
|
|
48
|
+
|
|
49
|
+
def execute(self, *a):
|
|
50
|
+
with self._l:
|
|
51
|
+
return _Rows(self._d.execute(*a))
|
|
52
|
+
|
|
53
|
+
def executemany(self, *a):
|
|
54
|
+
with self._l:
|
|
55
|
+
return self._d.executemany(*a)
|
|
56
|
+
|
|
57
|
+
def executescript(self, *a):
|
|
58
|
+
with self._l:
|
|
59
|
+
return self._d.executescript(*a)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _Rows:
|
|
63
|
+
"""A finished cursor: rows fetched while the lock was held."""
|
|
64
|
+
|
|
65
|
+
def __init__(self, cur):
|
|
66
|
+
self.rowcount = cur.rowcount
|
|
67
|
+
self._rows = cur.fetchall() if cur.description else []
|
|
68
|
+
|
|
69
|
+
def fetchone(self):
|
|
70
|
+
return self._rows[0] if self._rows else None
|
|
71
|
+
|
|
72
|
+
def fetchall(self):
|
|
73
|
+
return self._rows
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Store:
|
|
77
|
+
def __init__(self, path: Path):
|
|
78
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
self.path = path
|
|
80
|
+
# One connection shared by the webhook thread and the main loop, so every statement
|
|
81
|
+
# goes through one lock.
|
|
82
|
+
self._db = sqlite3.connect(str(path), isolation_level=None, timeout=30,
|
|
83
|
+
check_same_thread=False)
|
|
84
|
+
self._db.row_factory = sqlite3.Row
|
|
85
|
+
self.lock = threading.RLock()
|
|
86
|
+
self.db = _Locked(self._db, self.lock)
|
|
87
|
+
self.db.execute("PRAGMA journal_mode=WAL")
|
|
88
|
+
self.db.executescript(SCHEMA)
|
|
89
|
+
# a store made before a column existed gains it
|
|
90
|
+
cols = {r["name"] for r in self.db.execute("PRAGMA table_info(comment)").fetchall()}
|
|
91
|
+
for col, kind in (("resolved_at", "TEXT"), ("anchor_before", "TEXT"),
|
|
92
|
+
("anchor_lost", "INTEGER NOT NULL DEFAULT 0")):
|
|
93
|
+
if col not in cols:
|
|
94
|
+
self.db.execute(f"ALTER TABLE comment ADD COLUMN {col} {kind}")
|
|
95
|
+
|
|
96
|
+
# ---- docs
|
|
97
|
+
def doc(self, repo: str, path: str):
|
|
98
|
+
return self.db.execute("SELECT * FROM doc WHERE repo=? AND path=?", (repo, path)).fetchone()
|
|
99
|
+
|
|
100
|
+
def doc_by_id(self, doc_id: str):
|
|
101
|
+
return self.db.execute("SELECT * FROM doc WHERE doc_id=?", (doc_id,)).fetchone()
|
|
102
|
+
|
|
103
|
+
def docs(self, repo: str | None = None):
|
|
104
|
+
if repo:
|
|
105
|
+
return self.db.execute("SELECT * FROM doc WHERE repo=? ORDER BY path", (repo,)).fetchall()
|
|
106
|
+
return self.db.execute("SELECT * FROM doc ORDER BY repo, path").fetchall()
|
|
107
|
+
|
|
108
|
+
def save_doc(self, repo, path, doc_id, collection_id, base_repo, base_outline,
|
|
109
|
+
status="ok", detail=None):
|
|
110
|
+
self.db.execute(
|
|
111
|
+
"INSERT INTO doc(repo,path,doc_id,collection_id,base_repo,base_outline,status,detail,"
|
|
112
|
+
"synced_at) VALUES(?,?,?,?,?,?,?,?,?) ON CONFLICT(repo,path) DO UPDATE SET "
|
|
113
|
+
"doc_id=excluded.doc_id, collection_id=excluded.collection_id, "
|
|
114
|
+
"base_repo=excluded.base_repo, base_outline=excluded.base_outline, "
|
|
115
|
+
"status=excluded.status, detail=excluded.detail, synced_at=excluded.synced_at",
|
|
116
|
+
(repo, path, doc_id, collection_id, base_repo, base_outline, status, detail, time.time()))
|
|
117
|
+
|
|
118
|
+
def mark(self, repo, path, status, detail=None):
|
|
119
|
+
self.db.execute("UPDATE doc SET status=?, detail=? WHERE repo=? AND path=?",
|
|
120
|
+
(status, detail, repo, path))
|
|
121
|
+
|
|
122
|
+
# ---- comments
|
|
123
|
+
def add_comment(self, c: dict) -> bool:
|
|
124
|
+
"""Record a comment once. Returns True when it is new. A comment stored with
|
|
125
|
+
`delivered_at` set is kept for reading and never handed on as work."""
|
|
126
|
+
cur = self.db.execute(
|
|
127
|
+
"INSERT OR IGNORE INTO comment(id,document_id,parent_id,author_id,author,text,anchor,"
|
|
128
|
+
"created_at,received_at,mentions_agent,route,via,delivered_at,resolved_at,anchor_before) "
|
|
129
|
+
"VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
130
|
+
(c["id"], c["document_id"], c.get("parent_id"), c.get("author_id"), c.get("author"),
|
|
131
|
+
c.get("text"), c.get("anchor"), c["created_at"], time.time(),
|
|
132
|
+
int(bool(c.get("mentions_agent"))), c.get("route"), c["via"], c.get("delivered_at"),
|
|
133
|
+
c.get("resolved_at"), c.get("anchor_before")))
|
|
134
|
+
return cur.rowcount == 1
|
|
135
|
+
|
|
136
|
+
def set_place(self, comment_id: str, before: str | None) -> None:
|
|
137
|
+
"""Where a comment's passage sits: the words before it in its paragraph ("" when it opens
|
|
138
|
+
it), or None when the text has no mark for it any more (anchor_lost)."""
|
|
139
|
+
self.db.execute("UPDATE comment SET anchor_before=?, anchor_lost=? WHERE id=?",
|
|
140
|
+
(before, int(before is None), comment_id))
|
|
141
|
+
|
|
142
|
+
def anchored(self, document_id: str) -> list[str]:
|
|
143
|
+
"""The comments that open a thread on a passage of the document."""
|
|
144
|
+
return [r["id"] for r in self.db.execute(
|
|
145
|
+
"SELECT id FROM comment WHERE document_id=? AND parent_id IS NULL AND anchor IS NOT NULL "
|
|
146
|
+
"AND anchor <> ''", (document_id,)).fetchall()]
|
|
147
|
+
|
|
148
|
+
def without_anchor_before(self):
|
|
149
|
+
"""Comments on a passage whose place in the document is not recorded yet."""
|
|
150
|
+
return self.db.execute("SELECT id, document_id FROM comment WHERE parent_id IS NULL AND "
|
|
151
|
+
"anchor IS NOT NULL AND anchor <> '' AND anchor_before IS NULL "
|
|
152
|
+
"AND anchor_lost = 0 ORDER BY document_id").fetchall()
|
|
153
|
+
|
|
154
|
+
def set_resolved(self, comment_id: str, at: str | None) -> None:
|
|
155
|
+
"""When a thread was resolved, or None when it was opened again."""
|
|
156
|
+
self.db.execute("UPDATE comment SET resolved_at=? WHERE id=?", (at, comment_id))
|
|
157
|
+
|
|
158
|
+
def pending(self):
|
|
159
|
+
return self.db.execute("SELECT * FROM comment WHERE delivered_at IS NULL AND route IS NOT "
|
|
160
|
+
"NULL ORDER BY received_at").fetchall()
|
|
161
|
+
|
|
162
|
+
def delivered(self, ids: list[str], batch_id: str):
|
|
163
|
+
self.db.executemany("UPDATE comment SET delivered_at=?, batch_id=? WHERE id=?",
|
|
164
|
+
[(time.time(), batch_id, i) for i in ids])
|
|
165
|
+
|
|
166
|
+
# ---- small values (watermarks)
|
|
167
|
+
def get(self, k: str, default: str | None = None) -> str | None:
|
|
168
|
+
row = self.db.execute("SELECT v FROM kv WHERE k=?", (k,)).fetchone()
|
|
169
|
+
return row["v"] if row else default
|
|
170
|
+
|
|
171
|
+
def put(self, k: str, v: str):
|
|
172
|
+
self.db.execute("INSERT INTO kv(k,v) VALUES(?,?) ON CONFLICT(k) DO UPDATE SET v=excluded.v",
|
|
173
|
+
(k, v))
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@contextlib.contextmanager
|
|
177
|
+
def repo_lock(store: "Store", repo: str):
|
|
178
|
+
"""One writer per repo across processes: the serve loop and a chat's `margin link` both sync,
|
|
179
|
+
and two passes that both see "no document yet" would each create one."""
|
|
180
|
+
lock = store.path.parent / f"{store.path.stem}.{repo}.lock"
|
|
181
|
+
with open(lock, "w") as fh:
|
|
182
|
+
fcntl.flock(fh, fcntl.LOCK_EX)
|
|
183
|
+
try:
|
|
184
|
+
yield
|
|
185
|
+
finally:
|
|
186
|
+
fcntl.flock(fh, fcntl.LOCK_UN)
|