Move sealed telemetry to Rust

This commit is contained in:
Meta Vens 2026-05-06 08:05:35 +01:00
commit ffd1a14999
11 changed files with 546 additions and 334 deletions

View file

@ -0,0 +1,526 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use chrono::Utc;
use serde_json::Map;
use serde_json::Value;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
const TEXTLIKE_KEYS: &[&str] = &[
"activeTranscript",
"body",
"content",
"input",
"inputTranscript",
"items",
"message",
"note",
"output",
"prompt",
"raw",
"rawResult",
"reasoning",
"result",
"summary",
"text",
"turns",
];
const SAFE_KEYS: &[&str] = &[
"action",
"artifactPath",
"backendJobId",
"bindingId",
"changedFields",
"cwd",
"ephemeral",
"expectedRevision",
"id",
"jobId",
"kind",
"level",
"maxRuntimeSeconds",
"method",
"mode",
"path",
"recommendedAction",
"recommendedSceneAction",
"revision",
"roleId",
"source",
"stateStatus",
"status",
"targetRole",
"threadId",
"turnId",
"type",
"verdict",
];
const COMMAND_VERBS: &[&str] = &[
"rg",
"git",
"Get-Content",
"Get-ChildItem",
"Select-String",
"Test-Path",
"New-Item",
"Set-Content",
"python",
"cargo",
"dotnet",
"npm",
"node",
"apply_patch",
];
fn main() -> Result<()> {
let args = parse_args()?;
let telemetry = build_telemetry(&args.transcript)?;
if let Some(output) = args.output {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
fs::write(
&output,
format!("{}\n", serde_json::to_string_pretty(&telemetry)?),
)
.with_context(|| format!("failed to write {}", output.display()))?;
}
println!("{}", serde_json::to_string_pretty(&telemetry)?);
Ok(())
}
struct Args {
transcript: PathBuf,
output: Option<PathBuf>,
}
fn parse_args() -> Result<Args> {
let mut args = env::args().skip(1);
let Some(transcript) = args.next() else {
return Err(anyhow!(
"usage: epiphany-agent-telemetry <transcript> [--output <path>]"
));
};
let mut output = None;
while let Some(arg) = args.next() {
match arg.as_str() {
"--output" => {
output = Some(PathBuf::from(
args.next()
.ok_or_else(|| anyhow!("--output requires a path"))?,
));
}
_ => return Err(anyhow!("unknown argument: {arg}")),
}
}
Ok(Args {
transcript: PathBuf::from(transcript),
output,
})
}
fn build_telemetry(transcript_path: &Path) -> Result<Value> {
if !transcript_path.exists() {
return Ok(json!({
"transcriptPath": transcript_path,
"generatedAt": Utc::now().to_rfc3339(),
"status": "missing",
"events": [],
"counts": {},
}));
}
let raw = fs::read_to_string(transcript_path)
.with_context(|| format!("failed to read {}", transcript_path.display()))?;
let mut events = Vec::new();
let mut method_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut direction_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut function_counts: BTreeMap<String, u64> = BTreeMap::new();
let mut decode_errors = Vec::new();
for (index, line) in raw.lines().enumerate() {
if line.trim().is_empty() {
continue;
}
let record: Value = match serde_json::from_str(line) {
Ok(value) => value,
Err(error) => {
decode_errors.push(json!({
"index": index,
"error": error.to_string(),
"chars": line.chars().count(),
}));
continue;
}
};
let event = match record {
Value::Object(map) => telemetry_event(index, &map),
other => json!({
"index": index,
"payload": sealed_summary("record", &other),
}),
};
let direction = event
.get("direction")
.and_then(Value::as_str)
.unwrap_or("unknown")
.to_string();
*direction_counts.entry(direction).or_default() += 1;
if let Some(method) = event.get("method").and_then(Value::as_str) {
*method_counts.entry(method.to_string()).or_default() += 1;
}
if let Some(names) = event.get("functionNames").and_then(Value::as_array) {
for name in names.iter().filter_map(Value::as_str) {
*function_counts.entry(name.to_string()).or_default() += 1;
}
}
events.push(event);
}
let mut request_shape: BTreeMap<String, BTreeMap<String, u64>> = BTreeMap::new();
for event in &events {
let Some(event_id) = event.get("id") else {
continue;
};
let Some(direction) = event.get("direction").and_then(Value::as_str) else {
continue;
};
if direction != "sent" && direction != "received" {
continue;
}
let id = value_key(event_id);
let entry = request_shape.entry(id).or_insert_with(|| {
BTreeMap::from([("sent".to_string(), 0), ("received".to_string(), 0)])
});
*entry.entry(direction.to_string()).or_default() += 1;
}
Ok(json!({
"transcriptPath": transcript_path,
"generatedAt": Utc::now().to_rfc3339(),
"status": "ok",
"policy": {
"directThoughtsSealed": true,
"note": "Telemetry summarizes function/API shape only; text, raw results, and transcript payloads are sealed.",
},
"counts": {
"events": events.len(),
"directions": direction_counts,
"methods": method_counts,
"functionNames": function_counts,
"decodeErrors": decode_errors.len(),
},
"requestShape": request_shape,
"decodeErrors": decode_errors,
"events": events,
}))
}
fn telemetry_event(index: usize, record: &Map<String, Value>) -> Value {
let (direction, payload) = if let Some(sent) = record.get("sent") {
("sent", sent)
} else if let Some(received) = record.get("received") {
("received", received)
} else {
("unknown", &Value::Object(record.clone()))
};
let mut event = Map::new();
event.insert("index".to_string(), json!(index));
event.insert("direction".to_string(), json!(direction));
let Some(payload) = payload.as_object() else {
event.insert("payload".to_string(), sealed_summary("payload", payload));
return Value::Object(event);
};
for key in ["id", "method"] {
if let Some(value) = payload.get(key) {
event.insert(key.to_string(), value.clone());
}
}
if let Some(error) = payload.get("error") {
event.insert("error".to_string(), summarize_value(error, ""));
}
if let Some(params) = payload.get("params") {
event.insert("params".to_string(), summarize_value(params, "params"));
}
if let Some(result) = payload.get("result") {
event.insert(
"result".to_string(),
summarize_value(result, "responseResult"),
);
}
let command_telemetry = collect_command_telemetry(&Value::Object(payload.clone()));
if !command_telemetry.is_empty() {
event.insert(
"commandTelemetry".to_string(),
Value::Array(command_telemetry.iter().take(16).cloned().collect()),
);
event.insert(
"commandTelemetryCount".to_string(),
json!(command_telemetry.len()),
);
}
let names = collect_strings(
&Value::Object(payload.clone()),
&["toolName", "tool", "functionName", "name"],
);
if !names.is_empty() {
event.insert(
"functionNames".to_string(),
Value::Array(names.iter().take(16).map(|s| json!(s)).collect()),
);
event.insert("functionNameCount".to_string(), json!(names.len()));
}
let paths = collect_strings(
&Value::Object(payload.clone()),
&["path", "cwd", "artifactPath"],
);
if !paths.is_empty() {
event.insert(
"paths".to_string(),
Value::Array(paths.iter().take(16).map(|s| json!(s)).collect()),
);
event.insert("pathCount".to_string(), json!(paths.len()));
}
Value::Object(event)
}
fn summarize_value(value: &Value, key: &str) -> Value {
if let Some(scalar) = scalar_summary(key, value) {
return scalar;
}
if contains(TEXTLIKE_KEYS, key) {
return sealed_summary(key, value);
}
match value {
Value::Array(items) => json!({
"kind": "list",
"count": items.len(),
"items": items.iter().take(8).map(|item| summarize_value(item, "")).collect::<Vec<_>>(),
"truncated": items.len() > 8,
}),
Value::Object(map) => {
let mut result = Map::new();
for (child_key, child_value) in map {
let summarized =
if contains(TEXTLIKE_KEYS, child_key) && !contains(SAFE_KEYS, child_key) {
sealed_summary(child_key, child_value)
} else if contains(SAFE_KEYS, child_key)
|| child_value.is_object()
|| child_value.is_array()
{
summarize_value(child_value, child_key)
} else if let Some(text) = child_value.as_str() {
json!({"sealed": true, "kind": "text", "chars": text.chars().count()})
} else {
child_value.clone()
};
result.insert(child_key.clone(), summarized);
}
Value::Object(result)
}
other => json!({"sealed": true, "kind": value_kind(other)}),
}
}
fn scalar_summary(key: &str, value: &Value) -> Option<Value> {
match value {
Value::Null | Value::Bool(_) | Value::Number(_) => Some(value.clone()),
Value::String(text) if contains(SAFE_KEYS, key) => Some(json!(text)),
Value::String(text) => {
Some(json!({"sealed": true, "kind": "text", "chars": text.chars().count()}))
}
_ => None,
}
}
fn sealed_summary(key: &str, value: &Value) -> Value {
let mut summary = Map::new();
summary.insert("sealed".to_string(), json!(true));
summary.insert("key".to_string(), json!(key));
summary.insert(
"reason".to_string(),
json!("direct agent or transcript content is excluded from telemetry"),
);
match value {
Value::String(text) => {
summary.insert("chars".to_string(), json!(text.chars().count()));
}
Value::Array(items) => {
summary.insert("items".to_string(), json!(items.len()));
}
Value::Object(map) => {
let mut keys: Vec<_> = map.keys().map(String::as_str).collect();
keys.sort_unstable();
summary.insert(
"keys".to_string(),
json!(keys.into_iter().take(24).collect::<Vec<_>>()),
);
summary.insert("keyCount".to_string(), json!(map.len()));
}
_ => {}
}
Value::Object(summary)
}
fn collect_strings(value: &Value, key_names: &[&str]) -> Vec<String> {
let mut found = BTreeSet::new();
collect_strings_into(value, key_names, &mut found);
found.into_iter().collect()
}
fn collect_strings_into(value: &Value, key_names: &[&str], found: &mut BTreeSet<String>) {
match value {
Value::Object(map) => {
for (key, item) in map {
if contains(key_names, key) {
if let Some(text) = item.as_str() {
found.insert(text.to_string());
}
} else if item.is_object() || item.is_array() {
collect_strings_into(item, key_names, found);
}
}
}
Value::Array(items) => {
for item in items {
collect_strings_into(item, key_names, found);
}
}
_ => {}
}
}
fn collect_command_telemetry(value: &Value) -> Vec<Value> {
let mut found = Vec::new();
collect_command_telemetry_into(value, &mut found);
found
}
fn collect_command_telemetry_into(value: &Value, found: &mut Vec<Value>) {
match value {
Value::Object(map) => {
if map.get("type").and_then(Value::as_str) == Some("commandExecution")
&& let Some(command) = map.get("command").and_then(Value::as_str)
{
let mut info = summarize_command_text(command);
if let Some(object) = info.as_object_mut() {
object.insert(
"cwd".to_string(),
map.get("cwd").cloned().unwrap_or(Value::Null),
);
object.insert(
"status".to_string(),
map.get("status").cloned().unwrap_or(Value::Null),
);
object.insert(
"exitCode".to_string(),
map.get("exitCode").cloned().unwrap_or(Value::Null),
);
object.insert(
"durationMs".to_string(),
map.get("durationMs").cloned().unwrap_or(Value::Null),
);
}
found.push(info);
}
for item in map
.values()
.filter(|item| item.is_object() || item.is_array())
{
collect_command_telemetry_into(item, found);
}
}
Value::Array(items) => {
for item in items {
collect_command_telemetry_into(item, found);
}
}
_ => {}
}
}
fn summarize_command_text(command: &str) -> Value {
let compact = command
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join(" ");
let verbs = COMMAND_VERBS
.iter()
.filter(|verb| contains_token(command, verb))
.copied()
.collect::<Vec<_>>();
let has_write_verb = verbs.iter().any(|verb| {
["New-Item", "Set-Content", "apply_patch"]
.iter()
.any(|write| verb.eq_ignore_ascii_case(write))
});
json!({
"chars": command.chars().count(),
"lines": std::cmp::max(command.lines().count(), 1),
"preview": compact.chars().take(240).collect::<String>(),
"truncated": compact.chars().count() > 240,
"verbs": verbs,
"hasWriteVerb": has_write_verb,
})
}
fn contains_token(haystack: &str, needle: &str) -> bool {
let haystack = haystack.to_lowercase();
let needle = needle.to_lowercase();
let bytes = haystack.as_bytes();
let mut start = 0;
while let Some(offset) = haystack[start..].find(&needle) {
let idx = start + offset;
let end = idx + needle.len();
let before_ok = idx == 0 || !is_word(bytes[idx - 1] as char);
let after_ok = end >= bytes.len() || !is_word(bytes[end] as char);
if before_ok && after_ok {
return true;
}
start = end;
}
false
}
fn value_key(value: &Value) -> String {
value
.as_str()
.map(ToString::to_string)
.unwrap_or_else(|| value.to_string())
}
fn value_kind(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "bool",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "list",
Value::Object(_) => "dict",
}
}
fn contains(keys: &[&str], key: &str) -> bool {
keys.iter().any(|candidate| candidate == &key)
}
fn is_word(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
}

