velesdb_memory/context/memory_bridge.rs
1//! The context compiler's memory bridge: memory-backed fragment selection,
2//! recoverable sources, aggregatable compilation events, and persisted
3//! working contexts — the `MemoryService` half of EPIC-P-070's US-002.
4//!
5//! Everything the bridge persists is a **system fact**: hub-marked
6//! (`_veles_hub`) and carrying **only reserved `_veles_*` metadata keys**, so
7//! it is invisible to unfiltered recall (hub exclusion), can never match a
8//! caller's include filter (callers cannot name reserved keys), and can never
9//! be forged by a caller fact (reserved keys are rejected at `remember`).
10//! Stored ids are salted, and both the source writer and the handle resolver
11//! verify the `_veles_ctx_source` marker, so a caller fact squatting a salt
12//! preimage is neither overwritten nor ever served back as a source. Events
13//! carry metadata and hashes only — never fragment content. Event recording
14//! stamps wall-clock time; the compile pipeline itself stays clock-free and
15//! deterministic.
16
17use std::collections::BTreeMap;
18use std::sync::atomic::{AtomicU64, Ordering};
19#[cfg(not(target_arch = "wasm32"))]
20use std::time::{SystemTime, UNIX_EPOCH};
21
22/// Wall-clock nanos since the Unix epoch, stamped on savings events only —
23/// never in the compile pipeline. On `wasm32-unknown-unknown`
24/// `SystemTime::now()` aborts (`std` has no clock there), so events carry 0:
25/// the per-process sequence alone uniquifies their ids, and wasm stats are
26/// per-session by design (in-memory store).
27fn now_nanos() -> u128 {
28 #[cfg(target_arch = "wasm32")]
29 {
30 0
31 }
32 #[cfg(not(target_arch = "wasm32"))]
33 {
34 SystemTime::now()
35 .duration_since(UNIX_EPOCH)
36 .map(|elapsed| elapsed.as_nanos())
37 .unwrap_or(0)
38 }
39}
40
41/// Current Unix time in seconds. TWO call sites, not one — this comment used
42/// to name only the first, which is how the second went unexamined:
43///
44/// - [`MemoryService::should_upgrade_ttl`]'s extension-only comparison (the
45/// storage/expiry layer; the `compile` pipeline itself stays clock-free).
46/// On `wasm32-unknown-unknown` this is 0 (no clock, mirrors [`now_nanos`]);
47/// the wasm `MemoryStore` is in-memory only, so a stored durable expiry (a
48/// real epoch second count) never actually exists there for 0 to be
49/// compared against.
50/// - [`MemoryService::update_working_index`]'s `saved_at` stamp. The wasm 0
51/// is NOT harmless here: every entry gets the same stamp, so
52/// `list_working_contexts` falls through to its `session`-ascending
53/// tiebreak and orders alphabetically while five surfaces — this crate,
54/// the wasm binding, the TS SDK, `MCP_TOOLS.md`, and the MCP tool
55/// description the model itself reads — promise "most-recently-saved
56/// first". Recording the divergence, not excusing it: fixing it needs a
57/// real wasm clock or an optional `saved_at`, and that is a decision, not
58/// a patch.
59fn now_unix_secs() -> u64 {
60 #[cfg(target_arch = "wasm32")]
61 {
62 0
63 }
64 #[cfg(not(target_arch = "wasm32"))]
65 {
66 SystemTime::now()
67 .duration_since(UNIX_EPOCH)
68 .map(|elapsed| elapsed.as_secs())
69 .unwrap_or(0)
70 }
71}
72
73use serde_json::{Map, Number, Value};
74
75use super::{positive_ttl, MemoryService, Metadata, HUB_FIELD};
76use crate::context::model::{
77 CompilePolicy, CompileRequest, CompiledContext, ContextDecision, ContextFragment,
78 ContextSavings, ContextSource, ImportanceWeights, LoadedWorkingContext, MediaRef, MemoryScope,
79 WorkingContext, WorkingContextIndex, WorkingContextSession,
80};
81use crate::context::{media, provenance, ContextCompiler};
82use crate::embedder::Embedder;
83use crate::error::MemoryError;
84use crate::id::stable_id;
85use crate::model::FusionOptions;
86use crate::storage::{FactStore, GraphStore, RecallStore};
87
88/// Salt for stored source ids — disjoint from natural fact ids, so a caller
89/// later remembering the same text can never overwrite a stored source (or
90/// inherit its system marker).
91const SOURCE_ID_SALT: &str = "veles-ctx-source:";
92/// Salt for compilation-event ids.
93const EVENT_ID_SALT: &str = "veles-ctx-event:";
94/// Salt for working-context ids (deterministic per project+session, so a
95/// save is an idempotent upsert).
96const WORKING_ID_SALT: &str = "veles-ctx-working:";
97/// Salt for a project's working-context index id (deterministic per
98/// project, so every `save_working_context` call updates the SAME system
99/// fact rather than minting a new one).
100const WORKING_INDEX_ID_SALT: &str = "veles-ctx-working-index:";
101
102/// The constant lexical anchor every event's content starts with, so one
103/// vector query can sweep the event family for aggregation.
104const EVENT_ANCHOR: &str = "veles context compilation event";
105
106/// Reserved metadata keys of the bridge's system facts. Reserved (`_veles_`)
107/// on purpose: callers can neither set them (forgery) nor filter on them, and
108/// [`MemoryService::context_savings`] aggregates only genuine events (it
109/// filters at the storage layer, below the caller-facing validation).
110///
111/// Being unfilterable was once claimed here to make these facts "invisible to
112/// every caller-facing recall path". It did not (#1737). A caller cannot
113/// filter ON a reserved key, but `field != value` MATCHES a fact that has no
114/// such field — and a system fact has none of the caller's columns, so every
115/// `!=` predicate swept all of them in. Invisibility is now an exclusion
116/// [`crate::storage::INTERNAL_MARKER_FIELDS`] states and each backend
117/// applies, not a side effect of the naming rule.
118///
119/// The four markers below are therefore imported rather than redeclared: they
120/// ARE entries of that list, and a local copy could drift from it silently.
121use crate::storage::{
122 CTX_EVENT_FIELD, CTX_SOURCE_FIELD, CTX_WORKING_FIELD, CTX_WORKING_INDEX_FIELD,
123};
124
125const CTX_PROJECT_FIELD: &str = "_veles_ctx_project";
126const CTX_MODEL_FIELD: &str = "_veles_ctx_model";
127/// A stored source's media payload (US-009, PR2): `{"mime", "bytes_b64"}`,
128/// the exact [`MediaRef`] shape, set only when the source fragment carried
129/// one. Reserved like every other `_veles_ctx_*` key — a caller can neither
130/// set nor filter on it.
131const CTX_SOURCE_MEDIA_FIELD: &str = "_veles_ctx_source_media";
132/// The durable-TTL payload key set by [`super::positive_ttl`]-backed writes
133/// (`store_with_ttl`, via `store_fact`). Mirrors `velesdb_core::EXPIRES_AT_KEY`
134/// as a literal rather than an import: that re-export is `persistence`-gated,
135/// and this module (unlike `NativeStore`) must keep compiling under `context`
136/// alone (e.g. `velesdb-wasm`, which never enables `persistence`).
137const EXPIRES_AT_FIELD: &str = "_veles_expires_at";
138const CTX_SESSION_FIELD: &str = "_veles_ctx_session";
139const CTX_TOKENS_IN_FIELD: &str = "_veles_ctx_tokens_in";
140const CTX_TOKENS_OUT_FIELD: &str = "_veles_ctx_tokens_out";
141const CTX_TOKENS_SAVED_FIELD: &str = "_veles_ctx_tokens_saved";
142const CTX_COST_FIELD: &str = "_veles_ctx_cost_micros";
143const CTX_CURRENCY_FIELD: &str = "_veles_ctx_currency";
144const CTX_AT_FIELD: &str = "_veles_ctx_at";
145
146/// Per-process sequence folded into event ids so two compilations landing on
147/// the same clock tick (coarse timers, concurrent calls) never collide.
148static EVENT_SEQ: AtomicU64 = AtomicU64::new(0);
149
150/// Serializes the read-modify-write of the per-project working-context index.
151///
152/// The index is ONE fact per project, rewritten wholesale on every
153/// `save_working_context`. Without this, two saves racing on the same project
154/// both read the same pre-state and the second write erases the first
155/// session's entry — a silent loss: the erased session's own fact is still on
156/// disk and still loadable by exact id, but `list_working_contexts` (and
157/// therefore `load_working_context`'s `other_sessions` recovery hint) no
158/// longer knows it exists, and nothing anywhere returns an error.
159///
160/// **Scope: intra-process — which is the WHOLE problem (#1958).** An earlier
161/// version of this comment claimed two processes opening the same store
162/// still race past this lock. They cannot: `velesdb-core`'s
163/// `Database::open_impl` takes an exclusive `flock` on `velesdb.lock` at
164/// open and holds it for the `Database`'s entire lifetime — not per write —
165/// so a second process fails at `open` with `DatabaseLocked` before it can
166/// reach any read-modify-write, of this index or of anything else. Proven
167/// with real processes by `tests/http_lock_contention.rs` and
168/// `tests/working_index_two_daemons_process.rs` (the latter is #1958's
169/// success criterion verbatim: sessions saved under contention and across a
170/// process handoff, zero index entries lost). A trait-level compare-and-swap
171/// was considered there and declined: flock is the only cross-process
172/// primitive available here, and the store boundary already holds it — a
173/// second one around the index would guard against a concurrency the first
174/// makes unreachable. This mutex therefore covers the only concurrency that
175/// exists: threads of the one process allowed to hold the store (the MCP
176/// server's `spawn_blocking` handlers are exactly what made it reachable).
177///
178/// One global lock rather than one per project: index writes are rare (one
179/// per `save_working_context`), so the contention is negligible, whereas a
180/// `HashMap<String, _>` keyed by caller-supplied project names is an unbounded
181/// slow leak for no measurable gain. Per-project striping is the obvious
182/// upgrade if index writes ever become hot.
183static WORKING_INDEX_WRITE: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
184
185/// Keep the index at most [`crate::limits::MAX_WORKING_SESSIONS_PER_PROJECT`] entries,
186/// dropping the oldest-saved first. `just_saved` is pinned: it is the entry
187/// this very call put there, and a same-second flood of other sessions must
188/// not be able to evict it on a timestamp tie. The order among the rest is
189/// the read path's (`saved_at` desc, STABLE, ties keeping the stored recency
190/// order), so what survives is exactly what `list_working_contexts` would
191/// have shown first — on `wasm32`, where every `saved_at` is 0, that is the
192/// stored order alone, and eviction is by recency rather than by name.
193fn bound_sessions(sessions: &mut Vec<WorkingContextSession>, just_saved: &str) {
194 use crate::limits::MAX_WORKING_SESSIONS_PER_PROJECT as CAP;
195 if sessions.len() <= CAP {
196 return;
197 }
198 // Pin by moving the just-saved entry to the front — where the writer put
199 // it, and where the recency order wants it — with one in-place rotation.
200 // Everything after it is then stably ordered by `saved_at` alone (ties
201 // keep the stored recency order) and the tail past the cap is cut. When
202 // the entry is absent the whole vector is ordered and cut the same way.
203 let pinned = sessions.iter().position(|s| s.session == just_saved);
204 if let Some(at) = pinned {
205 sessions[..=at].rotate_right(1);
206 }
207 let unpinned_from = usize::from(pinned.is_some());
208 sessions[unpinned_from..].sort_by_key(|s| std::cmp::Reverse(s.saved_at));
209 sessions.truncate(CAP);
210}
211
212/// The compilation half — `compile_context` and its helpers; see
213/// `memory_bridge_compile.rs`'s module doc for why it is split.
214#[path = "memory_bridge_compile.rs"]
215mod compile;
216
217impl<E: Embedder, S: FactStore> MemoryService<E, S> {
218 /// Persist `working` under `project` + `session` (idempotent upsert:
219 /// saving again replaces the previous state). Returns the system fact id.
220 ///
221 /// Serialized size is capped at [`crate::limits::MAX_FACT_BYTES`] (1
222 /// MiB) — the same ceiling every other stored fact honors — checked
223 /// BEFORE anything is written, so an oversized working context is never
224 /// partially stored.
225 ///
226 /// An entirely empty `working` ([`WorkingContext::is_empty`]) is refused.
227 /// Because the write is an upsert, saving one would replace — destroy —
228 /// the state a previous save stored under the same project and session,
229 /// and the one tool whose job is surviving a context loss must not be
230 /// able to cause one on a call that carries nothing (issue #1654).
231 ///
232 /// # Errors
233 /// Returns [`MemoryError::EmptyWorkingContextKey`] if `project` or
234 /// `session` is empty or whitespace-only — they are the id the state is
235 /// saved and listed under — [`MemoryError::WorkingContextKeyTooLong`] if
236 /// either exceeds [`crate::limits::MAX_WORKING_CONTEXT_KEY_BYTES`],
237 /// [`MemoryError::EmptyWorkingContext`] if
238 /// `working` records nothing, [`MemoryError::WorkingContextCodec`] if
239 /// serialization fails,
240 /// [`MemoryError::ContextOverLimit`] if the serialized `working` exceeds
241 /// [`crate::limits::MAX_FACT_BYTES`], or a storage/embedding error.
242 pub fn save_working_context(
243 &self,
244 project: &str,
245 session: &str,
246 working: &WorkingContext,
247 ) -> Result<u64, MemoryError> {
248 let _generation = self.enter_generation();
249 for (key, value) in [("project", project), ("session", session)] {
250 if value.trim().is_empty() {
251 return Err(MemoryError::EmptyWorkingContextKey { key });
252 }
253 // Ids, not payloads — and until this check they were the one
254 // caller string that reached storage past every other ceiling
255 // (metadata, embedder, index). See the constant's doc for the
256 // measured bypass.
257 if value.len() > crate::limits::MAX_WORKING_CONTEXT_KEY_BYTES {
258 return Err(MemoryError::WorkingContextKeyTooLong {
259 key,
260 len: value.len(),
261 max: crate::limits::MAX_WORKING_CONTEXT_KEY_BYTES,
262 });
263 }
264 }
265 if working.is_empty() {
266 return Err(MemoryError::EmptyWorkingContext);
267 }
268 let content =
269 serde_json::to_string(working).map_err(|err| MemoryError::WorkingContextCodec {
270 detail: "encoding the working context for storage".to_owned(),
271 source: Some(Box::new(err)),
272 })?;
273 if content.len() > crate::limits::MAX_FACT_BYTES {
274 return Err(MemoryError::ContextOverLimit(format!(
275 "working context of {} bytes exceeds the cap of {} bytes",
276 content.len(),
277 crate::limits::MAX_FACT_BYTES
278 )));
279 }
280 let id = working_id(project, session);
281 let embedding = self
282 .embedder
283 .embed(&format!("working context {project} {session}"))?;
284 let meta = system_meta(&[
285 (CTX_WORKING_FIELD, Value::Bool(true)),
286 (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
287 (CTX_SESSION_FIELD, Value::String(session.to_owned())),
288 ]);
289 self.store_fact(id, &content, &embedding, Some(&meta), None)?;
290 self.update_working_index(project, session)?;
291 Ok(id)
292 }
293
294 /// The working context previously saved under `project` + `session`,
295 /// `None` when there is none.
296 ///
297 /// Symmetric to [`Self::context_source_metadata`]'s squatter guard: the
298 /// slot is only ever served back when its metadata carries the reserved
299 /// [`CTX_WORKING_FIELD`] marker (set exclusively by
300 /// [`Self::save_working_context`]). A slot occupied by an unmarked caller
301 /// fact — one that happened to land on this salted id, or a forged
302 /// probe — is indistinguishable from "nothing saved" on purpose: `None`,
303 /// never the forged content, and never an error (the caller cannot tell
304 /// a squatted slot from a genuinely empty one, which is the point — it
305 /// must never learn that *something* occupies this id).
306 ///
307 /// A pure read: it never writes, never prunes, never heals. Index
308 /// convergence happens on the WRITE path
309 /// ([`Self::update_working_index`]) — a lookup that rewrites shared state
310 /// turns every transient miss into permanent data loss and cannot safely
311 /// be retried.
312 ///
313 /// # Errors
314 /// Returns [`MemoryError::WorkingContextCodec`] if the stored payload
315 /// does not parse, or if the slot is marked but its body is gone (a torn
316 /// fact is corruption — reporting it as "nothing saved" would tell the
317 /// caller the one thing that is certainly false), or a storage error.
318 pub fn load_working_context(
319 &self,
320 project: &str,
321 session: &str,
322 ) -> Result<Option<WorkingContext>, MemoryError> {
323 let _generation = self.enter_generation();
324 self.load_working_context_inner(project, session)
325 }
326
327 fn load_working_context_inner(
328 &self,
329 project: &str,
330 session: &str,
331 ) -> Result<Option<WorkingContext>, MemoryError> {
332 let slot = working_id(project, session);
333 let payloads = self.store.get_metadata_batch(&[slot])?;
334 let marked = payloads
335 .into_iter()
336 .next()
337 .flatten()
338 .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)));
339 if !marked {
340 // The squatter/never-saved guard documented above: silent by
341 // design, and the branch a `forget` lands on (deleting a fact
342 // removes its metadata with it).
343 return Ok(None);
344 }
345 let Some((content, _)) = self.store.get(slot)? else {
346 return Err(MemoryError::WorkingContextCodec {
347 detail: format!(
348 "working context for project '{project}', session '{session}' is corrupt: \
349 the reserved marker is present but the stored body is gone"
350 ),
351 source: None,
352 });
353 };
354 serde_json::from_str(&content)
355 .map(Some)
356 .map_err(|err| MemoryError::WorkingContextCodec {
357 detail: format!(
358 "decoding the stored working context for project '{project}', \
359 session '{session}'"
360 ),
361 source: Some(Box::new(err)),
362 })
363 }
364
365 /// The full resumption envelope for `project` + `session`: what
366 /// [`Self::load_working_context`] found, plus the OTHER sessions saved
367 /// under the same project so a typo in `session` is recoverable.
368 ///
369 /// This is the ONE place the three policy rules live:
370 ///
371 /// 1. `other_sessions` is listed on a HIT too, not just on a miss — a
372 /// typo that lands on another REAL session returns `found: true`, and
373 /// the caller has no other way to notice it resumed the wrong work.
374 /// Costs one extra O(1) index read per successful load.
375 /// 2. The requested `session` is never echoed back: the field is named
376 /// `other_sessions`, so returning the requested id would be a
377 /// contradiction the caller cannot act on.
378 /// 3. An unreadable index is fatal on a MISS and survivable on a HIT —
379 /// see [`Self::other_sessions_for`].
380 ///
381 /// Every surface (the `load_working_context` MCP tool and the Node,
382 /// Python and WASM bindings) calls this rather than recomposing the
383 /// envelope from [`Self::load_working_context`] +
384 /// [`Self::list_working_contexts`]: four recompositions are four copies
385 /// of those rules, and a copy that stops matching the others fails
386 /// silently — the caller still gets a well-formed envelope, just a
387 /// different one.
388 ///
389 /// # Errors
390 /// Propagates [`Self::load_working_context`]'s errors (a corrupt or
391 /// unparseable stored payload), and [`Self::list_working_contexts`]'s (a
392 /// corrupt index, or a storage failure) **on a miss only** — rule 3.
393 pub fn resume_working_context(
394 &self,
395 project: &str,
396 session: &str,
397 ) -> Result<LoadedWorkingContext, MemoryError> {
398 let _generation = self.enter_generation();
399 let working = self.load_working_context_inner(project, session)?;
400 let other_sessions = self.other_sessions_for(project, session, working.is_some())?;
401 Ok(LoadedWorkingContext {
402 found: working.is_some(),
403 working,
404 other_sessions,
405 })
406 }
407
408 /// The project's OTHER sessions, and what to do when the index that holds
409 /// them cannot be read.
410 ///
411 /// The two answers differ because `other_sessions` plays a different part
412 /// on each path:
413 ///
414 /// - **On a hit** it is a HINT — "you asked for `alpha`, note that
415 /// `alpha-2` also exists, you may have resumed the wrong one". The
416 /// answer the caller actually asked for is already in hand and intact.
417 /// Failing the whole call here would turn a fault in one auxiliary fact
418 /// into a total loss of resumption for EVERY session of the project,
419 /// including the many that read back perfectly — which is why an
420 /// unreadable index degrades to an empty hint instead. Nothing is
421 /// swallowed: the corruption stays loudly reachable through
422 /// [`Self::list_working_contexts`], published on every surface.
423 /// - **On a miss** it is the ONLY signal there is. `[]` then reads as the
424 /// positive assertion "nothing else was ever saved under this project",
425 /// and an agent told that starts over on top of work sitting right next
426 /// to where it looked — the exact failure this envelope exists to
427 /// prevent. An assertion we cannot support must not be manufactured, so
428 /// the error propagates.
429 ///
430 /// # Errors
431 /// Propagates [`Self::list_working_contexts`]'s errors when `found` is
432 /// false.
433 fn other_sessions_for(
434 &self,
435 project: &str,
436 session: &str,
437 found: bool,
438 ) -> Result<Vec<String>, MemoryError> {
439 let listed = match self.list_working_contexts_inner(project) {
440 Ok(listed) => listed,
441 Err(_) if found => return Ok(Vec::new()),
442 Err(err) => return Err(err),
443 };
444 Ok(listed
445 .into_iter()
446 .map(|entry| entry.session)
447 .filter(|candidate| candidate != session)
448 .collect())
449 }
450
451 /// The sessions of `sessions` whose working-context fact is still there,
452 /// in the same order. One batched metadata lookup for the whole set — not
453 /// a store scan, but not free either (see
454 /// [`Self::list_working_contexts`]'s cost note).
455 ///
456 /// Shared by the read path (filter, persist nothing) and the write path
457 /// (filter, and persist the result), so both agree on what "alive" means.
458 fn live_sessions(
459 &self,
460 project: &str,
461 sessions: Vec<WorkingContextSession>,
462 ) -> Result<Vec<WorkingContextSession>, MemoryError> {
463 if sessions.is_empty() {
464 return Ok(sessions);
465 }
466 let ids: Vec<u64> = sessions
467 .iter()
468 .map(|entry| working_id(project, &entry.session))
469 .collect();
470 let payloads = self.store.get_metadata_batch(&ids)?;
471 if payloads.len() != ids.len() {
472 // The trait promises one result per id. A backend that breaks
473 // that promise must not be silently read as "these sessions are
474 // dead" — that would delete real entries on the write path.
475 return Err(MemoryError::WorkingContextCodec {
476 detail: format!(
477 "storage returned {} metadata rows for {} working-context ids",
478 payloads.len(),
479 ids.len()
480 ),
481 source: None,
482 });
483 }
484 Ok(sessions
485 .into_iter()
486 .zip(payloads)
487 .filter(|(_, meta)| {
488 meta.as_ref()
489 .is_some_and(|meta| meta.get(CTX_WORKING_FIELD) == Some(&Value::Bool(true)))
490 })
491 .map(|(entry, _)| entry)
492 .collect())
493 }
494
495 /// Reconstruct `project`'s session list by walking the store for its
496 /// working-context facts — the recovery path for an index that cannot be
497 /// read. The index is a derived cache; every fact it lists carries
498 /// [`CTX_WORKING_FIELD`], [`CTX_PROJECT_FIELD`] and [`CTX_SESSION_FIELD`],
499 /// so the listing can be rebuilt from the facts themselves.
500 ///
501 /// `saved_at` is NOT recoverable: it lives in the index alone, never on
502 /// the fact. Recovered entries carry 0, which sorts them after anything
503 /// stamped since — an honest "unknown", and a far smaller harm than
504 /// dropping them. The session being saved right now is stamped with the
505 /// real clock by the caller, which finds it already present here.
506 ///
507 /// Returns `Ok(None)` when the backend cannot enumerate.
508 /// [`FactStore::list`] is defaulted to [`MemoryError::Unsupported`] on
509 /// purpose, so an out-of-crate backend that cannot walk keeps saving
510 /// rather than failing — it just cannot recover the listing.
511 ///
512 /// Walks at most [`crate::limits::MAX_WORKING_SESSIONS_PER_PROJECT`]
513 /// matches: the rebuilt index is bounded by the same cap every other
514 /// write honors, so a huge store cannot turn this rare path into an
515 /// unbounded walk.
516 fn rebuild_working_index(
517 &self,
518 project: &str,
519 ) -> Result<Option<Vec<WorkingContextSession>>, MemoryError> {
520 const PAGE: usize = 256;
521 let wanted = Value::String(project.to_owned());
522 let mut sessions = Vec::new();
523 let mut cursor = None;
524 loop {
525 let (facts, next) = match self.store.list(cursor, PAGE) {
526 Ok(page) => page,
527 Err(MemoryError::Unsupported(_)) => return Ok(None),
528 Err(err) => return Err(err),
529 };
530 for fact in facts {
531 if fact.payload.get(CTX_WORKING_FIELD) != Some(&Value::Bool(true))
532 || fact.payload.get(CTX_PROJECT_FIELD) != Some(&wanted)
533 {
534 continue;
535 }
536 let Some(Value::String(session)) = fact.payload.get(CTX_SESSION_FIELD) else {
537 continue;
538 };
539 sessions.push(WorkingContextSession {
540 session: session.clone(),
541 saved_at: 0,
542 });
543 if sessions.len() >= crate::limits::MAX_WORKING_SESSIONS_PER_PROJECT {
544 return Ok(Some(sessions));
545 }
546 }
547 match next {
548 Some(at) => cursor = Some(at),
549 None => return Ok(Some(sessions)),
550 }
551 }
552 }
553
554 /// Every session still resumable under `project`'s working-context index
555 /// (V2a-1 quick win), most-recently-saved first. Empty when the project
556 /// never saved anything — that, and only that, is the empty case.
557 ///
558 /// Cost: one O(1) index read plus ONE batched metadata lookup of the
559 /// listed ids — never a store scan, but no longer a single read either.
560 /// The lookup is what drops sessions whose fact was forgotten since;
561 /// unlike the previous read-path prune it persists nothing, so a listing
562 /// can be retried and a transient miss costs nothing durable.
563 ///
564 /// # Errors
565 /// Returns a storage error if the index fact cannot be read, or
566 /// [`MemoryError::WorkingContextCodec`] if it does not parse or is
567 /// corrupt (marked, but with no body).
568 pub fn list_working_contexts(
569 &self,
570 project: &str,
571 ) -> Result<Vec<WorkingContextSession>, MemoryError> {
572 let _generation = self.enter_generation();
573 self.list_working_contexts_inner(project)
574 }
575
576 fn list_working_contexts_inner(
577 &self,
578 project: &str,
579 ) -> Result<Vec<WorkingContextSession>, MemoryError> {
580 let Some(index) = self.working_index(project)? else {
581 // The genuine "this project never saved anything" case — the only
582 // one that reaches here now that a corrupt index is an `Err`.
583 return Ok(Vec::new());
584 };
585 let mut sessions = self.live_sessions(project, index.sessions)?;
586 // Stable, and by `saved_at` alone: ties keep the stored order, which
587 // `update_working_index` maintains as recency (see its comment).
588 sessions.sort_by_key(|s| std::cmp::Reverse(s.saved_at));
589 Ok(sessions)
590 }
591
592 /// The raw working-context index fact for `project`, `None` when nothing
593 /// was ever saved under it. Symmetric squatter guard to
594 /// [`Self::load_working_context`]: a slot occupied without the reserved
595 /// [`CTX_WORKING_INDEX_FIELD`] marker is treated as empty, never as a
596 /// forged index.
597 ///
598 /// `None` means "absent". "Corrupt" is an `Err` — collapsing the two
599 /// would report a store that lost the index body as a project that never
600 /// saved anything, and an agent told that starts over instead of raising
601 /// a problem a human could fix.
602 fn working_index(&self, project: &str) -> Result<Option<WorkingContextIndex>, MemoryError> {
603 let slot = working_index_id(project);
604 let payloads = self.store.get_metadata_batch(&[slot])?;
605 let marked = payloads
606 .into_iter()
607 .next()
608 .flatten()
609 .is_some_and(|meta| meta.get(CTX_WORKING_INDEX_FIELD) == Some(&Value::Bool(true)));
610 if !marked {
611 return Ok(None);
612 }
613 match self.store.get(slot)? {
614 Some((content, _)) => serde_json::from_str(&content).map(Some).map_err(|err| {
615 MemoryError::WorkingContextCodec {
616 detail: format!("decoding the working-context index for project '{project}'"),
617 source: Some(Box::new(err)),
618 }
619 }),
620 None => Err(MemoryError::WorkingContextCodec {
621 detail: format!(
622 "working-context index for project '{project}' is corrupt: the index \
623 marker is present but the stored body is gone"
624 ),
625 source: None,
626 }),
627 }
628 }
629
630 /// Append (or refresh) `session`'s entry in `project`'s working-context
631 /// index — called by every [`Self::save_working_context`], so the index
632 /// is always current without a separate maintenance step. A resave of
633 /// the same project+session updates `saved_at` in place rather than
634 /// duplicating the entry.
635 ///
636 /// This is also where the index CONVERGES: entries whose working-context
637 /// fact was forgotten since are dropped here, on the write path, under
638 /// the same lock and in the same read-modify-write that was already
639 /// paid for. Reads never mutate it.
640 fn update_working_index(&self, project: &str, session: &str) -> Result<(), MemoryError> {
641 // The index slot's embedding derives from the PROJECT NAME alone,
642 // never from the index content, so it is computed here, BEFORE the
643 // lock: an embedder can be a network round-trip (or a hung one), and
644 // holding the global write lock across it stalls every working-index
645 // write in the process behind one slow call. Racing saves may embed
646 // concurrently, but they embed the same text, so whichever vector
647 // lands is equivalent — no re-check under the lock is needed. The
648 // index CONTENT read-modify-write stays entirely under the lock.
649 let embedding = self
650 .embedder
651 .embed(&format!("working context index {project}"))?;
652 // Read-modify-write of a single shared fact: held for the whole
653 // sequence, otherwise a concurrent save silently erases this entry.
654 let _guard = WORKING_INDEX_WRITE.lock();
655 // A corrupt index must not brick saving for the whole project:
656 // propagating the error here would make every future save of every
657 // session under it fail forever, with no way back, since the only
658 // writer of the index is this function.
659 //
660 // But starting from EMPTY was silent, permanent data loss. The rebuilt
661 // index overwrites the corrupt one, so the read path's error — the
662 // thing the caller was told to act on — is destroyed by the very next
663 // save, and in an agent workload that save is moments away. Every
664 // other session under the project leaves the listing for good while
665 // its fact sits intact on disk, reachable only by someone who already
666 // knows the exact id.
667 //
668 // The facts are the ground truth and the index is a derived cache, so
669 // rebuild the cache from the facts instead. Only when the backend
670 // cannot enumerate at all does this fall back to the empty rebuild —
671 // documented on [`Self::rebuild_working_index`], and still the
672 // save-keeps-working outcome it always was.
673 let mut index = match self.working_index(project) {
674 Ok(index) => index.unwrap_or_default(),
675 Err(MemoryError::WorkingContextCodec { .. }) => WorkingContextIndex {
676 sessions: self.rebuild_working_index(project)?.unwrap_or_default(),
677 },
678 Err(err) => return Err(err),
679 };
680 let now = now_unix_secs();
681 // The vector's ORDER is recency, most recent first, maintained here on
682 // every save: the entry being saved moves (or is inserted) at the
683 // front. `saved_at` alone cannot carry that order — it is whole
684 // seconds, so two saves in one second tie, and on `wasm32` it is
685 // always 0 (no clock), so EVERY entry ties. The read path and
686 // `bound_sessions` sort by `saved_at` with a STABLE sort and no other
687 // tiebreak, so where `saved_at` cannot decide, this order does, and
688 // "most-recently-saved first" holds on every target. Before this, ties
689 // fell through to a `session`-ascending tiebreak — alphabetical — and
690 // on wasm the whole listing was alphabetical while five surfaces
691 // promised recency.
692 if let Some(at) = index.sessions.iter().position(|s| s.session == session) {
693 // One in-place rotation moves the entry to the front and shifts
694 // the ones before it by one: no allocation, one pass.
695 index.sessions[..=at].rotate_right(1);
696 index.sessions[0].saved_at = now;
697 } else {
698 index.sessions.insert(
699 0,
700 WorkingContextSession {
701 session: session.to_owned(),
702 saved_at: now,
703 },
704 );
705 }
706 // The entry just appended is alive by construction (its fact was
707 // stored moments ago, before this call); this only sheds the ones a
708 // `forget` orphaned.
709 index.sessions = self.live_sessions(project, index.sessions)?;
710 bound_sessions(&mut index.sessions, session);
711 let content =
712 serde_json::to_string(&index).map_err(|err| MemoryError::WorkingContextCodec {
713 detail: format!("encoding the working-context index for project '{project}'"),
714 source: Some(Box::new(err)),
715 })?;
716 self.write_working_index(project, &content, &embedding)
717 }
718
719 /// Persist a serialized index into `project`'s reserved index slot —
720 /// always with the [`CTX_WORKING_INDEX_FIELD`] marker, since an index
721 /// written without it would be treated as a squatter and read back as
722 /// empty. Only [`Self::update_working_index`] (which holds
723 /// [`WORKING_INDEX_WRITE`] and supplies the slot `embedding` it computed
724 /// before taking that lock) calls this: nothing in here may call the
725 /// embedder, or the lock would again be held across a network hop.
726 fn write_working_index(
727 &self,
728 project: &str,
729 content: &str,
730 embedding: &[f32],
731 ) -> Result<(), MemoryError> {
732 let slot = working_index_id(project);
733 let meta = system_meta(&[
734 (CTX_WORKING_INDEX_FIELD, Value::Bool(true)),
735 (CTX_PROJECT_FIELD, Value::String(project.to_owned())),
736 ]);
737 self.store_fact(slot, content, embedding, Some(&meta), None)?;
738 Ok(())
739 }
740}
741
742/// How many memories a scope pulls when it does not say (`k` absent).
743const DEFAULT_MEMORY_K: usize = 5;
744
745/// One memory the scope pulled in, with its full ranking ventilation.
746struct PulledMemory {
747 fragment: ContextFragment,
748 memory_id: u64,
749 /// Fused score normalised over the pulled batch, in `[0, 1]` — the
750 /// importance-blended key (clamped) when the blend is active.
751 relevance: f32,
752 /// Normalised vector term of the fused score.
753 vector_norm: f64,
754 /// Graph promotion weight of the fused score.
755 graph_weight: f64,
756 /// Learned RL confidence the blend used (neutral `0.5` when the memory
757 /// never received feedback).
758 confidence: f64,
759 /// Batch-relative recency contribution in `[0, 1]` (`0` when the term
760 /// is inactive, the key is absent, or the batch is degenerate).
761 recency: f64,
762 /// Whether the importance blend ran — drives the extended four-signal
763 /// reason ventilation; `false` keeps the exact 0.8.0 reason bytes.
764 ventilated: bool,
765}
766
767/// A selected memory before the importance blend: its similarity base, its
768/// fused ventilation, and the caller-visible metadata the recency term reads.
769struct MemoryCandidate {
770 memory_id: u64,
771 /// Fused-normalised (or rank-based) similarity in `[0, 1]`.
772 base: f64,
773 vector_norm: f64,
774 graph_weight: f64,
775 metadata: Option<Metadata>,
776 content: String,
777}
778
779impl MemoryCandidate {
780 /// The unblended [`PulledMemory`] — bytes identical to the 0.8.0 pull.
781 fn into_pulled(self) -> PulledMemory {
782 #[allow(clippy::cast_possible_truncation)] // base is clamped into [0, 1]
783 let relevance = self.base as f32;
784 PulledMemory {
785 fragment: ContextFragment {
786 id: None,
787 content: self.content,
788 path: None,
789 kind: Some("memory".to_owned()),
790 priority: None,
791 metadata: None,
792 media: None,
793 },
794 memory_id: self.memory_id,
795 relevance,
796 vector_norm: self.vector_norm,
797 graph_weight: self.graph_weight,
798 confidence: NEUTRAL_CONFIDENCE,
799 recency: 0.0,
800 ventilated: false,
801 }
802 }
803}
804
805/// The neutral confidence of a memory with no feedback history — mirrors
806/// `reinforce::RL_NEUTRAL_CONFIDENCE`, whose module is `persistence`-gated:
807/// its contribution to the blend is exactly `0`.
808const NEUTRAL_CONFIDENCE: f64 = 0.5;
809
810/// The learned RL confidence off a raw payload, in `[0, 1]`. Without the
811/// `persistence` feature the RL module (and thus `feedback`) does not exist,
812/// so every memory reads neutral.
813#[cfg(feature = "persistence")]
814fn payload_confidence(payload: Option<&Metadata>) -> f64 {
815 f64::from(payload.map_or(
816 super::reinforce::RL_NEUTRAL_CONFIDENCE,
817 super::reinforce::read_confidence,
818 ))
819}
820
821/// See the `persistence` twin: no RL module, always neutral.
822#[cfg(not(feature = "persistence"))]
823fn payload_confidence(_payload: Option<&Metadata>) -> f64 {
824 NEUTRAL_CONFIDENCE
825}
826
827/// Whether the policy's importance weights change anything at all: a
828/// non-zero confidence weight, or a non-zero recency weight WITH a field to
829/// read. Zero weights must cost nothing and change nothing (0.8.0 parity).
830#[allow(
831 clippy::float_cmp,
832 reason = "an exact zero weight is the documented off switch; any non-zero weight, however small, is active"
833)]
834/// The batch-relative recency contribution of every candidate, in `[0, 1]`:
835/// min-max over the candidates that carry the policy's `recency_field` as a
836/// number (one monotone scale per batch — `YYYYMMDD` or an epoch, the
837/// caller's choice). A candidate without the key contributes `0` (never
838/// penalised), and a degenerate batch (`max == min`) contributes `0` for
839/// all. No clock: recency is relative to the newest of the batch.
840#[allow(
841 clippy::float_cmp,
842 reason = "an exact zero weight is the documented off switch for the recency term"
843)]
844/// Base metadata of every bridge-stored system fact: hub-marked (invisible
845/// to normal recall) plus the given extra keys.
846fn system_meta(extra: &[(&str, Value)]) -> Metadata {
847 let mut meta = Map::new();
848 meta.insert(HUB_FIELD.to_owned(), Value::Bool(true));
849 for (key, value) in extra {
850 meta.insert((*key).to_owned(), value.clone());
851 }
852 meta
853}
854
855/// A `u64` metadata field, `0` when absent or non-numeric.
856fn meta_u64(meta: &Metadata, key: &str) -> u64 {
857 meta.get(key).and_then(Value::as_u64).unwrap_or(0)
858}
859
860/// The handle-identity hash of one request fragment — the bridge-side twin
861/// of `Analysis::handle_hash` in `context.rs` (kept in lockstep; the two
862/// must key the same identity or stored slots and minted handles drift
863/// apart): raw decoded media bytes for a media fragment, caption/content
864/// [`stable_id`] otherwise.
865fn fragment_handle_hash(fragment: &ContextFragment) -> u64 {
866 fragment.media.as_ref().map_or_else(
867 || stable_id(&fragment.content),
868 |media_ref| media::analyze(media_ref).raw_hash,
869 )
870}
871
872/// The salted, deterministic system-fact id of a working context.
873fn working_id(project: &str, session: &str) -> u64 {
874 stable_id(&format!("{WORKING_ID_SALT}{project}\u{1f}{session}"))
875}
876
877/// The salted, deterministic system-fact id of a project's working-context
878/// index — one per project, so every save updates the same slot.
879fn working_index_id(project: &str) -> u64 {
880 stable_id(&format!("{WORKING_INDEX_ID_SALT}{project}"))
881}
882
883#[cfg(all(test, feature = "persistence"))]
884#[path = "memory_bridge_tests.rs"]
885mod tests;