From fede0925ed64ccaf97eafd930949804ddbb7b87c Mon Sep 17 00:00:00 2001 From: Viet Nguyen Date: Fri, 14 Aug 2026 10:06:21 +0000 Subject: [PATCH] feat(agent): let an agent sleep on it, and wake up knowing the same things in fewer words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory only ever grew. `MEMORY.md` gains a line every time an agent is told something and loses one only when a person deletes it, so after a month it holds the same fact written three ways, a correction sitting above the thing it corrected, and a note about a project that finished in March — all of it in every prompt, forever. So, optionally, once a night: the agent re-reads what it knows and writes back a shorter version that says the same things. Duplicates merge, the later correction wins, what is plainly finished falls away. Three rules make it safe to leave running, and they are enforced in code rather than asked for in the prompt: * **Nothing is invented.** The model sees only what the agent already wrote down. A dream that could add a belief would be a system quietly rewriting what it thinks of you, overnight, forever. * **Nothing is lost.** The previous memory is copied into `DREAMS.md` — verbatim, before the new one is written — so a bad night is one file to open and one block to paste back. * **It refuses rather than guesses.** An empty answer, an answer longer than what it was given, or one that dropped more than half the lines is discarded and the memory left untouched. "The model returned nothing useful" must never mean "the agent forgot everything". Off by default: it costs a language-model call per agent per day, and on the first day there is nothing to consolidate — a memory of four lines is already as short as it gets. The daemon checks on a timer rather than scheduling for 3 a.m., because a laptop is asleep at 3 a.m. and a cron-shaped design would only work for people who leave a desktop running. Never while recording. A night that refused still counts as slept, or the daemon would ask the same question every half hour until the memory changed. Measured on the running daemon against a stub provider: six lines in, four out, the duplicate pair merged, and the old six still readable in `DREAMS.md`. --- apps/web/src/components/agents/Dream.tsx | 120 ++++++++ apps/web/src/i18n/en.json | 10 +- apps/web/src/i18n/ja.json | 10 +- apps/web/src/i18n/vi.json | 10 +- apps/web/src/i18n/zh.json | 10 +- apps/web/src/screens/AgentsScreen.tsx | 2 + crates/summo-agent/src/memory.rs | 20 ++ crates/summo-core/src/settings.rs | 25 ++ crates/summo-engine/src/dream.rs | 332 +++++++++++++++++++++++ crates/summo-engine/src/lib.rs | 1 + crates/summo-engine/src/server.rs | 129 +++++++++ crates/summo-llm/src/prompt.rs | 36 +++ 12 files changed, 701 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/agents/Dream.tsx create mode 100644 crates/summo-engine/src/dream.rs diff --git a/apps/web/src/components/agents/Dream.tsx b/apps/web/src/components/agents/Dream.tsx new file mode 100644 index 0000000..3ba3f95 --- /dev/null +++ b/apps/web/src/components/agents/Dream.tsx @@ -0,0 +1,120 @@ +import { Moon } from "lucide-react"; +import { useCallback, useState } from "react"; + +import { useI18n } from "../../i18n/context"; +import { useEngine } from "../../lib/engine-context"; +import { useErrorText } from "../../lib/errors"; +import { readJson } from "../../lib/errors"; +import { url } from "../../lib/library"; +import { useLoad } from "../../lib/use-load"; +import { Button } from "../ui"; + +interface State { + dream: boolean; + hour: number; + last: { + day: string; + agents: { agent: string; before: number; after: number; refused?: string }[]; + } | null; +} + +/** + * Let the agents sleep on it. + * + * Memory only ever grew: the same fact written three ways, a correction sitting above the thing it + * corrected, a note about a project that finished in March — all of it in every prompt, forever. + * Once a night the agent re-reads what it knows and writes back a shorter version that says the + * same things. + * + * Off unless asked, because it is a language-model call per agent per day, and it says what it did: + * how many lines went in, how many came out, or why the night was thrown away. The previous memory + * is kept in `DREAMS.md` beside the agent, so a bad night is one file to open and one block to + * paste back. + */ +export function DreamPanel() { + const { handshake } = useEngine(); + const { t } = useI18n(); + const say = useErrorText(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + const state = useLoad( + useCallback( + async () => readJson(await fetch(url(handshake, "/agent/dream"))), + [handshake], + ), + [handshake], + ); + + const send = async (body: Record) => { + setBusy(true); + setError(null); + try { + await readJson( + await fetch(url(handshake, "/agent/dream"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + state.reload(); + } catch (e) { + setError(say(e)); + } finally { + setBusy(false); + } + }; + + const on = state.data?.dream ?? false; + const last = state.data?.last ?? null; + + return ( +
+
+
+

{t("agents.dream_hint")}

+ + {error && ( +

+ {error} +

+ )} + + {last && ( +
    + {last.agents.map((one) => ( +
  • + {one.agent} + {" — "} + {one.refused + ? t("agents.dream_refused", { why: one.refused }) + : t("agents.dream_shrank", { + before: String(one.before), + after: String(one.after), + })} +
  • + ))} + {last.agents.length === 0 &&
  • {t("agents.dream_none")}
  • } +
+ )} +
+ ); +} diff --git a/apps/web/src/i18n/en.json b/apps/web/src/i18n/en.json index 556e523..1d73be4 100644 --- a/apps/web/src/i18n/en.json +++ b/apps/web/src/i18n/en.json @@ -519,7 +519,15 @@ "task_count_one": "{count} open task", "task_count_other": "{count} open tasks", "dangling": "{from} can hand work to “{to}”, but no agent by that name exists.", - "unreadable": "Could not read {path}: {reason}" + "unreadable": "Could not read {path}: {reason}", + "dream": "Sleep on it", + "dream_nightly": "Every night, after {hour}:00", + "dream_now": "Do it now", + "dream_working": "Thinking…", + "dream_hint": "The agent re-reads its own memory and writes it shorter: merge duplicates, keep the latest correction, drop what is finished. It adds nothing. The old version stays in DREAMS.md.", + "dream_refused": "skipped — {why}", + "dream_shrank": "{before} → {after} lines", + "dream_none": "No night yet." }, "finder": { "title": "Find", diff --git a/apps/web/src/i18n/ja.json b/apps/web/src/i18n/ja.json index c5cfd73..d6c289b 100644 --- a/apps/web/src/i18n/ja.json +++ b/apps/web/src/i18n/ja.json @@ -519,7 +519,15 @@ "task_count_one": "未完了{count}件", "task_count_other": "未完了{count}件", "dangling": "{from}は「{to}」に仕事を渡せますが、その名前のエージェントは存在しません。", - "unreadable": "{path}を読めませんでした:{reason}" + "unreadable": "{path}を読めませんでした:{reason}", + "dream": "寝かせて整理", + "dream_nightly": "毎晩 {hour} 時以降", + "dream_now": "今すぐ整理", + "dream_working": "整理中…", + "dream_hint": "エージェントが自分の記憶を読み直して短くします:重複をまとめ、最新の訂正を残し、終わったことを落とす。新しいことは足しません。元の版は DREAMS.md に残ります。", + "dream_refused": "見送り — {why}", + "dream_shrank": "{before} → {after} 行", + "dream_none": "まだ一度もありません。" }, "finder": { "title": "絞り込み", diff --git a/apps/web/src/i18n/vi.json b/apps/web/src/i18n/vi.json index f258065..1012c9e 100644 --- a/apps/web/src/i18n/vi.json +++ b/apps/web/src/i18n/vi.json @@ -519,7 +519,15 @@ "task_count_one": "{count} việc treo", "task_count_other": "{count} việc treo", "dangling": "{from} có thể giao việc cho “{to}”, nhưng không có agent nào tên đó.", - "unreadable": "Không đọc được {path}: {reason}" + "unreadable": "Không đọc được {path}: {reason}", + "dream": "Ngủ & ôn lại", + "dream_nightly": "Mỗi đêm, sau {hour} giờ", + "dream_now": "Ôn ngay", + "dream_working": "Đang ôn…", + "dream_hint": "Agent đọc lại trí nhớ của nó rồi viết gọn: gộp trùng, giữ bản sửa mới nhất, bỏ việc đã xong. Không thêm điều gì mới. Bản cũ vẫn nằm trong DREAMS.md.", + "dream_refused": "bỏ qua — {why}", + "dream_shrank": "{before} → {after} dòng", + "dream_none": "Chưa ôn lần nào." }, "finder": { "title": "Tìm", diff --git a/apps/web/src/i18n/zh.json b/apps/web/src/i18n/zh.json index 8814d02..287cee6 100644 --- a/apps/web/src/i18n/zh.json +++ b/apps/web/src/i18n/zh.json @@ -519,7 +519,15 @@ "task_count_one": "{count}项未完成", "task_count_other": "{count}项未完成", "dangling": "{from}可以把活交给“{to}”,但没有叫这个名字的智能体。", - "unreadable": "读不了{path}:{reason}" + "unreadable": "读不了{path}:{reason}", + "dream": "睡一觉再整理", + "dream_nightly": "每晚 {hour} 点后", + "dream_now": "立即整理", + "dream_working": "整理中…", + "dream_hint": "助理重读自己的记忆并写得更短:合并重复、保留最新的更正、去掉已完成的。不会新增任何内容。旧版本保留在 DREAMS.md。", + "dream_refused": "跳过 — {why}", + "dream_shrank": "{before} → {after} 行", + "dream_none": "还没有整理过。" }, "finder": { "title": "筛选", diff --git a/apps/web/src/screens/AgentsScreen.tsx b/apps/web/src/screens/AgentsScreen.tsx index 398d8af..0f2bef9 100644 --- a/apps/web/src/screens/AgentsScreen.tsx +++ b/apps/web/src/screens/AgentsScreen.tsx @@ -1,3 +1,4 @@ +import { DreamPanel } from "../components/agents/Dream"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useMemo, useRef, useState } from "react"; @@ -151,6 +152,7 @@ export function AgentsScreen() { return ( + {error && (

diff --git a/crates/summo-agent/src/memory.rs b/crates/summo-agent/src/memory.rs index 574bfb5..907b550 100644 --- a/crates/summo-agent/src/memory.rs +++ b/crates/summo-agent/src/memory.rs @@ -124,6 +124,26 @@ pub fn remember(path: &Path, today: &str, text: &str) -> Result { Ok(true) } +/// Replace the whole list, keeping the file's shape. +/// +/// For consolidation — see `summo_engine::dream`, the one caller. Deliberately not exposed as a +/// tool: an agent that could rewrite its own memory wholesale mid-run could erase a correction it +/// had just been given, and the value of memory is that the user can rely on what they put in it. +/// A night's consolidation is a different act, and it archives what it replaces before it writes. +pub fn replace(path: &Path, facts: &[String], today: &str) -> Result<()> { + let mut out = String::from("# Memory\n\n"); + for text in facts.iter().take(MAX_LINES) { + let text = text.trim(); + if !text.is_empty() { + out.push_str(&format!("- {today} — {text}\n")); + } + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| Error::io(parent, e))?; + } + std::fs::write(path, out).map_err(|e| Error::io(path, e)) +} + /// Forget one fact, by exact text. What makes the memory the user's rather than the agent's. pub fn forget(path: &Path, text: &str) -> Result { let facts = load(path); diff --git a/crates/summo-core/src/settings.rs b/crates/summo-core/src/settings.rs index 789af3b..9542de5 100644 --- a/crates/summo-core/src/settings.rs +++ b/crates/summo-core/src/settings.rs @@ -25,11 +25,26 @@ pub struct Settings { pub llm: Llm, pub storage: Storage, pub interface: Interface, + pub agents: Agents, /// Fields this build does not know about, kept so a downgrade does not erase them. #[serde(flatten)] pub unknown: BTreeMap, } +/// What the agents do when nobody is asking. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Agents { + /// Once a day, let each agent re-read and shorten its own memory. + /// + /// Off by default. It costs a language-model call per agent per day, and on the first day + /// there is nothing to consolidate — a memory of four lines is already as short as it gets. + pub dream: bool, + /// Hour, local, after which it may happen. Late, because it is unattended work on a file that + /// steers every later answer, and the user should be asleep rather than mid-sentence. + pub dream_hour: u8, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct Recording { @@ -168,6 +183,15 @@ pub struct Interface { pub show_performance: bool, } +impl Default for Agents { + fn default() -> Self { + Self { + dream: false, + dream_hour: 3, + } + } +} + impl Default for Settings { fn default() -> Self { Self { @@ -177,6 +201,7 @@ impl Default for Settings { llm: Llm::default(), storage: Storage::default(), interface: Interface::default(), + agents: Agents::default(), unknown: BTreeMap::new(), } } diff --git a/crates/summo-engine/src/dream.rs b/crates/summo-engine/src/dream.rs new file mode 100644 index 0000000..85550b3 --- /dev/null +++ b/crates/summo-engine/src/dream.rs @@ -0,0 +1,332 @@ +//! What an agent does overnight. +//! +//! Two files grow every day and neither ever improves: `MEMORY.md`, one line per thing the agent +//! was told, and `HABITS.md`, one line per thing it was asked. After a month the memory holds the +//! same fact written three ways, a correction sitting above the thing it corrected, and a note +//! about a project that finished in March. Every one of those goes into every prompt. +//! +//! So once a day, if the user turns it on, the agent sleeps on it: reads what it knows, and writes +//! back a shorter version that says the same things. Duplicates merge, the correction wins over +//! what it corrected, and what is plainly finished falls away. +//! +//! ## Three rules that make this safe to leave running +//! +//! * **Nothing is invented.** The model is given only what the agent already wrote down and asked +//! to compress it. A dream that could add a belief would be a system that quietly rewrites what +//! it thinks of you, overnight, forever. +//! * **Nothing is lost.** The previous memory is copied into `DREAMS.md` before the new one is +//! written. Every night is a numbered entry with what changed, so a bad dream is one file to open +//! and one block to paste back. +//! * **It refuses rather than guesses.** An empty answer, an answer longer than what it started +//! with, or an answer that dropped more than half the lines is discarded and the memory left +//! exactly as it was. A consolidation that deletes most of an agent's memory is not a +//! consolidation, and the failure mode of "the model returned nothing useful" must not be +//! "the agent forgot everything". +//! +//! ## Why it is off by default +//! +//! It costs a language-model call per agent per day, for a benefit nobody asked for on the first +//! day of use — an empty memory has nothing to consolidate. It is a switch in Settings, and the +//! daemon only wakes it after the hour set there, never while a meeting is being recorded. + +use serde::{Deserialize, Serialize}; +use summo_agent::{habits, memory, roster::Roster}; +use summo_core::{Error, Result, paths::Paths}; +use summo_llm::LlmClient; + +/// What a night's sleep did. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Dreamt { + pub agent: String, + pub day: String, + pub before: usize, + pub after: usize, + /// Present when the dream was discarded, saying why. Shown rather than logged: a feature that + /// silently does nothing is one the user cannot tell from a feature that is broken. + #[serde(skip_serializing_if = "Option::is_none")] + pub refused: Option, +} + +/// The most a night may forget, as a fraction of what there was. +/// +/// Half. Merging three ways of saying one thing is the job; coming back with two lines out of forty +/// is a model that failed to follow the instruction, and applying it would cost a month of memory +/// to save a prompt. +const KEEP_AT_LEAST: f32 = 0.5; + +/// Consolidate one agent's memory. `None` means every agent in the roster. +pub async fn run( + paths: &Paths, + client: &LlmClient, + slug: Option<&str>, + day: &str, +) -> Result> { + let roster = Roster::load_or_seed(&paths.agents())?; + let agents: Vec<_> = match slug { + Some(slug) => roster + .get(slug) + .map(|agent| vec![agent.clone()]) + .ok_or_else(|| Error::msg("dream.no_agent", format!("không có agent {slug}")))?, + None => roster.all().cloned().collect(), + }; + + let asks = habits::habits(&habits::load(&paths.agents())); + let mut out = Vec::new(); + for agent in agents { + out.push(dream_one(&agent, client, &asks, day).await?); + } + Ok(out) +} + +async fn dream_one( + agent: &summo_agent::roster::AgentDef, + client: &LlmClient, + asks: &[habits::Habit], + day: &str, +) -> Result { + let path = agent.memory_path(); + let before = memory::load(&path); + let mut dreamt = Dreamt { + agent: agent.slug.clone(), + day: day.to_string(), + before: before.len(), + after: before.len(), + refused: None, + }; + + // Nothing to think about. Not an error, and not worth a request to a model: an agent used twice + // has a memory that is already as short as it can be. + if before.len() < 4 { + dreamt.refused = Some("chưa có gì để ôn lại".into()); + return Ok(dreamt); + } + + let messages = summo_llm::prompt::consolidate( + &memory::render(&before), + &habits::render(asks), + &agent.head.name, + ); + let response = client.complete(&messages).await?; + let proposed = lines(&response); + + if let Some(reason) = refuse(&before, &proposed) { + dreamt.refused = Some(reason); + return Ok(dreamt); + } + + // The old memory first, then the new one. In that order, so a crash between the two leaves the + // copy rather than leaving nothing. + archive(agent, day, &memory::render(&before), &proposed.join("\n"))?; + memory::replace(&path, &proposed, day)?; + dreamt.after = proposed.len(); + Ok(dreamt) +} + +/// Whether to throw the night away, and why. +fn refuse(before: &[memory::Fact], proposed: &[String]) -> Option { + if proposed.is_empty() { + return Some("model không trả về gì".into()); + } + if proposed.len() > before.len() { + // Consolidation that produces more lines than it was given is not consolidation; it is a + // model elaborating, which is the one thing this must never do to a memory. + return Some("model viết dài ra thay vì gọn lại".into()); + } + #[allow(clippy::cast_precision_loss)] + let kept = proposed.len() as f32 / before.len() as f32; + if kept < KEEP_AT_LEAST { + return Some(format!( + "bỏ mất quá nhiều ({} → {} dòng)", + before.len(), + proposed.len() + )); + } + None +} + +/// Bullets out of whatever the model returned. +fn lines(response: &str) -> Vec { + response + .lines() + .filter_map(|line| { + let line = line.trim(); + let text = line + .strip_prefix("- ") + .or_else(|| line.strip_prefix("* ")) + .unwrap_or_else(|| { + // A model that answered in plain sentences is still answering; only a heading + // or a fence is noise. + if line.starts_with('#') || line.starts_with("```") { + "" + } else { + line + } + }) + .trim(); + (!text.is_empty()).then(|| text.to_string()) + }) + .take(memory::MAX_LINES) + .collect() +} + +/// Keep the night in `DREAMS.md`, newest last. +/// +/// The whole previous memory, verbatim, because that is what makes this reversible by a person +/// with a text editor and no undo history. +fn archive( + agent: &summo_agent::roster::AgentDef, + day: &str, + before: &str, + after: &str, +) -> Result<()> { + let path = agent.dir.join("DREAMS.md"); + let mut out = std::fs::read_to_string(&path).unwrap_or_else(|_| { + String::from( + "# Những đêm đã ngủ\n\nMỗi mục là trí nhớ trước và sau một lần ôn lại. Không thích \ + thì chép phần \"Trước\" ngược lại vào MEMORY.md.\n", + ) + }); + out.push_str(&format!( + "\n## {day}\n\n### Trước\n\n{before}\n\n### Sau\n\n{after}\n" + )); + summo_vault::write::write_atomically(&path, out.as_bytes()) +} + +/// The last night, for the interface to show and the scheduler to avoid repeating. +/// +/// `~/.summo/dreams.json`, outside the vault: it is a fact about this installation's clock, not +/// about the notes, and syncing it would make two machines argue about whose night it was. +#[must_use] +pub fn last(paths: &Paths) -> Option { + let text = std::fs::read_to_string(paths.root().join("dreams.json")).ok()?; + serde_json::from_str(&text).ok() +} + +/// Write down that tonight happened, whatever it did. +/// +/// Recorded even when every agent refused, and that is deliberate: without it a daemon left +/// running would retry a refusal every half hour, which is a language-model call every half hour +/// for an answer that will not change until the memory does. +pub fn mark(paths: &Paths, day: &str, dreamt: &[Dreamt]) { + let path = paths.root().join("dreams.json"); + let record = serde_json::json!({ "day": day, "agents": dreamt }); + if let Ok(bytes) = serde_json::to_vec_pretty(&record) { + let _ = summo_vault::write::write_atomically(&path, &bytes); + } +} + +/// Whether tonight is still owed, given the clock and what was written down. +#[must_use] +pub fn due(paths: &Paths, today: &str, hour: u8, after: u8, recording: bool) -> bool { + if recording || hour < after { + return false; + } + last(paths) + .and_then(|record| { + record + .get("day") + .and_then(|d| d.as_str()) + .map(|d| d != today) + }) + .unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn facts(n: usize) -> Vec { + (0..n) + .map(|i| memory::Fact { + learned: "2026-08-14".into(), + text: format!("điều {i}"), + }) + .collect() + } + + /// The failure that would matter: a model answers with nothing, or with one line, and an agent + /// that knew forty things knows one. Refused, and the memory is untouched. + #[test] + fn a_night_that_forgets_too_much_is_thrown_away() { + let before = facts(40); + assert!(refuse(&before, &[]).is_some()); + assert!(refuse(&before, &["chỉ còn một dòng".into()]).is_some()); + assert!( + refuse( + &before, + &facts(10).iter().map(|f| f.text.clone()).collect::>() + ) + .is_some() + ); + } + + #[test] + fn a_night_that_writes_more_than_it_read_is_thrown_away() { + let before = facts(5); + let longer: Vec = (0..9).map(|i| format!("dòng {i}")).collect(); + assert!(refuse(&before, &longer).is_some()); + } + + #[test] + fn a_real_consolidation_is_kept() { + let before = facts(10); + let after: Vec = (0..7).map(|i| format!("gọn {i}")).collect(); + assert!(refuse(&before, &after).is_none()); + } + + #[test] + fn a_night_happens_once_and_not_before_its_hour() { + let tmp = tempfile::tempdir().unwrap(); + let paths = Paths::at(tmp.path()); + std::fs::create_dir_all(paths.root()).unwrap(); + + assert!( + !due(&paths, "2026-08-14", 1, 3, false), + "not before the hour" + ); + assert!( + !due(&paths, "2026-08-14", 5, 3, true), + "never while recording" + ); + assert!( + due(&paths, "2026-08-14", 5, 3, false), + "owed, and nothing written down" + ); + + mark(&paths, "2026-08-14", &[]); + assert!( + !due(&paths, "2026-08-14", 5, 3, false), + "already slept tonight" + ); + // Even a night where every agent refused counts as slept: otherwise the daemon asks a + // model the same question every half hour until the memory changes. + assert!( + due(&paths, "2026-08-15", 5, 3, false), + "tomorrow is owed again" + ); + } + + #[test] + fn bullets_headings_and_fences_are_sorted_out() { + let parsed = lines( + "# Memory\n\n- Ngọc phụ trách sản phẩm\n* Bình lo hợp đồng\n\nkhông gạch đầu dòng\n```\n", + ); + assert_eq!( + parsed, + vec![ + "Ngọc phụ trách sản phẩm", + "Bình lo hợp đồng", + "không gạch đầu dòng" + ] + ); + } + + #[test] + fn a_model_that_rambles_is_capped() { + let long = (0..memory::MAX_LINES + 20) + .map(|i| format!("- dòng {i}")) + .collect::>() + .join("\n"); + assert_eq!(lines(&long).len(), memory::MAX_LINES); + } +} diff --git a/crates/summo-engine/src/lib.rs b/crates/summo-engine/src/lib.rs index 9ea34ec..76400b5 100644 --- a/crates/summo-engine/src/lib.rs +++ b/crates/summo-engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod board; pub mod calsync; pub mod collaborate; pub mod draft; +pub mod dream; pub mod embedded; pub mod errand; pub mod imports; diff --git a/crates/summo-engine/src/server.rs b/crates/summo-engine/src/server.rs index 42f8e7b..8dd7dee 100644 --- a/crates/summo-engine/src/server.rs +++ b/crates/summo-engine/src/server.rs @@ -149,6 +149,7 @@ impl Server { .route("/settings/models", post(set_models)) .route("/agent/run", post(run_errand)) .route("/agent/habits", get(habits)) + .route("/agent/dream", get(dream_state).post(dream_now)) .route("/status", get(status)) .route("/shutdown", post(shutdown)) .route("/storage", get(storage)) @@ -289,6 +290,50 @@ impl Server { }); } + // A night's sleep, if the user asked for one. + // + // Checked on a timer rather than scheduled for the hour: a laptop is asleep at three in the + // morning, and a cron-shaped design would mean the feature works only for people who leave + // a desktop running. This runs on the first check after the hour, which for most machines + // is the moment the lid opens. + { + let engine = engine.clone(); + tokio::spawn(async move { + loop { + tokio::time::sleep(std::time::Duration::from_secs(1800)).await; + let paths = engine.paths(); + let settings = + summo_core::Settings::load(&paths.settings()).unwrap_or_default(); + if !settings.agents.dream { + continue; + } + let now = time::OffsetDateTime::now_local() + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let today = now.date().to_string(); + if !crate::dream::due( + paths, + &today, + now.hour(), + settings.agents.dream_hour, + engine.status().is_recording(), + ) { + continue; + } + let Ok(client) = llm_for_engine(&engine) else { + // No model configured is not an error to report at three in the morning. + continue; + }; + match crate::dream::run(paths, &client, None, &today).await { + Ok(dreamt) => { + tracing::info!(?dreamt, "agents slept on it"); + crate::dream::mark(paths, &today, &dreamt); + } + Err(e) => tracing::warn!(error = %e, "a night's consolidation failed"), + } + } + }); + } + let handle = tokio::spawn(async move { if let Err(e) = axum::serve(listener, app).await { tracing::error!(error = %e, "server stopped"); @@ -600,6 +645,90 @@ async fn habits( as_response(Ok(summo_agent::habits::habits(&asks))) } +/// Whether the agents sleep on it, and what the last night did. +async fn dream_state( + State(state): State, + headers: HeaderMap, + Query(q): Query, +) -> impl IntoResponse { + if let Err(rejection) = state.guard(&headers, q.token.as_deref()) { + return rejection.into_response(); + } + let paths = state.engine.paths(); + let settings = summo_core::Settings::load(&paths.settings()).unwrap_or_default(); + as_response(Ok(serde_json::json!({ + "dream": settings.agents.dream, + "hour": settings.agents.dream_hour, + "last": crate::dream::last(paths), + }))) +} + +#[derive(Debug, Default, Deserialize)] +struct DreamBody { + /// One agent, or all of them. + #[serde(default)] + agent: Option, + /// Turn the nightly pass on or off. Absent leaves the setting alone, so "do it now" and + /// "do it every night" are separate decisions. + #[serde(default)] + dream: Option, + #[serde(default)] + hour: Option, + /// Run one now. Off by default: this endpoint is also how the switch is set. + #[serde(default)] + now: bool, +} + +/// Change the setting, run a night by hand, or both. +async fn dream_now( + State(state): State, + headers: HeaderMap, + Query(q): Query, + body: Option>, +) -> impl IntoResponse { + if let Err(rejection) = state.guard(&headers, q.token.as_deref()) { + return rejection.into_response(); + } + let body = body.map(|Json(b)| b).unwrap_or_default(); + let paths = state.engine.paths(); + + if body.dream.is_some() || body.hour.is_some() { + let path = paths.settings(); + let result = summo_core::Settings::load(&path).and_then(|mut settings| { + if let Some(dream) = body.dream { + settings.agents.dream = dream; + } + if let Some(hour) = body.hour { + settings.agents.dream_hour = hour.min(23); + } + settings.save(&path) + }); + if let Err(e) = result { + return as_response(Err::(e)); + } + } + + if !body.now { + return as_response(Ok(serde_json::json!({ "dreamt": [] }))); + } + // Never during a meeting: it is a language-model call and a rewrite of a file the live pipeline + // reads, at the one moment the machine is busiest. + if state.engine.status().is_recording() { + return as_response(Ok( + serde_json::json!({ "dreamt": [], "skipped": "đang ghi âm" }), + )); + } + let client = match llm_client(&state) { + Ok(client) => client, + Err(e) => return as_response(Err::(e)), + }; + let done = crate::dream::run(paths, &client, body.agent.as_deref(), &summo_core::today()).await; + as_response(done.map(|dreamt| { + crate::dream::mark(paths, &summo_core::today(), &dreamt); + serde_json::json!({ "dreamt": dreamt }) + })) +} + /// Hand an agent a sentence. /// /// The instruction becomes a `- [ ] @agent …` checkbox in the day's scratch note and runs through diff --git a/crates/summo-llm/src/prompt.rs b/crates/summo-llm/src/prompt.rs index 1503142..a15cb1b 100644 --- a/crates/summo-llm/src/prompt.rs +++ b/crates/summo-llm/src/prompt.rs @@ -275,6 +275,42 @@ pub fn answer(question: &str, context: &str, language: &str) -> Vec { ] } +/// Ask a model to shorten what an agent knows, without changing what it knows. +/// +/// The instruction is almost entirely a list of things not to do, and that is the right shape for +/// it. This runs unattended, overnight, on the file that steers every later answer — so the failure +/// worth preventing is not a mediocre summary, it is a model that helpfully adds "the user prefers +/// short answers" to a memory nobody said that in. +/// +/// Habits are shown but never merged into the output. They are a different file with a different +/// owner: memory is what the agent was told, habits are what it was asked, and a night that folded +/// one into the other would make both unreadable. +#[must_use] +pub fn consolidate(memory: &str, habits: &str, agent: &str) -> Vec { + let context = if habits.trim().is_empty() { + String::new() + } else { + format!("\n\nFor context only — do NOT copy these into your answer:\n{habits}") + }; + vec![ + Message::system(format!( + "You are tidying the memory of an assistant called {agent}. You will be given its \ + memory as a list of lines.\n\nReturn the same knowledge in fewer lines:\n\ + - Merge lines that say the same thing, keeping the clearest wording.\n\ + - When two lines contradict, keep only the later one — it is a correction.\n\ + - Drop what is plainly finished or was only true of one past day.\n\n\ + Hard rules. Breaking any of them makes the answer useless:\n\ + - Add NOTHING. Every line you return must be supported by a line you were given. Do \ + not infer preferences, personality, or conclusions about anyone.\n\ + - Keep every fact that is still true, even if it seems minor.\n\ + - Never return fewer than half the lines you were given.\n\ + - Answer with the list and nothing else: one line each, no headings, no numbering, no \ + explanation of what you changed." + )), + Message::user(format!("Memory:\n\n{memory}{context}")), + ] +} + /// Rewrite one selected passage, and nothing else. /// /// The model is given the whole section for context but asked to return **only** the replacement