1mod binding;
8mod context;
9mod fusion;
10mod policy;
11mod semantic;
12mod semantic_binding;
13mod semantic_refresh;
14pub use binding::{
15 DurableMemoryBindingV1, DURABLE_MEMORY_BINDING_SCHEMA_VERSION,
16 DURABLE_MEMORY_HYBRID_BINDING_SCHEMA_VERSION, DURABLE_MEMORY_RETRIEVAL_PROFILE_V1,
17};
18pub(crate) use context::{durable_memory_context_id, DurableMemoryRecallIdentity};
19pub use context::{DURABLE_MEMORY_CONTEXT_ID_PROFILE_V1, DURABLE_MEMORY_CONTEXT_ID_PROFILE_V2};
20pub use policy::{
21 DurableMemoryMode, DurableMemoryRecallChannel, DurableMemoryRecallHit,
22 DurableMemoryRecallPolicy, DurableMemoryRecallPreview,
23};
24pub use semantic::DurableMemorySemanticRecall;
25pub(crate) use semantic::SemanticRefreshEmbeddingCache;
26pub use semantic_binding::{
27 DurableMemorySemanticBindingV1, DurableMemorySemanticError, DurableMemorySemanticRecallPolicy,
28 DURABLE_MEMORY_SEMANTIC_BINDING_SCHEMA_V1, DURABLE_MEMORY_SEMANTIC_FUSION_PROFILE_V1,
29};
30pub(crate) use semantic_refresh::DurableMemorySemanticRefreshRun;
31pub use semantic_refresh::{
32 DurableMemorySemanticRefreshCheckpoint, DurableMemorySemanticRefreshReceipt,
33 DURABLE_MEMORY_SEMANTIC_REFRESH_CHECKPOINT_SCHEMA_V1,
34 DURABLE_MEMORY_SEMANTIC_REFRESH_PROFILE_V1,
35};
36
37use a3s_memory::repository::{
38 DurableMemoryKind, EvidenceKind, EvidenceRef, MemoryAccessEvent, MemoryChangeSet,
39 MemoryNamespace, MemoryNode, MemoryNodeDraft, MemoryOperation, MemoryRepository,
40 MemoryRepositoryError, MemoryStatus, MAX_IDENTIFIER_BYTES,
41};
42use a3s_memory::vector::VectorMutationConsistency;
43use a3s_memory::{MemoryItem, MemoryType};
44use chrono::{DateTime, Utc};
45use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
46use serde::Serialize;
47use sha2::{Digest, Sha256};
48use std::sync::Arc;
49use tokio_util::sync::CancellationToken;
50
51#[derive(Debug, Clone)]
53pub struct DurableMemoryActivation {
54 idempotency_key: String,
55 node_id: String,
56 expected_revision: u64,
57 decision_evidence: EvidenceRef,
58 occurred_at: DateTime<Utc>,
59}
60
61impl DurableMemoryActivation {
62 pub fn try_new(
63 idempotency_key: impl Into<String>,
64 node_id: impl Into<String>,
65 expected_revision: u64,
66 decision_evidence: EvidenceRef,
67 occurred_at: DateTime<Utc>,
68 ) -> Result<Self, MemoryRepositoryError> {
69 let idempotency_key = idempotency_key.into();
70 let node_id = node_id.into();
71 validate_identifier("activation.idempotencyKey", &idempotency_key)?;
72 validate_identifier("activation.nodeId", &node_id)?;
73 if expected_revision == 0 {
74 return Err(invalid(
75 "activation.expectedRevision",
76 "must be greater than zero",
77 ));
78 }
79 if !matches!(
80 decision_evidence.kind,
81 EvidenceKind::Manual | EvidenceKind::Verification
82 ) {
83 return Err(invalid(
84 "activation.decisionEvidence.kind",
85 "must be manual or verification evidence",
86 ));
87 }
88 if decision_evidence.occurred_at > occurred_at {
89 return Err(invalid(
90 "activation.decisionEvidence.occurredAt",
91 "must not follow activation occurredAt",
92 ));
93 }
94 Ok(Self {
95 idempotency_key,
96 node_id,
97 expected_revision,
98 decision_evidence,
99 occurred_at,
100 })
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct DurableMemoryUse {
107 event_id: String,
108 node_id: String,
109 node_revision: u64,
110 occurred_at: DateTime<Utc>,
111 context_id: Option<String>,
112}
113
114impl DurableMemoryUse {
115 pub fn try_new(
116 event_id: impl Into<String>,
117 node_id: impl Into<String>,
118 node_revision: u64,
119 occurred_at: DateTime<Utc>,
120 ) -> Result<Self, MemoryRepositoryError> {
121 let event_id = event_id.into();
122 let node_id = node_id.into();
123 validate_identifier("use.eventId", &event_id)?;
124 validate_identifier("use.nodeId", &node_id)?;
125 if node_revision == 0 {
126 return Err(invalid("use.nodeRevision", "must be greater than zero"));
127 }
128 Ok(Self {
129 event_id,
130 node_id,
131 node_revision,
132 occurred_at,
133 context_id: None,
134 })
135 }
136
137 pub fn with_context_id(mut self, context_id: impl Into<String>) -> Self {
138 self.context_id = Some(context_id.into());
139 self
140 }
141}
142
143#[derive(Clone)]
145pub struct DurableMemorySession {
146 repository: Arc<dyn MemoryRepository>,
147 namespace: MemoryNamespace,
148 mode: DurableMemoryMode,
149 recall_policy: Option<DurableMemoryRecallPolicy>,
150 semantic_recall: Option<DurableMemorySemanticRecall>,
151}
152
153impl DurableMemorySession {
154 pub fn shadow(repository: Arc<dyn MemoryRepository>, namespace: MemoryNamespace) -> Self {
156 Self {
157 repository,
158 namespace,
159 mode: DurableMemoryMode::ShadowCandidates,
160 recall_policy: None,
161 semantic_recall: None,
162 }
163 }
164
165 pub fn active_recall(
167 repository: Arc<dyn MemoryRepository>,
168 namespace: MemoryNamespace,
169 recall_policy: DurableMemoryRecallPolicy,
170 ) -> Self {
171 Self {
172 repository,
173 namespace,
174 mode: DurableMemoryMode::ActiveRecall,
175 recall_policy: Some(recall_policy),
176 semantic_recall: None,
177 }
178 }
179
180 pub fn with_semantic_recall(
182 mut self,
183 semantic_recall: DurableMemorySemanticRecall,
184 ) -> Result<Self, DurableMemorySemanticError> {
185 if self.mode != DurableMemoryMode::ActiveRecall || self.recall_policy.is_none() {
186 return Err(DurableMemorySemanticError::InvalidConfiguration {
187 field: "mode",
188 reason: "semantic recall requires an Active recall binding".to_string(),
189 });
190 }
191 self.semantic_recall = Some(semantic_recall);
192 Ok(self)
193 }
194
195 pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
196 &self.repository
197 }
198
199 pub fn namespace(&self) -> &MemoryNamespace {
200 &self.namespace
201 }
202
203 pub fn mode(&self) -> DurableMemoryMode {
204 self.mode
205 }
206
207 pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
208 self.recall_policy
209 }
210
211 pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticRecall> {
212 self.semantic_recall.as_ref()
213 }
214
215 pub async fn refresh_semantic_recall(
218 &self,
219 cancellation: CancellationToken,
220 ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
221 self.refresh_semantic_recall_requiring(
222 VectorMutationConsistency::PartitionAtomic,
223 cancellation,
224 )
225 .await
226 }
227
228 pub async fn refresh_semantic_recall_requiring(
232 &self,
233 required_consistency: VectorMutationConsistency,
234 cancellation: CancellationToken,
235 ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
236 let semantic = self.semantic_recall.as_ref().ok_or_else(|| {
237 DurableMemorySemanticError::InvalidConfiguration {
238 field: "semanticRecall",
239 reason: "refresh requires an attached semantic recall generation".to_string(),
240 }
241 })?;
242 semantic
243 .refresh_repository_namespace(
244 self.repository.as_ref(),
245 &self.namespace,
246 required_consistency,
247 cancellation,
248 )
249 .await
250 }
251
252 pub fn binding(&self) -> DurableMemoryBindingV1 {
255 DurableMemoryBindingV1::new(
256 self.namespace.clone(),
257 self.mode,
258 self.recall_policy,
259 self.semantic_recall
260 .as_ref()
261 .map(|semantic| semantic.binding().clone()),
262 )
263 }
264
265 pub async fn activate_candidate(
267 &self,
268 activation: DurableMemoryActivation,
269 ) -> Result<MemoryNode, MemoryRepositoryError> {
270 let result = self
271 .repository
272 .apply(MemoryChangeSet::new(
273 activation.idempotency_key,
274 self.namespace.clone(),
275 activation.occurred_at,
276 vec![MemoryOperation::Activate {
277 node_id: activation.node_id.clone(),
278 expected_revision: activation.expected_revision,
279 evidence: vec![activation.decision_evidence],
280 }],
281 ))
282 .await?;
283 result
284 .nodes
285 .into_iter()
286 .find(|node| node.id == activation.node_id && node.status == MemoryStatus::Active)
287 .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
288 message: "activation change returned no active target node".into(),
289 })
290 }
291
292 pub async fn record_use(&self, usage: DurableMemoryUse) -> Result<(), MemoryRepositoryError> {
294 let mut event = MemoryAccessEvent::new(
295 usage.event_id,
296 self.namespace.clone(),
297 usage.node_id,
298 usage.node_revision,
299 usage.occurred_at,
300 );
301 if let Some(context_id) = usage.context_id {
302 event = event.with_context_id(context_id);
303 }
304 self.repository.record_use(event).await
305 }
306
307 pub(crate) async fn store_shadow_candidate(
308 &self,
309 item: &MemoryItem,
310 evidence: &DurableTurnEvidence,
311 ) -> Result<MemoryNode, MemoryRepositoryError> {
312 let kind = match item.memory_type {
313 MemoryType::Episodic => DurableMemoryKind::Episodic,
314 MemoryType::Semantic => DurableMemoryKind::Semantic,
315 MemoryType::Procedural => DurableMemoryKind::Procedural,
316 MemoryType::Working => {
317 return Err(MemoryRepositoryError::InvalidInput {
318 field: "candidate.memoryType".into(),
319 message: "working memory is not durable".into(),
320 });
321 }
322 };
323 let confidence = item
324 .metadata
325 .get("confidence")
326 .and_then(|value| value.parse::<f32>().ok())
327 .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
328 .unwrap_or(0.0);
329 let mut draft = MemoryNodeDraft::new(
330 "content-addressed-after-normalization",
331 self.namespace.clone(),
332 kind,
333 MemoryStatus::Candidate,
334 &item.content,
335 vec![evidence.reference.clone()],
336 evidence.occurred_at,
337 )
338 .with_confidence(confidence)
339 .with_importance(item.importance)
340 .with_label("a3s.origin", "code.llm_extraction");
341 for (source, label) in [
342 ("source", "a3s.extraction.source"),
343 ("scope", "a3s.extraction.scope"),
344 ("reason", "a3s.extraction.reason"),
345 ("schema", "a3s.extraction.schema"),
346 ] {
347 if let Some(value) = item.metadata.get(source) {
348 draft = draft.with_label(label, value);
349 }
350 }
351 if !item.tags.is_empty() {
352 let tags = serde_json::to_string(&item.tags).map_err(|error| {
353 MemoryRepositoryError::InvalidInput {
354 field: "candidate.tags".into(),
355 message: error.to_string(),
356 }
357 })?;
358 draft = draft.with_label("a3s.extraction.tags", tags);
359 }
360 draft.id = candidate_id(&draft)?;
361
362 let result = self
363 .repository
364 .apply(MemoryChangeSet::new(
365 draft.id.clone(),
366 self.namespace.clone(),
367 evidence.occurred_at,
368 vec![MemoryOperation::Create { node: draft }],
369 ))
370 .await?;
371 result
372 .nodes
373 .into_iter()
374 .next()
375 .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
376 message: "candidate change returned no node".into(),
377 })
378 }
379}
380
381fn invalid(field: &str, message: impl Into<String>) -> MemoryRepositoryError {
382 MemoryRepositoryError::InvalidInput {
383 field: field.into(),
384 message: message.into(),
385 }
386}
387
388fn validate_identifier(field: &str, value: &str) -> Result<(), MemoryRepositoryError> {
389 if value.trim().is_empty() {
390 return Err(invalid(field, "must not be empty or whitespace"));
391 }
392 if value.len() > MAX_IDENTIFIER_BYTES {
393 return Err(invalid(
394 field,
395 format!("must not exceed {MAX_IDENTIFIER_BYTES} bytes"),
396 ));
397 }
398 Ok(())
399}
400
401impl std::fmt::Debug for DurableMemorySession {
402 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
403 formatter
404 .debug_struct("DurableMemorySession")
405 .field("namespace", &self.namespace)
406 .field("mode", &self.mode)
407 .field("recall_policy", &self.recall_policy)
408 .field(
409 "semantic_recall",
410 &self
411 .semantic_recall
412 .as_ref()
413 .map(DurableMemorySemanticRecall::binding),
414 )
415 .finish_non_exhaustive()
416 }
417}
418
419#[derive(Debug, Clone)]
420pub(crate) struct DurableTurnEvidence {
421 reference: EvidenceRef,
422 occurred_at: DateTime<Utc>,
423}
424
425#[derive(Serialize)]
426#[serde(rename_all = "camelCase")]
427struct TurnEvidencePayload<'a> {
428 schema: &'static str,
429 session_id: &'a str,
430 prompt: &'a str,
431 response: &'a str,
432 transcript: &'a str,
433}
434
435impl DurableTurnEvidence {
436 pub(crate) fn try_new(
437 session_id: &str,
438 turn_id: &str,
439 prompt: &str,
440 response: &str,
441 transcript: &str,
442 occurred_at: DateTime<Utc>,
443 ) -> Result<Self, MemoryRepositoryError> {
444 let payload = TurnEvidencePayload {
445 schema: "a3s.code.memory.turn-evidence.v1",
446 session_id,
447 prompt,
448 response,
449 transcript,
450 };
451 let encoded =
452 serde_json::to_vec(&payload).map_err(|error| MemoryRepositoryError::InvalidInput {
453 field: "turnEvidence".into(),
454 message: error.to_string(),
455 })?;
456 let digest = format!("sha256:{:x}", Sha256::digest(encoded));
457 let session = utf8_percent_encode(session_id, NON_ALPHANUMERIC);
458 let turn = utf8_percent_encode(turn_id, NON_ALPHANUMERIC);
459 let reference = EvidenceRef::try_new(
460 format!("a3s://session/{session}/turn/{turn}"),
461 digest,
462 EvidenceKind::SessionTurn,
463 occurred_at,
464 )?;
465 Ok(Self {
466 reference,
467 occurred_at,
468 })
469 }
470}
471
472fn candidate_id(draft: &MemoryNodeDraft) -> Result<String, MemoryRepositoryError> {
473 let mut identity = draft.clone();
474 identity.id.clear();
475 let encoded =
476 serde_json::to_vec(&identity).map_err(|error| MemoryRepositoryError::InvalidInput {
477 field: "candidate".into(),
478 message: error.to_string(),
479 })?;
480 let mut hasher = Sha256::new();
481 hasher.update(b"a3s.code.memory.candidate.v1\0");
482 hasher.update(encoded);
483 Ok(format!("a3s-code-candidate-{:x}", hasher.finalize()))
484}
485
486#[cfg(test)]
487#[path = "durable_memory/tests.rs"]
488mod tests;