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}