From 7eb449b0a987347688a29c42f65590f7b7caeb7d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 25 Apr 2026 02:14:50 +0100 Subject: [PATCH] Add Epiphany map proposal surface --- epiphany-core/src/lib.rs | 4 + epiphany-core/src/proposal.rs | 414 ++++++++++++++++++ notes/epiphany-current-algorithmic-map.md | 108 ++++- notes/epiphany-fork-implementation-plan.md | 8 +- notes/fresh-workspace-handoff.md | 15 +- state/evidence.jsonl | 2 + state/map.yaml | 8 +- state/scratch.md | 8 +- .../src/protocol/common.rs | 58 +++ .../app-server-protocol/src/protocol/v2.rs | 17 + .../app-server/src/codex_message_processor.rs | 79 ++++ .../codex-rs/core/src/epiphany_proposal.rs | 3 + vendor/codex/codex-rs/core/src/lib.rs | 4 + 13 files changed, 701 insertions(+), 27 deletions(-) create mode 100644 epiphany-core/src/proposal.rs create mode 100644 vendor/codex/codex-rs/core/src/epiphany_proposal.rs diff --git a/epiphany-core/src/lib.rs b/epiphany-core/src/lib.rs index 8bf340b6..fd774587 100644 --- a/epiphany-core/src/lib.rs +++ b/epiphany-core/src/lib.rs @@ -1,6 +1,7 @@ mod distillation; mod promotion; mod prompt; +mod proposal; mod retrieval; mod rollout; @@ -11,6 +12,9 @@ pub use promotion::EpiphanyPromotionDecision; pub use promotion::EpiphanyPromotionInput; pub use promotion::evaluate_promotion; pub use prompt::render_epiphany_state; +pub use proposal::EpiphanyMapProposal; +pub use proposal::EpiphanyMapProposalInput; +pub use proposal::propose_map_update; pub use retrieval::EPIPHANY_RETRIEVAL_DEFAULT_LIMIT; pub use retrieval::EPIPHANY_RETRIEVAL_MAX_LIMIT; pub use retrieval::EpiphanyRetrieveQuery; diff --git a/epiphany-core/src/proposal.rs b/epiphany-core/src/proposal.rs new file mode 100644 index 00000000..e1022ff6 --- /dev/null +++ b/epiphany-core/src/proposal.rs @@ -0,0 +1,414 @@ +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::protocol::EpiphanyChurnState; +use codex_protocol::protocol::EpiphanyCodeRef; +use codex_protocol::protocol::EpiphanyEvidenceRecord; +use codex_protocol::protocol::EpiphanyGraphFrontier; +use codex_protocol::protocol::EpiphanyGraphNode; +use codex_protocol::protocol::EpiphanyGraphs; +use codex_protocol::protocol::EpiphanyObservation; +use codex_protocol::protocol::EpiphanyThreadState; +use sha1::Digest; +use sha1::Sha1; +use std::collections::HashSet; +use std::path::Path; +use std::path::PathBuf; + +const SUMMARY_LIMIT: usize = 220; + +#[derive(Debug, Clone)] +pub struct EpiphanyMapProposalInput { + pub state: EpiphanyThreadState, + pub observation_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EpiphanyMapProposal { + pub observation: EpiphanyObservation, + pub evidence: EpiphanyEvidenceRecord, + pub graphs: EpiphanyGraphs, + pub graph_frontier: EpiphanyGraphFrontier, + pub churn: EpiphanyChurnState, +} + +pub fn propose_map_update(input: EpiphanyMapProposalInput) -> Result { + let observation_ids = normalize_observation_ids(input.observation_ids)?; + let observations = select_observations(&input.state, &observation_ids)?; + let code_refs = collect_code_refs(&observations)?; + let fingerprint = fingerprint(&input.state.revision, &observation_ids, &code_refs); + let mut graphs = input.state.graphs.clone(); + let mut active_node_ids = Vec::new(); + + for path in unique_code_ref_paths(&code_refs) { + let node_id = graph_node_id(&path); + active_node_ids.push(node_id.clone()); + if !graphs + .architecture + .nodes + .iter() + .any(|node| node.id == node_id) + { + graphs.architecture.nodes.push(EpiphanyGraphNode { + id: node_id, + title: title_from_path(&path), + purpose: truncate_chars( + &format!( + "Candidate implementation surface from verified observation: {}", + observations[0].summary + ), + SUMMARY_LIMIT, + ), + mechanism: Some(format!( + "Proposed from observation ids: {}", + observation_ids.join(", ") + )), + metaphor: None, + status: Some("candidate".to_string()), + code_refs: code_refs_for_path(&code_refs, &path), + }); + } + } + + let mut frontier = input.state.graph_frontier.clone().unwrap_or_default(); + merge_unique(&mut frontier.active_node_ids, active_node_ids); + merge_unique( + &mut frontier.dirty_paths, + unique_code_ref_paths(&code_refs).into_iter().collect(), + ); + + let evidence_id = format!("ev-map-proposal-{fingerprint}"); + let observation = EpiphanyObservation { + id: format!("obs-map-proposal-{fingerprint}"), + summary: truncate_chars( + &format!( + "Map/churn proposal from verified observations: {}", + observation_ids.join(", ") + ), + SUMMARY_LIMIT, + ), + source_kind: "epiphany-map-proposal".to_string(), + status: "candidate".to_string(), + code_refs: code_refs.clone(), + evidence_ids: vec![evidence_id.clone()], + }; + let evidence = EpiphanyEvidenceRecord { + id: evidence_id, + kind: "proposal".to_string(), + status: "candidate".to_string(), + summary: truncate_chars( + &format!( + "Proposed graph frontier and churn update from verified observations: {}", + observation_ids.join(", ") + ), + SUMMARY_LIMIT, + ), + code_refs, + }; + let churn = EpiphanyChurnState { + understanding_status: "proposal_ready".to_string(), + diff_pressure: input + .state + .churn + .as_ref() + .map(|churn| churn.diff_pressure.trim()) + .filter(|value| !value.is_empty()) + .unwrap_or("low") + .to_string(), + graph_freshness: Some("proposal".to_string()), + warning: Some( + "Map/churn proposal derived from verified observations; promote only after verifier acceptance." + .to_string(), + ), + unexplained_writes: input + .state + .churn + .as_ref() + .and_then(|churn| churn.unexplained_writes), + }; + + Ok(EpiphanyMapProposal { + observation, + evidence, + graphs, + graph_frontier: frontier, + churn, + }) +} + +fn normalize_observation_ids(observation_ids: Vec) -> Result> { + let mut normalized = Vec::new(); + let mut seen = HashSet::new(); + for id in observation_ids { + let id = id.trim(); + if id.is_empty() { + continue; + } + if seen.insert(id.to_string()) { + normalized.push(id.to_string()); + } + } + if normalized.is_empty() { + Err(anyhow!("observationIds must include at least one id")) + } else { + Ok(normalized) + } +} + +fn select_observations<'a>( + state: &'a EpiphanyThreadState, + observation_ids: &[String], +) -> Result> { + let mut selected = Vec::new(); + for observation_id in observation_ids { + let observation = state + .observations + .iter() + .find(|observation| observation.id == *observation_id) + .ok_or_else(|| anyhow!("observation id {observation_id:?} not found in state"))?; + if !is_verified_status(&observation.status) { + return Err(anyhow!( + "observation id {observation_id:?} must have verified/accepted status, got {:?}", + observation.status + )); + } + selected.push(observation); + } + Ok(selected) +} + +fn collect_code_refs(observations: &[&EpiphanyObservation]) -> Result> { + let mut code_refs = Vec::new(); + let mut seen = HashSet::new(); + for observation in observations { + for code_ref in &observation.code_refs { + let key = code_ref_key(code_ref); + if seen.insert(key) { + code_refs.push(code_ref.clone()); + } + } + } + if code_refs.is_empty() { + Err(anyhow!( + "map proposals require at least one code ref on the selected observations" + )) + } else { + Ok(code_refs) + } +} + +fn unique_code_ref_paths(code_refs: &[EpiphanyCodeRef]) -> Vec { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for code_ref in code_refs { + let key = code_ref.path.to_string_lossy().to_string(); + if seen.insert(key) { + paths.push(code_ref.path.clone()); + } + } + paths +} + +fn code_refs_for_path(code_refs: &[EpiphanyCodeRef], path: &Path) -> Vec { + code_refs + .iter() + .filter(|code_ref| code_ref.path == path) + .cloned() + .collect() +} + +fn merge_unique(target: &mut Vec, additions: Vec) +where + T: Clone + Eq + std::hash::Hash, +{ + let mut seen: HashSet = target.iter().cloned().collect(); + for addition in additions { + if seen.insert(addition.clone()) { + target.push(addition); + } + } +} + +fn graph_node_id(path: &Path) -> String { + let mut hasher = Sha1::new(); + hasher.update(path.to_string_lossy().as_bytes()); + let digest = hasher.finalize(); + let suffix: String = format!("{digest:x}").chars().take(12).collect(); + format!("arch-path-{suffix}") +} + +fn title_from_path(path: &Path) -> String { + if let Some(file_name) = path + .file_name() + .and_then(|value| value.to_str()) + .filter(|value| !value.trim().is_empty()) + { + file_name.to_string() + } else { + path.to_string_lossy().to_string() + } +} + +fn fingerprint( + revision: &u64, + observation_ids: &[String], + code_refs: &[EpiphanyCodeRef], +) -> String { + let mut hasher = Sha1::new(); + hasher.update(revision.to_string().as_bytes()); + for observation_id in observation_ids { + hasher.update(b"\0"); + hasher.update(observation_id.as_bytes()); + } + for code_ref in code_refs { + hasher.update(b"\0"); + hasher.update(code_ref_key(code_ref).as_bytes()); + } + let digest = hasher.finalize(); + format!("{digest:x}").chars().take(12).collect() +} + +fn code_ref_key(code_ref: &EpiphanyCodeRef) -> String { + format!( + "{}:{}:{}:{}", + code_ref.path.to_string_lossy(), + code_ref.start_line.unwrap_or_default(), + code_ref.end_line.unwrap_or_default(), + code_ref.symbol.as_deref().unwrap_or_default() + ) +} + +fn truncate_chars(value: &str, limit: usize) -> String { + if value.chars().count() <= limit { + return value.to_string(); + } + let mut truncated = value + .chars() + .take(limit.saturating_sub(3)) + .collect::(); + truncated.push_str("..."); + truncated +} + +fn is_verified_status(status: &str) -> bool { + matches!( + status.trim().to_ascii_lowercase().as_str(), + "ok" | "accepted" | "verified" | "pass" | "passed" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::promotion::EpiphanyPromotionInput; + use crate::promotion::evaluate_promotion; + + fn code_ref(path: &str) -> EpiphanyCodeRef { + EpiphanyCodeRef { + path: PathBuf::from(path), + start_line: Some(10), + end_line: Some(20), + symbol: Some("demo".to_string()), + note: None, + } + } + + fn state_with_observation(status: &str) -> EpiphanyThreadState { + EpiphanyThreadState { + revision: 4, + observations: vec![EpiphanyObservation { + id: "obs-verified".to_string(), + summary: "The prompt renderer injects Epiphany state".to_string(), + source_kind: "verification".to_string(), + status: status.to_string(), + code_refs: vec![code_ref("epiphany-core/src/prompt.rs")], + evidence_ids: vec!["ev-verified".to_string()], + }], + churn: Some(EpiphanyChurnState { + understanding_status: "grounded".to_string(), + diff_pressure: "medium".to_string(), + ..Default::default() + }), + ..Default::default() + } + } + + fn verifier() -> EpiphanyEvidenceRecord { + EpiphanyEvidenceRecord { + id: "ev-verifier".to_string(), + kind: "verification".to_string(), + status: "ok".to_string(), + summary: "Verifier accepted proposal".to_string(), + code_refs: Vec::new(), + } + } + + #[test] + fn propose_map_update_returns_candidate_patch_parts() { + let proposal = propose_map_update(EpiphanyMapProposalInput { + state: state_with_observation("verified"), + observation_ids: vec!["obs-verified".to_string()], + }) + .expect("proposal"); + + assert!(proposal.observation.id.starts_with("obs-map-proposal-")); + assert_eq!(proposal.evidence.status, "candidate"); + assert_eq!(proposal.graphs.architecture.nodes.len(), 1); + assert_eq!(proposal.graphs.architecture.nodes[0].title, "prompt.rs"); + assert_eq!(proposal.graph_frontier.active_node_ids.len(), 1); + assert_eq!(proposal.churn.understanding_status, "proposal_ready"); + assert_eq!(proposal.churn.diff_pressure, "medium"); + } + + #[test] + fn propose_map_update_returns_promotion_acceptable_patch_parts() { + let proposal = propose_map_update(EpiphanyMapProposalInput { + state: state_with_observation("verified"), + observation_ids: vec!["obs-verified".to_string()], + }) + .expect("proposal"); + + let decision = evaluate_promotion(EpiphanyPromotionInput { + has_state_replacements: true, + active_subgoal_id: None, + subgoals: None, + invariants: None, + graphs: Some(proposal.graphs), + graph_frontier: Some(proposal.graph_frontier), + graph_checkpoint: None, + churn: Some(proposal.churn), + observations: vec![proposal.observation], + evidence: vec![proposal.evidence], + verifier_evidence: verifier(), + }); + + assert!(decision.accepted, "{:?}", decision.reasons); + } + + #[test] + fn propose_map_update_rejects_unverified_observation() { + let err = propose_map_update(EpiphanyMapProposalInput { + state: state_with_observation("failed"), + observation_ids: vec!["obs-verified".to_string()], + }) + .expect_err("unverified observation should fail"); + + assert!( + err.to_string() + .contains("must have verified/accepted status") + ); + } + + #[test] + fn propose_map_update_rejects_missing_code_refs() { + let mut state = state_with_observation("ok"); + state.observations[0].code_refs.clear(); + + let err = propose_map_update(EpiphanyMapProposalInput { + state, + observation_ids: vec!["obs-verified".to_string()], + }) + .expect_err("code refs are required"); + + assert!(err.to_string().contains("at least one code ref")); + } +} diff --git a/notes/epiphany-current-algorithmic-map.md b/notes/epiphany-current-algorithmic-map.md index 3155d059..dc6c467d 100644 --- a/notes/epiphany-current-algorithmic-map.md +++ b/notes/epiphany-current-algorithmic-map.md @@ -18,7 +18,8 @@ The spine exists in nine live paths: - per-turn persistence: normal turn setup persists one `RolloutItem::EpiphanyState` after the `TurnContext` when state exists in [session/mod.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/session/mod.rs:2677). - client read hydration: app-server thread views attach live or reconstructed `thread.epiphanyState` in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4520). - explicit distillation proposals: app-server routes read-only `thread/epiphany/distill` through a loaded-thread handler into `epiphany-core` so one explicit observation can become a patch candidate without mutating state in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4166) and [distillation.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/distillation.rs:28). -- explicit promotion gates: app-server routes `thread/epiphany/promote` through a loaded-thread handler into `epiphany-core` policy evaluation, rejects failed verifier evidence without mutation, and applies accepted candidates through the same durable update path in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4237) and [promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33). +- explicit map/churn proposals: app-server routes read-only `thread/epiphany/propose` through a loaded-thread handler into `epiphany-core` so verified observations with code refs can become candidate graph frontier/churn patches without mutating state in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4244) and [proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:34). +- explicit promotion gates: app-server routes `thread/epiphany/promote` through a loaded-thread handler into `epiphany-core` policy evaluation, rejects failed verifier evidence without mutation, and applies accepted candidates through the same durable update path in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4316) and [promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33). - explicit state updates: app-server routes `thread/epiphany/update` through a loaded `CodexThread` update method that mutates live `SessionState`, bumps the revision, and persists an immediate `RolloutItem::EpiphanyState` snapshot in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4337) and [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:373). - retrieval/indexing: app-server routes `thread/epiphany/retrieve` and `thread/epiphany/index` through loaded `CodexThread` host methods into `epiphany-core` in [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4039), [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:438), and [codex_thread.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/core/src/codex_thread.rs:456). @@ -28,6 +29,7 @@ The thick Epiphany-owned implementation is mostly outside vendored Codex now: - generic rollout replay for stored-thread reads lives in [epiphany-core/src/rollout.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/rollout.rs:38). - retrieval and indexing live in [epiphany-core/src/retrieval.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/retrieval.rs:144) and [retrieval.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/retrieval.rs:160), with summary-only freshness reads in [retrieval.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/retrieval.rs:100). - deterministic observation distillation lives in [epiphany-core/src/distillation.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/distillation.rs:28). +- deterministic map/churn proposal from verified observations lives in [epiphany-core/src/proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:34). - verifier-backed promotion policy lives in [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33). ## Whole Control Flow @@ -57,11 +59,15 @@ flowchart TD A --> R["thread/epiphany/retrieve requires loaded thread"] A --> S["thread/epiphany/index requires loaded thread"] A --> AA["thread/epiphany/distill requires loaded thread"] + A --> AH["thread/epiphany/propose requires loaded thread"] A --> AD["thread/epiphany/promote requires loaded thread"] A --> Z["thread/epiphany/update requires loaded thread"] AA --> AB["epiphany-core distill_observation"] AB --> AC["Observation/evidence patch proposal"] AC --> AD + AH --> AI["epiphany-core propose_map_update"] + AI --> AJ["Map/churn patch proposal"] + AJ --> AD AD --> AE["epiphany-core evaluate_promotion"] AE --> AF{"Accepted?"} AF -->|"No"| AG["Return reasons, no state mutation"] @@ -89,6 +95,7 @@ What this means in plain English: - A loaded thread can now accept explicit typed state patches and persist them immediately. - Retrieval and indexing are typed side paths hanging off loaded threads. - Retrieval remains read-only; state mutation has its own control-plane door. +- Map/churn proposal is also read-only; accepted graph/churn edits still go through promotion and update. ## Natural Language Spine @@ -106,6 +113,7 @@ This is the same flow in the language Epiphany is supposed to make the model car | Retrieval | A loaded thread can ask a structured question of the repo through one typed retrieval surface. | A librarian brings back marked pages instead of making the agent rummage through every shelf. | | Indexing | Persistent semantic memory is built only through an explicit indexing path. | The librarian updates the card catalog only when asked, not while pretending to answer a question. | | Distillation | One explicit observation can be normalized into a typed observation/evidence patch, but not promoted automatically. | The clerk drafts a ledger entry in pencil before anyone is allowed to ink it. | +| Proposal | Verified observations with code refs can produce a candidate graph frontier/churn patch without writing it. | The clerk sketches a blueprint revision on tracing paper before anyone touches the master copy. | | Promotion | A verifier-backed gate can reject a proposal without mutation or send it through the durable update path. | The foreman stamps the pencil draft before the clerk reaches for the red pen. | | State updates | A loaded thread can accept explicit typed patches that append observations/evidence and replace bounded map/scratch/churn fields. | The clerk finally has the red pen, but still writes only on the ledger page the form allows. | @@ -662,7 +670,81 @@ The response is a promotion-ready patch. It is not durable until a caller submit Distillation is read-only. It does not mutate `SessionState`, does not persist rollout items, does not run retrieval, and does not infer graph/churn changes. It sharpens one proposed observation into a typed shape; promotion remains a separate red-pen act. -## Flow 11: Promotion Gate Control Flow +## Flow 11: Map/Churn Proposal Control Flow + +### Input + +A client sends experimental `thread/epiphany/propose` for a loaded thread with: + +- `threadId` +- `observationIds` + +### Plain-language role + +Proposal is the tracing-paper path. It starts from observations that are already in the thread's typed state, requires those observations to have verified/accepted status, requires source-grounding through code refs, and returns a candidate patch that can replace graph/frontier/churn fields only if a later promotion accepts it. + +This is the first shipped chewing motion. It is deliberately small: no model call, no watcher invalidation, no automatic graph truth. It creates or focuses architecture nodes for the observed code paths, moves those nodes into the frontier, emits a proposal observation/evidence pair, and marks churn as `proposal_ready`. It is a candidate, not an oracle. We do not crown the goblin because it found a wrench. + +Protocol shape: + +- `threadId` +- `observationIds` + +Response shape: + +- `expectedRevision` +- `patch` + - `observations` + - `evidence` + - `graphs` + - `graphFrontier` + - `churn` + +Code refs: + +- [app-server-protocol/common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:350) +- [app-server-protocol/v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:3963) +- [epiphany-core/src/proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:34) + +### Mechanism + +The app-server handler: + +1. parses the thread id. +2. requires the thread to be loaded. +3. requires existing Epiphany state. +4. reads the current state revision as `expectedRevision`. +5. calls `propose_map_update`. +6. returns a `ThreadEpiphanyUpdatePatch` containing proposal observation/evidence, replacement graphs/frontier, and churn. + +`propose_map_update`: + +1. normalizes and deduplicates requested observation ids. +2. rejects unknown observations. +3. rejects observations whose status is not accepting: `ok`, `accepted`, `verified`, `pass`, or `passed`. +4. rejects proposals without code refs. +5. clones the current graphs and appends missing architecture nodes for observed code-ref paths. +6. merges those node ids and paths into the graph frontier. +7. emits a proposal observation/evidence pair tied to the candidate patch. +8. emits a churn replacement with `understanding_status: proposal_ready`. + +Code refs: + +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4244) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4287) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4301) +- [epiphany-core/src/proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:34) +- [epiphany-core/src/proposal.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/proposal.rs:346) + +### Output + +The response is a promotion-ready map/churn patch. The live smoke proved `thread/epiphany/propose` does not mutate state by itself; `thread/read` stayed at revision `1` with no persisted churn after proposal, then `thread/epiphany/promote` advanced revision to `2` and persisted the graph/churn patch only after verifier evidence accepted it. + +### Invariant + +Proposal is read-only. It does not mutate `SessionState`, does not persist rollout items, does not run retrieval, does not ask a model to infer a graph, and does not bypass promotion. It prepares a better bite; promotion still decides whether to swallow. + +## Flow 12: Promotion Gate Control Flow ### Input @@ -695,7 +777,7 @@ Response shape: Code refs: - [app-server-protocol/common.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs:349) -- [app-server-protocol/v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:3963) +- [app-server-protocol/v2.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs:3980) - [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33) ### Mechanism @@ -723,10 +805,10 @@ The app-server handler: Code refs: -- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4237) -- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4271) -- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4287) -- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4314) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4316) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4349) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4363) +- [codex_message_processor.rs](E:/Projects/EpiphanyAgent/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs:4393) - [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:33) - [epiphany-core/src/promotion.rs](E:/Projects/EpiphanyAgent/epiphany-core/src/promotion.rs:125) @@ -743,7 +825,7 @@ Promotion is a gate, not independent persistence. It does not write rollout item These are deliberately not shipped yet: - no live Epiphany event stream. -- no automatic evidence promotion from tool output; the current distill/promote path still requires explicit verifier-backed calls. +- no automatic evidence promotion from tool output; the current distill/propose/promote path still requires explicit verifier-backed calls. - no watcher-driven graph or semantic invalidation. - no code-intelligence graph. - no specialist-agent scheduler. @@ -759,11 +841,12 @@ stored Epiphany state -> visible to clients -> retrieval/indexing can inform future work -> explicit distillation can draft observation/evidence patches +-> explicit proposal can draft map/churn patches from verified observations -> explicit promotion can reject or accept verified candidates -> explicit update patches can revise durable map/evidence/churn state ``` -The current remaining missing organ is not the red pen. The red pen exists. The missing organ is the part that can propose a good map/churn edit from verified observations without silently auto-promoting it: +The current remaining missing organ is not the red pen and not the first chewing motion. Those exist. The missing organ is broader judgment: richer proposal heuristics, better observation selection, watcher/freshness inputs, and eventually role-scoped specialist ownership without silently auto-promoting anything. ```text model/tool observations @@ -774,9 +857,9 @@ model/tool observations -> durable evidence/map/churn mutation ``` -The typed write path exists, the first deterministic observation/evidence distillation path exists, and the first verifier-backed promotion gate exists. The first map/churn safety layer also exists: promotion can reject state replacement patches that are not tied to explicit observations/evidence or that contain structurally broken subgoal, invariant, graph, frontier, checkpoint, or churn fields. +The typed write path exists, the first deterministic observation/evidence distillation path exists, the first deterministic map/churn proposal path exists, and the first verifier-backed promotion gate exists. The first map/churn safety layer also exists: promotion can reject state replacement patches that are not tied to explicit observations/evidence or that contain structurally broken subgoal, invariant, graph, frontier, checkpoint, or churn fields. -In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, reject or accept a verified candidate, apply explicit map/evidence/churn edits, and sanity-check those edits before they hit the ledger. It still does not derive graph edits by itself. Teeth are installed; chewing is still supervised, and the next slice is teaching it to prepare a better bite without swallowing automatically. +In natural language: current Epiphany can preserve a map, show a map, retrieve evidence for the map, draft one explicit observation/evidence patch, derive a bounded graph/frontier/churn candidate from verified observations with code refs, reject or accept a verified candidate, apply explicit map/evidence/churn edits, and sanity-check those edits before they hit the ledger. It still does not automatically know which observations matter most, invalidate stale graph regions, or coordinate specialist ownership. Teeth are installed; chewing is supervised; the next slices are about taste, not jawbones. ## Verification Hooks @@ -786,11 +869,12 @@ Current tests cover the landed flows at useful seams: - rollback and compaction replay behavior in `codex-core` and `epiphany-core`. - explicit out-of-band Epiphany snapshot replay before the first user turn. - deterministic observation/evidence distillation in `epiphany-core`. +- deterministic map/churn proposal in `epiphany-core`. - verifier-backed promotion policy in `epiphany-core`. - map/churn promotion validation for evidence-backed state replacements in `epiphany-core`. - typed state-update patch application in `codex-core`. - retrieval ranking, fallback, stale manifest detection, and mocked Qdrant/Ollama indexing in `epiphany-core`. -- app-server protocol serde for `thread/epiphany/retrieve`, `thread/epiphany/index`, `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`. +- app-server protocol serde for `thread/epiphany/retrieve`, `thread/epiphany/index`, `thread/epiphany/distill`, `thread/epiphany/propose`, `thread/epiphany/promote`, and `thread/epiphany/update`. - app-server mapping of core retrieval/index summaries into protocol responses. Useful commands: diff --git a/notes/epiphany-fork-implementation-plan.md b/notes/epiphany-fork-implementation-plan.md index b2e3d115..cf7a87b2 100644 --- a/notes/epiphany-fork-implementation-plan.md +++ b/notes/epiphany-fork-implementation-plan.md @@ -15,9 +15,10 @@ This note tracks Epiphany as a fork of Codex with an opinionated modeling archit - a first typed state-update slice that adds explicit loaded-thread-only `thread/epiphany/update`, appends observations/evidence, replaces bounded map/scratch/churn fields, bumps the state revision, and persists an immediate rollout `EpiphanyState` snapshot - a first typed distillation/proposal slice that adds read-only loaded-thread-only `thread/epiphany/distill`, producing deterministic observation/evidence patches for explicit promotion through `thread/epiphany/update` - a first richer promotion-policy slice that treats map/churn/frontier/checkpoint replacements as higher-risk state edits: accepted promotions must be tied to explicit observations and patch evidence, and structural graph/churn mistakes are rejected before the durable update path +- a first typed map/churn proposal slice that adds read-only loaded-thread-only `thread/epiphany/propose`, producing candidate graph frontier/churn patches from verified observations with code refs for explicit promotion through `thread/epiphany/promote` - current phase: Phase 5 semantic distillation/promotion/proposal machinery -The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, build the first observation proposal surface, or make promotion notice broken map/churn replacements. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve` or `thread/epiphany/distill` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it. +The next job is no longer to prove the retriever exists, sketch the persistent follow-up in prose, invent the first red-pen path, build the first observation proposal surface, make promotion notice broken map/churn replacements, or ship the first read-only map/churn proposal surface. Those parts are landed. The next job is to keep the landing zone honest: do not casually turn `thread/epiphany/retrieve`, `thread/epiphany/distill`, or `thread/epiphany/propose` into durable Epiphany-state writers, and do not widen the explicit indexing/update paths into watcher-driven or GUI-shaped machinery before they earn it. ## Summary @@ -267,14 +268,15 @@ Add tests for: ## Immediate Next Step -Treat the current retrieval baseline and explicit indexing follow-up as landed Phase 4. Treat the explicit update path, distillation proposal path, verifier-backed promotion gate, and structural map/churn promotion validation as the active Phase 5 baseline. The next machine gap is no longer "can Epiphany retrieve code?" or "can it propose one durable observation patch?" It can. The next gap is "can Epiphany decide which observations deserve promotion, and what map/churn edits they imply?" +Treat the current retrieval baseline and explicit indexing follow-up as landed Phase 4. Treat the explicit update path, distillation proposal path, read-only map/churn proposal path, verifier-backed promotion gate, and structural map/churn promotion validation as the active Phase 5 baseline. The next machine gap is no longer "can Epiphany retrieve code?", "can it propose one durable observation patch?", or "can it draft one bounded map/churn candidate?" It can. The next gap is "can Epiphany make better proposal choices from richer evidence without silently promoting them?" 1. treat the current verified-and-landed query-time hybrid retriever as the Phase 4 slice 1 baseline 2. treat the verified and live-smoked `thread/epiphany/index` slice as the bounded persistent-semantic follow-up 3. keep the new `epiphany-core` boundary honest instead of letting vendored Codex re-accumulate the heavy implementation 4. do not add durable retrieval-summary writes from `thread/epiphany/retrieve` without a clean out-of-band rollout/update semantic -5. continue Phase 5 by building the next layer above the landed distill/update/promote surfaces: +5. continue Phase 5 by hardening the layer above the landed distill/propose/promote/update surfaces: - richer typed observation distillation from tool/model outputs + - better proposal heuristics for existing graph matching, frontier focus, and churn semantics - promotion policy beyond structural validation, but only when it can stay evidence-backed - verifier-backed acceptance/rejection evidence - no hidden retrieval writes diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 9140f82e..91a7ed7f 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -446,17 +446,18 @@ Without that, you get to learn about `symlink_dir failed: ... A required privile ## Recommended Next Implementation -Do not restart verification from superstition. Phase 4 retrieval/indexing/core-extraction is already verified, committed, and pushed. Phase 5 distill/promote/update and the first structural promotion safety layer are also landed. +Do not restart verification from superstition. Phase 4 retrieval/indexing/core-extraction is already verified, committed, and pushed. Phase 5 distill/propose/promote/update and the first structural promotion safety layer are also landed. -The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, the first distill/promote/update path is live-smoked, and promotion now has a first structural map/churn safety layer. The next clean move is richer observation-to-map proposal machinery, not pretending the already-shipped slices still need ceremony. +The durable state seam exists, the turn loop reads it, clients can load typed Epiphany state directly, the first retrieval slice is real, the explicit persistent-semantic indexing path is landed, the distill/propose/promote/update path is live-smoked, and promotion now has a first structural map/churn safety layer. The next clean move is improving proposal quality from richer evidence, not pretending the already-shipped slices still need ceremony. Current Phase 5 implementation move: -1. treat the live-smoked retrieval/indexing baseline as Phase 4 and the typed update/distill/promote surfaces as the active Phase 5 baseline +1. treat the live-smoked retrieval/indexing baseline as Phase 4 and the typed update/distill/propose/promote surfaces as the active Phase 5 baseline 2. keep the current env/default Qdrant/Ollama config surface for now -3. keep `thread/epiphany/retrieve` and `thread/epiphany/distill` read-only -4. continue Phase 5 by building the next layer above the distill/promote/update surfaces: - - richer map/churn patch generation from verified observations +3. keep `thread/epiphany/retrieve`, `thread/epiphany/distill`, and `thread/epiphany/propose` read-only +4. continue Phase 5 by hardening the next layer above the distill/propose/promote/update surfaces: + - richer map/churn patch generation from verified observations and tool/model outputs + - better existing-graph matching, frontier focus, and churn semantics - promotion policy beyond structural validation only when the evidence path stays explicit - verifier-backed acceptance/rejection evidence - no GUI, watcher, or specialist-agent machinery yet @@ -474,7 +475,7 @@ Not yet: The next slice should stay small and mean: - harden the explicit state-update path with a live app-server smoke when useful -- harden distillation only through explicit proposal/promotion checks, not hidden writes +- harden distillation/proposal only through explicit promotion checks, not hidden writes - keep GUI, watcher mutation, and specialist-agent logic dark ## Verification Commands diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 2ca5c504..7395f7c1 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -99,3 +99,5 @@ {"ts": "2026-04-24T19:48:44+00:00", "type": "documentation", "status": "ok", "note": "Source-grounded pass over notes/epiphany-current-algorithmic-map.md. Re-read protocol state, session resume/prompt/persistence, app-server thread hydration and Epiphany RPC handlers, CodexThread update/retrieval host methods, and epiphany-core prompt/rollout/retrieval/distillation/promotion code. Updated the map to include the runtime anti-Jenga operating-discipline prompt section, removed the stale no-update-surface invariant, refreshed moved code anchors, corrected the handoff's outdated Phase 3 no-update-RPC note, and mechanically checked 98 local map links with line anchors."} {"ts": "2026-04-24T21:02:31+01:00", "type": "documentation", "status": "ok", "note": "Corrected persisted phase memory after rechecking the harness roadmap and evidence log. Phase 4 is the landed retrieval/indexing/core-extraction baseline; Phase 5 is the current semantic distillation/promotion/proposal phase. Updated AGENTS.md, state/map.yaml, state/scratch.md, notes/fresh-workspace-handoff.md, and notes/epiphany-fork-implementation-plan.md so rehydrate/status no longer imply the current work is still a Phase 4 tail or a Qdrant live-smoke follow-up."} {"ts": "2026-04-24T21:10:00+01:00", "type": "decision", "status": "ok", "note": "Kept AGENTS.md as durable operating/bootstrap policy rather than volatile status memory. Current phase, next action, and handoff details belong in state/map.yaml, state/evidence.jsonl, and notes/fresh-workspace-handoff.md; AGENTS.md should only point agents to those canonical stores and state stable discipline."} +{"ts": "2026-04-25T02:09:29+01:00", "type": "implementation", "status": "ok", "note": "Landed the first read-only Phase 5 map/churn proposal slice in the working tree. Added epiphany-core/src/proposal.rs with deterministic propose_map_update, thin codex-core re-export, experimental loaded-thread-only thread/epiphany/propose protocol DTOs/routing/handler, and app-server mapping that returns a ThreadEpiphanyUpdatePatch with proposal observation/evidence, graph/frontier replacement, and churn replacement without mutating state."} +{"ts": "2026-04-25T02:09:30+01:00", "type": "verification", "status": "ok", "note": "Verified thread/epiphany/propose with cargo fmt, epiphany-core proposal tests including promotion-policy compatibility, codex-core compile via CARGO_TARGET_DIR, codex-app-server-protocol thread_epiphany_ tests, codex-app-server epiphany tests, stable schema fixture regeneration, full codex-app-server-protocol tests, codex-app-server build, and two live app-server stdio smokes. First smoke proved propose returns graph/churn patch at expectedRevision 1 and thread/read remains revision 1 with no persisted churn; second smoke proved distill -> update -> propose -> promote accepts verifier evidence and persists graph/churn at revision 2."} diff --git a/state/map.yaml b/state/map.yaml index fcb71263..08c8a12a 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -46,10 +46,10 @@ data_flow: - commit or backtrack active_subgoals: - - Keep the Phase 5 semantic distillation/promotion/proposal work aligned with reality in code and repo memory. + - Keep the Phase 5 distill/propose/promote/update work aligned with reality in code and repo memory. - Keep vendored Codex as a thin typed host seam while Epiphany-owned prompt and retrieval/indexing logic live in the repo-owned `epiphany-core` crate. - Preserve exact/path lookup and BM25 fallback while the Qdrant path stays explicit and bounded. - - Build the next layer above the landed typed distill/promote/update paths: richer observation-to-map proposal machinery while keeping verifier-backed acceptance/rejection explicit. + - Harden the next layer above the landed typed distill/propose/promote/update paths: richer evidence-to-map proposal quality while keeping verifier-backed acceptance/rejection explicit. accepted_design: - external typed state files @@ -113,5 +113,5 @@ rejected_paths: reason: audited candidates were either terminal-only, stale/aspirational, or incoherent wrappers; the comparison clones were removed from vendor to avoid building on untrusted drift current_status: - summary: Workspace initialized with protocol files, typed state, handoff notes, architecture maps, and a rolling implementation plan against vendored Codex. Phase 1 durable state, Phase 2 prompt integration, the minimal Phase 3 typed read surface, and Phase 4 retrieval/indexing/core-extraction are landed on `main`. Phase 5 semantic distillation and promotion is now active: the current baseline includes explicit Qdrant indexing with BM25 fallback, typed `thread/epiphany/update`, read-only `thread/epiphany/distill`, verifier-backed `thread/epiphany/promote`, structural map/churn promotion validation in `epiphany-core`, and thin vendored Codex host seams. The Windows app-server stack-pressure regression was narrowed to test-thread stack size; repo-local Cargo config sets `RUST_MIN_STACK=67108864`, and boxed-future request-dispatch experiments were removed after config-only verification passed. The repo also has source-available/commercial licensing plus a CLA gate for external contributions. - next_action: Continue Phase 5 by building the next bounded layer above `thread/epiphany/distill`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer observation-to-map/churn proposal machinery from verified observations, while keeping retrieval and distillation read-only and avoiding watcher, GUI, or specialist-agent machinery. + summary: Workspace initialized with protocol files, typed state, handoff notes, architecture maps, and a rolling implementation plan against vendored Codex. Phase 1 durable state, Phase 2 prompt integration, the minimal Phase 3 typed read surface, and Phase 4 retrieval/indexing/core-extraction are landed on `main`. Phase 5 semantic distillation/promotion/proposal is now active: the current baseline includes explicit Qdrant indexing with BM25 fallback, typed `thread/epiphany/update`, read-only `thread/epiphany/distill`, read-only `thread/epiphany/propose`, verifier-backed `thread/epiphany/promote`, structural map/churn promotion validation in `epiphany-core`, and thin vendored Codex host seams. The Windows app-server stack-pressure regression was narrowed to test-thread stack size; repo-local Cargo config sets `RUST_MIN_STACK=67108864`, and boxed-future request-dispatch experiments were removed after config-only verification passed. The repo also has source-available/commercial licensing plus a CLA gate for external contributions. + next_action: Continue Phase 5 by hardening the layer above `thread/epiphany/distill`, `thread/epiphany/propose`, `thread/epiphany/promote`, and `thread/epiphany/update`: richer evidence-to-map proposal quality from verified observations and tool/model outputs, while keeping retrieval/distillation/proposal read-only and avoiding watcher, GUI, or specialist-agent machinery. diff --git a/state/scratch.md b/state/scratch.md index 7fa831ea..e7549cf3 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -4,11 +4,17 @@ This file is intentionally disposable. ## Current Subgoal -- Continue Phase 5 by moving from structural map/churn promotion validation toward richer observation-to-map/churn proposal machinery without hidden writes. +- Continue Phase 5 by hardening the landed read-only map/churn proposal machinery without hidden writes. ## Working Notes - Phase 4 is landed retrieval/indexing/core-extraction. Phase 5 is the current semantic distillation/promotion/proposal phase. +- First read-only `thread/epiphany/propose` slice is landed in the working tree: + - `epiphany-core/src/proposal.rs` owns deterministic `propose_map_update` + - selected observations must already exist in thread state, have accepting status, and carry code refs + - proposal appends/focuses architecture graph nodes for observed code-ref paths, merges frontier focus, emits proposal observation/evidence, and marks churn `proposal_ready` + - app-server exposes experimental loaded-thread-only `thread/epiphany/propose` + - live stdio smoke proved propose is read-only, and propose -> promote persists graph/churn only after verifier evidence - The Phase 4 retrieval/indexing/core-extraction anchor is `80c29e0` on `main`. The older `360dfea` commit is only the first hybrid retrieval anchor, and later `main` also includes the typed update/distill/promote surfaces, promotion safety layer, and app-server stack-pressure fix. - The extraction boundary is now the important truth: - `epiphany-core/src/retrieval.rs` owns the heavy hybrid retrieval/indexing engine diff --git a/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs b/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs index 584d5fe6..248e5eda 100644 --- a/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/vendor/codex/codex-rs/app-server-protocol/src/protocol/common.rs @@ -346,6 +346,11 @@ client_request_definitions! { params: v2::ThreadEpiphanyDistillParams, response: v2::ThreadEpiphanyDistillResponse, }, + #[experimental("thread/epiphany/propose")] + ThreadEpiphanyPropose => "thread/epiphany/propose" { + params: v2::ThreadEpiphanyProposeParams, + response: v2::ThreadEpiphanyProposeResponse, + }, #[experimental("thread/epiphany/promote")] ThreadEpiphanyPromote => "thread/epiphany/promote" { params: v2::ThreadEpiphanyPromoteParams, @@ -1777,6 +1782,46 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_epiphany_propose_response() -> Result<()> { + let response = ClientResponse::ThreadEpiphanyPropose { + request_id: RequestId::Integer(12), + response: v2::ThreadEpiphanyProposeResponse { + expected_revision: 7, + patch: v2::ThreadEpiphanyUpdatePatch { + churn: Some(codex_protocol::protocol::EpiphanyChurnState { + understanding_status: "proposal_ready".to_string(), + diff_pressure: "low".to_string(), + graph_freshness: Some("proposal".to_string()), + ..Default::default() + }), + ..Default::default() + }, + }, + }; + + assert_eq!(response.id(), &RequestId::Integer(12)); + assert_eq!(response.method(), "thread/epiphany/propose"); + assert_eq!( + json!({ + "method": "thread/epiphany/propose", + "id": 12, + "response": { + "expectedRevision": 7, + "patch": { + "churn": { + "understanding_status": "proposal_ready", + "diff_pressure": "low", + "graph_freshness": "proposal" + } + } + } + }), + serde_json::to_value(&response)?, + ); + Ok(()) + } + #[test] fn serialize_thread_epiphany_promote_response() -> Result<()> { let response = ClientResponse::ThreadEpiphanyPromote { @@ -2378,6 +2423,19 @@ mod tests { assert_eq!(reason, Some("thread/epiphany/distill")); } + #[test] + fn thread_epiphany_propose_is_marked_experimental() { + let request = ClientRequest::ThreadEpiphanyPropose { + request_id: RequestId::Integer(1), + params: v2::ThreadEpiphanyProposeParams { + thread_id: "thr_123".to_string(), + observation_ids: vec!["obs-123".to_string()], + }, + }; + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); + assert_eq!(reason, Some("thread/epiphany/propose")); + } + #[test] fn thread_epiphany_promote_is_marked_experimental() { let request = ClientRequest::ThreadEpiphanyPromote { diff --git a/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs b/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs index 4478ed64..53b429c0 100644 --- a/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/vendor/codex/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3957,6 +3957,23 @@ pub struct ThreadEpiphanyDistillResponse { pub patch: ThreadEpiphanyUpdatePatch, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadEpiphanyProposeParams { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub observation_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadEpiphanyProposeResponse { + pub expected_revision: u64, + pub patch: ThreadEpiphanyUpdatePatch, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs index 4336238e..11cf11a0 100644 --- a/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs +++ b/vendor/codex/codex-rs/app-server/src/codex_message_processor.rs @@ -152,6 +152,8 @@ use codex_app_server_protocol::ThreadEpiphanyIndexParams; use codex_app_server_protocol::ThreadEpiphanyIndexResponse; use codex_app_server_protocol::ThreadEpiphanyPromoteParams; use codex_app_server_protocol::ThreadEpiphanyPromoteResponse; +use codex_app_server_protocol::ThreadEpiphanyProposeParams; +use codex_app_server_protocol::ThreadEpiphanyProposeResponse; use codex_app_server_protocol::ThreadEpiphanyRetrieveIndexSummary; use codex_app_server_protocol::ThreadEpiphanyRetrieveParams; use codex_app_server_protocol::ThreadEpiphanyRetrieveResponse; @@ -238,6 +240,7 @@ use codex_core::CodexThreadTurnContextOverrides; use codex_core::EPIPHANY_RETRIEVAL_DEFAULT_LIMIT; use codex_core::EPIPHANY_RETRIEVAL_MAX_LIMIT; use codex_core::EpiphanyDistillInput; +use codex_core::EpiphanyMapProposalInput; use codex_core::EpiphanyPromotionInput; use codex_core::EpiphanyRetrieveQuery; use codex_core::EpiphanyRetrieveResponse as CoreEpiphanyRetrieveResponse; @@ -986,6 +989,10 @@ impl CodexMessageProcessor { self.thread_epiphany_distill(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadEpiphanyPropose { request_id, params } => { + self.thread_epiphany_propose(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadEpiphanyPromote { request_id, params } => { self.thread_epiphany_promote(to_connection_request_id(request_id), params) .await; @@ -4234,6 +4241,78 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn thread_epiphany_propose( + &self, + request_id: ConnectionRequestId, + params: ThreadEpiphanyProposeParams, + ) { + let ThreadEpiphanyProposeParams { + thread_id, + observation_ids, + } = params; + + let thread_uuid = match ThreadId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; + + let thread = match self.thread_manager.get_thread(thread_uuid).await { + Ok(thread) => thread, + Err(_) => { + self.send_invalid_request_error( + request_id, + format!("thread not loaded: {thread_uuid}"), + ) + .await; + return; + } + }; + + let state = match thread.epiphany_state().await { + Some(state) => state, + None => { + self.send_invalid_request_error( + request_id, + format!("thread has no Epiphany state: {thread_uuid}"), + ) + .await; + return; + } + }; + let expected_revision = state.revision; + let proposal = match codex_core::propose_map_update(EpiphanyMapProposalInput { + state, + observation_ids, + }) { + Ok(proposal) => proposal, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to propose Epiphany map update: {err}"), + ) + .await; + return; + } + }; + let response = ThreadEpiphanyProposeResponse { + expected_revision, + patch: ThreadEpiphanyUpdatePatch { + observations: vec![proposal.observation], + evidence: vec![proposal.evidence], + graphs: Some(proposal.graphs), + graph_frontier: Some(proposal.graph_frontier), + churn: Some(proposal.churn), + ..Default::default() + }, + }; + + self.outgoing.send_response(request_id, response).await; + } + async fn thread_epiphany_promote( &self, request_id: ConnectionRequestId, diff --git a/vendor/codex/codex-rs/core/src/epiphany_proposal.rs b/vendor/codex/codex-rs/core/src/epiphany_proposal.rs new file mode 100644 index 00000000..c1a438f3 --- /dev/null +++ b/vendor/codex/codex-rs/core/src/epiphany_proposal.rs @@ -0,0 +1,3 @@ +pub use epiphany_core::EpiphanyMapProposal; +pub use epiphany_core::EpiphanyMapProposalInput; +pub use epiphany_core::propose_map_update; diff --git a/vendor/codex/codex-rs/core/src/lib.rs b/vendor/codex/codex-rs/core/src/lib.rs index 20f19b01..7dc6b0c1 100644 --- a/vendor/codex/codex-rs/core/src/lib.rs +++ b/vendor/codex/codex-rs/core/src/lib.rs @@ -19,6 +19,7 @@ mod codex_thread; mod compact_remote; mod epiphany_distillation; mod epiphany_promotion; +mod epiphany_proposal; mod epiphany_retrieval; mod epiphany_rollout; pub use codex_thread::CodexThread; @@ -31,6 +32,9 @@ pub use epiphany_distillation::distill_observation; pub use epiphany_promotion::EpiphanyPromotionDecision; pub use epiphany_promotion::EpiphanyPromotionInput; pub use epiphany_promotion::evaluate_promotion; +pub use epiphany_proposal::EpiphanyMapProposal; +pub use epiphany_proposal::EpiphanyMapProposalInput; +pub use epiphany_proposal::propose_map_update; pub use epiphany_retrieval::EPIPHANY_RETRIEVAL_DEFAULT_LIMIT; pub use epiphany_retrieval::EPIPHANY_RETRIEVAL_MAX_LIMIT; pub use epiphany_retrieval::EpiphanyRetrieveQuery;