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