Skip to main content

rightkit_memory_client/
membrane.rs

1//! Wrapper over Pulse's `membrane` CLI (issue #26 lane A).
2//!
3//! Contract source: Pulse `membrane/README.md` "CLI contract" and "Finding the binary", and
4//! `membrane/engine/crates/membrane/src/{cli.rs,modes.rs}` at Pulse commit 858972ca6. Every
5//! `--json` document carries `"schema": "membrane.<command>.v<N>"`; this adapter parses by
6//! schema id and rejects any major version other than [`SUPPORTED_MAJOR`]. Exit codes map to
7//! [`MembraneError`] (0 ok, 1 internal, 2 usage or refusal, 3 not available, 4 resident
8//! unreachable). A cursor conflict on `transcript append` is exit 2 with a conflict document on
9//! stdout; it is returned as [`TranscriptAppend::Conflict`], not as an error.
10//!
11//! Discovery: env override, `notch-state.json` `membraneBinary.path`
12//! (`{"path": null, "reason": ..}` means not installed), a fresh `resident.json` `binary`, then
13//! the installed Helpers path; [`MembraneCli::backend`] verifies the candidate with
14//! `membrane where --json` (`binary` must equal the path that was run).
15
16use serde_json::Value;
17use std::ffi::OsString;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21/// Environment override for the binary location.
22pub const BIN_ENV: &str = "RIGHTKIT_MEMBRANE_BIN";
23/// Reason reported when Pulse's Membrane is not installed.
24pub const OFF_REASON: &str = "off: pulse not installed";
25/// Default hard timeout for one invocation.
26pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
27/// The only schema major version this adapter understands.
28pub const SUPPORTED_MAJOR: u32 = 1;
29
30/// Pulse `membrane` exit codes (`membrane/src/lib.rs`).
31pub const EXIT_OK: i32 = 0;
32pub const EXIT_INTERNAL_ERROR: i32 = 1;
33pub const EXIT_USER_ERROR: i32 = 2;
34pub const EXIT_NOT_AVAILABLE: i32 = 3;
35pub const EXIT_RESIDENT_UNREACHABLE: i32 = 4;
36
37/// `resident.json` older than this is stale (README "Finding the binary").
38const RESIDENT_STALE_MS: u128 = 30_000;
39
40#[derive(Debug, thiserror::Error)]
41pub enum MembraneError {
42    /// Pulse is not installed (no binary found).
43    #[error("{0}")]
44    Unavailable(String),
45    #[error("cannot start membrane: {0}")]
46    Spawn(String),
47    #[error("membrane timed out after {0:?} and was killed")]
48    Timeout(Duration),
49    /// Exit 1: unreadable or corrupt store, encoding failure.
50    #[error("membrane internal error: {0}")]
51    Internal(String),
52    /// Exit 2: bad command, flag or value.
53    #[error("membrane usage error: {0}")]
54    Usage(String),
55    /// Exit 3: the answer cannot be produced (no store, not enrolled, recall off, no graph).
56    #[error("membrane not available: {0}")]
57    NotAvailable(String),
58    /// Exit 4: only `status --require-resident`.
59    #[error("membrane resident unreachable: {0}")]
60    ResidentUnreachable(String),
61    /// Any exit code outside the documented set.
62    #[error("membrane exited with {code:?}: {stderr}")]
63    Exit { code: Option<i32>, stderr: String },
64    #[error("cannot parse membrane output: {0}")]
65    Parse(String),
66    /// A document whose schema major version is not [`SUPPORTED_MAJOR`].
67    #[error("unsupported membrane schema `{got}` (supported major: v{SUPPORTED_MAJOR})")]
68    UnsupportedSchema { got: String },
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ContextPull {
73    /// Context text; empty when Membrane had nothing or is off.
74    pub text: String,
75    /// Set when context is off or not available, e.g. [`OFF_REASON`].
76    pub off_reason: Option<String>,
77    /// Retrieval reason from `membrane.context.pull.v1` (`empty` or the retrieval reason).
78    pub reason: Option<String>,
79    /// Reason detail (object or string), `Null` when absent.
80    pub detail: Value,
81}
82
83/// One row of `memory list` / `memory search`.
84#[derive(Debug, Clone, PartialEq)]
85pub struct MemoryItem {
86    pub id: String,
87    pub tier: String,
88    pub chars: Option<u64>,
89    pub access: Value,
90    pub inject: Value,
91    /// Search only: the memory's scope.
92    pub scope: Option<String>,
93    /// Search only.
94    pub score: Option<f64>,
95    /// Search only: the memory text.
96    pub content: Option<String>,
97}
98
99/// `membrane.memory.list.v1`.
100#[derive(Debug, Clone, PartialEq)]
101pub struct MemoryList {
102    pub scope: Option<String>,
103    pub limit: u64,
104    pub items: Vec<MemoryItem>,
105    pub completeness: Value,
106}
107
108/// `membrane.memory.search.v1`.
109#[derive(Debug, Clone, PartialEq)]
110pub struct MemorySearch {
111    pub query: Option<String>,
112    /// Tier names asked for; empty means every tier.
113    pub tiers: Vec<String>,
114    pub scope: Option<String>,
115    pub limit: Option<u64>,
116    /// Candidates ranked before the tier filter.
117    pub pool: Option<u64>,
118    /// True when more memories matched than `limit`.
119    pub more: bool,
120    pub items: Vec<MemoryItem>,
121}
122
123/// `membrane.memory.delete.v1`. `deleted` is false (exit 3) when no memory had the id.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct MemoryDelete {
126    pub id: String,
127    pub deleted: bool,
128}
129
130/// `membrane.blueprint.v1` / `membrane.blueprint.search.v1`: the Blueprint answer as given.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct BlueprintAnswer {
133    pub schema: String,
134    /// A `schema` the Blueprint answer carried itself.
135    pub payload_schema: Option<String>,
136    /// Remaining fields of the document.
137    pub body: Value,
138}
139
140/// `membrane.status.v1`.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct StatusInfo {
143    pub product_version: String,
144    /// `resident`, `in_process`, or `off` from [`Unavailable`].
145    pub mode: String,
146    pub state_root: Option<String>,
147    pub db_path: Option<String>,
148    pub db_present: Option<bool>,
149    pub model: Option<String>,
150    pub resident: Value,
151    /// The whole document, for fields this adapter does not type.
152    pub raw: Value,
153}
154
155/// `membrane.where.v1`.
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct WhereInfo {
158    pub binary: PathBuf,
159    pub product_version: Option<String>,
160    pub membrane_source_commit: Option<String>,
161    pub target: Option<String>,
162    pub state_root: Option<String>,
163}
164
165/// Result of `membrane transcript append`.
166#[derive(Debug, Clone, PartialEq)]
167pub enum TranscriptAppend {
168    /// Exit 0. `replayed` is true when the batch was an exact replay of committed rows.
169    Committed {
170        session: String,
171        cursor: u64,
172        replayed: bool,
173        /// `streamId`, `inserted`, `alreadyPresent`, `lastSeq`, `lastSourceSeq`, `prefixDigest`.
174        receipt: Value,
175    },
176    /// Exit 2 with `conflict: true`; nothing was written.
177    Conflict(TranscriptConflict),
178}
179
180/// The conflict receipt of `transcript append`.
181#[derive(Debug, Clone, PartialEq)]
182pub struct TranscriptConflict {
183    /// The stream's committed end; `None` when the tip could not be read.
184    pub current_cursor: Option<u64>,
185    /// 400, 409 or 410.
186    pub status: Option<u64>,
187    /// `transcript_cursor_mismatch`, `transcript_event_conflict`, `transcript_session_gone`,
188    /// `transcript_tip_unavailable`, or the refusal's own code.
189    pub code: String,
190    pub detail: Value,
191}
192
193/// `membrane.transcript.tip.v1`.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct TranscriptTip {
196    pub session: String,
197    /// The stream's committed end; 0 for an empty stream.
198    pub current_cursor: u64,
199    pub prefix_digest: Option<String>,
200}
201
202pub trait MembraneBackend {
203    fn context_pull(
204        &self,
205        cwd: &Path,
206        prompt: &str,
207        budget: Option<usize>,
208        repo: Option<&Path>,
209    ) -> Result<ContextPull, MembraneError>;
210    fn memory_push(&self, text: &str) -> Result<Value, MembraneError>;
211    fn memory_list(&self, limit: usize, repo: Option<&Path>) -> Result<MemoryList, MembraneError>;
212    fn memory_search(
213        &self,
214        query: &str,
215        tiers: &[String],
216        limit: usize,
217        repo: Option<&Path>,
218    ) -> Result<MemorySearch, MembraneError>;
219    fn memory_delete(&self, id: &str) -> Result<MemoryDelete, MembraneError>;
220    fn blueprint_search(
221        &self,
222        q: &str,
223        limit: usize,
224        repo: Option<&Path>,
225    ) -> Result<BlueprintAnswer, MembraneError>;
226    fn status(&self) -> Result<StatusInfo, MembraneError>;
227    /// `adapt observe`: an `AdaptObservationRequestV1` document on stdin. Returns `result`.
228    fn adapt_observe(&self, request: &Value) -> Result<Value, MembraneError>;
229    /// `adapt recall <query> -k n --scope s --dimension K=V`. Returns `result`.
230    fn adapt_recall(
231        &self,
232        query: &str,
233        k: usize,
234        scope: &str,
235        dimensions: &[String],
236    ) -> Result<Value, MembraneError>;
237    /// `adapt apply --insights <file> --installation-id <id> [--dry-run]`. Returns `result`.
238    fn adapt_apply_insights(
239        &self,
240        insights: &Path,
241        installation_id: &str,
242        dry_run: bool,
243    ) -> Result<Value, MembraneError>;
244    /// `adapt apply --manifest <file> [--dry-run]`. Returns `result`.
245    fn adapt_apply_manifest(&self, manifest: &Path, dry_run: bool) -> Result<Value, MembraneError>;
246    /// `adapt taste-review --input <file> --installation-id <id> --created-at <t>
247    /// [--canonical-pool-sha256 <d>]`. Returns `result`.
248    fn adapt_taste_review(
249        &self,
250        input: &Path,
251        installation_id: &str,
252        created_at: &str,
253        canonical_pool_sha256: Option<&str>,
254    ) -> Result<Value, MembraneError>;
255    /// `transcript append`: cursor-checked; a conflict is [`TranscriptAppend::Conflict`].
256    /// `events` is the JSON array of `host_event_v1` rows.
257    fn transcript_append(
258        &self,
259        session: &str,
260        cursor: u64,
261        scope: Option<&str>,
262        events: &Value,
263    ) -> Result<TranscriptAppend, MembraneError>;
264    /// `transcript tip`; a forgotten session is exit 2, [`MembraneError::Usage`].
265    fn transcript_tip(&self, session: &str) -> Result<TranscriptTip, MembraneError>;
266}
267
268/// Backend used when Pulse is not installed: empty context, explicit errors.
269#[derive(Debug, Default, Clone, Copy)]
270pub struct Unavailable;
271
272impl Unavailable {
273    fn err() -> MembraneError {
274        MembraneError::Unavailable(OFF_REASON.to_owned())
275    }
276}
277
278impl MembraneBackend for Unavailable {
279    fn context_pull(
280        &self,
281        _: &Path,
282        _: &str,
283        _: Option<usize>,
284        _: Option<&Path>,
285    ) -> Result<ContextPull, MembraneError> {
286        Ok(ContextPull {
287            text: String::new(),
288            off_reason: Some(OFF_REASON.to_owned()),
289            reason: None,
290            detail: Value::Null,
291        })
292    }
293    fn memory_push(&self, _: &str) -> Result<Value, MembraneError> {
294        Err(Self::err())
295    }
296    fn memory_list(&self, limit: usize, _: Option<&Path>) -> Result<MemoryList, MembraneError> {
297        Ok(MemoryList {
298            scope: None,
299            limit: limit as u64,
300            items: Vec::new(),
301            completeness: Value::Null,
302        })
303    }
304    fn memory_search(
305        &self,
306        query: &str,
307        _: &[String],
308        limit: usize,
309        _: Option<&Path>,
310    ) -> Result<MemorySearch, MembraneError> {
311        Ok(empty_search(query, limit))
312    }
313    fn memory_delete(&self, _: &str) -> Result<MemoryDelete, MembraneError> {
314        Err(Self::err())
315    }
316    fn blueprint_search(
317        &self,
318        _: &str,
319        _: usize,
320        _: Option<&Path>,
321    ) -> Result<BlueprintAnswer, MembraneError> {
322        Err(Self::err())
323    }
324    fn status(&self) -> Result<StatusInfo, MembraneError> {
325        let raw = serde_json::json!({"mode": "off", "reason": OFF_REASON});
326        Ok(StatusInfo {
327            product_version: String::new(),
328            mode: "off".to_owned(),
329            state_root: None,
330            db_path: None,
331            db_present: None,
332            model: None,
333            resident: Value::Null,
334            raw,
335        })
336    }
337    fn adapt_observe(&self, _: &Value) -> Result<Value, MembraneError> {
338        Err(Self::err())
339    }
340    fn adapt_recall(
341        &self,
342        _: &str,
343        _: usize,
344        _: &str,
345        _: &[String],
346    ) -> Result<Value, MembraneError> {
347        Err(Self::err())
348    }
349    fn adapt_apply_insights(&self, _: &Path, _: &str, _: bool) -> Result<Value, MembraneError> {
350        Err(Self::err())
351    }
352    fn adapt_apply_manifest(&self, _: &Path, _: bool) -> Result<Value, MembraneError> {
353        Err(Self::err())
354    }
355    fn adapt_taste_review(
356        &self,
357        _: &Path,
358        _: &str,
359        _: &str,
360        _: Option<&str>,
361    ) -> Result<Value, MembraneError> {
362        Err(Self::err())
363    }
364    fn transcript_append(
365        &self,
366        _: &str,
367        _: u64,
368        _: Option<&str>,
369        _: &Value,
370    ) -> Result<TranscriptAppend, MembraneError> {
371        Err(Self::err())
372    }
373    fn transcript_tip(&self, _: &str) -> Result<TranscriptTip, MembraneError> {
374        Err(Self::err())
375    }
376}
377
378fn empty_search(query: &str, limit: usize) -> MemorySearch {
379    MemorySearch {
380        query: Some(query.to_owned()),
381        tiers: Vec::new(),
382        scope: None,
383        limit: Some(limit as u64),
384        pool: None,
385        more: false,
386        items: Vec::new(),
387    }
388}
389
390#[derive(Debug, Clone)]
391pub struct MembraneCli {
392    binary: PathBuf,
393    timeout: Duration,
394    /// Extra environment for the child only; never the parent process env.
395    env: Vec<(OsString, OsString)>,
396}
397
398impl MembraneCli {
399    pub fn new(binary: PathBuf) -> Self {
400        Self {
401            binary,
402            timeout: DEFAULT_TIMEOUT,
403            env: Vec::new(),
404        }
405    }
406
407    pub fn with_timeout(mut self, timeout: Duration) -> Self {
408        self.timeout = timeout;
409        self
410    }
411
412    /// Set one environment variable on the spawned child only.
413    pub fn with_env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
414        self.env.push((key.into(), value.into()));
415        self
416    }
417
418    pub fn binary(&self) -> &Path {
419        &self.binary
420    }
421
422    /// Discovery order: env override, `notch-state.json`
423    /// `membraneBinary.path`, a fresh `resident.json` `binary`, then Pulse's installed
424    /// Helpers path; `None` when Pulse is not installed.
425    pub fn locate() -> Option<PathBuf> {
426        Self::locate_with(
427            std::env::var_os(BIN_ENV),
428            std::env::var_os("LOCALAPPDATA"),
429            std::env::var_os("HOME"),
430        )
431    }
432
433    pub fn locate_with(
434        env_bin: Option<OsString>,
435        local_app_data: Option<OsString>,
436        home: Option<OsString>,
437    ) -> Option<PathBuf> {
438        if let Some(bin) = env_bin.filter(|b| !b.is_empty()) {
439            let path = PathBuf::from(bin);
440            return path.is_file().then_some(path);
441        }
442        if let Some(path) = notch_state_binary(local_app_data.as_deref(), home.as_deref()) {
443            return Some(path);
444        }
445        if let Some(path) = resident_binary(local_app_data.as_deref(), home.as_deref()) {
446            return Some(path);
447        }
448        let mut candidates: Vec<PathBuf> = Vec::new();
449        if cfg!(windows) {
450            if let Some(base) = local_app_data {
451                // pulse.nsi: InstallDir "$LOCALAPPDATA\Programs\Pulse"; Helpers\membrane.exe
452                candidates.push(PathBuf::from(base).join("Programs/Pulse/Helpers/membrane.exe"));
453            }
454        } else if cfg!(target_os = "macos") {
455            candidates.push(PathBuf::from(
456                "/Applications/Pulse.app/Contents/Helpers/membrane",
457            ));
458            if let Some(h) = home {
459                candidates.push(
460                    PathBuf::from(h).join("Applications/Pulse.app/Contents/Helpers/membrane"),
461                );
462            }
463        }
464        candidates.into_iter().find(|p| p.is_file())
465    }
466
467    /// A real backend when the binary is found, else [`Unavailable`].
468    pub fn backend() -> Box<dyn MembraneBackend> {
469        match Self::locate() {
470            Some(bin) => {
471                let cli = Self::new(bin);
472                if cli.verify().is_ok() {
473                    Box::new(cli)
474                } else {
475                    Box::new(Unavailable)
476                }
477            }
478            None => Box::new(Unavailable),
479        }
480    }
481
482    /// README "Finding the binary" step 4: `where --json` must answer `membrane.where.v1`
483    /// and its `binary` must be the path that was run.
484    pub fn verify(&self) -> Result<WhereInfo, MembraneError> {
485        let info = self.where_binary()?;
486        let same = |a: &Path, b: &Path| match (a.canonicalize(), b.canonicalize()) {
487            (Ok(a), Ok(b)) => a == b,
488            _ => a == b,
489        };
490        if same(&info.binary, &self.binary) {
491            Ok(info)
492        } else {
493            Err(MembraneError::Parse(format!(
494                "`where` reports {} but {} was run",
495                info.binary.display(),
496                self.binary.display()
497            )))
498        }
499    }
500
501    /// `membrane where --json` (`membrane.where.v1`). Read-only; never contacts the resident.
502    pub fn where_binary(&self) -> Result<WhereInfo, MembraneError> {
503        let doc = self.run_json(&[s("where"), s("--json")], &["membrane.where"])?;
504        Ok(WhereInfo {
505            binary: PathBuf::from(str_field(&doc, "binary")?),
506            product_version: opt_str(&doc, "product_version"),
507            membrane_source_commit: opt_str(&doc, "membrane_source_commit"),
508            target: opt_str(&doc, "target"),
509            state_root: opt_str(&doc, "stateRoot"),
510        })
511    }
512
513    /// Run with a hard timeout; the child is killed and reaped on expiry.
514    pub fn run(&self, args: &[String]) -> Result<String, MembraneError> {
515        self.run_stdin(args, None)
516    }
517
518    /// [`Self::run`] with optional standard input.
519    pub fn run_stdin(
520        &self,
521        args: &[String],
522        stdin: Option<&[u8]>,
523    ) -> Result<String, MembraneError> {
524        let (code, stdout, stderr) = self.run_output(args, stdin)?;
525        if code == Some(0) {
526            Ok(stdout)
527        } else {
528            Err(map_exit(code, &stderr))
529        }
530    }
531
532    /// Run and return `(exit code, stdout, stderr)` for any exit; only spawn failure and
533    /// timeout are errors.
534    fn run_output(
535        &self,
536        args: &[String],
537        stdin: Option<&[u8]>,
538    ) -> Result<(Option<i32>, String, String), MembraneError> {
539        // The shared RightKit runner: hidden console, stdin written on its own thread (or null), both
540        // streams drained to their caps, the whole process tree terminated at the deadline.
541        let mut owned = rightkit_child_process::OwnedCommand::new(&self.binary);
542        owned
543            .command_mut()
544            .args(args)
545            .envs(self.env.iter().map(|(k, v)| (k, v)));
546        let options = rightkit_child_process::RunOptions::new(self.timeout)
547            .stdout_cap(usize::MAX)
548            .stderr_cap(usize::MAX);
549        let output = rightkit_child_process::run_command(owned, stdin, &options)
550            .map_err(|e| MembraneError::Spawn(e.to_string()))?;
551        if output.timed_out {
552            return Err(MembraneError::Timeout(self.timeout));
553        }
554        Ok((
555            output.status.code(),
556            String::from_utf8_lossy(&output.stdout.bytes).into_owned(),
557            String::from_utf8_lossy(&output.stderr.bytes).into_owned(),
558        ))
559    }
560
561    /// Run, parse one JSON document and check its schema id against `accepted` bases
562    /// (`membrane.<command>`, no version) and [`SUPPORTED_MAJOR`].
563    fn run_json(&self, args: &[String], accepted: &[&str]) -> Result<Value, MembraneError> {
564        let out = self.run(args)?;
565        parse_document(&out, accepted)
566    }
567}
568
569/// Exit code to error (README "Exit codes"); stderr is one `membrane: <reason>` line.
570pub fn map_exit(code: Option<i32>, stderr: &str) -> MembraneError {
571    let trimmed = stderr.trim();
572    let reason = trimmed
573        .strip_prefix("membrane:")
574        .unwrap_or(trimmed)
575        .trim()
576        .to_owned();
577    match code {
578        Some(EXIT_INTERNAL_ERROR) => MembraneError::Internal(reason),
579        Some(EXIT_USER_ERROR) => MembraneError::Usage(reason),
580        Some(EXIT_NOT_AVAILABLE) => MembraneError::NotAvailable(reason),
581        Some(EXIT_RESIDENT_UNREACHABLE) => MembraneError::ResidentUnreachable(reason),
582        other => MembraneError::Exit {
583            code: other,
584            stderr: trimmed.to_owned(),
585        },
586    }
587}
588
589/// Parse stdout as one JSON object and validate `schema` (`membrane.<cmd>.v<N>`).
590pub fn parse_document(out: &str, accepted: &[&str]) -> Result<Value, MembraneError> {
591    let doc: Value =
592        serde_json::from_str(out.trim()).map_err(|e| MembraneError::Parse(e.to_string()))?;
593    check_schema(&doc, accepted)?;
594    Ok(doc)
595}
596
597/// Accept only `accepted` command bases at major [`SUPPORTED_MAJOR`]; unknown majors are
598/// [`MembraneError::UnsupportedSchema`].
599pub fn check_schema(doc: &Value, accepted: &[&str]) -> Result<(), MembraneError> {
600    let schema = doc
601        .get("schema")
602        .and_then(Value::as_str)
603        .ok_or_else(|| MembraneError::Parse("document has no `schema`".to_owned()))?;
604    let (base, major) = schema
605        .rsplit_once(".v")
606        .and_then(|(b, n)| n.parse::<u32>().ok().map(|n| (b, n)))
607        .ok_or_else(|| MembraneError::Parse(format!("malformed schema id `{schema}`")))?;
608    if !accepted.contains(&base) {
609        return Err(MembraneError::Parse(format!(
610            "unexpected schema `{schema}` (wanted {accepted:?})"
611        )));
612    }
613    if major != SUPPORTED_MAJOR {
614        return Err(MembraneError::UnsupportedSchema {
615            got: schema.to_owned(),
616        });
617    }
618    Ok(())
619}
620
621fn str_field(doc: &Value, key: &str) -> Result<String, MembraneError> {
622    doc.get(key)
623        .and_then(Value::as_str)
624        .map(str::to_owned)
625        .ok_or_else(|| MembraneError::Parse(format!("missing string field `{key}`")))
626}
627
628fn opt_str(doc: &Value, key: &str) -> Option<String> {
629    doc.get(key).and_then(Value::as_str).map(str::to_owned)
630}
631
632fn parse_items(doc: &Value) -> Result<Vec<MemoryItem>, MembraneError> {
633    let items = doc
634        .get("items")
635        .and_then(Value::as_array)
636        .ok_or_else(|| MembraneError::Parse("missing array field `items`".to_owned()))?;
637    items
638        .iter()
639        .map(|it| {
640            Ok(MemoryItem {
641                id: str_field(it, "id")?,
642                tier: str_field(it, "tier")?,
643                chars: it.get("chars").and_then(Value::as_u64),
644                access: it.get("access").cloned().unwrap_or(Value::Null),
645                inject: it.get("inject").cloned().unwrap_or(Value::Null),
646                scope: opt_str(it, "scope"),
647                score: it.get("score").and_then(Value::as_f64),
648                content: opt_str(it, "content"),
649            })
650        })
651        .collect()
652}
653
654fn s(v: &str) -> String {
655    v.to_owned()
656}
657
658fn push_repo(args: &mut Vec<String>, repo: Option<&Path>) {
659    if let Some(r) = repo {
660        args.push(s("--repo"));
661        args.push(r.to_string_lossy().into_owned());
662    }
663}
664
665impl MembraneBackend for MembraneCli {
666    fn context_pull(
667        &self,
668        cwd: &Path,
669        prompt: &str,
670        budget: Option<usize>,
671        repo: Option<&Path>,
672    ) -> Result<ContextPull, MembraneError> {
673        let mut args = vec![
674            s("context"),
675            s("pull"),
676            s("--json"),
677            s("--cwd"),
678            cwd.to_string_lossy().into_owned(),
679            s("--prompt"),
680            s(prompt),
681        ];
682        if let Some(b) = budget {
683            args.push(s("--budget"));
684            args.push(b.to_string());
685        }
686        push_repo(&mut args, repo);
687        let doc = match self.run_json(&args, &["membrane.context.pull"]) {
688            Ok(doc) => doc,
689            // Exit 3: not enrolled, recall off or no graph. Context is simply off.
690            Err(MembraneError::NotAvailable(reason)) => {
691                return Ok(ContextPull {
692                    text: String::new(),
693                    off_reason: Some(reason),
694                    reason: None,
695                    detail: Value::Null,
696                });
697            }
698            Err(e) => return Err(e),
699        };
700        let text = doc
701            .get("context")
702            .and_then(Value::as_str)
703            .unwrap_or("")
704            .trim_end()
705            .to_owned();
706        Ok(ContextPull {
707            text,
708            off_reason: None,
709            reason: opt_str(&doc, "reason"),
710            detail: doc.get("detail").cloned().unwrap_or(Value::Null),
711        })
712    }
713
714    fn memory_push(&self, text: &str) -> Result<Value, MembraneError> {
715        // Not a versioned document in the README table; returned as one JSON value.
716        let out = self.run(&[s("memory"), s("push"), s(text)])?;
717        serde_json::from_str(out.trim()).map_err(|e| MembraneError::Parse(e.to_string()))
718    }
719
720    fn memory_list(&self, limit: usize, repo: Option<&Path>) -> Result<MemoryList, MembraneError> {
721        let mut args = vec![
722            s("memory"),
723            s("list"),
724            s("--json"),
725            s("--limit"),
726            limit.to_string(),
727        ];
728        push_repo(&mut args, repo);
729        let doc = match self.run_json(&args, &["membrane.memory.list"]) {
730            Ok(doc) => doc,
731            // Exit 3: no memory store yet (before the first push).
732            Err(MembraneError::NotAvailable(_)) => {
733                return Ok(MemoryList {
734                    scope: None,
735                    limit: limit as u64,
736                    items: Vec::new(),
737                    completeness: Value::Null,
738                });
739            }
740            Err(e) => return Err(e),
741        };
742        Ok(MemoryList {
743            scope: opt_str(&doc, "scope"),
744            limit: doc
745                .get("limit")
746                .and_then(Value::as_u64)
747                .unwrap_or(limit as u64),
748            items: parse_items(&doc)?,
749            completeness: doc.get("completeness").cloned().unwrap_or(Value::Null),
750        })
751    }
752
753    fn memory_search(
754        &self,
755        query: &str,
756        tiers: &[String],
757        limit: usize,
758        repo: Option<&Path>,
759    ) -> Result<MemorySearch, MembraneError> {
760        let mut args = vec![s("memory"), s("search"), s(query)];
761        for t in tiers {
762            args.push(s("--tier"));
763            args.push(t.clone());
764        }
765        args.push(s("--limit"));
766        args.push(limit.to_string());
767        push_repo(&mut args, repo);
768        args.push(s("--json"));
769        let doc = match self.run_json(&args, &["membrane.memory.search"]) {
770            Ok(doc) => doc,
771            Err(MembraneError::NotAvailable(_)) => return Ok(empty_search(query, limit)),
772            Err(e) => return Err(e),
773        };
774        Ok(MemorySearch {
775            query: opt_str(&doc, "query"),
776            tiers: doc
777                .get("tiers")
778                .and_then(Value::as_array)
779                .map(|a| {
780                    a.iter()
781                        .filter_map(Value::as_str)
782                        .map(str::to_owned)
783                        .collect()
784                })
785                .unwrap_or_default(),
786            scope: opt_str(&doc, "scope"),
787            limit: doc.get("limit").and_then(Value::as_u64),
788            pool: doc.get("pool").and_then(Value::as_u64),
789            more: doc.get("more").and_then(Value::as_bool).unwrap_or(false),
790            items: parse_items(&doc)?,
791        })
792    }
793
794    fn memory_delete(&self, id: &str) -> Result<MemoryDelete, MembraneError> {
795        // Exit 3 with `deleted: false` when no memory has the id.
796        let (code, stdout, stderr) =
797            self.run_output(&[s("memory"), s("delete"), s(id), s("--json")], None)?;
798        match code {
799            Some(0) | Some(EXIT_NOT_AVAILABLE) if !stdout.trim().is_empty() => {
800                let doc = parse_document(&stdout, &["membrane.memory.delete"])?;
801                Ok(MemoryDelete {
802                    id: opt_str(&doc, "id").unwrap_or_else(|| id.to_owned()),
803                    deleted: doc
804                        .get("deleted")
805                        .and_then(Value::as_bool)
806                        .unwrap_or(code == Some(0)),
807                })
808            }
809            Some(EXIT_NOT_AVAILABLE) => Ok(MemoryDelete {
810                id: id.to_owned(),
811                deleted: false,
812            }),
813            _ => Err(map_exit(code, &stderr)),
814        }
815    }
816
817    fn blueprint_search(
818        &self,
819        q: &str,
820        limit: usize,
821        repo: Option<&Path>,
822    ) -> Result<BlueprintAnswer, MembraneError> {
823        // `blueprint <query...> [--repo <path>]`: the whole tail is the query and `--repo` is
824        // split out of it (modes.rs `split_repo_flag`), so it follows the verb's own words.
825        let mut args = vec![
826            s("blueprint"),
827            s("search"),
828            s("--limit"),
829            limit.to_string(),
830            s(q),
831        ];
832        push_repo(&mut args, repo);
833        let doc = self.run_json(&args, &["membrane.blueprint.search"])?;
834        let schema = str_field(&doc, "schema")?;
835        let payload_schema = opt_str(&doc, "payload_schema");
836        let mut body = doc;
837        if let Some(obj) = body.as_object_mut() {
838            obj.remove("schema");
839            obj.remove("payload_schema");
840        }
841        Ok(BlueprintAnswer {
842            schema,
843            payload_schema,
844            body,
845        })
846    }
847
848    fn status(&self) -> Result<StatusInfo, MembraneError> {
849        let doc = self.run_json(&[s("status"), s("--json")], &["membrane.status"])?;
850        Ok(StatusInfo {
851            product_version: str_field(&doc, "product_version")?,
852            mode: str_field(&doc, "mode")?,
853            state_root: opt_str(&doc, "stateRoot"),
854            db_path: doc
855                .pointer("/db/path")
856                .and_then(Value::as_str)
857                .map(str::to_owned),
858            db_present: doc.pointer("/db/present").and_then(Value::as_bool),
859            model: opt_str(&doc, "model"),
860            resident: doc.get("resident").cloned().unwrap_or(Value::Null),
861            raw: doc,
862        })
863    }
864
865    fn adapt_observe(&self, request: &Value) -> Result<Value, MembraneError> {
866        let body = serde_json::to_vec(request).map_err(|e| MembraneError::Parse(e.to_string()))?;
867        let out = self.run_stdin(
868            &[s("adapt"), s("observe"), s("--stdin"), s("--json")],
869            Some(&body),
870        )?;
871        adapt_result(&out, "membrane.adapt.observe")
872    }
873
874    fn adapt_recall(
875        &self,
876        query: &str,
877        k: usize,
878        scope: &str,
879        dimensions: &[String],
880    ) -> Result<Value, MembraneError> {
881        let mut args = vec![
882            s("adapt"),
883            s("recall"),
884            s(query),
885            s("-k"),
886            k.to_string(),
887            s("--scope"),
888            s(scope),
889        ];
890        for d in dimensions {
891            args.push(s("--dimension"));
892            args.push(d.clone());
893        }
894        args.push(s("--json"));
895        adapt_result(&self.run(&args)?, "membrane.adapt.recall")
896    }
897
898    fn adapt_apply_insights(
899        &self,
900        insights: &Path,
901        installation_id: &str,
902        dry_run: bool,
903    ) -> Result<Value, MembraneError> {
904        let mut args = vec![
905            s("adapt"),
906            s("apply"),
907            s("--insights"),
908            insights.to_string_lossy().into_owned(),
909            s("--installation-id"),
910            s(installation_id),
911        ];
912        if dry_run {
913            args.push(s("--dry-run"));
914        }
915        args.push(s("--json"));
916        adapt_result(&self.run(&args)?, "membrane.adapt.apply")
917    }
918
919    fn adapt_apply_manifest(&self, manifest: &Path, dry_run: bool) -> Result<Value, MembraneError> {
920        let mut args = vec![
921            s("adapt"),
922            s("apply"),
923            s("--manifest"),
924            manifest.to_string_lossy().into_owned(),
925        ];
926        if dry_run {
927            args.push(s("--dry-run"));
928        }
929        args.push(s("--json"));
930        adapt_result(&self.run(&args)?, "membrane.adapt.apply")
931    }
932
933    fn adapt_taste_review(
934        &self,
935        input: &Path,
936        installation_id: &str,
937        created_at: &str,
938        canonical_pool_sha256: Option<&str>,
939    ) -> Result<Value, MembraneError> {
940        let mut args = vec![
941            s("adapt"),
942            s("taste-review"),
943            s("--input"),
944            input.to_string_lossy().into_owned(),
945            s("--installation-id"),
946            s(installation_id),
947            s("--created-at"),
948            s(created_at),
949        ];
950        if let Some(d) = canonical_pool_sha256 {
951            args.push(s("--canonical-pool-sha256"));
952            args.push(s(d));
953        }
954        args.push(s("--json"));
955        adapt_result(&self.run(&args)?, "membrane.adapt.taste_review")
956    }
957
958    fn transcript_append(
959        &self,
960        session: &str,
961        cursor: u64,
962        scope: Option<&str>,
963        events: &Value,
964    ) -> Result<TranscriptAppend, MembraneError> {
965        let mut args = vec![
966            s("transcript"),
967            s("append"),
968            s("--session"),
969            s(session),
970            s("--cursor"),
971            cursor.to_string(),
972        ];
973        if let Some(sc) = scope {
974            args.push(s("--scope"));
975            args.push(s(sc));
976        }
977        args.push(s("--stdin"));
978        args.push(s("--json"));
979        let body = serde_json::to_vec(events).map_err(|e| MembraneError::Parse(e.to_string()))?;
980        let (code, stdout, stderr) = self.run_output(&args, Some(&body))?;
981        // Exit 2 prints the conflict receipt on stdout; other exit-2 refusals print none.
982        if code == Some(0) || (code == Some(EXIT_USER_ERROR) && !stdout.trim().is_empty()) {
983            let doc = parse_document(&stdout, &["membrane.transcript.append"])?;
984            if doc.get("conflict").and_then(Value::as_bool) == Some(true) {
985                return Ok(TranscriptAppend::Conflict(TranscriptConflict {
986                    current_cursor: doc.get("current_cursor").and_then(Value::as_u64),
987                    status: doc.get("status").and_then(Value::as_u64),
988                    code: opt_str(&doc, "code").unwrap_or_default(),
989                    detail: doc.get("detail").cloned().unwrap_or(Value::Null),
990                }));
991            }
992            if code == Some(0) {
993                return Ok(TranscriptAppend::Committed {
994                    session: opt_str(&doc, "session").unwrap_or_else(|| session.to_owned()),
995                    cursor: doc.get("cursor").and_then(Value::as_u64).unwrap_or(cursor),
996                    replayed: doc
997                        .get("replayed")
998                        .and_then(Value::as_bool)
999                        .unwrap_or(false),
1000                    receipt: doc.get("receipt").cloned().unwrap_or(Value::Null),
1001                });
1002            }
1003        }
1004        Err(map_exit(code, &stderr))
1005    }
1006
1007    fn transcript_tip(&self, session: &str) -> Result<TranscriptTip, MembraneError> {
1008        let doc = self.run_json(
1009            &[
1010                s("transcript"),
1011                s("tip"),
1012                s("--session"),
1013                s(session),
1014                s("--json"),
1015            ],
1016            &["membrane.transcript.tip"],
1017        )?;
1018        Ok(TranscriptTip {
1019            session: opt_str(&doc, "session").unwrap_or_else(|| session.to_owned()),
1020            current_cursor: doc
1021                .get("current_cursor")
1022                .and_then(Value::as_u64)
1023                .ok_or_else(|| MembraneError::Parse("missing `current_cursor`".to_owned()))?,
1024            prefix_digest: opt_str(&doc, "prefix_digest"),
1025        })
1026    }
1027}
1028
1029/// The `result` of an `adapt` document.
1030fn adapt_result(out: &str, base: &str) -> Result<Value, MembraneError> {
1031    let doc = parse_document(out, &[base])?;
1032    doc.get("result")
1033        .cloned()
1034        .ok_or_else(|| MembraneError::Parse("missing field `result`".to_owned()))
1035}
1036
1037/// Pulse's notch state folder: Windows `%LOCALAPPDATA%\Pulse`, Mac
1038/// `~/Library/Application Support/Pulse` (`pulse/core/src/usage_snapshot.rs`,
1039/// `app_manager.rs`).
1040fn notch_state_path(
1041    local_app_data: Option<&std::ffi::OsStr>,
1042    home: Option<&std::ffi::OsStr>,
1043) -> Option<PathBuf> {
1044    if cfg!(windows) {
1045        local_app_data.map(|b| PathBuf::from(b).join("Pulse/notch-state.json"))
1046    } else {
1047        home.map(|h| PathBuf::from(h).join("Library/Application Support/Pulse/notch-state.json"))
1048    }
1049}
1050
1051/// `membraneBinary.path` of `notch-state.json`, when it names an existing file
1052/// (`{"path": null, "reason": ..}` means not installed).
1053fn notch_state_binary(
1054    local_app_data: Option<&std::ffi::OsStr>,
1055    home: Option<&std::ffi::OsStr>,
1056) -> Option<PathBuf> {
1057    let text = std::fs::read_to_string(notch_state_path(local_app_data, home)?).ok()?;
1058    let doc: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?;
1059    let path = PathBuf::from(doc.pointer("/membraneBinary/path")?.as_str()?);
1060    path.is_file().then_some(path)
1061}
1062
1063/// `binary` of a fresh `resident.json` (README "Finding the binary" step 1). Staleness is
1064/// judged by `ts` (Unix ms) only; a dead pid with a fresh `ts` cannot occur within 30 s of
1065/// the resident's 10 s refresh except right after a crash, and the `is_file` check still holds.
1066fn resident_binary(
1067    local_app_data: Option<&std::ffi::OsStr>,
1068    home: Option<&std::ffi::OsStr>,
1069) -> Option<PathBuf> {
1070    let state = match std::env::var_os("MEMBRANE_STATE_ROOT") {
1071        Some(root) if !root.is_empty() => PathBuf::from(root),
1072        _ if cfg!(windows) => PathBuf::from(local_app_data?).join("Orthic Labs/Membrane/state"),
1073        _ => PathBuf::from(home?).join("Library/Application Support/Orthic Labs/Membrane/state"),
1074    };
1075    let text = std::fs::read_to_string(state.join("resident.json")).ok()?;
1076    let doc: Value = serde_json::from_str(text.trim_start_matches('\u{feff}')).ok()?;
1077    let ts = u128::from(doc.get("ts")?.as_u64()?);
1078    let now = SystemTime::now()
1079        .duration_since(UNIX_EPOCH)
1080        .ok()?
1081        .as_millis();
1082    if now.saturating_sub(ts) > RESIDENT_STALE_MS {
1083        return None;
1084    }
1085    let path = PathBuf::from(doc.get("binary")?.as_str()?);
1086    path.is_file().then_some(path)
1087}