#![forbid(unsafe_code)]
use std::sync::Arc;
use bytes::Bytes;
use futures::StreamExt;
use lunaris_core::storage::types::{Filter, Lsn};
use lunaris_core::{Hlc, LunarisError, Scope, StorageError};
use lunaris_retrieve::Hit;
use crate::forget::{ForgetReceipt, ForgetTarget, ScopeSpec};
use crate::handle::Lunaris;
use crate::primitives::WorkingMemory;
const HELIOS_PREFIX: &str = "helios:fs/";
const READ_TOP: usize = 8;
#[derive(Clone)]
pub struct CodingSessionMemory {
lunaris: Arc<Lunaris>,
scope: Scope,
session_prefix: String,
wm: WorkingMemory,
}
impl CodingSessionMemory {
pub fn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self {
let session_prefix = format!("{HELIOS_PREFIX}{session_id}/");
let wm = WorkingMemory::new(lunaris.clone(), scope.clone(), session_prefix.clone());
Self { lunaris, scope, session_prefix, wm }
}
pub async fn write(&self, path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError> {
self.wm.write(path, serde_json::Value::String(content.into())).await
}
pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
match self.wm.read(path).await? {
Some(serde_json::Value::String(s)) => Ok(Some(s)),
Some(_) => Err(LunarisError::Storage(StorageError::Backend(
"coding_session_memory_read_unexpected_json_shape".into(),
))),
None => {
let source = format!("{}{}", self.session_prefix, path);
read_at(&self.lunaris, &source, path, None).await
}
}
}
pub async fn edit(&self, path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError> {
self.write(path, new).await
}
pub async fn grep(&self, pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError> {
let filter =
Filter::StartsWith { field: "source".into(), prefix: self.session_prefix.clone() };
let builder = self.lunaris.recall_with_degraded_check().await?;
builder.filter(filter).top(k).execute(lunaris_retrieve::Query::text(pattern)).await
}
pub async fn ls(&self, prefix: Option<&str>) -> Result<Vec<String>, LunarisError> {
let key_prefix: &[u8] = b"episode:";
let storage = self.lunaris.storage();
let mut stream = storage
.scan_range(&self.scope, key_prefix, None)
.await
.map_err(LunarisError::Storage)?;
let target_prefix = match prefix {
Some(p) => format!("{}{}", self.session_prefix, p),
None => self.session_prefix.clone(),
};
let mut paths: Vec<String> = Vec::new();
while let Some(item) = stream.next().await {
let (_k, v): (Bytes, Bytes) = item.map_err(LunarisError::Storage)?;
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&v) else {
continue;
};
let Some(source) = json.get("source").and_then(|s| s.as_str()) else {
continue;
};
if let Some(rel) = source.strip_prefix(&target_prefix) {
let mut full = String::with_capacity(target_prefix.len() + rel.len());
if let Some(tail) = source.strip_prefix(&self.session_prefix) {
full.push_str(tail);
} else {
full.push_str(rel);
}
paths.push(full);
}
}
paths.sort();
paths.dedup();
Ok(paths)
}
pub async fn forget(&self) -> Result<ForgetReceipt, LunarisError> {
#[allow(deprecated)]
self.lunaris
.forget(ForgetTarget::Scope(ScopeSpec::BySource(self.session_prefix.clone())))
.await
}
pub fn as_of(&self, ts: Hlc) -> AsOfScratchpad<'_> {
AsOfScratchpad { inner: self, ts }
}
}
#[deprecated(
since = "0.5.0",
note = "use CodingSessionMemory; HeliosScratchpad will be removed in v0.7"
)]
pub type HeliosScratchpad = CodingSessionMemory;
pub struct AsOfScratchpad<'a> {
inner: &'a CodingSessionMemory,
ts: Hlc,
}
impl AsOfScratchpad<'_> {
pub async fn read(&self, path: &str) -> Result<Option<String>, LunarisError> {
let source = format!("{}{}", self.inner.session_prefix, path);
read_at(&self.inner.lunaris, &source, path, Some(self.ts)).await
}
}
async fn read_at(
lunaris: &Arc<Lunaris>,
source: &str,
query_text: &str,
as_of: Option<Hlc>,
) -> Result<Option<String>, LunarisError> {
let filter = Filter::StartsWith { field: "source".into(), prefix: source.to_string() };
let mut builder = lunaris
.recall_with_degraded_check()
.await?
.filter(filter)
.top(READ_TOP)
.with_initial_degraded(false);
if let Some(ts) = as_of {
builder = builder.as_of(ts);
}
let hits = builder.execute(lunaris_retrieve::Query::text(query_text)).await?;
if hits.is_empty() {
return Ok(None);
}
let mut text = String::new();
for h in &hits {
text.push_str(&h.text);
}
Ok(Some(text))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coding_session_memory_public_surface_under_50_loc() {
let src = include_str!("./coding_session_memory.rs");
let production = src.split("#[cfg(test)]").next().unwrap_or(src);
let pub_fns = production.matches(" pub fn ").count()
+ production.matches(" pub async fn ").count();
assert!(
pub_fns <= 9,
"HELIOS-01 ≤50-LOC contract: CodingSessionMemory+AsOfScratchpad have {pub_fns} pub fns; cap is 9 (8 methods on CodingSessionMemory + AsOfScratchpad::read)"
);
assert!(
pub_fns >= 9,
"HELIOS-01 contract: expected exactly 9 public methods (8 on CodingSessionMemory + AsOfScratchpad::read); got {pub_fns} — did the public surface shrink?"
);
}
#[test]
fn new_constructs_session_prefix_format() {
let prefix = format!("{HELIOS_PREFIX}{}/", "session-42");
assert_eq!(prefix, "helios:fs/session-42/");
}
#[test]
fn helios_prefix_constant_is_stable() {
assert_eq!(HELIOS_PREFIX, "helios:fs/");
}
#[test]
fn coding_session_memory_contains_no_sql_wildcard_fragment() {
let src = include_str!("./coding_session_memory.rs");
let production = src.split("#[cfg(test)]").next().unwrap_or(src);
let banned: String = ['L', 'I', 'K', 'E'].iter().collect();
assert!(
!production.contains(&banned),
"T-12-01-01: SQL wildcard fragment found in production portion of coding_session_memory.rs — use Filter::StartsWith instead"
);
}
}