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 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}