Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 153 additions & 8 deletions client/src/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ const READS: &str = ".reads";
/// asked to check that it still holds: Codex drops a memory unused for 30,
/// Copilot one unused for 28.
const UNREAD_DAYS: i64 = 30;
const USAGE: &str = "usage: memory show [SCOPE]\n memory read NAME [SCOPE]\n memory save NAME --type user|feedback|project|reference --description TEXT --source TEXT [--external] [SCOPE] -- TEXT|-\n memory rm NAME [SCOPE]\n memory index [SCOPE]\n memory check [SCOPE]\n memory said [SINCE_MS] (Sonny: what the person wrote to any agent since then)\n memory changed | cleanup start | cleanup finish\n memory schedule [--model PROVIDER/MODEL [--effort LEVEL]] [--cron 'MIN HOUR DAY MONTH WEEKDAY']\n --model starts the memory-cleanup agent; without it, the existing one\n SCOPE: --user | --project NAME; none: the project of this folder";
const USAGE: &str = "usage: memory show [SCOPE]\n memory read NAME [SCOPE]\n memory save NAME --type user|feedback|project|reference --description TEXT --source TEXT [--external] [--confirmed N] [--pushed-back N] [SCOPE] -- TEXT|-\n memory rm NAME [SCOPE]\n memory index [SCOPE]\n memory check [SCOPE]\n memory said [SINCE_MS] (Sonny: what the person wrote to any agent since then)\n memory changed | cleanup start | cleanup finish\n memory schedule [--model PROVIDER/MODEL [--effort LEVEL]] [--cron 'MIN HOUR DAY MONTH WEEKDAY']\n --model starts the memory-cleanup agent; without it, the existing one\n SCOPE: --user | --project NAME; none: the project of this folder";

