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