Skip to content

Commit 0c1dd5b

Browse files
wow-mileyMiley Chandonnetclaude
authored
AMPR-180 #508: Emission protocol foundation (Wave 0) (#518)
* AMPR-180 #508: Emission protocol foundation (Wave 0) I introduce the Emission domain object and EmissionEvent family in commonMain, with four core kinds (Prose, Decision, Confirmation, Sensor), the shared Confidence enum, a content-deterministic dedup key (inputDigest, SHA-256/16 hex), full provenance, and stable @SerialNames. PerceptionEvaluator and OutcomeEvaluator now parse confidence through the shared enum, EmissionEvent.{Produced,Resolved} are registered on the EventRegistry, and two new concept cells (emission, emission-dedup) plus the _index entry document the CHI primitive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AMPR-180 #508: cover EmissionEvent branches in ampere-cli I add the EmissionEvent.{Produced,Resolved} branches to the two ampere-cli when expressions that compile against the Event sealed hierarchy (EventCategorizer, EventRenderer) so CI compiles again. Both new events categorize as SIGNIFICANT and render with a 📡 icon, mirroring the ampere-core SignificanceAwareEventLogger. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Miley Chandonnet <miley@Mileys-Mac-mini.local> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 848a951 commit 0c1dd5b

23 files changed

Lines changed: 1131 additions & 5 deletions

