Skip to main content

lunaris/recipes/
coding_session_memory.rs

1//! Phase 12 Plan 12-01 HELIOS-03 — `CodingSessionMemory` v2 delegates to the
2//! Phase 9 `WorkingMemory` primitive. Still `≤ 50 LOC public-API surface` per
3//! HELIOS-01 (unchanged contract — public symbols enumerated below).
4//!
5//! Maps Helios's Read/Write/Edit/Grep/Ls tool surface onto Lunaris:
6//!
7//! | helios-rfc §5.3 | Lunaris call                                                 |
8//! |-----------------|--------------------------------------------------------------|
9//! | write(p, c)     | `WorkingMemory::write(p, Value::String(c))`                  |
10//! | read(p)         | `WorkingMemory::read(p)` → unwrap `Value::String`            |
11//! | edit(p, _, n)   | `write(p, n)` — MVCC supersede via Plan 04-04 path           |
12//! | grep(pat, k)    | `Lunaris::recall().filter(StartsWith { source, session })`   |
13//! | ls(p)           | `storage().scan_range(<prefix bytes>, None)` (unchanged)     |
14//! | forget()        | `Lunaris::forget(ForgetTarget::Scope(ScopeSpec::BySource))`  |
15//! | as_of(ts)       | borrowed view re-running the read against a fixed [`Hlc`]   |
16//!
17//! ## ≤50-LOC public-surface contract (HELIOS-01)
18//!
19//! Public symbols on this module are exactly ten:
20//!
21//! 1. [`CodingSessionMemory::new`]
22//! 2. [`CodingSessionMemory::write`]
23//! 3. [`CodingSessionMemory::write_dated`]
24//! 4. [`CodingSessionMemory::read`]
25//! 5. [`CodingSessionMemory::edit`]
26//! 6. [`CodingSessionMemory::grep`]
27//! 7. [`CodingSessionMemory::ls`]
28//! 8. [`CodingSessionMemory::forget`]
29//! 9. [`CodingSessionMemory::as_of`]
30//! 10. [`AsOfScratchpad::read`]
31//!
32//! The unit test `coding_session_memory_public_surface_under_50_loc` enforces this
33//! ceiling by counting `pub fn` + `pub async fn` declarations in this file.
34//!
35//! ## MVCC retention via Plan 04-04 (D-15)
36//!
37//! [`CodingSessionMemory::edit`] is intentionally a plain [`CodingSessionMemory::write`]
38//! of the new content. The prior version's `bt.sys[1]` is set automatically by
39//! the existing MVCC supersede path in the storage layer. NO new mutation code
40//! lives here.
41//!
42//! ## v2 delegation (HELIOS-03 / Phase 12 CONTEXT.md D-01)
43//!
44//! Write + read route through [`WorkingMemory`] (in `lunaris::primitives`). The
45//! `content: String` is wrapped as `serde_json::Value::String(...)` on write
46//! and unwrapped on read — preserving the v0.1.0 caller surface byte-for-byte
47//! while routing every mutation through the Phase 9 primitive. Consolidator
48//! promotion is a separate operator-level concern toggled via
49//! `ConsolidatorPipelineHandle::enable_for_scope("helios:fs/")` (Plan 12-02);
50//! NO `pub fn consolidate` is added to this type.
51
52#![forbid(unsafe_code)]
53
54use std::sync::Arc;
55
56use bytes::Bytes;
57use futures::StreamExt;
58use lunaris_core::storage::types::{Filter, Lsn};
59use lunaris_core::{Hlc, LunarisError, Scope, StorageError};
60use lunaris_retrieve::Hit;
61
62use crate::forget::{ForgetReceipt, ForgetTarget, ScopeSpec};
63use crate::handle::Lunaris;
64use crate::primitives::WorkingMemory;
65
66/// helios-rfc §5.3 source-prefix convention — frozen for v0.
67const HELIOS_PREFIX: &str = "helios:fs/";
68
69/// **≤50 LOC public surface** (HELIOS-01 contract). Nine methods on
70/// `CodingSessionMemory` + [`AsOfScratchpad::read`] = 10 public symbols total.
71///
72/// v2 — delegates to [`WorkingMemory`] per HELIOS-03 / CONTEXT.md D-01.
73///
74/// `Clone` is cheap — all fields are `Arc` / `String` / `WorkingMemory`
75/// (which is itself `Arc<Lunaris>` + `String`).
76#[derive(Clone)]
77pub struct CodingSessionMemory {
78    lunaris: Arc<Lunaris>,
79    /// RFC 0001 partition key. Threaded into the inner [`WorkingMemory`] and
80    /// into every direct `StoragePort` call (e.g., `ls`'s `scan_range`).
81    scope: Scope,
82    /// Full prefix including session id, e.g. `"helios:fs/session-42/"`.
83    session_prefix: String,
84    /// Phase 9 primitive handling write / read scoping. Owns its own
85    /// `Arc<Lunaris>` clone + the identical `session_prefix`.
86    wm: WorkingMemory,
87}
88
89impl CodingSessionMemory {
90    /// Construct a new scratchpad bound to `scope` (RFC 0001 partition key)
91    /// and `session_id`. The session prefix becomes
92    /// `helios:fs/<session_id>/` — every write/read/edit/grep/ls operation
93    /// scopes through it on the source field, while `scope` partitions the
94    /// underlying KV / FT keyspace.
95    pub fn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self {
96        let session_prefix = format!("{HELIOS_PREFIX}{session_id}/");
97        let wm = WorkingMemory::new(lunaris.clone(), scope.clone(), session_prefix.clone());
98        Self { lunaris, scope, session_prefix, wm }
99    }
100
101    /// Write `content` to `path`. Delegates to [`WorkingMemory::write`] with the
102    /// content wrapped as `Value::String`. The Phase 9 primitive routes through
103    /// `Lunaris::ingest` — the single `atomic_write` invariant (INGEST-04) is
104    /// preserved, with exactly one level of indirection added.
105    pub async fn write(&self, path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError> {
106        self.wm.write(path, serde_json::Value::String(content.into())).await
107    }
108
109    /// [`Self::write`] with the content's real-world date stamped as
110    /// [`lunaris_core::Episode::t_ref`] — see [`WorkingMemory::write_dated`].
111    pub async fn write_dated(
112        &self,
113        path: &str,
114        content: impl Into<String>,
115        t_ref: chrono::DateTime<chrono::Utc>,
116    ) -> Result<Lsn, LunarisError> {
117        self.wm.write_dated(path, serde_json::Value::String(content.into()), t_ref).await
118    }
119
120    /// Read the latest content at `path`. Delegates to [`WorkingMemory::read`]
121    /// and unwraps the `Value::String` back into the caller's `String` — the
122    /// byte-for-byte-preserving inverse of [`Self::write`]. Non-`String`
123    /// variants raise `LunarisError::Storage(Backend(...))` (T-12-01-02
124    /// mitigation — refuses to decode ambiguous payloads).
125    pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
126        match self.wm.read(path).await? {
127            Some(serde_json::Value::String(s)) => Ok(Some(s)),
128            Some(_) => Err(LunarisError::Storage(StorageError::Backend(
129                "coding_session_memory_read_unexpected_json_shape".into(),
130            ))),
131            // F42 — there is no second reconstruction path any more. The old
132            // fallback existed because "a single-shot Value::String lookup
133            // misses the case where the chunker emitted multiple chunks",
134            // which was never true: `WorkingMemory::read` recovers the WHOLE
135            // value from the parent Episode payload, so chunk count is
136            // irrelevant to it. What the fallback actually did was concatenate
137            // `Hit::text` — smart-punctuation-rewritten chunk text — across
138            // every version of the path, so it answered with mangled, stale
139            // content in the one case it was reached. `None` is the honest
140            // answer when the episode row is gone.
141            None => Ok(None),
142        }
143    }
144
145    /// Replace the contents at `path` with `new`. `_old` is accepted for the
146    /// helios-rfc Read/Edit surface symmetry but intentionally unused —
147    /// Plan 04-04's `apply_supersede` stamps the prior version's `bt.sys[1]`
148    /// when the new ingest commits. NO new mutation code lives here (D-15).
149    pub async fn edit(&self, path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError> {
150        self.write(path, new).await
151    }
152
153    /// Hybrid retrieval (`Vector + Keyword(BM25) + RRF + rerank` defaults per
154    /// [`Lunaris::recall`]) scoped to the `helios:fs/<sid>/` prefix via
155    /// [`Filter::StartsWith`] — NEVER a SQL wildcard fragment (T-12-01-01
156    /// mitigation against crafted session_id escape).
157    ///
158    /// NOTE (delegation strategy): `grep` stays on the direct recall path
159    /// rather than forwarding to `WorkingMemory::grep` because `Hit` exposes
160    /// the rerank score / metadata columns the Helios caller consumes;
161    /// `WorkingMemory::grep` reshapes hits into `(source, Value)` tuples and
162    /// would force an `Arc<Hit>` round-trip. "Delegation in spirit" is
163    /// preserved: the same `StartsWith` filter + fused recall plan the
164    /// primitive uses.
165    pub async fn grep(&self, pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError> {
166        let filter =
167            Filter::StartsWith { field: "source".into(), prefix: self.session_prefix.clone() };
168        let builder = self.lunaris.recall_with_degraded_check().await?;
169        builder.filter(filter).top(k).execute(lunaris_retrieve::Query::text(pattern)).await
170    }
171
172    /// List unique stored `path`s under the optional sub-`prefix`. Walks
173    /// `StoragePort::scan_range` over `episode:` keys and strips the
174    /// `session_prefix` tail. Unchanged from v0.1.0 — `WorkingMemory` exposes
175    /// no equivalent primitive so the direct `StoragePort` path is retained.
176    pub async fn ls(&self, prefix: Option<&str>) -> Result<Vec<String>, LunarisError> {
177        let key_prefix: &[u8] = b"episode:";
178        let storage = self.lunaris.storage();
179        let mut stream = storage
180            .scan_range(&self.scope, key_prefix, None)
181            .await
182            .map_err(LunarisError::Storage)?;
183        let target_prefix = match prefix {
184            Some(p) => format!("{}{}", self.session_prefix, p),
185            None => self.session_prefix.clone(),
186        };
187        let mut paths: Vec<String> = Vec::new();
188        while let Some(item) = stream.next().await {
189            let (_k, v): (Bytes, Bytes) = item.map_err(LunarisError::Storage)?;
190            // Best-effort — payloads that fail to parse as Episode JSON are
191            // skipped; other key namespaces under `episode:` would be a bug
192            // in the writer, but keep `ls` resilient.
193            let Ok(json) = serde_json::from_slice::<serde_json::Value>(&v) else {
194                continue;
195            };
196            let Some(source) = json.get("source").and_then(|s| s.as_str()) else {
197                continue;
198            };
199            if let Some(rel) = source.strip_prefix(&target_prefix) {
200                let mut full = String::with_capacity(target_prefix.len() + rel.len());
201                if let Some(tail) = source.strip_prefix(&self.session_prefix) {
202                    full.push_str(tail);
203                } else {
204                    full.push_str(rel);
205                }
206                paths.push(full);
207            }
208        }
209        paths.sort();
210        paths.dedup();
211        Ok(paths)
212    }
213
214    /// GDPR-style purge of every primitive under the session prefix. Plan 04-05
215    /// `BySource` prefix-match path; soft-delete by default. Production callers
216    /// requiring hard delete go through the umbrella
217    /// [`Lunaris::confirm_hard_forget`] two-step rail (D-21).
218    pub async fn forget(&self) -> Result<ForgetReceipt, LunarisError> {
219        // P0 #1 Wave 2: CodingSessionMemory still routes through the deprecated
220        // bare `Lunaris::forget` path because the recipe does not yet carry
221        // an explicit `Scope` field. Wave 2 recipe-ctor migration adds that
222        // (tracked in docs/v0.3-known-debt.md alongside the WorkingMemory
223        // /  MessageStream / DocumentCorpus ctor work).
224        #[allow(deprecated)]
225        self.lunaris
226            .forget(ForgetTarget::Scope(ScopeSpec::BySource(self.session_prefix.clone())))
227            .await
228    }
229
230    /// Borrowed time-travel view per helios-rfc §5.3. `pad.as_of(ts).read(path)`
231    /// returns the content as it existed at `ts` (uses
232    /// `RetrievalBuilder::as_of(ts)` under the hood).
233    pub fn as_of(&self, ts: Hlc) -> AsOfScratchpad<'_> {
234        AsOfScratchpad { inner: self, ts }
235    }
236}
237
238/// Deprecated alias for [`CodingSessionMemory`].
239///
240/// Use `CodingSessionMemory` instead. `HeliosScratchpad` will be removed in v0.7.
241#[deprecated(
242    since = "0.5.0",
243    note = "use CodingSessionMemory; HeliosScratchpad will be removed in v0.7"
244)]
245pub type HeliosScratchpad = CodingSessionMemory;
246
247/// Borrowed time-travel view returned by [`CodingSessionMemory::as_of`].
248///
249/// Held as a borrow (not a clone) so the time-travel query cannot outlive the
250/// scratchpad — keeps the surface small (no `Clone` / `Send` requirement at the
251/// AsOf layer; the scratchpad already provides those).
252pub struct AsOfScratchpad<'a> {
253    inner: &'a CodingSessionMemory,
254    ts: Hlc,
255}
256
257impl AsOfScratchpad<'_> {
258    /// Time-travel read. Same shape as [`CodingSessionMemory::read`] but seeds the
259    /// retrieval `as_of` with this view's fixed timestamp.
260    pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
261        // F42 — same shape as `CodingSessionMemory::read`, one `as_of` apart.
262        // Both now go through the single `WorkingMemory` read, which recovers
263        // the value VERBATIM from the parent Episode instead of rebuilding it
264        // from lossy chunk text, and resolves to ONE version instead of gluing
265        // superseded bodies together.
266        match self.inner.wm.read_at(path, Some(self.ts)).await? {
267            Some(serde_json::Value::String(s)) => Ok(Some(s)),
268            Some(_) => Err(LunarisError::Storage(StorageError::Backend(
269                "coding_session_memory_as_of_read_unexpected_json_shape".into(),
270            ))),
271            None => Ok(None),
272        }
273    }
274}
275
276// ---------------------------------------------------------------------------
277// Internal helpers (kept private; do NOT count toward the ≤50 LOC contract)
278// ---------------------------------------------------------------------------
279
280// ---------------------------------------------------------------------------
281// Tests
282// ---------------------------------------------------------------------------
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// HELIOS-01 ≤50-LOC public-surface invariant. Counts `pub fn` and
289    /// `pub async fn` declarations in the production portion of the source
290    /// file (everything BEFORE the `#[cfg(test)]` marker — the test module's
291    /// literal-string mentions of `"pub fn"` are excluded by truncating at
292    /// that boundary). The cap is **10** symbols total: 9 methods on
293    /// [`CodingSessionMemory`] (incl. `write_dated`, added for Mechanism-B
294    /// session-date grounding 2026-07-29) + 1 on [`AsOfScratchpad`]. Adjust
295    /// ONLY alongside an HELIOS-* requirement update.
296    #[test]
297    fn coding_session_memory_public_surface_under_50_loc() {
298        let src = include_str!("./coding_session_memory.rs");
299        let production = src.split("#[cfg(test)]").next().unwrap_or(src);
300        let pub_fns = production.matches("    pub fn ").count()
301            + production.matches("    pub async fn ").count();
302        assert!(
303            pub_fns <= 10,
304            "HELIOS-01 ≤50-LOC contract: CodingSessionMemory+AsOfScratchpad have {pub_fns} pub fns; cap is 10 (9 methods on CodingSessionMemory incl. write_dated [Mechanism B session-date grounding, 2026-07-29] + AsOfScratchpad::read)"
305        );
306        assert!(
307            pub_fns >= 10,
308            "HELIOS-01 contract: expected exactly 10 public methods (9 on CodingSessionMemory + AsOfScratchpad::read); got {pub_fns} — did the public surface shrink?"
309        );
310    }
311
312    /// Source-prefix convention check. Doesn't construct a real `Lunaris`
313    /// (which would need a backend) — exercises the pure prefix-building path
314    /// shared by every public method.
315    #[test]
316    fn new_constructs_session_prefix_format() {
317        let prefix = format!("{HELIOS_PREFIX}{}/", "session-42");
318        assert_eq!(prefix, "helios:fs/session-42/");
319    }
320
321    /// Basic constant sanity — guards against an accidental rename of the
322    /// helios-rfc §5.3 prefix (any change here ripples through every Helios
323    /// consumer).
324    #[test]
325    fn helios_prefix_constant_is_stable() {
326        assert_eq!(HELIOS_PREFIX, "helios:fs/");
327    }
328
329    /// Plan 12-01 T-12-01-01 mitigation regression guard — this file MUST NOT
330    /// contain any SQL wildcard fragments (session_id → filter escape vector).
331    /// The banned keyword is built at runtime from its char codes so neither
332    /// this test nor its error string contains the literal substring — that
333    /// way the plan-spec raw `grep` gate on the uppercase keyword returns 0
334    /// across the whole file, and the guard never self-trips on its own doc
335    /// comments.
336    #[test]
337    fn coding_session_memory_contains_no_sql_wildcard_fragment() {
338        let src = include_str!("./coding_session_memory.rs");
339        let production = src.split("#[cfg(test)]").next().unwrap_or(src);
340        // Build the banned uppercase SQL keyword out of chars so the literal
341        // does not appear verbatim in this file.
342        let banned: String = ['L', 'I', 'K', 'E'].iter().collect();
343        assert!(
344            !production.contains(&banned),
345            "T-12-01-01: SQL wildcard fragment found in production portion of coding_session_memory.rs — use Filter::StartsWith instead"
346        );
347    }
348}