Skip to main content

packset_client/
lib.rs

1//! Loopback HTTP client for packsetd.
2//!
3//! Reads `PACKSET_URL` or `INSIDE_MEMORY_URL`. search/get against packsetd; no SQLite.
4//! Does not open LMDB.
5
6use serde::{Deserialize, Serialize};
7use std::env;
8use std::path::PathBuf;
9use std::time::Duration;
10
11/// How long one request may take: `PACKSET_TIMEOUT_MS`, else thirty seconds.
12/// A write that waits behind thirty others on a busy seat is late, not failed.
13fn timeout() -> Duration {
14    std::env::var("PACKSET_TIMEOUT_MS")
15        .ok()
16        .and_then(|v| v.trim().parse::<u64>().ok())
17        .filter(|ms| *ms > 0)
18        .map_or(Duration::from_secs(30), Duration::from_millis)
19}
20
21fn path_seg(id: &str) -> String {
22    let mut out = String::with_capacity(id.len());
23    for b in id.bytes() {
24        match b {
25            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
26                out.push(b as char)
27            }
28            _ => out.push_str(&format!("%{b:02X}")),
29        }
30    }
31    out
32}
33
34/// The port a writer listens on when nothing names one. The command line,
35/// the server and this client agree on it, so a seat needs no variable set.
36pub const DEFAULT_PORT: u16 = 8761;
37
38/// Load `~/.config/ljos/env` (KEY=VALUE) when the process has not set
39/// those keys. `ljos`, `packset`, and `packset-mcp` then share one pack.
40pub fn load_seat_env() {
41    let Some(home) = env::var_os("HOME") else {
42        return;
43    };
44    let path = PathBuf::from(home).join(".config/ljos/env");
45    let Ok(text) = std::fs::read_to_string(path) else {
46        return;
47    };
48    for line in text.lines() {
49        let line = line.trim();
50        if line.is_empty() || line.starts_with('#') {
51            continue;
52        }
53        let Some((k, v)) = line.split_once('=') else {
54            continue;
55        };
56        let k = k.trim();
57        if k.is_empty() || env::var_os(k).is_some() {
58            continue;
59        }
60        env::set_var(k, v.trim());
61    }
62}
63
64/// The workspace the seat's memory lives in: `PACKSET_WORKSPACE` after
65/// loading the seat env file, else `seat`. Not `default`, and not the
66/// working directory's git remote: those two are how one harness's
67/// remember missed the other's sitting.
68#[must_use]
69pub fn resolved_workspace() -> String {
70    load_seat_env();
71    env::var("PACKSET_WORKSPACE")
72        .ok()
73        .map(|w| w.trim().to_string())
74        .filter(|w| !w.is_empty())
75        .unwrap_or_else(|| "seat".to_string())
76}
77
78/// `PACKSET_PORT` (`GROK_MEM_PORT` is an alias), else [`DEFAULT_PORT`].
79#[must_use]
80pub fn default_port() -> u16 {
81    env::var("PACKSET_PORT")
82        .or_else(|_| env::var("GROK_MEM_PORT"))
83        .ok()
84        .and_then(|raw| raw.trim().parse().ok())
85        .unwrap_or(DEFAULT_PORT)
86}
87
88#[derive(Debug, thiserror::Error)]
89pub enum Error {
90    #[error("packset url missing")]
91    NoUrl,
92    #[error("http: {0}")]
93    Http(#[from] Box<ureq::Error>),
94    #[error("io: {0}")]
95    Io(#[from] std::io::Error),
96    #[error("json: {0}")]
97    Json(#[from] serde_json::Error),
98    #[error("bad response: {0}")]
99    Bad(String),
100}
101
102#[derive(Debug, Clone)]
103pub struct PacksetClient {
104    base: String,
105    /// A workspace pinned by the caller; `None` reads the environment and
106    /// the working directory.
107    workspace: Option<String>,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Hit {
112    pub id: Option<String>,
113    pub text: String,
114    #[serde(default)]
115    pub score: f64,
116    #[serde(default)]
117    pub kind: String,
118    /// When the memory was written, the writer's clock, RFC 3339. Absent
119    /// on card paragraphs, which have no clock.
120    #[serde(default)]
121    pub ts: Option<String>,
122    /// How many of the panel's ballots named this hit, and how many ran.
123    /// Two of three is agreement; one of three is one scorer's opinion.
124    /// The atom's entities: `seat:<name>` names the seat that wrote it,
125    /// `persona:<name>` a persona's own claim, `habit:<name>` a reading.
126    #[serde(default)]
127    pub entities: Vec<String>,
128    #[serde(default)]
129    pub ballots: Option<u32>,
130    #[serde(default)]
131    pub of: Option<u32>,
132}
133
134/// A refusal, carrying the reason the writer gave in its body.
135fn refused(url: &str, e: ureq::Error) -> Error {
136    match e {
137        ureq::Error::Status(code, response) => {
138            let text = response.into_string().unwrap_or_default();
139            let reason = serde_json::from_str::<serde_json::Value>(&text)
140                .ok()
141                .and_then(|v| v.get("error").and_then(|r| r.as_str()).map(str::to_string))
142                .unwrap_or(text);
143            let reason = reason.trim();
144            if reason.is_empty() {
145                Error::Bad(format!("{url}: status code {code}"))
146            } else {
147                Error::Bad(format!("{url}: {code}: {reason}"))
148            }
149        }
150        other => Error::Http(Box::new(other)),
151    }
152}
153
154impl PacksetClient {
155    pub fn new(base: impl Into<String>) -> Self {
156        let mut base = base.into();
157        while base.ends_with('/') {
158            base.pop();
159        }
160        Self {
161            base,
162            workspace: None,
163        }
164    }
165
166    /// Pin the workspace this client speaks for, ahead of `PACKSET_WORKSPACE`
167    /// and the working directory. A seat that is one memory across every
168    /// repository it works in sets this once.
169    #[must_use]
170    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
171        let workspace = workspace.into();
172        self.workspace = (!workspace.is_empty()).then_some(workspace);
173        self
174    }
175
176    /// The writer the seat talks to, with nothing set: `PACKSET_URL`
177    /// (`INSIDE_MEMORY_URL` is an alias), else the loopback port the command
178    /// line starts a writer on, `PACKSET_PORT` (`GROK_MEM_PORT`) or 8761.
179    /// `PACKSET_URL=off` is the one way to have no pack.
180    pub fn from_env() -> Result<Self, Error> {
181        load_seat_env();
182        let url = env::var("PACKSET_URL")
183            .or_else(|_| env::var("INSIDE_MEMORY_URL"))
184            .ok()
185            .filter(|url| !url.is_empty());
186        match url {
187            Some(url) if url == "off" => Err(Error::NoUrl),
188            Some(url) => Ok(Self::new(url)),
189            None => Ok(Self::new(format!("http://127.0.0.1:{}", default_port()))),
190        }
191    }
192
193    pub fn base(&self) -> &str {
194        &self.base
195    }
196
197    pub fn workspace(&self) -> String {
198        if let Some(w) = &self.workspace {
199            return w.clone();
200        }
201        if let Ok(w) = env::var("PACKSET_WORKSPACE") {
202            if !w.is_empty() {
203                return w;
204            }
205        }
206        let cwd = env::var("GROKOS_WORKSPACE")
207            .ok()
208            .map(|s| s.trim().to_string())
209            .filter(|s| !s.is_empty())
210            .map(std::path::PathBuf::from)
211            .or_else(|| env::current_dir().ok())
212            .unwrap_or_else(|| std::path::PathBuf::from("."));
213        self.workspace_for_cwd(&cwd)
214    }
215
216    /// Workspace id from `/v1/identity` for `cwd`, or `dir:<abs>` if that call fails.
217    pub fn workspace_for_cwd(&self, cwd: &std::path::Path) -> String {
218        let abs = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
219        let url = format!("{}/v1/identity", self.base);
220        let body = ureq::get(&url)
221            .query("cwd", abs.to_string_lossy().as_ref())
222            .timeout(timeout())
223            .call()
224            .ok()
225            .and_then(|r| r.into_string().ok());
226        if let Some(body) = body {
227            if let Ok(val) = serde_json::from_str::<serde_json::Value>(&body) {
228                if let Some(ws) = val.get("workspace").and_then(|v| v.as_str()) {
229                    if !ws.is_empty() {
230                        return ws.to_string();
231                    }
232                }
233            }
234        }
235        format!("dir:{}", abs.display())
236    }
237
238    pub fn health(&self) -> Result<String, Error> {
239        let url = format!("{}/health", self.base);
240        let body = ureq::get(&url)
241            .timeout(timeout())
242            .call()
243            .map_err(|e| refused(&url, e))?
244            .into_string()?;
245        Ok(body)
246    }
247
248    pub fn get_atom(&self, workspace: &str, id: &str) -> Result<serde_json::Value, Error> {
249        let encoded = path_seg(id);
250        let url = format!("{}/v1/atoms/{encoded}", self.base);
251        let resp = match ureq::get(&url)
252            .query("workspace", workspace)
253            .timeout(timeout())
254            .call()
255        {
256            Ok(resp) => resp,
257            Err(ureq::Error::Status(404, _)) => {
258                return Err(Error::Bad(format!("no atom {id}")));
259            }
260            Err(e) => return Err(Error::Http(Box::new(e))),
261        };
262        Ok(resp.into_json()?)
263    }
264
265    pub fn list_atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
266        self.atoms_as_of(workspace, None)
267    }
268
269    /// Live-now atoms, or the ones that were live at `as_of`.
270    ///
271    /// # Errors
272    ///
273    /// The request's, or a body that is not JSON.
274    pub fn atoms_as_of(
275        &self,
276        workspace: &str,
277        as_of: Option<&str>,
278    ) -> Result<Vec<serde_json::Value>, Error> {
279        let url = format!("{}/v1/atoms", self.base);
280        let mut req = ureq::get(&url)
281            .query("workspace", workspace)
282            .timeout(timeout());
283        if let Some(at) = as_of {
284            req = req.query("as_of", at);
285        }
286        let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
287        let atoms = body
288            .get("atoms")
289            .cloned()
290            .unwrap_or(serde_json::Value::Array(vec![]));
291        Ok(serde_json::from_value(atoms)?)
292    }
293
294    pub fn search(&self, workspace: &str, q: &str, limit: u32) -> Result<Vec<Hit>, Error> {
295        self.search_as_of(workspace, q, limit, None)
296    }
297
298    /// Ranked hits, optionally over the atoms that were live at `as_of`.
299    ///
300    /// # Errors
301    ///
302    /// The request's, or a body that is not JSON.
303    pub fn search_as_of(
304        &self,
305        workspace: &str,
306        q: &str,
307        limit: u32,
308        as_of: Option<&str>,
309    ) -> Result<Vec<Hit>, Error> {
310        self.search_opts(workspace, q, limit, as_of, false)
311    }
312
313    /// Ranked hits, optionally dated and optionally through the measured
314    /// cross-encoder stage.
315    ///
316    /// Off by default. On, the writer spends a forward pass per candidate and
317    /// the request waits for that rather than the usual five-second budget.
318    ///
319    /// # Errors
320    ///
321    /// The request's, or a body that is not a hit list.
322    pub fn search_opts(
323        &self,
324        workspace: &str,
325        q: &str,
326        limit: u32,
327        as_of: Option<&str>,
328        rerank: bool,
329    ) -> Result<Vec<Hit>, Error> {
330        let url = format!("{}/v1/search", self.base);
331        let budget = if rerank {
332            Duration::from_secs(60).max(timeout())
333        } else {
334            timeout()
335        };
336        let mut req = ureq::get(&url)
337            .query("workspace", workspace)
338            .query("q", q)
339            .query("limit", &limit.to_string())
340            .timeout(budget);
341        if let Some(at) = as_of {
342            req = req.query("as_of", at);
343        }
344        if rerank {
345            req = req.query("rerank", "1");
346        }
347        let body: serde_json::Value = req.call().map_err(|e| refused(&url, e))?.into_json()?;
348        let hits = body
349            .get("hits")
350            .cloned()
351            .unwrap_or(serde_json::Value::Array(vec![]));
352        Ok(serde_json::from_value(hits)?)
353    }
354
355    /// Seat home, atom counts by kind, pin, index and embedder.
356    ///
357    /// # Errors
358    ///
359    /// The request's, or a body that is not JSON.
360    pub fn status(&self, workspace: Option<&str>) -> Result<serde_json::Value, Error> {
361        let url = format!("{}/v1/status", self.base);
362        let mut req = ureq::get(&url).timeout(timeout());
363        if let Some(workspace) = workspace {
364            req = req.query("workspace", workspace);
365        }
366        Ok(req.call().map_err(|e| refused(&url, e))?.into_json()?)
367    }
368
369    /// The set a workspace is pinned to.
370    ///
371    /// # Errors
372    ///
373    /// The request's, or a body that is not JSON.
374    pub fn pin(&self, workspace: &str) -> Result<serde_json::Value, Error> {
375        let url = format!("{}/v1/pin", self.base);
376        Ok(ureq::get(&url)
377            .query("workspace", workspace)
378            .timeout(timeout())
379            .call()
380            .map_err(|e| refused(&url, e))?
381            .into_json()?)
382    }
383
384    /// Pin a workspace to a set.
385    ///
386    /// # Errors
387    ///
388    /// The request's, or a body that is not JSON.
389    pub fn set_pin(&self, workspace: &str, name: &str) -> Result<serde_json::Value, Error> {
390        let url = format!("{}/v1/pin", self.base);
391        Ok(ureq::put(&url)
392            .timeout(timeout())
393            .send_json(serde_json::json!({ "workspace": workspace, "name": name }))
394            .map_err(|e| refused(&url, e))?
395            .into_json()?)
396    }
397
398    /// The deed accessions a workspace's live atoms cite, sorted.
399    ///
400    /// The accession is the only identifier crossing the tracker, the pack and
401    /// the deed store, so this is what `deedar evidence -` reads.
402    ///
403    /// # Errors
404    ///
405    /// The request's, or a body that is not JSON.
406    pub fn accessions(&self, workspace: &str) -> Result<Vec<String>, Error> {
407        let url = format!("{}/v1/accessions", self.base);
408        let body: serde_json::Value = ureq::get(&url)
409            .query("workspace", workspace)
410            .timeout(timeout())
411            .call()
412            .map_err(|e| refused(&url, e))?
413            .into_json()?;
414        let found = body
415            .get("accessions")
416            .cloned()
417            .unwrap_or(serde_json::Value::Array(vec![]));
418        Ok(serde_json::from_value(found)?)
419    }
420    /// Every live atom in a workspace.
421    ///
422    /// The bodies, not the join keys: this is what a handover carries when
423    /// somebody is given what the seat learned rather than only what it cites.
424    ///
425    /// # Errors
426    ///
427    /// The request's, or a body that is not JSON.
428    pub fn atoms(&self, workspace: &str) -> Result<Vec<serde_json::Value>, Error> {
429        self.atoms_as_of(workspace, None)
430    }
431
432    /// The live atoms in a workspace that cite one deed accession.
433    ///
434    /// # Errors
435    ///
436    /// The request's, or a body that is not JSON.
437    pub fn citers(
438        &self,
439        workspace: &str,
440        accession: &str,
441    ) -> Result<Vec<serde_json::Value>, Error> {
442        let url = format!("{}/v1/citers", self.base);
443        let body: serde_json::Value = ureq::get(&url)
444            .query("workspace", workspace)
445            .query("accession", accession)
446            .timeout(timeout())
447            .call()
448            .map_err(|e| refused(&url, e))?
449            .into_json()?;
450        let found = body
451            .get("atoms")
452            .cloned()
453            .unwrap_or(serde_json::Value::Array(vec![]));
454        Ok(serde_json::from_value(found)?)
455    }
456
457    /// Tombstone one atom. The daemon keeps the record and drops the index
458    /// entry, so a forgotten atom stops being recalled without the pack losing
459    /// the fact that it once held it.
460    ///
461    /// `why` is the deed accession that withdrew the claim, and the daemon
462    /// refuses one that is not an accession. It rides onto the tombstone beside
463    /// the text, so the retraction and what it retracted read back together.
464    ///
465    /// # Errors
466    ///
467    /// [`Error::Bad`] when the workspace does not hold that atom, else the
468    /// request's or a body that is not JSON.
469    pub fn delete_atom(
470        &self,
471        workspace: &str,
472        id: &str,
473        why: Option<&str>,
474    ) -> Result<serde_json::Value, Error> {
475        let url = format!("{}/v1/atoms/delete", self.base);
476        let mut body = serde_json::json!({
477            "workspace": workspace,
478            "id": id,
479        });
480        if let Some(accession) = why {
481            body["why"] = serde_json::Value::String(accession.to_string());
482        }
483        let resp = match ureq::post(&url).timeout(timeout()).send_json(body) {
484            Ok(resp) => resp,
485            Err(ureq::Error::Status(404, _)) => {
486                return Err(Error::Bad(format!("no atom {id}")));
487            }
488            Err(e) => return Err(refused(&url, e)),
489        };
490        Ok(resp.into_json()?)
491    }
492
493    /// Move one atom along the review clock: recalled, or lapsed.
494    pub fn grade(
495        &self,
496        workspace: &str,
497        id: &str,
498        recalled: bool,
499    ) -> Result<serde_json::Value, Error> {
500        let url = format!("{}/v1/grade", self.base);
501        let body: serde_json::Value = ureq::post(&url)
502            .timeout(timeout())
503            .send_json(serde_json::json!({
504                "workspace": workspace,
505                "id": id,
506                "recalled": recalled,
507            }))
508            .map_err(|e| refused(&url, e))?
509            .into_json()?;
510        Ok(body)
511    }
512
513    /// The link graph's communities, largest first.
514    /// The claims the link graph turns on, highest first.
515    pub fn hubs(&self, workspace: &str, limit: usize) -> Result<serde_json::Value, Error> {
516        let url = format!("{}/v1/hubs", self.base);
517        let body: serde_json::Value = ureq::get(&url)
518            .query("workspace", workspace)
519            .query("limit", &limit.to_string())
520            .timeout(timeout())
521            .call()
522            .map_err(|e| refused(&url, e))?
523            .into_json()?;
524        Ok(body)
525    }
526
527    pub fn islands(&self, workspace: &str) -> Result<serde_json::Value, Error> {
528        let url = format!("{}/v1/islands", self.base);
529        let body: serde_json::Value = ureq::get(&url)
530            .query("workspace", workspace)
531            .timeout(timeout())
532            .call()
533            .map_err(|e| refused(&url, e))?
534            .into_json()?;
535        Ok(body)
536    }
537
538    /// Claims that fired together: their links gain weight.
539    pub fn fire(&self, workspace: &str, ids: &[String]) -> Result<serde_json::Value, Error> {
540        let url = format!("{}/v1/fire", self.base);
541        let body: serde_json::Value = ureq::post(&url)
542            .timeout(timeout())
543            .send_json(serde_json::json!({"workspace": workspace, "ids": ids}))
544            .map_err(|e| refused(&url, e))?
545            .into_json()?;
546        Ok(body)
547    }
548
549    /// Consolidate the workspace: every claim that replaces an earlier one
550    /// closes it (the write-time rule, run over what is held). `apply`
551    /// false reports the pairs and writes nothing.
552    pub fn consolidate(&self, workspace: &str, apply: bool) -> Result<serde_json::Value, Error> {
553        let url = format!("{}/v1/consolidate", self.base);
554        let body: serde_json::Value = ureq::post(&url)
555            .timeout(timeout())
556            .send_json(serde_json::json!({"workspace": workspace, "apply": apply}))
557            .map_err(|e| refused(&url, e))?
558            .into_json()?;
559        Ok(body)
560    }
561
562    /// The memories a cue activates, strongest first; with `fire`, the top
563    /// of them fire together.
564    pub fn activate(
565        &self,
566        workspace: &str,
567        q: &str,
568        limit: u32,
569        fire: bool,
570    ) -> Result<serde_json::Value, Error> {
571        let url = format!("{}/v1/activate", self.base);
572        let body: serde_json::Value = ureq::get(&url)
573            .query("workspace", workspace)
574            .query("q", q)
575            .query("limit", &limit.to_string())
576            .query("fire", if fire { "1" } else { "0" })
577            .timeout(timeout())
578            .call()
579            .map_err(|e| refused(&url, e))?
580            .into_json()?;
581        Ok(body)
582    }
583
584    pub fn post_atom(&self, atom: &serde_json::Value) -> Result<serde_json::Value, Error> {
585        let url = format!("{}/v1/atoms", self.base);
586        let body: serde_json::Value = ureq::post(&url)
587            .timeout(timeout())
588            .send_json(atom.clone())
589            .map_err(|e| refused(&url, e))?
590            .into_json()?;
591        Ok(body)
592    }
593}
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    #[test]
600    fn resolved_workspace_reads_ljos_env_not_default() {
601        let dir = std::env::temp_dir().join(format!("packset-ljos-env-{}", std::process::id()));
602        std::fs::create_dir_all(dir.join(".config/ljos")).unwrap();
603        std::fs::write(
604            dir.join(".config/ljos/env"),
605            "PACKSET_WORKSPACE=git:example.com/seat/notes\n",
606        )
607        .unwrap();
608        let old_home = env::var("HOME").ok();
609        let old_ws = env::var("PACKSET_WORKSPACE").ok();
610        unsafe {
611            env::remove_var("PACKSET_WORKSPACE");
612            env::set_var("HOME", &dir);
613        }
614        let got = resolved_workspace();
615        unsafe {
616            match old_home {
617                Some(h) => env::set_var("HOME", h),
618                None => env::remove_var("HOME"),
619            }
620            match old_ws {
621                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
622                None => env::remove_var("PACKSET_WORKSPACE"),
623            }
624        }
625        assert_eq!(got, "git:example.com/seat/notes");
626    }
627
628    #[test]
629    fn resolved_workspace_without_env_is_seat_not_default() {
630        let dir = std::env::temp_dir().join(format!("packset-no-ljos-env-{}", std::process::id()));
631        std::fs::create_dir_all(&dir).unwrap();
632        let old_home = env::var("HOME").ok();
633        let old_ws = env::var("PACKSET_WORKSPACE").ok();
634        unsafe {
635            env::remove_var("PACKSET_WORKSPACE");
636            env::set_var("HOME", &dir);
637        }
638        let got = resolved_workspace();
639        unsafe {
640            match old_home {
641                Some(h) => env::set_var("HOME", h),
642                None => env::remove_var("HOME"),
643            }
644            match old_ws {
645                Some(w) => env::set_var("PACKSET_WORKSPACE", w),
646                None => env::remove_var("PACKSET_WORKSPACE"),
647            }
648        }
649        assert_eq!(got, "seat");
650        assert_ne!(got, "default");
651    }
652}