A Rust backend that owns the sessions and an Android app that reads them. The server spawns and adopts CLI processes, normalises everything they emit into one event model, keeps the transcript, and serves it over pinned TLS on a WireGuard interface; the phone streams that, replies, sends images, and imports conversations the machine already has. `AGENTS.md` is the working guide -- what runs where, what has been measured, and the faults that were expensive to find. `PLAN.md` is the design record. History before this point was squashed away. It was a personal project's running commentary and carried a name and a couple of machine paths that have no business in a public repository; the tree is what mattered and the tree is here.
165 lines
6.8 KiB
Kotlin
165 lines
6.8 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.content.ClipData
|
|
import android.content.ClipboardManager
|
|
import android.content.Context
|
|
import androidx.core.content.getSystemService
|
|
import java.util.concurrent.ConcurrentHashMap
|
|
import java.util.concurrent.atomic.AtomicLong
|
|
|
|
/**
|
|
* Counters and timers for the work the transcript does, for the readout behind the debug button.
|
|
*
|
|
* Here because the emulator cannot answer the question this is for. Its own scroll sits at the same
|
|
* frame times as the stock Settings app -- 21ms at the median for both -- so every app-level cost
|
|
* is under the floor of what it can measure, and a frame number taken in it says nothing about a
|
|
* 120Hz phone. Counts do not have that problem: how many times a row was composed, or a reply
|
|
* parsed, is the same number on any machine, and it is the number that says whether the work is
|
|
* proportional to what is on screen or to everything ever loaded.
|
|
*
|
|
* Always on rather than behind a build flag. What is measured is an atomic increment on paths that
|
|
* already allocate lists and parse markdown, and a counter that is only compiled into the build
|
|
* nobody is holding when it is slow is not an instrument.
|
|
*/
|
|
object DebugStats {
|
|
private val counts = ConcurrentHashMap<String, AtomicLong>()
|
|
private val nanos = ConcurrentHashMap<String, AtomicLong>()
|
|
private val worst = ConcurrentHashMap<String, AtomicLong>()
|
|
|
|
private fun at(map: ConcurrentHashMap<String, AtomicLong>, name: String) =
|
|
map.computeIfAbsent(name) { AtomicLong() }
|
|
|
|
fun count(name: String, by: Long = 1) {
|
|
at(counts, name).addAndGet(by)
|
|
}
|
|
|
|
/** Keeps [name] at the largest value it has been given, for a high-water mark. */
|
|
fun atLeast(name: String, value: Long) {
|
|
val slot = at(counts, name)
|
|
while (true) {
|
|
val had = slot.get()
|
|
if (value <= had || slot.compareAndSet(had, value)) break
|
|
}
|
|
}
|
|
|
|
/** Records one occurrence of [name] that took [elapsed] nanoseconds. */
|
|
fun record(name: String, elapsed: Long) {
|
|
count(name)
|
|
at(nanos, name).addAndGet(elapsed)
|
|
val slot = at(worst, name)
|
|
while (true) {
|
|
val had = slot.get()
|
|
if (elapsed <= had || slot.compareAndSet(had, elapsed)) break
|
|
}
|
|
}
|
|
|
|
fun <T> timed(name: String, body: () -> T): T {
|
|
val started = System.nanoTime()
|
|
try {
|
|
return body()
|
|
} finally {
|
|
record(name, System.nanoTime() - started)
|
|
}
|
|
}
|
|
|
|
fun reset() {
|
|
counts.clear()
|
|
nanos.clear()
|
|
worst.clear()
|
|
}
|
|
|
|
/** One line per counter: how many, how long in total, and the worst single one. */
|
|
fun lines(): List<String> =
|
|
counts.keys.sorted().map { name ->
|
|
val n = counts[name]?.get() ?: 0
|
|
val total = nanos[name]?.get() ?: 0
|
|
if (total == 0L) " $name: $n"
|
|
else
|
|
" $name: $n, ${ms(total)}ms total, ${ms(total / n.coerceAtLeast(1))}ms mean," +
|
|
" ${ms(worst[name]?.get() ?: 0)}ms worst"
|
|
}
|
|
|
|
/** How long everything named [name] took in total, or zero if it never happened. */
|
|
fun nanosOf(name: String): Long = nanos[name]?.get() ?: 0
|
|
|
|
private fun ms(nanos: Long) = "%.1f".format(nanos / 1_000_000.0)
|
|
}
|
|
|
|
/**
|
|
* How much of the frame's draw phase is this app's own work, and how much is not.
|
|
*
|
|
* The draw phase is where Compose's measurement lands as well as its recording -- the platform
|
|
* calls `measureAndLayout()` from `dispatchDraw` -- so "draw is high" has never said which of three
|
|
* different things is high. The transcript times its own measure, its own placement and its own
|
|
* recording, and this is the subtraction that was otherwise done by hand in a conversation every
|
|
* time a report arrived. What is left over is the framework's per-frame bookkeeping after a layout,
|
|
* which grows with how many nodes are alive rather than with how many are on screen.
|
|
*
|
|
* Per frame rather than in total, because the budget it has to fit in is per frame. The recordings
|
|
* are not themselves per-frame -- a measurement happens on the frames that need one -- so these are
|
|
* shares of an average frame, not a claim about any particular one.
|
|
*/
|
|
fun drawAccounting(drawNanos: Long, frames: Int): List<String> {
|
|
if (frames == 0 || drawNanos == 0L) return emptyList()
|
|
val measure = DebugStats.nanosOf("measure: the whole transcript")
|
|
val place = DebugStats.nanosOf("place: the whole transcript")
|
|
// The rows and blocks record *inside* this one, so adding them too would count them twice.
|
|
val record = DebugStats.nanosOf("draw: the whole transcript")
|
|
val ours = measure + place + record
|
|
val rest = (drawNanos - ours).coerceAtLeast(0)
|
|
fun per(n: Long) = "%.2f".format(n / 1_000_000.0 / frames)
|
|
return listOf(
|
|
" draw phase ${per(drawNanos)}ms per frame, of which:",
|
|
" the transcript: ${per(ours)}ms" +
|
|
" (measure ${per(measure)}, place ${per(place)}, record ${per(record)})",
|
|
" everything else: ${per(rest)}ms" +
|
|
" (${if (drawNanos == 0L) "n/a" else "${rest * 100 / drawNanos}%"})",
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Everything the debug button copies: what the device is, what the transcript is holding, where the
|
|
* frames went, and what the app did to produce them.
|
|
*
|
|
* Written for somebody to paste into a conversation, so it is plain text with the units on every
|
|
* number -- a report whose reader has to ask what the columns mean costs another round trip, and
|
|
* the whole point of it is to save one.
|
|
*/
|
|
fun debugReport(
|
|
device: String,
|
|
transcript: List<String>,
|
|
frames: List<String>,
|
|
accounting: List<String>,
|
|
crash: String?,
|
|
): String = buildString {
|
|
appendLine("ai-app render report")
|
|
appendLine(device)
|
|
appendLine()
|
|
// First, because a crash outranks every timing below it and the reader should not have to
|
|
// scroll past two screens of counters to find out the app fell over.
|
|
if (crash != null) {
|
|
appendLine("last crash:")
|
|
crash.trimEnd().lines().forEach { appendLine(" $it") }
|
|
appendLine()
|
|
}
|
|
appendLine("transcript:")
|
|
transcript.forEach { appendLine(it) }
|
|
appendLine()
|
|
appendLine("frames:")
|
|
frames.forEach { appendLine(it) }
|
|
appendLine()
|
|
if (accounting.isNotEmpty()) {
|
|
appendLine("where the draw phase went:")
|
|
accounting.forEach { appendLine(it) }
|
|
appendLine()
|
|
}
|
|
appendLine("work since this was last copied:")
|
|
val work = DebugStats.lines()
|
|
if (work.isEmpty()) appendLine(" nothing recorded") else work.forEach { appendLine(it) }
|
|
}
|
|
|
|
/** Puts [text] on the clipboard under [label], which is what the system offers as its name. */
|
|
fun Context.copyToClipboard(label: String, text: String) {
|
|
getSystemService<ClipboardManager>()?.setPrimaryClip(ClipData.newPlainText(label, text))
|
|
}
|