View file

@ -40,7 +40,7 @@ The spine exists in more than twenty-five live paths:
- fixed-lane coordinator: app-server declares experimental read-only `thread/epiphany/coordinator`, composes role, pressure, reorient, CRRC, role result, and reorient result signals, and returns one current action without mutation. Review acceptance and implementation clearance are intentionally different: an accepted verification finding clears implementation only when the verifier verdict is `pass`; accepted `needs-evidence`, `needs-review`, or `fail` findings route back toward modeling/checkpoint strengthening before code work resumes. `regatherManually` is now fallback, not a supervisor-puppeteering excuse: completed unaccepted modeling findings stop at `reviewModelingResult`, modeling acceptance precedes verification, and fixed lane actions are considered before manual regather.
- MVP operator view: `tools/epiphany_mvp_status.py` launches app-server over stdio, starts or reads one thread, gathers scene, pressure, reorient, jobs, roles, Imagination/modeling/verification role results, reorient result, and CRRC responses, and renders them as text or JSON so the loop can be dogfooded without reading Rust.
- MVP dogfood runner: `tools/epiphany_mvp_dogfood.py` launches app-server over stdio, drives a bounded state/role/CRRC/reorientation loop, and writes operator-safe JSON/text artifacts plus sealed transcript diagnostics under `.epiphany-dogfood/mvp-loop` so a human can audit the run through projections and compare it against a vanilla Codex reference.
- Agent function/API telemetry: `tools/epiphany_agent_telemetry.py` mechanically parses sealed AppServerClient JSONL transcripts into operator-safe telemetry with method counts, request shape, job/status/path fields, and visible function/tool names while sealing text, turn items, raw results, and direct agent messages.
- Agent function/API telemetry: native `epiphany-agent-telemetry` mechanically parses sealed AppServerClient JSONL transcripts into operator-safe telemetry with method counts, request shape, job/status/path fields, and visible function/tool names while sealing text, turn items, raw results, and direct agent messages.
- reorientation launch: app-server now declares experimental `thread/epiphany/reorientLaunch`, requires a loaded thread plus durable investigation checkpoint, consumes the live reorientation verdict, and launches one fixed `reorient-worker` binding through the existing job-control seam with explicit resume-versus-regather scope, checkpoint-derived payload, and structured-output expectations instead of pretending CRRC suddenly grew a scheduler in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:370), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4575), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4519).
- reorientation result read-back: app-server now declares experimental read-only `thread/epiphany/reorientResult`, defaults to the fixed `reorient-worker` binding, resolves that binding through the current `agent_jobs` backend when available, reads the single backend item result, and projects a reviewable finding with mode, summary, next safe move, checkpoint-validity flag, inspected files, frontier ids, evidence ids, raw result, and any job/item errors without mutating state, promoting evidence, or scheduling follow-up work in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:374), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4610), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4684).
- reorientation finding acceptance: app-server now declares experimental `thread/epiphany/reorientAccept`, requires a loaded thread and a completed `reorient-worker` backend result, appends accepted observation/evidence records, optionally banks the finding into scratch and the durable investigation checkpoint, persists through the normal typed update path, and emits `thread/epiphany/stateUpdated` with source `reorientAccept` without accepting pending work, auto-promoting arbitrary worker output, launching follow-up work, or silently continuing implementation in [common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:378), [v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:4635), and [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4885).

