Skip to main content

ljos_cli/
jev.rs

1//! The prompt hook's judgments through Jev, TypeSafe's decision model, on
2//! TypeSafe's own API or OpenRouter's Decisions API: which candidate claims
3//! bear on a prompt, whether the prompt corrects the agent, and whether it
4//! puts a choice.
5//!
6//! One request answers all three: the prompt and the candidates are the
7//! state, and each judgment is a `noul` question, a probability that the
8//! statement is true. Opt-in per machine through `~/.config/ljos/jev.toml`,
9//! because the call sends the prompt and the candidate claims off the
10//! machine; with no file, no key, a failure or a spent budget, the hook
11//! keeps its local path.
12
13use std::collections::BTreeMap;
14use std::path::PathBuf;
15use std::time::Duration;
16
17use serde_json::Value;
18
19/// `~/.config/ljos/jev.toml`.
20#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
21pub struct Config {
22    /// Off unless set: the call sends the prompt and claims off the machine.
23    #[serde(default)]
24    pub enabled: bool,
25    /// A file holding the key, one line, mode 0600.
26    #[serde(default)]
27    pub key_file: Option<String>,
28    /// A command that prints the key on its first line, such as
29    /// `["pass", "show", "api/typesafe/jev"]`; asked once per login and held
30    /// in the runtime directory, mode 0600.
31    #[serde(default)]
32    pub key_cmd: Option<Vec<String>>,
33    /// The model: `jev-1.13.0` on TypeSafe's API, `typesafe/jev-1.13` on
34    /// OpenRouter's.
35    #[serde(default = "default_model")]
36    pub model: String,
37    /// How long the hook waits for the answer.
38    #[serde(default = "default_budget")]
39    pub budget_ms: u64,
40    /// TypeSafe's endpoint, or `https://openrouter.ai/api/alpha/decisions`.
41    #[serde(default = "default_endpoint")]
42    pub endpoint: String,
43    /// The month's spend, in US dollars, past which the hook stops asking.
44    #[serde(default = "default_monthly")]
45    pub monthly_usd: f64,
46    /// A prompt with fewer words is an acknowledgement ("yes", "keep
47    /// going"), with too little in it for a judgment to add anything.
48    #[serde(default = "default_min_words")]
49    pub min_words: usize,
50    /// Fewer candidates than this is nothing to choose between; the local
51    /// filters answer.
52    #[serde(default = "default_min_candidates")]
53    pub min_candidates: usize,
54    /// US dollars per million input tokens, for an API whose answer does
55    /// not carry its cost. Output is not charged.
56    #[serde(default = "default_price_in")]
57    pub usd_per_mtok_in: f64,
58    /// The probability at which a candidate counts as bearing on the
59    /// prompt; higher lets fewer off-topic claims through.
60    #[serde(default = "default_cut")]
61    pub bears_at: f64,
62    /// The probability at which the prompt counts as a correction or a
63    /// choice.
64    #[serde(default = "default_cut")]
65    pub cue_at: f64,
66    /// A persona ballot whose confidence is under this goes to a subagent
67    /// instead of being cast.
68    #[serde(default = "default_escalate")]
69    pub escalate_below: f64,
70    /// Days an answer is kept and given again for an identical request, at
71    /// no cost; 0 turns the cache off. Jev keeps no cache of its own.
72    #[serde(default = "default_cache_days")]
73    pub cache_days: u64,
74}
75
76fn default_model() -> String {
77    "jev-1.13.0".into()
78}
79fn default_budget() -> u64 {
80    2000
81}
82fn default_endpoint() -> String {
83    "https://api.typesafe.ai/v1/systemone".into()
84}
85fn default_monthly() -> f64 {
86    4.0
87}
88fn default_min_words() -> usize {
89    4
90}
91fn default_min_candidates() -> usize {
92    2
93}
94fn default_cache_days() -> u64 {
95    7
96}
97fn default_escalate() -> f64 {
98    0.8
99}
100fn default_cut() -> f64 {
101    0.5
102}
103fn default_price_in() -> f64 {
104    0.042
105}
106
107/// What a call cost: the API's own figure when it sends one (OpenRouter
108/// does), else the input tokens at the configured price.
109fn cost_of(body: &Value, usd_per_mtok_in: f64) -> f64 {
110    body["usage"]["cost"].as_f64().unwrap_or_else(|| {
111        body["usage"]["input_tokens"].as_f64().unwrap_or(0.0) * usd_per_mtok_in / 1e6
112    })
113}
114
115/// The longest prompt the state carries; a pasted log past it adds cost
116/// and no judgment.
117const PROMPT_CHARS: usize = 2000;
118
119fn config_path() -> PathBuf {
120    std::env::var_os("XDG_CONFIG_HOME")
121        .filter(|v| !v.is_empty())
122        .map(PathBuf::from)
123        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
124        .unwrap_or_else(|| PathBuf::from(".config"))
125        .join("ljos")
126        .join("jev.toml")
127}
128
129fn expand(path: &str) -> PathBuf {
130    match path.strip_prefix("~/") {
131        Some(rest) => std::env::var_os("HOME")
132            .map_or_else(|| PathBuf::from(path), |h| PathBuf::from(h).join(rest)),
133        None => PathBuf::from(path),
134    }
135}
136
137fn read_config() -> Option<Config> {
138    let text = std::fs::read_to_string(config_path()).ok()?;
139    toml::from_str(&text).ok()
140}
141
142/// Whether this machine turned Jev on, key or not. The hook's local path
143/// then skips the cross-encoder, which is what Jev stands in for.
144#[must_use]
145pub fn enabled() -> bool {
146    read_config().is_some_and(|c| c.enabled)
147}
148
149/// The key from the first line of what a key file or command holds. A
150/// `name: value` or `name=value` line gives its value.
151fn key_from(text: &str) -> Option<String> {
152    let line = text.lines().next()?.trim();
153    let value = line
154        .rsplit(|c: char| c == ':' || c == '=' || c.is_whitespace())
155        .next()
156        .unwrap_or(line)
157        .trim();
158    (!value.is_empty()).then(|| value.to_string())
159}
160
161fn key_cache() -> Option<PathBuf> {
162    let dir = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty())?;
163    Some(PathBuf::from(dir).join("ljos").join("jev-key"))
164}
165
166/// Run the key command once, with no terminal to prompt on and three
167/// seconds to answer, and hold what it printed for the rest of the login.
168fn key_by_command(argv: &[String]) -> Option<String> {
169    use std::io::Write;
170    use std::os::unix::fs::OpenOptionsExt;
171    let cache = key_cache();
172    if let Some(key) = cache
173        .as_ref()
174        .and_then(|p| std::fs::read_to_string(p).ok())
175        .and_then(|t| key_from(&t))
176    {
177        return Some(key);
178    }
179    let (prog, args) = argv.split_first()?;
180    let out = std::process::Command::new("timeout")
181        .arg("3")
182        .arg(prog)
183        .args(args)
184        .stdin(std::process::Stdio::null())
185        .stderr(std::process::Stdio::null())
186        .output()
187        .ok()?;
188    if !out.status.success() {
189        return None;
190    }
191    let key = key_from(&String::from_utf8_lossy(&out.stdout))?;
192    if let Some(path) = cache {
193        let _ = std::fs::create_dir_all(path.parent()?);
194        if let Ok(mut f) = std::fs::OpenOptions::new()
195            .write(true)
196            .create(true)
197            .truncate(true)
198            .mode(0o600)
199            .open(&path)
200        {
201            let _ = writeln!(f, "{key}");
202        }
203    }
204    Some(key)
205}
206
207/// The machine's Jev setting, when it turned Jev on, its key is there and
208/// the month's spend is under its cap.
209#[must_use]
210pub fn config() -> Option<(Config, String)> {
211    let cfg = read_config()?;
212    if !cfg.enabled || month_cost().unwrap_or(0.0) >= cfg.monthly_usd {
213        return None;
214    }
215    let key = match (&cfg.key_cmd, &cfg.key_file) {
216        (Some(argv), _) => key_by_command(argv)?,
217        (None, Some(file)) => key_from(&std::fs::read_to_string(expand(file)).ok()?)?,
218        (None, None) => return None,
219    };
220    Some((cfg, key))
221}
222
223/// What Jev said about one prompt.
224#[derive(Debug, Clone, Default, PartialEq)]
225pub struct Judgment {
226    /// Probability that each candidate, by its index, bears on the prompt.
227    pub bears: Vec<f64>,
228    /// Probability the prompt corrects something the agent did or forgot.
229    pub correction: f64,
230    /// Probability the prompt puts a choice between options to the agent.
231    pub choice: f64,
232    /// What the call cost, in US dollars.
233    pub cost: f64,
234    /// The machine's cut for `bears`.
235    pub bears_at: f64,
236    /// The machine's cut for `correction` and `choice`.
237    pub cue_at: f64,
238}
239
240impl Judgment {
241    /// Whether candidate `i` bears on the prompt at the machine's cut.
242    #[must_use]
243    pub fn bears(&self, i: usize) -> bool {
244        self.bears.get(i).is_some_and(|p| *p >= self.bears_at)
245    }
246}
247
248/// The request body: the prompt and numbered candidates as state, one
249/// `noul` per candidate and one each for a correction and a choice.
250#[must_use]
251pub fn request(model: &str, prompt: &str, candidates: &[&str]) -> Value {
252    let prompt: String = prompt.chars().take(PROMPT_CHARS).collect();
253    let mut state = format!("Prompt from the person to the agent:\n{prompt}\n\nStored claims:\n");
254    for (i, text) in candidates.iter().enumerate() {
255        state.push_str(&format!("[{i}] {text}\n"));
256    }
257    let mut questions = serde_json::Map::new();
258    for i in 0..candidates.len() {
259        questions.insert(
260            format!("bears_{i}"),
261            serde_json::json!({
262                "type": "noul",
263                "instructions": format!("Does stored claim [{i}] bear on what the prompt asks the agent to do now?"),
264                "criteria": {
265                    "true": "The claim changes or informs how the agent should act on this prompt",
266                    "false": "The claim is about something else, or only shares words with the prompt"
267                }
268            }),
269        );
270    }
271    questions.insert(
272        "correction".into(),
273        serde_json::json!({
274            "type": "noul",
275            "instructions": "Does the person correct the agent for something it did, forgot or was already told?",
276            "criteria": {
277                "true": "The prompt tells the agent it was wrong or should already know",
278                "false": "The prompt asks for work or information without correcting the agent"
279            }
280        }),
281    );
282    questions.insert(
283        "choice".into(),
284        serde_json::json!({
285            "type": "noul",
286            "instructions": "Does the prompt put to the agent a choice between two or more defensible options?",
287            "criteria": {
288                "true": "The person asks which of several ways to take, or weighs options",
289                "false": "The person names one thing to do, or asks a factual question"
290            }
291        }),
292    );
293    serde_json::json!({ "model": model, "state": state, "questions": questions })
294}
295
296/// Read the answers into a judgment; `None` when a question went
297/// unanswered, so the caller falls back rather than trusting half an answer.
298#[must_use]
299pub fn parse(body: &Value, candidates: usize) -> Option<Judgment> {
300    let answers = body.get("answers")?.as_object()?;
301    let noul = |key: &str| answers.get(key)?.get("noul")?.as_f64();
302    let bears: Vec<f64> = (0..candidates)
303        .map(|i| noul(&format!("bears_{i}")))
304        .collect::<Option<_>>()?;
305    Some(Judgment {
306        bears,
307        correction: noul("correction")?,
308        choice: noul("choice")?,
309        cost: 0.0,
310        bears_at: 0.5,
311        cue_at: 0.5,
312    })
313}
314
315/// Send one request inside the configured budget, record its cost and
316/// log its answers; the answers object, or `None` on any failure.
317fn post(cfg: &Config, key: &str, body: Value, kind: &str, about: Value) -> Option<Value> {
318    let request = format!("{}\n{body}", cfg.endpoint);
319    if let Some(reply) = cached(&request, cfg.cache_days) {
320        count("cached");
321        return Some(reply);
322    }
323    let reply: Value = ureq::post(&cfg.endpoint)
324        .timeout(Duration::from_millis(cfg.budget_ms))
325        .set("Authorization", &format!("Bearer {key}"))
326        .set("Content-Type", "application/json")
327        .send_json(body)
328        .ok()?
329        .into_json()
330        .ok()?;
331    let answers = reply.get("answers")?.clone();
332    let cost = cost_of(&reply, cfg.usd_per_mtok_in);
333    record_cost(cost);
334    if cfg.cache_days > 0 {
335        keep(&request, &reply);
336    }
337    log(&serde_json::json!({
338        "ts": crate::now_utc(),
339        "kind": kind,
340        "model": cfg.model,
341        "about": about,
342        "answers": answers,
343        "cost": cost,
344    }));
345    Some(reply)
346}
347
348/// Ask Jev about one prompt, inside the configured budget.
349#[must_use]
350pub fn judge(prompt: &str, candidates: &[&str]) -> Option<Judgment> {
351    let (cfg, key) = config()?;
352    let body = request(&cfg.model, prompt, candidates);
353    let reply = post(&cfg, &key, body, "hook", Value::Null)?;
354    let mut judged = parse(&reply, candidates.len())?;
355    judged.cost = cost_of(&reply, cfg.usd_per_mtok_in);
356    judged.bears_at = cfg.bears_at;
357    judged.cue_at = cfg.cue_at;
358    Some(judged)
359}
360
361/// A persona's ballot as Jev answered it: the choice with its confidence
362/// and the probability of every option, and its forecast of the share each
363/// option gets from the rest of the panel.
364#[derive(Debug, Clone, PartialEq)]
365pub struct Ballot {
366    pub choice: String,
367    pub confidence: f64,
368    pub probabilities: BTreeMap<String, f64>,
369    pub forecast: BTreeMap<String, f64>,
370    /// The machine's cut under which the ballot goes to a subagent.
371    pub escalate_below: f64,
372}
373
374impl Ballot {
375    /// Whether Jev is too unsure for its answer to stand as the ballot.
376    #[must_use]
377    pub fn escalates(&self) -> bool {
378        self.confidence < self.escalate_below
379    }
380}
381
382/// The ballot request: the persona's brief as state, one `choice` for its
383/// own vote and one for what the rest of the panel will pick.
384#[must_use]
385pub fn ballot_request(model: &str, brief: &str, options: &[String]) -> Value {
386    let criteria = |verb: &str| -> Value {
387        options
388            .iter()
389            .map(|o| (o.clone(), Value::String(format!("{verb} {o}"))))
390            .collect::<serde_json::Map<_, _>>()
391            .into()
392    };
393    serde_json::json!({
394        "model": model,
395        "state": brief,
396        "questions": {
397            "ballot": {
398                "type": "choice",
399                "instructions": "You are the persona the state describes. Which option do you vote for, from your own view and what you know?",
400                "criteria": criteria("vote for"),
401            },
402            "forecast": {
403                "type": "choice",
404                "instructions": "Which option will most of the other reviewers on this panel vote for?",
405                "criteria": criteria("most others pick"),
406            },
407        }
408    })
409}
410
411/// Read a ballot answer; `None` when either question went unanswered or
412/// the choice is not one of the options.
413#[must_use]
414pub fn parse_ballot(body: &Value, options: &[String]) -> Option<Ballot> {
415    let answers = body.get("answers")?;
416    let probs = |key: &str| -> Option<BTreeMap<String, f64>> {
417        let map = answers.get(key)?.get("probabilities")?.as_object()?;
418        Some(
419            map.iter()
420                .filter_map(|(k, v)| Some((k.clone(), v.as_f64()?)))
421                .collect(),
422        )
423    };
424    let ballot = answers.get("ballot")?;
425    let choice = ballot.get("choice")?.as_str()?.to_string();
426    if !options.contains(&choice) {
427        return None;
428    }
429    Some(Ballot {
430        confidence: ballot.get("confidence")?.as_f64()?,
431        probabilities: probs("ballot")?,
432        forecast: probs("forecast")?,
433        choice,
434        escalate_below: 0.8,
435    })
436}
437
438/// Ask Jev for a persona's ballot on an issue.
439#[must_use]
440pub fn ballot(persona: &str, issue: &str, brief: &str, options: &[String]) -> Option<Ballot> {
441    let (cfg, key) = config()?;
442    let body = ballot_request(&cfg.model, brief, options);
443    let about = serde_json::json!({"issue": issue, "persona": persona, "options": options});
444    let reply = post(&cfg, &key, body, "ballot", about)?;
445    let mut b = parse_ballot(&reply, options)?;
446    b.escalate_below = cfg.escalate_below;
447    Some(b)
448}
449
450/// `$XDG_CACHE_HOME/ljos/jev`, one file per request.
451fn cache_dir() -> Option<PathBuf> {
452    Some(
453        std::env::var_os("XDG_CACHE_HOME")
454            .filter(|v| !v.is_empty())
455            .map(PathBuf::from)
456            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))?
457            .join("ljos")
458            .join("jev"),
459    )
460}
461
462/// The file an identical request lands in. The hash only names the file;
463/// the file holds the whole request, and a hit must match it exactly.
464fn cache_file(request: &str) -> Option<PathBuf> {
465    use std::hash::{Hash, Hasher};
466    let mut h = std::collections::hash_map::DefaultHasher::new();
467    request.hash(&mut h);
468    Some(cache_dir()?.join(format!("{:016x}.json", h.finish())))
469}
470
471/// The answer to an identical request made within `days`.
472fn cached(request: &str, days: u64) -> Option<Value> {
473    if days == 0 {
474        return None;
475    }
476    let path = cache_file(request)?;
477    let age = std::fs::metadata(&path)
478        .ok()?
479        .modified()
480        .ok()?
481        .elapsed()
482        .ok()?;
483    if age > Duration::from_secs(days * 86_400) {
484        let _ = std::fs::remove_file(&path);
485        return None;
486    }
487    let entry: Value = serde_json::from_str(&std::fs::read_to_string(&path).ok()?).ok()?;
488    (entry["request"].as_str() == Some(request)).then(|| entry["reply"].clone())
489}
490
491fn keep(request: &str, reply: &Value) {
492    let Some(path) = cache_file(request) else {
493        return;
494    };
495    if let Some(dir) = path.parent() {
496        let _ = std::fs::create_dir_all(dir);
497    }
498    let entry = serde_json::json!({"request": request, "reply": reply});
499    let _ = std::fs::write(path, entry.to_string());
500}
501
502/// Add one to this month's tally named `what` (`calls`, `cached`).
503fn count(what: &str) {
504    let Some(dir) = state_dir() else { return };
505    let _ = std::fs::create_dir_all(&dir);
506    let path = dir.join("jev-cost.toml");
507    let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
508        .ok()
509        .and_then(|t| toml::from_str(&t).ok())
510        .unwrap_or_default();
511    *totals
512        .entry(format!("{}-{what}", this_month()))
513        .or_default() += 1.0;
514    if let Ok(text) = toml::to_string(&totals) {
515        let _ = std::fs::write(path, text);
516    }
517}
518
519/// The stop audit's cuts. A stop is held back only on a near-certain
520/// answer: the model is asked about the agent's own words, and a false
521/// block costs the person a turn.
522pub const AUDIT_CLAIM_AT: f64 = 0.9;
523/// The test run shown counts as red at or under this.
524pub const AUDIT_RED_BELOW: f64 = 0.1;
525/// The final message counts as deferring asked work at or over this.
526pub const AUDIT_DEFER_AT: f64 = 0.9;
527
528/// What Jev said about an agent about to stop.
529#[derive(Debug, Clone, Copy, PartialEq)]
530pub struct Audit {
531    /// The final message claims the work is done, passing or ready.
532    pub claims_complete: f64,
533    /// The last test output shown passes with no failure.
534    pub tests_green: f64,
535    /// The final message puts part of the asked work off, or out of scope.
536    pub deferral: f64,
537}
538
539/// The audit request: the turn as state, three nouls.
540#[must_use]
541pub fn audit_request(model: &str, state: &str) -> Value {
542    serde_json::json!({
543        "model": model,
544        "state": state,
545        "questions": {
546            "claims_complete": {
547                "type": "noul",
548                "instructions": "Does the agent's final message claim the asked work is done, complete, passing, green or ready?",
549                "criteria": {
550                    "true": "It says the work is finished or the tests pass",
551                    "false": "It reports progress, a failure, a question or what is still open"
552                }
553            },
554            "tests_green": {
555                "type": "noul",
556                "instructions": "Does the most recent test output in the state pass, with no failed, errored or crashed test?",
557                "criteria": {
558                    "true": "The latest run reports every test passing",
559                    "false": "The latest run reports a failure, an error, a crash or a build that did not finish"
560                }
561            },
562            "deferral": {
563                "type": "noul",
564                "instructions": "Does the final message put part of what the person asked off to later, or call it out of scope, without naming something outside the agent's control that blocks it?",
565                "criteria": {
566                    "true": "It leaves asked work for a later change, session or person, with no external block",
567                    "false": "It finishes the asked work, or names a real block such as a missing credential or a failing external service"
568                }
569            }
570        }
571    })
572}
573
574/// Read the audit; `None` on a partial answer.
575#[must_use]
576pub fn parse_audit(body: &Value) -> Option<Audit> {
577    let a = body.get("answers")?;
578    let noul = |k: &str| a.get(k)?.get("noul")?.as_f64();
579    Some(Audit {
580        claims_complete: noul("claims_complete")?,
581        tests_green: noul("tests_green")?,
582        deferral: noul("deferral")?,
583    })
584}
585
586/// Ask Jev about a turn that is about to end.
587#[must_use]
588pub fn audit(state: &str) -> Option<Audit> {
589    let (cfg, key) = config()?;
590    let body = audit_request(&cfg.model, state);
591    let reply = post(&cfg, &key, body, "stop-audit", Value::Null)?;
592    parse_audit(&reply)
593}
594
595/// Why a stop is held back, from the audit and whether a test ran in the
596/// turn; `None` lets the agent stop.
597#[must_use]
598pub fn audit_reason(a: &Audit, test_ran: bool) -> Option<String> {
599    if test_ran && a.claims_complete >= AUDIT_CLAIM_AT && a.tests_green <= AUDIT_RED_BELOW {
600        return Some(
601            "The final message says the work is done, and the last test run shown is red. \
602             Say what still fails, or fix it, before stopping."
603                .to_string(),
604        );
605    }
606    if a.deferral >= AUDIT_DEFER_AT {
607        return Some(
608            "The final message leaves part of the asked work for later without naming what blocks it. \
609             Do that part, or say in one sentence what outside the work blocks it."
610                .to_string(),
611        );
612    }
613    None
614}
615
616fn state_dir() -> Option<PathBuf> {
617    Some(
618        std::env::var_os("XDG_STATE_HOME")
619            .filter(|v| !v.is_empty())
620            .map(PathBuf::from)
621            .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))?
622            .join("ljos"),
623    )
624}
625
626/// Every answer Jev gave, one JSON line each in the state directory, so
627/// its probabilities can be scored once the outcomes are known.
628fn log(entry: &Value) {
629    use std::io::Write;
630    let Some(dir) = state_dir() else { return };
631    let _ = std::fs::create_dir_all(&dir);
632    if let Ok(mut f) = std::fs::OpenOptions::new()
633        .create(true)
634        .append(true)
635        .open(dir.join("jev-log.jsonl"))
636    {
637        let _ = writeln!(f, "{entry}");
638    }
639}
640
641/// Add a call's cost to this month's running total in the state directory,
642/// so `ljos doctor` can say what Jev has cost.
643fn record_cost(cost: f64) {
644    let Some(dir) = state_dir() else { return };
645    let _ = std::fs::create_dir_all(&dir);
646    let month = crate::now_utc().chars().take(7).collect::<String>();
647    let path = dir.join("jev-cost.toml");
648    let mut totals: BTreeMap<String, f64> = std::fs::read_to_string(&path)
649        .ok()
650        .and_then(|t| toml::from_str(&t).ok())
651        .unwrap_or_default();
652    *totals.entry(format!("{month}-calls")).or_default() += 1.0;
653    *totals.entry(month).or_default() += cost;
654    if let Ok(text) = toml::to_string(&totals) {
655        let _ = std::fs::write(path, text);
656    }
657}
658
659fn month_totals() -> Option<BTreeMap<String, f64>> {
660    let dir = state_dir()?;
661    let text = std::fs::read_to_string(dir.join("jev-cost.toml")).ok()?;
662    toml::from_str(&text).ok()
663}
664
665fn this_month() -> String {
666    crate::now_utc().chars().take(7).collect()
667}
668
669/// This month's recorded Jev spend, in US dollars.
670#[must_use]
671pub fn month_cost() -> Option<f64> {
672    month_totals()?.get(&this_month()).copied()
673}
674
675/// This month's tally named `what`: `calls` made, `cached` answered from
676/// the cache.
677#[must_use]
678pub fn month_count(what: &str) -> u64 {
679    month_totals()
680        .and_then(|t| t.get(&format!("{}-{what}", this_month())).copied())
681        .unwrap_or(0.0) as u64
682}
683
684/// How many calls this month made.
685#[must_use]
686pub fn month_calls() -> u64 {
687    month_count("calls")
688}
689
690/// The `jev` row in `ljos doctor`, only on a machine with a Jev file: off,
691/// on without its key, or on with this month's spend. A setting that does
692/// not parse is not ok, since the hook then keeps its local path silently.
693#[must_use]
694pub fn doctor_row() -> Option<crate::Habitat> {
695    let text = std::fs::read_to_string(config_path()).ok()?;
696    let (state, ok) = match toml::from_str::<Config>(&text) {
697        Err(e) => (format!("{}: {e}", config_path().display()), false),
698        Ok(cfg) if !cfg.enabled => ("off".to_string(), true),
699        Ok(cfg) => {
700            let spent = month_cost().unwrap_or(0.0);
701            let head = format!(
702                "{}  {} calls, {} cached  ${spent:.4} of ${:.2} this month",
703                cfg.model,
704                month_calls(),
705                month_count("cached"),
706                cfg.monthly_usd
707            );
708            if spent >= cfg.monthly_usd {
709                (format!("capped  {head}"), true)
710            } else if config().is_none() {
711                (
712                    "on, but the key file or command gave no key".to_string(),
713                    false,
714                )
715            } else {
716                (format!("on  {head}"), true)
717            }
718        }
719    };
720    Some(crate::Habitat {
721        name: "jev",
722        state,
723        ok,
724    })
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn one_request_asks_about_every_candidate_and_both_cues() {
733        let body = request("jev-1.13.0", "fix the ci", &["alpha claim", "beta claim"]);
734        let q = body["questions"].as_object().unwrap();
735        assert_eq!(q.len(), 4);
736        assert_eq!(q["bears_1"]["type"], "noul");
737        assert!(body["state"].as_str().unwrap().contains("[1] beta claim"));
738    }
739
740    #[test]
741    fn a_full_answer_is_read_and_a_partial_one_is_refused() {
742        let full = serde_json::json!({
743            "answers": {
744                "bears_0": {"type": "noul", "noul": 0.9},
745                "bears_1": {"type": "noul", "noul": 0.1},
746                "correction": {"type": "noul", "noul": 0.2},
747                "choice": {"type": "noul", "noul": 0.7}
748            },
749            "usage": {"input_tokens": 900, "output_tokens": 40, "cost": 0.0000378}
750        });
751        let j = parse(&full, 2).unwrap();
752        assert_eq!(j.bears, vec![0.9, 0.1]);
753        assert!(j.bears(0) && !j.bears(1));
754        let strict = Judgment {
755            bears_at: 0.95,
756            ..j.clone()
757        };
758        assert!(!strict.bears(0), "a higher cut drops the 0.9");
759        assert!((j.choice - 0.7).abs() < 1e-9);
760        assert!(
761            (cost_of(&full, 0.042) - 0.0000378).abs() < 1e-12,
762            "the API's figure"
763        );
764        let direct = serde_json::json!({"usage": {"input_tokens": 1000, "output_tokens": 60}});
765        assert!(
766            (cost_of(&direct, 0.042) - 0.000042).abs() < 1e-12,
767            "tokens at the price"
768        );
769        let partial = serde_json::json!({"answers": {"bears_0": {"noul": 0.9}}});
770        assert!(parse(&partial, 2).is_none());
771    }
772
773    #[test]
774    fn jev_is_off_without_a_file_and_off_when_the_file_says_so() {
775        let dir = tempfile::tempdir().unwrap();
776        // Safety: the test sets and clears this for itself.
777        unsafe { std::env::set_var("XDG_CONFIG_HOME", dir.path()) };
778        assert!(config().is_none(), "no file, no call");
779        std::fs::create_dir_all(dir.path().join("ljos")).unwrap();
780        let key = dir.path().join("key");
781        std::fs::write(&key, "sk-or-test\n").unwrap();
782        std::fs::write(
783            dir.path().join("ljos/jev.toml"),
784            format!("enabled = false\nkey_file = \"{}\"\n", key.display()),
785        )
786        .unwrap();
787        assert!(config().is_none(), "a file that says off is off");
788        std::fs::write(
789            dir.path().join("ljos/jev.toml"),
790            format!("enabled = true\nkey_file = \"{}\"\n", key.display()),
791        )
792        .unwrap();
793        let (cfg, k) = config().unwrap();
794        assert_eq!(k, "sk-or-test");
795        assert_eq!(cfg.budget_ms, 2000);
796        assert_eq!(cfg.min_candidates, 2);
797        unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
798    }
799
800    #[test]
801    fn a_key_line_gives_its_value() {
802        assert_eq!(key_from("sk-or-v1-abc\n").as_deref(), Some("sk-or-v1-abc"));
803        assert_eq!(
804            key_from("apikey: sk-or-v1-abc\nurl: x\n").as_deref(),
805            Some("sk-or-v1-abc")
806        );
807        assert_eq!(
808            key_from("apikey=sk-or-v1-abc").as_deref(),
809            Some("sk-or-v1-abc")
810        );
811        assert_eq!(key_from("\n"), None);
812    }
813
814    #[test]
815    fn a_ballot_carries_its_confidence_and_forecast_and_escalates_under_the_cut() {
816        let options = vec!["age".to_string(), "gpg".to_string()];
817        let body = ballot_request("jev-1.13.0", "You are brio.", &options);
818        assert_eq!(body["questions"]["ballot"]["type"], "choice");
819        assert_eq!(
820            body["questions"]["forecast"]["criteria"]["gpg"],
821            "most others pick gpg"
822        );
823        let reply = serde_json::json!({"answers": {
824            "ballot": {"type": "choice", "choice": "age", "confidence": 0.97,
825                       "probabilities": {"age": 0.98, "gpg": 0.02}},
826            "forecast": {"type": "choice", "choice": "age", "confidence": 0.95,
827                         "probabilities": {"age": 0.97, "gpg": 0.03}}}});
828        let b = parse_ballot(&reply, &options).unwrap();
829        assert_eq!(b.choice, "age");
830        assert!(!b.escalates(), "0.97 stands at the 0.8 cut");
831        assert!((b.forecast["gpg"] - 0.03).abs() < 1e-9);
832        let unsure = Ballot {
833            confidence: 0.6,
834            ..b.clone()
835        };
836        assert!(unsure.escalates(), "0.6 goes to a subagent");
837        let off = serde_json::json!({"answers": {
838            "ballot": {"choice": "rsa", "confidence": 0.9, "probabilities": {}},
839            "forecast": {"choice": "age", "confidence": 0.9, "probabilities": {}}}});
840        assert!(
841            parse_ballot(&off, &options).is_none(),
842            "a choice off the list is refused"
843        );
844    }
845
846    #[test]
847    fn an_identical_request_is_answered_from_the_cache_and_only_that_one() {
848        let dir = tempfile::tempdir().unwrap();
849        // Safety: the test sets and clears this for itself.
850        unsafe { std::env::set_var("XDG_CACHE_HOME", dir.path()) };
851        let reply = serde_json::json!({"answers": {"x": {"noul": 0.9}}});
852        assert!(cached("req-a", 7).is_none(), "nothing kept yet");
853        keep("req-a", &reply);
854        assert_eq!(cached("req-a", 7), Some(reply));
855        assert!(cached("req-b", 7).is_none(), "another request misses");
856        assert!(cached("req-a", 0).is_none(), "0 days is off");
857        unsafe { std::env::remove_var("XDG_CACHE_HOME") };
858    }
859
860    #[test]
861    fn a_stop_is_held_only_on_done_beside_red_or_an_open_deferral() {
862        let a = |c: f64, g: f64, d: f64| Audit {
863            claims_complete: c,
864            tests_green: g,
865            deferral: d,
866        };
867        assert!(
868            audit_reason(&a(0.95, 0.05, 0.1), true).is_some(),
869            "done beside red"
870        );
871        assert!(
872            audit_reason(&a(0.95, 0.05, 0.1), false).is_none(),
873            "no test ran, nothing to be red"
874        );
875        assert!(
876            audit_reason(&a(0.95, 0.9, 0.1), true).is_none(),
877            "done beside green"
878        );
879        assert!(
880            audit_reason(&a(0.5, 0.05, 0.1), true).is_none(),
881            "a red run reported as red"
882        );
883        assert!(
884            audit_reason(&a(0.2, 0.9, 0.95), false).is_some(),
885            "work put off"
886        );
887        let reply = serde_json::json!({"answers": {
888            "claims_complete": {"noul": 0.9}, "tests_green": {"noul": 0.1}, "deferral": {"noul": 0.0}}});
889        assert_eq!(parse_audit(&reply), Some(a(0.9, 0.1, 0.0)));
890        assert_eq!(
891            audit_request("m", "s")["questions"]
892                .as_object()
893                .unwrap()
894                .len(),
895            3
896        );
897    }
898}