memory: reshape the two stores into a maintained wiki
Nightly Build / build (push) Successful in 6m50s
Nightly Build / build (push) Successful in 6m50s
Memory was a scrapbook: notes accumulated, nothing kept them consistent, and shared memory had no rule saying what belonged in it. This adopts the LLM-wiki pattern — the assistant maintains an evolving artifact rather than re-deriving knowledge each session. The schema (agents/common/memory-wiki.md, included by the three type:chat agents) adds an append-only log.md beside each index.md, names Ingest / Recall / Lint as habits, and states the rule for shared memory: write it there only if you would say it out loud with every member in the room. One person's health, results or another member's opinion of them stays private — that is what shared folders, not shared memory, are for. Tampering is the reason the rules are shaped this way. Shared facts carry provenance and are superseded rather than erased, and a member who contradicts a fact they did not write gets a logged CLAIM instead of an overwrite: only the originator or an admin can turn it into a change. The approval gate cannot enforce this — it asks the caller, who is the same person pushing — so a per-role write permission on shared memory is still the real boundary. The prompt is the etiquette, not the fence. append_file is a new fs tool because the log needs a write that cannot shorten a file. On memory paths it is one SQL statement, so concurrent appends (parallel tool batches, two sessions of one user) cannot lose a line — a dropped line in an audit trail is worse than a failed write. It is auto-allowed on shared-memory/log.md at a lower priority than the shared write rule: gating the trail would be friction with no safety, and a rejected log write yields an unlogged change. memory::scaffold seeds index.md / log.md / user.md so the schema does not describe files that do not exist. Seeded empty rather than left absent: a missing note resolves to nothing at injection, so the model cannot tell "nothing recorded yet" from "this mechanism is not running". __MEMBERS__ renders the roster from users + roles instead of a note the model maintains. A remembered copy drifts and can be talked into being edited; this one bypasses the model entirely. users.notes are excluded — they are the admin's private notes about a person and this block is visible to every member. Still open: a UI to browse memory (list_dir does not classify memory paths yet), a tool-written revisions table (log lines are still typed by the model), and the periodic lint pass.
This commit is contained in:
@@ -77,6 +77,44 @@ pub async fn upsert(pool: &SqlitePool, path: &str, content: &str) -> Result<Memo
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Append `content` to the note at `path`, creating it if absent. Returns the
|
||||
/// stored row.
|
||||
///
|
||||
/// **A single statement, so it is atomic** — unlike a read-modify-write through
|
||||
/// [`get`] + [`upsert`], two concurrent appends cannot lose one another's text.
|
||||
/// That is the whole point of this accessor: the append-only `log.md` of each
|
||||
/// memory store is an audit trail, and a silently dropped line there is worse
|
||||
/// than a failed write. Parallel tool batches (and two sessions of the same
|
||||
/// user) do append concurrently.
|
||||
///
|
||||
/// Line-oriented by construction: a newline is inserted first when the existing
|
||||
/// note does not already end with one, so the caller never has to know whether
|
||||
/// the file ends cleanly. The `AFTER UPDATE` trigger re-indexes FTS.
|
||||
pub async fn append(pool: &SqlitePool, path: &str, content: &str) -> Result<MemoryDoc> {
|
||||
sqlx::query(
|
||||
"INSERT INTO memory_docs (path, content)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
content = CASE
|
||||
WHEN memory_docs.content = ''
|
||||
OR substr(memory_docs.content, -1, 1) = char(10)
|
||||
THEN memory_docs.content
|
||||
ELSE memory_docs.content || char(10)
|
||||
END || excluded.content,
|
||||
updated_at = datetime('now')",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(content)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let row = sqlx::query_as::<_, MemoryDoc>(sqlx::AssertSqlSafe(format!("{SELECT} WHERE path = ?")))
|
||||
.bind(path)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// List notes whose path starts with `prefix` (pass `""` for all), most recently
|
||||
/// edited first. Metadata only — the `content` body is not loaded.
|
||||
pub async fn list(pool: &SqlitePool, prefix: &str) -> Result<Vec<MemoryEntry>> {
|
||||
@@ -210,6 +248,64 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn append_creates_then_adds_lines_and_never_glues_them() {
|
||||
let (pool, dir) = owner_pool("append").await;
|
||||
|
||||
// Absent note: append creates it.
|
||||
let doc = append(&pool, "log.md", "2026-07-26 | ADD | anna | casa.md | created\n").await.unwrap();
|
||||
assert_eq!(doc.content, "2026-07-26 | ADD | anna | casa.md | created\n");
|
||||
|
||||
// Existing note ending in a newline: no extra blank line.
|
||||
append(&pool, "log.md", "2026-07-26 | UPDATE | anna | casa.md | wifi\n").await.unwrap();
|
||||
let content = get(&pool, "log.md").await.unwrap().unwrap().content;
|
||||
assert_eq!(content.lines().count(), 2, "no blank line between appends");
|
||||
|
||||
// Existing note NOT ending in a newline: a separator is inserted, so the
|
||||
// two lines never glue together.
|
||||
upsert(&pool, "ragged.md", "first").await.unwrap();
|
||||
append(&pool, "ragged.md", "second\n").await.unwrap();
|
||||
assert_eq!(get(&pool, "ragged.md").await.unwrap().unwrap().content, "first\nsecond\n");
|
||||
|
||||
// An empty note gets no leading newline.
|
||||
upsert(&pool, "empty.md", "").await.unwrap();
|
||||
append(&pool, "empty.md", "only\n").await.unwrap();
|
||||
assert_eq!(get(&pool, "empty.md").await.unwrap().unwrap().content, "only\n");
|
||||
|
||||
// FTS follows an append (the AFTER UPDATE trigger re-indexes).
|
||||
assert!(search(&pool, "wifi", 10).await.unwrap().iter().any(|h| h.path == "log.md"));
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The reason `append` is one statement rather than get + upsert: concurrent
|
||||
/// appends to the audit log must not lose a line.
|
||||
#[tokio::test]
|
||||
async fn concurrent_appends_lose_nothing() {
|
||||
let (pool, dir) = owner_pool("append-race").await;
|
||||
upsert(&pool, "log.md", "").await.unwrap();
|
||||
|
||||
const N: usize = 40;
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for i in 0..N {
|
||||
let pool = pool.clone();
|
||||
set.spawn(async move { append(&pool, "log.md", &format!("line {i}\n")).await });
|
||||
}
|
||||
while let Some(r) = set.join_next().await {
|
||||
r.unwrap().unwrap();
|
||||
}
|
||||
|
||||
let content = get(&pool, "log.md").await.unwrap().unwrap().content;
|
||||
assert_eq!(content.lines().count(), N, "every concurrent append must survive");
|
||||
for i in 0..N {
|
||||
assert!(content.contains(&format!("line {i}\n")), "lost line {i}");
|
||||
}
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_by_prefix_and_delete_deindexes() {
|
||||
let (pool, dir) = owner_pool("list").await;
|
||||
|
||||
Reference in New Issue
Block a user