View file

@ -49,7 +49,7 @@ remembers doctrine; the branch remembers the blade.
- The heartbeat/Face operator API pass is landed. Native `epiphany-heartbeat-store status` returns machine-readable initiative state plus CultCache store presence, native `epiphany-face-discord bubble` writes Discord-independent `epiphany.face_bubble.v0` artifacts, `tools/epiphany_mvp_status.py` includes `heartbeat` and `face` blocks, and `tools/epiphany_gui_action.py` exposes `heartbeatStatus`, `runHeartbeat`, and `faceBubble`. `runHeartbeat` advances one slot and emits a Face bubble so Aquarium can show chat even before Discord posting is configured.
- The Aetheria dogfood run has a contamination scar. The supervising Codex session directly edited and committed target-repo work on `E:\Projects\Aetheria-Economy` instead of only driving Epiphany lanes. Treat those Aetheria commits as supervisor-seeded implementation, not clean evidence that Epiphany coordinated the work. Future dogfood must run through the GUI/coordinator/fixed role lanes with auditable artifacts unless the user explicitly authorizes an operator intervention. Remember the sunburn: do not stare into the worker's objective until you become it.
- The dogfood quarantine now has a direct-thought boundary. The supervisor may read coordinator actions, role/reorient statuses, structured finding summaries, reviewed state patches, rendered status snapshots, and artifact manifests. It must not read raw worker transcripts, direct worker messages, full turn logs, or `rawResult` payloads during normal dogfood. Those artifacts are sealed black reliquaries for explicit forensic debugging only.
- `tools/epiphany_agent_telemetry.py` is the safe instrument panel for sealed runs. Status/coordinator/GUI/dogfood/live-specialist tools generate telemetry JSON from sealed transcripts that preserves method names, call shape, job/status/path counts, and any visible function/tool names while sealing text, direct messages, and raw results.
- Native `epiphany-agent-telemetry` is the safe instrument panel for sealed runs. Status/coordinator/GUI/dogfood/live-specialist tools generate telemetry JSON from sealed transcripts that preserves method names, call shape, job/status/path counts, and any visible function/tool names while sealing text, direct messages, and raw results.
- Latest Aetheria supervised dogfood thread: `019ddc52-4ee8-7203-b6c0-106a9c270067` with codex-home `.epiphany-dogfood/aetheria-supervised/codex-home`. It has now exercised modeling, verification, implementation audit, no-diff repair, CRRC reorientation, reorient acceptance, and coordinator replay through operator-safe artifacts without opening sealed worker thoughts.
- Coordinator policy now treats accepted verifier results as implementation clearance only when the verifier verdict is `pass`. Accepting a `needs-evidence`, `needs-review`, or `fail` verifier finding records the review but routes the loop back toward modeling/checkpoint strengthening or reorientation. The policy was tightened after a too-permissive coordinator path treated reviewed verifier output as a green light.
- A later dogfood attempt exposed another supervision scar: manually launching modeling, reorientation, and verification from the supervisor is not clean dogfood. The coordinator now treats `regatherManually` as fallback only after fixed lanes cannot advance, stops at `reviewModelingResult` for completed unaccepted modeling findings, and the CLI runner's explicit test-only `--auto-review` mode can accept modeling `statePatch` findings before launching verification. Production semantic findings remain review-gated.

