Skip to main content

assistant_context_adapter/
assistant_context_adapter.rs

1use std::collections::VecDeque;
2use std::io::{self, Write};
3use std::sync::{Arc, Mutex};
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use lcsa_core::{
7    Capability, ContextApi, PermissionRequest, Scope, SignalEnvelope, SignalSupport, SignalType,
8    StructuralSignal,
9};
10use serde_json::json;
11
12const MAX_EVENTS: usize = 20;
13
14#[derive(Clone, Default)]
15struct AdapterState {
16    recent_focus: Option<String>,
17    recent_selection: Option<String>,
18    recent_clipboard: Option<String>,
19    recent_events: VecDeque<String>,
20}
21
22fn main() -> Result<(), Box<dyn std::error::Error>> {
23    let mut api = ContextApi::new()?;
24    let state = Arc::new(Mutex::new(AdapterState::default()));
25
26    println!("LCSA Assistant Adapter Demo");
27    println!("Type a developer task and press Enter.");
28    println!("Commands: :help :context :grant-content :revoke-content :quit");
29    println!();
30
31    attach_signal_subscriptions(&mut api, Arc::clone(&state))?;
32
33    let stdin = io::stdin();
34    loop {
35        print!("ask> ");
36        io::stdout().flush()?;
37
38        let mut input = String::new();
39        stdin.read_line(&mut input)?;
40        let input = input.trim();
41        if input.is_empty() {
42            continue;
43        }
44
45        match input {
46            ":help" => print_help(),
47            ":context" => print_context_snapshot(&api, &state),
48            ":grant-content" => grant_clipboard_content(&mut api)?,
49            ":revoke-content" => {
50                let revoked = api.revoke_permission(Capability::ReadClipboardContent);
51                println!("clipboard content access revoked={revoked}");
52            }
53            ":quit" | ":exit" => break,
54            task => print_augmented_packet(&api, &state, task),
55        }
56    }
57
58    Ok(())
59}
60
61fn attach_signal_subscriptions(
62    api: &mut ContextApi,
63    state: Arc<Mutex<AdapterState>>,
64) -> Result<(), Box<dyn std::error::Error>> {
65    for signal_type in [
66        SignalType::Clipboard,
67        SignalType::Selection,
68        SignalType::Focus,
69    ] {
70        match api.signal_support(signal_type) {
71            SignalSupport::Supported => {
72                let state = Arc::clone(&state);
73                api.subscribe_enveloped(signal_type, move |envelope| {
74                    ingest_envelope(&state, envelope);
75                })?;
76                println!("{signal_type:?}: subscribed");
77            }
78            unsupported => {
79                println!("{signal_type:?}: {unsupported:?}");
80            }
81        }
82    }
83
84    println!();
85    Ok(())
86}
87
88fn ingest_envelope(state: &Arc<Mutex<AdapterState>>, envelope: SignalEnvelope) {
89    let mut state = state.lock().expect("adapter state mutex poisoned");
90    let ts = unix_secs(envelope.emitted_at);
91
92    match envelope.payload {
93        StructuralSignal::Clipboard(signal) => {
94            let summary = format!(
95                "clipboard type={:?} bytes={} sensitive={} command={} source={}",
96                signal.content_type,
97                signal.size_bytes,
98                signal.likely_sensitive,
99                signal.likely_command,
100                signal.source_app
101            );
102            state.recent_clipboard = Some(summary.clone());
103            push_event(&mut state.recent_events, format!("{ts} {summary}"));
104        }
105        StructuralSignal::Selection(signal) => {
106            let preview = format!(
107                "selection type={:?} bytes={} editable={} source={}",
108                signal.content_type, signal.size_bytes, signal.is_editable, signal.source_app
109            );
110            state.recent_selection = Some(preview.clone());
111            push_event(&mut state.recent_events, format!("{ts} {preview}"));
112        }
113        StructuralSignal::Focus(signal) => {
114            let summary = format!(
115                "focus target={:?} editable={} source={}",
116                signal.target, signal.is_editable, signal.source_app
117            );
118            state.recent_focus = Some(summary.clone());
119            push_event(&mut state.recent_events, format!("{ts} {summary}"));
120        }
121        StructuralSignal::Filesystem(signal) => {
122            push_event(
123                &mut state.recent_events,
124                format!(
125                    "{ts} filesystem {} {:?}",
126                    signal.event_name(),
127                    signal.action
128                ),
129            );
130        }
131    }
132}
133
134fn print_help() {
135    println!(":context         show current signal snapshot");
136    println!(":grant-content   grant clipboard content access for this session");
137    println!(":revoke-content  revoke clipboard content access");
138    println!(":quit            exit");
139}
140
141fn print_context_snapshot(api: &ContextApi, state: &Arc<Mutex<AdapterState>>) {
142    let snapshot = state.lock().expect("adapter state mutex poisoned").clone();
143    let packet = build_context_packet(api, &snapshot, "(snapshot)", None);
144    println!(
145        "{}",
146        serde_json::to_string_pretty(&packet).unwrap_or_default()
147    );
148}
149
150fn grant_clipboard_content(api: &mut ContextApi) -> Result<(), Box<dyn std::error::Error>> {
151    api.request_permission(PermissionRequest::new(
152        Capability::ReadClipboardContent,
153        Scope::Session,
154        "Enable content-level context in assistant adapter demo",
155    ))?;
156    println!("clipboard content access granted for current session");
157    Ok(())
158}
159
160fn print_augmented_packet(api: &ContextApi, state: &Arc<Mutex<AdapterState>>, task: &str) {
161    let snapshot = state.lock().expect("adapter state mutex poisoned").clone();
162    let clipboard_preview = api.read_clipboard_content().ok().map(|content| {
163        // The preview intentionally keeps sensitive content redacted when needed.
164        content.redacted_preview()
165    });
166
167    let packet = build_context_packet(api, &snapshot, task, clipboard_preview.clone());
168    println!();
169    println!("=== Augmented Context Packet ===");
170    println!(
171        "{}",
172        serde_json::to_string_pretty(&packet).unwrap_or_default()
173    );
174    println!();
175    println!("=== Prompt Preview ===");
176    println!(
177        "{}",
178        build_prompt_preview(task, &snapshot, clipboard_preview)
179    );
180    println!();
181}
182
183fn build_context_packet(
184    api: &ContextApi,
185    snapshot: &AdapterState,
186    task: &str,
187    clipboard_preview: Option<String>,
188) -> serde_json::Value {
189    json!({
190        "task": task,
191        "device": {
192            "id": api.device_context().device_id,
193            "platform": format!("{:?}", api.device_context().platform),
194        },
195        "capabilities": {
196            "clipboard": format!("{:?}", api.signal_support(SignalType::Clipboard)),
197            "selection": format!("{:?}", api.signal_support(SignalType::Selection)),
198            "focus": format!("{:?}", api.signal_support(SignalType::Focus)),
199            "clipboard_content_access": api.can_access(Capability::ReadClipboardContent),
200        },
201        "context": {
202            "recent_focus": snapshot.recent_focus,
203            "recent_selection": snapshot.recent_selection,
204            "recent_clipboard_signal": snapshot.recent_clipboard,
205            "clipboard_content_preview": clipboard_preview,
206            "recent_events": snapshot.recent_events.iter().take(8).cloned().collect::<Vec<_>>(),
207        },
208        "hints": derive_hints(snapshot),
209    })
210}
211
212fn build_prompt_preview(
213    task: &str,
214    snapshot: &AdapterState,
215    clipboard_preview: Option<String>,
216) -> String {
217    let mut lines = vec![
218        "You are a coding assistant helping with a local development task.".to_string(),
219        format!("Task: {task}"),
220    ];
221
222    if let Some(focus) = &snapshot.recent_focus {
223        lines.push(format!("Current focus: {focus}"));
224    }
225    if let Some(selection) = &snapshot.recent_selection {
226        lines.push(format!("Recent selection: {selection}"));
227    }
228    if let Some(clipboard) = &snapshot.recent_clipboard {
229        lines.push(format!("Recent clipboard signal: {clipboard}"));
230    }
231    if let Some(preview) = clipboard_preview {
232        lines.push(format!("Clipboard content preview: {preview}"));
233    }
234
235    for hint in derive_hints(snapshot) {
236        lines.push(format!("Hint: {hint}"));
237    }
238
239    lines.join("\n")
240}
241
242fn derive_hints(snapshot: &AdapterState) -> Vec<String> {
243    let mut hints = Vec::new();
244
245    if let Some(focus) = &snapshot.recent_focus {
246        let lowercase = focus.to_ascii_lowercase();
247        if lowercase.contains("terminal") {
248            hints.push("User is likely in a terminal-driven debugging flow.".to_string());
249        }
250        if lowercase.contains("browser") {
251            hints.push("User may be validating behavior in a browser.".to_string());
252        }
253    }
254
255    if let Some(selection) = &snapshot.recent_selection {
256        if selection.contains("Code") {
257            hints.push(
258                "Selected text looks code-like; prioritize code-aware suggestions.".to_string(),
259            );
260        }
261    }
262
263    if let Some(clipboard) = &snapshot.recent_clipboard {
264        if clipboard.contains("sensitive=true") {
265            hints
266                .push("Clipboard likely contains sensitive data; avoid verbatim echo.".to_string());
267        }
268        if clipboard.contains("command=true") {
269            hints.push(
270                "Clipboard resembles a shell command; offer safe command guidance.".to_string(),
271            );
272        }
273    }
274
275    hints
276}
277
278fn push_event(events: &mut VecDeque<String>, line: String) {
279    events.push_front(line);
280    while events.len() > MAX_EVENTS {
281        let _ = events.pop_back();
282    }
283}
284
285fn unix_secs(ts: SystemTime) -> u64 {
286    ts.duration_since(UNIX_EPOCH)
287        .map(|duration| duration.as_secs())
288        .unwrap_or_default()
289}