Skip to main content

locus_cli/
main.rs

1use std::fs;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::{Context, Result, anyhow, bail};
6use async_trait::async_trait;
7use chrono::{DateTime, Utc};
8use clap::{Parser, Subcommand, ValueEnum};
9use locus_core_rs::domain::models::{AvecState, MonthlyRollupRequest, SttpNode};
10use locus_core_rs::{
11    CalibrationService, InMemoryNodeStore, InMemorySemanticIndexStore, MonthlyRollupService,
12    MoodCatalogService, NodeStore, NodeStoreInitializer, NodeValidator, QueryParams,
13    SemanticIndexStore, SemanticIndexStoreInitializer, StoreContextService, SttpNodeParser,
14    SurrealDbClient, SurrealDbEndpointsSettings, SurrealDbNodeStore, SurrealDbRuntimeOptions,
15    SurrealDbSemanticIndexStore, SurrealDbSettings, TreeSitterValidator,
16};
17use locus_sdk::application::memory_evict::MemoryEvictService;
18use locus_sdk::application::memory_find::MemoryFindService;
19use locus_sdk::application::memory_graph::MemoryGraphService;
20use locus_sdk::application::memory_recall::MemoryRecallService;
21use locus_sdk::domain::evict::{MemoryEvictMode, MemoryEvictRequest};
22use locus_sdk::domain::graph::MemoryGraphRequest;
23use locus_sdk::domain::memory::{
24    MemoryFilter, MemoryFindRequest, MemoryPage, MemoryRecallRequest, MemoryScope,
25};
26use serde_json::{Value, json};
27use surrealdb::engine::any::{Any, connect};
28use surrealdb::opt::auth::Root;
29
30const DEFAULT_TENANT: &str = "default";
31const TENANT_SCOPE_PREFIX: &str = "tenant:";
32const TENANT_SCOPE_SEPARATOR: &str = "::session:";
33
34#[derive(Copy, Clone, Debug, ValueEnum)]
35enum StorageMode {
36    InMemory,
37    Surreal,
38}
39
40#[derive(Parser, Debug)]
41#[command(name = "locus-cli", version, about = "SDK-backed CLI for Locus memory operations")]
42struct Cli {
43    #[arg(long, env = "LOCUS_STORAGE", default_value = "surreal")]
44    storage: StorageMode,
45
46    #[arg(long, env = "LOCUS_TENANT_ID", help = "Optional tenant ID")]
47    tenant_id: Option<String>,
48
49    #[arg(long, env = "LOCUS_REMOTE", default_value_t = false)]
50    remote: bool,
51
52    #[arg(long, env = "LOCUS_ROOT_DIR_NAME", default_value = ".locus-cli")]
53    root_dir_name: String,
54
55    #[arg(long, env = "LOCUS_SURREAL_ENDPOINT")]
56    surreal_endpoint: Option<String>,
57
58    #[arg(long, env = "LOCUS_SURREAL_REMOTE_ENDPOINT")]
59    surreal_remote_endpoint: Option<String>,
60
61    #[arg(long, env = "LOCUS_SURREAL_EMBEDDED_ENDPOINT")]
62    surreal_embedded_endpoint: Option<String>,
63
64    #[arg(long, env = "LOCUS_SURREAL_NAMESPACE", default_value = "entasis")]
65    surreal_namespace: String,
66
67    #[arg(long, env = "LOCUS_SURREAL_DATABASE", default_value = "locus_cli")]
68    surreal_database: String,
69
70    #[arg(long, env = "LOCUS_SURREAL_USERNAME")]
71    surreal_username: Option<String>,
72
73    #[arg(long, env = "LOCUS_SURREAL_PASSWORD")]
74    surreal_password: Option<String>,
75
76    #[arg(long, help = "Pretty-print JSON output")]
77    pretty: bool,
78
79    #[command(subcommand)]
80    command: Commands,
81}
82
83#[derive(Subcommand, Debug)]
84enum Commands {
85    Health,
86    Calibrate {
87        #[arg(long)]
88        session_id: String,
89        #[arg(long)]
90        stability: f32,
91        #[arg(long)]
92        friction: f32,
93        #[arg(long)]
94        logic: f32,
95        #[arg(long)]
96        autonomy: f32,
97        #[arg(long, default_value = "manual")]
98        trigger: String,
99    },
100    Store {
101        #[arg(long)]
102        session_id: String,
103        #[arg(long, help = "Path to a file containing one STTP node")]
104        node_file: PathBuf,
105    },
106    Context {
107        #[arg(long)]
108        session_id: String,
109        #[arg(long)]
110        stability: f32,
111        #[arg(long)]
112        friction: f32,
113        #[arg(long)]
114        logic: f32,
115        #[arg(long)]
116        autonomy: f32,
117        #[arg(long)]
118        limit: Option<usize>,
119        #[arg(long)]
120        from_utc: Option<String>,
121        #[arg(long)]
122        to_utc: Option<String>,
123        #[arg(long, value_delimiter = ',')]
124        tiers: Vec<String>,
125        #[arg(long)]
126        query_text: Option<String>,
127        #[arg(long)]
128        alpha: Option<f32>,
129        #[arg(long)]
130        beta: Option<f32>,
131        #[arg(long, value_delimiter = ',')]
132        tags: Vec<String>,
133        #[arg(long)]
134        link_rel: Option<String>,
135    },
136    Nodes {
137        #[arg(long)]
138        limit: Option<usize>,
139        #[arg(long)]
140        session_id: Option<String>,
141        #[arg(long, value_delimiter = ',')]
142        tags: Vec<String>,
143        #[arg(long)]
144        link_rel: Option<String>,
145    },
146    Graph {
147        #[arg(long)]
148        session_id: Option<String>,
149        #[arg(long)]
150        limit: Option<usize>,
151        #[arg(long)]
152        link_rel: Option<String>,
153        #[arg(long)]
154        target_prefix: Option<String>,
155        #[arg(long, value_delimiter = ',')]
156        tags: Vec<String>,
157    },
158    Evict {
159        #[arg(long)]
160        session: String,
161        #[arg(long)]
162        sync_key: Option<String>,
163        #[arg(long, value_delimiter = ',')]
164        sync_keys: Vec<String>,
165        #[arg(long)]
166        node_id: Option<String>,
167        #[arg(long, value_delimiter = ',')]
168        node_ids: Vec<String>,
169        #[arg(long, value_delimiter = ',')]
170        tags: Vec<String>,
171        #[arg(long)]
172        link_rel: Option<String>,
173        #[arg(long)]
174        dry_run: bool,
175        #[arg(long)]
176        force: bool,
177        #[arg(long)]
178        purge_session: bool,
179        #[arg(long)]
180        include_calibration: bool,
181        #[arg(long)]
182        include_checkpoints: bool,
183        #[arg(long)]
184        max_nodes: Option<usize>,
185    },
186    Moods {
187        #[arg(long)]
188        target_mood: Option<String>,
189        #[arg(long)]
190        blend: Option<f32>,
191        #[arg(long)]
192        current_stability: Option<f32>,
193        #[arg(long)]
194        current_friction: Option<f32>,
195        #[arg(long)]
196        current_logic: Option<f32>,
197        #[arg(long)]
198        current_autonomy: Option<f32>,
199    },
200    Rollup {
201        #[arg(long)]
202        session_id: String,
203        #[arg(long)]
204        start_date_utc: String,
205        #[arg(long)]
206        end_date_utc: String,
207        #[arg(long)]
208        source_session_id: Option<String>,
209        #[arg(long)]
210        parent_node_id: Option<String>,
211        #[arg(long)]
212        persist: Option<bool>,
213        #[arg(long)]
214        limit: Option<usize>,
215    },
216}
217
218struct Services {
219    calibration: CalibrationService,
220    store_context: StoreContextService,
221    memory_find: MemoryFindService,
222    memory_recall: MemoryRecallService,
223    memory_graph: MemoryGraphService,
224    memory_evict: MemoryEvictService,
225    moods: MoodCatalogService,
226    monthly_rollup: MonthlyRollupService,
227    storage_mode: &'static str,
228    storage_endpoint: Option<String>,
229    storage_namespace: Option<String>,
230    storage_database: Option<String>,
231}
232
233pub struct RuntimeSurrealDbClient {
234    db: surrealdb::Surreal<Any>,
235}
236
237impl RuntimeSurrealDbClient {
238    pub async fn connect(
239        runtime: &SurrealDbRuntimeOptions,
240        user: Option<&str>,
241        password: Option<&str>,
242    ) -> Result<Self> {
243        let db = connect(runtime.endpoint.as_str()).await.with_context(|| {
244            format!(
245                "failed to connect to SurrealDB endpoint '{}'",
246                runtime.endpoint
247            )
248        })?;
249
250        if runtime.use_remote {
251            let username = user.filter(|v| !v.trim().is_empty()).unwrap_or("root");
252            let password = password.filter(|v| !v.trim().is_empty()).unwrap_or("root");
253
254            db.signin(Root {
255                username: username.to_string(),
256                password: password.to_string(),
257            })
258            .await
259            .context("failed to authenticate against remote SurrealDB")?;
260        }
261
262        db.use_ns(runtime.namespace.as_str())
263            .use_db(runtime.database.as_str())
264            .await
265            .with_context(|| {
266                format!(
267                    "failed to select namespace '{}' and database '{}'",
268                    runtime.namespace, runtime.database
269                )
270            })?;
271
272        Ok(Self { db })
273    }
274
275    fn is_read_query(query: &str) -> bool {
276        query
277            .trim_start()
278            .to_ascii_uppercase()
279            .starts_with("SELECT")
280    }
281}
282
283#[async_trait]
284impl SurrealDbClient for RuntimeSurrealDbClient {
285    async fn raw_query(&self, query: &str, parameters: QueryParams) -> Result<Vec<Value>> {
286        let is_read_query = Self::is_read_query(query);
287
288        let response = if parameters.is_empty() {
289            self.db.query(query).await?
290        } else {
291            self.db.query(query).bind(parameters).await?
292        };
293
294        let mut response = response.check()?;
295
296        if !is_read_query {
297            return Ok(Vec::new());
298        }
299
300        if let Ok(rows) = response.take::<Vec<Value>>(0) {
301            return Ok(rows);
302        }
303
304        if let Ok(Some(row)) = response.take::<Option<Value>>(0) {
305            return Ok(vec![row]);
306        }
307
308        Ok(Vec::new())
309    }
310}
311
312#[tokio::main]
313async fn main() -> Result<()> {
314    let cli = Cli::parse();
315    let tenant = resolve_tenant(cli.tenant_id.as_deref())?;
316    let services = build_services(&cli).await?;
317
318    let output = match cli.command {
319        Commands::Health => {
320            json!({
321                "status": "ok",
322                "transport": "sdk-core",
323                "storage": {
324                    "mode": services.storage_mode,
325                    "endpoint": services.storage_endpoint,
326                    "namespace": services.storage_namespace,
327                    "database": services.storage_database,
328                }
329            })
330        }
331        Commands::Calibrate {
332            session_id,
333            stability,
334            friction,
335            logic,
336            autonomy,
337            trigger,
338        } => {
339            let session_id = scope_session_id(&tenant, &session_id);
340            let result = services
341                .calibration
342                .calibrate_async(
343                    &session_id,
344                    stability,
345                    friction,
346                    logic,
347                    autonomy,
348                    &trigger,
349                )
350                .await?;
351
352            json!({
353                "previousAvec": avec_to_json(result.previous_avec),
354                "delta": result.delta,
355                "driftClassification": format!("{:?}", result.drift_classification),
356                "trigger": result.trigger,
357                "triggerHistory": result.trigger_history,
358                "isFirstCalibration": result.is_first_calibration,
359            })
360        }
361        Commands::Store {
362            session_id,
363            node_file,
364        } => {
365            let session_id = scope_session_id(&tenant, &session_id);
366            let node = fs::read_to_string(&node_file)
367                .with_context(|| format!("failed to read node file: {}", node_file.display()))?;
368
369            if node.trim().is_empty() {
370                bail!("node file is empty");
371            }
372
373            let result = services.store_context.store_async(&node, &session_id).await;
374            json!({
375                "nodeId": result.node_id,
376                "psi": result.psi,
377                "valid": result.valid,
378                "validationError": result.validation_error,
379            })
380        }
381        Commands::Context {
382            session_id,
383            stability,
384            friction,
385            logic,
386            autonomy,
387            limit,
388            from_utc,
389            to_utc,
390            tiers,
391            query_text,
392            alpha,
393            beta,
394            tags,
395            link_rel,
396        } => {
397            let session_id = scope_session_id(&tenant, &session_id);
398            let from_utc = parse_utc_optional(from_utc.as_deref(), "from_utc")?;
399            let to_utc = parse_utc_optional(to_utc.as_deref(), "to_utc")?;
400            let tiers = normalize_tiers(tiers);
401            let indexed_tags = if tags.is_empty() {
402                None
403            } else {
404                Some(tags)
405            };
406
407            let request = MemoryRecallRequest {
408                scope: MemoryScope {
409                    tenant_id: None,
410                    session_ids: Some(vec![session_id]),
411                    tiers,
412                    from_utc,
413                    to_utc,
414                },
415                page: MemoryPage {
416                    limit: limit.unwrap_or(5),
417                    cursor: None,
418                },
419                scoring: locus_sdk::domain::memory::MemoryScoring {
420                    alpha: alpha.unwrap_or(0.7),
421                    beta: beta.unwrap_or(0.3),
422                    ..Default::default()
423                },
424                filter: MemoryFilter {
425                    indexed_tags,
426                    link_rel,
427                    ..Default::default()
428                },
429                current_avec: Some(AvecState {
430                    stability,
431                    friction,
432                    logic,
433                    autonomy,
434                }),
435                query_text,
436                query_embedding: None,
437                ..Default::default()
438            };
439
440            let result = services.memory_recall.execute(&request).await?;
441            let nodes = normalize_nodes_for_tenant(result.nodes, &tenant);
442
443            json!({
444                "nodes": nodes.iter().map(sttp_node_to_json).collect::<Vec<_>>(),
445                "retrieved": nodes.len(),
446                "psiRange": {
447                    "min": result.psi_range.min,
448                    "max": result.psi_range.max,
449                    "average": result.psi_range.average,
450                },
451                "retrievalPath": format!("{:?}", result.retrieval_path),
452                "hasMore": result.has_more,
453                "nextCursor": result.next_cursor,
454            })
455        }
456        Commands::Nodes {
457            limit,
458            session_id,
459            tags,
460            link_rel,
461        } => {
462            let requested_limit = limit.unwrap_or(50).clamp(1, 200);
463            let indexed_tags = if tags.is_empty() {
464                None
465            } else {
466                Some(tags)
467            };
468
469            let scoped_session = session_id.as_deref().map(|id| scope_session_id(&tenant, id));
470            let result = services
471                .memory_find
472                .execute(&MemoryFindRequest {
473                    scope: MemoryScope {
474                        session_ids: scoped_session.map(|id| vec![id]),
475                        ..Default::default()
476                    },
477                    filter: MemoryFilter {
478                        indexed_tags,
479                        link_rel,
480                        ..Default::default()
481                    },
482                    page: MemoryPage {
483                        limit: if session_id.is_some() {
484                            requested_limit
485                        } else {
486                            (requested_limit * 4).clamp(1, 200)
487                        },
488                        cursor: None,
489                    },
490                    ..Default::default()
491                })
492                .await?;
493
494            let mut nodes = normalize_nodes_for_tenant(result.nodes, &tenant);
495            nodes.truncate(requested_limit);
496
497            json!({
498                "nodes": nodes.iter().map(sttp_node_to_json).collect::<Vec<_>>(),
499                "retrieved": nodes.len(),
500            })
501        }
502        Commands::Graph {
503            session_id,
504            limit,
505            link_rel,
506            target_prefix,
507            tags,
508        } => {
509            let scoped_session = session_id.as_deref().map(|id| scope_session_id(&tenant, id));
510            let indexed_tags = if tags.is_empty() {
511                None
512            } else {
513                Some(tags)
514            };
515
516            let result = services
517                .memory_graph
518                .execute(&MemoryGraphRequest {
519                    scope: MemoryScope {
520                        tenant_id: Some(tenant.clone()),
521                        session_ids: scoped_session.map(|id| vec![id]),
522                        ..Default::default()
523                    },
524                    filter: MemoryFilter {
525                        indexed_tags,
526                        link_rel: link_rel.clone(),
527                        ..Default::default()
528                    },
529                    include_lineage: true,
530                    include_semantic: true,
531                    include_session_topology: true,
532                    rel: link_rel,
533                    target_prefix,
534                    limit: limit.unwrap_or(1000),
535                })
536                .await?;
537
538            json!({
539                "retrieved": result.retrieved,
540                "sessions": result.sessions,
541                "nodes": result.nodes,
542                "edges": result.edges,
543            })
544        }
545        Commands::Evict {
546            session,
547            sync_key,
548            sync_keys,
549            node_id,
550            node_ids,
551            tags,
552            link_rel,
553            dry_run,
554            force,
555            purge_session,
556            include_calibration,
557            include_checkpoints,
558            max_nodes,
559        } => {
560            let scoped_session = scope_session_id(&tenant, &session);
561            let mut keys = sync_keys;
562            if let Some(key) = sync_key {
563                keys.push(key);
564            }
565            let mut ids = node_ids;
566            if let Some(id) = node_id {
567                ids.push(id);
568            }
569
570            let mode = if purge_session {
571                MemoryEvictMode::PurgeSession
572            } else if !keys.is_empty() {
573                MemoryEvictMode::BySyncKeys
574            } else if !ids.is_empty() {
575                MemoryEvictMode::ByNodeIds
576            } else if !tags.is_empty() || link_rel.is_some() {
577                MemoryEvictMode::ByFilter
578            } else {
579                bail!("provide --sync-key, --node-id, --tags/--link-rel, or --purge-session");
580            };
581
582            let indexed_tags = if tags.is_empty() {
583                None
584            } else {
585                Some(tags)
586            };
587
588            let result = services
589                .memory_evict
590                .execute(&MemoryEvictRequest {
591                    mode,
592                    scope: MemoryScope {
593                        tenant_id: Some(tenant.clone()),
594                        session_ids: Some(vec![scoped_session]),
595                        ..Default::default()
596                    },
597                    filter: MemoryFilter {
598                        indexed_tags,
599                        link_rel,
600                        ..Default::default()
601                    },
602                    sync_keys: if keys.is_empty() { None } else { Some(keys) },
603                    node_ids: if ids.is_empty() { None } else { Some(ids) },
604                    dry_run,
605                    force,
606                    max_nodes: max_nodes.unwrap_or(5000),
607                    include_calibration: include_calibration || purge_session,
608                    include_checkpoints: include_checkpoints || purge_session,
609                })
610                .await?;
611
612            json!({
613                "dryRun": result.dry_run,
614                "deleted": result.deleted,
615                "blocked": result.blocked,
616                "notFound": result.not_found,
617                "skipped": result.skipped,
618                "wouldDelete": result.would_delete,
619                "calibrationsDeleted": result.calibrations_deleted,
620                "checkpointsDeleted": result.checkpoints_deleted,
621                "records": result.records,
622            })
623        }
624        Commands::Moods {
625            target_mood,
626            blend,
627            current_stability,
628            current_friction,
629            current_logic,
630            current_autonomy,
631        } => {
632            let result = services.moods.get(
633                target_mood.as_deref(),
634                blend.unwrap_or(1.0),
635                current_stability,
636                current_friction,
637                current_logic,
638                current_autonomy,
639            );
640
641            json!({
642                "presets": result.presets.iter().map(|preset| json!({
643                    "name": preset.name,
644                    "description": preset.description,
645                    "avec": avec_to_json(preset.avec),
646                })).collect::<Vec<_>>(),
647                "applyGuide": result.apply_guide,
648                "swapPreview": result.swap_preview.map(|preview| json!({
649                    "targetMood": preview.target_mood,
650                    "blend": preview.blend,
651                    "current": avec_to_json(preview.current),
652                    "target": avec_to_json(preview.target),
653                    "blended": avec_to_json(preview.blended),
654                })),
655            })
656        }
657        Commands::Rollup {
658            session_id,
659            start_date_utc,
660            end_date_utc,
661            source_session_id,
662            parent_node_id,
663            persist,
664            limit,
665        } => {
666            let session_id = scope_session_id(&tenant, &session_id);
667            let source_session_id = source_session_id.map(|id| scope_session_id(&tenant, &id));
668            let request = MonthlyRollupRequest {
669                session_id,
670                start_utc: parse_utc_required(&start_date_utc, "start_date_utc")?,
671                end_utc: parse_utc_required(&end_date_utc, "end_date_utc")?,
672                source_session_id,
673                parent_node_id,
674                limit: limit.unwrap_or(5000),
675                persist: persist.unwrap_or(true),
676            };
677
678            let result = services.monthly_rollup.create_async(request).await;
679            json!({
680                "success": result.success,
681                "nodeId": result.node_id,
682                "rawNode": result.raw_node,
683                "error": result.error,
684                "sourceNodes": result.source_nodes,
685                "parentReference": result.parent_reference,
686                "userAverage": avec_to_json(result.user_average),
687                "modelAverage": avec_to_json(result.model_average),
688                "compressionAverage": avec_to_json(result.compression_average),
689                "rhoRange": {
690                    "min": result.rho_range.min,
691                    "max": result.rho_range.max,
692                    "average": result.rho_range.average,
693                },
694                "kappaRange": {
695                    "min": result.kappa_range.min,
696                    "max": result.kappa_range.max,
697                    "average": result.kappa_range.average,
698                },
699                "psiRange": {
700                    "min": result.psi_range.min,
701                    "max": result.psi_range.max,
702                    "average": result.psi_range.average,
703                },
704                "rhoBands": {
705                    "low": result.rho_bands.low,
706                    "medium": result.rho_bands.medium,
707                    "high": result.rho_bands.high,
708                },
709                "kappaBands": {
710                    "low": result.kappa_bands.low,
711                    "medium": result.kappa_bands.medium,
712                    "high": result.kappa_bands.high,
713                },
714            })
715        }
716    };
717
718    if cli.pretty {
719        println!(
720            "{}",
721            serde_json::to_string_pretty(&output)
722                .context("failed to render pretty JSON output")?
723        );
724    } else {
725        println!("{}", serde_json::to_string(&output)?);
726    }
727
728    Ok(())
729}
730
731async fn build_services(cli: &Cli) -> Result<Services> {
732    let (
733        store,
734        semantic_index,
735        initializer,
736        semantic_initializer,
737        storage_mode,
738        storage_endpoint,
739        storage_namespace,
740        storage_database,
741    ) = match cli.storage {
742            StorageMode::InMemory => {
743                let store = Arc::new(InMemoryNodeStore::new());
744                let semantic_index = Arc::new(InMemorySemanticIndexStore::new());
745                let initializer: Arc<dyn NodeStoreInitializer> = store.clone();
746                let semantic_initializer: Arc<dyn SemanticIndexStoreInitializer> =
747                    semantic_index.clone();
748                let node_store: Arc<dyn NodeStore> = store;
749                let semantic_trait: Arc<dyn SemanticIndexStore> = semantic_index;
750                (
751                    node_store,
752                    semantic_trait,
753                    initializer,
754                    semantic_initializer,
755                    "in-memory",
756                    None,
757                    None,
758                    None,
759                )
760            }
761            StorageMode::Surreal => {
762                let settings = surreal_settings_from_cli(cli);
763                let runtime = surreal_runtime_from_cli(cli, &settings)?;
764
765                let client = Arc::new(
766                    RuntimeSurrealDbClient::connect(
767                        &runtime,
768                        settings.user.as_deref(),
769                        settings.password.as_deref(),
770                    )
771                    .await?,
772                );
773
774                let semantic_index = Arc::new(SurrealDbSemanticIndexStore::new(client.clone()));
775                let store = Arc::new(SurrealDbNodeStore::new(client));
776                let initializer: Arc<dyn NodeStoreInitializer> = store.clone();
777                let semantic_initializer: Arc<dyn SemanticIndexStoreInitializer> =
778                    semantic_index.clone();
779                let node_store: Arc<dyn NodeStore> = store;
780                let semantic_trait: Arc<dyn SemanticIndexStore> = semantic_index;
781
782                (
783                    node_store,
784                    semantic_trait,
785                    initializer,
786                    semantic_initializer,
787                    if runtime.use_remote {
788                        "surreal-remote"
789                    } else {
790                        "surreal-embedded"
791                    },
792                    Some(runtime.endpoint),
793                    Some(runtime.namespace),
794                    Some(runtime.database),
795                )
796            }
797        };
798
799    initializer.initialize_async().await?;
800    semantic_initializer.initialize_async().await?;
801
802    let validator: Arc<dyn NodeValidator> = Arc::new(TreeSitterValidator::new());
803
804    Ok(Services {
805        calibration: CalibrationService::new(store.clone()),
806        store_context: StoreContextService::new(
807            store.clone(),
808            validator.clone(),
809            SttpNodeParser::new(),
810        )
811        .with_semantic_index(semantic_index.clone()),
812        memory_find: MemoryFindService::new(store.clone()).with_semantic_index(semantic_index.clone()),
813        memory_recall: MemoryRecallService::new(store.clone())
814            .with_semantic_index(semantic_index.clone()),
815        memory_graph: MemoryGraphService::new(store.clone()).with_semantic_index(semantic_index.clone()),
816        memory_evict: MemoryEvictService::new(store.clone()).with_semantic_index(semantic_index.clone()),
817        moods: MoodCatalogService::new(),
818        monthly_rollup: MonthlyRollupService::new(store, validator)
819            .with_semantic_index(semantic_index),
820        storage_mode,
821        storage_endpoint,
822        storage_namespace,
823        storage_database,
824    })
825}
826
827fn surreal_settings_from_cli(cli: &Cli) -> SurrealDbSettings {
828    let mut settings = SurrealDbSettings {
829        endpoints: SurrealDbEndpointsSettings {
830            embedded: Some(
831                cli.surreal_embedded_endpoint
832                    .clone()
833                    .unwrap_or_else(|| "surrealkv://data/locus-cli".to_string()),
834            ),
835            remote: cli.surreal_remote_endpoint.clone(),
836        },
837        namespace: cli.surreal_namespace.clone(),
838        database: cli.surreal_database.clone(),
839        user: cli.surreal_username.clone(),
840        password: cli.surreal_password.clone(),
841    };
842
843    if let Some(endpoint) = cli
844        .surreal_endpoint
845        .as_ref()
846        .map(|value| value.trim())
847        .filter(|value| !value.is_empty())
848    {
849        settings.endpoints.embedded = Some(endpoint.to_string());
850        settings.endpoints.remote = Some(endpoint.to_string());
851    }
852
853    settings
854}
855
856fn surreal_runtime_from_cli(cli: &Cli, settings: &SurrealDbSettings) -> Result<SurrealDbRuntimeOptions> {
857    let mut args = Vec::new();
858    if cli.remote {
859        args.push("--remote".to_string());
860    }
861
862    SurrealDbRuntimeOptions::from_args(&args, settings, Some(&cli.root_dir_name))
863}
864
865fn normalize_tiers(tiers: Vec<String>) -> Option<Vec<String>> {
866    let tiers = tiers
867        .into_iter()
868        .map(|tier| tier.trim().to_ascii_lowercase())
869        .filter(|tier| !tier.is_empty())
870        .collect::<Vec<_>>();
871
872    if tiers.is_empty() {
873        None
874    } else {
875        Some(tiers)
876    }
877}
878
879fn resolve_tenant(value: Option<&str>) -> Result<String> {
880    match value.and_then(normalize_tenant_value) {
881        Some(tenant) => Ok(tenant),
882        None => {
883            if value.is_some() {
884                bail!("tenant id can only contain letters, digits, '-' or '_'");
885            }
886            Ok(DEFAULT_TENANT.to_string())
887        }
888    }
889}
890
891fn normalize_tenant_value(value: &str) -> Option<String> {
892    let trimmed = value.trim();
893    if trimmed.is_empty() {
894        return None;
895    }
896
897    let normalized = trimmed.to_ascii_lowercase();
898    if normalized
899        .chars()
900        .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
901    {
902        Some(normalized)
903    } else {
904        None
905    }
906}
907
908fn scope_session_id(tenant: &str, session_id: &str) -> String {
909    if tenant == DEFAULT_TENANT {
910        session_id.to_string()
911    } else {
912        format!("{TENANT_SCOPE_PREFIX}{tenant}{TENANT_SCOPE_SEPARATOR}{session_id}")
913    }
914}
915
916fn parse_scoped_session_id(session_id: &str) -> Option<(&str, &str)> {
917    let remainder = session_id.strip_prefix(TENANT_SCOPE_PREFIX)?;
918    remainder.split_once(TENANT_SCOPE_SEPARATOR)
919}
920
921fn session_belongs_to_tenant(session_id: &str, tenant: &str) -> bool {
922    match parse_scoped_session_id(session_id) {
923        Some((scoped_tenant, _)) => scoped_tenant == tenant,
924        None => tenant == DEFAULT_TENANT,
925    }
926}
927
928fn display_session_id(session_id: &str) -> String {
929    match parse_scoped_session_id(session_id) {
930        Some((_, base_session_id)) => base_session_id.to_string(),
931        None => session_id.to_string(),
932    }
933}
934
935fn normalize_nodes_for_tenant(nodes: Vec<SttpNode>, tenant: &str) -> Vec<SttpNode> {
936    nodes
937        .into_iter()
938        .filter_map(|mut node| {
939            if !session_belongs_to_tenant(&node.session_id, tenant) {
940                return None;
941            }
942            node.session_id = display_session_id(&node.session_id);
943            Some(node)
944        })
945        .collect()
946}
947
948fn avec_to_json(avec: AvecState) -> Value {
949    json!({
950        "stability": avec.stability,
951        "friction": avec.friction,
952        "logic": avec.logic,
953        "autonomy": avec.autonomy,
954        "psi": avec.psi(),
955    })
956}
957
958fn sttp_node_to_json(node: &SttpNode) -> Value {
959    json!({
960        "raw": node.raw,
961        "sessionId": node.session_id,
962        "tier": node.tier,
963        "timestamp": node.timestamp.to_rfc3339(),
964        "compressionDepth": node.compression_depth,
965        "parentNodeId": node.parent_node_id,
966        "syncKey": node.sync_key,
967        "updatedAt": node.updated_at.to_rfc3339(),
968        "contextSummary": node.context_summary,
969        "semanticTags": node.semantic_tags,
970        "semanticLinks": node.semantic_links.as_ref().map(|links| {
971            links.iter().map(|link| {
972                json!({
973                    "rel": link.rel,
974                    "target": link.target,
975                    "confidence": link.confidence,
976                })
977            }).collect::<Vec<_>>()
978        }),
979        "embeddingModel": node.embedding_model,
980        "embeddingDimensions": node.embedding_dimensions,
981        "embeddedAt": node.embedded_at.map(|v| v.to_rfc3339()),
982        "userAvec": avec_to_json(node.user_avec),
983        "modelAvec": avec_to_json(node.model_avec),
984        "compressionAvec": node.compression_avec.map(avec_to_json),
985        "rho": node.rho,
986        "kappa": node.kappa,
987        "psi": node.psi,
988    })
989}
990
991fn parse_utc_required(value: &str, field: &str) -> Result<DateTime<Utc>> {
992    DateTime::parse_from_rfc3339(value)
993        .map(|parsed| parsed.with_timezone(&Utc))
994        .map_err(|_| anyhow!("{field} must be an ISO8601 UTC datetime"))
995}
996
997fn parse_utc_optional(value: Option<&str>, field: &str) -> Result<Option<DateTime<Utc>>> {
998    match value {
999        Some(raw) => parse_utc_required(raw, field).map(Some),
1000        None => Ok(None),
1001    }
1002}