package com.sptmobile.endpoint import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull /** * The phone's digest window for one endpoint — the Kotlin twin of * `rust/link-client/src/digest.rs` (semantics mirrored 1:1; the JNI surface * hands over raw snapshot/delta JSON, so the view state lives app-side). * * Wire shapes are spt-core's, carried verbatim and never re-derived: * * - **Snapshot** (`spt endpoint digest --json`, host passthrough): one object * `{"turns": [...]}`; each turn is `{input, input_seq?, entries, partial?}`; * entries are single-key tagged (`Agent` / `ToolSprint` / `Boundary` / * `Context`); `seq` appears ONLY on closed-turn `Agent`/`ToolSprint` * entries, and `input_seq` ONLY once the turn CLOSES — the open turn is * `partial: true` with NO `input_seq` (probed against the released binary * 2026-07-07; KNOWN-HAZARDS 3.1). An `--after ` reply re-includes the * cursor turn and the open turn; entries carry an optional RFC3339-UTC `ts` * (public docs, digest contract). With `--after `, a cursor that * predates the retained window comes back as the full window plus * `"after_predates_window": true`. * - **Delta** (`--follow --json`, one per line): compact * `{version, from, turns}` — truncate the local view to `from` turns and * append; the first line is the full window at `from: 0`. * * Dedup is EXACT, two axes, never fuzzy (REQ-HAZARD-DUP-ROWS): digest rows * dedup by seq / turn `input_seq` across snapshot+follow reconnects; the * phone's own sends collapse against their digest echo by msg-id. */ // [impl->REQ-ENDPOINT-VIEW-INTERLACE] class DigestViewState { enum class DeltaOutcome { Applied, /** `from` points past the view — a real gap. View untouched; re-sync * via `digestSnapshot(endpoint, cursor())`. */ GapResync, } enum class SnapshotOutcome { /** Full window replaced the view (first load or predated cursor). */ Replaced, /** Turns merged by `input_seq` — overlap replaced, new appended. */ Merged, } var version: Long = 0 private set /** * Closed/stable turns, keyed by their stable turn seq, in seq order. * APPEND-ONLY: a logged turn is only ever updated in place, never removed * or reordered — so the timeline only grows and a scroll anchor in it is * never disturbed. This is the accumulator that replaced the old * mirror-the-window model (whose delta-truncate vs snapshot-merge desync * flip-flopped the list length + threw the scroll on every heartbeat). */ private val closed = sortedMapOf() /** The single live (`partial`) turn — the mutable tail, replaced each * ingest with its latest version (its entries only grow, stable-keyed). */ private var open: JsonObject? = null /** Turns for display: the append-only closed log, then the live tail. */ val turns: List get() = closed.values + listOfNotNull(open) private val ownMsgIds = mutableSetOf() /** * Accumulate a batch of turns. Snapshots and follow deltas are treated * identically — both just CONTRIBUTE turns; nothing is truncated or * removed. A closed turn is logged/deduped by its stable seq (idempotent, * so a re-delivered turn is a no-op and a resync can never oscillate the * length). The batch's `partial` turn becomes the live tail; a batch that * carries the tail but no partial turn means the open turn just closed * (now logged) with none reopened yet. */ fun ingest(turns: List) { if (turns.isEmpty()) return var sawPartial = false for (turn in turns) { if (isPartial(turn)) { open = turn sawPartial = true } else { turnKey(turn)?.let { closed[it] = turn } } } if (!sawPartial) open = null } /** * Apply one follow delta — just accumulate its turns. The positional * `from` is deliberately ignored: truncating to it (the old behaviour) is * exactly what desynced against the snapshot merge. Always [Applied]; gaps * self-heal because a later batch (or the resync belt) re-contributes. */ fun applyDelta(line: String): DeltaOutcome { val d = json.parseToJsonElement(line).jsonObject val turns = d["turns"]?.jsonArray?.map { it.jsonObject } ?: throw IllegalArgumentException("delta missing turns") ingest(turns) version = d["version"]?.jsonPrimitive?.longOrNull ?: version return DeltaOutcome.Applied } /** Apply a snapshot (initial load or `--after` re-sync) — accumulate its * turns. Idempotent against what is already logged. */ fun applySnapshot(snapshotJson: String): SnapshotOutcome { val v = json.parseToJsonElement(snapshotJson).jsonObject val turns = v["turns"]?.jsonArray?.map { it.jsonObject } ?: throw IllegalArgumentException("snapshot has no turns array") val wasEmpty = closed.isEmpty() && open == null ingest(turns) return if (wasEmpty) SnapshotOutcome.Replaced else SnapshotOutcome.Merged } /** * The re-sync cursor: the highest seq anywhere in the view (turn * `input_seq`s and closed-entry `seq`s). Null on an empty/unseqed view → * re-sync without `--after` (pass `after < 0` to the JNI call). */ fun cursor(): Long? = turns.flatMap(::seqsOfTurn).maxOrNull() /** Every seq in view order — the digest-row dedup axis. */ fun seqs(): List = turns.flatMap(::seqsOfTurn) /** Record an own send's msg-id so its digest echo collapses. */ fun noteOwnSend(msgId: String) { ownMsgIds += msgId } /** Snapshot of the own-send msg-id set for interlacing. */ fun ownSends(): Set = ownMsgIds.toSet() companion object { private val json = Json { ignoreUnknownKeys = true } /** * A turn's `input_seq` — its exact identity across snapshot merges. * Present only once the turn CLOSES; the open (`partial: true`) turn * carries none (probed 2026-07-07 — see the class doc and * KNOWN-HAZARDS 3.1). */ fun inputSeq(turn: JsonObject): Long? = turn["input_seq"]?.jsonPrimitive?.longOrNull /** The live turn is `partial: true` — it has no `input_seq` and its * entries carry no `seq` until they finalize. */ fun isPartial(turn: JsonObject): Boolean = turn["partial"]?.jsonPrimitive?.booleanOrNull ?: false /** * A closed turn's stable log key: its `input_seq`, or — for a CLOSED * null-input turn (agent-context, psyche-download, boundary crossing) — * its first entry `seq`. Null only for a turn with no seq anchor at all * (the live turn, handled separately). Stable across re-deliveries, so * it dedups the accumulator. */ fun turnKey(turn: JsonObject): Long? = inputSeq(turn) ?: seqsOfTurn(turn).minOrNull() /** * All seqs a turn contributes: its `input_seq` plus each closed * `Agent`/`ToolSprint` entry's `seq` (`Boundary`/`Context`/partial * entries carry none). */ fun seqsOfTurn(turn: JsonObject): List { val seqs = mutableListOf() inputSeq(turn)?.let { seqs += it } turn["entries"]?.jsonArray?.forEach { entry -> val inner = entry.jsonObject.values.firstOrNull()?.jsonObject inner?.get("seq")?.jsonPrimitive?.longOrNull?.let { seqs += it } } return seqs } /** * Recover the msg-id from a digest `Context{kind: owl_message}` row: * its body is the composed `` verbatim; the `--json-payload` * blob rides the `json` attr (KNOWN-HAZARDS 3.1 mapping). Null for * every other row kind — never fuzzy. */ fun echoMsgId(entry: JsonObject): String? { val ctx = entry["Context"]?.jsonObject ?: return null if (ctx["kind"]?.jsonPrimitive?.content != "owl_message") return null val body = ctx["body"]?.jsonPrimitive?.content ?: return null val ev = EventFrame.parse(body) ?: return null val payload = ev.attr("json") ?: return null return try { json.parseToJsonElement(payload).jsonObject["msg-id"] ?.jsonPrimitive?.content } catch (_: IllegalArgumentException) { null } } } }