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