Skip to content

Commit df4ac10

Browse files
authored
Merge pull request #522 from spacedriveapp/codex/memory-observation-p1
fix(memory): tighten persistence rules and add conversational events
2 parents 7bd70aa + 5ee42d4 commit df4ac10

5 files changed

Lines changed: 156 additions & 42 deletions

File tree

prompts/en/memory_persistence.md.j2

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,17 @@ This is an automatic process triggered periodically during conversation. You are
2828
- Use `part_of` when a detail belongs to a larger concept already in memory
2929

3030
4. **Extract events.** While reviewing the conversation, identify key decisions, important events, and errors. Include them in the `events` field of `memory_persistence_complete`:
31-
- `event_type`: "decision" for commitments or choices made, "error" for failures or problems, "system" for other notable events
31+
- `event_type`:
32+
- `"decision"` for commitments or choices made
33+
- `"user_correction"` when the user corrects a prior assumption, instruction, or framing
34+
- `"decision_revised"` when a prior choice changes after feedback or new information
35+
- `"error"` for failures or problems
36+
- `"system"` for other notable events
3237
- `summary`: one-line description of what happened
33-
- `importance`: 0.0-1.0 score (decisions and errors typically 0.6-0.8)
38+
- Normalize relative time references to absolute dates/times with timezone
39+
(for example `2026-03-31T14:20:00-04:00`) so downstream memory checks are
40+
stable across sessions.
41+
- `importance`: 0.0-1.0 score (`decision`, `user_correction`, and `decision_revised` are typically 0.6-0.8)
3442
- Events feed the agent's temporal working memory — they help the agent remember *what happened today*, not just facts.
3543

3644
5. **Finish with the terminal tool.** You must call `memory_persistence_complete` before finishing:
@@ -46,3 +54,14 @@ This is an automatic process triggered periodically during conversation. You are
4654
4. Focus on the most recent portion of the conversation — older content has likely already been captured by previous persistence runs.
4755
5. Do not invent memory IDs. Every ID in `saved_memory_ids` must come from a real successful `memory_save` call in this run.
4856
6. Do not return plain text as the terminal result. End the run by calling `memory_persistence_complete`.
57+
58+
7. Exclude non-durable information:
59+
- Raw repo-state or derivable state such as `ls`, `pwd`, environment output,
60+
git status/history, or full file diffs unless it materially changes the
61+
plan or decision.
62+
- Ephemeral task chatter: retries, progress updates, temporary tool output,
63+
or worker process chatter.
64+
65+
8. Verify stale memory before relying on it. If a recalled memory conflicts with
66+
the newer conversation context, treat the older item as stale, avoid propagating
67+
it as truth, and capture the latest truth via `updates` or `contradicts`.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
Save a memory to long-term storage. Memories persist across conversations and can be recalled later via branches.
1+
Save a validated, durable memory to long-term storage. Use this only for information that should persist across conversations and be recalled later via branches.