Binary file not shown.

View file

@ -69,7 +69,7 @@ active_subgoals:
- Keep persistent project state ruthlessly distilled so rehydration strengthens the next awakening instead of drowning it in vellum.
- Quarantine the Aetheria dogfood contamination: future target-repo work must be driven through Epiphany's GUI/coordinator/fixed lanes with auditable artifacts, not direct supervisor implementation.
- Keep the supervisor out of Epiphany worker thought streams: dogfood artifacts should be operator-safe by default, with direct transcripts and raw result payloads sealed in black reliquaries.
- Use `tools/epiphany_agent_telemetry.py` and generated `agent-function-telemetry.json` files to inspect behavior shape without opening sealed transcripts.
- Use native `epiphany-agent-telemetry` and generated `agent-function-telemetry.json` files to inspect behavior shape without opening sealed transcripts.
- Treat repeated no-diff implementation turns as an MVP blocker, not success. A bounded implementation pass must leave a reviewable workspace diff or an explicit reviewable failure artifact before dogfood can be called healthy; empty rites do not sanctify the machine.
- Build the Rider and Unity bridges before the next Aetheria implementation dogfood run: the Aetheria-side resident Unity editor package, named Unity bridge operation planner, Aquarium Environment surface, EpiphanyGraph-backed graph dashboard, Rider context bridge CLI, and Rider plugin MVP scaffold are now landed; the Rider plugin still needs build verification.
- Preserve the newly landed GUI Planning view, explicit Objective Draft adoption action, and Imagination planning synthesis lane; defer GitHub Issues import until a backlog source is fresh enough to be worth feeding Imagination.
@ -147,7 +147,7 @@ accepted_design:
- Epiphany must resist pattern completion bias: the implementor should relentlessly chase the stated objective, while the coordinator critically asks whether implementation satisfied the objective with evidence or merely produced a plausible turn, shortcut, or decorative diff. Pretty motion without objective movement is scrap-code theater.
- Dogfood supervision is a separate operator role. If the supervising Codex session edits the target repo directly, that work can be useful seed material, but it is not clean Epiphany evidence and must be labeled as supervisor intervention or contamination.
- Dogfood supervision is also a projection boundary: the operator may inspect summaries, verdicts, missing coverage, state patches, job state, and artifact receipts, but not the specialist's raw stream of thought or full transcript unless a forensic debugging request explicitly breaks the seal.
- `tools/epiphany_agent_telemetry.py` derives operator-safe function/API telemetry from sealed app-server transcripts so the supervisor can see calls, statuses, paths, and counts without reading direct thoughts.
- Native `epiphany-agent-telemetry` derives operator-safe function/API telemetry from sealed app-server transcripts so the supervisor can see calls, statuses, paths, and counts without reading direct thoughts.
- Editor/runtime verification is now part of the MVP product boundary for engine repos: native `epiphany-unity-bridge` is the first controlled bridge, resolving the project-pinned Unity editor, refusing wrong or missing versions, running explicit batch/probe commands only through the exact editor, and writing inspection/command/log artifacts for evidence.
- Rider and Unity are environment organs, not sources of truth: Rider should expose IDE/source context, diagnostics, navigation, and changed ranges; Unity should expose pinned-editor runtime facts, compilation, tests, probes, assets, scenes, and logs; Epiphany remains the durable Self that coordinates, records, and reviews.
- The product architecture is three-pronged: Rider is the code view for human inspection of repo state, source tree, diffs, diagnostics, and code refs; Epiphany Aquarium is the agent dashboard for objectives, specialist lane state, logs/artifacts, persisted typed state, and graph/control-flow views; Unity is the runtime environment for pinned tests, probes, scene configuration, assets, shaders, and play/edit-mode evidence.

