@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/Cargo.lock +2050 -0
- package/Cargo.toml +32 -0
- package/LICENSE +201 -0
- package/README.md +429 -2
- package/assets/background.rgb +0 -0
- package/assets/readme/app-decision.png +0 -0
- package/assets/readme/app-home.png +0 -0
- package/assets/readme/hero.jpg +0 -0
- package/bin/shev-cli.cjs +2 -0
- package/bin/shev.cjs +2 -0
- package/install.sh +122 -0
- package/package.json +9 -4
- package/scripts/install.cjs +28 -0
- package/scripts/launch.cjs +64 -0
- package/scripts/launch.test.cjs +56 -0
- package/src/bin/shev.rs +4534 -0
- package/src/installation.rs +390 -0
- package/src/integrations.rs +563 -0
- package/src/lib.rs +3678 -0
- package/src/main.rs +18 -0
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(®istered).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
|
+
}
|