src/config/types.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,11 +732,20 @@ pub struct CompactionConfig {
732732
/// Spawns a silent branch every N messages to recall existing memories and save
733733
/// new ones from the recent conversation. Runs without blocking the channel and
734734
/// the result is never injected into channel history.
735+
///
736+
/// Legacy note: active working-memory persistence triggers are now configured in
737+
/// `WorkingMemoryConfig` (`persistence_message_threshold`,
738+
/// `persistence_time_threshold_secs`, `persistence_event_density_threshold`).
739+
/// Keep these values in sync only if you intentionally preserve this legacy
740+
/// branch cadence.
735741
#[derive(Debug, Clone, Copy)]
736742
pub struct MemoryPersistenceConfig {
737743
/// Whether auto memory persistence branches are enabled.
738744
pub enabled: bool,
739-
/// Number of user messages between automatic memory persistence branches.
745+
/// Legacy branch cadence in user messages.
746+
///
747+
/// Runtime checks now use the working-memory thresholds in
748+
/// `WorkingMemoryConfig`.
740749
pub message_interval: usize,
741750
}
742751

src/memory/working.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ pub enum WorkingMemoryEventType {
3939
MemorySaved,
4040
/// A decision was made (extracted from conversation).
4141
Decision,
42+
/// The user corrected prior instructions or assumptions.
43+
UserCorrection,
44+
/// A prior decision was revised.
45+
DecisionRevised,
4246
/// An error or failure occurred.
4347
Error,
4448
/// A task was created or updated.
@@ -62,6 +66,8 @@ impl WorkingMemoryEventType {
6266
Self::CronExecuted => "cron_executed",
6367
Self::MemorySaved => "memory_saved",
6468
Self::Decision => "decision",
69+
Self::UserCorrection => "user_correction",
70+
Self::DecisionRevised => "decision_revised",
6571
Self::Error => "error",
6672
Self::TaskUpdate => "task_update",
6773
Self::AgentMessage => "agent_message",
@@ -79,6 +85,8 @@ impl WorkingMemoryEventType {
7985
"cron_executed" => Some(Self::CronExecuted),
8086
"memory_saved" => Some(Self::MemorySaved),
8187
"decision" => Some(Self::Decision),
88+
"user_correction" => Some(Self::UserCorrection),
89+
"decision_revised" => Some(Self::DecisionRevised),
8290
"error" => Some(Self::Error),
8391
"task_update" => Some(Self::TaskUpdate),
8492
"agent_message" => Some(Self::AgentMessage),
@@ -818,6 +826,8 @@ fn format_event_line(event: &WorkingMemoryEvent, current_channel_id: &str) -> St
818826
WorkingMemoryEventType::CronExecuted => "Cron executed",
819827
WorkingMemoryEventType::MemorySaved => "Memory saved",
820828
WorkingMemoryEventType::Decision => "Decision",
829+
WorkingMemoryEventType::UserCorrection => "User correction",
830+
WorkingMemoryEventType::DecisionRevised => "Decision revised",
821831
WorkingMemoryEventType::Error => "Error",
822832
WorkingMemoryEventType::TaskUpdate => "Task update",
823833
WorkingMemoryEventType::AgentMessage => "Agent message",
@@ -1156,6 +1166,8 @@ mod tests {
11561166
WorkingMemoryEventType::CronExecuted,
11571167
WorkingMemoryEventType::MemorySaved,
11581168
WorkingMemoryEventType::Decision,
1169+
WorkingMemoryEventType::UserCorrection,
1170+
WorkingMemoryEventType::DecisionRevised,
11591171
WorkingMemoryEventType::Error,
11601172
WorkingMemoryEventType::TaskUpdate,
11611173
WorkingMemoryEventType::AgentMessage,
@@ -1178,7 +1190,7 @@ mod tests {
11781190
}
11791191

11801192
let events = store.get_events_for_day(&today).await.unwrap();
1181-
assert_eq!(events.len(), 12);
1193+
assert_eq!(events.len(), 14);
11821194

11831195
// Verify all types survived the roundtrip.
11841196
let types: Vec<WorkingMemoryEventType> = events.iter().map(|e| e.event_type).collect();

src/tools/memory_persistence_complete.rs

Lines changed: 111 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ pub struct MemoryPersistenceCompleteArgs {
109109
/// A single event extracted by the persistence branch for the working memory log.
110110
#[derive(Debug, Clone, Deserialize, JsonSchema)]
111111
pub struct WorkingMemoryEventInput {
112-
/// Event type: "decision", "error", or "system".
112+
/// Event type: "decision", "user_correction", "decision_revised", "error", or "system".
113113
pub event_type: String,
114114
/// One-line summary of the event.
115115
pub summary: String,
@@ -165,8 +165,14 @@ impl Tool for MemoryPersistenceCompleteTool {
165165
"type": "object",
166166
"properties": {
167167
"event_type": {
168-
"type": "string",
169-
"enum": ["decision", "error", "system"],
168+
"type": "string",
169+
"enum": [
170+
"decision",
171+
"user_correction",
172+
"decision_revised",
173+
"error",
174+
"system"
175+
],
170176
"description": "Type of event"
171177
},
172178
"summary": {
@@ -190,33 +196,7 @@ impl Tool for MemoryPersistenceCompleteTool {
190196
async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
191197
let outcome = args.outcome.trim();
192198
let recorded_ids = self.state.saved_memory_ids();
193-
194-
// Write any extracted events to working memory (fire-and-forget).
195-
if let Some(working_memory) = &self.working_memory {
196-
for event_input in &args.events {
197-
let event_type = match event_input.event_type.as_str() {
198-
"decision" => crate::memory::WorkingMemoryEventType::Decision,
199-
"error" => crate::memory::WorkingMemoryEventType::Error,
200-
_ => crate::memory::WorkingMemoryEventType::System,
201-
};
202-
let importance = event_input.importance.clamp(0.0, 1.0);
203-
let mut builder = working_memory
204-
.emit(event_type, &event_input.summary)
205-
.importance(importance);
206-
if let Some(channel_id) = &self.channel_id {
207-
builder = builder.channel(channel_id.clone());
208-
}
209-
builder.record();
210-
}
211-
if !args.events.is_empty() {
212-
tracing::info!(
213-
event_count = args.events.len(),
214-
"persistence branch extracted events into working memory"
215-
);
216-
}
217-
}
218-
219-
match outcome {
199+
let output = match outcome {
220200
"saved" => {
221201
if args.saved_memory_ids.is_empty() {
222202
return Err(MemoryPersistenceCompleteError(
@@ -246,12 +226,12 @@ impl Tool for MemoryPersistenceCompleteTool {
246226
)));
247227
}
248228

249-
Ok(MemoryPersistenceCompleteOutput {
229+
MemoryPersistenceCompleteOutput {
250230
success: true,
251231
outcome: "saved".to_string(),
252232
saved_memory_ids: provided_ids,
253233
reason: None,
254-
})
234+
}
255235
}
256236
"no_memories" => {
257237
if !args.saved_memory_ids.is_empty() {
@@ -273,17 +253,52 @@ impl Tool for MemoryPersistenceCompleteTool {
273253
));
274254
}
275255

276-
Ok(MemoryPersistenceCompleteOutput {
256+
MemoryPersistenceCompleteOutput {
277257
success: true,
278258
outcome: "no_memories".to_string(),
279259
saved_memory_ids: Vec::new(),
280260
reason: Some(reason.trim().to_string()),
281-
})
261+
}
262+
}
263+
_ => {
264+
return Err(MemoryPersistenceCompleteError(format!(
265+
"invalid outcome '{outcome}'; expected 'saved' or 'no_memories'"
266+
)));
267+
}
268+
};
269+
270+
// Write extracted events to working memory only after validation succeeds.
271+
if let Some(working_memory) = &self.working_memory {
272+
for event_input in &args.events {
273+
let event_type =
274+
match crate::memory::WorkingMemoryEventType::parse(&event_input.event_type) {
275+
Some(event_type) => event_type,
276+
None => {
277+
tracing::trace!(
278+
raw_event_type = %event_input.event_type,
279+
"unrecognized event_type, falling back to System"
280+
);
281+
crate::memory::WorkingMemoryEventType::System
282+
}
283+
};
284+
let importance = event_input.importance.clamp(0.0, 1.0);
285+
let mut builder = working_memory
286+
.emit(event_type, &event_input.summary)
287+
.importance(importance);
288+
if let Some(channel_id) = &self.channel_id {
289+
builder = builder.channel(channel_id.clone());
290+
}
291+
builder.record();
292+
}
293+
if !args.events.is_empty() {
294+
tracing::info!(
295+
event_count = args.events.len(),
296+
"persistence branch extracted events into working memory"
297+
);
282298
}
283-
_ => Err(MemoryPersistenceCompleteError(format!(
284-
"invalid outcome '{outcome}'; expected 'saved' or 'no_memories'"
285-
))),
286299
}
300+
301+
Ok(output)
287302
}
288303
}
289304

@@ -349,4 +364,63 @@ mod tests {
349364
assert!(output.success);
350365
assert_eq!(output.outcome, "no_memories");
351366
}
367+
368+
#[tokio::test]
369+
async fn persists_conversational_events() {
370+
let state = Arc::new(MemoryPersistenceContractState::default());
371+
372+
let pool = sqlx::SqlitePool::connect("sqlite::memory:")
373+
.await
374+
.expect("sqlite connect");
375+
sqlx::migrate!("./migrations")
376+
.run(&pool)
377+
.await
378+
.expect("migrations");
379+
380+
let working_memory = crate::memory::WorkingMemoryStore::new(pool, chrono_tz::Tz::UTC);
381+
let tool = MemoryPersistenceCompleteTool::new(state)
382+
.with_working_memory(working_memory.clone(), Some("test-channel".to_string()));
383+
384+
tool.call(MemoryPersistenceCompleteArgs {
385+
outcome: "no_memories".to_string(),
386+
saved_memory_ids: Vec::new(),
387+
reason: Some("Nothing worth retaining".to_string()),
388+
events: vec![
389+
WorkingMemoryEventInput {
390+
event_type: "user_correction".to_string(),
391+
summary: "User corrected a payment split assumption".to_string(),
392+
importance: 0.8,
393+
},
394+
WorkingMemoryEventInput {
395+
event_type: "decision_revised".to_string(),
396+
summary: "Decision changed after user feedback".to_string(),
397+
importance: 0.8,
398+
},
399+
],
400+
})
401+
.await
402+
.expect("persistence complete should pass");
403+
404+
let events = tokio::time::timeout(std::time::Duration::from_secs(2), async {
405+
loop {
406+
let events = working_memory
407+
.get_events_for_channel("test-channel", 10)
408+
.await
409+
.expect("working memory query");
410+
if events.len() == 2 {
411+
break events;
412+
}
413+
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
414+
}
415+
})
416+
.await
417+
.expect("timed out waiting for working memory events");
418+
assert_eq!(events.len(), 2);
419+
assert!(events.iter().any(|event| {
420+
event.event_type == crate::memory::WorkingMemoryEventType::UserCorrection
421+
}));
422+
assert!(events.iter().any(|event| {
423+
event.event_type == crate::memory::WorkingMemoryEventType::DecisionRevised
424+
}));
425+
}
352426
}

0 commit comments

Comments
 (0)