cellstate/entity/
context.rs

1//! `cellstate context` — High-level context commit and recall.
2//!
3//! Subcommands: commit, recall, assemble, outline.
4//!
5//! These use non-standard paths (`/api/v1/context/commit`, etc.) rather than
6//! the CRUD pattern. The request/response types are defined inline because
7//! they currently live in the server crate and haven't been migrated to core.
8
9use 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// ── Inline types (server-side types not yet in core) ────────────────────────
21
22#[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
55// ── Command builder ─────────────────────────────────────────────────────────
56
57pub 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
150// ── Dispatch ────────────────────────────────────────────────────────────────
151
152pub 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(&params.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
276/// Minimal URL encoding for query parameter values.
277fn urlencoded(s: &str) -> String {
278    s.replace(' ', "%20")
279        .replace('&', "%26")
280        .replace('=', "%3D")
281        .replace('+', "%2B")
282        .replace('#', "%23")
283}