ai-app: a phone interface to Claude Code and llama.cpp sessions
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.
This commit is contained in:
commit
b172c464ea
100 files changed
+31795
No files matched your search
@@ -0,0 +1,281 @@
|
||||
package com.example.aiapp
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
// The common event model, mirrored from server/src/session/driver.rs --
|
||||
// the app renders purely from this stream (replayed from the transcript by
|
||||
// cursor, then live), so there is no separate "load history" shape to keep
|
||||
// in sync with it.
|
||||
|
||||
/** One transcript line: the event plus its resume cursor and time. */
|
||||
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
|
||||
|
||||
/**
|
||||
* One choice offered in answer to a question.
|
||||
*
|
||||
* More than a label because the reader is deciding rather than confirming: what an option means,
|
||||
* and what picking it would produce, are the things that decide it. Both are absent on a
|
||||
* permission, whose Allow and Deny mean exactly what they say.
|
||||
*/
|
||||
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
||||
|
||||
sealed class SessionEvent {
|
||||
data class UserMessage(
|
||||
val text: String,
|
||||
/**
|
||||
* The [MessageQueued] this resolves, or null when it never waited.
|
||||
*
|
||||
* Matched on rather than the text, because the same message sent twice is two waiting
|
||||
* bubbles and clearing whichever one matched first would leave the wrong one on screen.
|
||||
*/
|
||||
val id: String?,
|
||||
/**
|
||||
* What was attached to it, by the ref the files route serves.
|
||||
*
|
||||
* On the message rather than beside it: these arrived as separate image events until
|
||||
* 2026-08-30, which drew somebody's screenshot as a row floating above the bubble that sent
|
||||
* it, and left this app deciding from adjacency alone which message an image went with --
|
||||
* something the sender knew and could simply have said.
|
||||
*/
|
||||
val images: List<String>,
|
||||
) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A message the server has accepted and the session has not read yet.
|
||||
*
|
||||
* From the server, not from this app's memory of what it sent. The pending bubble used to be
|
||||
* screen state, so leaving the session or restarting the app drew nothing waiting while the
|
||||
* message was still queued -- and nothing waiting is what "there is nothing" looks like.
|
||||
*
|
||||
* Resolved by the [UserMessage] carrying the same id, exactly as [CommandQueued] is resolved by
|
||||
* [CommandSent].
|
||||
*/
|
||||
data class MessageQueued(val id: String, val text: String, val images: List<String>) :
|
||||
SessionEvent()
|
||||
|
||||
data class AssistantText(val delta: String) : SessionEvent()
|
||||
|
||||
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
|
||||
|
||||
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
|
||||
|
||||
data class ToolEnd(val id: String, val output: String) : SessionEvent()
|
||||
|
||||
data class Image(
|
||||
val ref: String,
|
||||
/** The tool call whose result carried it, or null for a person's own attachment. */
|
||||
val about: String?,
|
||||
) : SessionEvent()
|
||||
|
||||
data class Question(
|
||||
val id: String,
|
||||
val prompt: String,
|
||||
/** A few words naming what the question is about, when the asker offered one. */
|
||||
val header: String?,
|
||||
val options: List<QuestionOption>,
|
||||
/** Whether several options may be chosen at once. */
|
||||
val multiSelect: Boolean,
|
||||
/** The tool call this is permission for, or null when it is not about one. */
|
||||
val about: String?,
|
||||
) : SessionEvent()
|
||||
|
||||
/** Everything chosen for one question, in the order it was offered. */
|
||||
data class Answered(val id: String, val answers: List<String>) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A message another agent sent this session.
|
||||
*
|
||||
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
|
||||
* claim they had. It is also the explanation for a session that starts working on something
|
||||
* this device never asked for.
|
||||
*/
|
||||
data class PeerMessage(val from: String, val text: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A command the session was asked to run on itself and cannot run yet.
|
||||
*
|
||||
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
|
||||
* one, so nothing here ever draws a bubble that resolves in the same frame.
|
||||
*/
|
||||
data class CommandQueued(val id: String, val text: String) : SessionEvent()
|
||||
|
||||
/** The same command, handed to the session. */
|
||||
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
||||
|
||||
data class Status(val state: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* What the session is set to, as the session itself reports it.
|
||||
*
|
||||
* Either field alone: the two are confirmed separately and by different things. Asking for a
|
||||
* change is not having one, so this -- not the request -- is what the pickers show.
|
||||
*/
|
||||
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* What a turn cost, and how much the model was holding when it ended.
|
||||
*
|
||||
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
|
||||
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
|
||||
* conversation's context drops at a compaction and a clear, so adding turns up would report a
|
||||
* figure the session stopped being true of. Null where the dialect did not say, and on entries
|
||||
* recorded before the backend sent it -- which leaves the context unmeasured rather than
|
||||
* unchanged.
|
||||
*/
|
||||
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
|
||||
|
||||
/**
|
||||
* A compaction that finished, and how much context it recovered.
|
||||
*
|
||||
* The counts are nullable because the server sends them only when it was told them: a
|
||||
* compaction whose size nobody measured has to be able to say so, since a zero here would read
|
||||
* as "recovered nothing" and a made-up number would read as a measurement.
|
||||
*/
|
||||
data class Compacted(
|
||||
val preTokens: Long?,
|
||||
val postTokens: Long?,
|
||||
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
|
||||
val trigger: String?,
|
||||
) : SessionEvent()
|
||||
|
||||
/**
|
||||
* The conversation was cleared. Everything above this is still here to read and is no longer in
|
||||
* the session's context.
|
||||
*
|
||||
* An object rather than a class because it carries nothing: what it means is entirely its
|
||||
* position in the transcript.
|
||||
*/
|
||||
data object Cleared : SessionEvent()
|
||||
|
||||
data class Error(val message: String) : SessionEvent()
|
||||
|
||||
/**
|
||||
* An event type this app build doesn't know -- a newer server. Kept (not thrown) so one new
|
||||
* event kind degrades to a placeholder row instead of killing the stream.
|
||||
*/
|
||||
data class Unknown(val type: String) : SessionEvent()
|
||||
}
|
||||
|
||||
/**
|
||||
* A JSON array of strings under [name], empty when the field is absent.
|
||||
*
|
||||
* Absent is the ordinary case -- most messages carry no attachment, and the server omits the field
|
||||
* rather than sending an empty list -- so this is the shape every caller wants.
|
||||
*/
|
||||
private fun JSONObject.stringList(name: String): List<String> {
|
||||
val array = optJSONArray(name) ?: return emptyList()
|
||||
return (0 until array.length()).map { array.getString(it) }
|
||||
}
|
||||
|
||||
fun parseSeqEvent(json: String): SeqEvent {
|
||||
val body = JSONObject(json)
|
||||
val event =
|
||||
when (val type = body.getString("type")) {
|
||||
"userMessage" ->
|
||||
SessionEvent.UserMessage(
|
||||
body.getString("text"),
|
||||
body.optString("id").ifEmpty { null },
|
||||
body.stringList("images"),
|
||||
)
|
||||
"messageQueued" ->
|
||||
SessionEvent.MessageQueued(
|
||||
body.getString("id"),
|
||||
body.getString("text"),
|
||||
body.stringList("images"),
|
||||
)
|
||||
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
|
||||
"toolStart" ->
|
||||
SessionEvent.ToolStart(
|
||||
id = body.getString("id"),
|
||||
tool = body.getString("tool"),
|
||||
// Kept as raw JSON text: the input shape is the tool's own
|
||||
// business, and the UI only ever shows it verbatim.
|
||||
input = body.get("input").toString(),
|
||||
)
|
||||
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
|
||||
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
|
||||
"image" ->
|
||||
SessionEvent.Image(
|
||||
ref = body.getString("ref"),
|
||||
about = body.optString("about").ifEmpty { null },
|
||||
)
|
||||
"question" ->
|
||||
SessionEvent.Question(
|
||||
id = body.getString("id"),
|
||||
prompt = body.getString("prompt"),
|
||||
header = body.optString("header").ifEmpty { null },
|
||||
options =
|
||||
body.getJSONArray("options").let { options ->
|
||||
(0 until options.length()).map { at ->
|
||||
val option = options.getJSONObject(at)
|
||||
QuestionOption(
|
||||
label = option.getString("label"),
|
||||
description = option.optString("description").ifEmpty { null },
|
||||
preview = option.optString("preview").ifEmpty { null },
|
||||
)
|
||||
}
|
||||
},
|
||||
multiSelect = body.optBoolean("multiSelect", false),
|
||||
about = body.optString("about").ifEmpty { null },
|
||||
)
|
||||
"answered" ->
|
||||
SessionEvent.Answered(
|
||||
body.getString("id"),
|
||||
body.getJSONArray("answers").let { answers ->
|
||||
(0 until answers.length()).map { answers.getString(it) }
|
||||
},
|
||||
)
|
||||
"peerMessage" ->
|
||||
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
|
||||
"commandQueued" ->
|
||||
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
||||
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
||||
"status" -> SessionEvent.Status(body.getString("state"))
|
||||
"settings" ->
|
||||
SessionEvent.Settings(
|
||||
model = body.optString("model").ifEmpty { null },
|
||||
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
||||
)
|
||||
"usageDelta" ->
|
||||
SessionEvent.UsageDelta(
|
||||
body.getLong("tokens"),
|
||||
if (body.has("context")) body.getLong("context") else null,
|
||||
)
|
||||
"compacted" ->
|
||||
SessionEvent.Compacted(
|
||||
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
||||
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
|
||||
trigger = body.optString("trigger").ifEmpty { null },
|
||||
)
|
||||
"cleared" -> SessionEvent.Cleared
|
||||
"error" -> SessionEvent.Error(body.getString("message"))
|
||||
else -> SessionEvent.Unknown(type)
|
||||
}
|
||||
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
||||
}
|
||||
|
||||
/**
|
||||
* The context after [event], given what it was before.
|
||||
*
|
||||
* The same rule the server folds with, because the screen has to keep up between page loads: the
|
||||
* summary it opened with is a measurement from before this stream started, and every event that
|
||||
* moves the figure arrives here.
|
||||
*
|
||||
* The two that lower it are the point. A clear takes the conversation away and a compaction
|
||||
* replaces it with a summary, so a figure measured before either stopped being true at that moment
|
||||
* -- and carrying it forward is how a session that had just been cleared went on reporting the
|
||||
* context it no longer had.
|
||||
*
|
||||
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
|
||||
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
|
||||
* since.
|
||||
*/
|
||||
fun contextAfter(current: Long?, event: SessionEvent): Long? =
|
||||
when (event) {
|
||||
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
|
||||
// turn -- which every context figure is -- rather than unknown.
|
||||
is SessionEvent.UsageDelta -> event.context ?: current
|
||||
is SessionEvent.Compacted -> event.postTokens
|
||||
is SessionEvent.Cleared -> null
|
||||
else -> current
|
||||
}
|
||||
Reference in new issue
Block a user