Skip to main content

rightkit_memory_client/backend/
native.rs

1//! `MemoryBackend` and Adapt client over Pulse's `membrane` CLI.
2//!
3//! Everything here goes through `crate::membrane::MembraneBackend`; each CLI call is a
4//! child process bounded by the adapter's hard timeout, and each async call is additionally
5//! bounded by the caller's [`CallContext`] deadline and cancellation.
6//!
7//! Mapping notes (the CLI is narrower than the old resident protocol):
8//!
9//! * `put`, `try_put`, `remember`: `membrane memory push <text>` stores the content bytes
10//!   verbatim. Name, scope, tier and keywords have no CLI equivalent; Pulse scopes and tiers
11//!   the memory itself. Nothing is dropped from the content.
12//! * `remember_consolidated`: no server-side dedupe exists. An exact (trimmed) content match
13//!   in the top search hit returns `Ok(None)`; anything else is pushed. The similarity
14//!   `threshold` is not used, so near-duplicates are kept rather than risk losing a memory.
15//! * `recall_scored`/`search`: `memory search`. The CLI takes no scope list, so `scopes` is
16//!   applied client-side to items that carry a scope; items with no scope or `global` stay.
17//! * `list`: `memory list` (the `scope` argument cannot be forwarded and is ignored).
18//! * `entries`, `get_full`, `activity_json`, `embedder_dim`: no CLI equivalent, explicit error.
19//! * `record_injections`, `log_recall_with_replay`: telemetry only; Pulse records injections
20//!   itself, so these succeed as no-ops.
21
22use 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
37/// Error text for operations Pulse's CLI does not expose yet.
38pub 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        /// `membrane where --json` check, run once on first use (never at construction).
46        verified: OnceLock<Result<(), String>>,
47    },
48    /// Pulse is not installed.
49    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/// Shared, cheaply cloneable access to the located `membrane` binary.
79#[derive(Clone)]
80pub struct MembraneHandle {
81    inner: Arc<Inner>,
82}
83
84impl MembraneHandle {
85    /// Locate Pulse's `membrane` (env override, notch state, resident file, installed path).
86    /// Touches the filesystem only; the binary is verified lazily on first call.
87    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    /// The one process-wide handle: a single discovery and a single `membrane where` check,
97    /// shared by the memory backend, `DaemonState.membrane` and the transcript exporter.
98    pub fn shared() -> Self {
99        static SHARED: OnceLock<MembraneHandle> = OnceLock::new();
100        SHARED.get_or_init(Self::connect).clone()
101    }
102
103    /// The verified adapter, or `None` when Pulse is absent or the binary failed verification.
104    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    /// Shareable `MembraneBackend`: the verified CLI, else `Unavailable`.
112    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    /// Use an explicit adapter (custom timeout, test binary).
120    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    /// True when no Pulse binary was found.
130    pub fn is_off(&self) -> bool {
131        matches!(*self.inner, Inner::Off)
132    }
133
134    /// `off: pulse not installed`, or the verify failure. May run `membrane where` (bounded).
135    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    /// Synchronous call, bounded by the adapter's per-invocation timeout.
147    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    /// Async call on the blocking pool, bounded by the context deadline and cancellation.
155    /// A timeout or cancellation of a mutation reports `CommitUnknown`.
156    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    /// Bounded async `context pull`. Pulse absent yields empty text with the off reason.
196    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
226/// Map an adapter error. A mutation whose outcome is unknown (timeout, odd exit, unparsable
227/// answer after the child ran) is `CommitUnknown`, never a plain failure.
228pub 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
281/// Memory backend over Pulse's `membrane` CLI.
282pub 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    /// `memory list` carries no content, so full entries cannot be built; use `list` for rows
415    /// and `search`/`recall_scored` for content.
416    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        // Over-fetch because scope filtering happens after the CLI answers.
484        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    /// Membrane resolves scope server-side; `global` is always a valid member of the chain.
528    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
611/// True when no Pulse binary was found, so context runs off (`OFF_REASON`).
612pub fn context_is_off() -> bool {
613    MembraneHandle::shared().is_off()
614}
615
616/// Connect to Pulse's Membrane. Pulse absent yields the explicit unavailable backend
617/// (`off: pulse not installed`); sessions keep running without memory.
618pub 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
631/// Membrane's repository scope slug for a workspace root. Reproduced exactly from
632/// `membrane_federation::root::canonical_repository_id`
633/// (`context/engine/crates/membrane-federation/src/root.rs:148`) so ids stay byte-identical.
634pub 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/// Adapt operations over `membrane adapt ...`. `status`, `adjudicate_taste` and `mine_session`
655/// have no CLI equivalent and return an explicit error.
656#[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    /// `adapt observe` with an `AdaptObservationRequestV1` document.
671    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}