loopd: the memory block from memoryd, recall and memory as core tools

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-25 16:41:44 -07:00
committed by kyle
parent e167fc871b
commit 15ee4b8e3d
14 changed files with 484 additions and 87 deletions
+17 -26
View File
@@ -1,5 +1,5 @@
//! The baseline: the fixed prefix of every request in an epoch — the system prompt, core memory
//! and core tool schemas. It is snapshotted per session so an edit to `system.md` changes nothing
//! The baseline: the fixed prefix of every request in an epoch — the system prompt, the memory
//! block and core tool schemas. It is snapshotted per session so an edit to `system.md` changes nothing
//! for a running session, and `messages` turns a session log back into the request's messages.
use std::path::{Path, PathBuf};
@@ -25,7 +25,6 @@ pub enum BaselineError {
Read(PathBuf, std::io::Error),
Parse(PathBuf, serde_json::Error),
Hash,
Core(PathBuf, std::io::Error),
}
impl std::fmt::Display for BaselineError {
@@ -34,11 +33,6 @@ impl std::fmt::Display for BaselineError {
BaselineError::Read(path, err) => write!(f, "{}: {err}", path.display()),
BaselineError::Parse(path, err) => write!(f, "{}: {err}", path.display()),
BaselineError::Hash => write!(f, "hashing the baseline failed"),
BaselineError::Core(path, err) => write!(
f,
"{}: {err}; see docs/runbook.md#core-memory-unreadable",
path.display()
),
}
}
}
@@ -46,29 +40,26 @@ impl std::fmt::Display for BaselineError {
impl std::error::Error for BaselineError {}
impl Baseline {
/// The system prompt trimmed, plus core memory if it has content; the core tool schemas.
/// The system prompt trimmed, with no memory: for tests and the self-test.
pub fn assemble(cfg: &Config, registry: &Registry) -> Result<Baseline, BaselineError> {
Baseline::assemble_with(cfg, registry, "")
}
/// The system prompt trimmed, then a blank line and the memory block `memoryd` rendered (M5a
/// spec, section 4), when there is one; the core tool schemas.
pub fn assemble_with(
cfg: &Config,
registry: &Registry,
memory: &str,
) -> Result<Baseline, BaselineError> {
let system = std::fs::read_to_string(&cfg.baseline.system)
.map_err(|e| BaselineError::Read(cfg.baseline.system.clone(), e))?;
let mut system = system.trim_end().to_string();
let core = cfg.paths.home.join("memory/core.md");
match std::fs::read_to_string(&core) {
Ok(text) => {
let trimmed = text.trim();
if !trimmed.is_empty() {
system.push_str("\n\n");
system.push_str(trimmed);
}
}
// A missing file is fine; an unreadable one is reported so the owner is not given a
// session without the memory they curated and no sign of it.
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
return Err(BaselineError::Core(core.clone(), e));
}
Err(_) => {}
let memory = memory.trim();
if !memory.is_empty() {
system.push_str("\n\n");
system.push_str(memory);
}
Ok(Baseline {
system,
tools: registry.core_schemas(),
+21 -1
View File
@@ -10,6 +10,7 @@ use std::sync::{Arc, Mutex};
use crate::baseline::Baseline;
use crate::config::Config;
use crate::llama::Client;
use crate::memory::MemorySource;
use crate::session::{Session, SessionError};
use crate::tools::{Registry, ToolPort};
use crate::turn::{Runtime, TurnError, run_turn};
@@ -24,6 +25,8 @@ pub struct Context {
pub client: Client,
pub port: Box<dyn ToolPort>,
pub registry: Registry,
/// Where a new session's memory block comes from, and who hears that a session changed.
pub memory: Box<dyn MemorySource>,
busy: Mutex<HashSet<SessionId>>,
}
@@ -33,12 +36,14 @@ impl Context {
client: Client,
port: Box<dyn ToolPort>,
registry: Registry,
memory: Box<dyn MemorySource>,
) -> Context {
Context {
cfg,
client,
port,
registry,
memory,
busy: Mutex::new(HashSet::new()),
}
}
@@ -140,7 +145,11 @@ pub fn handle(stream: UnixStream, ctx: Arc<Context>) {
}
}
} else {
let baseline = match Baseline::assemble(&ctx.cfg, &ctx.registry) {
// A new session takes the memory block as it is now; no session starts without it.
let assembled = ctx.memory.block().and_then(|block| {
Baseline::assemble_with(&ctx.cfg, &ctx.registry, &block).map_err(|e| e.to_string())
});
let baseline = match assembled {
Ok(baseline) => baseline,
Err(e) => {
drop(held);
@@ -184,6 +193,17 @@ pub fn handle(stream: UnixStream, ctx: Arc<Context>) {
// 5. Release the session before the final frame, so the next turn does not find it busy.
drop(held);
// A finished turn's records are for `memoryd` to index; best effort, since `memoryd` catches
// up at its next start.
if outcome.is_ok()
&& let Err(e) = ctx.memory.updated(&turn.session)
{
eprintln!(
"loopd: memoryd was not told about {}: {e}",
turn.session.as_str()
);
}
// 6. and 7. A turn done is success; a turn error maps to a code. A write failure means the
// channel is gone, so we stop and finish quietly.
match outcome {
+19
View File
@@ -173,6 +173,16 @@ pub struct Config {
pub baseline: Baseline,
#[serde(default)]
pub broker: Broker,
#[serde(default)]
pub memory: Memory,
}
/// `[memory]`: `memoryd`'s socket for `loopd` (M5a spec, section 9). An empty path means the
/// default under `home`.
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct Memory {
pub socket: std::path::PathBuf,
}
#[derive(Debug)]
@@ -206,6 +216,15 @@ impl Config {
}
Ok(config)
}
/// `memoryd`'s socket for `loopd`.
pub fn memory_socket(&self) -> std::path::PathBuf {
if self.memory.socket.as_os_str().is_empty() {
self.paths.home.join("run/loop-memory/memory.sock")
} else {
self.memory.socket.clone()
}
}
pub fn channel_socket(&self) -> std::path::PathBuf {
if self.channel.socket.as_os_str().is_empty() {
self.paths.home.join("run/loop/loop.sock")
+1
View File
@@ -6,6 +6,7 @@ pub mod channel;
pub mod config;
pub mod http;
pub mod llama;
pub mod memory;
pub mod selftest;
pub mod session;
pub mod socket;
+3 -1
View File
@@ -12,6 +12,7 @@ use loopd::broker_port::{BrokerPort, NoBroker, not_configured_line};
use loopd::channel::{self, Context};
use loopd::config::Config;
use loopd::llama::Client;
use loopd::memory::{MEMORY_WAIT, MemoryPort};
use loopd::selftest::{SelfTestError, run};
use loopd::tools::{Registry, ToolPort};
@@ -117,7 +118,8 @@ fn run_serve(path: &Path) -> ExitCode {
Box::new(NoBroker)
}
};
let ctx = Arc::new(Context::new(cfg, client, port, Registry::m3a()));
let memory = Box::new(MemoryPort::new(cfg.memory_socket(), MEMORY_WAIT));
let ctx = Arc::new(Context::new(cfg, client, port, Registry::m5a(), memory));
if let Err(e) = channel::serve(listener, ctx) {
eprintln!("loopd: the channel server stopped: {e}");
return ExitCode::from(1);
+150
View File
@@ -0,0 +1,150 @@
//! Memory, as `loopd` sees it (M5a spec, section 9): the block `memoryd` renders for a new
//! session's baseline, the schemas of the two core memory tools, and the notice after a turn.
//! `loopd` keeps no memory itself; `brokerd` decides the tool calls and `memoryd` runs them.
use std::os::unix::net::UnixStream;
use std::path::PathBuf;
use std::time::Duration;
use proto::{
Empty, Envelope, Message, PROTOCOL_VERSION, SessionId, SessionUpdated, read_frame, write_frame,
};
use crate::llama::ToolSchema;
/// Where a new session's baseline points when there is no memory block.
pub const UNAVAILABLE: &str = "see docs/runbook.md#memory-unavailable";
/// How long `loopd` waits for `memoryd`.
pub const MEMORY_WAIT: Duration = Duration::from_secs(10);
/// Where the memory block comes from, and who hears that a session has new records.
pub trait MemorySource: Send + Sync {
/// The block for a new session. `Err` ends in `UNAVAILABLE`: no session starts without it.
fn block(&self) -> Result<String, String>;
/// The session has new records; best effort, a failure is only logged by the caller.
fn updated(&self, session: &SessionId) -> Result<(), String>;
}
/// No memory: for tests and the self-test, never for `loopd serve`.
pub struct NoMemory;
impl MemorySource for NoMemory {
fn block(&self) -> Result<String, String> {
Ok(String::new())
}
fn updated(&self, _session: &SessionId) -> Result<(), String> {
Ok(())
}
}
/// A client of `memoryd`'s `loopd` socket. One connection per request.
pub struct MemoryPort {
socket: PathBuf,
wait: Duration,
}
impl MemoryPort {
pub fn new(socket: PathBuf, wait: Duration) -> MemoryPort {
MemoryPort { socket, wait }
}
fn ask(&self, msg: Message) -> Result<Message, String> {
let mut stream = match UnixStream::connect(&self.socket) {
Ok(stream) => stream,
Err(e) => return Err(self.line(&e)),
};
if let Err(e) = stream.set_write_timeout(Some(self.wait)) {
return Err(self.line(&e));
}
if let Err(e) = stream.set_read_timeout(Some(self.wait)) {
return Err(self.line(&e));
}
let env = Envelope {
v: PROTOCOL_VERSION,
id: 1,
r#final: true,
msg,
};
if let Err(e) = write_frame(&mut stream, &env) {
return Err(self.line(&e));
}
match read_frame(&mut stream) {
Ok(env) => Ok(env.msg),
Err(e) => Err(self.line(&e)),
}
}
fn line(&self, e: &dyn std::fmt::Display) -> String {
format!("memoryd at {}: {e}; {UNAVAILABLE}", self.socket.display())
}
}
impl MemorySource for MemoryPort {
fn block(&self) -> Result<String, String> {
match self.ask(Message::MemoryBlock(Empty {}))? {
Message::MemoryBlockReply(reply) => Ok(reply.text),
Message::Error(e) => Err(format!("memoryd: {}; {UNAVAILABLE}", e.detail)),
_ => Err(format!(
"memoryd answered with the wrong message; {UNAVAILABLE}"
)),
}
}
fn updated(&self, session: &SessionId) -> Result<(), String> {
match self.ask(Message::SessionUpdated(SessionUpdated {
session: session.clone(),
}))? {
Message::Ok(_) => Ok(()),
Message::Error(e) => Err(format!("memoryd: {}", e.detail)),
_ => Err("memoryd answered with the wrong message".to_string()),
}
}
}
pub const RECALL: &str = "recall";
pub const MEMORY: &str = "memory";
pub fn recall_schema() -> ToolSchema {
ToolSchema {
name: RECALL.to_string(),
description: "Search your memory: past conversations and notes. Each hit says where it \
came from: identity, memory, or memory-tainted (from a session that read \
untrusted content)."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": { "type": "string", "description": "Words to search for." },
"k": { "type": "integer", "description": "How many hits, 1 to 10. Default 5." }
},
"required": ["query"]
}),
}
}
pub fn memory_schema() -> ToolSchema {
ToolSchema {
name: MEMORY.to_string(),
description: "Write memory. note: record something seen or told. add, replace, remove: \
edit memory.md (your work) or user.md (your owner); entries are named by \
their exact text. consolidate: rewrite a whole file, moving or dropping \
entries to notes. Takes effect in the next session."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["note", "add", "replace", "remove", "consolidate"] },
"file": { "type": "string", "enum": ["memory", "user"] },
"text": { "type": "string", "description": "The new entry, or the note." },
"old": { "type": "string", "description": "The exact text of the entry to replace or remove." },
"entries": {
"type": "array",
"description": "consolidate: every current entry once, as {old, keep: true}, {old, text}, {old, move: true} or {old, drop: true}; new entries as {text}.",
"items": { "type": "object" }
}
},
"required": ["action"]
}),
}
}
+21
View File
@@ -82,6 +82,27 @@ impl Registry {
])
}
/// The tool set for the M5a epoch: `clock`, `recall` and `memory` (core), then the four tools
/// `brokerd` decides on. `loopd serve` uses this one.
pub fn m5a() -> Registry {
let mut entries = Registry::m3a().entries;
entries.insert(
1,
Entry {
schema: crate::memory::recall_schema(),
core: true,
},
);
entries.insert(
2,
Entry {
schema: crate::memory::memory_schema(),
core: true,
},
);
Registry::new(entries)
}
/// The fixed tools array: core entries, then `find_tool`, then `call_tool`.
pub fn core_schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self