View file

@ -1,325 +0,0 @@
from __future__ import annotations
import argparse
from collections import Counter
from collections import defaultdict
from datetime import datetime
from datetime import timezone
import json
from pathlib import Path
import re
from typing import Any
TEXTLIKE_KEYS = {
"activeTranscript",
"body",
"content",
"input",
"inputTranscript",
"items",
"message",
"note",
"output",
"prompt",
"raw",
"rawResult",
"reasoning",
"result",
"summary",
"text",
"turns",
}
SAFE_KEYS = {
"action",
"artifactPath",
"backendJobId",
"bindingId",
"changedFields",
"cwd",
"ephemeral",
"expectedRevision",
"id",
"jobId",
"kind",
"level",
"maxRuntimeSeconds",
"method",
"mode",
"path",
"recommendedAction",
"recommendedSceneAction",
"revision",
"roleId",
"source",
"stateStatus",
"status",
"targetRole",
"threadId",
"turnId",
"type",
"verdict",
}
def json_dump(value: Any) -> str:
return json.dumps(value, indent=2, ensure_ascii=False) + "\n"
def scalar_summary(key: str, value: Any) -> Any:
if value is None or isinstance(value, (bool, int, float)):
return value
if isinstance(value, str):
if key in SAFE_KEYS:
return value
return {"sealed": True, "kind": "text", "chars": len(value)}
return None
def summarize_value(value: Any, *, key: str = "") -> Any:
scalar = scalar_summary(key, value)
if scalar is not None:
return scalar
if key in TEXTLIKE_KEYS:
return sealed_summary(key, value)
if isinstance(value, list):
return {
"kind": "list",
"count": len(value),
"items": [summarize_value(item) for item in value[:8]],
"truncated": len(value) > 8,
}
if isinstance(value, dict):
result: dict[str, Any] = {}
for child_key, child_value in value.items():
if child_key in TEXTLIKE_KEYS and child_key not in SAFE_KEYS:
result[child_key] = sealed_summary(child_key, child_value)
elif child_key in SAFE_KEYS:
result[child_key] = summarize_value(child_value, key=child_key)
elif isinstance(child_value, (dict, list)):
result[child_key] = summarize_value(child_value, key=child_key)
elif isinstance(child_value, str):
result[child_key] = {"sealed": True, "kind": "text", "chars": len(child_value)}
else:
result[child_key] = child_value
return result
return {"sealed": True, "kind": type(value).__name__}
def sealed_summary(key: str, value: Any) -> dict[str, Any]:
summary: dict[str, Any] = {
"sealed": True,
"key": key,
"reason": "direct agent or transcript content is excluded from telemetry",
}
if isinstance(value, str):
summary["chars"] = len(value)
elif isinstance(value, list):
summary["items"] = len(value)
elif isinstance(value, dict):
summary["keys"] = sorted(str(k) for k in value.keys())[:24]
summary["keyCount"] = len(value)
return summary
def collect_strings(value: Any, key_names: set[str]) -> list[str]:
found: list[str] = []
if isinstance(value, dict):
for key, item in value.items():
if key in key_names and isinstance(item, str):
found.append(item)
elif isinstance(item, (dict, list)):
found.extend(collect_strings(item, key_names))
elif isinstance(value, list):
for item in value:
found.extend(collect_strings(item, key_names))
return found
def summarize_command_text(command: str) -> dict[str, Any]:
compact = " ".join(line.strip() for line in command.splitlines() if line.strip())
verbs = sorted(
{
match.group(0)
for match in re.finditer(
r"\b(?:rg|git|Get-Content|Get-ChildItem|Select-String|Test-Path|New-Item|Set-Content|"
r"python|cargo|dotnet|npm|node|apply_patch)\b",
command,
flags=re.IGNORECASE,
)
},
key=str.lower,
)
return {
"chars": len(command),
"lines": len(command.splitlines()) or 1,
"preview": compact[:240],
"truncated": len(compact) > 240,
"verbs": verbs,
"hasWriteVerb": any(
verb.lower() in {"new-item", "set-content", "apply_patch"}
for verb in verbs
),
}
def collect_command_telemetry(value: Any) -> list[dict[str, Any]]:
found: list[dict[str, Any]] = []
if isinstance(value, dict):
if value.get("type") == "commandExecution" and isinstance(value.get("command"), str):
command_info = summarize_command_text(value["command"])
command_info["cwd"] = value.get("cwd")
command_info["status"] = value.get("status")
command_info["exitCode"] = value.get("exitCode")
command_info["durationMs"] = value.get("durationMs")
found.append(command_info)
for item in value.values():
if isinstance(item, (dict, list)):
found.extend(collect_command_telemetry(item))
elif isinstance(value, list):
for item in value:
found.extend(collect_command_telemetry(item))
return found
def telemetry_event(index: int, record: dict[str, Any]) -> dict[str, Any]:
direction = "unknown"
payload: Any = record
if "sent" in record:
direction = "sent"
payload = record["sent"]
elif "received" in record:
direction = "received"
payload = record["received"]
event: dict[str, Any] = {"index": index, "direction": direction}
if not isinstance(payload, dict):
event["payload"] = sealed_summary("payload", payload)
return event
if "id" in payload:
event["id"] = payload["id"]
if "method" in payload:
event["method"] = payload["method"]
if "error" in payload:
event["error"] = summarize_value(payload["error"])
if "params" in payload:
event["params"] = summarize_value(payload["params"], key="params")
if "result" in payload:
event["result"] = summarize_value(payload["result"], key="responseResult")
command_telemetry = collect_command_telemetry(payload)
if command_telemetry:
event["commandTelemetry"] = command_telemetry[:16]
event["commandTelemetryCount"] = len(command_telemetry)
names = sorted(set(collect_strings(payload, {"toolName", "tool", "functionName", "name"})))
if names:
event["functionNames"] = names[:16]
event["functionNameCount"] = len(names)
paths = sorted(set(collect_strings(payload, {"path", "cwd", "artifactPath"})))
if paths:
event["paths"] = paths[:16]
event["pathCount"] = len(paths)
return event
def build_telemetry(transcript_path: Path) -> dict[str, Any]:
events: list[dict[str, Any]] = []
method_counts: Counter[str] = Counter()
direction_counts: Counter[str] = Counter()
function_counts: Counter[str] = Counter()
decode_errors: list[dict[str, Any]] = []
if not transcript_path.exists():
return {
"transcriptPath": str(transcript_path),
"generatedAt": datetime.now(timezone.utc).isoformat(),
"status": "missing",
"events": [],
"counts": {},
}
for index, line in enumerate(transcript_path.read_text(encoding="utf-8").splitlines()):
if not line.strip():
continue
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
decode_errors.append({"index": index, "error": str(exc), "chars": len(line)})
continue
if not isinstance(record, dict):
events.append({"index": index, "payload": sealed_summary("record", record)})
continue
event = telemetry_event(index, record)
events.append(event)
direction_counts[event.get("direction", "unknown")] += 1
method = event.get("method")
if isinstance(method, str):
method_counts[method] += 1
for name in event.get("functionNames", []):
function_counts[name] += 1
request_latency_shape: dict[str, dict[str, int]] = defaultdict(lambda: {"sent": 0, "received": 0})
for event in events:
event_id = event.get("id")
if event_id is None:
continue
direction = str(event.get("direction", "unknown"))
if direction in {"sent", "received"}:
request_latency_shape[str(event_id)][direction] += 1
return {
"transcriptPath": str(transcript_path),
"generatedAt": datetime.now(timezone.utc).isoformat(),
"status": "ok",
"policy": {
"directThoughtsSealed": True,
"note": "Telemetry summarizes function/API shape only; text, raw results, and transcript payloads are sealed.",
},
"counts": {
"events": len(events),
"directions": dict(direction_counts),
"methods": dict(method_counts),
"functionNames": dict(function_counts),
"decodeErrors": len(decode_errors),
},
"requestShape": dict(request_latency_shape),
"decodeErrors": decode_errors,
"events": events,
}
def write_transcript_telemetry(transcript_path: Path, telemetry_path: Path) -> dict[str, Any]:
telemetry = build_telemetry(transcript_path)
telemetry_path.parent.mkdir(parents=True, exist_ok=True)
telemetry_path.write_text(json_dump(telemetry), encoding="utf-8")
return telemetry
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate operator-safe function/API telemetry from a sealed app-server transcript."
)
parser.add_argument("transcript", type=Path)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
telemetry = build_telemetry(args.transcript.resolve())
if args.output:
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json_dump(telemetry), encoding="utf-8")
else:
print(json_dump(telemetry), end="")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -13,11 +13,11 @@ import tomllib
from pathlib import Path
from typing import Any
from epiphany_agent_telemetry import write_transcript_telemetry
from epiphany_mvp_status import DEFAULT_APP_SERVER
from epiphany_mvp_status import collect_status
from epiphany_mvp_status import render_status
from epiphany_mvp_status import sanitize_for_operator
from epiphany_mvp_status import write_transcript_telemetry
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import ROOT
from epiphany_phase6_reorient_launch_smoke import BINDING_ID as REORIENT_BINDING_ID

