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:
@@ -336,6 +336,9 @@ impl ApprovalManager {
|
||||
/// - `shared-memory/*` → reads **allow** (`@fs_read`), writes **require** (`@fs_write`):
|
||||
/// shared memory is visible to everyone, so a write is a deliberate, human-confirmed
|
||||
/// act — the agent must not silently push one person's information into it.
|
||||
/// - `shared-memory/log.md` + `append_file` → **allow**, at a lower priority number so it
|
||||
/// is evaluated first: the audit trail must always be writable, and `append_file` is
|
||||
/// the one write tool that cannot shorten a file.
|
||||
/// - `data/*` → **allow** (scratch/data workspace).
|
||||
/// - `memory_search` → **allow**, path-less: it searches note *content* (arg `query`,
|
||||
/// not `path`), so it needs a tool-scoped rule rather than a path pattern.
|
||||
@@ -347,22 +350,31 @@ impl ApprovalManager {
|
||||
/// stopped meaning "the box's credential store" and started meaning "any folder a
|
||||
/// user dared name `secrets`".
|
||||
pub async fn seed_fs_path_rules(&self) -> Result<()> {
|
||||
// (tool_pattern, path_pattern, action, note). `path_pattern = None` is a
|
||||
// tool-scoped rule that matches regardless of args.
|
||||
let rules: &[(&str, Option<&str>, &str, &str)] = &[
|
||||
("@fs_any", Some("user-memory/*"), "allow", "auto-allow user-memory/"),
|
||||
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/"),
|
||||
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/"),
|
||||
("@fs_any", Some("data/*"), "allow", "auto-allow data/"),
|
||||
// (tool_pattern, path_pattern, action, note, priority). `path_pattern = None`
|
||||
// is a tool-scoped rule that matches regardless of args. Priority 5 unless a
|
||||
// rule must be evaluated *before* a broader sibling — lower number wins.
|
||||
let rules: &[(&str, Option<&str>, &str, &str, i64)] = &[
|
||||
("@fs_any", Some("user-memory/*"), "allow", "auto-allow user-memory/", 5),
|
||||
// Ahead of the `shared-memory/*` write rule below: `log.md` is the
|
||||
// append-only audit trail of shared memory, and `append_file` is the one
|
||||
// write tool that cannot shorten a file. Gating it would be friction with
|
||||
// no safety — worse, a rejected log write yields an *unlogged* change,
|
||||
// which is exactly the failure the trail exists to prevent. Every other
|
||||
// shared write, and every other tool on `log.md`, still falls through to
|
||||
// `require`.
|
||||
("append_file", Some("shared-memory/log.md"), "allow", "auto-allow shared-memory audit log", 4),
|
||||
("@fs_read", Some("shared-memory/*"), "allow", "auto-allow read shared-memory/", 5),
|
||||
("@fs_write", Some("shared-memory/*"), "require", "require write shared-memory/", 5),
|
||||
("@fs_any", Some("data/*"), "allow", "auto-allow data/", 5),
|
||||
// Project folders (`projects/{owner}/{slug}`, blueprint §6): reads + writes
|
||||
// frictionless, matching the working-project UX. A read-only member's mount
|
||||
// is `:ro`, so a write physically fails regardless of this allow.
|
||||
("@fs_any", Some("projects/*"), "allow", "auto-allow projects/"),
|
||||
("memory_search", None, "allow", "allow memory_search"),
|
||||
("@fs_any", Some("projects/*"), "allow", "auto-allow projects/", 5),
|
||||
("memory_search", None, "allow", "allow memory_search", 5),
|
||||
];
|
||||
|
||||
let mut seeded = 0;
|
||||
for &(tool_pattern, path_pattern, action, note) in rules {
|
||||
for &(tool_pattern, path_pattern, action, note, priority) in rules {
|
||||
// A NULL path can't be matched with `=` (NULL comparisons are never true),
|
||||
// so the existence check branches on it — otherwise the row would re-insert
|
||||
// on every boot.
|
||||
@@ -388,12 +400,13 @@ impl ApprovalManager {
|
||||
}
|
||||
sqlx::query(
|
||||
"INSERT INTO approval_rules (tool_pattern, path_pattern, action, note, priority, group_id)
|
||||
VALUES (?, ?, ?, ?, 5, 'default')",
|
||||
VALUES (?, ?, ?, ?, ?, 'default')",
|
||||
)
|
||||
.bind(tool_pattern)
|
||||
.bind(path_pattern) // Option<&str> → NULL when None
|
||||
.bind(action)
|
||||
.bind(note)
|
||||
.bind(priority)
|
||||
.execute(self.db.as_ref())
|
||||
.await?;
|
||||
seeded += 1;
|
||||
@@ -1194,6 +1207,17 @@ mod tests {
|
||||
assert!(matches!(decide(&mgr, "read_file", "shared-memory/casa.md").await, GateResult::Allow));
|
||||
assert!(matches!(decide(&mgr, "write_file", "shared-memory/casa.md").await, GateResult::Require));
|
||||
assert!(matches!(decide(&mgr, "edit_file", "shared-memory/casa.md").await, GateResult::Require));
|
||||
// The shared audit log is the one exception, and only for `append_file` — the
|
||||
// one write tool that cannot shorten a file. Its lower priority number must
|
||||
// beat the `@fs_write shared-memory/* require` rule.
|
||||
assert!(matches!(decide(&mgr, "append_file", "shared-memory/log.md").await, GateResult::Allow));
|
||||
// …and the exception is narrow in both directions: another tool on the same
|
||||
// path, or the same tool on another shared note, still needs a human.
|
||||
assert!(matches!(decide(&mgr, "write_file", "shared-memory/log.md").await, GateResult::Require));
|
||||
assert!(matches!(decide(&mgr, "edit_file", "shared-memory/log.md").await, GateResult::Require));
|
||||
assert!(matches!(decide(&mgr, "append_file", "shared-memory/casa.md").await, GateResult::Require));
|
||||
// Private memory is frictionless throughout, log included.
|
||||
assert!(matches!(decide(&mgr, "append_file", "user-memory/log.md").await, GateResult::Allow));
|
||||
assert!(matches!(decide(&mgr, "read_file", "data/x.txt").await, GateResult::Allow));
|
||||
// project folders auto-allow reads and writes (subtree match on projects/*).
|
||||
assert!(matches!(decide(&mgr, "write_file", "projects/alice/budget/x.md").await, GateResult::Allow));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,6 +102,12 @@ impl SystemContextSource for AgentSystemContext {
|
||||
&render_user_profile_section(&self.shared_pool, &self.user_id).await?,
|
||||
);
|
||||
}
|
||||
if static_content.contains("__MEMBERS__") {
|
||||
static_content = static_content.replace(
|
||||
"__MEMBERS__",
|
||||
&render_members_section(&self.shared_pool, &self.user_id).await?,
|
||||
);
|
||||
}
|
||||
|
||||
for (key, value) in &self.substitutions {
|
||||
let sentinel = format!("__{key}__");
|
||||
@@ -324,6 +330,79 @@ pub(crate) async fn render_user_profile_section(
|
||||
))
|
||||
}
|
||||
|
||||
/// `__MEMBERS__` section, resolved from the registry.
|
||||
///
|
||||
/// The roster is **generated, never remembered**: `users` and `roles` already
|
||||
/// hold it, so a `members.md` note maintained by the model would only be a copy
|
||||
/// that drifts — and one a member could talk the model into rewriting. Memory
|
||||
/// is for what only the assistant knows; what the database knows is read from
|
||||
/// the database.
|
||||
///
|
||||
/// Deliberately **not** included: `users.notes`. Those are the admin's private
|
||||
/// notes *about* a person, and this block is visible to every member.
|
||||
pub(crate) async fn render_members_section(
|
||||
shared_pool: &SqlitePool,
|
||||
caller_id: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let users = crate::db::users::list(shared_pool).await?;
|
||||
let roles = crate::db::roles::list(shared_pool).await?;
|
||||
Ok(render_members_table(&users, &roles, caller_id, chrono::Utc::now().date_naive()))
|
||||
}
|
||||
|
||||
/// Renders the `__MEMBERS__` table. Pure (and so testable): `today` is passed in
|
||||
/// for the age computation, exactly as in [`render_user_profile_block`].
|
||||
fn render_members_table(
|
||||
users: &[crate::db::users::User],
|
||||
roles: &[crate::db::roles::Role],
|
||||
caller_id: &str,
|
||||
today: chrono::NaiveDate,
|
||||
) -> String {
|
||||
use crate::db::roles::ADMIN_ROLE_ID;
|
||||
|
||||
/// The role's human label, marked when it carries admin authority — the
|
||||
/// contradiction rule in the memory schema turns on "or an admin", so the
|
||||
/// model must be able to tell. Keyed on the role *id*, so a relabelled or
|
||||
/// translated admin role is still recognised; the suffix is skipped when the
|
||||
/// label already says it.
|
||||
fn role_cell(role_id: &str, label: &str) -> String {
|
||||
if role_id == ADMIN_ROLE_ID && !label.to_lowercase().contains("admin") {
|
||||
format!("{label} (admin)")
|
||||
} else {
|
||||
label.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
let active: Vec<&crate::db::users::User> = users.iter().filter(|u| u.active).collect();
|
||||
if active.len() <= 1 {
|
||||
return "_You are the only member of this instance._\n".to_string();
|
||||
}
|
||||
|
||||
let mut out = String::from("| Name | Age | Sex | Role |\n|------|-----|-----|------|\n");
|
||||
for u in active {
|
||||
let name = non_empty(&u.display_name).unwrap_or(u.username.as_str());
|
||||
let you = if u.id == caller_id { " (you)" } else { "" };
|
||||
|
||||
let age = non_empty(&u.birthdate)
|
||||
.and_then(|raw| chrono::NaiveDate::parse_from_str(raw, "%Y-%m-%d").ok())
|
||||
.and_then(|dob| today.years_since(dob))
|
||||
.map(|age| age.to_string())
|
||||
.unwrap_or_else(|| "—".to_string());
|
||||
|
||||
let sex = non_empty(&u.sex).unwrap_or("—");
|
||||
|
||||
let label = roles.iter()
|
||||
.find(|r| r.id == u.role_id)
|
||||
.map(|r| r.label.as_str())
|
||||
.unwrap_or(u.role_id.as_str());
|
||||
|
||||
out.push_str(&format!(
|
||||
"| {name}{you} | {age} | {sex} | {} |\n",
|
||||
role_cell(&u.role_id, label),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders the shared-folders section body as a Markdown table — one row per
|
||||
/// folder the user belongs to, naming the folder's other members so the model
|
||||
/// knows exactly who sees what is written there. An empty membership yields an
|
||||
@@ -465,6 +544,112 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn test_role(id: &str, label: &str) -> crate::db::roles::Role {
|
||||
crate::db::roles::Role {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
permission_group: "default".into(),
|
||||
attrs: None,
|
||||
created_at: "now".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fixture: an admin, another adult, a child, and a deactivated account.
|
||||
fn test_members() -> (Vec<crate::db::users::User>, Vec<crate::db::roles::Role>) {
|
||||
let mut anna = test_user();
|
||||
anna.id = "u-anna".into();
|
||||
anna.username = "anna".into();
|
||||
anna.display_name = Some("Anna".into());
|
||||
anna.role_id = "admin".into();
|
||||
anna.birthdate = Some("1984-03-02".into());
|
||||
anna.sex = Some("female".into());
|
||||
anna.notes = Some("keeps the calendar".into());
|
||||
|
||||
let mut luca = test_user();
|
||||
luca.id = "u-luca".into();
|
||||
luca.username = "luca".into();
|
||||
luca.role_id = "member".into();
|
||||
|
||||
let mut marco = test_user();
|
||||
marco.id = "u-marco".into();
|
||||
marco.username = "marco".into();
|
||||
marco.display_name = Some("Marco".into());
|
||||
marco.role_id = "children".into();
|
||||
marco.birthdate = Some("2014-06-01".into());
|
||||
marco.sex = Some("male".into());
|
||||
|
||||
let mut gone = test_user();
|
||||
gone.id = "u-gone".into();
|
||||
gone.username = "gone".into();
|
||||
gone.active = false;
|
||||
|
||||
(
|
||||
vec![anna, luca, marco, gone],
|
||||
vec![
|
||||
test_role("admin", "Amministratore"),
|
||||
test_role("member", "Member"),
|
||||
test_role("children", "Children"),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn today() -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::from_ymd_opt(2026, 7, 26).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_table_lists_active_members_with_age_and_role() {
|
||||
let (users, roles) = test_members();
|
||||
let out = render_members_table(&users, &roles, "u-anna", today());
|
||||
|
||||
// The caller is marked, so the model does not talk about them in the
|
||||
// third person.
|
||||
assert!(out.contains("| Anna (you) |"), "caller not marked: {out}");
|
||||
assert!(!out.contains("Marco (you)"));
|
||||
|
||||
// Age is computed at render time, never stored.
|
||||
assert!(out.contains("| 42 |"), "anna's age missing: {out}");
|
||||
assert!(out.contains("| 12 |"), "marco's age missing: {out}");
|
||||
|
||||
// No display name → username; no birthdate/sex → explicit em dash.
|
||||
assert!(out.contains("| luca | — | — |"), "fallbacks wrong: {out}");
|
||||
|
||||
// A deactivated account is not a member.
|
||||
assert!(!out.contains("gone"), "inactive user listed: {out}");
|
||||
}
|
||||
|
||||
/// The admin must be identifiable whatever the role was relabelled to — the
|
||||
/// memory schema's contradiction rule turns on "or an admin".
|
||||
#[test]
|
||||
fn members_table_marks_the_admin_role_whatever_its_label() {
|
||||
let (users, roles) = test_members();
|
||||
let out = render_members_table(&users, &roles, "u-marco", today());
|
||||
assert!(out.contains("Amministratore (admin)"), "admin not identifiable: {out}");
|
||||
|
||||
// …and does not stutter when the label already says it.
|
||||
let roles = vec![test_role("admin", "Admin"), test_role("children", "Children")];
|
||||
let out = render_members_table(&users, &roles, "u-marco", today());
|
||||
assert!(out.contains("| Admin |"), "redundant suffix: {out}");
|
||||
}
|
||||
|
||||
/// `users.notes` are the admin's private notes *about* a person; this block
|
||||
/// is visible to every member, so they must never reach it.
|
||||
#[test]
|
||||
fn members_table_never_leaks_admin_notes() {
|
||||
let (users, roles) = test_members();
|
||||
let out = render_members_table(&users, &roles, "u-marco", today());
|
||||
assert!(!out.contains("keeps the calendar"), "admin notes leaked: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_table_says_so_when_alone() {
|
||||
let (mut users, roles) = test_members();
|
||||
users.retain(|u| u.id == "u-anna");
|
||||
let out = render_members_table(&users, &roles, "u-anna", today());
|
||||
assert!(out.contains("only member"), "solo instance not explicit: {out}");
|
||||
assert!(!out.contains('|'), "no table for a single member: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_profile_renders_all_fields_with_runtime_age() {
|
||||
let mut u = test_user();
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
//! - [`Memory::tools`] is called per turn; the returned tools are added to the
|
||||
//! LLM's tool list and dispatched before the global registry.
|
||||
|
||||
pub mod scaffold;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Seeds the two structural notes every memory store needs — `index.md` and
|
||||
//! `log.md` — so the wiki has a skeleton before anything is written to it.
|
||||
//!
|
||||
//! Why this exists: the agents' memory schema (`agents/common/memory-wiki.md`)
|
||||
//! tells the model to keep both files in sync, and `meta.json` injects
|
||||
//! `index.md` into every chat turn. On a fresh store neither file exists, an
|
||||
//! injection of a missing note silently resolves to nothing, and the model is
|
||||
//! left to invent the structure — or not. Seeding costs two SELECTs at boot and
|
||||
//! removes that coin flip.
|
||||
//!
|
||||
//! The bodies are deliberately **minimal**. `index.md` rides in the system
|
||||
//! prompt of every turn, so it must not restate the schema — the schema is
|
||||
//! already in the prompt, and saying it twice is how two sources of truth start
|
||||
//! to drift.
|
||||
//!
|
||||
//! Called for the shared store at boot (idempotent, so an existing instance
|
||||
//! gets it too) and for a private store when its database is created.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::db::memory_docs;
|
||||
|
||||
/// The catalogue: one line per note. Injected into every chat turn.
|
||||
const INDEX_PATH: &str = "index.md";
|
||||
const INDEX_SEED: &str = "# Index\n\n_No notes yet._\n";
|
||||
|
||||
/// The append-only history. Never injected — read on demand.
|
||||
const LOG_PATH: &str = "log.md";
|
||||
const LOG_SEED: &str = "# History\n";
|
||||
|
||||
/// The assistant's front page for its owner. Private stores only: shared memory
|
||||
/// has no single "user" it is about.
|
||||
const USER_PATH: &str = "user.md";
|
||||
const USER_SEED: &str = "# User\n\n_Nothing recorded yet._\n";
|
||||
|
||||
/// The two notes every store has, private or shared.
|
||||
const COMMON: &[(&str, &str)] = &[(INDEX_PATH, INDEX_SEED), (LOG_PATH, LOG_SEED)];
|
||||
|
||||
/// Scaffolds a **private** store: the two common notes plus `user.md`.
|
||||
///
|
||||
/// `user.md` is seeded even though only the `assistant` agent injects it, and
|
||||
/// seeded *empty* rather than left absent: a missing note resolves to nothing
|
||||
/// at injection time, so the model cannot tell "no facts yet" from "this
|
||||
/// mechanism is not running". An explicit `_Nothing recorded yet._` is a signal
|
||||
/// it can act on — the same convention as the `unknown` lines in the user
|
||||
/// profile block.
|
||||
pub async fn seed_private(pool: &SqlitePool) -> Result<()> {
|
||||
write_missing(pool, COMMON).await?;
|
||||
write_missing(pool, &[(USER_PATH, USER_SEED)]).await
|
||||
}
|
||||
|
||||
/// Scaffolds the **shared** store: the two common notes only.
|
||||
pub async fn seed_shared(pool: &SqlitePool) -> Result<()> {
|
||||
write_missing(pool, COMMON).await
|
||||
}
|
||||
|
||||
/// Creates each note that is absent, leaving existing ones untouched — so this
|
||||
/// is safe to run on every boot and can never overwrite a real index, truncate a
|
||||
/// history, or wipe a curated `user.md`.
|
||||
async fn write_missing(pool: &SqlitePool, notes: &[(&str, &str)]) -> Result<()> {
|
||||
for &(path, body) in notes {
|
||||
if memory_docs::get(pool, path).await?.is_none() {
|
||||
memory_docs::upsert(pool, path, body).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
async fn owner_pool(tag: &str) -> (SqlitePool, PathBuf) {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("skald-scaffold-{tag}-{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let pool = crate::db::create_user_pool(&dir.join("owner.db"), None).await.unwrap();
|
||||
(pool, dir)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn private_seed_creates_all_three_notes_and_never_clobbers_them() {
|
||||
let (pool, dir) = owner_pool("seed").await;
|
||||
|
||||
seed_private(&pool).await.unwrap();
|
||||
assert_eq!(memory_docs::get(&pool, "index.md").await.unwrap().unwrap().content, INDEX_SEED);
|
||||
assert_eq!(memory_docs::get(&pool, "log.md").await.unwrap().unwrap().content, LOG_SEED);
|
||||
assert_eq!(memory_docs::get(&pool, "user.md").await.unwrap().unwrap().content, USER_SEED);
|
||||
|
||||
// Real content lands on top…
|
||||
memory_docs::append(&pool, "log.md", "2026-07-26 | ADD | anna | casa.md | created\n")
|
||||
.await.unwrap();
|
||||
memory_docs::upsert(&pool, "index.md", "# Index\n\n- casa.md — the house\n").await.unwrap();
|
||||
memory_docs::upsert(&pool, "user.md", "# User\n\n- Prefers Italian\n").await.unwrap();
|
||||
|
||||
// …and a second boot must not undo it. This is the whole safety property:
|
||||
// the seed runs on every start, over stores that are already in use.
|
||||
seed_private(&pool).await.unwrap();
|
||||
let log = memory_docs::get(&pool, "log.md").await.unwrap().unwrap().content;
|
||||
assert!(log.contains("casa.md | created"), "seed truncated the history: {log:?}");
|
||||
let index = memory_docs::get(&pool, "index.md").await.unwrap().unwrap().content;
|
||||
assert!(index.contains("the house"), "seed overwrote the index: {index:?}");
|
||||
let user = memory_docs::get(&pool, "user.md").await.unwrap().unwrap().content;
|
||||
assert!(user.contains("Prefers Italian"), "seed wiped the front page: {user:?}");
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Shared memory is nobody's front page — `user.md` there would be a note
|
||||
/// about "the user" in a store that has no single user.
|
||||
#[tokio::test]
|
||||
async fn shared_seed_omits_the_user_front_page() {
|
||||
let (pool, dir) = owner_pool("shared").await;
|
||||
|
||||
seed_shared(&pool).await.unwrap();
|
||||
assert!(memory_docs::get(&pool, "index.md").await.unwrap().is_some());
|
||||
assert!(memory_docs::get(&pool, "log.md").await.unwrap().is_some());
|
||||
assert!(
|
||||
memory_docs::get(&pool, "user.md").await.unwrap().is_none(),
|
||||
"shared memory must not get a user front page",
|
||||
);
|
||||
|
||||
pool.close().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,13 @@ impl Interaction {
|
||||
}
|
||||
info!("approval manager ready");
|
||||
|
||||
// Shared memory is owned by the system database, so its skeleton is
|
||||
// seeded here rather than in `initialize_instance` — idempotent, so an
|
||||
// instance that predates the memory wiki gets it on its next boot.
|
||||
if let Err(e) = crate::memory::scaffold::seed_shared(&rt.db).await {
|
||||
warn!(error = %e, "failed to seed shared memory scaffold (non-fatal)");
|
||||
}
|
||||
|
||||
let clarification = ClarificationManager::new(rt.global_tx.clone());
|
||||
let elicitation = ElicitationManager::new(rt.global_tx.clone());
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
use crate::tools::{
|
||||
SimpleExecution, Tool, ToolContext, ToolDescriptionLength, ToolExecution, ToolResult,
|
||||
truncate_label, MAX_LABEL_SHORT,
|
||||
};
|
||||
use super::{classify_memory, resolve, MemScope};
|
||||
|
||||
/// Appends text to the end of a file or note, creating it when absent.
|
||||
///
|
||||
/// Exists as its own tool rather than as a `insert_at_line` idiom for two
|
||||
/// reasons. It is **atomic** on the memory path — one SQL statement, see
|
||||
/// [`crate::db::memory_docs::append`] — where a read-modify-write would drop a
|
||||
/// line under concurrent appends. And it **cannot destroy**: no argument of this
|
||||
/// tool can shorten a file, which is what makes it safe to auto-allow on the
|
||||
/// append-only `log.md` of shared memory (see `seed_fs_path_rules`) while every
|
||||
/// other shared write still needs a human.
|
||||
pub struct AppendFile {
|
||||
/// The `shared-memory` (system) pool; see [`ReadFile`](super::ReadFile).
|
||||
shared_pool: Arc<SqlitePool>,
|
||||
}
|
||||
|
||||
impl AppendFile {
|
||||
pub fn new(shared_pool: Arc<SqlitePool>) -> Self { Self { shared_pool } }
|
||||
}
|
||||
|
||||
/// Normalises appended text to whole lines: a trailing newline is added when
|
||||
/// missing, so consecutive appends never run into one another. The *leading*
|
||||
/// separator is the storage layer's job — it depends on how the existing content
|
||||
/// ends, which only the writer can see atomically.
|
||||
fn line_terminated(content: &str) -> String {
|
||||
if content.ends_with('\n') { content.to_string() } else { format!("{content}\n") }
|
||||
}
|
||||
|
||||
impl Tool for AppendFile {
|
||||
fn name(&self) -> &str { "append_file" }
|
||||
fn display_name(&self) -> &str { "Append to File" }
|
||||
fn icon(&self) -> &str { "edit" }
|
||||
fn category(&self) -> crate::tools::ToolCategory { crate::tools::ToolCategory::Filesystem }
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Add text to the END of a file, creating the file if it does not exist. \
|
||||
Never reads, rewrites or shortens what is already there — use this for append-only files such as a log. \
|
||||
The text is written as whole lines: a newline is added before it if needed, and after it if missing. \
|
||||
Relative paths are resolved from your home directory (`~`); absolute paths (starting with /) are used as-is. \
|
||||
Works on user-memory/ and shared-memory/ notes as well as on disk."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "File path. Relative to `~` (your home), or absolute."
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "Text to add at the end. May span multiple lines."
|
||||
}
|
||||
},
|
||||
"required": ["path", "content"]
|
||||
})
|
||||
}
|
||||
|
||||
fn target_path(&self, args: &Value) -> Option<String> {
|
||||
super::path_arg(args)
|
||||
}
|
||||
|
||||
fn describe(&self, args: &Value, length: ToolDescriptionLength) -> String {
|
||||
let path = args["path"].as_str().unwrap_or("?");
|
||||
let _ = length;
|
||||
truncate_label(&format!("append_file `{path}`"), MAX_LABEL_SHORT)
|
||||
}
|
||||
|
||||
/// Routes `user-memory/…` / `shared-memory/…` to the note store; every other
|
||||
/// path falls through to the on-disk [`execute`](Self::execute).
|
||||
fn run_with<'a>(&'a self, ctx: &ToolContext, args: Value) -> Box<dyn ToolExecution + 'a> {
|
||||
let path = super::path_arg(&args).unwrap_or_default();
|
||||
let Some(m) = classify_memory(&path) else {
|
||||
return match super::rewrite_to_host(&ctx.fs, &path, args) {
|
||||
Ok(args) => self.run(args),
|
||||
Err(e) => super::error_exec(e.to_string()),
|
||||
};
|
||||
};
|
||||
let pool = match m.scope {
|
||||
MemScope::User => Arc::clone(&ctx.pool),
|
||||
MemScope::Shared => Arc::clone(&self.shared_pool),
|
||||
};
|
||||
let rel = m.rel;
|
||||
let content = args["content"].as_str().map(str::to_string);
|
||||
|
||||
Box::new(SimpleExecution::new(Box::pin(async move {
|
||||
let content = content.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
if rel.is_empty() {
|
||||
anyhow::bail!("{path} is a memory root, not a note — append to a path like {path}/log.md");
|
||||
}
|
||||
let text = line_terminated(&content);
|
||||
crate::db::memory_docs::append(&pool, &rel, &text).await?;
|
||||
Ok(ToolResult::Text(format!("Appended {} bytes to {path}.", text.len())))
|
||||
})))
|
||||
}
|
||||
|
||||
fn execute(&self, args: Value) -> Result<String> {
|
||||
use std::io::Write;
|
||||
|
||||
let user_path = args["path"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: path"))?;
|
||||
let display = super::display_path_arg(&args);
|
||||
let content = args["content"].as_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing required argument: content"))?;
|
||||
|
||||
let abs = resolve(user_path)?;
|
||||
if let Some(parent) = abs.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
|
||||
}
|
||||
|
||||
// A file that does not end in a newline would otherwise glue the two
|
||||
// lines together — mirror the memory path's line discipline.
|
||||
let needs_sep = match std::fs::metadata(&abs) {
|
||||
Ok(md) if md.len() > 0 => {
|
||||
let mut tail = [0u8; 1];
|
||||
read_last_byte(&abs, &mut tail)?;
|
||||
tail[0] != b'\n'
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
let text = line_terminated(content);
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&abs)
|
||||
.with_context(|| format!("Failed to open for append: {}", abs.display()))?;
|
||||
if needs_sep {
|
||||
f.write_all(b"\n")
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))?;
|
||||
}
|
||||
f.write_all(text.as_bytes())
|
||||
.with_context(|| format!("Failed to write: {}", abs.display()))?;
|
||||
|
||||
Ok(format!("Appended {} bytes to {display}.", text.len()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the final byte of `path` without loading the file — an append must not
|
||||
/// pay for the size of what it appends to.
|
||||
fn read_last_byte(path: &std::path::Path, buf: &mut [u8; 1]) -> Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = std::fs::File::open(path)
|
||||
.with_context(|| format!("Cannot read file: {}", path.display()))?;
|
||||
f.seek(SeekFrom::End(-1))?;
|
||||
f.read_exact(buf)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
mod append_file;
|
||||
mod edit_file;
|
||||
mod grep_files;
|
||||
mod insert_at_line;
|
||||
@@ -26,6 +27,7 @@ pub(crate) fn path_arg(args: &Value) -> Option<String> {
|
||||
args.get("path").and_then(Value::as_str).map(str::to_string)
|
||||
}
|
||||
|
||||
pub use append_file::AppendFile;
|
||||
pub use edit_file::EditFile;
|
||||
pub use grep_files::GrepFiles;
|
||||
pub use insert_at_line::InsertAtLine;
|
||||
@@ -277,6 +279,7 @@ pub(crate) fn error_exec<'a>(msg: String) -> Box<dyn ToolExecution + 'a> {
|
||||
/// tools; each still resolves the per-user (`user-memory`) pool per call from the
|
||||
/// `ToolContext`.
|
||||
pub fn register_all(registry: &mut ToolRegistry, shared_pool: Arc<SqlitePool>) {
|
||||
registry.register(AppendFile::new(Arc::clone(&shared_pool)));
|
||||
registry.register(EditFile::new(Arc::clone(&shared_pool)));
|
||||
registry.register(GrepFiles::new()); // not memory-aware yet — see blueprint Prossimi passi
|
||||
registry.register(InsertAtLine::new(Arc::clone(&shared_pool)));
|
||||
@@ -666,4 +669,52 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
|
||||
/// `append_file` on a physical path: creates, adds whole lines, and — the
|
||||
/// property the tool exists for — never shortens what was already there.
|
||||
#[tokio::test]
|
||||
async fn append_file_on_disk_creates_and_only_ever_grows() {
|
||||
let (shared, sdir) = store("append-shared").await;
|
||||
let (user, udir) = store("append-user").await;
|
||||
|
||||
let root = std::env::temp_dir().join(format!("skald-append-{}", uuid::Uuid::new_v4()));
|
||||
let home = root.join("homes").join("u1");
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
|
||||
let fs = Arc::new(UserFs::new(
|
||||
"u1", home.clone(), "skald-u1", PathBuf::from("/root"), vec![], vec![], None,
|
||||
));
|
||||
let ctx = ToolContext { session_id: 1, user_id: "u1".into(), pool: Arc::clone(&user), fs };
|
||||
let append = AppendFile::new(Arc::clone(&shared));
|
||||
|
||||
// Absent file → created, with the trailing newline supplied for us.
|
||||
let out = drive(&append, &ctx, json!({"path":"~/log.md","content":"first"}))
|
||||
.await.unwrap();
|
||||
assert!(out.contains("~/log.md"), "agent path missing: {out}");
|
||||
assert!(!out.contains("/homes/u1"), "host path leaked: {out}");
|
||||
assert_eq!(std::fs::read_to_string(home.join("log.md")).unwrap(), "first\n");
|
||||
|
||||
// Second append lands on its own line, first line untouched.
|
||||
drive(&append, &ctx, json!({"path":"~/log.md","content":"second\n"})).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(home.join("log.md")).unwrap(), "first\nsecond\n");
|
||||
|
||||
// A file that does not end in a newline gets a separator, never a splice.
|
||||
std::fs::write(home.join("ragged.md"), "no-newline").unwrap();
|
||||
drive(&append, &ctx, json!({"path":"~/ragged.md","content":"next"})).await.unwrap();
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(home.join("ragged.md")).unwrap(),
|
||||
"no-newline\nnext\n",
|
||||
"append must not glue itself onto an unterminated last line"
|
||||
);
|
||||
|
||||
// Containment holds like every other physical fs tool (blueprint §6).
|
||||
let err = drive(&append, &ctx, json!({"path":"../escape.md","content":"x"}))
|
||||
.await.unwrap_err();
|
||||
assert!(!err.is_empty(), "an escaping path must be rejected");
|
||||
assert!(!root.join("escape.md").exists(), "append escaped the home");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _ = std::fs::remove_dir_all(&udir);
|
||||
let _ = std::fs::remove_dir_all(&sdir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ pub const FILE_WRITE_TOOLS: &[&str] = &[
|
||||
"edit_file",
|
||||
"insert_at_line",
|
||||
"replace_lines",
|
||||
"append_file",
|
||||
];
|
||||
|
||||
/// Returns `true` if `name` is a file-write tool (i.e. it modifies files on disk).
|
||||
|
||||
@@ -322,6 +322,13 @@ impl UserManager {
|
||||
let pool = db::create_user_pool(&path, dek.as_ref())
|
||||
.await
|
||||
.with_context_path(&path)?;
|
||||
// The only moment this database is open with its key in hand before the
|
||||
// user ever logs in — so it is where the private memory store gets its
|
||||
// skeleton (`index.md` + `log.md`). Non-fatal: a user without a seeded
|
||||
// index is a worse assistant, not a broken account.
|
||||
if let Err(e) = crate::memory::scaffold::seed_private(&pool).await {
|
||||
warn!(user = %id, error = %e, "failed to seed private memory scaffold (non-fatal)");
|
||||
}
|
||||
pool.close().await;
|
||||
|
||||
if let Err(e) =
|
||||
|
||||
Reference in New Issue
Block a user