1use crate::backend::contract::{CallContext, MemoryBackend};
23use crate::backend::error::{MemoryBackendError, MemoryBackendErrorKind};
24use crate::backend::types::{
25 normalize_scope, BackendBinding, BackendIdentity, FullMemoryRecord, JsonValue, MemoryEntry,
26 MemoryId, MemoryListRow, MemoryTier, Score,
27};
28use crate::membrane::{
29 ContextPull, MembraneBackend, MembraneCli, MembraneError, MemoryItem, Unavailable, OFF_REASON,
30};
31use async_trait::async_trait;
32use serde_json::Value;
33use std::path::Path;
34use std::sync::{Arc, OnceLock};
35use std::time::{SystemTime, UNIX_EPOCH};
36
37pub const NOT_AVAILABLE_VIA_CLI: &str = "not available via Membrane CLI (Orthic-Labs/pulse#129)";
39
40static OFF: Unavailable = Unavailable;
41
42enum Inner {
43 Cli {
44 cli: MembraneCli,
45 verified: OnceLock<Result<(), String>>,
47 },
48 Off,
50}
51
52impl Inner {
53 fn backend(&self) -> Result<&dyn MembraneBackend, String> {
54 match self {
55 Inner::Off => Ok(&OFF),
56 Inner::Cli { cli, verified } => {
57 match verified.get_or_init(|| cli.verify().map(|_| ()).map_err(|e| e.to_string())) {
58 Ok(()) => Ok(cli),
59 Err(reason) => Err(reason.clone()),
60 }
61 }
62 }
63 }
64
65 fn run<T>(
66 &self,
67 f: impl FnOnce(&dyn MembraneBackend) -> Result<T, MembraneError>,
68 ) -> Result<T, MembraneError> {
69 match self.backend() {
70 Ok(backend) => f(backend),
71 Err(reason) => Err(MembraneError::Unavailable(format!(
72 "off: membrane unusable: {reason}"
73 ))),
74 }
75 }
76}
77
78#[derive(Clone)]
80pub struct MembraneHandle {
81 inner: Arc<Inner>,
82}
83
84impl MembraneHandle {
85 pub fn connect() -> Self {
88 match MembraneCli::locate() {
89 Some(binary) => Self::from_cli(MembraneCli::new(binary)),
90 None => Self {
91 inner: Arc::new(Inner::Off),
92 },
93 }
94 }
95
96 pub fn shared() -> Self {
99 static SHARED: OnceLock<MembraneHandle> = OnceLock::new();
100 SHARED.get_or_init(Self::connect).clone()
101 }
102
103 pub fn verified_cli(&self) -> Option<MembraneCli> {
105 match &*self.inner {
106 Inner::Off => None,
107 Inner::Cli { cli, .. } => self.inner.backend().ok().map(|_| cli.clone()),
108 }
109 }
110
111 pub fn backend_arc(&self) -> Arc<dyn MembraneBackend + Send + Sync> {
113 match self.verified_cli() {
114 Some(cli) => Arc::new(cli),
115 None => Arc::new(Unavailable),
116 }
117 }
118
119 pub fn from_cli(cli: MembraneCli) -> Self {
121 Self {
122 inner: Arc::new(Inner::Cli {
123 cli,
124 verified: OnceLock::new(),
125 }),
126 }
127 }
128
129 pub fn is_off(&self) -> bool {
131 matches!(*self.inner, Inner::Off)
132 }
133
134 pub fn off_reason(&self) -> Option<String> {
136 match &*self.inner {
137 Inner::Off => Some(OFF_REASON.to_owned()),
138 Inner::Cli { .. } => self
139 .inner
140 .backend()
141 .err()
142 .map(|reason| format!("off: membrane unusable: {reason}")),
143 }
144 }
145
146 pub fn call_blocking<T>(
148 &self,
149 f: impl FnOnce(&dyn MembraneBackend) -> Result<T, MembraneError>,
150 ) -> Result<T, MembraneError> {
151 self.inner.run(f)
152 }
153
154 pub async fn call<T, F>(
157 &self,
158 context: &CallContext,
159 mutation: bool,
160 f: F,
161 ) -> Result<T, MemoryBackendError>
162 where
163 T: Send + 'static,
164 F: FnOnce(&dyn MembraneBackend) -> Result<T, MembraneError> + Send + 'static,
165 {
166 context.check()?;
167 let inner = Arc::clone(&self.inner);
168 let task = tokio::task::spawn_blocking(move || inner.run(f));
169 let cancel = context.cancellation();
170 let deadline = context.deadline();
171 let waited = async {
172 match deadline {
173 Some(at) => tokio::time::timeout_at(tokio::time::Instant::from_std(at), task)
174 .await
175 .map_err(|_| ()),
176 None => Ok(task.await),
177 }
178 };
179 let joined = tokio::select! {
180 result = waited => match result {
181 Ok(joined) => joined,
182 Err(()) => return Err(interrupted(mutation, true)),
183 },
184 _ = cancel.cancelled() => return Err(interrupted(mutation, false)),
185 };
186 match joined {
187 Ok(result) => result.map_err(|error| map_error(error, mutation)),
188 Err(error) => Err(MemoryBackendError::Internal {
189 code: "membrane_task_failed".into(),
190 message: format!("membrane call did not complete: {error}"),
191 }),
192 }
193 }
194
195 pub async fn context_pull(
197 &self,
198 context: &CallContext,
199 cwd: &Path,
200 prompt: &str,
201 budget: Option<usize>,
202 repo: Option<&Path>,
203 ) -> Result<ContextPull, MemoryBackendError> {
204 let (cwd, prompt, repo) = (
205 cwd.to_path_buf(),
206 prompt.to_owned(),
207 repo.map(Path::to_path_buf),
208 );
209 self.call(context, false, move |m| {
210 m.context_pull(&cwd, &prompt, budget, repo.as_deref())
211 })
212 .await
213 }
214}
215
216fn interrupted(mutation: bool, timed_out: bool) -> MemoryBackendError {
217 match (mutation, timed_out) {
218 (true, _) => MemoryBackendError::commit_unknown(
219 "membrane mutation interrupted before an answer; it may have committed",
220 ),
221 (false, true) => MemoryBackendError::timeout("memory operation deadline elapsed"),
222 (false, false) => MemoryBackendError::cancelled("memory operation cancelled"),
223 }
224}
225
226pub fn map_error(error: MembraneError, mutation: bool) -> MemoryBackendError {
229 let message = error.to_string();
230 match error {
231 MembraneError::Unavailable(_)
232 | MembraneError::Spawn(_)
233 | MembraneError::ResidentUnreachable(_) => MemoryBackendError::BackendUnavailable {
234 code: "backend_unavailable".into(),
235 message,
236 },
237 MembraneError::Timeout(_) if mutation => MemoryBackendError::commit_unknown(message),
238 MembraneError::Timeout(_) => MemoryBackendError::timeout(message),
239 MembraneError::Exit { .. } | MembraneError::Parse(_) if mutation => {
240 MemoryBackendError::commit_unknown(message)
241 }
242 MembraneError::Internal(_) => MemoryBackendError::Store {
243 code: "membrane_internal".into(),
244 message,
245 },
246 MembraneError::Usage(_) => MemoryBackendError::Invalid {
247 code: "membrane_usage".into(),
248 message,
249 },
250 MembraneError::NotAvailable(_) => MemoryBackendError::Unavailable {
251 code: "membrane_not_available".into(),
252 message,
253 },
254 MembraneError::Parse(_) | MembraneError::UnsupportedSchema { .. } => {
255 MemoryBackendError::Incompatible {
256 code: "membrane_schema".into(),
257 message,
258 }
259 }
260 MembraneError::Exit { .. } => MemoryBackendError::Internal {
261 code: "membrane_exit".into(),
262 message,
263 },
264 }
265}
266
267fn not_available(operation: &str) -> MemoryBackendError {
268 MemoryBackendError::Unavailable {
269 code: "not_available_via_membrane_cli".into(),
270 message: format!("{operation}: {NOT_AVAILABLE_VIA_CLI}"),
271 }
272}
273
274pub fn is_unavailable(error: &MemoryBackendError) -> bool {
275 matches!(
276 error.kind(),
277 MemoryBackendErrorKind::Unavailable | MemoryBackendErrorKind::BackendUnavailable
278 )
279}
280
281pub struct MembraneMemoryBackend {
283 handle: MembraneHandle,
284 binding: BackendBinding,
285}
286
287impl MembraneMemoryBackend {
288 pub fn new(handle: MembraneHandle) -> Self {
289 Self {
290 handle,
291 binding: BackendBinding::new(BackendIdentity::service("membrane-cli", 1, 1, None)),
292 }
293 }
294
295 pub fn handle(&self) -> &MembraneHandle {
296 &self.handle
297 }
298
299 async fn push(&self, context: &CallContext, text: &str) -> Result<Value, MemoryBackendError> {
300 let text = text.to_owned();
301 let answer = self
302 .handle
303 .call(context, true, move |m| m.memory_push(&text))
304 .await?;
305 if answer.get("stored").and_then(Value::as_bool) == Some(false) {
306 return Err(MemoryBackendError::Store {
307 code: "membrane_push_refused".into(),
308 message: format!("membrane did not store the memory: {answer}"),
309 });
310 }
311 Ok(answer)
312 }
313}
314
315fn pushed_id(answer: &Value) -> Option<String> {
316 ["id", "memory_id", "memoryId"]
317 .iter()
318 .find_map(|key| answer.get(*key).and_then(Value::as_str))
319 .filter(|id| !id.is_empty())
320 .map(str::to_owned)
321}
322
323fn count(value: &Value) -> u32 {
324 match value {
325 Value::Number(n) => n.as_u64().unwrap_or(0).min(u32::MAX as u64) as u32,
326 Value::Bool(true) => 1,
327 _ => 0,
328 }
329}
330
331fn tier_of(raw: &str) -> MemoryTier {
332 MemoryTier::parse(raw).unwrap_or(MemoryTier::Semantic)
333}
334
335fn entry_from_item(item: &MemoryItem) -> MemoryEntry {
336 MemoryEntry {
337 id: item.id.clone(),
338 tier: tier_of(&item.tier),
339 content: item.content.clone().unwrap_or_default(),
340 keywords: Vec::new(),
341 score: item.score.unwrap_or(0.0),
342 created_at: String::new(),
343 access_count: count(&item.access),
344 embedding: None,
345 scope_id: item
346 .scope
347 .as_deref()
348 .map(normalize_scope)
349 .unwrap_or_else(|| "global".to_owned()),
350 }
351}
352
353fn in_scopes(item: &MemoryItem, scopes: &[String]) -> bool {
354 if scopes.is_empty() {
355 return true;
356 }
357 match item.scope.as_deref().map(normalize_scope) {
358 None => true,
359 Some(scope) => scope == "global" || scopes.iter().any(|s| normalize_scope(s) == scope),
360 }
361}
362
363fn now_rfc3339() -> String {
364 let secs = SystemTime::now()
365 .duration_since(UNIX_EPOCH)
366 .map(|d| d.as_secs() as i64)
367 .unwrap_or(0);
368 rightkit_time::DateTime::from_unix(secs, 0)
369 .map(|dt| dt.format_rfc3339())
370 .unwrap_or_default()
371}
372
373fn pushed_entry(answer: &Value, content: &str, keywords: Vec<String>) -> MemoryEntry {
374 MemoryEntry {
375 id: pushed_id(answer).unwrap_or_else(|| "membrane-pushed".to_owned()),
376 tier: MemoryTier::Semantic,
377 content: content.to_owned(),
378 keywords,
379 score: 1.0,
380 created_at: now_rfc3339(),
381 access_count: 0,
382 embedding: None,
383 scope_id: "global".to_owned(),
384 }
385}
386
387#[async_trait]
388impl MemoryBackend for MembraneMemoryBackend {
389 fn binding(&self) -> &BackendBinding {
390 &self.binding
391 }
392
393 async fn activity_json(
394 &self,
395 _: &CallContext,
396 _: usize,
397 ) -> Result<JsonValue, MemoryBackendError> {
398 Err(not_available("activity_json"))
399 }
400
401 async fn delete(&self, context: &CallContext, id: &str) -> Result<bool, MemoryBackendError> {
402 let id = id.to_owned();
403 let deleted = self
404 .handle
405 .call(context, true, move |m| m.memory_delete(&id))
406 .await?;
407 Ok(deleted.deleted)
408 }
409
410 async fn embedder_dim(&self, _: &CallContext) -> Result<usize, MemoryBackendError> {
411 Err(not_available("embedder_dim"))
412 }
413
414 async fn entries(
417 &self,
418 _: &CallContext,
419 _: usize,
420 ) -> Result<Vec<MemoryEntry>, MemoryBackendError> {
421 Err(not_available("entries (memory list has no content)"))
422 }
423
424 async fn get_full(
425 &self,
426 _: &CallContext,
427 _: &str,
428 ) -> Result<FullMemoryRecord, MemoryBackendError> {
429 Err(not_available("get_full"))
430 }
431
432 #[allow(clippy::too_many_arguments)]
433 async fn log_recall_with_replay(
434 &self,
435 context: &CallContext,
436 _: &str,
437 _: Option<&str>,
438 _: usize,
439 _: usize,
440 _: usize,
441 _: usize,
442 _: &str,
443 _: Option<&str>,
444 _: Option<&str>,
445 _: Option<&str>,
446 _: Option<&str>,
447 _: Option<&str>,
448 _: Option<&str>,
449 _: Option<&str>,
450 _: &str,
451 _: &str,
452 _: &str,
453 ) -> Result<(), MemoryBackendError> {
454 context.check()
455 }
456
457 async fn metrics_json(&self, context: &CallContext) -> Result<JsonValue, MemoryBackendError> {
458 let status = self.handle.call(context, false, |m| m.status()).await?;
459 Ok(status.raw)
460 }
461
462 async fn put(
463 &self,
464 context: &CallContext,
465 _name: &str,
466 content: &str,
467 _scope: &str,
468 _tier: MemoryTier,
469 ) -> Result<MemoryId, MemoryBackendError> {
470 let answer = self.push(context, content).await?;
471 Ok(pushed_id(&answer)
472 .or_else(|| (!_name.is_empty()).then(|| _name.to_owned()))
473 .unwrap_or_else(|| "membrane-pushed".to_owned()))
474 }
475
476 async fn recall_scored(
477 &self,
478 context: &CallContext,
479 query: &str,
480 limit: usize,
481 scopes: &[String],
482 ) -> Result<Vec<(MemoryEntry, Score)>, MemoryBackendError> {
483 let fetch = if scopes.is_empty() {
485 limit
486 } else {
487 limit.saturating_mul(3).clamp(1, 200)
488 };
489 let query = query.to_owned();
490 let found = self
491 .handle
492 .call(context, false, move |m| {
493 m.memory_search(&query, &[], fetch, None)
494 })
495 .await?;
496 Ok(found
497 .items
498 .iter()
499 .filter(|item| in_scopes(item, scopes))
500 .take(limit)
501 .map(|item| {
502 let entry = entry_from_item(item);
503 let score = entry.score;
504 (entry, score)
505 })
506 .collect())
507 }
508
509 async fn record_injections(
510 &self,
511 context: &CallContext,
512 _: &[String],
513 ) -> Result<(), MemoryBackendError> {
514 context.check()
515 }
516
517 async fn remember(
518 &self,
519 context: &CallContext,
520 content: &str,
521 keywords: Vec<String>,
522 ) -> Result<MemoryEntry, MemoryBackendError> {
523 let answer = self.push(context, content).await?;
524 Ok(pushed_entry(&answer, content, keywords))
525 }
526
527 async fn scopes(&self, context: &CallContext) -> Result<Vec<String>, MemoryBackendError> {
529 context.check()?;
530 Ok(vec!["global".to_owned()])
531 }
532
533 async fn search(
534 &self,
535 context: &CallContext,
536 query: &str,
537 limit: usize,
538 ) -> Result<Vec<MemoryEntry>, MemoryBackendError> {
539 let query = query.to_owned();
540 let found = self
541 .handle
542 .call(context, false, move |m| {
543 m.memory_search(&query, &[], limit, None)
544 })
545 .await?;
546 Ok(found.items.iter().map(entry_from_item).collect())
547 }
548
549 async fn try_put(
550 &self,
551 context: &CallContext,
552 name: &str,
553 content: &str,
554 scope: &str,
555 tier: MemoryTier,
556 ) -> Result<MemoryId, MemoryBackendError> {
557 self.put(context, name, content, scope, tier).await
558 }
559
560 async fn list(
561 &self,
562 context: &CallContext,
563 _scope: Option<&str>,
564 ) -> Result<Vec<MemoryListRow>, MemoryBackendError> {
565 const LIST_LIMIT: usize = 1000;
566 let list = self
567 .handle
568 .call(context, false, |m| m.memory_list(LIST_LIMIT, None))
569 .await?;
570 Ok(list
571 .items
572 .iter()
573 .map(|item| MemoryListRow {
574 id: item.id.clone(),
575 tier: tier_of(&item.tier),
576 chars: item.chars.unwrap_or(0) as usize,
577 access_count: count(&item.access),
578 inject_count: count(&item.inject),
579 })
580 .collect())
581 }
582
583 async fn remember_consolidated(
584 &self,
585 context: &CallContext,
586 _name: &str,
587 content: &str,
588 keywords: Vec<String>,
589 _threshold: Score,
590 ) -> Result<Option<MemoryEntry>, MemoryBackendError> {
591 let probe = content.to_owned();
592 let found = self
593 .handle
594 .call(context, false, move |m| {
595 m.memory_search(&probe, &[], 1, None)
596 })
597 .await?;
598 let wanted = content.trim();
599 if found
600 .items
601 .iter()
602 .any(|item| item.content.as_deref().map(str::trim) == Some(wanted))
603 {
604 return Ok(None);
605 }
606 let answer = self.push(context, content).await?;
607 Ok(Some(pushed_entry(&answer, content, keywords)))
608 }
609}
610
611pub fn context_is_off() -> bool {
613 MembraneHandle::shared().is_off()
614}
615
616pub fn connect_from_environment() -> Result<Arc<dyn MemoryBackend>, MemoryBackendError> {
619 let handle = MembraneHandle::shared();
620 if handle.is_off() {
621 return Ok(Arc::new(
622 crate::backend::unavailable::UnavailableMemoryBackend::new(
623 "backend_unavailable",
624 OFF_REASON,
625 ),
626 ));
627 }
628 Ok(Arc::new(MembraneMemoryBackend::new(handle)))
629}
630
631pub fn canonical_repository_scope(path: &Path) -> String {
635 let mut value = path.to_string_lossy().replace('\\', "/");
636 if let Some(rest) = value.strip_prefix("//?/") {
637 value = rest.to_owned();
638 }
639 value = value.replace([':', '/'], "-");
640 let value = value.trim_matches('-');
641 if value.is_empty() {
642 return "global".to_owned();
643 }
644 let bytes = value.as_bytes();
645 if bytes.len() > 1 && bytes[0].is_ascii_lowercase() && bytes[1] == b'-' {
646 let mut normalized = value.to_owned();
647 normalized.replace_range(..1, &value[..1].to_ascii_uppercase());
648 normalized
649 } else {
650 value.to_owned()
651 }
652}
653
654#[derive(Clone)]
657pub struct AdaptClient {
658 handle: MembraneHandle,
659}
660
661impl AdaptClient {
662 pub fn new(handle: MembraneHandle) -> Self {
663 Self { handle }
664 }
665
666 fn unavailable(operation: &str) -> MembraneError {
667 MembraneError::NotAvailable(format!("{operation}: {NOT_AVAILABLE_VIA_CLI}"))
668 }
669
670 pub fn observe_execution(&self, request: Value) -> Result<Value, MembraneError> {
672 if !request.is_object() {
673 return Err(MembraneError::Usage(
674 "Adapt observation request must be a JSON object".into(),
675 ));
676 }
677 self.handle.call_blocking(|m| m.adapt_observe(&request))
678 }
679
680 pub fn status(&self, _scope: impl Into<String>) -> Result<Value, MembraneError> {
681 Err(Self::unavailable("adapt status"))
682 }
683
684 pub fn review_taste(
685 &self,
686 input: std::path::PathBuf,
687 installation_id: impl Into<String>,
688 canonical_pool_sha256: Option<String>,
689 created_at: impl Into<String>,
690 ) -> Result<Value, MembraneError> {
691 let (installation_id, created_at) = (installation_id.into(), created_at.into());
692 self.handle.call_blocking(|m| {
693 m.adapt_taste_review(
694 &input,
695 &installation_id,
696 &created_at,
697 canonical_pool_sha256.as_deref(),
698 )
699 })
700 }
701
702 pub fn adjudicate_taste(
703 &self,
704 _manifest: std::path::PathBuf,
705 _decisions: std::path::PathBuf,
706 _validated_at: impl Into<String>,
707 ) -> Result<Value, MembraneError> {
708 Err(Self::unavailable("adapt adjudicate_taste"))
709 }
710
711 pub fn apply_insights(
712 &self,
713 input: std::path::PathBuf,
714 installation_id: impl Into<String>,
715 dry_run: bool,
716 ) -> Result<Value, MembraneError> {
717 let installation_id = installation_id.into();
718 self.handle
719 .call_blocking(|m| m.adapt_apply_insights(&input, &installation_id, dry_run))
720 }
721
722 pub fn apply_taste(
723 &self,
724 manifest: std::path::PathBuf,
725 dry_run: bool,
726 ) -> Result<Value, MembraneError> {
727 self.handle
728 .call_blocking(|m| m.adapt_apply_manifest(&manifest, dry_run))
729 }
730
731 pub fn recall(
732 &self,
733 query: impl Into<String>,
734 scope: impl Into<String>,
735 k: usize,
736 dimensions: Vec<String>,
737 ) -> Result<Value, MembraneError> {
738 let (query, scope) = (query.into(), scope.into());
739 self.handle
740 .call_blocking(|m| m.adapt_recall(&query, k, &scope, &dimensions))
741 }
742}