From 6738f144693712c9aa2a2922c25081cdbc092377 Mon Sep 17 00:00:00 2001 From: ilia Date: Wed, 5 Aug 2026 14:06:56 -0400 Subject: [PATCH] Fix tight-circle selection stealing neighboring text Prefer a11y nodes centered in the circle, skip OCR merge when a11y is already useful, and smoke-test the How to circle heading case. --- CHANGELOG.md | 9 ++ app/build.gradle.kts | 4 +- .../accessibility/CrklAccessibilityService.kt | 6 +- .../com/example/crkl/agent/LocalAssistStub.kt | 13 +- .../crkl/vision/RegionContentExtractor.kt | 45 ++++--- .../example/crkl/vision/SelectionOverlap.kt | 101 +++++++++++++++ .../example/crkl/agent/LocalAssistStubTest.kt | 15 +++ .../crkl/vision/SelectionOverlapTest.kt | 70 ++++++++++ docs/dogfood.md | 2 +- scripts/smoke-circle.sh | 121 ++++++++++++++---- 10 files changed, 333 insertions(+), 53 deletions(-) create mode 100644 app/src/main/kotlin/com/example/crkl/vision/SelectionOverlap.kt create mode 100644 app/src/test/java/com/example/crkl/vision/SelectionOverlapTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index ae682aa..e12a1cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 1.16.3-tight-select — 2026-08-05 + +- Fix: circling a short heading no longer steals the large text block above it + (prefer a11y nodes whose center sits in the circle / high coverage) +- Fix: stub headline no longer turns `1. Circle…` into a lone `1.` +- Fix: do not OCR-merge when a11y already has useful text (short labels were + polluted by neighboring OCR) +- Smoke now asserts the heading case (not only email → Copy) + ## 1.16.2-circle-copy — 2026-08-05 - More user-facing strings say **Circle** (panel, empty state, Integrations label, Vikunja hint) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c04b737..1aedd4f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.example.crkl" minSdk = 27 targetSdk = 34 - versionCode = 19 - versionName = "1.16.2-circle-copy" + versionCode = 20 + versionName = "1.16.3-tight-select" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt index 7071f9b..3b9deeb 100644 --- a/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt +++ b/app/src/main/kotlin/com/example/crkl/accessibility/CrklAccessibilityService.kt @@ -256,8 +256,10 @@ class CrklAccessibilityService : AccessibilityService() { } } - // OCR when a11y is thin, or always merge for image-like sparse text. - val needOcr = a11y.isEmpty || a11y.text.length < 40 + // OCR only when a11y found nothing useful. Short labels like + // "How to circle" are real hits — OCR-merge used to re-pull the + // neighboring Ship demo block from the selection bitmap. + val needOcr = a11y.isEmpty || !ContentCapture.hasUsefulText(a11y.text) val merged = if (needOcr) { withContext(Dispatchers.Main) { val debug = IntegrationSettings(this@CrklAccessibilityService).showDebugMeta diff --git a/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt b/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt index a063a48..045c966 100644 --- a/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt +++ b/app/src/main/kotlin/com/example/crkl/agent/LocalAssistStub.kt @@ -44,7 +44,8 @@ object LocalAssistStub { title = "Circled text", meta = "", body = buildString { - if (summary.isNotBlank()) { + // Headline only when it shortens a longer blob — never a lone "1." marker. + if (summary.length >= 12 && summary.length < preview.length) { appendLine(summary) appendLine() appendLine("—") @@ -187,16 +188,18 @@ object LocalAssistStub { private fun heuristicSummary(text: String): String { val firstLine = text.lineSequence().firstOrNull { it.isNotBlank() }?.trim().orEmpty() + // Do not treat "1." / "2." list markers as sentence terminators. val sentence = firstLine - .split(Regex("(?<=[.!?])\\s+")) + .split(Regex("(?<=[A-Za-z][.!?])\\s+")) .firstOrNull() ?.trim() .orEmpty() val head = when { - sentence.isNotEmpty() && sentence.length <= 160 -> sentence - firstLine.length <= 160 -> firstLine - else -> firstLine.take(157) + "…" + sentence.length in 12..160 -> sentence + firstLine.length in 12..160 -> firstLine + firstLine.length > 160 -> firstLine.take(157) + "…" + else -> "" } return head diff --git a/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt b/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt index 62b132d..9ea7e3e 100644 --- a/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt +++ b/app/src/main/kotlin/com/example/crkl/vision/RegionContentExtractor.kt @@ -7,7 +7,7 @@ import android.view.accessibility.AccessibilityNodeInfo import android.view.accessibility.AccessibilityWindowInfo /** - * Pulls readable text from accessibility nodes that intersect a screen-space selection. + * Pulls readable text from accessibility nodes that fit a screen-space selection. * * Prefers the accessibility node tree over MediaProjection so the first vertical * slice stays permission-light and fully on-device. @@ -33,8 +33,8 @@ object RegionContentExtractor { selection: RectF ): ExtractionResult { val selectionRect = Rect().also { selection.round(it) } - val snippets = linkedSetOf() - var nodesHit = 0 + val candidates = mutableListOf>() + var nodesVisited = 0 var packageName: String? = null val roots = mutableListOf() @@ -53,61 +53,70 @@ object RegionContentExtractor { packageName = root.packageName?.toString() } try { - nodesHit += collectIntersectingText(root, selectionRect, snippets) + nodesVisited += collectCandidates(root, selectionRect, candidates) } finally { // Only recycle roots we obtained from windows; caller owns rootFallback. if (root !== rootFallback) { root.recycle() } } - if (snippets.isNotEmpty() || nodesHit >= MAX_NODES) break + if (candidates.isNotEmpty() || nodesVisited >= MAX_NODES) break } - val joined = snippets + val picked = SelectionOverlap.pickBest(candidates) + val joined = picked + .distinct() .joinToString(separator = "\n") .trim() .take(MAX_CHARS) Log.d( TAG, - "extract: nodes=$nodesHit snippets=${snippets.size} chars=${joined.length} pkg=$packageName" + "extract: visited=$nodesVisited candidates=${candidates.size} " + + "picked=${picked.size} chars=${joined.length} pkg=$packageName " + + "preview=${joined.take(100).replace('\n', '|')}" ) return ExtractionResult( text = joined, - nodeCount = nodesHit, + nodeCount = picked.size, bounds = RectF(selection), packageName = packageName ) } - private fun collectIntersectingText( + private fun collectCandidates( node: AccessibilityNodeInfo, selection: Rect, - out: MutableSet, + out: MutableList>, depth: Int = 0 ): Int { if (depth > 40 || out.size >= MAX_NODES) return 0 - var hit = 0 + var visited = 1 val bounds = Rect() node.getBoundsInScreen(bounds) + val score = SelectionOverlap.score(bounds, selection) - if (Rect.intersects(bounds, selection)) { - readableText(node)?.let { out.add(it) } - hit = 1 - } - + var childTextHits = 0 for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue try { - hit += collectIntersectingText(child, selection, out, depth + 1) + val before = out.size + visited += collectCandidates(child, selection, out, depth + 1) + if (out.size > before) childTextHits++ } finally { child.recycle() } if (out.size >= MAX_NODES) break } - return hit + + // Prefer leaf (or near-leaf) text — skip parents that only duplicate children. + if (score.include && childTextHits == 0) { + readableText(node)?.let { out.add(it to score) } + } + + return visited } private fun readableText(node: AccessibilityNodeInfo): String? { diff --git a/app/src/main/kotlin/com/example/crkl/vision/SelectionOverlap.kt b/app/src/main/kotlin/com/example/crkl/vision/SelectionOverlap.kt new file mode 100644 index 0000000..40168e8 --- /dev/null +++ b/app/src/main/kotlin/com/example/crkl/vision/SelectionOverlap.kt @@ -0,0 +1,101 @@ +package com.example.crkl.vision + +import android.graphics.Rect + +/** + * Scores how well an a11y node fits a circle's screen bounds. + * + * Loose [Rect.intersects] alone pulls in large TextViews that only graze the + * selection (e.g. circling "How to circle" also hits the Ship demo block above). + * + * Geometry uses raw ints so JVM unit tests do not need a mocked [Rect]. + */ +object SelectionOverlap { + + data class Score( + val include: Boolean, + /** Higher = tighter match. */ + val rank: Float, + val centerInSelection: Boolean, + /** Fraction of the node covered by the selection (0..1). */ + val coverage: Float + ) + + fun score(nodeBounds: Rect, selection: Rect): Score = + score( + nodeLeft = nodeBounds.left, + nodeTop = nodeBounds.top, + nodeRight = nodeBounds.right, + nodeBottom = nodeBounds.bottom, + selLeft = selection.left, + selTop = selection.top, + selRight = selection.right, + selBottom = selection.bottom + ) + + fun score( + nodeLeft: Int, + nodeTop: Int, + nodeRight: Int, + nodeBottom: Int, + selLeft: Int, + selTop: Int, + selRight: Int, + selBottom: Int + ): Score { + val nodeW = nodeRight - nodeLeft + val nodeH = nodeBottom - nodeTop + val selW = selRight - selLeft + val selH = selBottom - selTop + if (nodeW <= 0 || nodeH <= 0 || selW <= 0 || selH <= 0) { + return Score(include = false, rank = 0f, centerInSelection = false, coverage = 0f) + } + + val interLeft = maxOf(nodeLeft, selLeft) + val interTop = maxOf(nodeTop, selTop) + val interRight = minOf(nodeRight, selRight) + val interBottom = minOf(nodeBottom, selBottom) + val interW = interRight - interLeft + val interH = interBottom - interTop + if (interW <= 0 || interH <= 0) { + return Score(include = false, rank = 0f, centerInSelection = false, coverage = 0f) + } + + val overlap = interW.toLong() * interH.toLong() + val nodeArea = nodeW.toLong() * nodeH.toLong() + val coverage = (overlap.toFloat() / nodeArea.toFloat()).coerceIn(0f, 1f) + val cx = (nodeLeft + nodeRight) / 2 + val cy = (nodeTop + nodeBottom) / 2 + val centerIn = cx in selLeft until selRight && cy in selTop until selBottom + + // Prefer nodes whose center sits in the circle; otherwise require most of + // the node to lie inside (partial paragraph circle still works). + val include = centerIn || coverage >= 0.45f + val rank = (if (centerIn) 2f else 0f) + coverage + + (1f / (1f + nodeArea / 50_000f)) + + return Score( + include = include, + rank = rank, + centerInSelection = centerIn, + coverage = coverage + ) + } + + /** + * From scored hits, keep center-in nodes when any exist; else high-coverage ones. + */ + fun pickBest(candidates: List>): List { + if (candidates.isEmpty()) return emptyList() + val included = candidates.filter { it.second.include } + val pool = when { + included.any { it.second.centerInSelection } -> + included.filter { it.second.centerInSelection } + included.isNotEmpty() -> included + else -> candidates.filter { it.second.coverage >= 0.25f } + } + return pool + .sortedByDescending { it.second.rank } + .map { it.first } + } +} diff --git a/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt b/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt index 62da565..7b1d8a4 100644 --- a/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt +++ b/app/src/test/java/com/example/crkl/agent/LocalAssistStubTest.kt @@ -34,6 +34,21 @@ class LocalAssistStubTest { assertTrue(response.meta.isEmpty()) } + @Test + fun respond_numberedList_doesNotUseLoneOneAsHeadline() { + val result = RegionContentExtractor.ExtractionResult( + text = "1. Circle the English sentence → tap Translate [Translate]\n" + + "2. Circle the paragraph → Copy, then Explain [Copy / Explain]", + nodeCount = 1, + bounds = android.graphics.RectF(), + packageName = "com.example.crkl" + ) + val response = LocalAssistStub.respond(result) + assertTrue(response.body.contains("Circle the English")) + // Old bug: summary split on "1." and showed a lone "1." above the divider. + assertTrue(!response.body.trimStart().startsWith("1.\n")) + } + @Test fun respond_ocrJunk_treatedAsEmpty() { val result = RegionContentExtractor.ExtractionResult( diff --git a/app/src/test/java/com/example/crkl/vision/SelectionOverlapTest.kt b/app/src/test/java/com/example/crkl/vision/SelectionOverlapTest.kt new file mode 100644 index 0000000..34c63d5 --- /dev/null +++ b/app/src/test/java/com/example/crkl/vision/SelectionOverlapTest.kt @@ -0,0 +1,70 @@ +package com.example.crkl.vision + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SelectionOverlapTest { + + @Test + fun tightHeading_includesCenterNode_excludesGrazingBlock() { + // Circle bbox around a heading + val headScore = SelectionOverlap.score( + nodeLeft = 120, nodeTop = 410, nodeRight = 360, nodeBottom = 470, + selLeft = 100, selTop = 400, selRight = 400, selBottom = 480 + ) + // Large block above — only bottom edge grazes the selection + val blockScore = SelectionOverlap.score( + nodeLeft = 40, nodeTop = 100, nodeRight = 500, nodeBottom = 420, + selLeft = 100, selTop = 400, selRight = 400, selBottom = 480 + ) + + assertTrue(headScore.include) + assertTrue(headScore.centerInSelection) + assertFalse( + "grazing ship-demo block must not steal the circle", + blockScore.include + ) + } + + @Test + fun pickBest_prefersCenterInOverGrazing() { + val heading = "How to circle" to SelectionOverlap.score( + 120, 410, 360, 470, 100, 400, 400, 480 + ) + val block = "1. Circle the English…" to SelectionOverlap.score( + 40, 100, 500, 420, 100, 400, 400, 480 + ) + + val picked = SelectionOverlap.pickBest(listOf(block, heading)) + assertEquals(listOf("How to circle"), picked) + } + + @Test + fun pickBest_multipleCenterNodes_keepsAllRanked() { + val a = "How to circle" to SelectionOverlap.score( + 120, 410, 360, 470, 100, 390, 400, 520 + ) + val b = "1. Tap the floating C" to SelectionOverlap.score( + 100, 480, 500, 560, 100, 390, 400, 520 + ) + val leak = "Ship demo blob" to SelectionOverlap.score( + 40, 50, 500, 400, 100, 390, 400, 520 + ) + val picked = SelectionOverlap.pickBest(listOf(leak, b, a)) + assertTrue(picked.contains("How to circle")) + assertFalse(picked.contains("Ship demo blob")) + } + + @Test + fun tinyCornerGraze_excluded() { + val score = SelectionOverlap.score( + nodeLeft = 0, nodeTop = 0, nodeRight = 1000, nodeBottom = 1000, + selLeft = 900, selTop = 900, selRight = 980, selBottom = 980 + ) + assertFalse(score.centerInSelection) + assertTrue(score.coverage < 0.45f) + assertFalse(score.include) + } +} diff --git a/docs/dogfood.md b/docs/dogfood.md index ffb4ba5..a8585b3 100644 --- a/docs/dogfood.md +++ b/docs/dogfood.md @@ -30,5 +30,5 @@ Run during ship freeze. Bugs only — no new features. ## Notes _Date:_ 2026-08-05 -_Issues:_ Emulator smoke (`make smoke`) passed on CrklEmulator: FAB → closed loop → extract (278+ chars) → **Copy** (`Copied … clipboard`) and earlier **Translate** (`English → Russian`). Use `make emulator` (launchd on macOS) if qemu dies under the agent sandbox. +_Issues:_ **1.16.3** fixes heading steal (Ship demo) + `1.` headline; smoke asserts “How to circle” + Copy. diff --git a/scripts/smoke-circle.sh b/scripts/smoke-circle.sh index dc63d1f..d30a95d 100755 --- a/scripts/smoke-circle.sh +++ b/scripts/smoke-circle.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# Device/emulator smoke: unit tests already cover JVM; this hits the live overlay. -# Requires: adb device online, Circle Overlay enabled (or we enable it). +# Device/emulator smoke — catches the bugs that unit tests + "any extract" miss. +# Cases: +# 1) Tight circle on "How to circle" must NOT steal Ship demo block above +# 2) Circle email region → Copy chip works set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -11,7 +13,9 @@ PKG=com.example.crkl die() { echo "FAIL: $*" >&2; exit 1; } pass() { echo "OK: $*"; } -"$ADB" devices | awk '/\tdevice$/{found=1} END{exit !found}' || die "no adb device" +# adb devices -l uses spaces, not tabs, between serial and state +"$ADB" devices | awk '/device( |$)/ && $1 !~ /List/{found=1} END{exit !found}' \ + || die "no adb device" echo "== smoke Circle on $($ADB get-serialno) ==" @@ -25,32 +29,31 @@ sleep 1.5 "$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null sleep 1.5 -"$ADB" logcat -c -# FAB: BOTTOM|END, ~964,2200 on 1080x2400 Pixel-7-ish -"$ADB" shell input tap 964 2200 -sleep 0.5 -if ! "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then - # sweep near known FAB frame - hit=0 +enter_circle() { + "$ADB" logcat -c + "$ADB" shell input tap 964 2200 + sleep 0.4 + if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then + return 0 + fi for y in $(seq 2140 20 2280); do for x in $(seq 900 20 1030); do "$ADB" shell input tap "$x" "$y" sleep 0.05 if "$ADB" logcat -d -s OverlayView:D | grep -q 'floating button clicked'; then - hit=1 - break 2 + return 0 fi done done - [[ "$hit" = 1 ]] || die "FAB not clickable — is Circle Overlay ON?" -fi -pass "enter circle mode" + return 1 +} -# Closed loop around fixtures email subject region -python3 - <<'PY' -import math, subprocess, time +draw_loop() { + local cx="$1" cy="$2" r="$3" + python3 - "$cx" "$cy" "$r" <<'PY' +import math, subprocess, sys, time +cx, cy, r = map(int, sys.argv[1:4]) adb = lambda *a: subprocess.check_call(["adb", *a]) -cx, cy, r = 540, 1720, 160 n = 28 pts = [ ( @@ -66,16 +69,84 @@ adb("shell", "input", "motionevent", "UP", str(pts[-1][0]), str(pts[-1][1])) for _ in range(25): time.sleep(0.3) out = subprocess.check_output(["adb", "logcat", "-d"], text=True) - if "stub path" in out or "Selection ready" in out: + if "extract:" in out or "Selection ready" in out: break PY +} -"$ADB" logcat -d | grep -q 'Selection ready' || die "no selection" -"$ADB" logcat -d | grep -qE 'extract:.*chars=[1-9]' || die "no text extracted" -pass "circle → extract" +close_panel() { + "$ADB" shell input tap 980 1300 || true + sleep 0.3 + "$ADB" shell input keyevent KEYCODE_BACK || true + sleep 0.3 +} + +# Resolve live UI bounds so layout/scroll changes do not false-fail smoke. +"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null +"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke.xml >/dev/null +eval "$(python3 <<'PY' +import re, sys +xml = open("/tmp/crkl-smoke.xml").read() +m = re.search(r'text="How to circle"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if not m: + sys.exit("How to circle not on screen — open fixtures / scroll to top") +x1, y1, x2, y2 = map(int, m.groups()) +cx, cy = (x1 + x2) // 2, (y1 + y2) // 2 +r = max(60, min(100, (x2 - x1) // 2 + 30, (y2 - y1) // 2 + 40)) +print(f"HEAD_CX={cx}; HEAD_CY={cy}; HEAD_R={r}") +# Email fixture / subject area — prefer Subject line if present +m2 = re.search(r'text="[^"]*Subject: Q2[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if not m2: + m2 = re.search(r'text="[^"]*Q2 planning moved[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if m2: + a, b, c, d = map(int, m2.groups()) + print(f"MAIL_CX={(a+c)//2}; MAIL_CY={(b+d)//2}; MAIL_R=160") +else: + print("MAIL_CX=540; MAIL_CY=1720; MAIL_R=160") +PY +)" + +# --- Case 1: heading must not steal Ship demo --- +enter_circle || die "FAB not clickable — is Circle Overlay ON?" +pass "enter circle mode" +draw_loop "$HEAD_CX" "$HEAD_CY" "$HEAD_R" +logs="$("$ADB" logcat -d)" +echo "$logs" | grep -q 'Selection ready' || die "case1: no selection" +echo "$logs" | grep -E 'extract:.*preview=' | grep -qi 'How to circle' \ + || die "case1: extract missing 'How to circle' (got wrong node?)" +if echo "$logs" | grep -E 'extract:.*preview=' | grep -qi 'English sentence'; then + die "case1: Ship demo block leaked into heading circle" +fi +pass "tight heading extract (no Ship demo leak)" +close_panel + +# --- Case 2: email region → Copy --- +"$ADB" shell am start -n "$PKG/.fixtures.TestFixturesActivity" >/dev/null +sleep 1 +# Re-dump in case scroll changed +"$ADB" shell uiautomator dump /sdcard/crkl-smoke.xml >/dev/null +"$ADB" pull /sdcard/crkl-smoke.xml /tmp/crkl-smoke.xml >/dev/null +eval "$(python3 <<'PY' +import re +xml = open("/tmp/crkl-smoke.xml").read() +m2 = re.search(r'text="[^"]*Subject: Q2[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if not m2: + m2 = re.search(r'text="[^"]*Q2 planning moved[^"]*"[^>]*bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"', xml) +if m2: + a, b, c, d = map(int, m2.groups()) + print(f"MAIL_CX={(a+c)//2}; MAIL_CY={(b+d)//2}; MAIL_R=160") +else: + print("MAIL_CX=540; MAIL_CY=1720; MAIL_R=160") +PY +)" + +enter_circle || die "FAB not clickable (case2)" +draw_loop "$MAIL_CX" "$MAIL_CY" "$MAIL_R" +logs="$("$ADB" logcat -d)" +echo "$logs" | grep -qE 'extract:.*chars=[1-9]' || die "case2: no text extracted" +pass "email region extract" "$ADB" logcat -c -# Copy is second VIP chip (~x=320, y=2185 on this skin) "$ADB" shell input tap 320 2185 sleep 1 "$ADB" logcat -d | grep -q 'action kind=COPY' || die "Copy chip not triggered" @@ -83,4 +154,4 @@ sleep 1 pass "Copy chip" echo "== smoke PASSED ==" -echo "Tip: full dogfood is docs/dogfood.md (Translate / Explain / Share / Vikunja / real app)." +echo "Tip: full dogfood is docs/dogfood.md" -- 2.49.1