Skip to main content

ryu_predict/
lib.rs

1//! Predictive typing — the "brain" behind system-wide inline autocomplete.
2//!
3//! This crate owns the completion **engine**: the config (model / per-app
4//! allowlist / debounce), the privacy denylist for password & secure controls,
5//! the prompt assembly, the cleanup of the raw model reply, and the
6//! `/api/predict/*` HTTP surface ([`api`]). The native overlay (`apps-store/predict`)
7//! stays deliberately dumb — it reads the caret context, POSTs it here, and
8//! renders whatever string comes back. No Gateway URL, key, or model id ever
9//! lives in the overlay process.
10//!
11//! Placement (CLAUDE.md §1, Core vs Gateway): deciding *what runs* (assemble the
12//! prompt, enforce the app allowlist, refuse secure fields) is **Core**. The
13//! actual model call is handed to the **Gateway** via the host's
14//! [`PredictHost::call_side_model`] (the same path `/btw`, goal, and double-check
15//! use), so model routing / firewall / budgets / audit all apply — nothing
16//! hardcoded.
17//!
18//! ## What stayed in the kernel
19//!
20//! The process-global "predictive typing is on" flag — `predict::set_enabled` /
21//! `predict::is_enabled`, seeded at boot from the built-in **Predict** plugin's
22//! persisted state and flipped live from the plugin enable/disable path
23//! (`apply_policy`) — stays in `apps/core`: it is the plugin's on/off switch, part
24//! of the AppGate/plugin wiring, not the completion engine. This crate reads it
25//! through [`PredictHost::is_enabled`]. The plugin id const + the `predict.manifest.json`
26//! fixture likewise stay in Core.
27//!
28//! The shared in-editor copilot (PlateJS ghost text) routes through the Gateway
29//! directly from the desktop webview; this endpoint is the *system-wide* sibling
30//! for arbitrary native apps, but both speak the same predictive contract.
31
32use async_trait::async_trait;
33use serde::{Deserialize, Serialize};
34
35pub mod api;
36
37pub use api::{routes, PredictCtx};
38
39/// Preference key holding the predictive-typing config blob (one JSON object,
40/// mirroring how `editor-ai` is stored). The desktop settings tab and the
41/// `apps-store/predict` overlay both read/write this single key.
42pub const PREDICT_CONFIG_PREF: &str = "predict-config";
43
44/// The kernel couplings the moved predict engine needs, inverted so this crate
45/// stays free of any `apps/core` dependency. Core implements it (`predict_host.rs`)
46/// over the `ServerState` and installs it into the [`PredictCtx`].
47#[async_trait]
48pub trait PredictHost: Send + Sync {
49    /// Whether system-wide predictive typing is currently enabled (i.e. the
50    /// built-in Predict plugin is installed and enabled). The flag is owned by the
51    /// kernel (`predict::is_enabled`, seeded at boot + flipped on plugin
52    /// enable/disable); the request path reads it here and refuses when off.
53    fn is_enabled(&self) -> bool;
54
55    /// Read a persisted preference value (the predict config blob).
56    async fn pref_get(&self, key: &str) -> Option<String>;
57
58    /// Persist a preference value (the predict config blob).
59    async fn pref_set(&self, key: &str, value: &str) -> Result<(), String>;
60
61    /// The bound chat model of an agent, if that agent exists and has one. Lets an
62    /// explicit `agent_id` make the prediction agent a real, swappable card — its
63    /// bound model wins over the config's `model`.
64    async fn agent_bound_model(&self, agent_id: &str) -> Option<String>;
65
66    /// The built-in default model id, used when nothing else resolves.
67    fn default_model(&self) -> String;
68
69    /// Hand the completion call to the Gateway (routing / firewall / budgets /
70    /// audit all apply). Returns the raw assistant text.
71    async fn call_side_model(
72        &self,
73        model: &str,
74        effort: &str,
75        system: &str,
76        user: &str,
77    ) -> Result<String, String>;
78}
79
80/// Default debounce between caret changes and a prediction request (ms).
81pub const DEFAULT_DEBOUNCE_MS: u64 = 400;
82
83/// Default cap on a suggestion's length (characters). Keeps inline ghost text to
84/// a sentence-ish continuation rather than a runaway paragraph.
85pub const DEFAULT_MAX_CHARS: usize = 240;
86
87/// Persisted predictive-typing configuration. `camelCase` so the desktop
88/// settings tab and the overlay can read/write the same JSON shape.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90#[serde(rename_all = "camelCase")]
91pub struct PredictConfig {
92    /// Gateway-routable model id. Empty → resolved from the agent / env / default.
93    #[serde(default)]
94    pub model: String,
95    /// `reasoning_effort` passthrough; empty → omitted.
96    #[serde(default)]
97    pub effort: String,
98    /// Optional agent backing predictions. When set, the agent's bound model wins
99    /// over `model`, and the id is forwarded to the Gateway for per-agent
100    /// routing / budgets / audit.
101    #[serde(default, rename = "agentId", skip_serializing_if = "Option::is_none")]
102    pub agent_id: Option<String>,
103    /// Per-app allowlist of process names (e.g. `notepad.exe`, `chrome.exe`).
104    /// **Empty = every app allowed** (the default). A non-empty list restricts
105    /// predictions to exactly those apps (case-insensitive match).
106    #[serde(default, rename = "appAllowlist")]
107    pub app_allowlist: Vec<String>,
108    /// Debounce (ms) the overlay waits after the caret settles before requesting.
109    #[serde(default = "default_debounce", rename = "debounceMs")]
110    pub debounce_ms: u64,
111    /// Max characters of a returned suggestion.
112    #[serde(default = "default_max_chars", rename = "maxChars")]
113    pub max_chars: usize,
114}
115
116fn default_debounce() -> u64 {
117    DEFAULT_DEBOUNCE_MS
118}
119fn default_max_chars() -> usize {
120    DEFAULT_MAX_CHARS
121}
122
123impl Default for PredictConfig {
124    fn default() -> Self {
125        Self {
126            model: String::new(),
127            effort: String::new(),
128            agent_id: None,
129            app_allowlist: Vec::new(),
130            debounce_ms: DEFAULT_DEBOUNCE_MS,
131            max_chars: DEFAULT_MAX_CHARS,
132        }
133    }
134}
135
136impl PredictConfig {
137    /// Parse the persisted pref blob, falling back to defaults on absent/garbage.
138    pub fn from_pref(raw: Option<&str>) -> Self {
139        raw.and_then(|s| serde_json::from_str::<PredictConfig>(s).ok())
140            .unwrap_or_default()
141    }
142}
143
144/// Localized control-type tokens that indicate a **password or otherwise secure**
145/// field, where we must NOT read context or suggest. UIA reports a localized
146/// control type (e.g. "edit", "password"); some apps expose "password" directly,
147/// and browsers surface secure inputs whose name/type carries these markers. We
148/// match loosely (substring, case-insensitive) and fail *closed* — if in doubt,
149/// refuse. This is the privacy floor the Gateway moat exists to enforce: never
150/// exfiltrate a secret to a model just because the user was typing one.
151const SECURE_CONTROL_MARKERS: &[&str] = &[
152    "password", "passwd", "secure", "pin", "otp", "cvv", "secret",
153];
154
155/// True when a control type / field descriptor names a password or secure input.
156/// Pure + case-insensitive so it is unit-testable without UIA.
157pub fn is_secure_control(control: &str) -> bool {
158    let lower = control.to_lowercase();
159    SECURE_CONTROL_MARKERS.iter().any(|m| lower.contains(m))
160}
161
162/// True when `app` is permitted by `allowlist`. An **empty** allowlist permits
163/// every app; otherwise the process name must match an entry (case-insensitive,
164/// trimmed). `app` may be a full path or a bare exe name — we compare on the
165/// file name component so `C:\\…\\chrome.exe` matches `chrome.exe`.
166pub fn app_allowed(allowlist: &[String], app: &str) -> bool {
167    if allowlist.is_empty() {
168        return true;
169    }
170    let name = app
171        .rsplit(['\\', '/'])
172        .next()
173        .unwrap_or(app)
174        .trim()
175        .to_lowercase();
176    if name.is_empty() {
177        return false;
178    }
179    let name_stem = name.trim_end_matches(".exe");
180    allowlist.iter().any(|entry| {
181        let e = entry.trim().to_lowercase();
182        !e.is_empty() && e.trim_end_matches(".exe") == name_stem
183    })
184}
185
186/// The predictive system prompt + user message for a given caret context. Pure
187/// so the exact wording is testable and lives in one place.
188///
189/// The instructions mirror the in-editor copilot's (continue naturally, no new
190/// block, end on punctuation, return the sentinel `0` for "no good
191/// continuation") so both predictive surfaces behave consistently.
192pub fn build_messages(context: &str) -> (String, String) {
193    let system = "You are an inline autocomplete engine, like GitHub Copilot but for any text \
194field. Predict the immediate continuation of the user's text from the context before their cursor. \
195Rules:\n\
196- Output ONLY the continuation text — never repeat the context, never explain.\n\
197- Continue naturally, up to roughly the next clause or sentence.\n\
198- Match the existing style, tone, and language.\n\
199- Do not start a new line or block; continue in place.\n\
200- If you cannot confidently continue, output exactly: 0"
201        .to_string();
202    let user = format!(
203        "Continue the text after the cursor. Text before the cursor:\n\"\"\"\n{context}\n\"\"\""
204    );
205    (system, user)
206}
207
208/// Clean a raw model reply into an inline suggestion. Strips wrapping quotes /
209/// code fences, collapses to a single line, trims, enforces `max_chars`, and
210/// maps the `0` sentinel (and empties) to an empty string = "no suggestion".
211pub fn clean_suggestion(raw: &str, max_chars: usize) -> String {
212    let mut s = raw.trim().to_string();
213    // The sentinel for "nothing to suggest".
214    if s == "0" {
215        return String::new();
216    }
217    // Strip a leading/trailing code fence if the model wrapped the reply.
218    if let Some(rest) = s.strip_prefix("```") {
219        s = rest.to_string();
220        if let Some(idx) = s.find('\n') {
221            s = s[idx + 1..].to_string();
222        }
223        if let Some(idx) = s.rfind("```") {
224            s = s[..idx].to_string();
225        }
226        s = s.trim().to_string();
227    }
228    // Strip symmetric wrapping quotes.
229    for (open, close) in [('"', '"'), ('\'', '\''), ('“', '”')] {
230        if s.starts_with(open) && s.ends_with(close) && s.chars().count() >= 2 {
231            let inner: String = s.chars().skip(1).take(s.chars().count() - 2).collect();
232            s = inner.trim().to_string();
233        }
234    }
235    // Single line only: inline ghost text never spans blocks.
236    if let Some(idx) = s.find(['\n', '\r']) {
237        s = s[..idx].to_string();
238    }
239    let s = s.trim().to_string();
240    if s == "0" || s.is_empty() {
241        return String::new();
242    }
243    if s.chars().count() > max_chars {
244        return s.chars().take(max_chars).collect::<String>();
245    }
246    s
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn secure_controls_are_refused() {
255        assert!(is_secure_control("Password"));
256        assert!(is_secure_control("password edit"));
257        assert!(is_secure_control("Secure Text Field"));
258        assert!(is_secure_control("OTP"));
259        assert!(!is_secure_control("edit"));
260        assert!(!is_secure_control("document"));
261        assert!(!is_secure_control("text box"));
262    }
263
264    #[test]
265    fn empty_allowlist_permits_all() {
266        assert!(app_allowed(&[], "notepad.exe"));
267        assert!(app_allowed(&[], "C:\\x\\chrome.exe"));
268    }
269
270    #[test]
271    fn allowlist_matches_basename_case_insensitive() {
272        let allow = vec!["Notepad.exe".to_string(), "chrome".to_string()];
273        assert!(app_allowed(&allow, "notepad.exe"));
274        assert!(app_allowed(&allow, "C:\\Windows\\System32\\notepad.exe"));
275        assert!(app_allowed(&allow, "chrome.exe"));
276        assert!(!app_allowed(&allow, "code.exe"));
277        assert!(!app_allowed(&allow, ""));
278    }
279
280    #[test]
281    fn cleans_sentinel_and_empty() {
282        assert_eq!(clean_suggestion("0", 240), "");
283        assert_eq!(clean_suggestion("   ", 240), "");
284        assert_eq!(clean_suggestion("0\n", 240), "");
285    }
286
287    #[test]
288    fn strips_quotes_and_collapses_to_one_line() {
289        assert_eq!(clean_suggestion("\" world\"", 240), "world");
290        assert_eq!(clean_suggestion("hello\nthere", 240), "hello");
291        assert_eq!(clean_suggestion("```\ncode here\n```", 240), "code here");
292    }
293
294    #[test]
295    fn enforces_max_chars() {
296        let long = "a".repeat(500);
297        assert_eq!(clean_suggestion(&long, 10).chars().count(), 10);
298    }
299
300    #[test]
301    fn config_roundtrips_through_pref() {
302        let cfg = PredictConfig {
303            model: "gpt-4o-mini".to_string(),
304            effort: "low".to_string(),
305            agent_id: Some("ryu".to_string()),
306            app_allowlist: vec!["notepad.exe".to_string()],
307            debounce_ms: 250,
308            max_chars: 120,
309        };
310        let raw = serde_json::to_string(&cfg).unwrap();
311        let back = PredictConfig::from_pref(Some(&raw));
312        assert_eq!(cfg, back);
313    }
314
315    #[test]
316    fn missing_pref_is_default() {
317        let cfg = PredictConfig::from_pref(None);
318        assert_eq!(cfg.debounce_ms, DEFAULT_DEBOUNCE_MS);
319        assert_eq!(cfg.max_chars, DEFAULT_MAX_CHARS);
320        assert!(cfg.app_allowlist.is_empty());
321    }
322
323    #[test]
324    fn garbage_pref_falls_back_to_default() {
325        let cfg = PredictConfig::from_pref(Some("not json"));
326        assert_eq!(cfg, PredictConfig::default());
327    }
328}