@noorslvt/shev 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/lib.rs ADDED
@@ -0,0 +1,3678 @@
1
+ //! shev — hand a blob of text to DeepSeek and record the resulting decision
2
+ //! as JSON.
3
+ //!
4
+ //! Flow:
5
+ //! 1. onboarding: read `DEEPSEEK_API_KEY`, or read the saved key from
6
+ //! `~/.config/shev/config.json`, or prompt for it once and save it.
7
+ //! 2. verification: hit the DeepSeek API with the key and refuse to continue
8
+ //! if the key is not accepted.
9
+ //! 3. loop: read one whole message (blank lines included), ask the user what
10
+ //! they want to do with it, hand both to the model, validate the decision
11
+ //! and append it to `responses.json` as `response1`, `response2`, ...
12
+ //!
13
+ //! The message is judged as a whole — one decision for the entire message, never
14
+ //! per sentence or per paragraph.
15
+ //!
16
+ //! Every decision has these fields:
17
+ //! actionable : string — the concrete action, or "" when there is none
18
+ //! take_action : boolean — whether the action should be taken
19
+ //! confidence : integer — inside the closed range [34, 100]
20
+ //! generated_response : string — the reply or next step, written out, driven
21
+ //! by what the user said they want to do
22
+
23
+ use std::env;
24
+ use std::fs;
25
+ use std::io::{self, BufRead, IsTerminal, Read, Write};
26
+ use std::path::{Path, PathBuf};
27
+ use std::sync::{Mutex, OnceLock};
28
+ use std::thread::sleep;
29
+ use std::time::{Duration, SystemTime, UNIX_EPOCH};
30
+
31
+ use serde_json::{Map, Value, json};
32
+
33
+ pub mod installation;
34
+ pub mod integrations;
35
+
36
+ pub fn managed_home() -> Option<PathBuf> {
37
+ env::var_os("SHEV_HOME")
38
+ .filter(|value| !value.is_empty())
39
+ .map(PathBuf::from)
40
+ }
41
+
42
+ pub fn default_responses_path() -> PathBuf {
43
+ managed_home()
44
+ .map(|home| home.join("responses.json"))
45
+ .unwrap_or_else(|| PathBuf::from(DEFAULT_RESPONSES_FILE))
46
+ }
47
+
48
+ const API_BASE: &str = "https://api.deepseek.com";
49
+ pub const MODEL: &str = "deepseek-chat";
50
+ pub const MIN_CONFIDENCE: i64 = 34;
51
+ pub const MAX_CONFIDENCE: i64 = 100;
52
+ pub const DEFAULT_RESPONSES_FILE: &str = "responses.json";
53
+ pub const DEFAULT_INTENT: &str = "Decide whether this message needs action.";
54
+ /// How many earlier decisions the TUI loads into its transcript.
55
+ pub const MAX_HISTORY: usize = 20;
56
+ /// The summary the model returns is capped at three sentences and 120 words.
57
+ pub const MAX_SUMMARY_WORDS: usize = 120;
58
+ pub const MAX_SUMMARY_SENTENCES: usize = 3;
59
+
60
+ const SYSTEM_PROMPT: &str = r#"You are a decision engine. The user hands you one message plus a statement of
61
+ what the user wants to do with that message.
62
+
63
+ Judge the message AS A WHOLE. There is exactly one decision for the entire message.
64
+ Never judge it sentence by sentence, line by line or paragraph by paragraph, and never
65
+ return a list of decisions.
66
+
67
+ Reply with a single JSON object and nothing else. It must use exactly these keys:
68
+ {
69
+ "actionable": "<one short sentence naming the concrete action the message calls for, or \"\" if there is none>",
70
+ "take_action": <true if an action should be taken, otherwise false>,
71
+ "confidence": <integer between 34 and 100 inclusive: how sure you are of this decision>,
72
+ "summary": "<at most 3 sentences and at most 120 words saying what the user should actually do>",
73
+ "generated_response": "<the reply, or the concrete next step, written out in full so the user can use it as-is>"
74
+ }
75
+
76
+ Rules:
77
+ - The whole message is one unit: one actionable, one take_action, one confidence.
78
+ - "take_action" is true only when a concrete, useful action follows from the message
79
+ given what the user says they want to do.
80
+ - "summary" is the short read: at most 3 sentences, at most 120 words, plain prose,
81
+ telling the user what they should actually do next. No bullet lists, no headings.
82
+ - "generated_response" is the deliverable: write the actual reply text, or the actual
83
+ concrete steps to take, taking the user's stated intent as the goal. Write it as
84
+ finished prose the user could send or follow without editing.
85
+ - If the user's intent is to do nothing, or the message needs nothing, return "" for
86
+ "summary" and "generated_response", and false for "take_action".
87
+ - "confidence" must be an integer inside the closed range [34, 100]. Never below 34,
88
+ never above 100.
89
+ - No extra keys, no prose outside the JSON, no markdown fences."#;
90
+
91
+ /// The lean prompt for the local model. The remote prompt above is long — some
92
+ /// four hundred words — and on a small on-device model almost all of the latency
93
+ /// is the prompt prefill, so the faster path wants the same rules in far fewer
94
+ /// tokens. Same five fields, same one-object contract.
95
+ const LOCAL_SYSTEM_PROMPT: &str = r#"You are a decision engine. The user gives one message and what they want to do with it.
96
+ Judge the message as a whole, once. Reply with one JSON object and nothing else:
97
+ {"actionable":"","take_action":false,"confidence":70,"summary":"","generated_response":""}
98
+ Fill in those five fields. take_action is true only for an outstanding action relevant to the user's intent; false when none remains. Do not invent obligations. confidence is an integer 34..100. summary is at most 3 sentences and 120 words. generated_response is the concise reply or next step, ready to use.
99
+ No prose outside the JSON, no markdown fences."#;
100
+
101
+ /// The instruction that wraps the user's message and their stated intent.
102
+ const USER_TEMPLATE: &str = "WHAT THE USER WANTS TO DO WITH THIS MESSAGE:\n%INTENT%\n\nTHE MESSAGE, AS A WHOLE:\n<message>\n%MESSAGE%\n</message>";
103
+
104
+ /// The line-based CLI: read a message, ask for the intent interactively, print
105
+ /// the decision and append it to the responses file.
106
+ pub fn cli_run() -> Result<(), String> {
107
+ let options = match parse_args()? {
108
+ Some(options) => options,
109
+ None => return Ok(()), // --help was printed
110
+ };
111
+
112
+ // Serving MCP replaces the whole CLI: stdin/stdout are a JSON-RPC channel.
113
+ if options.mcp {
114
+ return mcp_serve(&options.responses);
115
+ }
116
+ installation::remember_history(&options.responses)?;
117
+
118
+ let interactive = io::stdin().is_terminal();
119
+ let client = http_client()?;
120
+
121
+ // Local by default. Only the explicitly selected remote provider needs a key.
122
+ let target = match load_provider() {
123
+ Provider::Bonsai => {
124
+ ensure_bonsai_ready()?;
125
+ Target::bonsai()
126
+ }
127
+ Provider::DeepSeek => Target::deepseek(&obtain_api_key(&client, interactive)?),
128
+ };
129
+
130
+ // 3: one message, or a loop over messages.
131
+ if let Some(message) = options.once {
132
+ let intent = options
133
+ .intent
134
+ .clone()
135
+ .unwrap_or_else(|| DEFAULT_INTENT.to_string());
136
+ let decision = request_decision_target(&client, &target, &message, &intent)?;
137
+ store_decision(&options.responses, &decision, &message, &intent)?;
138
+ return Ok(());
139
+ }
140
+
141
+ if !interactive {
142
+ // Piped in: the whole of stdin is one message.
143
+ let mut message = String::new();
144
+ io::stdin()
145
+ .read_to_string(&mut message)
146
+ .map_err(|e| format!("could not read stdin: {e}"))?;
147
+ let message = message.trim().to_string();
148
+ if message.is_empty() {
149
+ return Err("no message was piped in on stdin".to_string());
150
+ }
151
+ let intent = options
152
+ .intent
153
+ .clone()
154
+ .unwrap_or_else(|| DEFAULT_INTENT.to_string());
155
+ let decision = request_decision_target(&client, &target, &message, &intent)?;
156
+ store_decision(&options.responses, &decision, &message, &intent)?;
157
+ return Ok(());
158
+ }
159
+
160
+ println!(
161
+ "\nshev is ready.\n\
162
+ 1. Paste the whole message, then a line containing only EOF (Ctrl-D ends it too).\n\
163
+ \x20 Blank lines inside the message are kept, so paragraphs survive.\n\
164
+ 2. You are then asked what you want to do with it.\n\
165
+ The model judges the message as a whole and writes the response for you.\n\
166
+ Decisions are appended to {}.\n\
167
+ Type 'exit' as the first line to quit.\n",
168
+ options.responses.display()
169
+ );
170
+
171
+ let mut stdin = io::stdin().lock();
172
+ while let Some(message) = read_message(&mut stdin)? {
173
+ let Some(intent) = read_intent(&mut stdin)? else {
174
+ break;
175
+ };
176
+ match request_decision_target(&client, &target, &message, &intent) {
177
+ Ok(decision) => store_decision(&options.responses, &decision, &message, &intent)?,
178
+ Err(err) => eprintln!("request failed: {err}\n(the message was not recorded)"),
179
+ }
180
+ }
181
+
182
+ println!("bye.");
183
+ Ok(())
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // onboarding: api key > verification
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /// Resolve a usable API key: env var, then saved config, then an interactive
191
+ /// prompt. Every candidate is verified against the API before it is accepted.
192
+ fn obtain_api_key(client: &reqwest::blocking::Client, interactive: bool) -> Result<String, String> {
193
+ if let Ok(key) = env::var("DEEPSEEK_API_KEY") {
194
+ let key = key.trim().to_string();
195
+ if !key.is_empty() {
196
+ println!("using DEEPSEEK_API_KEY from the environment");
197
+ match verify_key(client, &key) {
198
+ Ok(()) => {
199
+ println!("API key verified.");
200
+ return Ok(key);
201
+ }
202
+ Err(err) => {
203
+ if !interactive {
204
+ return Err(format!("DEEPSEEK_API_KEY was rejected: {err}"));
205
+ }
206
+ eprintln!("DEEPSEEK_API_KEY was rejected: {err}");
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ if let Some(key) = load_saved_key()? {
213
+ println!("found a saved key in {}", config_path().display());
214
+ match verify_key(client, &key) {
215
+ Ok(()) => {
216
+ println!("API key verified.");
217
+ return Ok(key);
218
+ }
219
+ Err(err) => {
220
+ if !interactive {
221
+ return Err(format!("the saved API key was rejected: {err}"));
222
+ }
223
+ eprintln!("the saved API key was rejected: {err}");
224
+ }
225
+ }
226
+ }
227
+
228
+ if !interactive {
229
+ return Err(
230
+ "no API key available. Set DEEPSEEK_API_KEY, or run shev interactively once to save one."
231
+ .to_string(),
232
+ );
233
+ }
234
+
235
+ for attempt in 1..=3 {
236
+ let key = read_secret("Paste your DeepSeek API key (input is hidden): ")?;
237
+ if key.is_empty() {
238
+ println!("nothing entered, try again.");
239
+ continue;
240
+ }
241
+ match verify_key(client, &key) {
242
+ Ok(()) => {
243
+ save_key(&key)?;
244
+ println!("API key verified and saved to {}", config_path().display());
245
+ return Ok(key);
246
+ }
247
+ Err(err) => eprintln!("verification failed (attempt {attempt}/3): {err}"),
248
+ }
249
+ }
250
+
251
+ Err("could not verify an API key after 3 attempts".to_string())
252
+ }
253
+
254
+ /// Verification: ask the API who we are. A 200 means the key works.
255
+ pub fn verify_key(client: &reqwest::blocking::Client, key: &str) -> Result<(), String> {
256
+ let response = client
257
+ .get(format!("{API_BASE}/models"))
258
+ .bearer_auth(key)
259
+ .send()
260
+ .map_err(|e| format!("could not reach {API_BASE}: {e}"))?;
261
+
262
+ let status = response.status();
263
+ let body = response.text().unwrap_or_default();
264
+ if status.is_success() {
265
+ return Ok(());
266
+ }
267
+ Err(format!("HTTP {} {}", status.as_u16(), snippet(&body)))
268
+ }
269
+
270
+ pub fn config_path() -> PathBuf {
271
+ if let Some(home) = managed_home() {
272
+ return home.join("config/config.json");
273
+ }
274
+ let base = env::var_os("XDG_CONFIG_HOME")
275
+ .map(PathBuf::from)
276
+ .unwrap_or_else(|| home_dir().join(".config"));
277
+ base.join("shev").join("config.json")
278
+ }
279
+
280
+ fn home_dir() -> PathBuf {
281
+ env::var_os("HOME")
282
+ .map(PathBuf::from)
283
+ .unwrap_or_else(|| PathBuf::from("."))
284
+ }
285
+
286
+ /// Where the config lived before the rename to `shev`.
287
+ fn legacy_config_path() -> PathBuf {
288
+ let base = env::var_os("XDG_CONFIG_HOME")
289
+ .map(PathBuf::from)
290
+ .unwrap_or_else(|| home_dir().join(".config"));
291
+ base.join("deepslop").join("config.json")
292
+ }
293
+
294
+ /// What to read: the new path, or the pre-rename one when the new is absent.
295
+ /// Writes always go to [`config_path`], so the first save migrates the file.
296
+ fn read_config_path() -> PathBuf {
297
+ let path = config_path();
298
+ if path.exists() || managed_home().is_some() {
299
+ return path;
300
+ }
301
+ let legacy = legacy_config_path();
302
+ if legacy.exists() { legacy } else { path }
303
+ }
304
+
305
+ pub fn load_saved_key() -> Result<Option<String>, String> {
306
+ let path = read_config_path();
307
+ if !path.exists() {
308
+ return Ok(None);
309
+ }
310
+ let raw =
311
+ fs::read_to_string(&path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
312
+ let parsed: Value = serde_json::from_str(&raw)
313
+ .map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
314
+ let key = parsed
315
+ .get("api_key")
316
+ .and_then(Value::as_str)
317
+ .unwrap_or_default()
318
+ .trim()
319
+ .to_string();
320
+ Ok((!key.is_empty()).then_some(key))
321
+ }
322
+
323
+ pub fn save_key(key: &str) -> Result<(), String> {
324
+ let mut map = load_config_map();
325
+ map.insert("api_key".to_string(), Value::String(key.to_string()));
326
+ write_config_map(&map)
327
+ }
328
+
329
+ #[cfg(unix)]
330
+ fn restrict_permissions(path: &Path) {
331
+ use std::os::unix::fs::PermissionsExt;
332
+ let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
333
+ }
334
+
335
+ #[cfg(not(unix))]
336
+ fn restrict_permissions(_path: &Path) {}
337
+
338
+ /// Read a line from the terminal without echoing it back.
339
+ fn read_secret(prompt: &str) -> Result<String, String> {
340
+ print!("{prompt}");
341
+ io::stdout().flush().map_err(|e| e.to_string())?;
342
+
343
+ let _ = std::process::Command::new("stty").arg("-echo").status();
344
+ let mut line = String::new();
345
+ let read = io::stdin().read_line(&mut line);
346
+ let _ = std::process::Command::new("stty").arg("echo").status();
347
+ println!();
348
+
349
+ read.map_err(|e| format!("could not read the key: {e}"))?;
350
+ Ok(line.trim().to_string())
351
+ }
352
+
353
+ // ---------------------------------------------------------------------------
354
+ // the model call
355
+ // ---------------------------------------------------------------------------
356
+
357
+ pub fn http_client() -> Result<reqwest::blocking::Client, String> {
358
+ reqwest::blocking::Client::builder()
359
+ .timeout(Duration::from_secs(120))
360
+ .user_agent(concat!("shev/", env!("CARGO_PKG_VERSION")))
361
+ .build()
362
+ .map_err(|e| format!("could not build the HTTP client: {e}"))
363
+ }
364
+
365
+ /// Where a request goes: which endpoint, which model, which key.
366
+ pub struct Target {
367
+ pub provider: Provider,
368
+ pub chat_url: String,
369
+ pub models_url: String,
370
+ pub model: String,
371
+ pub key: String,
372
+ }
373
+
374
+ impl Target {
375
+ pub fn deepseek(key: &str) -> Self {
376
+ Self {
377
+ provider: Provider::DeepSeek,
378
+ chat_url: format!("{API_BASE}/chat/completions"),
379
+ models_url: format!("{API_BASE}/models"),
380
+ model: DEEPSEEK_MODEL.to_string(),
381
+ key: key.to_string(),
382
+ }
383
+ }
384
+
385
+ pub fn bonsai() -> Self {
386
+ Self {
387
+ provider: Provider::Bonsai,
388
+ // no network server: the local pack runs in a resident worker
389
+ chat_url: String::new(),
390
+ models_url: String::new(),
391
+ model: bonsai_dir().display().to_string(),
392
+ key: "none".to_string(),
393
+ }
394
+ }
395
+
396
+ pub fn is_local(&self) -> bool {
397
+ self.provider == Provider::Bonsai
398
+ }
399
+ }
400
+
401
+ /// Send one whole message plus the user's stated intent to DeepSeek and return
402
+ /// the validated decision object.
403
+ pub fn request_decision(
404
+ client: &reqwest::blocking::Client,
405
+ key: &str,
406
+ message: &str,
407
+ intent: &str,
408
+ ) -> Result<Value, String> {
409
+ request_decision_target(client, &Target::deepseek(key), message, intent)
410
+ }
411
+
412
+ /// The same, against whichever endpoint the target names.
413
+ pub fn request_decision_target(
414
+ client: &reqwest::blocking::Client,
415
+ target: &Target,
416
+ message: &str,
417
+ intent: &str,
418
+ ) -> Result<Value, String> {
419
+ let user_content = USER_TEMPLATE
420
+ .replace("%INTENT%", intent)
421
+ .replace("%MESSAGE%", message);
422
+
423
+ // the local pack has no server to post to: run its own loader for this one
424
+ // request and read the decision back out of the subprocess
425
+ if target.is_local() {
426
+ return request_decision_bonsai(&user_content);
427
+ }
428
+
429
+ let payload = json!({
430
+ "model": target.model,
431
+ "messages": [
432
+ { "role": "system", "content": SYSTEM_PROMPT },
433
+ { "role": "user", "content": user_content },
434
+ ],
435
+ "stream": false,
436
+ "response_format": { "type": "json_object" },
437
+ "temperature": 0.2,
438
+ "max_tokens": 2048,
439
+ });
440
+
441
+ let mut last_error = String::from("no attempt was made");
442
+
443
+ for attempt in 1..=3u32 {
444
+ let sent = client
445
+ .post(&target.chat_url)
446
+ .bearer_auth(&target.key)
447
+ .json(&payload)
448
+ .send();
449
+
450
+ match sent {
451
+ Ok(response) => {
452
+ let status = response.status();
453
+ let body = response.text().unwrap_or_default();
454
+
455
+ if status.is_success() {
456
+ let parsed: Value = serde_json::from_str(&body).map_err(|e| {
457
+ format!("{} returned unparseable JSON: {e}", target.provider.slug())
458
+ })?;
459
+ let content = parsed
460
+ .get("choices")
461
+ .and_then(|c| c.get(0))
462
+ .and_then(|c| c.get("message"))
463
+ .and_then(|m| m.get("content"))
464
+ .and_then(Value::as_str)
465
+ .ok_or_else(|| {
466
+ format!(
467
+ "{} response had no message content: {}",
468
+ target.provider.slug(),
469
+ snippet(&body)
470
+ )
471
+ })?;
472
+ let mut decision = parse_decision(content)?;
473
+ // carry the token accounting alongside the decision so the
474
+ // TUI can show a running total without a second request
475
+ if let Some(object) = decision.as_object_mut() {
476
+ object.insert("usage".to_string(), normalize_usage(parsed.get("usage")));
477
+ }
478
+ return Ok(decision);
479
+ }
480
+
481
+ if status.as_u16() == 401 || status.as_u16() == 403 {
482
+ return Err(format!(
483
+ "the API key was rejected (HTTP {}): {}",
484
+ status.as_u16(),
485
+ snippet(&body)
486
+ ));
487
+ }
488
+
489
+ last_error = format!("HTTP {} {}", status.as_u16(), snippet(&body));
490
+ if status.is_server_error() || status.as_u16() == 429 {
491
+ sleep(Duration::from_secs(u64::from(attempt) * 2));
492
+ continue;
493
+ }
494
+ return Err(last_error);
495
+ }
496
+ Err(err) => {
497
+ last_error = format!("request failed: {err}");
498
+ sleep(Duration::from_secs(u64::from(attempt) * 2));
499
+ }
500
+ }
501
+ }
502
+
503
+ Err(format!(
504
+ "giving up after 3 attempts, last error: {last_error}"
505
+ ))
506
+ }
507
+
508
+ /// Answer one decision with the local Bonsai pack. Prefers the warm engine: the
509
+ /// model is loaded once and kept resident, so every decision after the first
510
+ /// skips the reload that dominates the one-shot path. If the engine cannot
511
+ /// start, or dies mid-request, this falls back to the cold one-shot loader.
512
+ fn request_decision_bonsai(user_content: &str) -> Result<Value, String> {
513
+ local_decision_with_retry(user_content, |prompt| match warm_engine_decide(prompt) {
514
+ Ok(reply) => Ok(reply),
515
+ Err(warm) => Ok(LocalReply {
516
+ text: bonsai_oneshot(prompt).map_err(|cold| {
517
+ format!("local worker failed ({warm}); fallback failed ({cold})")
518
+ })?,
519
+ usage: json!({"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}),
520
+ }),
521
+ })
522
+ }
523
+
524
+ fn local_decision_with_retry(
525
+ user_content: &str,
526
+ mut run: impl FnMut(&str) -> Result<LocalReply, String>,
527
+ ) -> Result<Value, String> {
528
+ let mut prompt = format!("{LOCAL_SYSTEM_PROMPT}\n\n{user_content}");
529
+ let mut usage = json!({"prompt_tokens":0,"completion_tokens":0,"total_tokens":0});
530
+ let mut last_error = String::new();
531
+ for attempt in 0..3 {
532
+ let reply = run(&prompt)?;
533
+ for field in ["prompt_tokens", "completion_tokens", "total_tokens"] {
534
+ usage[field] = json!(
535
+ usage[field]
536
+ .as_u64()
537
+ .unwrap_or(0)
538
+ .saturating_add(reply.usage[field].as_u64().unwrap_or(0))
539
+ );
540
+ }
541
+ match parse_decision(&reply.text) {
542
+ Ok(mut decision) => {
543
+ decision["model"] = json!(BONSAI_REPO);
544
+ decision["usage"] = usage;
545
+ return Ok(decision);
546
+ }
547
+ Err(error) => last_error = error,
548
+ }
549
+ prompt.push_str(&format!("\nFormat correction {}: return strictly valid JSON. Include commas between all five fields and use double-quoted strings. Keep every string short so the complete object fits in the token budget.", attempt + 1));
550
+ }
551
+ Err(format!(
552
+ "local model returned an invalid decision after 3 attempts: {last_error}"
553
+ ))
554
+ }
555
+
556
+ /// The original cold path: run the pack's bundled loader in a subprocess for a
557
+ /// single request. Kept as the fallback for a machine where the resident engine
558
+ /// cannot start.
559
+ fn bonsai_oneshot(prompt: &str) -> Result<String, String> {
560
+ let argv = bonsai_run_command()?;
561
+ let (program, args) = argv
562
+ .split_first()
563
+ .ok_or_else(|| "empty command".to_string())?;
564
+
565
+ let mut child = std::process::Command::new(program)
566
+ .args(args)
567
+ .stdin(std::process::Stdio::piped())
568
+ .stdout(std::process::Stdio::piped())
569
+ .stderr(std::process::Stdio::piped())
570
+ .spawn()
571
+ .map_err(|e| format!("could not start the bonsai runner ({program}): {e}"))?;
572
+
573
+ if let Some(mut stdin) = child.stdin.take() {
574
+ stdin
575
+ .write_all(prompt.as_bytes())
576
+ .map_err(|e| format!("could not send the prompt to the bonsai runner: {e}"))?;
577
+ }
578
+
579
+ let output = child
580
+ .wait_with_output()
581
+ .map_err(|e| format!("the bonsai runner failed: {e}"))?;
582
+ if !output.status.success() {
583
+ return Err(format!(
584
+ "the bonsai runner exited ({}) — {}",
585
+ output.status,
586
+ snippet(&String::from_utf8_lossy(&output.stderr))
587
+ ));
588
+ }
589
+ Ok(String::from_utf8_lossy(&output.stdout).to_string())
590
+ }
591
+
592
+ // ---------------------------------------------------------------------------
593
+ // the warm local engine: one resident process, many decisions
594
+ // ---------------------------------------------------------------------------
595
+
596
+ /// A resident local engine: one Python process that holds the model in memory
597
+ /// and answers one JSON request per line. The point is the reload it does *not*
598
+ /// pay — the model load is the single biggest cost of a local decision.
599
+ struct LocalEngine {
600
+ child: std::process::Child,
601
+ stdin: std::process::ChildStdin,
602
+ stdout: io::BufReader<std::process::ChildStdout>,
603
+ }
604
+
605
+ struct LocalReply {
606
+ text: String,
607
+ usage: Value,
608
+ }
609
+
610
+ impl LocalEngine {
611
+ fn start() -> Result<Self, String> {
612
+ let argv = bonsai_worker_command()?;
613
+ let (program, args) = argv
614
+ .split_first()
615
+ .ok_or_else(|| "empty command".to_string())?;
616
+
617
+ let mut child = std::process::Command::new(program)
618
+ .args(args)
619
+ .stdin(std::process::Stdio::piped())
620
+ .stdout(std::process::Stdio::piped())
621
+ .stderr(std::process::Stdio::null())
622
+ .spawn()
623
+ .map_err(|e| format!("could not start the local engine ({program}): {e}"))?;
624
+
625
+ let stdin = child
626
+ .stdin
627
+ .take()
628
+ .ok_or_else(|| "the local engine had no stdin".to_string())?;
629
+ let stdout = child
630
+ .stdout
631
+ .take()
632
+ .ok_or_else(|| "the local engine had no stdout".to_string())?;
633
+
634
+ // the worker announces itself once the model is loaded
635
+ let mut stdout = io::BufReader::new(stdout);
636
+ let mut ready = String::new();
637
+ BufRead::read_line(&mut stdout, &mut ready)
638
+ .map_err(|e| format!("the local engine did not start: {e}"))?;
639
+ if !ready.contains("ready") {
640
+ let _ = child.kill();
641
+ return Err(format!(
642
+ "the local engine did not report ready: {}",
643
+ snippet(&ready)
644
+ ));
645
+ }
646
+
647
+ Ok(Self {
648
+ child,
649
+ stdin,
650
+ stdout,
651
+ })
652
+ }
653
+
654
+ fn decide(&mut self, prompt: &str) -> Result<LocalReply, String> {
655
+ let request = json!({ "prompt": prompt });
656
+ self.stdin
657
+ .write_all(request.to_string().as_bytes())
658
+ .and_then(|()| self.stdin.write_all(b"\n"))
659
+ .and_then(|()| self.stdin.flush())
660
+ .map_err(|e| format!("could not reach the local engine: {e}"))?;
661
+
662
+ loop {
663
+ let mut line = String::new();
664
+ let read = BufRead::read_line(&mut self.stdout, &mut line)
665
+ .map_err(|e| format!("could not read from the local engine: {e}"))?;
666
+ if read == 0 {
667
+ return Err("the local engine closed its output".to_string());
668
+ }
669
+ let line = line.trim();
670
+ if line.is_empty() {
671
+ continue; // a streaming delta with no payload
672
+ }
673
+ let value: Value = serde_json::from_str(line)
674
+ .map_err(|e| format!("the local engine sent bad JSON: {e}"))?;
675
+ if let Some(text) = value.get("text").and_then(Value::as_str) {
676
+ return Ok(LocalReply {
677
+ text: text.to_string(),
678
+ usage: value.get("usage").cloned().unwrap_or_else(
679
+ || json!({"prompt_tokens":0,"completion_tokens":0,"total_tokens":0}),
680
+ ),
681
+ });
682
+ }
683
+ if let Some(err) = value.get("error").and_then(Value::as_str) {
684
+ return Err(format!("the local engine failed: {err}"));
685
+ }
686
+ // otherwise it is a `delta` line: keep reading until `text`
687
+ }
688
+ }
689
+ }
690
+
691
+ impl Drop for LocalEngine {
692
+ fn drop(&mut self) {
693
+ // closing stdin ends the worker's loop; kill is the belt to that braces
694
+ let _ = self.child.kill();
695
+ let _ = self.child.wait();
696
+ }
697
+ }
698
+
699
+ /// The one resident engine, behind a lock. `None` until the first local request.
700
+ fn local_engine_slot() -> &'static Mutex<Option<LocalEngine>> {
701
+ static SLOT: OnceLock<Mutex<Option<LocalEngine>>> = OnceLock::new();
702
+ SLOT.get_or_init(|| Mutex::new(None))
703
+ }
704
+
705
+ /// Answer one prompt from the resident engine, starting it on first use and
706
+ /// restarting it once if it died.
707
+ fn warm_engine_decide(prompt: &str) -> Result<LocalReply, String> {
708
+ let slot = local_engine_slot();
709
+ let mut guard = slot
710
+ .lock()
711
+ .map_err(|_| "the local engine lock was poisoned".to_string())?;
712
+
713
+ if guard.is_none() {
714
+ *guard = Some(LocalEngine::start()?);
715
+ }
716
+
717
+ // drop the whole engine on failure so the retry gets a clean process
718
+ let first = guard
719
+ .as_mut()
720
+ .ok_or_else(|| "the local engine vanished".to_string())?
721
+ .decide(prompt);
722
+ match first {
723
+ Ok(text) => Ok(text),
724
+ Err(first_error) => {
725
+ *guard = None;
726
+ let mut fresh = LocalEngine::start()?;
727
+ let text = fresh.decide(prompt).map_err(|second| {
728
+ format!("the local engine failed twice ({first_error}; {second})")
729
+ })?;
730
+ *guard = Some(fresh);
731
+ Ok(text)
732
+ }
733
+ }
734
+ }
735
+
736
+ /// Normalise whatever the model said into the guaranteed fields.
737
+ pub fn parse_decision(raw: &str) -> Result<Value, String> {
738
+ let cleaned = strip_code_fences(raw);
739
+ let parsed: Value =
740
+ serde_json::from_str(&cleaned).map_err(|e| format!("model reply was not JSON: {e}"))?;
741
+
742
+ if !parsed.is_object() {
743
+ return Err("model reply was not a JSON object".to_string());
744
+ }
745
+
746
+ let actionable = match lookup(&parsed, "actionable") {
747
+ Some(Value::String(s)) => s.trim().to_string(),
748
+ Some(Value::Null) | None => String::new(),
749
+ Some(other) => other.to_string(),
750
+ };
751
+
752
+ let take_action = match lookup(&parsed, "take_action") {
753
+ Some(Value::Bool(b)) => *b,
754
+ Some(Value::String(s)) => match s.trim().to_ascii_lowercase().as_str() {
755
+ "true" | "yes" | "y" | "1" => true,
756
+ "false" | "no" | "n" | "0" | "" => false,
757
+ other => return Err(format!("take_action was not a boolean: {other:?}")),
758
+ },
759
+ Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false),
760
+ Some(other) => return Err(format!("take_action had an unsupported type: {other}")),
761
+ None => return Err("the model reply is missing the key \"take_action\"".to_string()),
762
+ };
763
+
764
+ let raw_confidence = match lookup(&parsed, "confidence") {
765
+ Some(Value::Number(n)) => n.as_f64(),
766
+ Some(Value::String(s)) => s.trim().parse::<f64>().ok(),
767
+ Some(other) => return Err(format!("confidence had an unsupported type: {other}")),
768
+ None => return Err("the model reply is missing the key \"confidence\"".to_string()),
769
+ }
770
+ .ok_or_else(|| "confidence was not a number".to_string())?;
771
+
772
+ // 34 < x < 100 "including both" -> a closed range of [34, 100].
773
+ let rounded = raw_confidence.round() as i64;
774
+ let confidence = rounded.clamp(MIN_CONFIDENCE, MAX_CONFIDENCE);
775
+ if confidence != rounded {
776
+ eprintln!(
777
+ "note: model returned confidence {raw_confidence}, clamped into [{MIN_CONFIDENCE}, {MAX_CONFIDENCE}]"
778
+ );
779
+ }
780
+
781
+ // The deliverable. Tolerate the model answering as a list of steps.
782
+ let generated_response =
783
+ match lookup(&parsed, "generated_response").or_else(|| lookup(&parsed, "response")) {
784
+ Some(Value::String(s)) => s.trim().to_string(),
785
+ Some(Value::Array(items)) => items
786
+ .iter()
787
+ .map(|item| match item {
788
+ Value::String(s) => s.trim().to_string(),
789
+ other => other.to_string(),
790
+ })
791
+ .filter(|item| !item.is_empty())
792
+ .collect::<Vec<_>>()
793
+ .join("\n"),
794
+ Some(Value::Null) | None => String::new(),
795
+ Some(other) => other.to_string(),
796
+ };
797
+
798
+ let summary = match lookup(&parsed, "summary") {
799
+ Some(Value::String(s)) => clamp_summary(s),
800
+ Some(Value::Array(items)) => clamp_summary(
801
+ &items
802
+ .iter()
803
+ .map(|item| match item {
804
+ Value::String(s) => s.trim().to_string(),
805
+ other => other.to_string(),
806
+ })
807
+ .collect::<Vec<_>>()
808
+ .join(" "),
809
+ ),
810
+ Some(Value::Null) | None => String::new(),
811
+ Some(other) => clamp_summary(&other.to_string()),
812
+ };
813
+
814
+ Ok(json!({
815
+ "actionable": actionable,
816
+ "take_action": take_action,
817
+ "confidence": confidence,
818
+ "summary": summary,
819
+ "generated_response": generated_response,
820
+ }))
821
+ }
822
+
823
+ /// Keep a summary inside three sentences and 120 words, whatever the model sent.
824
+ pub fn clamp_summary(text: &str) -> String {
825
+ let text = text.trim();
826
+ if text.is_empty() {
827
+ return String::new();
828
+ }
829
+
830
+ let mut sentences: Vec<String> = Vec::new();
831
+ let mut current = String::new();
832
+ for ch in text.chars() {
833
+ current.push(ch);
834
+ if matches!(ch, '.' | '!' | '?') {
835
+ let trimmed = current.trim();
836
+ if !trimmed.is_empty() {
837
+ sentences.push(trimmed.to_string());
838
+ }
839
+ current.clear();
840
+ if sentences.len() == MAX_SUMMARY_SENTENCES {
841
+ break;
842
+ }
843
+ }
844
+ }
845
+ if sentences.len() < MAX_SUMMARY_SENTENCES {
846
+ let tail = current.trim();
847
+ if !tail.is_empty() {
848
+ sentences.push(tail.to_string());
849
+ }
850
+ }
851
+
852
+ let mut out = sentences.join(" ");
853
+ let words: Vec<&str> = out.split_whitespace().collect();
854
+ if words.len() > MAX_SUMMARY_WORDS {
855
+ out = words[..MAX_SUMMARY_WORDS].join(" ");
856
+ out.push('…');
857
+ }
858
+ out
859
+ }
860
+
861
+ /// Reduce whatever `usage` the API sent into the three integers we care about.
862
+ fn normalize_usage(usage: Option<&Value>) -> Value {
863
+ let number = |key: &str| -> u64 {
864
+ usage
865
+ .and_then(|u| lookup(u, key))
866
+ .and_then(Value::as_u64)
867
+ .unwrap_or(0)
868
+ };
869
+ let prompt = number("prompt_tokens");
870
+ let completion = number("completion_tokens");
871
+ let total = match usage
872
+ .and_then(|u| lookup(u, "total_tokens"))
873
+ .and_then(Value::as_u64)
874
+ {
875
+ Some(total) if total > 0 => total,
876
+ _ => prompt + completion,
877
+ };
878
+ json!({
879
+ "prompt_tokens": prompt,
880
+ "completion_tokens": completion,
881
+ "total_tokens": total,
882
+ })
883
+ }
884
+
885
+ /// Total tokens a stored record accounts for, for the running counter.
886
+ pub fn record_tokens(record: &Value) -> u64 {
887
+ let usage = record.get("usage");
888
+ let number = |key: &str| {
889
+ usage
890
+ .and_then(|u| u.get(key))
891
+ .and_then(Value::as_u64)
892
+ .unwrap_or(0)
893
+ };
894
+ let total = number("total_tokens");
895
+ if total > 0 {
896
+ total
897
+ } else {
898
+ number("prompt_tokens") + number("completion_tokens")
899
+ }
900
+ }
901
+
902
+ /// `100000` reads as `100k`, `1500` as `1.5k`, `2_400_000` as `2.4M`.
903
+ pub fn format_tokens(tokens: u64) -> String {
904
+ if tokens < 1_000 {
905
+ return tokens.to_string();
906
+ }
907
+ if tokens < 1_000_000 {
908
+ let thousands = tokens as f64 / 1_000.0;
909
+ if thousands >= 100.0 {
910
+ format!("{}k", thousands.round() as u64)
911
+ } else if thousands >= 10.0 {
912
+ format!("{thousands:.0}k")
913
+ } else {
914
+ format!("{thousands:.1}k")
915
+ }
916
+ } else {
917
+ format!("{:.1}M", tokens as f64 / 1_000_000.0)
918
+ }
919
+ }
920
+
921
+ fn lookup<'a>(object: &'a Value, key: &str) -> Option<&'a Value> {
922
+ let map = object.as_object()?;
923
+ if let Some(value) = map.get(key) {
924
+ return Some(value);
925
+ }
926
+ map.iter()
927
+ .find(|(k, _)| k.eq_ignore_ascii_case(key))
928
+ .map(|(_, v)| v)
929
+ }
930
+
931
+ fn strip_code_fences(raw: &str) -> String {
932
+ let trimmed = raw.trim();
933
+ let without_open = match trimmed.strip_prefix("```") {
934
+ Some(rest) => match rest.find('\n') {
935
+ Some(newline) => &rest[newline + 1..],
936
+ None => rest,
937
+ },
938
+ None => trimmed,
939
+ };
940
+ let without_close = without_open
941
+ .strip_suffix("```")
942
+ .unwrap_or(without_open)
943
+ .trim();
944
+ without_close.to_string()
945
+ }
946
+
947
+ fn snippet(body: &str) -> String {
948
+ let one_line = body.split_whitespace().collect::<Vec<_>>().join(" ");
949
+ let mut out: String = one_line.chars().take(300).collect();
950
+ if one_line.chars().count() > 300 {
951
+ out.push('…');
952
+ }
953
+ if out.is_empty() {
954
+ out.push_str("(empty response)");
955
+ }
956
+ out
957
+ }
958
+
959
+ // ---------------------------------------------------------------------------
960
+ // recording decisions: {"response1": {..}, "response2": {..}}
961
+ // ---------------------------------------------------------------------------
962
+
963
+ /// The record that gets written for one decision, minus its `responseN` key.
964
+ pub fn build_record(decision: &Value, message: &str, intent: &str) -> Value {
965
+ json!({
966
+ "actionable": decision.get("actionable").cloned().unwrap_or(Value::String(String::new())),
967
+ "take_action": decision.get("take_action").cloned().unwrap_or(Value::Bool(false)),
968
+ "confidence": decision.get("confidence").cloned().unwrap_or(json!(MIN_CONFIDENCE)),
969
+ "summary": decision.get("summary").cloned().unwrap_or(Value::String(String::new())),
970
+ "generated_response": decision.get("generated_response").cloned().unwrap_or(Value::String(String::new())),
971
+ "usage": decision.get("usage").cloned().unwrap_or(Value::Null),
972
+ "user_intent": intent,
973
+ "input": message,
974
+ "model": decision.get("model").and_then(Value::as_str).unwrap_or(MODEL),
975
+ "created_at": unix_timestamp(),
976
+ })
977
+ }
978
+
979
+ /// Append one record to the responses file under the next free `responseN` key.
980
+ /// Returns the key it was filed under. Prints nothing, so a TUI can call it.
981
+ pub fn append_record(path: &Path, record: &Value) -> Result<String, String> {
982
+ let mut root: Map<String, Value> = if path.exists() {
983
+ let raw = fs::read_to_string(path)
984
+ .map_err(|e| format!("could not read {}: {e}", path.display()))?;
985
+ if raw.trim().is_empty() {
986
+ Map::new()
987
+ } else {
988
+ match serde_json::from_str::<Value>(&raw) {
989
+ Ok(Value::Object(map)) => map,
990
+ Ok(_) => {
991
+ return Err(format!(
992
+ "{} does not contain a JSON object, refusing to overwrite it",
993
+ path.display()
994
+ ));
995
+ }
996
+ Err(e) => {
997
+ return Err(format!(
998
+ "{} is not valid JSON ({e}), refusing to overwrite it",
999
+ path.display()
1000
+ ));
1001
+ }
1002
+ }
1003
+ }
1004
+ } else {
1005
+ Map::new()
1006
+ };
1007
+
1008
+ let next_index = root
1009
+ .keys()
1010
+ .filter_map(|key| key.strip_prefix("response"))
1011
+ .filter_map(|index| index.parse::<u64>().ok())
1012
+ .max()
1013
+ .unwrap_or(0)
1014
+ + 1;
1015
+ let key = format!("response{next_index}");
1016
+
1017
+ root.insert(key.clone(), record.clone());
1018
+
1019
+ if let Some(parent) = path.parent()
1020
+ && !parent.as_os_str().is_empty()
1021
+ {
1022
+ fs::create_dir_all(parent)
1023
+ .map_err(|e| format!("could not create {}: {e}", parent.display()))?;
1024
+ }
1025
+ let body = serde_json::to_string_pretty(&Value::Object(root))
1026
+ .map_err(|e| format!("could not encode the responses file: {e}"))?;
1027
+ fs::write(path, format!("{body}\n"))
1028
+ .map_err(|e| format!("could not write {}: {e}", path.display()))?;
1029
+
1030
+ Ok(key)
1031
+ }
1032
+
1033
+ /// Delete only the chosen saved history record. Never renumbers other records.
1034
+ pub fn delete_record(path: &Path, key: &str) -> Result<bool, String> {
1035
+ if !path.exists() {
1036
+ return Ok(false);
1037
+ }
1038
+ let mut records = history_map(path)?;
1039
+ if records.remove(key).is_none() {
1040
+ return Ok(false);
1041
+ }
1042
+ write_history(path, &records)?;
1043
+ Ok(true)
1044
+ }
1045
+
1046
+ /// Clear saved chat data, not configuration, credentials, or downloaded models.
1047
+ pub fn clear_records(path: &Path) -> Result<(), String> {
1048
+ if !path.exists() {
1049
+ return Ok(());
1050
+ }
1051
+ let mut records = history_map(path)?; // Refuse malformed files; preserve non-chat metadata.
1052
+ records.retain(|key, _| {
1053
+ key.strip_prefix("response")
1054
+ .and_then(|n| n.parse::<u64>().ok())
1055
+ .is_none()
1056
+ });
1057
+ write_history(path, &records)
1058
+ }
1059
+
1060
+ fn history_map(path: &Path) -> Result<Map<String, Value>, String> {
1061
+ let raw = fs::read_to_string(path).map_err(|e| format!("could not read history: {e}"))?;
1062
+ if raw.trim().is_empty() {
1063
+ return Ok(Map::new());
1064
+ }
1065
+ serde_json::from_str::<Value>(&raw)
1066
+ .ok()
1067
+ .and_then(|value| value.as_object().cloned())
1068
+ .ok_or_else(|| "history is not a valid JSON object; left unchanged".into())
1069
+ }
1070
+
1071
+ fn write_history(path: &Path, records: &Map<String, Value>) -> Result<(), String> {
1072
+ let resolved =
1073
+ fs::canonicalize(path).map_err(|e| format!("could not resolve history file: {e}"))?;
1074
+ let path = resolved.as_path(); // Update the target, not an existing user symlink.
1075
+ let parent = path
1076
+ .parent()
1077
+ .filter(|p| !p.as_os_str().is_empty())
1078
+ .unwrap_or(Path::new("."));
1079
+ let stamp = SystemTime::now()
1080
+ .duration_since(UNIX_EPOCH)
1081
+ .unwrap_or_default()
1082
+ .as_nanos();
1083
+ let temporary = parent.join(format!(".shev-history-{}-{stamp}.tmp", std::process::id()));
1084
+ let mut created = false;
1085
+ let result = (|| {
1086
+ let mut file = fs::OpenOptions::new()
1087
+ .write(true)
1088
+ .create_new(true)
1089
+ .open(&temporary)
1090
+ .map_err(|e| format!("could not prepare history update: {e}"))?;
1091
+ created = true;
1092
+ if let Ok(metadata) = fs::metadata(path) {
1093
+ fs::set_permissions(&temporary, metadata.permissions()).map_err(|e| e.to_string())?;
1094
+ }
1095
+ let body = serde_json::to_string_pretty(records).map_err(|e| e.to_string())?;
1096
+ writeln!(file, "{body}")
1097
+ .and_then(|()| file.sync_all())
1098
+ .map_err(|e| format!("could not write history: {e}"))?;
1099
+ fs::rename(&temporary, path).map_err(|e| format!("could not replace history: {e}"))
1100
+ })();
1101
+ if result.is_err() && created {
1102
+ let _ = fs::remove_file(&temporary);
1103
+ }
1104
+ result
1105
+ }
1106
+
1107
+ pub fn store_decision(
1108
+ path: &Path,
1109
+ decision: &Value,
1110
+ message: &str,
1111
+ intent: &str,
1112
+ ) -> Result<(), String> {
1113
+ let record = build_record(decision, message, intent);
1114
+ let key = append_record(path, &record)?;
1115
+
1116
+ println!("\n--- {key} ---");
1117
+ let summary = record["summary"].as_str().unwrap_or_default();
1118
+ if !summary.is_empty() {
1119
+ println!("summary: {summary}");
1120
+ }
1121
+ let generated = record["generated_response"].as_str().unwrap_or_default();
1122
+ if generated.is_empty() {
1123
+ println!("generated_response: (empty — nothing to send)");
1124
+ } else {
1125
+ println!("generated_response:\n{generated}");
1126
+ }
1127
+ let tokens = record_tokens(&record);
1128
+ if tokens > 0 {
1129
+ println!("\ntokens: {} ({tokens})", format_tokens(tokens));
1130
+ }
1131
+ println!(
1132
+ "\njson:\n{}",
1133
+ serde_json::to_string_pretty(&record).unwrap()
1134
+ );
1135
+ println!("appended to {}\n", path.display());
1136
+ Ok(())
1137
+ }
1138
+
1139
+ /// Put text on the system clipboard. Falls back to the OSC 52 escape sequence,
1140
+ /// which works in terminals that allow it even without a clipboard utility.
1141
+ pub fn copy_to_clipboard(text: &str) -> Result<(), String> {
1142
+ use std::process::{Command, Stdio};
1143
+
1144
+ let candidates: [&[&str]; 4] = [
1145
+ &["pbcopy"],
1146
+ &["wl-copy"],
1147
+ &["xclip", "-selection", "clipboard"],
1148
+ &["termux-clipboard-set"],
1149
+ ];
1150
+
1151
+ let mut attempted = Vec::new();
1152
+ for command in candidates {
1153
+ let spawned = Command::new(command[0])
1154
+ .args(&command[1..])
1155
+ .stdin(Stdio::piped())
1156
+ .stdout(Stdio::null())
1157
+ .stderr(Stdio::null())
1158
+ .spawn();
1159
+
1160
+ let Ok(mut child) = spawned else {
1161
+ attempted.push(command[0]);
1162
+ continue;
1163
+ };
1164
+ let wrote = child
1165
+ .stdin
1166
+ .as_mut()
1167
+ .map(|stdin| stdin.write_all(text.as_bytes()).is_ok())
1168
+ .unwrap_or(false);
1169
+ if wrote && child.wait().map(|status| status.success()).unwrap_or(false) {
1170
+ return Ok(());
1171
+ }
1172
+ attempted.push(command[0]);
1173
+ }
1174
+
1175
+ // Last resort: OSC 52. Terminals that support it put the text on the
1176
+ // clipboard; the ones that do not simply ignore the sequence.
1177
+ let encoded = base64_encode(text.as_bytes());
1178
+ let sequence = format!("\x1b]52;c;{encoded}\x07");
1179
+ let mut stdout = io::stdout();
1180
+ stdout
1181
+ .write_all(sequence.as_bytes())
1182
+ .and_then(|_| stdout.flush())
1183
+ .map_err(|e| format!("could not reach a clipboard (tried {attempted:?}): {e}"))
1184
+ }
1185
+
1186
+ fn base64_encode(input: &[u8]) -> String {
1187
+ const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
1188
+ let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
1189
+ for chunk in input.chunks(3) {
1190
+ let first = chunk[0] as u32;
1191
+ let second = *chunk.get(1).unwrap_or(&0) as u32;
1192
+ let third = *chunk.get(2).unwrap_or(&0) as u32;
1193
+ let triple = (first << 16) | (second << 8) | third;
1194
+ out.push(TABLE[(triple >> 18) as usize & 0x3f] as char);
1195
+ out.push(TABLE[(triple >> 12) as usize & 0x3f] as char);
1196
+ out.push(if chunk.len() > 1 {
1197
+ TABLE[(triple >> 6) as usize & 0x3f] as char
1198
+ } else {
1199
+ '='
1200
+ });
1201
+ out.push(if chunk.len() > 2 {
1202
+ TABLE[triple as usize & 0x3f] as char
1203
+ } else {
1204
+ '='
1205
+ });
1206
+ }
1207
+ out
1208
+ }
1209
+
1210
+ /// The current git branch of `directory`, if it is inside a repository.
1211
+ pub fn git_branch(directory: &Path) -> Option<String> {
1212
+ let output = std::process::Command::new("git")
1213
+ .args([
1214
+ "-C",
1215
+ &directory.to_string_lossy(),
1216
+ "rev-parse",
1217
+ "--abbrev-ref",
1218
+ "HEAD",
1219
+ ])
1220
+ .output()
1221
+ .ok()?;
1222
+ if !output.status.success() {
1223
+ return None;
1224
+ }
1225
+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
1226
+ (!branch.is_empty()).then_some(branch)
1227
+ }
1228
+
1229
+ pub fn unix_timestamp() -> u64 {
1230
+ SystemTime::now()
1231
+ .duration_since(UNIX_EPOCH)
1232
+ .map(|d| d.as_secs())
1233
+ .unwrap_or(0)
1234
+ }
1235
+
1236
+ // ---------------------------------------------------------------------------
1237
+ // input
1238
+ // ---------------------------------------------------------------------------
1239
+
1240
+ /// Read one whole message. Everything is kept verbatim, including blank lines
1241
+ /// between paragraphs. A line containing only `EOF` ends it, as does Ctrl-D, so
1242
+ /// the message is judged as one unit rather than one paragraph at a time.
1243
+ fn read_message(stdin: &mut impl BufRead) -> Result<Option<String>, String> {
1244
+ println!("Paste the message (a line with only EOF ends it, Ctrl-D works too):");
1245
+ let mut buffer = String::new();
1246
+
1247
+ loop {
1248
+ let mut line = String::new();
1249
+ let read = stdin
1250
+ .read_line(&mut line)
1251
+ .map_err(|e| format!("could not read input: {e}"))?;
1252
+ if read == 0 {
1253
+ break; // EOF from the terminal
1254
+ }
1255
+ let line = line.trim_end_matches(['\n', '\r']);
1256
+
1257
+ if buffer.is_empty() && matches!(line.trim(), "exit" | "quit" | ":q") {
1258
+ return Ok(None);
1259
+ }
1260
+ if line.trim().eq_ignore_ascii_case("eof") {
1261
+ break;
1262
+ }
1263
+ buffer.push_str(line);
1264
+ buffer.push('\n');
1265
+ }
1266
+
1267
+ let trimmed = buffer.trim();
1268
+ if trimmed.is_empty() {
1269
+ return Ok(None);
1270
+ }
1271
+ Ok(Some(trimmed.to_string()))
1272
+ }
1273
+
1274
+ /// Ask the user what they want to do with the message. This is what the model
1275
+ /// writes its response against.
1276
+ fn read_intent(stdin: &mut impl BufRead) -> Result<Option<String>, String> {
1277
+ println!("What do you want to do with it? (press Enter for the default)");
1278
+ let mut line = String::new();
1279
+ let read = stdin
1280
+ .read_line(&mut line)
1281
+ .map_err(|e| format!("could not read input: {e}"))?;
1282
+ if read == 0 {
1283
+ return Ok(None); // EOF
1284
+ }
1285
+ let intent = line.trim();
1286
+ let intent = if intent.is_empty() {
1287
+ DEFAULT_INTENT.to_string()
1288
+ } else {
1289
+ intent.to_string()
1290
+ };
1291
+ println!("intent: {intent}\n");
1292
+ Ok(Some(intent))
1293
+ }
1294
+
1295
+ // ---------------------------------------------------------------------------
1296
+ // cli
1297
+ // ---------------------------------------------------------------------------
1298
+
1299
+ struct Options {
1300
+ responses: PathBuf,
1301
+ once: Option<String>,
1302
+ intent: Option<String>,
1303
+ mcp: bool,
1304
+ }
1305
+
1306
+ fn parse_args() -> Result<Option<Options>, String> {
1307
+ let mut responses = env::var_os("SHEV_RESPONSES_FILE")
1308
+ .map(PathBuf::from)
1309
+ .unwrap_or_else(default_responses_path);
1310
+ let mut once: Option<String> = None;
1311
+ let mut intent: Option<String> = env::var("SHEV_INTENT").ok();
1312
+ let mut mcp = false;
1313
+ let mut args = env::args().skip(1);
1314
+
1315
+ while let Some(arg) = args.next() {
1316
+ match arg.as_str() {
1317
+ "-h" | "--help" => {
1318
+ print_help();
1319
+ return Ok(None);
1320
+ }
1321
+ "--mcp" => mcp = true,
1322
+ "-f" | "--file" => {
1323
+ let value = args
1324
+ .next()
1325
+ .ok_or_else(|| format!("{arg} needs a path argument"))?;
1326
+ responses = PathBuf::from(value);
1327
+ }
1328
+ "-c" | "--once" | "--message" => {
1329
+ let value = args
1330
+ .next()
1331
+ .ok_or_else(|| format!("{arg} needs a message argument"))?;
1332
+ once = Some(value);
1333
+ }
1334
+ "-i" | "--intent" => {
1335
+ let value = args
1336
+ .next()
1337
+ .ok_or_else(|| format!("{arg} needs an intent argument"))?;
1338
+ intent = Some(value);
1339
+ }
1340
+ other if other.starts_with('-') => {
1341
+ return Err(format!("unknown option {other} (try --help)"));
1342
+ }
1343
+ other => once = Some(other.to_string()),
1344
+ }
1345
+ }
1346
+
1347
+ Ok(Some(Options {
1348
+ responses,
1349
+ once,
1350
+ intent,
1351
+ mcp,
1352
+ }))
1353
+ }
1354
+
1355
+ fn print_help() {
1356
+ println!(
1357
+ "shev — hand a whole message and your intent to Bonsai (default) or DeepSeek, get back a decision and a response\n\n\
1358
+ USAGE:\n\
1359
+ \x20 shev interactive loop\n\
1360
+ \x20 shev \"some message\" judge one message and exit\n\
1361
+ \x20 echo \"some message\" | shev judge one piped message and exit\n\n\
1362
+ \x20 shev --mcp serve the decisions over MCP (stdio)\n\n\
1363
+ OPTIONS:\n\
1364
+ \x20 -f, --file <path> where decisions are appended (default {DEFAULT_RESPONSES_FILE})\n\
1365
+ \x20 -c, --once <message> judge one message and exit\n\
1366
+ \x20 -i, --intent <text> what you want to do with it\n\
1367
+ \x20 --mcp speak MCP on stdin/stdout instead of the CLI\n\
1368
+ \x20 -h, --help show this help\n\n\
1369
+ ENVIRONMENT:\n\
1370
+ \x20 DEEPSEEK_API_KEY api key, used before the saved config\n\
1371
+ \x20 SHEV_RESPONSES_FILE same as --file\n\
1372
+ \x20 SHEV_INTENT same as --intent\n\n\
1373
+ Decisions look like:\n\
1374
+ \x20 {{ \"actionable\": \"...\", \"take_action\": true, \"confidence\": 87,\n\
1375
+ \x20 \"generated_response\": \"...\" }}"
1376
+ );
1377
+ println!("Maintenance: --version · --uninstall [--dry-run] [--yes]");
1378
+ }
1379
+
1380
+ // ---------------------------------------------------------------------------
1381
+ // skills, plugins and tools: reuse what Codex and OpenCode already have
1382
+ // ---------------------------------------------------------------------------
1383
+
1384
+ /// One thing shev can load as extra guidance: a skill, a plugin, or an MCP tool
1385
+ /// that is already installed for Codex or OpenCode. shev only reads these; it
1386
+ /// never edits them.
1387
+ #[derive(Clone, Debug, PartialEq, Eq)]
1388
+ pub struct Capability {
1389
+ /// "skill", "plugin" or "tool".
1390
+ pub kind: String,
1391
+ pub name: String,
1392
+ pub description: String,
1393
+ /// The file holding the guidance (a SKILL.md), when there is one.
1394
+ pub path: Option<PathBuf>,
1395
+ /// Where it came from, for the label.
1396
+ pub source: String,
1397
+ }
1398
+
1399
+ /// The folders Codex and OpenCode keep their skills in. Only the ones that
1400
+ /// exist are returned, so a machine with none simply discovers nothing.
1401
+ pub fn capability_roots() -> Vec<(String, PathBuf)> {
1402
+ let home = home_dir();
1403
+ let config = env::var_os("XDG_CONFIG_HOME")
1404
+ .map(PathBuf::from)
1405
+ .unwrap_or_else(|| home.join(".config"));
1406
+ let codex = env::var_os("CODEX_HOME")
1407
+ .map(PathBuf::from)
1408
+ .unwrap_or_else(|| home.join(".codex"));
1409
+ let mut candidates = vec![
1410
+ ("opencode", config.join("opencode/skills")),
1411
+ ("opencode", config.join("opencode/skill")),
1412
+ ("claude", home.join(".claude/skills")),
1413
+ ("codex", codex.join("skills")),
1414
+ ("shared", home.join(".agents/skills")),
1415
+ ("shared", home.join("skills")),
1416
+ ];
1417
+ if let Ok(cwd) = env::current_dir() {
1418
+ for ancestor in cwd.ancestors() {
1419
+ for relative in [
1420
+ ".opencode/skills",
1421
+ ".opencode/skill",
1422
+ ".agents/skills",
1423
+ ".codex/skills",
1424
+ ] {
1425
+ candidates.push(("project", ancestor.join(relative)));
1426
+ }
1427
+ }
1428
+ }
1429
+ for config_file in opencode_configs()
1430
+ .into_iter()
1431
+ .chain(opencode_project_configs())
1432
+ {
1433
+ let Some(value) = fs::read_to_string(&config_file)
1434
+ .ok()
1435
+ .and_then(|text| parse_jsonc(&text))
1436
+ else {
1437
+ continue;
1438
+ };
1439
+ let Some(skills) = value.get("skills") else {
1440
+ continue;
1441
+ };
1442
+ if let Some(paths) = skills
1443
+ .as_array()
1444
+ .or_else(|| skills.get("paths").and_then(Value::as_array))
1445
+ {
1446
+ for path in paths.iter().filter_map(Value::as_str) {
1447
+ if path.contains("://") {
1448
+ continue;
1449
+ } // Never download instructions during discovery.
1450
+ let path = if let Some(relative) = path.strip_prefix("~/") {
1451
+ home.join(relative)
1452
+ } else {
1453
+ let path = PathBuf::from(path);
1454
+ if path.is_absolute() {
1455
+ path
1456
+ } else {
1457
+ config_file.parent().unwrap_or(Path::new(".")).join(path)
1458
+ }
1459
+ };
1460
+ candidates.push(("configured", path));
1461
+ }
1462
+ }
1463
+ }
1464
+ candidates
1465
+ .into_iter()
1466
+ .filter(|(_, path)| path.is_dir())
1467
+ .map(|(source, path)| (source.to_string(), path))
1468
+ .collect()
1469
+ }
1470
+
1471
+ /// Read `name:` and `description:` out of a SKILL.md front matter block.
1472
+ fn parse_front_matter(text: &str) -> (Option<String>, Option<String>) {
1473
+ let mut name = None;
1474
+ let mut description = None;
1475
+ let mut lines = text.lines();
1476
+ if lines.next().map(str::trim) != Some("---") {
1477
+ return (None, None);
1478
+ }
1479
+ for line in lines {
1480
+ let line = line.trim();
1481
+ if line == "---" {
1482
+ break;
1483
+ }
1484
+ let unquote = |value: &str| {
1485
+ value
1486
+ .trim()
1487
+ .trim_matches('"')
1488
+ .trim_matches('\'')
1489
+ .to_string()
1490
+ };
1491
+ if let Some(value) = line.strip_prefix("name:") {
1492
+ name = Some(unquote(value));
1493
+ } else if let Some(value) = line.strip_prefix("description:") {
1494
+ description = Some(unquote(value));
1495
+ }
1496
+ }
1497
+ (name, description)
1498
+ }
1499
+
1500
+ /// Strip a leading `---` front matter block, leaving the body.
1501
+ pub fn strip_front_matter(text: &str) -> String {
1502
+ let mut in_body = false;
1503
+ let mut started = false;
1504
+ let mut body = String::new();
1505
+ for (index, line) in text.lines().enumerate() {
1506
+ if !started {
1507
+ if index == 0 && line.trim() == "---" {
1508
+ started = true;
1509
+ continue;
1510
+ }
1511
+ break;
1512
+ }
1513
+ if !in_body {
1514
+ if line.trim() == "---" {
1515
+ in_body = true;
1516
+ }
1517
+ continue;
1518
+ }
1519
+ body.push_str(line);
1520
+ body.push('\n');
1521
+ }
1522
+ if in_body {
1523
+ body.trim_start_matches('\n').to_string()
1524
+ } else {
1525
+ text.to_string()
1526
+ }
1527
+ }
1528
+
1529
+ /// Best-effort parse of JSON with `//` and `/* */` comments and trailing commas,
1530
+ /// which is how OpenCode writes its config. Strict JSON is a subset, so this
1531
+ /// reads both. Read-only and tolerant: returns `None` rather than failing a scan.
1532
+ fn parse_jsonc(text: &str) -> Option<Value> {
1533
+ let chars: Vec<char> = text.chars().collect();
1534
+ let mut stripped = String::with_capacity(text.len());
1535
+ let mut i = 0;
1536
+ let mut in_string = false;
1537
+ let mut escaped = false;
1538
+ while i < chars.len() {
1539
+ let ch = chars[i];
1540
+ if in_string {
1541
+ stripped.push(ch);
1542
+ if escaped {
1543
+ escaped = false;
1544
+ } else if ch == '\\' {
1545
+ escaped = true;
1546
+ } else if ch == '"' {
1547
+ in_string = false;
1548
+ }
1549
+ i += 1;
1550
+ continue;
1551
+ }
1552
+ if ch == '"' {
1553
+ in_string = true;
1554
+ stripped.push(ch);
1555
+ i += 1;
1556
+ continue;
1557
+ }
1558
+ if ch == '/' && chars.get(i + 1) == Some(&'/') {
1559
+ while i < chars.len() && chars[i] != '\n' {
1560
+ i += 1;
1561
+ }
1562
+ continue;
1563
+ }
1564
+ if ch == '/' && chars.get(i + 1) == Some(&'*') {
1565
+ i += 2;
1566
+ while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') {
1567
+ i += 1;
1568
+ }
1569
+ i = (i + 2).min(chars.len());
1570
+ continue;
1571
+ }
1572
+ stripped.push(ch);
1573
+ i += 1;
1574
+ }
1575
+
1576
+ // drop the commas that the removed comments left dangling before a closer
1577
+ let mut cleaned = String::with_capacity(stripped.len());
1578
+ let chars: Vec<char> = stripped.chars().collect();
1579
+ let mut i = 0;
1580
+ let mut in_string = false;
1581
+ let mut escaped = false;
1582
+ while i < chars.len() {
1583
+ let ch = chars[i];
1584
+ if in_string {
1585
+ cleaned.push(ch);
1586
+ if escaped {
1587
+ escaped = false;
1588
+ } else if ch == '\\' {
1589
+ escaped = true;
1590
+ } else if ch == '"' {
1591
+ in_string = false;
1592
+ }
1593
+ i += 1;
1594
+ continue;
1595
+ }
1596
+ if ch == '"' {
1597
+ in_string = true;
1598
+ cleaned.push(ch);
1599
+ i += 1;
1600
+ continue;
1601
+ }
1602
+ if ch == ',' {
1603
+ let mut j = i + 1;
1604
+ while j < chars.len() && chars[j].is_whitespace() {
1605
+ j += 1;
1606
+ }
1607
+ if matches!(chars.get(j), Some(&'}') | Some(&']')) {
1608
+ i += 1; // the trailing comma; leave it out
1609
+ continue;
1610
+ }
1611
+ }
1612
+ cleaned.push(ch);
1613
+ i += 1;
1614
+ }
1615
+
1616
+ serde_json::from_str(&cleaned).ok()
1617
+ }
1618
+
1619
+ /// Everything loadable: the skills under the Codex/OpenCode roots, plus the MCP
1620
+ /// servers registered for OpenCode (those are the "tools").
1621
+ pub fn discover_capabilities() -> Vec<Capability> {
1622
+ let mut out: Vec<Capability> = Vec::new();
1623
+
1624
+ for (source, root) in capability_roots() {
1625
+ let Ok(entries) = fs::read_dir(&root) else {
1626
+ continue;
1627
+ };
1628
+ for entry in entries.flatten() {
1629
+ let dir = entry.path();
1630
+ if !dir.is_dir() {
1631
+ continue;
1632
+ }
1633
+ let file = dir.join("SKILL.md");
1634
+ if !file.is_file() {
1635
+ continue;
1636
+ }
1637
+ let Ok(text) = fs::read_to_string(&file) else {
1638
+ continue;
1639
+ };
1640
+ let (name, description) = parse_front_matter(&text);
1641
+ let name = name
1642
+ .or_else(|| dir.file_name().map(|n| n.to_string_lossy().to_string()))
1643
+ .unwrap_or_default();
1644
+ out.push(Capability {
1645
+ kind: "skill".to_string(),
1646
+ name,
1647
+ description: description.unwrap_or_default(),
1648
+ path: Some(file),
1649
+ source: source.clone(),
1650
+ });
1651
+ }
1652
+ }
1653
+
1654
+ for server in integrations::servers() {
1655
+ out.push(Capability {
1656
+ kind: "tool".to_string(),
1657
+ name: server.name,
1658
+ description: "MCP server · enter to inspect read-only tools".to_string(),
1659
+ path: None,
1660
+ source: server.source,
1661
+ });
1662
+ }
1663
+ for path in opencode_configs()
1664
+ .into_iter()
1665
+ .chain(opencode_project_configs())
1666
+ {
1667
+ let Some(value) = fs::read_to_string(&path)
1668
+ .ok()
1669
+ .and_then(|text| parse_jsonc(&text))
1670
+ else {
1671
+ continue;
1672
+ };
1673
+ if let Some(plugins) = value
1674
+ .get("plugins")
1675
+ .or_else(|| value.get("plugin"))
1676
+ .and_then(Value::as_array)
1677
+ {
1678
+ for plugin in plugins {
1679
+ if let Some(name) = plugin
1680
+ .as_str()
1681
+ .or_else(|| plugin.get("package").and_then(Value::as_str))
1682
+ {
1683
+ out.push(Capability {
1684
+ kind: "plugin".into(),
1685
+ name: name.to_string(),
1686
+ description: "Requires OpenCode host runtime · not a native shev plugin"
1687
+ .into(),
1688
+ path: None,
1689
+ source: "opencode".into(),
1690
+ });
1691
+ }
1692
+ }
1693
+ }
1694
+ }
1695
+
1696
+ out.sort_by(|a, b| (&a.kind, &a.name).cmp(&(&b.kind, &b.name)));
1697
+ out.dedup_by(|a, b| a.kind == b.kind && a.name == b.name);
1698
+ out
1699
+ }
1700
+
1701
+ /// The guidance text of a capability: the SKILL.md body, front matter stripped.
1702
+ /// Read-only — shev never writes back to the source.
1703
+ pub fn capability_guidance(capability: &Capability) -> Result<String, String> {
1704
+ let Some(path) = &capability.path else {
1705
+ return Ok(String::new());
1706
+ };
1707
+ let text =
1708
+ fs::read_to_string(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
1709
+ Ok(strip_front_matter(&text).trim().to_string())
1710
+ }
1711
+
1712
+ // ---------------------------------------------------------------------------
1713
+ // models: a remote DeepSeek endpoint, or a local MLX model
1714
+ // ---------------------------------------------------------------------------
1715
+
1716
+ /// The DeepSeek model.
1717
+ pub const DEEPSEEK_MODEL: &str = MODEL;
1718
+ /// The local MLX model pack, run on Apple Silicon through its bundled loader.
1719
+ /// Stock mlx-lm/mlx-vlm cannot load these weights, so there is no server to hit:
1720
+ /// the pack's own runtime stays resident between requests.
1721
+ pub const BONSAI_REPO: &str = "prism-ml/Ternary-Bonsai-2-2B-mlx-2bit";
1722
+ pub const BONSAI_DIR_NAME: &str = "Ternary-Bonsai-2-2B-mlx-2bit";
1723
+ /// How many tokens the local model may generate for one decision.
1724
+ pub const BONSAI_MAX_TOKENS: u32 = 256;
1725
+
1726
+ /// Which model backs a request.
1727
+ #[derive(Clone, Copy, PartialEq, Eq, Debug)]
1728
+ pub enum Provider {
1729
+ /// Remote DeepSeek, needs an API key.
1730
+ DeepSeek,
1731
+ /// The local ternary Bonsai model, served over MLX on this machine.
1732
+ Bonsai,
1733
+ }
1734
+
1735
+ impl Provider {
1736
+ pub fn slug(self) -> &'static str {
1737
+ match self {
1738
+ Provider::DeepSeek => "deepseek",
1739
+ Provider::Bonsai => "bonsai",
1740
+ }
1741
+ }
1742
+
1743
+ pub fn label(self) -> &'static str {
1744
+ match self {
1745
+ Provider::DeepSeek => "deepseek (api.deepseek.com)",
1746
+ Provider::Bonsai => "bonsai (local MLX pack)",
1747
+ }
1748
+ }
1749
+
1750
+ pub fn from_slug(value: &str) -> Option<Self> {
1751
+ match value.trim().to_ascii_lowercase().as_str() {
1752
+ "deepseek" | "ds" | "remote" => Some(Provider::DeepSeek),
1753
+ "bonsai" | "local" | "mlx" | "prism" => Some(Provider::Bonsai),
1754
+ _ => None,
1755
+ }
1756
+ }
1757
+ }
1758
+
1759
+ fn load_config_map() -> Map<String, Value> {
1760
+ fs::read_to_string(read_config_path())
1761
+ .ok()
1762
+ .and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
1763
+ .and_then(|value| value.as_object().cloned())
1764
+ .unwrap_or_default()
1765
+ }
1766
+
1767
+ fn write_config_map(map: &Map<String, Value>) -> Result<(), String> {
1768
+ let path = config_path();
1769
+ if let Some(parent) = path.parent() {
1770
+ fs::create_dir_all(parent)
1771
+ .map_err(|e| format!("could not create {}: {e}", parent.display()))?;
1772
+ }
1773
+ let body =
1774
+ serde_json::to_string_pretty(map).map_err(|e| format!("could not encode config: {e}"))?;
1775
+ fs::write(&path, format!("{body}\n"))
1776
+ .map_err(|e| format!("could not write {}: {e}", path.display()))?;
1777
+ restrict_permissions(&path);
1778
+ Ok(())
1779
+ }
1780
+
1781
+ /// Which model the user last selected. Bonsai unless told otherwise.
1782
+ pub fn load_provider() -> Provider {
1783
+ load_config_map()
1784
+ .get("provider")
1785
+ .and_then(Value::as_str)
1786
+ .and_then(Provider::from_slug)
1787
+ .unwrap_or(DEFAULT_PROVIDER)
1788
+ }
1789
+
1790
+ /// Local inference is primary; remote inference must be explicitly selected.
1791
+ pub const DEFAULT_PROVIDER: Provider = Provider::Bonsai;
1792
+
1793
+ pub fn save_provider(provider: Provider) -> Result<(), String> {
1794
+ let mut map = load_config_map();
1795
+ map.insert(
1796
+ "provider".to_string(),
1797
+ Value::String(provider.slug().to_string()),
1798
+ );
1799
+ write_config_map(&map)
1800
+ }
1801
+
1802
+ /// Whether an API key is on disk (without reading its value back out).
1803
+ pub fn saved_key_present() -> bool {
1804
+ load_config_map()
1805
+ .get("api_key")
1806
+ .and_then(Value::as_str)
1807
+ .map(|key| !key.trim().is_empty())
1808
+ .unwrap_or(false)
1809
+ }
1810
+
1811
+ /// Forget the stored API key, leaving the rest of the config alone.
1812
+ pub fn remove_saved_key() -> Result<(), String> {
1813
+ let mut map = load_config_map();
1814
+ map.remove("api_key");
1815
+ write_config_map(&map)
1816
+ }
1817
+
1818
+ /// Where the local models live.
1819
+ pub fn models_dir() -> PathBuf {
1820
+ env::var_os("SHEV_MODELS_DIR")
1821
+ .map(PathBuf::from)
1822
+ .unwrap_or_else(|| {
1823
+ managed_home()
1824
+ .map(|home| home.join("models"))
1825
+ .unwrap_or_else(|| home_dir().join(".local/share/shev/models"))
1826
+ })
1827
+ }
1828
+
1829
+ pub fn bonsai_dir() -> PathBuf {
1830
+ models_dir().join(BONSAI_DIR_NAME)
1831
+ }
1832
+
1833
+ pub fn bonsai_installed() -> bool {
1834
+ let dir = bonsai_dir();
1835
+ dir.join("model.safetensors").exists() || dir.join("config.json").exists()
1836
+ }
1837
+
1838
+ pub fn bonsai_size() -> u64 {
1839
+ fn dir_size(path: &Path) -> u64 {
1840
+ let mut total = 0;
1841
+ if let Ok(entries) = fs::read_dir(path) {
1842
+ for entry in entries.flatten() {
1843
+ let Ok(meta) = entry.metadata() else { continue };
1844
+ if meta.is_dir() {
1845
+ total += dir_size(&entry.path());
1846
+ } else {
1847
+ total += meta.len();
1848
+ }
1849
+ }
1850
+ }
1851
+ total
1852
+ }
1853
+ let dir = bonsai_dir();
1854
+ if dir.exists() { dir_size(&dir) } else { 0 }
1855
+ }
1856
+
1857
+ pub fn human_bytes(bytes: u64) -> String {
1858
+ const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"];
1859
+ let mut value = bytes as f64;
1860
+ let mut unit = 0;
1861
+ while value >= 1024.0 && unit + 1 < UNITS.len() {
1862
+ value /= 1024.0;
1863
+ unit += 1;
1864
+ }
1865
+ if unit == 0 {
1866
+ format!("{bytes} B")
1867
+ } else {
1868
+ format!("{value:.1} {}", UNITS[unit])
1869
+ }
1870
+ }
1871
+
1872
+ fn program_on_path(name: &str) -> Option<PathBuf> {
1873
+ let path = env::var_os("PATH")?;
1874
+ env::split_paths(&path)
1875
+ .map(|dir| dir.join(name))
1876
+ .find(|candidate| candidate.is_file())
1877
+ }
1878
+
1879
+ /// The command that downloads the Bonsai model, preferring the tools on PATH.
1880
+ pub fn bonsai_download_command() -> Result<Vec<String>, String> {
1881
+ let dir = bonsai_dir().display().to_string();
1882
+ let tail = vec!["download", BONSAI_REPO, "--local-dir", dir.as_str()]
1883
+ .into_iter()
1884
+ .map(|part| part.to_string())
1885
+ .collect::<Vec<_>>();
1886
+
1887
+ let lead: Vec<String> = if program_on_path("hf").is_some() {
1888
+ vec!["hf".to_string()]
1889
+ } else if program_on_path("huggingface-cli").is_some() {
1890
+ vec!["huggingface-cli".to_string()]
1891
+ } else if program_on_path("uvx").is_some() {
1892
+ vec![
1893
+ "uvx".to_string(),
1894
+ "--from".to_string(),
1895
+ "huggingface_hub".to_string(),
1896
+ "hf".to_string(),
1897
+ ]
1898
+ } else if program_on_path("uv").is_some() {
1899
+ vec![
1900
+ "uv".to_string(),
1901
+ "tool".to_string(),
1902
+ "run".to_string(),
1903
+ "--from".to_string(),
1904
+ "huggingface_hub".to_string(),
1905
+ "hf".to_string(),
1906
+ ]
1907
+ } else {
1908
+ return Err(
1909
+ "no Hugging Face tooling on PATH — install `uv` (or huggingface_hub) so `hf download` can run"
1910
+ .to_string(),
1911
+ );
1912
+ };
1913
+
1914
+ Ok(lead.into_iter().chain(tail).collect())
1915
+ }
1916
+
1917
+ /// The one-shot runner written into the models dir. The pack ships this loader
1918
+ /// because stock MLX loaders cannot load its weights, so we drive `runtime/`
1919
+ /// directly. The prompt arrives on stdin and only the generated text is printed,
1920
+ /// which keeps the Rust side free of shell quoting.
1921
+ const BONSAI_RUNNER: &str = r####""""shev's one-shot runner for the Ternary Bonsai MLX pack.
1922
+
1923
+ python shev_bonsai_runner.py <pack-dir> # prompt on stdin, text on stdout
1924
+ """
1925
+ import json
1926
+ import os
1927
+ import sys
1928
+ from pathlib import Path
1929
+
1930
+ pack = Path(sys.argv[1]).resolve()
1931
+ sys.path.insert(0, str(pack / "runtime"))
1932
+ max_tokens = int(os.environ.get("SHEV_BONSAI_MAX_TOKENS", "2048"))
1933
+
1934
+
1935
+ def read_json(name, default):
1936
+ path = pack / name
1937
+ return json.loads(path.read_text()) if path.is_file() else default
1938
+
1939
+
1940
+ def sampler_settings():
1941
+ gen = read_json("generation_config.json", {})
1942
+ out = {
1943
+ "temperature": float(os.environ.get("SHEV_BONSAI_TEMPERATURE", "0")),
1944
+ "top_p": gen.get("top_p", 0.95),
1945
+ "top_k": gen.get("top_k", 20),
1946
+ }
1947
+ if gen.get("do_sample") is False:
1948
+ out["temperature"] = 0.0
1949
+ return out
1950
+
1951
+
1952
+ def render(prompt):
1953
+ template = pack / "chat_template.jinja"
1954
+ if not template.is_file():
1955
+ return prompt
1956
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
1957
+
1958
+ env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)
1959
+ return env.from_string(template.read_text()).render(
1960
+ messages=[{"role": "user", "content": prompt}],
1961
+ add_generation_prompt=True,
1962
+ enable_thinking=False,
1963
+ )
1964
+
1965
+
1966
+ def generate_vision(prompt, settings):
1967
+ from mlx_vlm import generate
1968
+ from vision_artifact import load_vl_model
1969
+
1970
+ model, processor, _ = load_vl_model(pack)
1971
+ result = generate(model, processor, render(prompt), max_tokens=max_tokens, **settings)
1972
+ return result if isinstance(result, str) else result.text
1973
+
1974
+
1975
+ def generate_text(prompt, settings):
1976
+ import mlx.core as mx
1977
+ from artifact import load_model
1978
+ from mlx_lm.sample_utils import make_logits_processors, make_sampler
1979
+ from tokenizers import Tokenizer
1980
+
1981
+ model, _ = load_model(pack)
1982
+ tokenizer = Tokenizer.from_file(str(pack / "tokenizer.json"))
1983
+ eos = read_json("generation_config.json", {}).get("eos_token_id")
1984
+ stop = set(eos if isinstance(eos, list) else [] if eos is None else [eos])
1985
+ stop |= {
1986
+ i
1987
+ for i in (
1988
+ tokenizer.token_to_id("<|im_end|>"),
1989
+ tokenizer.token_to_id("<|endoftext|>"),
1990
+ )
1991
+ if i is not None
1992
+ }
1993
+ sample = make_sampler(
1994
+ temp=settings["temperature"],
1995
+ top_p=settings["top_p"],
1996
+ top_k=settings["top_k"],
1997
+ min_p=settings.get("min_p", 0.0),
1998
+ )
1999
+ processors = make_logits_processors(repetition_penalty=settings.get("repetition_penalty"))
2000
+ ids = tokenizer.encode(render(prompt), add_special_tokens=False).ids
2001
+ x = mx.array([ids])
2002
+ cache = model.make_cache()
2003
+ generated = []
2004
+ for _ in range(max_tokens):
2005
+ logits = model.lm_head(model.model(x, cache=cache)[:, -1:, :])[:, -1, :]
2006
+ for processor in processors:
2007
+ logits = processor(mx.array(generated), logits)
2008
+ x = sample(logits)[:, None]
2009
+ mx.eval(x)
2010
+ token = int(x.item())
2011
+ if token in stop:
2012
+ break
2013
+ generated.append(token)
2014
+ return tokenizer.decode(generated)
2015
+
2016
+
2017
+ def main():
2018
+ prompt = sys.stdin.read()
2019
+ settings = sampler_settings()
2020
+ if read_json("config.json", {}).get("components", {}).get("vision"):
2021
+ print(generate_vision(prompt, settings))
2022
+ else:
2023
+ print(generate_text(prompt, settings))
2024
+
2025
+
2026
+ if __name__ == "__main__":
2027
+ main()
2028
+ "####;
2029
+
2030
+ /// The warm worker: the same loader as `BONSAI_RUNNER`, but it loads the model
2031
+ /// once and then answers one JSON request per line, so the reload that dominates
2032
+ /// a one-shot decision is paid once and not again. Generation stops the moment
2033
+ /// the reply closes its top-level JSON object, which keeps the answer short and
2034
+ /// guarantees a single object.
2035
+ const BONSAI_WORKER: &str = r####""""shev's warm worker for the Ternary Bonsai MLX pack.
2036
+
2037
+ python shev_bonsai_worker.py <pack-dir>
2038
+
2039
+ Loads the pack's model once, then serves one request per line on stdin:
2040
+
2041
+ {"prompt": "<the whole prompt>"}
2042
+
2043
+ and answers one result per line on stdout:
2044
+
2045
+ {"text": "<the decision, a single JSON object>"}
2046
+ """
2047
+ import json
2048
+ import os
2049
+ import sys
2050
+ from pathlib import Path
2051
+
2052
+ pack = Path(sys.argv[1]).resolve()
2053
+ sys.path.insert(0, str(pack / "runtime"))
2054
+ max_tokens = int(os.environ.get("SHEV_BONSAI_MAX_TOKENS", "256"))
2055
+ stream = os.environ.get("SHEV_BONSAI_STREAM") == "1"
2056
+
2057
+
2058
+ def read_json(name, default):
2059
+ path = pack / name
2060
+ return json.loads(path.read_text()) if path.is_file() else default
2061
+
2062
+
2063
+ def sampler_settings():
2064
+ gen = read_json("generation_config.json", {})
2065
+ out = {
2066
+ "temperature": float(os.environ.get("SHEV_BONSAI_TEMPERATURE", "0")),
2067
+ "top_p": gen.get("top_p", 0.95),
2068
+ "top_k": gen.get("top_k", 20),
2069
+ }
2070
+ if gen.get("do_sample") is False:
2071
+ out["temperature"] = 0.0
2072
+ return out
2073
+
2074
+
2075
+ def render(prompt):
2076
+ template = pack / "chat_template.jinja"
2077
+ if not template.is_file():
2078
+ return prompt
2079
+ from jinja2.sandbox import ImmutableSandboxedEnvironment
2080
+
2081
+ env = ImmutableSandboxedEnvironment(trim_blocks=True, lstrip_blocks=True)
2082
+ return env.from_string(template.read_text()).render(
2083
+ messages=[{"role": "user", "content": prompt}],
2084
+ add_generation_prompt=True,
2085
+ enable_thinking=False,
2086
+ )
2087
+
2088
+
2089
+ class JsonWatch:
2090
+ "Track whether a stream of pieces has closed one top-level {...} object."
2091
+
2092
+ def __init__(self):
2093
+ self.depth = 0
2094
+ self.seen = False
2095
+ self.in_string = False
2096
+ self.escaped = False
2097
+ self.offset = 0
2098
+ self.end = None
2099
+
2100
+ def feed(self, piece):
2101
+ for ch in piece:
2102
+ self.offset += 1
2103
+ if self.in_string:
2104
+ if self.escaped:
2105
+ self.escaped = False
2106
+ elif ch == "\\":
2107
+ self.escaped = True
2108
+ elif ch == '"':
2109
+ self.in_string = False
2110
+ continue
2111
+ if ch == '"':
2112
+ self.in_string = True
2113
+ elif ch == "{":
2114
+ self.depth += 1
2115
+ self.seen = True
2116
+ elif ch == "}":
2117
+ self.depth -= 1
2118
+ if self.seen and self.depth == 0:
2119
+ self.end = self.offset
2120
+ return True
2121
+ return False
2122
+
2123
+
2124
+ class Engine:
2125
+ def __init__(self):
2126
+ self.vision = bool(read_json("config.json", {}).get("components", {}).get("vision"))
2127
+ if self.vision:
2128
+ from vision_artifact import load_vl_model
2129
+
2130
+ self.model, self.processor, _ = load_vl_model(pack)
2131
+ else:
2132
+ import mlx.core as mx
2133
+ from artifact import load_model
2134
+ from tokenizers import Tokenizer
2135
+
2136
+ self.mx = mx
2137
+ self.model, _ = load_model(pack)
2138
+ self.tokenizer = Tokenizer.from_file(str(pack / "tokenizer.json"))
2139
+ eos = read_json("generation_config.json", {}).get("eos_token_id")
2140
+ self.stop = set(eos if isinstance(eos, list) else [] if eos is None else [eos])
2141
+ self.stop |= {
2142
+ i
2143
+ for i in (
2144
+ self.tokenizer.token_to_id("<|im_end|>"),
2145
+ self.tokenizer.token_to_id("<|endoftext|>"),
2146
+ )
2147
+ if i is not None
2148
+ }
2149
+
2150
+ def generate(self, prompt, on_token=None):
2151
+ settings = sampler_settings()
2152
+ self.usage = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
2153
+ if self.vision:
2154
+ from mlx_vlm import stream_generate
2155
+
2156
+ watch = JsonWatch()
2157
+ pieces = []
2158
+ generation = stream_generate(
2159
+ self.model, self.processor, render(prompt), max_tokens=max_tokens, **settings
2160
+ )
2161
+ try:
2162
+ for result in generation:
2163
+ piece = result if isinstance(result, str) else result.text
2164
+ if not isinstance(result, str):
2165
+ self.usage = {"prompt_tokens": result.prompt_tokens,
2166
+ "completion_tokens": result.generation_tokens,
2167
+ "total_tokens": result.total_tokens}
2168
+ pieces.append(piece)
2169
+ if on_token:
2170
+ on_token(piece)
2171
+ if watch.feed(piece):
2172
+ break
2173
+ finally:
2174
+ generation.close()
2175
+ text = "".join(pieces)
2176
+ return text[:watch.end] if watch.end is not None else text
2177
+
2178
+ from mlx_lm.sample_utils import make_logits_processors, make_sampler
2179
+
2180
+ sample = make_sampler(
2181
+ temp=settings["temperature"],
2182
+ top_p=settings["top_p"],
2183
+ top_k=settings["top_k"],
2184
+ min_p=settings.get("min_p", 0.0),
2185
+ )
2186
+ processors = make_logits_processors(repetition_penalty=settings.get("repetition_penalty"))
2187
+ ids = self.tokenizer.encode(render(prompt), add_special_tokens=False).ids
2188
+ x = self.mx.array([ids])
2189
+ cache = self.model.make_cache()
2190
+ watch = JsonWatch()
2191
+ pieces = []
2192
+ for _ in range(max_tokens):
2193
+ logits = self.model.lm_head(self.model.model(x, cache=cache)[:, -1:, :])[:, -1, :]
2194
+ for processor in processors:
2195
+ logits = processor(self.mx.array(ids + pieces), logits)
2196
+ x = sample(logits)[:, None]
2197
+ self.mx.async_eval(x) # queue the next step while Python runs
2198
+ token = int(x.item())
2199
+ if token in self.stop:
2200
+ break
2201
+ piece = self.tokenizer.decode([token])
2202
+ pieces.append(token)
2203
+ if on_token:
2204
+ on_token(piece)
2205
+ if watch.feed(piece):
2206
+ break
2207
+ self.usage = {"prompt_tokens": len(ids), "completion_tokens": len(pieces),
2208
+ "total_tokens": len(ids) + len(pieces)}
2209
+ return self.tokenizer.decode(pieces)
2210
+
2211
+
2212
+ def emit(obj):
2213
+ sys.stdout.write(json.dumps(obj) + "\n")
2214
+ sys.stdout.flush()
2215
+
2216
+
2217
+ def main():
2218
+ engine = Engine()
2219
+ emit({"ready": True})
2220
+ for line in sys.stdin:
2221
+ line = line.strip()
2222
+ if not line:
2223
+ continue
2224
+ try:
2225
+ prompt = json.loads(line).get("prompt", "")
2226
+ if stream:
2227
+ text = engine.generate(
2228
+ prompt,
2229
+ on_token=lambda piece: emit({"delta": piece}),
2230
+ )
2231
+ else:
2232
+ text = engine.generate(prompt)
2233
+ emit({"text": text, "usage": engine.usage})
2234
+ except Exception as exc: # keep serving; report the failure
2235
+ emit({"error": str(exc)})
2236
+
2237
+
2238
+ if __name__ == "__main__":
2239
+ main()
2240
+ "####;
2241
+
2242
+ /// Where the generated runner lives.
2243
+ pub fn bonsai_runner_path() -> PathBuf {
2244
+ models_dir().join("shev_bonsai_runner.py")
2245
+ }
2246
+
2247
+ /// Write the runner next to the model, refreshing it on every call.
2248
+ pub fn write_bonsai_runner() -> Result<PathBuf, String> {
2249
+ let dir = models_dir();
2250
+ fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
2251
+ let path = bonsai_runner_path();
2252
+ fs::write(&path, BONSAI_RUNNER)
2253
+ .map_err(|e| format!("could not write {}: {e}", path.display()))?;
2254
+ Ok(path)
2255
+ }
2256
+
2257
+ /// Where the generated warm worker lives.
2258
+ pub fn bonsai_worker_path() -> PathBuf {
2259
+ models_dir().join("shev_bonsai_worker.py")
2260
+ }
2261
+
2262
+ /// Write the warm worker next to the model, refreshing it on every call.
2263
+ pub fn write_bonsai_worker() -> Result<PathBuf, String> {
2264
+ let dir = models_dir();
2265
+ fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
2266
+ let path = bonsai_worker_path();
2267
+ fs::write(&path, BONSAI_WORKER)
2268
+ .map_err(|e| format!("could not write {}: {e}", path.display()))?;
2269
+ Ok(path)
2270
+ }
2271
+
2272
+ /// The command that answers one decision locally. Prefers `uv` so the pinned
2273
+ /// MLX stack from the pack's `runtime/requirements.txt` is used in an ephemeral
2274
+ /// environment; on a machine that already has the stack, plain `python3` works.
2275
+ pub fn bonsai_run_command() -> Result<Vec<String>, String> {
2276
+ if !bonsai_installed() {
2277
+ return Err(format!(
2278
+ "the bonsai model is not installed yet — install it into {}",
2279
+ bonsai_dir().display()
2280
+ ));
2281
+ }
2282
+ let runner = write_bonsai_runner()?.display().to_string();
2283
+ let requirements = bonsai_dir()
2284
+ .join("runtime")
2285
+ .join("requirements.txt")
2286
+ .display()
2287
+ .to_string();
2288
+
2289
+ let mut argv: Vec<String> = if program_on_path("uv").is_some() {
2290
+ vec![
2291
+ "uv".to_string(),
2292
+ "run".to_string(),
2293
+ "--quiet".to_string(),
2294
+ "--python".to_string(),
2295
+ "3.12".to_string(),
2296
+ "--with-requirements".to_string(),
2297
+ requirements,
2298
+ "python".to_string(),
2299
+ runner,
2300
+ ]
2301
+ } else {
2302
+ vec!["python3".to_string(), runner]
2303
+ };
2304
+ argv.push(bonsai_dir().display().to_string());
2305
+ Ok(argv)
2306
+ }
2307
+
2308
+ /// The command that starts the warm worker: same environment as the one-shot
2309
+ /// runner, but a different script, with the model held in memory between
2310
+ /// requests. The pack dir is the trailing argument, as for the runner.
2311
+ pub fn bonsai_worker_command() -> Result<Vec<String>, String> {
2312
+ if !bonsai_installed() {
2313
+ return Err(format!(
2314
+ "the bonsai model is not installed yet — install it into {}",
2315
+ bonsai_dir().display()
2316
+ ));
2317
+ }
2318
+ let worker = write_bonsai_worker()?.display().to_string();
2319
+ let requirements = bonsai_dir()
2320
+ .join("runtime")
2321
+ .join("requirements.txt")
2322
+ .display()
2323
+ .to_string();
2324
+
2325
+ let mut argv: Vec<String> = if program_on_path("uv").is_some() {
2326
+ vec![
2327
+ "uv".to_string(),
2328
+ "run".to_string(),
2329
+ "--quiet".to_string(),
2330
+ "--python".to_string(),
2331
+ "3.12".to_string(),
2332
+ "--with-requirements".to_string(),
2333
+ requirements,
2334
+ "python".to_string(),
2335
+ worker,
2336
+ ]
2337
+ } else {
2338
+ vec!["python3".to_string(), worker]
2339
+ };
2340
+ argv.push(bonsai_dir().display().to_string());
2341
+ Ok(argv)
2342
+ }
2343
+
2344
+ /// Everything a local request needs: the pack on disk and a fresh runner.
2345
+ pub fn ensure_bonsai_ready() -> Result<(), String> {
2346
+ if !bonsai_installed() {
2347
+ return Err(format!(
2348
+ "the bonsai model is not installed yet — install it from the /model menu (into {})",
2349
+ bonsai_dir().display()
2350
+ ));
2351
+ }
2352
+ write_bonsai_runner()?;
2353
+ write_bonsai_worker()?;
2354
+ Ok(())
2355
+ }
2356
+
2357
+ /// Is the local model downloaded and ready to run?
2358
+ pub fn bonsai_ready() -> bool {
2359
+ bonsai_installed()
2360
+ }
2361
+
2362
+ pub fn run_command(argv: &[String]) -> Result<std::process::Output, String> {
2363
+ let (program, args) = argv
2364
+ .split_first()
2365
+ .ok_or_else(|| "empty command".to_string())?;
2366
+ std::process::Command::new(program)
2367
+ .args(args)
2368
+ .output()
2369
+ .map_err(|e| format!("could not run {program}: {e}"))
2370
+ }
2371
+
2372
+ /// Download the model from the internet.
2373
+ pub fn install_bonsai() -> Result<String, String> {
2374
+ let argv = bonsai_download_command()?;
2375
+ fs::create_dir_all(models_dir())
2376
+ .map_err(|e| format!("could not create {}: {e}", models_dir().display()))?;
2377
+ let output = run_command(&argv)?;
2378
+ if !output.status.success() {
2379
+ let stderr = String::from_utf8_lossy(&output.stderr);
2380
+ let stdout = String::from_utf8_lossy(&output.stdout);
2381
+ let detail = if stderr.trim().is_empty() {
2382
+ stdout
2383
+ } else {
2384
+ stderr
2385
+ };
2386
+ return Err(format!("download failed: {}", snippet(&detail)));
2387
+ }
2388
+ if !bonsai_installed() {
2389
+ return Err(format!(
2390
+ "the download finished but {} still looks empty",
2391
+ bonsai_dir().display()
2392
+ ));
2393
+ }
2394
+ fs::write(bonsai_dir().join(".shev-owned"), "shev model\n")
2395
+ .map_err(|e| format!("could not mark the model installation: {e}"))?;
2396
+ Ok(format!(
2397
+ "installed {BONSAI_REPO} into {} ({})",
2398
+ bonsai_dir().display(),
2399
+ human_bytes(bonsai_size())
2400
+ ))
2401
+ }
2402
+
2403
+ /// Delete the downloaded model.
2404
+ pub fn uninstall_bonsai() -> Result<String, String> {
2405
+ let dir = bonsai_dir();
2406
+ if !dir.exists() {
2407
+ return Ok(format!("{} was not installed", dir.display()));
2408
+ }
2409
+ let size = bonsai_size();
2410
+ fs::remove_dir_all(&dir).map_err(|e| format!("could not remove {}: {e}", dir.display()))?;
2411
+ let _ = fs::remove_file(bonsai_runner_path());
2412
+ Ok(format!(
2413
+ "removed {} (freed {})",
2414
+ dir.display(),
2415
+ human_bytes(size)
2416
+ ))
2417
+ }
2418
+
2419
+ // ---------------------------------------------------------------------------
2420
+ // mcp: hand the recorded JSON decisions to other models
2421
+ // ---------------------------------------------------------------------------
2422
+
2423
+ /// Every decision in the responses file, oldest first.
2424
+ pub fn load_records(path: &Path) -> Result<Vec<(String, Value)>, String> {
2425
+ let raw = match fs::read_to_string(path) {
2426
+ Ok(raw) => raw,
2427
+ Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
2428
+ Err(e) => return Err(format!("could not read {}: {e}", path.display())),
2429
+ };
2430
+ if raw.trim().is_empty() {
2431
+ return Ok(Vec::new());
2432
+ }
2433
+ let parsed: Value = serde_json::from_str(&raw)
2434
+ .map_err(|e| format!("{} is not valid JSON: {e}", path.display()))?;
2435
+ let map = parsed
2436
+ .as_object()
2437
+ .ok_or_else(|| format!("{} is not a JSON object", path.display()))?;
2438
+ let mut indexed: Vec<(u64, String, Value)> = map
2439
+ .iter()
2440
+ .filter_map(|(key, record)| {
2441
+ key.strip_prefix("response")
2442
+ .and_then(|index| index.parse::<u64>().ok())
2443
+ .map(|index| (index, key.clone(), record.clone()))
2444
+ })
2445
+ .collect();
2446
+ indexed.sort_by_key(|(index, _, _)| *index);
2447
+ Ok(indexed
2448
+ .into_iter()
2449
+ .map(|(_, key, record)| (key, record))
2450
+ .collect())
2451
+ }
2452
+
2453
+ /// Serve the recorded decisions over MCP on stdin/stdout. Read-only: other
2454
+ /// models pull the JSON that shev already produced.
2455
+ pub fn mcp_serve(responses: &Path) -> Result<(), String> {
2456
+ let stdin = io::stdin();
2457
+ let mut out = io::stdout().lock();
2458
+
2459
+ for line in stdin.lock().lines() {
2460
+ let line = line.map_err(|e| format!("could not read stdin: {e}"))?;
2461
+ let line = line.trim();
2462
+ if line.is_empty() {
2463
+ continue;
2464
+ }
2465
+ let message: Value = match serde_json::from_str(line) {
2466
+ Ok(value) => value,
2467
+ Err(e) => {
2468
+ write_json(
2469
+ &mut out,
2470
+ &json!({
2471
+ "jsonrpc": "2.0",
2472
+ "id": Value::Null,
2473
+ "error": { "code": -32700, "message": format!("parse error: {e}") }
2474
+ }),
2475
+ )?;
2476
+ continue;
2477
+ }
2478
+ };
2479
+
2480
+ // notifications have no id and get no reply
2481
+ let Some(id) = message.get("id").cloned() else {
2482
+ continue;
2483
+ };
2484
+ let method = message.get("method").and_then(Value::as_str).unwrap_or("");
2485
+
2486
+ let reply = match method {
2487
+ "initialize" => Ok(mcp_initialize()),
2488
+ "ping" => Ok(json!({})),
2489
+ "tools/list" => Ok(json!({ "tools": mcp_tools() })),
2490
+ "tools/call" => mcp_call(responses, message.get("params")),
2491
+ "resources/list" => Ok(json!({ "resources": mcp_resources(responses) })),
2492
+ "resources/read" => mcp_read(responses, message.get("params")),
2493
+ other => {
2494
+ Err(json!({ "code": -32601, "message": format!("method not found: {other}") }))
2495
+ }
2496
+ };
2497
+
2498
+ let envelope = match reply {
2499
+ Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
2500
+ Err(error) => json!({ "jsonrpc": "2.0", "id": id, "error": error }),
2501
+ };
2502
+ write_json(&mut out, &envelope)?;
2503
+ }
2504
+ Ok(())
2505
+ }
2506
+
2507
+ fn write_json(out: &mut impl Write, value: &Value) -> Result<(), String> {
2508
+ serde_json::to_writer(&mut *out, value).map_err(|e| format!("could not write: {e}"))?;
2509
+ out.write_all(b"\n")
2510
+ .map_err(|e| format!("could not write: {e}"))?;
2511
+ out.flush().map_err(|e| format!("could not flush: {e}"))
2512
+ }
2513
+
2514
+ fn mcp_initialize() -> Value {
2515
+ json!({
2516
+ "protocolVersion": "2024-11-05",
2517
+ "capabilities": { "tools": {}, "resources": {} },
2518
+ "serverInfo": { "name": "shev", "version": env!("CARGO_PKG_VERSION") },
2519
+ "instructions": "Read-only access to the JSON decisions shev recorded. \
2520
+ Use list_decisions or latest_decision to pull them into context."
2521
+ })
2522
+ }
2523
+
2524
+ fn tool(name: &str, description: &str, schema: Value) -> Value {
2525
+ json!({ "name": name, "description": description, "inputSchema": schema,
2526
+ "annotations": {"readOnlyHint": true, "destructiveHint": false, "idempotentHint": true} })
2527
+ }
2528
+
2529
+ fn mcp_tools() -> Value {
2530
+ json!([
2531
+ tool(
2532
+ "list_decisions",
2533
+ "List the JSON decisions shev recorded, oldest first.",
2534
+ json!({
2535
+ "type": "object",
2536
+ "properties": { "limit": { "type": "integer", "description": "how many of the most recent decisions (default 20)" } },
2537
+ "additionalProperties": false
2538
+ })
2539
+ ),
2540
+ tool(
2541
+ "latest_decision",
2542
+ "The most recent decision shev recorded.",
2543
+ json!({ "type": "object", "properties": {}, "additionalProperties": false })
2544
+ ),
2545
+ tool(
2546
+ "get_decision",
2547
+ "One decision, by its responseN key or by 1-based index (defaults to the latest).",
2548
+ json!({
2549
+ "type": "object",
2550
+ "properties": { "key": { "type": "string" }, "index": { "type": "integer" } },
2551
+ "additionalProperties": false
2552
+ })
2553
+ ),
2554
+ tool(
2555
+ "search_decisions",
2556
+ "Decisions whose message, action or response contains a phrase.",
2557
+ json!({
2558
+ "type": "object",
2559
+ "properties": { "query": { "type": "string" }, "limit": { "type": "integer" } },
2560
+ "required": ["query"],
2561
+ "additionalProperties": false
2562
+ })
2563
+ ),
2564
+ ])
2565
+ }
2566
+
2567
+ fn mcp_call(responses: &Path, params: Option<&Value>) -> Result<Value, Value> {
2568
+ let params = params.cloned().unwrap_or(Value::Null);
2569
+ let name = params.get("name").and_then(Value::as_str).unwrap_or("");
2570
+ let args = params.get("arguments").cloned().unwrap_or(json!({}));
2571
+ let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize;
2572
+
2573
+ let records = load_records(responses).map_err(|e| json!({ "code": -32603, "message": e }))?;
2574
+ let as_entry = |(key, record): &(String, Value)| json!({ "key": key, "decision": record });
2575
+
2576
+ let payload = match name {
2577
+ "list_decisions" => {
2578
+ let start = records.len().saturating_sub(limit);
2579
+ json!(records[start..].iter().map(as_entry).collect::<Vec<_>>())
2580
+ }
2581
+ "latest_decision" => records.last().map(as_entry).unwrap_or(Value::Null),
2582
+ "get_decision" => {
2583
+ let found = match (
2584
+ args.get("key").and_then(Value::as_str),
2585
+ args.get("index").and_then(Value::as_u64),
2586
+ ) {
2587
+ (Some(key), _) => records.iter().find(|(k, _)| k == key),
2588
+ (_, Some(index)) if index > 0 => records.get((index - 1) as usize),
2589
+ _ => records.last(),
2590
+ };
2591
+ found.map(as_entry).unwrap_or(Value::Null)
2592
+ }
2593
+ "search_decisions" => {
2594
+ let query = args
2595
+ .get("query")
2596
+ .and_then(Value::as_str)
2597
+ .unwrap_or("")
2598
+ .to_ascii_lowercase();
2599
+ let hits: Vec<Value> = records
2600
+ .iter()
2601
+ .filter(|(_, record)| {
2602
+ let haystack = format!(
2603
+ "{} {} {}",
2604
+ record.get("input").and_then(Value::as_str).unwrap_or(""),
2605
+ record
2606
+ .get("actionable")
2607
+ .and_then(Value::as_str)
2608
+ .unwrap_or(""),
2609
+ record
2610
+ .get("generated_response")
2611
+ .and_then(Value::as_str)
2612
+ .unwrap_or("")
2613
+ )
2614
+ .to_ascii_lowercase();
2615
+ haystack.contains(&query)
2616
+ })
2617
+ .map(as_entry)
2618
+ .collect();
2619
+ let start = hits.len().saturating_sub(limit);
2620
+ json!(hits[start..].to_vec())
2621
+ }
2622
+ other => {
2623
+ return Err(json!({ "code": -32602, "message": format!("unknown tool {other}") }));
2624
+ }
2625
+ };
2626
+
2627
+ let text = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| payload.to_string());
2628
+ Ok(json!({
2629
+ "content": [{ "type": "text", "text": text }],
2630
+ "isError": false
2631
+ }))
2632
+ }
2633
+
2634
+ fn mcp_resources(responses: &Path) -> Value {
2635
+ json!([
2636
+ {
2637
+ "uri": "shev://decisions",
2638
+ "name": "decisions",
2639
+ "description": format!("Every decision shev has recorded in {}", responses.display()),
2640
+ "mimeType": "application/json"
2641
+ },
2642
+ {
2643
+ "uri": "shev://decisions/latest",
2644
+ "name": "latest decision",
2645
+ "description": "The most recent decision, on its own",
2646
+ "mimeType": "application/json"
2647
+ }
2648
+ ])
2649
+ }
2650
+
2651
+ fn mcp_read(responses: &Path, params: Option<&Value>) -> Result<Value, Value> {
2652
+ let uri = params
2653
+ .and_then(|params| params.get("uri"))
2654
+ .and_then(Value::as_str)
2655
+ .unwrap_or("");
2656
+ let records = load_records(responses).map_err(|e| json!({ "code": -32603, "message": e }))?;
2657
+ let text = match uri {
2658
+ "shev://decisions" => {
2659
+ let map: Map<String, Value> = records.into_iter().collect();
2660
+ serde_json::to_string_pretty(&Value::Object(map)).unwrap_or_default()
2661
+ }
2662
+ "shev://decisions/latest" => match records.last() {
2663
+ Some((_, record)) => serde_json::to_string_pretty(record).unwrap_or_default(),
2664
+ None => "null".to_string(),
2665
+ },
2666
+ other => {
2667
+ return Err(json!({ "code": -32602, "message": format!("unknown resource {other}") }));
2668
+ }
2669
+ };
2670
+ Ok(json!({
2671
+ "contents": [{ "uri": uri, "mimeType": "application/json", "text": text }]
2672
+ }))
2673
+ }
2674
+
2675
+ /// Where the generated MCP config and manifest live.
2676
+ pub fn mcp_dir() -> PathBuf {
2677
+ config_path()
2678
+ .parent()
2679
+ .map(|parent| parent.join("mcp"))
2680
+ .unwrap_or_else(|| PathBuf::from(".shev-mcp"))
2681
+ }
2682
+
2683
+ /// The paste-anywhere descriptor (`mcpServers`, as Claude Desktop and Cursor use).
2684
+ fn mcp_descriptor(exe: &Path, responses: &Path) -> Value {
2685
+ let exe = exe.display().to_string();
2686
+ let file = responses.display().to_string();
2687
+ json!({
2688
+ "mcpServers": {
2689
+ "shev": {
2690
+ "command": exe,
2691
+ "args": ["--mcp"],
2692
+ "env": { "SHEV_RESPONSES_FILE": file }
2693
+ }
2694
+ }
2695
+ })
2696
+ }
2697
+
2698
+ /// An OpenCode local-server entry (under `mcp.servers.<name>` in V2).
2699
+ fn mcp_opencode_entry(exe: &Path, responses: &Path) -> Value {
2700
+ json!({
2701
+ "type": "local",
2702
+ "command": [exe.display().to_string(), "--mcp"],
2703
+ "environment": { "SHEV_RESPONSES_FILE": responses.display().to_string() },
2704
+ "enabled": true
2705
+ })
2706
+ }
2707
+
2708
+ /// Where OpenCode keeps its config: the shared XDG config dir, under `opencode`.
2709
+ /// Both the modern `.json` and the comment-friendly `.jsonc`. (This used to point
2710
+ /// at a file inside shev's own config dir, so registrations and the tools shev
2711
+ /// borrowed from it never actually reached OpenCode.)
2712
+ fn opencode_configs() -> Vec<PathBuf> {
2713
+ let base = env::var_os("XDG_CONFIG_HOME")
2714
+ .map(PathBuf::from)
2715
+ .unwrap_or_else(|| home_dir().join(".config"));
2716
+ let dir = base.join("opencode");
2717
+ vec![dir.join("opencode.json"), dir.join("opencode.jsonc")]
2718
+ }
2719
+
2720
+ /// OpenCode config in the current project. Discovery only — shev never writes
2721
+ /// to these, so a borrowed tool is never registered behind your back.
2722
+ fn opencode_project_configs() -> Vec<PathBuf> {
2723
+ let mut paths = Vec::new();
2724
+ if let Ok(cwd) = env::current_dir() {
2725
+ let ancestors: Vec<_> = cwd.ancestors().collect();
2726
+ for relative in ["", ".opencode"] {
2727
+ for ancestor in ancestors.iter().rev() {
2728
+ for name in ["opencode.json", "opencode.jsonc"] {
2729
+ paths.push(ancestor.join(relative).join(name));
2730
+ }
2731
+ }
2732
+ }
2733
+ }
2734
+ paths
2735
+ }
2736
+
2737
+ fn write_pretty(path: &Path, value: &Value) -> Result<(), String> {
2738
+ if let Some(parent) = path.parent() {
2739
+ fs::create_dir_all(parent)
2740
+ .map_err(|e| format!("could not create {}: {e}", parent.display()))?;
2741
+ }
2742
+ let body = serde_json::to_string_pretty(value).map_err(|e| format!("could not encode: {e}"))?;
2743
+ fs::write(path, format!("{body}\n"))
2744
+ .map_err(|e| format!("could not write {}: {e}", path.display()))
2745
+ }
2746
+
2747
+ /// Install the local MCP server: write its descriptor and, when opencode is
2748
+ /// present, register it there too. Reversible with [`mcp_uninstall`].
2749
+ pub fn mcp_install(responses: &Path) -> Result<String, String> {
2750
+ let exe = env::current_exe().map_err(|e| format!("could not find my own path: {e}"))?;
2751
+ let responses = if responses.is_absolute() {
2752
+ responses.to_path_buf()
2753
+ } else {
2754
+ env::current_dir()
2755
+ .map_err(|e| format!("could not resolve the history path: {e}"))?
2756
+ .join(responses)
2757
+ };
2758
+ let responses = fs::canonicalize(&responses).unwrap_or(responses);
2759
+ let dir = mcp_dir();
2760
+ fs::create_dir_all(&dir).map_err(|e| format!("could not create {}: {e}", dir.display()))?;
2761
+
2762
+ let descriptor = mcp_descriptor(&exe, &responses);
2763
+ let entry = mcp_opencode_entry(&exe, &responses);
2764
+ write_pretty(&dir.join("server.json"), &descriptor)?;
2765
+ write_pretty(
2766
+ &dir.join("opencode.json"),
2767
+ &json!({ "mcp": { "servers": { "shev": entry.clone() } } }),
2768
+ )?;
2769
+
2770
+ let mut report = vec![format!("wrote {}", dir.join("server.json").display())];
2771
+ let mut registrations = Map::new();
2772
+ for candidate in opencode_configs() {
2773
+ if !candidate.exists() {
2774
+ continue;
2775
+ }
2776
+ match register_opencode(&candidate, &entry) {
2777
+ Ok(mode) => {
2778
+ report.push(format!(
2779
+ "registered with opencode in {} ({mode})",
2780
+ candidate.display()
2781
+ ));
2782
+ registrations.insert(
2783
+ candidate.display().to_string(),
2784
+ json!({ "mode": mode, "entry": entry }),
2785
+ );
2786
+ }
2787
+ Err(e) => report.push(format!("could not update {}: {e}", candidate.display())),
2788
+ }
2789
+ }
2790
+ if registrations.is_empty() {
2791
+ report.push(format!(
2792
+ "no opencode config found — paste this into your MCP client:\n{}",
2793
+ serde_json::to_string_pretty(&descriptor).unwrap_or_default()
2794
+ ));
2795
+ }
2796
+
2797
+ write_pretty(
2798
+ &dir.join("installed.json"),
2799
+ &json!({
2800
+ "installed_at": unix_timestamp(),
2801
+ "exe": exe.display().to_string(),
2802
+ "responses": responses.display().to_string(),
2803
+ "registrations": Value::Object(registrations),
2804
+ }),
2805
+ )?;
2806
+ Ok(report.join("\n"))
2807
+ }
2808
+
2809
+ fn register_opencode(path: &Path, entry: &Value) -> Result<&'static str, String> {
2810
+ let text =
2811
+ fs::read_to_string(path).map_err(|e| format!("could not read {}: {e}", path.display()))?;
2812
+
2813
+ let backup = PathBuf::from(format!("{}.shev.bak", path.display()));
2814
+ if !backup.exists() {
2815
+ fs::write(&backup, &text).map_err(|_| "could not back up OpenCode config".to_string())?;
2816
+ restrict_permissions(&backup);
2817
+ }
2818
+
2819
+ if let Ok(mut parsed) = serde_json::from_str::<Value>(&text) {
2820
+ let root = parsed
2821
+ .as_object_mut()
2822
+ .ok_or_else(|| "the opencode config is not a JSON object".to_string())?;
2823
+ let mcp = root.entry("mcp".to_string()).or_insert_with(|| json!({}));
2824
+ if !mcp.is_object() {
2825
+ return Err("the existing MCP configuration is not an object; left unchanged".into());
2826
+ }
2827
+ let native = mcp.get("servers").is_some() || mcp.as_object().is_some_and(Map::is_empty);
2828
+ let servers = if native {
2829
+ mcp.as_object_mut()
2830
+ .unwrap()
2831
+ .entry("servers")
2832
+ .or_insert_with(|| json!({}))
2833
+ } else {
2834
+ mcp
2835
+ };
2836
+ let servers = servers
2837
+ .as_object_mut()
2838
+ .ok_or("the existing MCP servers configuration is not an object")?;
2839
+ if servers.get("shev") == Some(entry) {
2840
+ return Ok("json");
2841
+ }
2842
+ if servers.contains_key("shev") {
2843
+ return Err("a shev registration already exists; left unchanged".into());
2844
+ }
2845
+ servers.insert("shev".to_string(), entry.clone());
2846
+ write_pretty(path, &parsed)?;
2847
+ return Ok("json");
2848
+ }
2849
+
2850
+ // JSONC: preserve comments and unrelated fields, and reject duplicates.
2851
+ let parsed = parse_jsonc(&text).ok_or("the OpenCode config is not valid JSONC")?;
2852
+ let servers = parsed.pointer("/mcp/servers").or_else(|| parsed.get("mcp"));
2853
+ if servers.and_then(|m| m.get("shev")) == Some(entry) {
2854
+ return Ok("jsonc");
2855
+ }
2856
+ if servers.and_then(|m| m.get("shev")).is_some() {
2857
+ return Err("a shev registration already exists; left unchanged".into());
2858
+ }
2859
+ let Some(anchor) = text.find("\"mcp\"") else {
2860
+ return Err("there is no \"mcp\" section to merge into".to_string());
2861
+ };
2862
+ let anchor = if parsed.pointer("/mcp/servers").is_some() {
2863
+ anchor
2864
+ + text[anchor..]
2865
+ .find("\"servers\"")
2866
+ .ok_or("the MCP servers section could not be located")?
2867
+ } else {
2868
+ anchor
2869
+ };
2870
+ let Some(brace) = text[anchor..].find('{') else {
2871
+ return Err("the \"mcp\" section could not be parsed".to_string());
2872
+ };
2873
+ let insert_at = anchor + brace + 1;
2874
+ let serialized = serde_json::to_string(entry).map_err(|e| format!("could not encode: {e}"))?;
2875
+ let insert = format!("\n\t\"shev\": {serialized},\n");
2876
+ let mut merged = String::with_capacity(text.len() + insert.len());
2877
+ merged.push_str(&text[..insert_at]);
2878
+ merged.push_str(&insert);
2879
+ merged.push_str(&text[insert_at..]);
2880
+ let mut verified = parse_jsonc(&merged)
2881
+ .ok_or("MCP registration would produce invalid JSONC; left unchanged")?;
2882
+ let location = if parsed.pointer("/mcp/servers").is_some() {
2883
+ "/mcp/servers"
2884
+ } else {
2885
+ "/mcp"
2886
+ };
2887
+ let removed = verified
2888
+ .pointer_mut(location)
2889
+ .and_then(Value::as_object_mut)
2890
+ .and_then(|servers| servers.remove("shev"));
2891
+ if removed.as_ref() != Some(entry) || verified != parsed {
2892
+ return Err("could not safely locate the MCP registration; left unchanged".into());
2893
+ }
2894
+ fs::write(path, &merged).map_err(|e| format!("could not write {}: {e}", path.display()))?;
2895
+ Ok("jsonc")
2896
+ }
2897
+
2898
+ fn unregister_opencode(path: &Path, mode: &str) -> Result<bool, String> {
2899
+ let Ok(text) = fs::read_to_string(path) else {
2900
+ return Ok(false);
2901
+ };
2902
+
2903
+ if mode == "json"
2904
+ && let Ok(mut parsed) = serde_json::from_str::<Value>(&text)
2905
+ {
2906
+ let removed = parsed
2907
+ .get_mut("mcp")
2908
+ .map(|mcp| {
2909
+ if mcp.get("servers").is_some() {
2910
+ mcp.get_mut("servers").unwrap()
2911
+ } else {
2912
+ mcp
2913
+ }
2914
+ })
2915
+ .and_then(Value::as_object_mut)
2916
+ .map(|mcp| mcp.remove("shev").is_some())
2917
+ .unwrap_or(false);
2918
+ if removed {
2919
+ write_pretty(path, &parsed)?;
2920
+ }
2921
+ return Ok(removed);
2922
+ }
2923
+
2924
+ // jsonc: drop the exact line we inserted
2925
+ let Some(anchor) = text.find("\"shev\":") else {
2926
+ return Ok(false);
2927
+ };
2928
+ let line_start = text[..anchor].rfind('\n').map(|i| i + 1).unwrap_or(0);
2929
+ let line = text[line_start
2930
+ ..text[anchor..]
2931
+ .find('\n')
2932
+ .map(|i| anchor + i)
2933
+ .unwrap_or(text.len())]
2934
+ .trim();
2935
+ if !line.starts_with("\"shev\":") {
2936
+ return Ok(false);
2937
+ }
2938
+ let line_end = text[anchor..]
2939
+ .find('\n')
2940
+ .map(|i| anchor + i + 1)
2941
+ .unwrap_or(text.len());
2942
+ let mut out = String::with_capacity(text.len());
2943
+ out.push_str(&text[..line_start]);
2944
+ out.push_str(&text[line_end..]);
2945
+ let mut expected = parse_jsonc(&text).ok_or("could not safely parse the existing config")?;
2946
+ let location = if expected.pointer("/mcp/servers").is_some() {
2947
+ "/mcp/servers"
2948
+ } else {
2949
+ "/mcp"
2950
+ };
2951
+ expected
2952
+ .pointer_mut(location)
2953
+ .and_then(Value::as_object_mut)
2954
+ .and_then(|servers| servers.remove("shev"));
2955
+ if parse_jsonc(&out).as_ref() != Some(&expected) {
2956
+ return Err("could not safely remove the registration; left unchanged".into());
2957
+ }
2958
+ fs::write(path, out).map_err(|e| format!("could not write {}: {e}", path.display()))?;
2959
+ Ok(true)
2960
+ }
2961
+
2962
+ /// Remove the MCP config and any registration it made.
2963
+ pub fn mcp_uninstall() -> Result<String, String> {
2964
+ let dir = mcp_dir();
2965
+ let manifest: Value = fs::read_to_string(dir.join("installed.json"))
2966
+ .ok()
2967
+ .and_then(|raw| serde_json::from_str(&raw).ok())
2968
+ .unwrap_or(Value::Null);
2969
+
2970
+ let mut report = Vec::new();
2971
+ if let Some(registrations) = manifest.get("registrations").and_then(Value::as_object) {
2972
+ for (path, info) in registrations {
2973
+ let path = PathBuf::from(path);
2974
+ let mode = info.get("mode").and_then(Value::as_str).unwrap_or("json");
2975
+ if let Some(expected) = info.get("entry") {
2976
+ let current = fs::read_to_string(&path)
2977
+ .ok()
2978
+ .and_then(|text| parse_jsonc(&text));
2979
+ let actual = current.as_ref().and_then(|value| {
2980
+ value
2981
+ .pointer("/mcp/servers/shev")
2982
+ .or_else(|| value.pointer("/mcp/shev"))
2983
+ });
2984
+ if actual != Some(expected) {
2985
+ report.push(format!(
2986
+ "left changed registration untouched in {}",
2987
+ path.display()
2988
+ ));
2989
+ continue;
2990
+ }
2991
+ }
2992
+ match unregister_opencode(&path, mode) {
2993
+ Ok(true) => report.push(format!("unregistered from {}", path.display())),
2994
+ Ok(false) => report.push(format!("nothing to undo in {}", path.display())),
2995
+ Err(e) => {
2996
+ return Err(format!(
2997
+ "could not update {}: {e}; registration manifest kept for retry",
2998
+ path.display()
2999
+ ));
3000
+ }
3001
+ }
3002
+ }
3003
+ }
3004
+
3005
+ if dir.exists() {
3006
+ fs::remove_dir_all(&dir).map_err(|e| format!("could not remove {}: {e}", dir.display()))?;
3007
+ report.push(format!("removed {}", dir.display()));
3008
+ }
3009
+ if report.is_empty() {
3010
+ report.push("nothing was installed".to_string());
3011
+ }
3012
+ Ok(report.join("\n"))
3013
+ }
3014
+
3015
+ /// One line describing whether the MCP server is installed.
3016
+ pub fn mcp_status() -> String {
3017
+ let dir = mcp_dir();
3018
+ if !dir.exists() {
3019
+ return "off — /mcp install adds the local shev MCP".to_string();
3020
+ }
3021
+ let mut parts = vec![format!("on — {}", dir.display())];
3022
+ if let Ok(raw) = fs::read_to_string(dir.join("installed.json"))
3023
+ && let Ok(manifest) = serde_json::from_str::<Value>(&raw)
3024
+ && let Some(registrations) = manifest.get("registrations").and_then(Value::as_object)
3025
+ {
3026
+ for path in registrations.keys() {
3027
+ parts.push(format!("registered in {path}"));
3028
+ }
3029
+ }
3030
+ parts.push(
3031
+ "tools: list_decisions · latest_decision · get_decision · search_decisions".to_string(),
3032
+ );
3033
+ parts.join(" · ")
3034
+ }
3035
+
3036
+ /// Show a file in the desktop file manager (Finder on macOS).
3037
+ pub fn reveal_path(path: &Path) -> Result<(), String> {
3038
+ let mut command;
3039
+ if cfg!(target_os = "macos") {
3040
+ command = std::process::Command::new("open");
3041
+ command.arg("-R").arg(path);
3042
+ } else if cfg!(target_os = "windows") {
3043
+ command = std::process::Command::new("explorer");
3044
+ command.arg(format!("/select,{}", path.display()));
3045
+ } else {
3046
+ command = std::process::Command::new("xdg-open");
3047
+ command.arg(path.parent().unwrap_or(path));
3048
+ }
3049
+
3050
+ match command.status() {
3051
+ Ok(status) if status.success() => Ok(()),
3052
+ Ok(status) => Err(format!("could not open the file manager: {status}")),
3053
+ Err(e) => Err(format!("could not open the file manager: {e}")),
3054
+ }
3055
+ }
3056
+
3057
+ // ---------------------------------------------------------------------------
3058
+ // tests
3059
+ // ---------------------------------------------------------------------------
3060
+
3061
+ #[cfg(test)]
3062
+ mod tests {
3063
+ use super::*;
3064
+
3065
+ #[test]
3066
+ fn bonsai_is_primary_and_deepseek_remains_selectable() {
3067
+ assert_eq!(DEFAULT_PROVIDER, Provider::Bonsai);
3068
+ assert_eq!(Provider::from_slug("deepseek"), Some(Provider::DeepSeek));
3069
+ assert!(Target::bonsai().is_local());
3070
+ let decision = json!({"model": BONSAI_REPO, "actionable": "Reply",
3071
+ "confidence": 80, "take_action": true});
3072
+ assert_eq!(build_record(&decision, "m", "i")["model"], BONSAI_REPO);
3073
+ }
3074
+
3075
+ #[test]
3076
+ fn invalid_local_json_is_retried_and_all_usage_is_counted() {
3077
+ let mut calls = 0;
3078
+ let result = local_decision_with_retry("original intent", |prompt| {
3079
+ calls += 1;
3080
+ assert!(prompt.contains("original intent"));
3081
+ if calls > 1 { assert!(prompt.contains("Format correction")); }
3082
+ Ok(LocalReply {
3083
+ text: if calls == 1 { "not JSON".into() } else {
3084
+ r#"{"actionable":"Reply","take_action":true,"confidence":80,"summary":"Reply.","generated_response":"OK."}"#.into()
3085
+ },
3086
+ usage: json!({"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}),
3087
+ })
3088
+ }).unwrap();
3089
+ assert_eq!(calls, 2);
3090
+ assert_eq!(result["usage"]["total_tokens"], 30);
3091
+ let mut failures = 0;
3092
+ assert!(
3093
+ local_decision_with_retry("intent", |_| {
3094
+ failures += 1;
3095
+ Ok(LocalReply {
3096
+ text: "bad".into(),
3097
+ usage: json!({}),
3098
+ })
3099
+ })
3100
+ .is_err()
3101
+ );
3102
+ assert_eq!(failures, 3);
3103
+ }
3104
+
3105
+ #[test]
3106
+ fn history_deletion_refuses_malformed_files_and_preserves_other_data() {
3107
+ let path = env::temp_dir().join(format!("shev-history-delete-{}.json", std::process::id()));
3108
+ let mut file = fs::OpenOptions::new()
3109
+ .create_new(true)
3110
+ .write(true)
3111
+ .open(&path)
3112
+ .unwrap();
3113
+ write!(file, "not JSON").unwrap();
3114
+ drop(file);
3115
+ assert!(delete_record(&path, "response1").is_err());
3116
+ assert!(clear_records(&path).is_err());
3117
+ assert_eq!(fs::read_to_string(&path).unwrap(), "not JSON");
3118
+ fs::write(
3119
+ &path,
3120
+ r#"{"response1":{"text":"one"},"response2":{"text":"two"},"metadata":"keep"}"#,
3121
+ )
3122
+ .unwrap();
3123
+ assert!(delete_record(&path, "response1").unwrap());
3124
+ assert!(!delete_record(&path, "missing").unwrap());
3125
+ let saved: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
3126
+ assert!(saved.get("response2").is_some());
3127
+ clear_records(&path).unwrap();
3128
+ assert_eq!(
3129
+ serde_json::from_str::<Value>(&fs::read_to_string(&path).unwrap()).unwrap(),
3130
+ json!({"metadata":"keep"})
3131
+ );
3132
+ fs::remove_file(path).unwrap();
3133
+ }
3134
+
3135
+ #[test]
3136
+ #[cfg(unix)]
3137
+ fn clearing_history_keeps_a_user_symlink_intact() {
3138
+ let target =
3139
+ env::temp_dir().join(format!("shev-history-target-{}.json", std::process::id()));
3140
+ let link = target.with_extension("link");
3141
+ let mut file = fs::OpenOptions::new()
3142
+ .write(true)
3143
+ .create_new(true)
3144
+ .open(&target)
3145
+ .unwrap();
3146
+ write!(file, "{}", json!({"response1":{"text":"one"}})).unwrap();
3147
+ drop(file);
3148
+ std::os::unix::fs::symlink(&target, &link).unwrap();
3149
+ clear_records(&link).unwrap();
3150
+ assert!(
3151
+ fs::symlink_metadata(&link)
3152
+ .unwrap()
3153
+ .file_type()
3154
+ .is_symlink()
3155
+ );
3156
+ assert_eq!(
3157
+ serde_json::from_str::<Value>(&fs::read_to_string(&target).unwrap()).unwrap(),
3158
+ json!({})
3159
+ );
3160
+ fs::remove_file(link).unwrap();
3161
+ fs::remove_file(target).unwrap();
3162
+ }
3163
+
3164
+ #[test]
3165
+ fn parses_a_healthy_decision() {
3166
+ let decision = parse_decision(
3167
+ r#"{"actionable": "Reply to Sam", "take_action": true, "confidence": 87, "generated_response": "Hi Sam, ..."}"#,
3168
+ )
3169
+ .unwrap();
3170
+ assert_eq!(decision["actionable"], json!("Reply to Sam"));
3171
+ assert_eq!(decision["take_action"], json!(true));
3172
+ assert_eq!(decision["confidence"], json!(87));
3173
+ assert_eq!(decision["generated_response"], json!("Hi Sam, ..."));
3174
+ }
3175
+
3176
+ #[test]
3177
+ fn accepts_a_generated_response_written_as_steps() {
3178
+ let decision = parse_decision(
3179
+ r#"{"actionable": "Prepare for spring", "take_action": true, "confidence": 88, "generated_response": ["Open PAWS.", "Check holds.", "Register on October 28."]}"#,
3180
+ )
3181
+ .unwrap();
3182
+ assert_eq!(
3183
+ decision["generated_response"],
3184
+ json!("Open PAWS.\nCheck holds.\nRegister on October 28.")
3185
+ );
3186
+ }
3187
+
3188
+ #[test]
3189
+ fn missing_generated_response_becomes_an_empty_string() {
3190
+ let decision =
3191
+ parse_decision(r#"{"actionable": "", "take_action": false, "confidence": 40}"#)
3192
+ .unwrap();
3193
+ assert_eq!(decision["generated_response"], json!(""));
3194
+ }
3195
+
3196
+ #[test]
3197
+ fn clamps_confidence_into_range() {
3198
+ let low =
3199
+ parse_decision(r#"{"actionable": "", "take_action": false, "confidence": 3}"#).unwrap();
3200
+ assert_eq!(low["confidence"], json!(MIN_CONFIDENCE));
3201
+
3202
+ let high = parse_decision(r#"{"actionable": "", "take_action": false, "confidence": 140}"#)
3203
+ .unwrap();
3204
+ assert_eq!(high["confidence"], json!(MAX_CONFIDENCE));
3205
+
3206
+ let edge =
3207
+ parse_decision(r#"{"actionable": "", "take_action": true, "confidence": 34}"#).unwrap();
3208
+ assert_eq!(edge["confidence"], json!(34));
3209
+
3210
+ let top = parse_decision(r#"{"actionable": "", "take_action": true, "confidence": 100}"#)
3211
+ .unwrap();
3212
+ assert_eq!(top["confidence"], json!(100));
3213
+ }
3214
+
3215
+ #[test]
3216
+ fn tolerates_fences_and_loose_types() {
3217
+ let decision = parse_decision(
3218
+ "```json\n{\"Actionable\": \"fix the build\", \"Take_Action\": \"yes\", \"Confidence\": \"72.6\"}\n```",
3219
+ )
3220
+ .unwrap();
3221
+ assert_eq!(decision["actionable"], json!("fix the build"));
3222
+ assert_eq!(decision["take_action"], json!(true));
3223
+ assert_eq!(decision["confidence"], json!(73));
3224
+ }
3225
+
3226
+ #[test]
3227
+ fn rejects_replies_without_the_decision_keys() {
3228
+ assert!(parse_decision(r#"{"confidence": 50}"#).is_err());
3229
+ assert!(parse_decision("not json at all").is_err());
3230
+ }
3231
+
3232
+ #[test]
3233
+ fn appends_responses_with_incrementing_keys() {
3234
+ let path = env::temp_dir().join(format!("shev-test-{}.json", std::process::id()));
3235
+ let _ = fs::remove_file(&path);
3236
+
3237
+ let decision = json!({ "actionable": "a", "take_action": true, "confidence": 50 });
3238
+ store_decision(&path, &decision, "first", "reply").unwrap();
3239
+ store_decision(&path, &decision, "second", "archive").unwrap();
3240
+
3241
+ let raw = fs::read_to_string(&path).unwrap();
3242
+ let parsed: Value = serde_json::from_str(&raw).unwrap();
3243
+ assert_eq!(parsed["response1"]["input"], json!("first"));
3244
+ assert_eq!(parsed["response2"]["input"], json!("second"));
3245
+ assert_eq!(parsed["response1"]["user_intent"], json!("reply"));
3246
+ assert_eq!(parsed["response2"]["user_intent"], json!("archive"));
3247
+ assert_eq!(parsed["response1"]["confidence"], json!(50));
3248
+
3249
+ let _ = fs::remove_file(&path);
3250
+ }
3251
+
3252
+ #[test]
3253
+ fn reads_the_whole_message_including_blank_lines() {
3254
+ let input = "Hello Adi,\n\nThe schedule is in PAWS.\n\nSincerely,\nAdi\nEOF\n";
3255
+ let mut cursor = io::Cursor::new(input);
3256
+ let message = read_message(&mut cursor).unwrap().unwrap();
3257
+ assert_eq!(
3258
+ message,
3259
+ "Hello Adi,\n\nThe schedule is in PAWS.\n\nSincerely,\nAdi"
3260
+ );
3261
+ }
3262
+
3263
+ #[test]
3264
+ fn quits_on_exit_and_returns_none_on_eof() {
3265
+ let mut cursor = io::Cursor::new("exit\n");
3266
+ assert!(read_message(&mut cursor).unwrap().is_none());
3267
+
3268
+ let mut cursor = io::Cursor::new("");
3269
+ assert!(read_message(&mut cursor).unwrap().is_none());
3270
+ }
3271
+
3272
+ #[test]
3273
+ fn empty_intent_falls_back_to_the_default() {
3274
+ let mut cursor = io::Cursor::new("\n");
3275
+ assert_eq!(read_intent(&mut cursor).unwrap().unwrap(), DEFAULT_INTENT);
3276
+
3277
+ let mut cursor = io::Cursor::new("draft a polite reply\n");
3278
+ assert_eq!(
3279
+ read_intent(&mut cursor).unwrap().unwrap(),
3280
+ "draft a polite reply"
3281
+ );
3282
+ }
3283
+
3284
+ #[test]
3285
+ fn the_prompt_carries_both_the_intent_and_the_message() {
3286
+ let content = USER_TEMPLATE
3287
+ .replace("%INTENT%", "draft a reply")
3288
+ .replace("%MESSAGE%", "Hello Adi,\n\nSincerely, Adi");
3289
+ assert!(content.contains("draft a reply"));
3290
+ assert!(content.contains("Hello Adi,\n\nSincerely, Adi"));
3291
+ assert!(!content.contains("%INTENT%"));
3292
+ assert!(!content.contains("%MESSAGE%"));
3293
+ }
3294
+
3295
+ #[test]
3296
+ fn keeps_a_summary_to_three_sentences() {
3297
+ let summary = clamp_summary("Do this. Then that. Then more. And a fourth that must go.");
3298
+ assert_eq!(summary, "Do this. Then that. Then more.");
3299
+ }
3300
+
3301
+ #[test]
3302
+ fn keeps_a_summary_to_120_words() {
3303
+ let long = (1..=200)
3304
+ .map(|n| format!("word{n}"))
3305
+ .collect::<Vec<_>>()
3306
+ .join(" ");
3307
+ let summary = clamp_summary(&long);
3308
+ assert_eq!(summary.split_whitespace().count(), MAX_SUMMARY_WORDS);
3309
+ assert!(summary.ends_with('…'));
3310
+ }
3311
+
3312
+ #[test]
3313
+ fn parses_the_summary_out_of_a_decision() {
3314
+ let decision = parse_decision(
3315
+ r#"{"actionable": "pay it", "take_action": true, "confidence": 80, "summary": "Pay the invoice today. Then reply to Sam.", "generated_response": "ok"}"#,
3316
+ )
3317
+ .unwrap();
3318
+ assert_eq!(
3319
+ decision["summary"],
3320
+ json!("Pay the invoice today. Then reply to Sam.")
3321
+ );
3322
+ }
3323
+
3324
+ #[test]
3325
+ fn formats_token_counts_compactly() {
3326
+ assert_eq!(format_tokens(0), "0");
3327
+ assert_eq!(format_tokens(950), "950");
3328
+ assert_eq!(format_tokens(1_500), "1.5k");
3329
+ assert_eq!(format_tokens(12_400), "12k");
3330
+ assert_eq!(format_tokens(100_000), "100k");
3331
+ assert_eq!(format_tokens(999_000), "999k");
3332
+ assert_eq!(format_tokens(2_400_000), "2.4M");
3333
+ }
3334
+
3335
+ #[test]
3336
+ fn provider_slugs_round_trip() {
3337
+ assert_eq!(Provider::from_slug("deepseek"), Some(Provider::DeepSeek));
3338
+ assert_eq!(Provider::from_slug("BONSAI"), Some(Provider::Bonsai));
3339
+ assert_eq!(Provider::from_slug("mlx"), Some(Provider::Bonsai));
3340
+ assert_eq!(Provider::from_slug("nope"), None);
3341
+ assert_eq!(Provider::DeepSeek.slug(), "deepseek");
3342
+ assert_eq!(Provider::Bonsai.slug(), "bonsai");
3343
+ }
3344
+
3345
+ #[test]
3346
+ fn reads_byte_sizes_compactly() {
3347
+ assert_eq!(human_bytes(512), "512 B");
3348
+ assert_eq!(human_bytes(1024), "1.0 KB");
3349
+ assert_eq!(human_bytes(1_200_000_000), "1.1 GB");
3350
+ }
3351
+
3352
+ #[test]
3353
+ fn the_targets_point_at_the_right_endpoints() {
3354
+ let bonsai = Target::bonsai();
3355
+ assert!(bonsai.is_local());
3356
+ // the local pack runs in a subprocess, not over HTTP
3357
+ assert!(bonsai.chat_url.is_empty());
3358
+ assert!(bonsai.models_url.is_empty());
3359
+
3360
+ let deepseek = Target::deepseek("sk-test");
3361
+ assert!(!deepseek.is_local());
3362
+ assert_eq!(deepseek.model, DEEPSEEK_MODEL);
3363
+ assert!(
3364
+ deepseek.chat_url.ends_with("/chat/completions"),
3365
+ "{}",
3366
+ deepseek.chat_url
3367
+ );
3368
+ }
3369
+
3370
+ #[test]
3371
+ fn the_bonsai_runner_is_a_self_contained_loader() {
3372
+ // the prompt arrives on stdin and the pack's own loader is used, because
3373
+ // stock MLX loaders cannot load these weights
3374
+ assert!(BONSAI_RUNNER.contains("sys.stdin.read()"));
3375
+ assert!(BONSAI_RUNNER.contains("load_vl_model"));
3376
+ assert!(BONSAI_RUNNER.contains("artifact"));
3377
+ assert!(BONSAI_RUNNER.contains("generation_config.json"));
3378
+ assert!(BONSAI_RUNNER.contains("sampler_settings"));
3379
+ }
3380
+
3381
+ #[test]
3382
+ fn the_warm_worker_loads_once_and_stops_at_the_json_object() {
3383
+ // it announces readiness, then loops over requests without reloading
3384
+ assert!(BONSAI_WORKER.contains("emit({\"ready\": True})"));
3385
+ assert!(BONSAI_WORKER.contains("for line in sys.stdin"));
3386
+ assert!(BONSAI_WORKER.contains("class JsonWatch"));
3387
+ assert!(BONSAI_WORKER.contains("artifact"));
3388
+ assert!(BONSAI_WORKER.contains("generation_config.json"));
3389
+ // the model is loaded in Engine.__init__, i.e. once, not per request
3390
+ assert!(BONSAI_WORKER.contains("class Engine"));
3391
+ }
3392
+
3393
+ #[test]
3394
+ fn the_vision_worker_really_stops_at_json_and_reports_usage() {
3395
+ let script = r#"
3396
+ import ast, sys, types
3397
+ source = ast.parse(sys.stdin.read())
3398
+ classes = [node for node in source.body if isinstance(node, ast.ClassDef)]
3399
+ env = {'sampler_settings':lambda: {}, 'render':lambda s:s, 'max_tokens':256}
3400
+ exec(compile(ast.Module(body=classes,type_ignores=[]), '<worker>', 'exec'), env)
3401
+ watch = env['JsonWatch']()
3402
+ assert not watch.feed('{"a":"brace } inside string",')
3403
+ assert watch.feed('"b":{"c":1}} trailing')
3404
+ closed = []
3405
+ def stream(*args, **kwargs):
3406
+ try:
3407
+ yield types.SimpleNamespace(text='{"a":',prompt_tokens=9,generation_tokens=1,total_tokens=10)
3408
+ yield types.SimpleNamespace(text='true} trailing',prompt_tokens=9,generation_tokens=2,total_tokens=11)
3409
+ raise AssertionError('generated beyond closed JSON')
3410
+ finally:
3411
+ closed.append(True)
3412
+ sys.modules['mlx_vlm'] = types.SimpleNamespace(stream_generate=stream)
3413
+ engine = env['Engine'].__new__(env['Engine'])
3414
+ engine.vision, engine.model, engine.processor = True, None, None
3415
+ assert engine.generate('prompt') == '{"a":true}'
3416
+ assert closed == [True]
3417
+ assert engine.usage['total_tokens'] == 11
3418
+ "#;
3419
+ let Ok(mut child) = std::process::Command::new("python3")
3420
+ .args(["-c", script])
3421
+ .stdin(std::process::Stdio::piped())
3422
+ .stdout(std::process::Stdio::piped())
3423
+ .stderr(std::process::Stdio::piped())
3424
+ .spawn()
3425
+ else {
3426
+ return;
3427
+ };
3428
+ child
3429
+ .stdin
3430
+ .take()
3431
+ .unwrap()
3432
+ .write_all(BONSAI_WORKER.as_bytes())
3433
+ .unwrap();
3434
+ let output = child.wait_with_output().unwrap();
3435
+ assert!(
3436
+ output.status.success(),
3437
+ "{}",
3438
+ String::from_utf8_lossy(&output.stderr)
3439
+ );
3440
+ }
3441
+
3442
+ #[test]
3443
+ fn the_local_prompt_keeps_the_same_five_fields_in_fewer_tokens() {
3444
+ for key in [
3445
+ "actionable",
3446
+ "take_action",
3447
+ "confidence",
3448
+ "summary",
3449
+ "generated_response",
3450
+ ] {
3451
+ assert!(
3452
+ LOCAL_SYSTEM_PROMPT.contains(key),
3453
+ "the lean prompt lost {key}"
3454
+ );
3455
+ }
3456
+ // the whole point is that the fast prompt is much smaller than the remote one
3457
+ assert!(LOCAL_SYSTEM_PROMPT.len() * 2 < SYSTEM_PROMPT.len());
3458
+ }
3459
+
3460
+ /// End-to-end timing for the resident local engine. Needs the Bonsai pack
3461
+ /// installed, so it is ignored by default:
3462
+ /// cargo test --lib the_warm_local -- --ignored --nocapture
3463
+ #[test]
3464
+ #[ignore = "needs the local Bonsai pack installed"]
3465
+ fn the_warm_local_engine_serves_a_second_decision_faster() {
3466
+ if !bonsai_installed() {
3467
+ eprintln!("bonsai is not installed; skipping the warm-engine timing");
3468
+ return;
3469
+ }
3470
+ let client = http_client().unwrap();
3471
+ let target = Target::bonsai();
3472
+ let message = "the electricity bill is due friday, pay it tonight";
3473
+ let intent = "decide whether this needs action and say so";
3474
+
3475
+ let first = std::time::Instant::now();
3476
+ let a = request_decision_target(&client, &target, message, intent).unwrap();
3477
+ let first_ms = first.elapsed().as_millis();
3478
+
3479
+ let second = std::time::Instant::now();
3480
+ let b = request_decision_target(&client, &target, message, intent).unwrap();
3481
+ let second_ms = second.elapsed().as_millis();
3482
+
3483
+ eprintln!("first (load + decide): {first_ms} ms");
3484
+ eprintln!("second (warm decide): {second_ms} ms");
3485
+ assert!(a.get("take_action").is_some());
3486
+ assert!(b.get("take_action").is_some());
3487
+ assert!(b["usage"]["total_tokens"].as_u64().unwrap_or(0) > 0);
3488
+ assert!(
3489
+ second_ms < first_ms,
3490
+ "the warm decision should beat the cold one ({second_ms} ms vs {first_ms} ms)"
3491
+ );
3492
+ }
3493
+
3494
+ #[test]
3495
+ fn reads_a_skill_front_matter_without_touching_the_body() {
3496
+ let text = "---\nname: tldraw-offline\ndescription: \"Drive the offline canvas.\"\n---\n\n# Steps\n\nDo the thing.\n";
3497
+ let (name, description) = parse_front_matter(text);
3498
+ assert_eq!(name.as_deref(), Some("tldraw-offline"));
3499
+ assert_eq!(description.as_deref(), Some("Drive the offline canvas."));
3500
+
3501
+ let body = strip_front_matter(text);
3502
+ assert!(body.starts_with("# Steps"), "body was {body:?}");
3503
+ assert!(body.contains("Do the thing."));
3504
+ assert!(
3505
+ !body.contains("description:"),
3506
+ "front matter leaked through"
3507
+ );
3508
+ }
3509
+
3510
+ #[test]
3511
+ fn a_skill_without_front_matter_is_left_whole() {
3512
+ let text = "# Just a body\n\nNo front matter here.\n";
3513
+ assert_eq!(parse_front_matter(text), (None, None));
3514
+ assert_eq!(strip_front_matter(text), text);
3515
+ }
3516
+
3517
+ #[test]
3518
+ fn reads_a_jsonc_config_with_comments_and_trailing_commas() {
3519
+ let text = r#"{
3520
+ // the servers shev can borrow
3521
+ "mcp": {
3522
+ "github": { "type": "remote", "url": "https://example/" }, /* inline */
3523
+ "shadcn": { "command": ["npx", "shadcn@latest", "mcp"], },
3524
+ },
3525
+ }"#;
3526
+ let value = parse_jsonc(text).expect("jsonc should parse");
3527
+ let mcp = value.get("mcp").unwrap().as_object().unwrap();
3528
+ assert!(mcp.contains_key("github"), "the github tool must survive");
3529
+ assert_eq!(mcp["shadcn"]["command"].as_array().unwrap().len(), 3);
3530
+
3531
+ // a `//` inside a string is not a comment
3532
+ let value = parse_jsonc(r#"{"url": "https://a//b"}"#).unwrap();
3533
+ assert_eq!(value["url"], "https://a//b");
3534
+ }
3535
+
3536
+ #[test]
3537
+ fn carries_usage_into_the_record_and_counts_it() {
3538
+ let decision = json!({
3539
+ "actionable": "a",
3540
+ "take_action": true,
3541
+ "confidence": 50,
3542
+ "summary": "do it.",
3543
+ "generated_response": "done",
3544
+ "usage": { "prompt_tokens": 100, "completion_tokens": 40, "total_tokens": 140 }
3545
+ });
3546
+ let record = build_record(&decision, "message", "intent");
3547
+ assert_eq!(record["summary"], json!("do it."));
3548
+ assert_eq!(record_tokens(&record), 140);
3549
+
3550
+ // totals fall back to prompt + completion when the API omits the total
3551
+ let record = build_record(
3552
+ &json!({ "usage": { "prompt_tokens": 10, "completion_tokens": 5 } }),
3553
+ "m",
3554
+ "i",
3555
+ );
3556
+ assert_eq!(record_tokens(&record), 15);
3557
+ }
3558
+
3559
+ #[test]
3560
+ fn the_mcp_server_lists_and_reads_decisions() {
3561
+ let path = env::temp_dir().join(format!("shev-mcp-test-{}.json", std::process::id()));
3562
+ let _ = fs::remove_file(&path);
3563
+ let decision = json!({
3564
+ "actionable": "Pay the invoice",
3565
+ "take_action": true,
3566
+ "confidence": 90,
3567
+ "summary": "Pay it today.",
3568
+ "generated_response": "Paid."
3569
+ });
3570
+ append_record(
3571
+ &path,
3572
+ &build_record(&decision, "the invoice is overdue", "reply"),
3573
+ )
3574
+ .unwrap();
3575
+ append_record(
3576
+ &path,
3577
+ &build_record(
3578
+ &json!({ "actionable": "", "take_action": false, "confidence": 40 }),
3579
+ "just a notice",
3580
+ "archive",
3581
+ ),
3582
+ )
3583
+ .unwrap();
3584
+
3585
+ // tools/list
3586
+ let tools = mcp_tools();
3587
+ let names: Vec<&str> = tools
3588
+ .as_array()
3589
+ .unwrap()
3590
+ .iter()
3591
+ .map(|tool| tool["name"].as_str().unwrap())
3592
+ .collect();
3593
+ assert!(names.contains(&"list_decisions"));
3594
+ assert!(names.contains(&"search_decisions"));
3595
+
3596
+ // tools/call list_decisions
3597
+ let listed = mcp_call(&path, Some(&json!({ "name": "list_decisions" }))).unwrap();
3598
+ let text = listed["content"][0]["text"].as_str().unwrap();
3599
+ assert!(text.contains("response1"), "{text}");
3600
+ assert!(text.contains("response2"), "{text}");
3601
+
3602
+ // tools/call latest_decision
3603
+ let latest = mcp_call(&path, Some(&json!({ "name": "latest_decision" }))).unwrap();
3604
+ let text = latest["content"][0]["text"].as_str().unwrap();
3605
+ assert!(text.contains("response2"), "{text}");
3606
+
3607
+ // tools/call search_decisions
3608
+ let found = mcp_call(
3609
+ &path,
3610
+ Some(&json!({ "name": "search_decisions", "arguments": { "query": "overdue" } })),
3611
+ )
3612
+ .unwrap();
3613
+ let text = found["content"][0]["text"].as_str().unwrap();
3614
+ assert!(text.contains("response1"), "{text}");
3615
+ assert!(!text.contains("response2"), "{text}");
3616
+
3617
+ // resources/read
3618
+ let resource = mcp_read(&path, Some(&json!({ "uri": "shev://decisions" }))).unwrap();
3619
+ let text = resource["contents"][0]["text"].as_str().unwrap();
3620
+ assert!(text.contains("Paid."), "{text}");
3621
+ assert!(text.contains("just a notice"), "{text}");
3622
+
3623
+ let latest = mcp_read(&path, Some(&json!({ "uri": "shev://decisions/latest" }))).unwrap();
3624
+ let text = latest["contents"][0]["text"].as_str().unwrap();
3625
+ assert!(text.contains("just a notice"), "{text}");
3626
+
3627
+ let _ = fs::remove_file(&path);
3628
+ }
3629
+
3630
+ #[test]
3631
+ fn reads_the_mcp_opencode_registration_and_removes_it() {
3632
+ let path = env::temp_dir().join(format!("shev-oc-test-{}.json", std::process::id()));
3633
+ let _ = fs::remove_file(&path);
3634
+ // JSONC with a trailing comma and a comment, as opencode writes it
3635
+ fs::write(
3636
+ &path,
3637
+ "{\n // mine\n \"mcp\": {\n \"other\": { \"type\": \"local\", \"command\": [\"x\"] },\n },\n}\n",
3638
+ )
3639
+ .unwrap();
3640
+
3641
+ let entry = json!({ "type": "local", "command": ["/bin/shev", "--mcp"], "enabled": true });
3642
+ assert_eq!(register_opencode(&path, &entry).unwrap(), "jsonc");
3643
+ let merged = fs::read_to_string(&path).unwrap();
3644
+ assert!(merged.contains("\"shev\""));
3645
+ assert!(merged.contains("\"other\""));
3646
+
3647
+ assert!(unregister_opencode(&path, "jsonc").unwrap());
3648
+ let cleaned = fs::read_to_string(&path).unwrap();
3649
+ assert!(!cleaned.contains("shev"));
3650
+ assert!(cleaned.contains("\"other\""));
3651
+
3652
+ let _ = fs::remove_file(&path);
3653
+ let _ = fs::remove_file(format!("{}.shev.bak", path.display()));
3654
+ }
3655
+
3656
+ #[test]
3657
+ fn native_mcp_registration_preserves_other_servers_and_is_idempotent() {
3658
+ let entry = json!({"type":"local","command":["shev","--mcp"]});
3659
+ for (index, original) in [
3660
+ "{\"mcp\":{\"servers\":{\"other\":{\"type\":\"remote\",\"url\":\"https://example.test\"}}}}",
3661
+ "{\n// keep this comment\n\"mcp\": {\"servers\": {\"other\": {\"type\":\"remote\",\"url\":\"https://example.test\"},},},\n}",
3662
+ ].into_iter().enumerate() {
3663
+ let path = env::temp_dir().join(format!("shev-native-{}-{index}.jsonc", std::process::id()));
3664
+ fs::write(&path, original).unwrap();
3665
+ let mode = register_opencode(&path, &entry).unwrap();
3666
+ let registered = fs::read_to_string(&path).unwrap();
3667
+ assert_eq!(parse_jsonc(&registered).unwrap()["mcp"]["servers"]["shev"], entry);
3668
+ register_opencode(&path, &entry).unwrap();
3669
+ assert_eq!(fs::read_to_string(&path).unwrap(), registered);
3670
+ assert!(register_opencode(&path, &json!({"command":["different"]})).is_err());
3671
+ assert_eq!(fs::read_to_string(&path).unwrap(), registered);
3672
+ assert!(unregister_opencode(&path, mode).unwrap());
3673
+ assert_eq!(parse_jsonc(&fs::read_to_string(&path).unwrap()), parse_jsonc(original));
3674
+ fs::remove_file(&path).unwrap();
3675
+ fs::remove_file(format!("{}.shev.bak", path.display())).unwrap();
3676
+ }
3677
+ }
3678
+ }