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}