/// `memory show|save|rm|index|check|changed|cleanup|schedule`, as
/// `~/.agent/memory` runs it.
Expand Down Expand Up @@ -91,6 +91,14 @@ pub fn call(home: &Path, cwd: &Path, arguments: &Value) -> Result<Value, String>
if save["external"] == true {
args.push("--external".to_owned());
}
for (key, flag) in [
("confirmed", "--confirmed"),
("pushed_back", "--pushed-back"),
] {
if let Some(n) = save[key].as_u64() {
args.extend([flag.to_owned(), n.to_string()]);
}
}
args
} else if let Some(name) = arguments["read"].as_str() {
vec!["read".to_owned(), name.to_owned()]
Expand Down Expand Up @@ -163,13 +171,23 @@ fn run(
let mut fields = [None, None, None];
let mut scope = None;
let mut external = false;
let mut counts = [None, None];
let mut it = flags.iter();
while let Some(arg) = it.next() {
let slot = match arg.as_str() {
"--external" if verb == "save" && !external => {
external = true;
continue;
}
count @ ("--confirmed" | "--pushed-back") if verb == "save" => {
let at = usize::from(count == "--pushed-back");
let n = it.next().and_then(|n| n.parse::<u32>().ok());
if counts[at].is_some() || n.is_none() {
return Err(usage());
}
counts[at] = n;
continue;
}
"--type" => &mut fields[0],
"--description" => &mut fields[1],
"--source" => &mut fields[2],
Expand Down Expand Up @@ -246,8 +264,10 @@ fn run(
source,
verified: today(),
external,
confirmed: counts[0].unwrap_or_default(),
pushed_back: counts[1].unwrap_or_default(),
};
save(&scope()?, &fact, &text)
save(&scope()?, fact, counts, &text)
}
("read", Some(name), [None, None, None], None) => read(&scope()?, &name),
("rm", Some(name), [None, None, None], None) => remove(&scope()?, &name),
Expand Down Expand Up @@ -331,12 +351,17 @@ struct Fact {
/// Saved in a turn that read outside content, a web page, a search or
/// an MCP server, so it may carry words no person wrote.
external: bool,
/// How many times the person confirmed what the fact says, and pushed
/// back on it, as ExpeL's insights and ACE's playbook bullets keep
/// helpful and harmful counts, so a later agent sees its weight.
confirmed: u32,
pushed_back: u32,
}

impl Fact {
fn render(&self, body: &str) -> String {
format!(
"---\nname: {}\ndescription: {}\ntype: {}\nsource: {}\nverified: {}\n{}---\n\n{}\n",
"---\nname: {}\ndescription: {}\ntype: {}\nsource: {}\nverified: {}\n{}{}---\n\n{}\n",
self.name,
self.description,
self.kind,
Expand All @@ -347,17 +372,31 @@ impl Fact {
} else {
""
},
self.counts(|key, n| format!("{key}: {n}\n")),
body.trim()
)
}

/// The counts that are not zero, each as `each` writes it.
fn counts(&self, each: impl Fn(&str, u32) -> String) -> String {
[
("confirmed", self.confirmed),
("pushed_back", self.pushed_back),
]
.into_iter()
.filter(|(_, n)| *n > 0)
.map(|(key, n)| each(key, n))
.collect()
}

fn line(&self) -> String {
format!(
"- {} ({}, verified {}{}): {}\n",
"- {} ({}, verified {}{}{}): {}\n",
self.name,
self.kind,
self.verified,
if self.external { ", external" } else { "" },
self.counts(|key, n| format!(", {} {n}", key.replace('_', " "))),
self.description
)
}
Expand Down Expand Up @@ -394,6 +433,8 @@ impl Fact {
Some(&"true") => true,
Some(other) => return Err(format!("external is true or absent, not {other}")),
},
confirmed: count(&fields, "confirmed")?,
pushed_back: count(&fields, "pushed_back")?,
};
if format!("{}.md", fact.name) != file {
return Err(format!("its name is {}, not its file's", fact.name));
Expand Down Expand Up @@ -422,6 +463,15 @@ impl Fact {
}
}

/// A count in a fact's front matter: absent is none.
fn count(fields: &std::collections::HashMap<&str, &str>, key: &str) -> Result<u32, String> {
fields.get(key).map_or(Ok(0), |value| {
value
.parse()
.map_err(|_| format!("{key} is a whole number or absent, not {value}"))
})
}

/// A fact's name, which is also its file's. `memory` would be the index's
/// file on a Mac, whose folders ignore case.
fn valid_name(name: &str) -> Result<(), String> {
Expand Down Expand Up @@ -507,21 +557,29 @@ fn render_index(dir: &Path, facts: &[Fact]) -> String {
text
}

fn save(dir: &Path, fact: &Fact, body: &str) -> Result<Value, String> {
/// Save `fact`; a count left out (`None` in `counts`) keeps the one the
/// fact had, so an agent rewording a fact does not reset its weight.
fn save(dir: &Path, mut fact: Fact, counts: [Option<u32>; 2], body: &str) -> Result<Value, String> {
fact.valid()?;
if body.trim().is_empty() {
return Err("invalid_text: a fact needs a body".into());
}
let _lock = lock(dir)?;
let path = dir.join(format!("{}.md", fact.name));
let before = std::fs::read_to_string(&path).ok();
if let Some(had) =
(before.as_deref()).and_then(|text| Fact::parse(&format!("{}.md", fact.name), text).ok())
{
fact.confirmed = counts[0].unwrap_or(had.confirmed);
fact.pushed_back = counts[1].unwrap_or(had.pushed_back);
}
let text = fact.render(body);
if text.len() > LIMIT {
return Err(format!(
"fact_too_large: {} bytes with its front matter, above {LIMIT}; save one fact per file",
text.len()
));
}
let _lock = lock(dir)?;
let path = dir.join(format!("{}.md", fact.name));
let before = std::fs::read_to_string(&path).ok();
// Index the folder as it will be before writing anything, so a save
// that does not fit changes nothing.
let mut all = facts(dir)?;
Expand Down Expand Up @@ -1605,6 +1663,8 @@ mod tests {
source: "the person, 2020-01-01".into(),
verified: verified.into(),
external: false,
confirmed: 0,
pushed_back: 0,
};
std::fs::write(root.join(format!("{name}.md")), fact.render("old")).unwrap();
};
Expand Down Expand Up @@ -1721,6 +1781,91 @@ mod tests {
std::fs::remove_dir_all(home).unwrap();
}

#[test]
fn a_facts_counts_show_in_the_index_and_outlast_a_save_without_them() {
let home = temp("counts");
let root = home.join(".agents/memory");
let code = |r: Result<Value, String>| error_json(&r.unwrap_err())["error"].clone();
let save = json!({"save": {"name": "split-reviews", "type": "feedback",
"description": "Reviews go to a task of their own", "source": "the person, 2026-10-30",
"text": "Start a task for each review.", "confirmed": 3, "pushed_back": 1}, "user": true});
call(&home, &home, &save).unwrap();
let file = || std::fs::read_to_string(root.join("split-reviews.md")).unwrap();
assert!(
file().contains("\nconfirmed: 3\npushed_back: 1\n---\n"),
"{}",
file()
);
let index = || std::fs::read_to_string(root.join(INDEX)).unwrap();
assert!(
index().contains(", confirmed 3, pushed back 1): Reviews go to a task of their own"),
"{}",
index()
);
// Reworded without counts, the fact keeps its weight.
let mut reworded = save.clone();
reworded["save"]
.as_object_mut()
.unwrap()
.remove("confirmed");
reworded["save"]
.as_object_mut()
.unwrap()
.remove("pushed_back");
reworded["save"]["text"] = json!("Each review is a task.");
call(&home, &home, &reworded).unwrap();
assert!(file().contains("confirmed: 3") && file().contains("pushed_back: 1"));
// A count given is the count; zero leaves it out of file and index.
reworded["save"]["confirmed"] = json!(4);
reworded["save"]["pushed_back"] = json!(0);
call(&home, &home, &reworded).unwrap();
assert!(file().contains("confirmed: 4") && !file().contains("pushed_back"));
assert!(index().contains(", confirmed 4): "), "{}", index());
// The script takes them as flags, once each, as whole numbers.
let run_ = |line: &[&str]| run(&root, &home, &args(line), &no_stdin);
let base = [
"save",
"x",
"--type",
"project",
"--description",
"d",
"--source",
"s",
"--user",
];
let line = |extra: &[&str]| {
let mut line: Vec<&str> = base.to_vec();
line.extend_from_slice(extra);
line.extend_from_slice(&["--", "t"]);
run_(&line)
};
line(&["--pushed-back", "2"]).unwrap();
assert!(
std::fs::read_to_string(root.join("x.md"))
.unwrap()
.contains("pushed_back: 2")
);
assert_eq!(
code(line(&["--confirmed", "1", "--confirmed", "2"])),
"usage"
);
assert_eq!(code(line(&["--confirmed", "many"])), "usage");
assert_eq!(
code(run_(&["rm", "x", "--confirmed", "1", "--user"])),
"usage"
);
assert_eq!(check(&root)["problems"], json!([]));
// Any other value is not a fact.
std::fs::write(
root.join("y.md"),
"---\nname: y\ndescription: d\ntype: project\nsource: s\nverified: 2026-10-29\nconfirmed: lots\n---\n\nt\n",
)
.unwrap();
assert_eq!(code(run_(&["index", "--user"])), "memory_invalid");
std::fs::remove_dir_all(home).unwrap();
}

#[test]
fn dates_count_days_from_1970() {
assert_eq!(day_number("1970-01-01"), Some(0));
Expand Down
20 changes: 17 additions & 3 deletions docs/SONNY.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,10 @@ the person's preferences, what they corrected and what they confirmed
worked, traps, pointers. Each fact is one
Markdown file of at most 16 KiB with front matter (`name`, `description`,
`type` of `user`, `feedback`, `project` or `reference`, `source`,
`verified`, and `external` when it is so). The person's facts are in `~/.agents/memory`, a project's in
`verified`, `external` when it is so, and `confirmed` and `pushed_back`,
how many times the person confirmed or pushed back on it, when not zero, as
ExpeL's insights and ACE's playbook bullets keep helpful and harmful
counts; a save that leaves a count out keeps it). The person's facts are in `~/.agents/memory`, a project's in
`~/.agents/memory/projects/NAME`, outside the checkout, so its lead and every
task's worktree share one copy whatever is committed (a task's worktree
starts at the last commit, so an uncommitted AGENTS.md line never reaches
Expand Down Expand Up @@ -421,7 +424,13 @@ named client's mark, steers included, newest first and at most
that turn ran in. History keeps no times, so a message is dated by its
turn: the one that started a turn is as old as the turn's start, and one
sent into it later came while it ran, so a long turn's opening message
from before then is left out. It saves what no
from before then is left out. The person's reactions to a bot's replies
are in it too, each with `reacted_to`, the first line of the reply it
answers (the bot is told only the emoji when it was its last reply). A
reaction counts once, and not at all once the person took it off, as the
window's reactions file says. An answer to an ask comes with
`recommended`, the label of the option the ask recommended, as ChatGPT
Pulse keeps feedback with the card it was on. It saves what no
agent saved, as Codex mines finished sessions and OpenClaw's dreaming reads
the day's notes, taking only the person's own words and their scope, as
Codex's extraction does.
Expand All @@ -434,7 +443,12 @@ in their chat, mail and the like when they connected servers for those. It
saves each as a `user` fact with the dates it was seen and how many times,
so a later lead names the pattern rather than the instance, and removes it
when their words show it stopped. It also saves how they took what an
agent did unasked, which is how far leads go on their own. It reads only
agent did unasked, which is how far leads go on their own, and what
worked: an approach they welcomed, in words or with a reaction, or picked
when it was recommended, raising its fact's `confirmed` count, or
`pushed_back` when they pushed back on it. A fact pushed back on more than
it was confirmed is removed, as ExpeL drops an insight whose count reaches
zero; it reinforces approaches, never agreement. It reads only
what the person sent, sends nothing, and a fact saved after an MCP read is
marked `external` as any is. A server it cannot read, as one whose sign-in
expired, gets an `unreadable-SERVER` fact, and that night is left
Expand Down
2 changes: 1 addition & 1 deletion sonny/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ per feature; group small things into the row they belong to.
|---|---|---|---|---|
| `ask`: a question for the person, as a decision card with reasons | shipped | pinned questions above the composer 10-27 #192; declared tool 10-27 #225; decision card where it was asked 10-28 #245; options are buttons 10-29 #326; a task's question reaches its lead, a yellow ? and a muted bar, decisions and sends go through it 10-30 #395 | Agents ask instead of guessing; the person answers in one click | ? |
| `plan`: the agent's plan, drawn in the conversation | shipped | added 10-27 #225 | See the steps an agent intends | ? |
| Memory (`sonny memory`, the memory skills, nightly cleanup) | shipped | app memory 10-09 #82, 10-10 #104, #120; moved into Sonny 10-27 #189; a tool 10-28 #278; 16 KiB 10-28 #241; read tracking, 30-day unread check 10-29 #354; outside-content marks 10-29 #356; the cleanup reflects on habits, from the person's words and what they sent through their servers 10-30 #403; a server it cannot read holds the night open 10-30 #411; confirmed approaches saved, not only corrections 10-30 #410 | Later agents start knowing what earlier ones learned | ? |
| Memory (`sonny memory`, the memory skills, nightly cleanup) | shipped | app memory 10-09 #82, 10-10 #104, #120; moved into Sonny 10-27 #189; a tool 10-28 #278; 16 KiB 10-28 #241; read tracking, 30-day unread check 10-29 #354; outside-content marks 10-29 #356; the cleanup reflects on habits, from the person's words and what they sent through their servers 10-30 #403; a server it cannot read holds the night open 10-30 #411; confirmed approaches saved, not only corrections 10-30 #410; confirm and push-back counts per fact in the index, the person's reactions and ask picks reach the cleanup 10-30 #431 | Later agents start knowing what earlier ones learned | ? |
| Triggers (`sonny trigger`): wake an agent at a time, on a file, a commit, or by name | shipped | app triggers 10-10 #89-#92; moved into Sonny 10-27 #189; skipped checks, `--replace` 10-28 #241; a tool 10-28 #278; Linux via systemd 10-29 #344; `--start` agents get Sonny's tools 10-30 #403 | Work that starts while nobody is at the terminal | ? |
| Turn-end triggers | removed | added 10-10 #89-#92; removed 10-27 #189 | Leads hear of task turns from task updates and the lookout | ? |
| Goals: keep an agent working until a check command passes | experimental | added 10-29 #341; `/goal WHAT` asks the agent to set one, `/goal` shows, `/goal clear` clears 10-30 #397 | Autonomy with a check, not a model's say-so | ? |
Expand Down
19 changes: 18 additions & 1 deletion sonny/skills/memory-cleanup/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,11 @@ of it ends `tool_unserved`, run the same command with
out when that is null). It lists what the person wrote to any agent
since the last cleanup, newest first, each message with its agent
(`bot`), `turn`, `text` and `project` (null when the agent's folder is
in none); `cut: true` means older messages were left out. Save a fact
in none); `cut: true` means older messages were left out. A message
with `reacted_to` is their reaction to the agent's reply that begins
with that line (null when the reply is out of reach): feedback on that
reply. One with `recommended` answers an agent's ask, and names the
option the agent recommended. Save a fact
for what they said that a later agent would otherwise have to ask
again and no fact holds yet: a decision and why, a correction, how
they want work done, a constraint, where something lives. Follow the
Expand Down Expand Up @@ -87,6 +91,19 @@ of it ends `tool_unserved`, run the same command with
- Save how they took what an agent did or said unasked, welcomed or
pushed back on, to the fact that holds how far agents may go on
their own, or a new `feedback` fact. Later agents follow it.
- Look the same way for what worked: an approach they welcomed in
words or with a reaction, or picked when an agent recommended it,
more than once. Save it as a `feedback` fact with each time's
source (`turn:BOT/TURN`) and date in its text, or add to the one
that holds it, and raise its `confirmed` count by the times not
listed there yet; raise `pushed_back` the same way on a fact whose
approach they pushed back on or picked against. A turn can reach
two nights, so a time already listed counts nothing again. Save the
approach, never that they liked being agreed with. Picking against
the recommendation on one kind of question again and again is a
preference too: save what they pick.
- A fact pushed back on more times than it was confirmed no longer
holds: remove it, or save what still holds.
- Only what the person wrote counts. In chat and mail read what they
sent, not what others sent them, and save nothing about other
people. Read only: send, reply, mark or move nothing there. A fact
Expand Down
Loading
Loading