///|
pub enum Target {
Memory
User
}
///|
pub enum AddResult {
AddOk(Int, Int) // (used_chars, max_chars)
AddErr(String, String) // (error_message, error_code)
}
///|
pub enum OpResult {
OpOk(Int, Int) // (used_chars, max_chars)
OpErr(String, String) // (error_message, error_code)
}
// --- Filesystem ops ---
//
// Core reads / writes / exists / mkdir -p are backed by the cross-target
// moonbitlang/x/fs. The atomic-write path still reaches into node:fs
// because we need fsync + rename guarantees that x/fs doesn't surface; a
// future native stub can fill that in. For now atomic write is
// target-gated to js (see moon.pkg) and native users get best-effort
// write_string_to_file instead.
///| Exists check, cross-target.
fn ffi_exists(path : String) -> Bool {
@fs.path_exists(path)
}
///| Read as UTF-8 string, returning "" on any error (consistent with the
///| previous JS semantics — missing files are treated as empty).
fn ffi_read_file(path : String) -> String {
(try? @fs.read_file_to_string(path)).unwrap_or("")
}
///| `mkdir -p`: walk the path and create intermediate dirs. Built on
///| moonbitlang/x/fs.create_dir which is not recursive.
fn ffi_mkdirp(path : String) -> Unit {
if @fs.path_exists(path) {
return
}
// Recurse on the parent first; ignore errors (already-exists etc).
let mut last = -1
let p = path.view()
let mut i = 0
while i < p.length() {
if p.get_char(i) is Some('/') {
last = i
}
i = i + 1
}
if last > 0 {
let parent = p[0:last].to_string()
ffi_mkdirp(parent)
}
let _ = try? @fs.create_dir(path)
}
///| Join two path segments with "/". Not full POSIX-correct (no
///| normalization), but sufficient for the in-repo paths we build.
fn ffi_path_join(a : String, b : String) -> String {
if a.is_empty() {
b
} else if a.has_suffix("/") {
a + b
} else {
a + "/" + b
}
}
///| Atomic write primitive: tmp + write + fsync + rename, with tmp
///| cleanup on any failure. Delegates to a per-target implementation
///| in `memory_store_js.mbt` (Node fs.openSync + fsyncSync + renameSync)
///| or `memory_store_native.mbt` (C stub in native_stub.c). Callers
///| here stay target-agnostic.
fn ffi_write_file(path : String, content : String) -> Unit {
ffi_atomic_write_impl(path, content)
}
// --- Internal helpers ---
///|
fn target_filename(target : Target) -> String {
match target {
Memory => "MEMORY.md"
User => "USER.md"
}
}
///|
fn read_entries(path : String) -> Array[String] {
if !ffi_exists(path) {
return []
}
let raw = ffi_read_file(path)
if raw.is_empty() {
return []
}
raw
.split(delim)
.filter(fn(s) { !s.is_empty() })
.map(fn(sv) { sv.to_string() })
.to_array()
}
// --- MemoryStore struct ---
///|
pub struct MemoryStore {
root_dir : String
mut entries_memory : Array[String]
mut entries_user : Array[String]
snapshot_memory : String
snapshot_user : String
max_memory : Int
max_user : Int
}
///| Load (or create) a MemoryStore from the given root directory.
pub fn MemoryStore::load(
root_dir : String,
max_memory~ : Int = 2200,
max_user~ : Int = 1375
) -> MemoryStore {
let mem_dir = ffi_path_join(root_dir, "memories")
ffi_mkdirp(mem_dir)
// No pre-creation of .lock: O_EXCL strategy requires the file to be absent
// at acquire time. Pre-creating it would prevent with_lock from ever acquiring.
let entries_memory = read_entries(
ffi_path_join(mem_dir, target_filename(Memory)),
)
let entries_user = read_entries(
ffi_path_join(mem_dir, target_filename(User)),
)
let snapshot_memory = render_block("memory", entries_memory, max_memory)
let snapshot_user = render_block("user", entries_user, max_user)
{
root_dir,
entries_memory,
entries_user,
snapshot_memory,
snapshot_user,
max_memory,
max_user,
}
}
///| Return a copy of entries for the given target.
pub fn MemoryStore::list(self : MemoryStore, target : Target) -> Array[String] {
match target {
Memory => self.entries_memory.copy()
User => self.entries_user.copy()
}
}
///| Return the frozen snapshot for the given target (taken at load time).
pub fn MemoryStore::snapshot(self : MemoryStore, target : Target) -> String {
match target {
Memory => self.snapshot_memory
User => self.snapshot_user
}
}
///| Add a new entry. Acquires a per-directory write lock before touching disk.
pub async fn MemoryStore::add(
self : MemoryStore,
target : Target,
content : String
) -> AddResult {
let trimmed = content.trim().to_string()
if trimmed.is_empty() {
return AddErr("empty content", "empty")
}
match scan_content(trimmed) {
ScanBlocked(reason) =>
return AddErr("blocked: \{reason}", "injection_blocked")
ScanOk => ()
}
let mem_dir = ffi_path_join(self.root_dir, "memories")
let file_path = ffi_path_join(mem_dir, target_filename(target))
let lock_path = ffi_path_join(mem_dir, ".writing")
let max = match target {
Memory => self.max_memory
User => self.max_user
}
let mut result : AddResult = AddErr("unreachable", "unreachable")
with_lock(lock_path, fn() {
let fresh = read_entries(file_path)
if fresh.contains(trimmed) {
result = AddErr("duplicate", "duplicate")
return
}
let new_entries = fresh.copy()
new_entries.push(trimmed)
let joined = new_entries.join(delim)
if joined.length() > max {
result = AddErr("budget exceeded", "budget_exceeded")
return
}
ffi_write_file(file_path, joined)
match target {
Memory => self.entries_memory = new_entries
User => self.entries_user = new_entries
}
result = AddOk(joined.length(), max)
})
result
}
///| Replace the first entry containing old_substring with new_content.
pub async fn MemoryStore::replace(
self : MemoryStore,
target : Target,
old_substring : String,
new_content : String
) -> OpResult {
let trimmed = new_content.trim().to_string()
if trimmed.is_empty() {
return OpErr("empty", "empty")
}
match scan_content(trimmed) {
ScanBlocked(reason) =>
return OpErr("blocked: \{reason}", "injection_blocked")
ScanOk => ()
}
let mem_dir = ffi_path_join(self.root_dir, "memories")
let file_path = ffi_path_join(mem_dir, target_filename(target))
let lock_path = ffi_path_join(mem_dir, ".writing")
let max = match target {
Memory => self.max_memory
User => self.max_user
}
let mut result : OpResult = OpErr("unreachable", "unreachable")
with_lock(lock_path, fn() {
let fresh = read_entries(file_path)
// find index of first entry containing old_substring
let mut idx = -1
for i, e in fresh {
if idx == -1 && e.contains(old_substring) {
idx = i
}
}
if idx == -1 {
result = OpErr("substring not found", "substring_not_found")
return
}
let replaced = fresh.copy()
replaced[idx] = trimmed
let joined = replaced.join(delim)
if joined.length() > max {
result = OpErr("budget exceeded", "budget_exceeded")
return
}
ffi_write_file(file_path, joined)
match target {
Memory => self.entries_memory = replaced
User => self.entries_user = replaced
}
result = OpOk(joined.length(), max)
})
result
}
///| Remove the single entry containing old_substring.
///| If zero entries match → substring_not_found.
///| If multiple entries match → ambiguous_match (no modification).
pub async fn MemoryStore::remove(
self : MemoryStore,
target : Target,
old_substring : String
) -> OpResult {
let mem_dir = ffi_path_join(self.root_dir, "memories")
let file_path = ffi_path_join(mem_dir, target_filename(target))
let lock_path = ffi_path_join(mem_dir, ".writing")
let max = match target {
Memory => self.max_memory
User => self.max_user
}
let mut result : OpResult = OpErr("unreachable", "unreachable")
with_lock(lock_path, fn() {
let fresh = read_entries(file_path)
let matches = fresh.filter(fn(e) { e.contains(old_substring) })
if matches.length() == 0 {
result = OpErr("substring not found", "substring_not_found")
return
}
if matches.length() > 1 {
let n = matches.length()
result = OpErr(
"\{n} entries matched the substring; remove is ambiguous",
"ambiguous_match",
)
return
}
let filtered = fresh.filter(fn(e) { !e.contains(old_substring) })
let joined = filtered.join(delim)
ffi_write_file(file_path, joined)
match target {
Memory => self.entries_memory = filtered
User => self.entries_user = filtered
}
result = OpOk(joined.length(), max)
})
result
}
// --- atomic write hardening tests ---
///| Sanity: the happy path leaves no .mem_*.tmp droppings.
test "atomic write: no tmp leak on success" {
let dir = ffi_ms_tmp_dir()
let path = dir + "/MEMORY.md"
ffi_write_file(path, "hello")
let leftovers = ffi_ms_list_tmp_droppings(dir)
assert_eq(leftovers, 0)
ffi_ms_rm_dir(dir)
}
///| On rename failure (target is a directory → EISDIR on POSIX), the tmp file
///| must be unlinked instead of accumulating under memories/.
test "atomic write: cleans up tmp when rename fails" {
let dir = ffi_ms_tmp_dir()
let collide = dir + "/MEMORY.md"
ffi_ms_mkdir(collide)
let ok = ffi_ms_try_write(collide, "hello")
assert_eq(ok, false)
let leftovers = ffi_ms_list_tmp_droppings(dir)
assert_eq(leftovers, 0)
ffi_ms_rm_dir(dir)
}
///| Monotonic counter for uniqueness across rapid consecutive calls.
let _ms_tmp_counter : Array[Int] = [0]
///| Cross-target scratch dir for inline tests — $TMPDIR (or /tmp) plus
///| a counter + ms suffix so consecutive calls in the same millisecond
///| still produce distinct paths.
fn ffi_ms_tmp_dir() -> String {
let tmp = (@env.get_env_var("TMPDIR")).unwrap_or("/tmp")
_ms_tmp_counter[0] = _ms_tmp_counter[0] + 1
let suffix = @env.now().to_string() + "-" + _ms_tmp_counter[0].to_string()
let dir = tmp + "/mnemo-ms-" + suffix
let _ = try? @fs.create_dir(dir)
dir
}
///| Best-effort recursive rmdir.
fn ffi_ms_rm_dir(path : String) -> Unit {
let _ = try? @fs.remove_dir(path)
}
///| Cross-target mkdir.
fn ffi_ms_mkdir(path : String) -> Unit {
let _ = try? @fs.create_dir(path)
}
///| Count files matching `.mem_*.tmp` in a directory.
fn ffi_ms_list_tmp_droppings(dir : String) -> Int {
let entries = try {
@fs.read_dir(dir)
} catch {
_ => return 0
}
let mut count = 0
for name in entries {
if name.has_prefix(".mem_") && name.has_suffix(".tmp") {
count = count + 1
}
}
count
}
// `ffi_ms_try_write` is declared per-target in
// memory_store_js.mbt / memory_store_native.mbt.