1use serde_json::Value;
17use std::ffi::OsString;
18use std::path::{Path, PathBuf};
19use std::time::{Duration, SystemTime, UNIX_EPOCH};
20
21pub const BIN_ENV: &str = "RIGHTKIT_MEMBRANE_BIN";
23pub const OFF_REASON: &str = "off: pulse not installed";
25pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(15);
27pub const SUPPORTED_MAJOR: u32 = 1;
29
30pub 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
37const RESIDENT_STALE_MS: u128 = 30_000;
39
40#[derive(Debug, thiserror::Error)]
41pub enum MembraneError {
42 #[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 #[error("membrane internal error: {0}")]
51 Internal(String),
52 #[error("membrane usage error: {0}")]
54 Usage(String),
55 #[error("membrane not available: {0}")]
57 NotAvailable(String),
58 #[error("membrane resident unreachable: {0}")]
60 ResidentUnreachable(String),
61 #[error("membrane exited with {code:?}: {stderr}")]
63 Exit { code: Option<i32>, stderr: String },
64 #[error("cannot parse membrane output: {0}")]
65 Parse(String),
66 #[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 pub text: String,
75 pub off_reason: Option<String>,
77 pub reason: Option<String>,
79 pub detail: Value,
81}
82
83#[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 pub scope: Option<String>,
93 pub score: Option<f64>,
95 pub content: Option<String>,
97}
98
99#[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#[derive(Debug, Clone, PartialEq)]
110pub struct MemorySearch {
111 pub query: Option<String>,
112 pub tiers: Vec<String>,
114 pub scope: Option<String>,
115 pub limit: Option<u64>,
116 pub pool: Option<u64>,
118 pub more: bool,
120 pub items: Vec<MemoryItem>,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct MemoryDelete {
126 pub id: String,
127 pub deleted: bool,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct BlueprintAnswer {
133 pub schema: String,
134 pub payload_schema: Option<String>,
136 pub body: Value,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct StatusInfo {
143 pub product_version: String,
144 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 pub raw: Value,
153}
154
155#[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#[derive(Debug, Clone, PartialEq)]
167pub enum TranscriptAppend {
168 Committed {
170 session: String,
171 cursor: u64,
172 replayed: bool,
173 receipt: Value,
175 },
176 Conflict(TranscriptConflict),
178}
179
180#[derive(Debug, Clone, PartialEq)]
182pub struct TranscriptConflict {
183 pub current_cursor: Option<u64>,
185 pub status: Option<u64>,
187 pub code: String,
190 pub detail: Value,
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct TranscriptTip {
196 pub session: String,
197 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 fn adapt_observe(&self, request: &Value) -> Result<Value, MembraneError>;
229 fn adapt_recall(
231 &self,
232 query: &str,
233 k: usize,
234 scope: &str,
235 dimensions: &[String],
236 ) -> Result<Value, MembraneError>;
237 fn adapt_apply_insights(
239 &self,
240 insights: &Path,
241 installation_id: &str,
242 dry_run: bool,
243 ) -> Result<Value, MembraneError>;
244 fn adapt_apply_manifest(&self, manifest: &Path, dry_run: bool) -> Result<Value, MembraneError>;
246 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 fn transcript_append(
258 &self,
259 session: &str,
260 cursor: u64,
261 scope: Option<&str>,
262 events: &Value,
263 ) -> Result<TranscriptAppend, MembraneError>;
264 fn transcript_tip(&self, session: &str) -> Result<TranscriptTip, MembraneError>;
266}
267
268#[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 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 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 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 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 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 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 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 pub fn run(&self, args: &[String]) -> Result<String, MembraneError> {
515 self.run_stdin(args, None)
516 }
517
518 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 fn run_output(
535 &self,
536 args: &[String],
537 stdin: Option<&[u8]>,
538 ) -> Result<(Option<i32>, String, String), MembraneError> {
539 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 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
569pub 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
589pub 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
597pub 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 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 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 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 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 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 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
1029fn 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
1037fn 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
1051fn 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
1063fn 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}