View file

@ -9,11 +9,11 @@ import time
from pathlib import Path
from typing import Any
from epiphany_agent_telemetry import write_transcript_telemetry
from epiphany_mvp_status import DEFAULT_APP_SERVER
from epiphany_mvp_status import collect_status
from epiphany_mvp_status import render_status
from epiphany_mvp_status import sanitize_for_operator
from epiphany_mvp_status import write_transcript_telemetry
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import ROOT
from epiphany_phase5_smoke import require

View file

@ -6,11 +6,11 @@ import shutil
from pathlib import Path
from typing import Any
from epiphany_agent_telemetry import write_transcript_telemetry
from epiphany_mvp_status import DEFAULT_APP_SERVER
from epiphany_mvp_status import collect_status
from epiphany_mvp_status import render_status
from epiphany_mvp_status import sanitize_for_operator
from epiphany_mvp_status import write_transcript_telemetry
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import ROOT
from epiphany_phase5_smoke import require

View file

@ -9,11 +9,11 @@ import time
from pathlib import Path
from typing import Any
from epiphany_agent_telemetry import write_transcript_telemetry
from epiphany_mvp_status import DEFAULT_APP_SERVER
from epiphany_mvp_status import collect_status
from epiphany_mvp_status import render_status
from epiphany_mvp_status import sanitize_for_operator
from epiphany_mvp_status import write_transcript_telemetry
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import ROOT
from epiphany_phase5_smoke import require

View file

@ -7,7 +7,6 @@ from pathlib import Path
import subprocess
from typing import Any
from epiphany_agent_telemetry import write_transcript_telemetry
from epiphany_phase5_smoke import AppServerClient
from epiphany_phase5_smoke import DEFAULT_APP_SERVER
from epiphany_phase5_smoke import ROOT
@ -50,6 +49,18 @@ def native_epiphany_bin(bin_name: str, *args: str) -> dict[str, Any]:
return json.loads(completed.stdout)
def write_transcript_telemetry(transcript_path: Path, telemetry_path: Path) -> dict[str, Any]:
telemetry = native_epiphany_bin(
"epiphany-agent-telemetry",
str(transcript_path.resolve()),
"--output",
str(telemetry_path),
)
if telemetry_path.exists():
return json.loads(telemetry_path.read_text(encoding="utf-8"))
return telemetry
def heartbeat_status(*, artifact_dir: Path = DEFAULT_HEARTBEAT_ARTIFACT_DIR, limit: int = 8) -> dict[str, Any]:
return native_epiphany_bin(
"epiphany-heartbeat-store",