Skip to main content

lean_ctx/tools/
ctx_expand.rs

1use crate::core::archive;
2use crate::core::context_handles::HandleRegistry;
3use crate::core::context_ledger::ContextLedger;
4
5pub fn handle(args: &serde_json::Value) -> String {
6    let action = args
7        .get("action")
8        .and_then(|v| v.as_str())
9        .unwrap_or("retrieve");
10
11    match action {
12        "list" => handle_list(args),
13        "search_all" => handle_search_all(args),
14        _ => handle_retrieve(args),
15    }
16}
17
18/// Try to resolve a handle reference (@F1, @K1, etc.) to a file path.
19/// Returns None if the ID is not a handle reference.
20pub fn resolve_handle_ref(id: &str) -> Option<String> {
21    let clean = id.strip_prefix('@').unwrap_or(id);
22    if clean.len() < 2 {
23        return None;
24    }
25    let prefix = clean.chars().next()?;
26    if !matches!(prefix, 'F' | 'S' | 'K' | 'M' | 'P') {
27        return None;
28    }
29    if !clean[1..].chars().all(|c| c.is_ascii_digit()) {
30        return None;
31    }
32
33    let ledger = ContextLedger::load();
34    let mut registry = HandleRegistry::new();
35    for entry in &ledger.entries {
36        if let (Some(item_id), Some(kind)) = (&entry.id, &entry.kind) {
37            let phi = entry.phi.unwrap_or(0.5);
38            let view_costs = entry.view_costs.clone().unwrap_or_else(|| {
39                crate::core::context_field::ViewCosts::from_full_tokens(entry.original_tokens)
40            });
41            registry.register(
42                item_id.clone(),
43                *kind,
44                &entry.path,
45                &format!("{} {}L", entry.path, entry.original_tokens),
46                &view_costs,
47                phi,
48                entry
49                    .state
50                    .as_ref()
51                    .is_some_and(|s| *s == crate::core::context_field::ContextState::Pinned),
52            );
53        }
54    }
55
56    registry.resolve(clean).map(|h| h.source_path.clone())
57}
58
59fn handle_retrieve(args: &serde_json::Value) -> String {
60    let Some(id) = args.get("id").and_then(|v| v.as_str()) else {
61        return "ERROR: 'id' parameter is required. Use ctx_expand(action=\"list\") to see available archives, or pass a handle ref like @F1.".to_string();
62    };
63
64    // Handle reference resolution: @F1, @K1, @S1, etc.
65    if let Some(path) = resolve_handle_ref(id) {
66        let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("full");
67        return format!(
68            "[handle:{id} -> {path}]\nUse ctx_read(path=\"{path}\", mode=\"{mode}\") to load content."
69        );
70    }
71
72    // Unified tee-store handle (#482 / #936). One resolver, fixed precedence:
73    // the proxy's prune / live-compression stubs (`proxy_<hash>`), the JSON
74    // crusher's lossy originals (`json_<hash>`), AND every compressed shell
75    // command's already-teed verbatim output (`<slug>_<8hex>.log`) all live in
76    // the shared content-addressed store and resolve here, before the reference
77    // (`ref_`) and archive (hex) stores below. The agent pulls back just the
78    // slice it needs (head / tail / search / json_path / range) instead of
79    // re-injecting the whole original — the surgical front-end the issue calls
80    // "preferred when available". Works with a plain native file read too.
81    if let Some(path) = crate::proxy::ccr::resolve_tee(id) {
82        return expand_tee_file(&path, args);
83    }
84
85    // Resolve the entry's content once, then run the shared selector ladder.
86    // Archive IDs are hex-only; reference IDs are `ref_`-prefixed — the prefix
87    // picks the exact store, so the two stores differ only in *how* content is
88    // resolved, never in *how* selectors are dispatched (#498). Resolving up
89    // front also drops the archive path's per-selector disk re-reads.
90    if id.starts_with("ref_") {
91        let Some(content) = crate::server::reference_store::resolve(id) else {
92            return format!(
93                "Reference '{id}' not found or expired (5-min TTL). \
94                 Use the HTTP proxy at /v1/references/{id} if available."
95            );
96        };
97        return dispatch_selectors(id, &content, "Reference", args);
98    }
99    let Some(content) = archive::retrieve(id) else {
100        return format!(
101            "Archive '{id}' not found or expired. Use ctx_expand(action=\"list\") to see available archives."
102        );
103    };
104    dispatch_selectors(id, &content, "Archive", args)
105}
106
107/// Apply the structured selector ladder (head / tail / json_keys / search /
108/// range / full) to already-resolved `content`. Resolving once and formatting
109/// in-memory lets the archive and reference stores share a single code path and
110/// the same `archive::format_*` formatters, so output is byte-identical
111/// regardless of which store backed the ID (#498). `noun` is the capitalised
112/// store name used in messages — `"Archive"` or `"Reference"`.
113fn dispatch_selectors(id: &str, content: &str, noun: &str, args: &serde_json::Value) -> String {
114    let label = format!("{} {id}", noun.to_ascii_lowercase());
115
116    if let Some(n) = args.get("head").and_then(serde_json::Value::as_u64) {
117        let n = n as usize;
118        return format!(
119            "{noun} {id} head {n}:\n{}",
120            archive::format_range(content, 1, n)
121        );
122    }
123    if let Some(n) = args.get("tail").and_then(serde_json::Value::as_u64) {
124        let n = n as usize;
125        let total = content.lines().count();
126        let start = if total > n { total - n + 1 } else { 1 };
127        return format!(
128            "{noun} {id} tail {n}:\n{}",
129            archive::format_range(content, start, total)
130        );
131    }
132    if args.get("json_keys").and_then(serde_json::Value::as_bool) == Some(true)
133        || args.get("json_path").is_some()
134    {
135        let path = args.get("json_path").and_then(|v| v.as_str());
136        return match archive::format_json_keys(content, path, &label) {
137            Some(out) => out,
138            None => format!(
139                "{noun} '{id}' is not valid JSON. Use ctx_expand(id=\"{id}\") for raw content."
140            ),
141        };
142    }
143    if let Some(pattern) = args.get("search").and_then(|v| v.as_str()) {
144        return archive::format_search(content, pattern, &label);
145    }
146
147    let start = args
148        .get("start_line")
149        .and_then(serde_json::Value::as_u64)
150        .map(|v| v as usize);
151    let end = args
152        .get("end_line")
153        .and_then(serde_json::Value::as_u64)
154        .map(|v| v as usize);
155    if let (Some(s), Some(e)) = (start, end) {
156        return format!(
157            "{noun} {id} lines {s}-{e}:\n{}",
158            archive::format_range(content, s, e)
159        );
160    }
161
162    let lines = content.lines().count();
163    let chars = content.len();
164    format!("{noun} {id} ({chars} chars, {lines} lines):\n{content}")
165}
166
167/// Surgical retrieval over a CCR proxy tee file (#482). Mirrors the archive
168/// selectors (head / tail / search / json_path / range / full) but operates on
169/// the verbatim tee content on disk, so the agent pulls back only the slice it
170/// needs rather than undoing the proxy's compression with a full re-inject.
171fn expand_tee_file(path: &std::path::Path, args: &serde_json::Value) -> String {
172    let Ok(content) = std::fs::read_to_string(path) else {
173        return format!(
174            "ERROR: CCR tee file is no longer available: {}",
175            path.display()
176        );
177    };
178    let label = path.file_name().and_then(|n| n.to_str()).unwrap_or("ccr");
179
180    if let Some(n) = args.get("head").and_then(serde_json::Value::as_u64) {
181        return format!(
182            "[ccr {label}] head {n}:\n{}",
183            head_lines(&content, n as usize)
184        );
185    }
186    if let Some(n) = args.get("tail").and_then(serde_json::Value::as_u64) {
187        return format!(
188            "[ccr {label}] tail {n}:\n{}",
189            tail_lines(&content, n as usize)
190        );
191    }
192    if args.get("json_keys").and_then(serde_json::Value::as_bool) == Some(true)
193        || args.get("json_path").is_some()
194    {
195        let jp = args.get("json_path").and_then(|v| v.as_str());
196        return match json_view(&content, jp) {
197            Some(out) => format!("[ccr {label}] json {}:\n{out}", jp.unwrap_or("(keys)")),
198            None => format!(
199                "[ccr {label}] not valid JSON or path not found. Use ctx_expand(id=\"{label}\") for raw content."
200            ),
201        };
202    }
203    if let Some(pattern) = args.get("search").and_then(|v| v.as_str()) {
204        return format!(
205            "[ccr {label}] search \"{pattern}\":\n{}",
206            search_lines(&content, pattern)
207        );
208    }
209    let start = args
210        .get("start_line")
211        .and_then(serde_json::Value::as_u64)
212        .map(|v| v as usize);
213    let end = args
214        .get("end_line")
215        .and_then(serde_json::Value::as_u64)
216        .map(|v| v as usize);
217    if let (Some(s), Some(e)) = (start, end) {
218        return format!(
219            "[ccr {label}] lines {s}-{e}:\n{}",
220            range_lines(&content, s, e)
221        );
222    }
223
224    let lines = content.lines().count();
225    format!(
226        "[ccr {label}] ({} chars, {lines} lines):\n{content}",
227        content.len()
228    )
229}
230
231fn head_lines(s: &str, n: usize) -> String {
232    s.lines().take(n).collect::<Vec<_>>().join("\n")
233}
234
235fn tail_lines(s: &str, n: usize) -> String {
236    let v: Vec<&str> = s.lines().collect();
237    let start = v.len().saturating_sub(n);
238    v[start..].join("\n")
239}
240
241/// 1-indexed inclusive line range, clamped to the available lines.
242fn range_lines(s: &str, start: usize, end: usize) -> String {
243    let v: Vec<&str> = s.lines().collect();
244    let a = start.saturating_sub(1).min(v.len());
245    let b = end.min(v.len());
246    if a >= b {
247        return String::new();
248    }
249    v[a..b].join("\n")
250}
251
252fn search_lines(s: &str, pattern: &str) -> String {
253    let hits: Vec<String> = s
254        .lines()
255        .enumerate()
256        .filter(|(_, l)| l.contains(pattern))
257        .map(|(i, l)| format!("{}: {}", i + 1, l))
258        .collect();
259    if hits.is_empty() {
260        format!("(no lines match \"{pattern}\")")
261    } else {
262        hits.join("\n")
263    }
264}
265
266/// `json_path` navigation over the tee content: object segments by key, array
267/// segments by numeric index, dot-separated. Empty path lists the root keys.
268/// Objects render as their key list; scalars/arrays pretty-print.
269fn json_view(s: &str, path: Option<&str>) -> Option<String> {
270    let root: serde_json::Value = serde_json::from_str(s).ok()?;
271    let target = match path {
272        Some(p) if !p.is_empty() => navigate_json(&root, p)?,
273        _ => &root,
274    };
275    if let Some(obj) = target.as_object() {
276        Some(obj.keys().cloned().collect::<Vec<_>>().join("\n"))
277    } else {
278        serde_json::to_string_pretty(target).ok()
279    }
280}
281
282fn navigate_json<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
283    let mut cur = v;
284    for seg in path.split('.').filter(|s| !s.is_empty()) {
285        cur = match seg.parse::<usize>() {
286            Ok(idx) => cur.get(idx)?,
287            Err(_) => cur.get(seg)?,
288        };
289    }
290    Some(cur)
291}
292
293fn handle_search_all(args: &serde_json::Value) -> String {
294    let query = match args.get("query").and_then(|v| v.as_str()) {
295        Some(q) if !q.is_empty() => q,
296        _ => return "ERROR: 'query' parameter required for search_all.".to_string(),
297    };
298    let limit = args
299        .get("limit")
300        .and_then(serde_json::Value::as_u64)
301        .unwrap_or(10) as usize;
302
303    let results = crate::core::archive_fts::search(query, limit);
304    if results.is_empty() {
305        return format!(
306            "No archives match \"{query}\". Indexed: {} entries.",
307            crate::core::archive_fts::entry_count()
308        );
309    }
310
311    let mut out = format!("{} result(s) for \"{}\":\n", results.len(), query);
312    for r in &results {
313        out.push_str(&format!(
314            "  {} | {} | {} | …{}…\n",
315            r.archive_id, r.tool, r.command, r.snippet
316        ));
317    }
318    out.push_str("\nRetrieve full: ctx_expand(id=\"<archive_id>\")");
319    out
320}
321
322fn handle_list(args: &serde_json::Value) -> String {
323    let session_id = args.get("session_id").and_then(|v| v.as_str());
324    let entries = archive::list_entries(session_id);
325
326    if entries.is_empty() {
327        return "No archives found.".to_string();
328    }
329
330    let mut out = format!("{} archive(s):\n", entries.len());
331    for e in &entries {
332        out.push_str(&format!(
333            "  {} | {} | {} | {} chars ({} tok) | {}\n",
334            e.id,
335            e.tool,
336            e.command,
337            e.size_chars,
338            e.size_tokens,
339            e.created_at.format("%H:%M:%S")
340        ));
341    }
342    out.push_str("\nRetrieve: ctx_expand(id=\"<id>\")");
343    out.push_str("\nSearch: ctx_expand(id=\"<id>\", search=\"ERROR\")");
344    out.push_str("\nRange: ctx_expand(id=\"<id>\", start_line=10, end_line=50)");
345    out.push_str("\nHead/Tail: ctx_expand(id=\"<id>\", head=120) | tail=40");
346    out.push_str("\nJSON: ctx_expand(id=\"<id>\", json_keys=true) | json_path=\"data.items\"");
347    out
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use serde_json::json;
354
355    #[test]
356    fn handle_missing_id_returns_error() {
357        let result = handle(&json!({}));
358        assert!(result.contains("ERROR"));
359        assert!(result.contains("id"));
360    }
361
362    #[test]
363    fn handle_nonexistent_returns_not_found() {
364        let result = handle(&json!({"id": "nonexistent_xyz"}));
365        assert!(result.contains("not found"));
366    }
367
368    #[test]
369    fn handle_list_empty() {
370        let result = handle(&json!({"action": "list"}));
371        assert!(
372            result.contains("No archives") || result.contains("archive(s)"),
373            "unexpected: {result}"
374        );
375    }
376
377    #[test]
378    fn text_selectors_slice_correctly() {
379        let body = (1..=10)
380            .map(|i| format!("line {i}"))
381            .collect::<Vec<_>>()
382            .join("\n");
383        assert_eq!(head_lines(&body, 2), "line 1\nline 2");
384        assert_eq!(tail_lines(&body, 2), "line 9\nline 10");
385        assert_eq!(range_lines(&body, 3, 4), "line 3\nline 4");
386        assert!(search_lines(&body, "line 7").contains("7: line 7"));
387        assert!(search_lines(&body, "zzz").contains("no lines match"));
388    }
389
390    #[test]
391    fn json_view_lists_keys_and_navigates() {
392        let doc = r#"{"a":{"b":[10,20,30]},"c":1}"#;
393        assert_eq!(json_view(doc, None).unwrap(), "a\nc");
394        assert_eq!(json_view(doc, Some("a")).unwrap(), "b");
395        assert_eq!(json_view(doc, Some("a.b.1")).unwrap(), "20");
396        assert!(json_view(doc, Some("a.missing")).is_none());
397        assert!(json_view("not json", None).is_none());
398    }
399
400    #[test]
401    fn ctx_expand_retrieves_proxy_tee_handle_surgically() {
402        let _lock = crate::core::data_dir::test_env_lock();
403        // Mimic what the proxy does: persist a verbatim original to the tee store
404        // and hand the agent its content-addressed handle.
405        let original = (1..=60)
406            .map(|i| format!("output row {i:03}"))
407            .collect::<Vec<_>>()
408            .join("\n");
409        assert!(original.len() >= crate::proxy::ccr::MIN_TEE_BYTES);
410        let tee_handle = crate::proxy::ccr::persist(&original).expect("tee handle");
411
412        // Full content via the handle path (proxy-only fallback also reads this).
413        let full = handle(&json!({"id": tee_handle}));
414        assert!(full.contains("output row 001") && full.contains("output row 060"));
415
416        // Surgical slices via the bare hash form the stub can also carry.
417        let hash = crate::core::hasher::hash_short(&original);
418        let head = handle(&json!({"id": hash, "head": 2}));
419        assert!(head.contains("output row 001") && !head.contains("output row 010"));
420        let search = handle(&json!({"id": hash, "search": "row 042"}));
421        assert!(search.contains("output row 042") && !search.contains("output row 001"));
422    }
423
424    #[test]
425    fn ctx_expand_retrieves_shell_tee_output_surgically() {
426        let _lock = crate::core::data_dir::test_env_lock();
427        // Every compressed shell command already tees its verbatim output to the
428        // shared store (#936). With the unified resolver, ctx_expand can slice
429        // that tee surgically — the agent no longer has to re-read the whole file.
430        let original = (1..=60)
431            .map(|i| format!("api response row {i:03}"))
432            .collect::<Vec<_>>()
433            .join("\n");
434        let tee_path =
435            crate::shell::save_tee("gh api /repos/foo/bar", &original).expect("shell tee saved");
436        let name = std::path::Path::new(&tee_path)
437            .file_name()
438            .and_then(|n| n.to_str())
439            .unwrap()
440            .to_string();
441
442        // Full content via the bare shell-tee basename the footer advertises.
443        let full = handle(&json!({ "id": name.clone() }));
444        assert!(full.contains("api response row 001") && full.contains("api response row 060"));
445
446        // Surgical slices: head and search, same ladder as proxy/archive handles.
447        let head = handle(&json!({ "id": name.clone(), "head": 2 }));
448        assert!(head.contains("api response row 001") && !head.contains("api response row 010"));
449        let search = handle(&json!({ "id": name, "search": "row 042" }));
450        assert!(search.contains("api response row 042") && !search.contains("row 001"));
451    }
452
453    #[test]
454    fn ctx_expand_retrieves_json_crush_original() {
455        let _lock = crate::core::data_dir::test_env_lock();
456        // The lossy JSON crusher persists its verbatim original under json_<hash>
457        // (#936). The dropped columns must be recoverable through the same
458        // ctx_expand path the stub/footer advertises.
459        let original = (1..=50)
460            .map(|i| format!(r#"{{"id":{i},"ts":"2026-06-22T10:00:{i:02}Z"}}"#))
461            .collect::<Vec<_>>()
462            .join("\n");
463        assert!(original.len() >= crate::proxy::ccr::MIN_TEE_BYTES);
464        let handle_path = crate::proxy::ccr::persist_json(&original).expect("json tee handle");
465        assert!(handle_path.contains("json_"), "json_ prefix: {handle_path}");
466
467        let out = handle(&json!({ "id": handle_path }));
468        assert!(
469            out.contains("2026-06-22T10:00:42Z"),
470            "dropped column recoverable: {out}"
471        );
472    }
473
474    #[test]
475    fn ctx_expand_resolves_reference_store_ids() {
476        // #498: `ref_`-prefixed IDs route to the in-memory reference store, not
477        // the on-disk archive. Exercises the resolve-then-dispatch ladder end to
478        // end through the public `handle` entry point.
479        let body = (1..=40)
480            .map(|i| format!("ref row {i}"))
481            .collect::<Vec<_>>()
482            .join("\n");
483        let id = crate::server::reference_store::store(body);
484        assert!(id.starts_with("ref_"), "store must mint a ref_ id: {id}");
485
486        let full = handle(&json!({"id": id}));
487        assert!(
488            full.contains("Reference") && full.contains("ref row 1") && full.contains("ref row 40"),
489            "full: {full}"
490        );
491
492        let head = handle(&json!({"id": id, "head": 3}));
493        assert!(head.contains("ref row 1") && head.contains("ref row 3"));
494        assert!(!head.contains("ref row 4"), "head leaked row 4: {head}");
495
496        let tail = handle(&json!({"id": id, "tail": 2}));
497        assert!(tail.contains("ref row 39") && tail.contains("ref row 40"));
498        assert!(!tail.contains("ref row 38"), "tail leaked row 38: {tail}");
499
500        let search = handle(&json!({"id": id, "search": "ref row 7"}));
501        assert!(search.contains("ref row 7") && !search.contains("ref row 1\n"));
502
503        let range = handle(&json!({"id": id, "start_line": 5, "end_line": 6}));
504        assert!(range.contains("ref row 5") && range.contains("ref row 6"));
505        assert!(!range.contains("ref row 4") && !range.contains("ref row 7"));
506    }
507
508    #[test]
509    fn ctx_expand_reference_json_keys_and_missing() {
510        let id = crate::server::reference_store::store(r#"{"a":1,"b":[1,2,3]}"#.to_string());
511        let keys = handle(&json!({"id": id, "json_keys": true}));
512        assert!(keys.contains("object (2 keys)"), "got: {keys}");
513        assert!(keys.contains("array(3)"), "got: {keys}");
514
515        // An expired/unknown ref must explain the 5-min TTL, not fall through to
516        // the archive's "not found" message.
517        let missing = handle(&json!({"id": "ref_deadbeefcafef00d"}));
518        assert!(
519            missing.contains("not found or expired") && missing.contains("5-min TTL"),
520            "got: {missing}"
521        );
522    }
523}