Helios Scratchpad
Reach for CodingSessionMemory when an agent needs a filesystem-shaped
working store — write / read / edit / grep / ls over a
session-scoped namespace — backed by Lunaris’s bi-temporal MVCC store, with
as_of time-travel for free.
CodingSessionMemory is exported by the umbrella lunaris crate (not
lunaris-recipes) — use lunaris::{CodingSessionMemory, Lunaris, Scope, Hlc};. It
was built for Helios, Lunaris’s
first downstream consumer, which replaces deepagents’ ephemeral dict-backed
mock filesystem with a real bi-temporal store. It is a convenience over the
WorkingMemory
primitive — Lunaris doesn’t know Helios exists; the recipe is not a coupling.
This chapter is the public-facing recipe summary. For the full integration story — multi-session servers, GDPR purge, graph-aware entity recall, degraded-state handling, dual-backend portability, and a production checklist — see
docs/helios-integration.md.
The frozen 9-method surface
CodingSessionMemory holds an Arc<Lunaris> + a session_prefix (e.g.
"helios:fs/session-42/") + a delegated WorkingMemory (itself
Arc<Lunaris> + String), and is Clone (every field is cheap). Its
public surface is frozen at nine symbols — the
helios_scratchpad_public_surface_under_50_loc test asserts exactly nine
at compile time, so the surface can neither grow nor shrink without an
HELIOS-* requirement update:
| # | Method | Signature |
|---|---|---|
| 1 | new | fn new(lunaris: Arc<Lunaris>, scope: Scope, session_id: &str) -> Self |
| 2 | write | async fn write(path: &str, content: impl Into<String>) -> Result<Lsn, LunarisError> |
| 3 | read | async fn read(path: &str) -> Result<Option<String>, LunarisError> |
| 4 | edit | async fn edit(path: &str, _old: &str, new: &str) -> Result<Lsn, LunarisError> |
| 5 | grep | async fn grep(pattern: &str, k: usize) -> Result<Vec<Hit>, LunarisError> |
| 6 | ls | async fn ls(prefix: Option<&str>) -> Result<Vec<String>, LunarisError> |
| 7 | forget | async fn forget() -> Result<ForgetReceipt, LunarisError> |
| 8 | as_of | fn as_of(ts: Hlc) -> AsOfScratchpad<'_> |
| 9 | AsOfScratchpad::read | async fn read(path: &str) -> Result<Option<String>, LunarisError> |
A few load-bearing facts:
newis pure — no I/O. The session prefix ishelios:fs/<session_id>/, frozen by convention. The first storage round-trip happens on the firstwrite/read/grep/ls/forget. Use aUlid(or UUIDv7) for the session id in multi-session servers — two pads with the same id co-mingle and oneforget()wipes both.write/readroute throughWorkingMemory— the contentStringis wrapped asserde_json::Value::String(...)on write and unwrapped on read.readreturnsNonefor “never written / already purged” (notSome("")); for large payloads that the chunker split,readfalls back to a multi-chunk reconstruction path that concatenates up to 8 hits.editis a plainwriteof the new content._oldis accepted for Helios’s Read/Edit symmetry but unused — MVCC supersede stamps the prior version’sbt.sys[1]automatically when the new ingest commits. No history is overwritten in place;pad.as_of(pre_edit_ts).read(path)returns the pre-edit bytes.grepis hybrid recall (Vector + Keyword(BM25) + RRF + rerankperLunaris::recalldefaults) scoped to thehelios:fs/<sid>/prefix viaFilter::StartsWith— never a SQL wildcard fragment. It surfacesHit::degradedper hit when the verifier queue is backed up; the agent UX decides what to do with that flag.forget()is soft-delete only. It lowers toForgetTarget::Scope(ScopeSpec::BySource(session_prefix))with default options — an MVCC supersede that stampsbt.sys[1]; rows are still physically present and return fromread_as_of(ts)for anytsbefore the delete. There is nopad.hard_forget()— for GDPR-irreversible purge you drop to theLunarishandle’s two-stepconfirm_hard_forgetrail. See Forgetting.as_ofreturns a borrowed, read-only view.AsOfScratchpad<'a>holds&CodingSessionMemoryso the borrow checker stops you moving the pad while a time-travel view is alive. Its only method isread(path). There is no historicalwrite/edit/grep/forget.
Everything else — graph-aware recall, dry-run forget, hard-delete
confirmation, verifier queue tuning — drops one level to the Lunaris
handle itself. The recipe is intentionally narrow.
Example — basic session lifecycle
use std::sync::Arc;
use lunaris::{CodingSessionMemory, Lunaris, Scope};
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
// One handle per process — share via Arc. URL scheme picks the backend.
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
// Session prefix becomes "helios:fs/session-42/".
let scope = Scope::new("helios")?;
let pad = CodingSessionMemory::new(lunaris.clone(), scope, "session-42");
// Write two docs.
pad.write("notes.md", "# Notes\nFirst draft.").await?;
pad.write("todo.md", "- [ ] finish draft").await?;
// Read back. `read` returns `Option<String>` — `None` means no hit.
let notes = pad.read("notes.md").await?;
assert!(notes.is_some());
// Edit — `_old` is accepted for symmetry but unused; MVCC supersedes
// the prior version automatically.
pad.edit("notes.md", "First draft.", "# Notes\nSecond draft.").await?;
// Hybrid recall over this session's namespace.
let hits = pad.grep("draft", 5).await?;
for h in &hits {
println!("source={} score={:.3} degraded={}", h.source, h.score, h.degraded);
}
// List stored paths (session-prefix stripped, sorted, deduped).
let paths = pad.ls(None).await?;
println!("session paths: {paths:?}");
// End of session: soft-delete every primitive under the session prefix.
let _receipt = pad.forget().await?;
Ok(())
}
Example — time-travel debugging
use std::sync::Arc;
use lunaris::{CodingSessionMemory, Hlc, Lunaris, Scope};
#[tokio::main]
async fn main() -> Result<(), lunaris::LunarisError> {
let lunaris = Arc::new(Lunaris::open("moon://localhost:6380").await?);
let scope = Scope::new("helios")?;
let pad = CodingSessionMemory::new(lunaris.clone(), scope, "session-42");
// t1: agent writes the first draft.
pad.write("plan.md", "Plan v1: go left").await?;
// Capture a causal timestamp at the decision point. `HlcClock::tick()`
// (via `lunaris.clock()`) is the only monotonic-now source — `Hlc::now()`
// does not exist.
let decision_hlc: Hlc = lunaris.clock().tick();
// t2: agent edits the plan.
pad.edit("plan.md", "Plan v1: go left", "Plan v2: go right").await?;
// The live read sees the latest version.
let latest = pad.read("plan.md").await?;
assert_eq!(latest.as_deref(), Some("Plan v2: go right"));
// ... but the time-travel view reads the state as-of t1.
let as_of_view = pad.as_of(decision_hlc);
let historical = as_of_view.read("plan.md").await?;
assert_eq!(historical.as_deref(), Some("Plan v1: go left"));
Ok(())
}
Notes
- Resuming a session needs no load step.
CodingSessionMemory::new(handle, session_id)is pure (no I/O) — it just builds the"helios:fs/<session_id>/"prefix. A later process that reconstructs the pad with the samesession_idsees every priorwrite/edit(the data lives in the durable backend keyspacelunaris:{scope}:{kind}:{ulid}); apad.as_of(ts)view still reads any historical state. Same id ⇒ same pad — which is also why two pads with the same id co-mingle. CodingSessionMemoryis the recipe;WorkingMemoryis the primitive. If you want a JSON-valued scratchpad rather than a string-valued filesystem, useWorkingMemorydirectly. If you want consolidator promotion of hot notes, that is toggled per-scope on the consolidator pipeline (lunaris.consolidator_pipeline()...) —CodingSessionMemoryitself adds noconsolidatemethod.- Backend —
moon://host:portis the only selector as of 0.7.0. The latency budget is Moon recall p50 ≤ 25 ms; see The Storage Backend. - For everything beyond the basics — multi-session servers, hard
delete, graph-aware entity recall, degraded-state handling, the
production checklist — read
docs/helios-integration.md.