File tree

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/watch/presentation/EventCategorizer.kt

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package link.socket.ampere.cli.watch.presentation
33
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
44
import link.socket.ampere.agents.domain.event.CognitiveEvent
55
import link.socket.ampere.agents.domain.event.CognitivePhaseEvent
6+
import link.socket.ampere.agents.domain.event.EmissionEvent
67
import link.socket.ampere.agents.domain.event.Event
78
import link.socket.ampere.agents.domain.event.FileSystemEvent
89
import link.socket.ampere.agents.domain.event.GitEvent
@@ -96,7 +97,9 @@ object EventCategorizer {
9697
is TicketEvent.TicketCompleted,
9798
is TicketEvent.TicketMeetingScheduled,
9899
is AgentSurfaceEvent.Requested,
99-
is AgentSurfaceEvent.Responded -> EventSignificance.SIGNIFICANT
100+
is AgentSurfaceEvent.Responded,
101+
is EmissionEvent.Produced,
102+
is EmissionEvent.Resolved -> EventSignificance.SIGNIFICANT
100103

101104
// Routine cognitive operations - maintenance work
102105
is GitEvent.BranchCreated,

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/renderer/EventRenderer.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import link.socket.ampere.agents.domain.Urgency
1515
import link.socket.ampere.agents.domain.event.AgentSurfaceEvent
1616
import link.socket.ampere.agents.domain.event.CognitiveEvent
1717
import link.socket.ampere.agents.domain.event.CognitivePhaseEvent
18+
import link.socket.ampere.agents.domain.event.EmissionEvent
1819
import link.socket.ampere.agents.domain.event.Event
1920
import link.socket.ampere.agents.domain.event.EventSource
2021
import link.socket.ampere.agents.domain.event.FileSystemEvent
@@ -168,6 +169,9 @@ class EventRenderer(
168169
// AgentSurface events: native UI surface request/response
169170
is AgentSurfaceEvent.Requested -> "🪟" to magenta
170171
is AgentSurfaceEvent.Responded -> "🪟" to blue
172+
// Emission events: the unifying CHI primitive (produced/resolved)
173+
is EmissionEvent.Produced -> "📡" to magenta
174+
is EmissionEvent.Resolved -> "📡" to blue
171175
}
172176
}
173177

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/domain/Identifiers.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,10 @@ typealias TeamId = String
44
typealias SprintId = String
55
typealias PRId = String
66
typealias RunId = String
7+
8+
/**
9+
* Correlation id for a broader reasoning unit (a perception, a plan, a task)
10+
* that spans multiple [Event]s. Distinct from [RunId], which scopes a single
11+
* Arc execution.
12+
*/
13+
typealias WorkflowId = String
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.json.JsonElement
5+
6+
/**
7+
* A single response option attached to an [Emission].
8+
*
9+
* The `signalPayload` is opaque to AMPERE — it travels back out on the bus
10+
* as part of `EmissionEvent.Resolved` when the human selects this
11+
* affordance. Surface-side consumers decide what to render and what value
12+
* to populate; AMPERE only carries the bytes.
13+
*/
14+
@Serializable
15+
data class Affordance(
16+
val id: AffordanceId,
17+
val label: String,
18+
val signalPayload: JsonElement,
19+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.datetime.Instant
4+
import kotlinx.serialization.Serializable
5+
import link.socket.ampere.agents.domain.reasoning.Confidence
6+
7+
/**
8+
* One moment of computer-initiated human contact — the unit of CHI.
9+
*
10+
* An Emission is published as `EmissionEvent.Produced` on the
11+
* `EventSerialBus`. AMPERE has no opinion on rendering: that is a
12+
* Socket-side concern. AMPERE owns the noun (this type), the verb (the
13+
* event family), and the provenance.
14+
*
15+
* Construction is the only point at which an Emission is mutated. Once
16+
* published it must be treated as immutable; in particular, `dedupKey` is
17+
* fixed at construction. Callers compute `dedupKey` via
18+
* [computeDedupKey] for effect-bearing kinds (Confirmation today, Action
19+
* tomorrow) and leave it `null` for kinds that should always render.
20+
*/
21+
@Serializable
22+
data class Emission(
23+
val id: EmissionId,
24+
val kind: EmissionKind,
25+
val payload: EmissionPayload,
26+
val affordances: List<Affordance> = emptyList(),
27+
val confidence: Confidence? = null,
28+
val provenance: EmissionProvenance,
29+
val dedupKey: String? = null,
30+
val producedAt: Instant,
31+
)
32+
33+
/**
34+
* Convenience helper for the dedup contract: returns a content digest for
35+
* effect-bearing kinds (currently [EmissionKind.Confirmation]) and `null`
36+
* otherwise. Callers are free to override this default — the helper exists
37+
* so the common case is one call.
38+
*/
39+
fun Emission.computeDedupKey(): String? = when (kind) {
40+
is EmissionKind.Confirmation -> inputDigest(payload)
41+
else -> null
42+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.serialization.json.Json
4+
import okio.ByteString.Companion.encodeUtf8
5+
6+
/**
7+
* Canonical [Json] used for [inputDigest]. Kept private so the hash
8+
* function is the only public way to produce a dedup digest — any change
9+
* to these flags shifts every previously-stored digest, so callers must
10+
* not pick their own configuration.
11+
*
12+
* Mirrors `RepositoryFactory.DEFAULT_JSON`'s `classDiscriminator = "type"`
13+
* convention so the digest matches what the bus would serialize.
14+
*/
15+
private val DigestJson: Json = Json {
16+
classDiscriminator = "type"
17+
encodeDefaults = true
18+
prettyPrint = false
19+
}
20+
21+
/**
22+
* Number of hex characters retained from the SHA-256 digest. 16 chars =
23+
* 64 bits of collision resistance, which is sufficient for the dedup
24+
* window (seconds to minutes on a single agent) and keeps the digest
25+
* short enough to log inline.
26+
*/
27+
private const val DIGEST_HEX_CHARS = 16
28+
29+
/**
30+
* Content-deterministic digest of an [EmissionPayload]. The same payload
31+
* always yields the same digest; differing payloads always yield
32+
* differing digests (modulo SHA-256 collisions).
33+
*
34+
* Stability comes from two pieces:
35+
* - `kotlinx.serialization` writes data-class fields in declaration order
36+
* - [DigestJson] forces a single canonical configuration
37+
*
38+
* Field ordering in source code therefore does not affect the digest, and
39+
* adding a default-valued field to a payload variant changes the digest
40+
* — both of which are the intended semantics.
41+
*/
42+
fun inputDigest(payload: EmissionPayload): String {
43+
val json = DigestJson.encodeToString(EmissionPayload.serializer(), payload)
44+
return json.encodeUtf8().sha256().hex().take(DIGEST_HEX_CHARS)
45+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
/**
4+
* Random identity for a published [Emission]. Generated via the platform
5+
* `randomUUID()` helper. Dedup must never be overloaded onto this id — see
6+
* the `EmissionDedup` concept cell.
7+
*/
8+
typealias EmissionId = String
9+
10+
/**
11+
* Random identity for an [Affordance] attached to an [Emission]. Stable for
12+
* the lifetime of the emission so a corresponding `EmissionEvent.Resolved`
13+
* can causally link to the chosen affordance.
14+
*/
15+
typealias AffordanceId = String
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* The four core kinds of [Emission] surfaced by AMPERE today. The kind is a
8+
* peer of the payload — a tag — and is preserved on the wire to allow
9+
* consumers to dispatch without unpacking the payload.
10+
*
11+
* Adding a kind is a wire-format change: introduce a new variant, add the
12+
* matching [EmissionPayload] variant, and bump the concept cells.
13+
*/
14+
@Serializable
15+
sealed interface EmissionKind {
16+
17+
/** Prose narration intended for human reading. */
18+
@Serializable
19+
@SerialName("EmissionKind.Prose")
20+
data object Prose : EmissionKind
21+
22+
/** A question with affordances representing distinct choices. */
23+
@Serializable
24+
@SerialName("EmissionKind.Decision")
25+
data object Decision : EmissionKind
26+
27+
/** A request to confirm an effect before it executes. */
28+
@Serializable
29+
@SerialName("EmissionKind.Confirmation")
30+
data object Confirmation : EmissionKind
31+
32+
/** An ambient observation or reading (gauge, status, latency, …). */
33+
@Serializable
34+
@SerialName("EmissionKind.Sensor")
35+
data object Sensor : EmissionKind
36+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Format applied to an [EmissionPayload.Prose] body. Renderers honour the
8+
* tag; AMPERE itself never renders.
9+
*/
10+
@Serializable
11+
enum class ProseFormat {
12+
PLAIN,
13+
MARKDOWN,
14+
}
15+
16+
/**
17+
* Danger tier for an [EmissionPayload.Confirmation]. Carries no rendering
18+
* decision — it expresses the underlying *effect* the human is being asked
19+
* to confirm. Surface-side policy decides how loud to be.
20+
*/
21+
@Serializable
22+
enum class DangerLevel {
23+
LOW,
24+
MEDIUM,
25+
HIGH,
26+
}
27+
28+
/**
29+
* Typed body of an [Emission]. One variant per [EmissionKind]; the pairing
30+
* is established by callers and validated lightly in [Emission.init].
31+
*
32+
* Adding a payload variant is a wire-format change — pick a stable
33+
* `@SerialName` and never rename it.
34+
*/
35+
@Serializable
36+
sealed interface EmissionPayload {
37+
38+
/** Prose narration intended for human reading. */
39+
@Serializable
40+
@SerialName("EmissionPayload.Prose")
41+
data class Prose(
42+
val text: String,
43+
val format: ProseFormat,
44+
) : EmissionPayload
45+
46+
/**
47+
* A question framed for the human. Affordances representing the
48+
* available answers live on [Emission.affordances], not in the payload.
49+
*/
50+
@Serializable
51+
@SerialName("EmissionPayload.Decision")
52+
data class Decision(
53+
val prompt: String,
54+
val context: String? = null,
55+
) : EmissionPayload
56+
57+
/**
58+
* A request to confirm an effect before AMPERE executes it. `preview`
59+
* is a short renderable summary of the effect (for example, the diff
60+
* a tool is about to apply).
61+
*/
62+
@Serializable
63+
@SerialName("EmissionPayload.Confirmation")
64+
data class Confirmation(
65+
val action: String,
66+
val preview: String? = null,
67+
val dangerLevel: DangerLevel,
68+
) : EmissionPayload
69+
70+
/**
71+
* Ambient sensor reading. Optional `refreshUri` lets a renderer pull a
72+
* fresh value without going back through the bus.
73+
*/
74+
@Serializable
75+
@SerialName("EmissionPayload.Sensor")
76+
data class Sensor(
77+
val label: String,
78+
val value: String,
79+
val unit: String? = null,
80+
val refreshUri: String? = null,
81+
) : EmissionPayload
82+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package link.socket.ampere.agents.domain.emission
2+
3+
import kotlinx.serialization.Serializable
4+
import link.socket.ampere.agents.domain.RunId
5+
import link.socket.ampere.agents.domain.WorkflowId
6+
import link.socket.ampere.agents.domain.event.EventId
7+
8+
/**
9+
* Where this [Emission] came from. Every Emission carries provenance — that
10+
* is what makes it auditable downstream.
11+
*
12+
* - `runId` / `workflowId` link the Emission to the Arc and reasoning unit
13+
* that produced it.
14+
* - `sourceEventId` records the event that motivated the Emission (for
15+
* example, the tool result that prompted a Confirmation).
16+
* - `toolInvocationId`, `pluginId`, `modelId` are populated when the
17+
* Emission can be attributed to a specific tool call, plugin, or model.
18+
* - `inputDigest` is the deterministic SHA-256 hash of the payload (see
19+
* [inputDigest]) and lets consumers reason about content identity
20+
* without re-hashing.
21+
*/
22+
@Serializable
23+
data class EmissionProvenance(
24+
val runId: RunId? = null,
25+
val workflowId: WorkflowId? = null,
26+
val sourceEventId: EventId? = null,
27+
val toolInvocationId: String? = null,
28+
val pluginId: String? = null,
29+
val modelId: String? = null,
30+
val inputDigest: String,
31+
)

0 commit comments

Comments
 (0)