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