1use anyhow::Result;
10use clap::{Arg, ArgMatches, Command};
11use serde::{Deserialize, Serialize};
12use uuid::Uuid;
13
14use super::client::ApiClient;
15use super::output::OutputConfig;
16use super::require_arg;
17
18const BASE: &str = "/api/v1/context";
19
20#[derive(Debug, Serialize)]
23struct ContextCommitRequest {
24 trajectory_id: Uuid,
25 scope_id: Uuid,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 agent_id: Option<Uuid>,
28 content: String,
29 #[serde(skip_serializing_if = "Option::is_none")]
30 mode: Option<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 query: Option<String>,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 metadata: Option<serde_json::Value>,
35}
36
37#[derive(Debug, Deserialize)]
38struct ContextCommitResponse {
39 event_id: Uuid,
40}
41
42#[derive(Debug, Serialize)]
43struct AssembleContextRequest {
44 trajectory_id: Uuid,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 scope_id: Option<Uuid>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 user_input: Option<String>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 token_budget: Option<i32>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 agent_id: Option<Uuid>,
53}
54
55pub fn build_command() -> Command {
58 Command::new("context")
59 .about("High-level context commit and recall")
60 .subcommand_required(true)
61 .subcommand(
62 Command::new("commit")
63 .about("Commit context to the event DAG")
64 .arg(Arg::new("content").required(true).help("Content to commit"))
65 .arg(
66 Arg::new("trajectory-id")
67 .long("trajectory-id")
68 .help("Trajectory ID (defaults to session trajectory)"),
69 )
70 .arg(
71 Arg::new("scope-id")
72 .long("scope-id")
73 .help("Scope ID (defaults to session scope)"),
74 )
75 .arg(
76 Arg::new("agent-id")
77 .long("agent-id")
78 .help("Agent ID (defaults to session agent)"),
79 )
80 .arg(Arg::new("mode").long("mode").help("Commit mode"))
81 .arg(Arg::new("query").long("query").help("Associated query")),
82 )
83 .subcommand(
84 Command::new("recall")
85 .about("Recall context from the event DAG")
86 .arg(Arg::new("query").help("Search query for recall"))
87 .arg(
88 Arg::new("trajectory-id")
89 .long("trajectory-id")
90 .help("Filter by trajectory ID"),
91 )
92 .arg(
93 Arg::new("scope-id")
94 .long("scope-id")
95 .help("Filter by scope ID"),
96 )
97 .arg(
98 Arg::new("max-results")
99 .long("max-results")
100 .default_value("10")
101 .help("Maximum number of results"),
102 )
103 .arg(
104 Arg::new("recency-weight")
105 .long("recency-weight")
106 .help("Recency weight (0.0-1.0)"),
107 ),
108 )
109 .subcommand(
110 Command::new("assemble")
111 .about("Assemble context for an LLM prompt")
112 .arg(
113 Arg::new("trajectory-id")
114 .long("trajectory-id")
115 .help("Trajectory ID (defaults to session trajectory)"),
116 )
117 .arg(
118 Arg::new("scope-id")
119 .long("scope-id")
120 .help("Scope ID (auto-selects most recent if omitted)"),
121 )
122 .arg(
123 Arg::new("input")
124 .long("input")
125 .help("Current user input/query for relevance ranking"),
126 )
127 .arg(
128 Arg::new("token-budget")
129 .long("token-budget")
130 .help("Maximum token budget"),
131 )
132 .arg(
133 Arg::new("agent-id")
134 .long("agent-id")
135 .help("Agent ID for multi-agent scenarios"),
136 ),
137 )
138 .subcommand(
139 Command::new("outline")
140 .about("Get a context outline (structure without full content)")
141 .arg(
142 Arg::new("trajectory-id")
143 .long("trajectory-id")
144 .help("Trajectory ID (defaults to session trajectory)"),
145 )
146 .arg(Arg::new("scope-id").long("scope-id").help("Scope ID")),
147 )
148}
149
150pub async fn dispatch(
153 matches: &ArgMatches,
154 client: &ApiClient,
155 output: &OutputConfig,
156 session: &crate::session::CliSession,
157) -> Result<()> {
158 match matches.subcommand() {
159 Some(("commit", sub)) => {
160 let trajectory_id: Uuid = require_arg(
161 sub,
162 "trajectory-id",
163 session.trajectory_id.as_deref(),
164 "trajectory",
165 )?
166 .parse()?;
167 let scope_id: Uuid =
168 require_arg(sub, "scope-id", session.scope_id.as_deref(), "scope")?.parse()?;
169 let agent_id: Option<Uuid> =
170 super::resolve_arg(sub, "agent-id", session.agent_id.as_deref())
171 .map(|s| s.parse())
172 .transpose()?;
173
174 let req = ContextCommitRequest {
175 trajectory_id,
176 scope_id,
177 agent_id,
178 content: sub.get_one::<String>("content").unwrap().clone(),
179 mode: sub.get_one::<String>("mode").cloned(),
180 query: sub.get_one::<String>("query").cloned(),
181 metadata: None,
182 };
183 let resp: ContextCommitResponse = client.post(&format!("{BASE}/commit"), &req).await?;
184 if output.json {
185 output.print(&resp.event_id);
186 } else {
187 println!("Committed. event_id={}", resp.event_id);
188 }
189 }
190 Some(("recall", sub)) => {
191 let mut path = format!("{BASE}/recall?");
192 let mut params = Vec::new();
193 if let Some(query) = sub.get_one::<String>("query") {
194 params.push(format!("query_text={}", urlencoded(query)));
195 }
196 if let Some(tid) =
197 super::resolve_arg(sub, "trajectory-id", session.trajectory_id.as_deref())
198 {
199 params.push(format!("trajectory_id={tid}"));
200 }
201 if let Some(sid) = super::resolve_arg(sub, "scope-id", session.scope_id.as_deref()) {
202 params.push(format!("scope_id={sid}"));
203 }
204 let max_results: i32 = sub.get_one::<String>("max-results").unwrap().parse()?;
205 params.push(format!("max_results={max_results}"));
206 if let Some(rw) = sub.get_one::<String>("recency-weight") {
207 params.push(format!("recency_weight={rw}"));
208 }
209 path.push_str(¶ms.join("&"));
210 let resp: serde_json::Value = client.get_raw(&path).await?;
211 output.print_value(&resp);
212 }
213 Some(("assemble", sub)) => {
214 let trajectory_id: Uuid = require_arg(
215 sub,
216 "trajectory-id",
217 session.trajectory_id.as_deref(),
218 "trajectory",
219 )?
220 .parse()?;
221 let scope_id: Option<Uuid> =
222 super::resolve_arg(sub, "scope-id", session.scope_id.as_deref())
223 .map(|s| s.parse())
224 .transpose()?;
225 let agent_id: Option<Uuid> =
226 super::resolve_arg(sub, "agent-id", session.agent_id.as_deref())
227 .map(|s| s.parse())
228 .transpose()?;
229 let token_budget: Option<i32> = sub
230 .get_one::<String>("token-budget")
231 .map(|s| s.parse())
232 .transpose()?;
233
234 let req = AssembleContextRequest {
235 trajectory_id,
236 scope_id,
237 user_input: sub.get_one::<String>("input").cloned(),
238 token_budget,
239 agent_id,
240 };
241 let resp: serde_json::Value =
242 client.post_raw(&format!("{BASE}/assemble"), &req).await?;
243 output.print_value(&resp);
244 }
245 Some(("outline", sub)) => {
246 let mut body = serde_json::Map::new();
247 let trajectory_id: Uuid = require_arg(
248 sub,
249 "trajectory-id",
250 session.trajectory_id.as_deref(),
251 "trajectory",
252 )?
253 .parse()?;
254 body.insert(
255 "trajectory_id".into(),
256 serde_json::Value::String(trajectory_id.to_string()),
257 );
258 if let Some(scope_id) = super::resolve_arg(sub, "scope-id", session.scope_id.as_deref())
259 {
260 let sid: Uuid = scope_id.parse()?;
261 body.insert(
262 "scope_id".into(),
263 serde_json::Value::String(sid.to_string()),
264 );
265 }
266 let resp: serde_json::Value = client
267 .post_raw(&format!("{BASE}/outline"), &serde_json::Value::Object(body))
268 .await?;
269 output.print_value(&resp);
270 }
271 _ => unreachable!("subcommand_required(true) prevents this"),
272 }
273 Ok(())
274}
275
276fn urlencoded(s: &str) -> String {
278 s.replace(' ', "%20")
279 .replace('&', "%26")
280 .replace('=', "%3D")
281 .replace('+', "%2B")
282 .replace('#', "%23")
283}