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 active_recall(
159 repository: Arc<dyn MemoryRepository>,
160 namespace: MemoryNamespace,
161 recall_policy: DurableMemoryRecallPolicy,
162 ) -> Self {
163 Self {
164 repository,
165 namespace,
166 mode: DurableMemoryMode::ActiveRecall,
167 recall_policy: Some(recall_policy),
168 semantic_recall: None,
169 }
170 }
171
172 pub fn with_semantic_recall(
174 mut self,
175 semantic_recall: DurableMemorySemanticRecall,
176 ) -> Result<Self, DurableMemorySemanticError> {
177 if self.mode != DurableMemoryMode::ActiveRecall || self.recall_policy.is_none() {
178 return Err(DurableMemorySemanticError::InvalidConfiguration {
179 field: "mode",
180 reason: "semantic recall requires an Active recall binding".to_string(),
181 });
182 }
183 self.semantic_recall = Some(semantic_recall);
184 Ok(self)
185 }
186
187 pub fn repository(&self) -> &Arc<dyn MemoryRepository> {
188 &self.repository
189 }
190
191 pub fn namespace(&self) -> &MemoryNamespace {
192 &self.namespace
193 }
194
195 pub fn mode(&self) -> DurableMemoryMode {
196 self.mode
197 }
198
199 pub fn recall_policy(&self) -> Option<DurableMemoryRecallPolicy> {
200 self.recall_policy
201 }
202
203 pub fn semantic_recall(&self) -> Option<&DurableMemorySemanticRecall> {
204 self.semantic_recall.as_ref()
205 }
206
207 pub async fn refresh_semantic_recall(
210 &self,
211 cancellation: CancellationToken,
212 ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
213 self.refresh_semantic_recall_requiring(
214 VectorMutationConsistency::PartitionAtomic,
215 cancellation,
216 )
217 .await
218 }
219
220 pub async fn refresh_semantic_recall_requiring(
224 &self,
225 required_consistency: VectorMutationConsistency,
226 cancellation: CancellationToken,
227 ) -> Result<DurableMemorySemanticRefreshReceipt, DurableMemorySemanticError> {
228 let semantic = self.semantic_recall.as_ref().ok_or_else(|| {
229 DurableMemorySemanticError::InvalidConfiguration {
230 field: "semanticRecall",
231 reason: "refresh requires an attached semantic recall generation".to_string(),
232 }
233 })?;
234 semantic
235 .refresh_repository_namespace(
236 self.repository.as_ref(),
237 &self.namespace,
238 required_consistency,
239 cancellation,
240 )
241 .await
242 }
243
244 pub fn binding(&self) -> DurableMemoryBindingV1 {
247 DurableMemoryBindingV1::new(
248 self.namespace.clone(),
249 self.mode,
250 self.recall_policy,
251 self.semantic_recall
252 .as_ref()
253 .map(|semantic| semantic.binding().clone()),
254 )
255 }
256
257 pub async fn activate_candidate(
259 &self,
260 activation: DurableMemoryActivation,
261 ) -> Result<MemoryNode, MemoryRepositoryError> {
262 let result = self
263 .repository
264 .apply(MemoryChangeSet::new(
265 activation.idempotency_key,
266 self.namespace.clone(),
267 activation.occurred_at,
268 vec![MemoryOperation::Activate {
269 node_id: activation.node_id.clone(),
270 expected_revision: activation.expected_revision,
271 evidence: vec![activation.decision_evidence],
272 }],
273 ))
274 .await?;
275 result
276 .nodes
277 .into_iter()
278 .find(|node| node.id == activation.node_id && node.status == MemoryStatus::Active)
279 .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
280 message: "activation change returned no active target node".into(),
281 })
282 }
283
284 pub async fn record_use(&self, usage: DurableMemoryUse) -> Result<(), MemoryRepositoryError> {
286 let mut event = MemoryAccessEvent::new(
287 usage.event_id,
288 self.namespace.clone(),
289 usage.node_id,
290 usage.node_revision,
291 usage.occurred_at,
292 );
293 if let Some(context_id) = usage.context_id {
294 event = event.with_context_id(context_id);
295 }
296 self.repository.record_use(event).await
297 }
298
299 pub(crate) async fn store_shadow_candidate(
300 &self,
301 item: &MemoryItem,
302 evidence: &DurableTurnEvidence,
303 ) -> Result<MemoryNode, MemoryRepositoryError> {
304 let kind = match item.memory_type {
305 MemoryType::Episodic => DurableMemoryKind::Episodic,
306 MemoryType::Semantic => DurableMemoryKind::Semantic,
307 MemoryType::Procedural => DurableMemoryKind::Procedural,
308 MemoryType::Working => {
309 return Err(MemoryRepositoryError::InvalidInput {
310 field: "candidate.memoryType".into(),
311 message: "working memory is not durable".into(),
312 });
313 }
314 };
315 let confidence = item
316 .metadata
317 .get("confidence")
318 .and_then(|value| value.parse::<f32>().ok())
319 .filter(|value| value.is_finite() && (0.0..=1.0).contains(value))
320 .unwrap_or(0.0);
321 let mut draft = MemoryNodeDraft::new(
322 "content-addressed-after-normalization",
323 self.namespace.clone(),
324 kind,
325 MemoryStatus::Candidate,
326 &item.content,
327 vec![evidence.reference.clone()],
328 evidence.occurred_at,
329 )
330 .with_confidence(confidence)
331 .with_importance(item.importance)
332 .with_label("a3s.origin", "code.llm_extraction");
333 for (source, label) in [
334 ("source", "a3s.extraction.source"),
335 ("scope", "a3s.extraction.scope"),
336 ("reason", "a3s.extraction.reason"),
337 ("schema", "a3s.extraction.schema"),
338 ] {
339 if let Some(value) = item.metadata.get(source) {
340 draft = draft.with_label(label, value);
341 }
342 }
343 if !item.tags.is_empty() {
344 let tags = serde_json::to_string(&item.tags).map_err(|error| {
345 MemoryRepositoryError::InvalidInput {
346 field: "candidate.tags".into(),
347 message: error.to_string(),
348 }
349 })?;
350 draft = draft.with_label("a3s.extraction.tags", tags);
351 }
352 draft.id = candidate_id(&draft)?;
353
354 let result = self
355 .repository
356 .apply(MemoryChangeSet::new(
357 draft.id.clone(),
358 self.namespace.clone(),
359 evidence.occurred_at,
360 vec![MemoryOperation::Create { node: draft }],
361 ))
362 .await?;
363 result
364 .nodes
365 .into_iter()
366 .next()
367 .ok_or_else(|| MemoryRepositoryError::InvariantViolation {
368 message: "candidate change returned no node".into(),
369 })
370 }
371}
372
373fn invalid(field: &str, message: impl Into<String>) -> MemoryRepositoryError {
374 MemoryRepositoryError::InvalidInput {
375 field: field.into(),
376 message: message.into(),
377 }
378}
379
380fn validate_identifier(field: &str, value: &str) -> Result<(), MemoryRepositoryError> {
381 if value.trim().is_empty() {
382 return Err(invalid(field, "must not be empty or whitespace"));
383 }
384 if value.len() > MAX_IDENTIFIER_BYTES {
385 return Err(invalid(
386 field,
387 format!("must not exceed {MAX_IDENTIFIER_BYTES} bytes"),
388 ));
389 }
390 Ok(())
391}
392
393impl std::fmt::Debug for DurableMemorySession {
394 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
395 formatter
396 .debug_struct("DurableMemorySession")
397 .field("namespace", &self.namespace)
398 .field("mode", &self.mode)
399 .field("recall_policy", &self.recall_policy)
400 .field(
401 "semantic_recall",
402 &self
403 .semantic_recall
404 .as_ref()
405 .map(DurableMemorySemanticRecall::binding),
406 )
407 .finish_non_exhaustive()
408 }
409}
410
411#[derive(Debug, Clone)]
412pub(crate) struct DurableTurnEvidence {
413 reference: EvidenceRef,
414 occurred_at: DateTime<Utc>,
415}
416
417#[derive(Serialize)]
418#[serde(rename_all = "camelCase")]
419struct TurnEvidencePayload<'a> {
420 schema: &'static str,
421 session_id: &'a str,
422 prompt: &'a str,
423 response: &'a str,
424 transcript: &'a str,
425}
426
427impl DurableTurnEvidence {
428 pub(crate) fn try_new(
429 session_id: &str,
430 turn_id: &str,
431 prompt: &str,
432 response: &str,
433 transcript: &str,
434 occurred_at: DateTime<Utc>,
435 ) -> Result<Self, MemoryRepositoryError> {
436 let payload = TurnEvidencePayload {
437 schema: "a3s.code.memory.turn-evidence.v1",
438 session_id,
439 prompt,
440 response,
441 transcript,
442 };
443 let encoded =
444 serde_json::to_vec(&payload).map_err(|error| MemoryRepositoryError::InvalidInput {
445 field: "turnEvidence".into(),
446 message: error.to_string(),
447 })?;
448 let digest = format!("sha256:{:x}", Sha256::digest(encoded));
449 let session = utf8_percent_encode(session_id, NON_ALPHANUMERIC);
450 let turn = utf8_percent_encode(turn_id, NON_ALPHANUMERIC);
451 let reference = EvidenceRef::try_new(
452 format!("a3s://session/{session}/turn/{turn}"),
453 digest,
454 EvidenceKind::SessionTurn,
455 occurred_at,
456 )?;
457 Ok(Self {
458 reference,
459 occurred_at,
460 })
461 }
462}
463
464fn candidate_id(draft: &MemoryNodeDraft) -> Result<String, MemoryRepositoryError> {
465 let mut identity = draft.clone();
466 identity.id.clear();
467 let encoded =
468 serde_json::to_vec(&identity).map_err(|error| MemoryRepositoryError::InvalidInput {
469 field: "candidate".into(),
470 message: error.to_string(),
471 })?;
472 let mut hasher = Sha256::new();
473 hasher.update(b"a3s.code.memory.candidate.v1\0");
474 hasher.update(encoded);
475 Ok(format!("a3s-code-candidate-{:x}", hasher.finalize()))
476}
477
478#[cfg(test)]
479#[path = "durable_memory/tests.rs"]
480mod tests;