mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
feat: seed ad-hoc memory extension instructions (#20606)
## Summary Ad-hoc memory notes are written under `memories/extensions/ad_hoc/`, but the consolidation agent only knows how to interpret an extension when the extension folder has an `instructions.md`. Seed those instructions from the memories write pipeline so an enabled memories startup creates the expected ad-hoc extension layout automatically. This also moves extension-specific write behavior behind a dedicated `memories/write/src/extensions/` module. `ad_hoc` owns the seeded instructions template, while the existing resource-retention cleanup lives in its own `prune` module so future memory extensions can add their own write-side setup without growing a flat helper file. ## Changes - Seed `memories/extensions/ad_hoc/instructions.md` during eligible memory startup without overwriting an existing file. - Store the ad-hoc instructions template under `memories/write/templates/extensions/ad_hoc/`, keeping ownership in `codex-memories-write`. - Split memory extension support into `extensions::ad_hoc` and `extensions::prune`. - Keep the existing old-resource pruning behavior unchanged. ## Verification - `cargo test -p codex-memories-write` - `bazel build //codex-rs/memories/write:write` --------- Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
70fc55b8f3
commit
ff27d01676
7 files changed
+83
-1
No files matched your search
@@ -0,0 +1,28 @@
|
||||
use crate::memory_extensions_root;
|
||||
use std::path::Path;
|
||||
|
||||
pub(super) const INSTRUCTIONS: &str =
|
||||
include_str!("../../templates/extensions/ad_hoc/instructions.md");
|
||||
|
||||
pub(super) async fn seed_instructions(memory_root: &Path) -> std::io::Result<()> {
|
||||
let extension_root = memory_extensions_root(memory_root).join("ad_hoc");
|
||||
let instructions_path = extension_root.join("instructions.md");
|
||||
|
||||
tokio::fs::create_dir_all(&extension_root).await?;
|
||||
match tokio::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&instructions_path)
|
||||
.await
|
||||
{
|
||||
Ok(mut file) => {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut file, INSTRUCTIONS.as_bytes()).await
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ad_hoc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,36 @@
|
||||
use super::*;
|
||||
use crate::memory_extensions_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn seeds_instructions_without_overwriting_existing_file() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let instructions_path = memory_extensions_root(&memory_root).join("ad_hoc/instructions.md");
|
||||
|
||||
seed_instructions(&memory_root)
|
||||
.await
|
||||
.expect("seed ad-hoc instructions");
|
||||
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&instructions_path)
|
||||
.await
|
||||
.expect("read seeded ad-hoc instructions"),
|
||||
INSTRUCTIONS
|
||||
);
|
||||
|
||||
tokio::fs::write(&instructions_path, "custom instructions")
|
||||
.await
|
||||
.expect("write custom instructions");
|
||||
seed_instructions(&memory_root)
|
||||
.await
|
||||
.expect("seed ad-hoc instructions again");
|
||||
|
||||
assert_eq!(
|
||||
tokio::fs::read_to_string(&instructions_path)
|
||||
.await
|
||||
.expect("read custom ad-hoc instructions"),
|
||||
"custom instructions"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod ad_hoc;
|
||||
mod prune;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) async fn seed_extension_instructions(memory_root: &Path) -> std::io::Result<()> {
|
||||
ad_hoc::seed_instructions(memory_root).await
|
||||
}
|
||||
|
||||
pub use prune::prune_old_extension_resources;
|
||||
@@ -0,0 +1,100 @@
|
||||
use crate::memory_extensions_root;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use tracing::warn;
|
||||
|
||||
pub async fn prune_old_extension_resources(memory_root: &Path) {
|
||||
prune_old_extension_resources_with_now(memory_root, Utc::now()).await
|
||||
}
|
||||
|
||||
async fn prune_old_extension_resources_with_now(memory_root: &Path, now: DateTime<Utc>) {
|
||||
let cutoff = now - Duration::days(crate::extension_resources::RETENTION_DAYS);
|
||||
let extensions_root = memory_extensions_root(memory_root);
|
||||
let mut extensions = match tokio::fs::read_dir(&extensions_root).await {
|
||||
Ok(extensions) => extensions,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extensions root {}: {err}",
|
||||
extensions_root.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(extension_entry)) = extensions.next_entry().await {
|
||||
let extension_path = extension_entry.path();
|
||||
let Ok(file_type) = extension_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir()
|
||||
|| !tokio::fs::try_exists(extension_path.join("instructions.md"))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let resources_path = extension_path.join("resources");
|
||||
let mut resources = match tokio::fs::read_dir(&resources_path).await {
|
||||
Ok(resources) => resources,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extension resources {}: {err}",
|
||||
resources_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(resource_entry)) = resources.next_entry().await {
|
||||
let resource_file_path = resource_entry.path();
|
||||
let Ok(file_type) = resource_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = resource_file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let Some(resource_timestamp) = resource_timestamp(file_name) else {
|
||||
continue;
|
||||
};
|
||||
if resource_timestamp > cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Err(err) = tokio::fs::remove_file(&resource_file_path).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
warn!(
|
||||
"failed pruning old memory extension resource {}: {err}",
|
||||
resource_file_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_timestamp(file_name: &str) -> Option<DateTime<Utc>> {
|
||||
let timestamp = file_name.get(..19)?;
|
||||
let naive =
|
||||
NaiveDateTime::parse_from_str(timestamp, crate::extension_resources::FILENAME_TS_FORMAT)
|
||||
.ok()?;
|
||||
Some(DateTime::from_naive_utc_and_offset(naive, Utc))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "prune_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,85 @@
|
||||
use super::*;
|
||||
use crate::memory_extensions_root;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn prunes_only_old_resources_from_extensions_with_instructions() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let extensions_root = memory_extensions_root(&memory_root);
|
||||
let chronicle_resources = extensions_root.join("chronicle/resources");
|
||||
tokio::fs::create_dir_all(&chronicle_resources)
|
||||
.await
|
||||
.expect("create chronicle resources");
|
||||
tokio::fs::write(
|
||||
extensions_root.join("chronicle/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await
|
||||
.expect("write chronicle instructions");
|
||||
|
||||
let now = DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::parse_from_str(
|
||||
"2026-04-14T12-00-00",
|
||||
crate::extension_resources::FILENAME_TS_FORMAT,
|
||||
)
|
||||
.expect("parse now"),
|
||||
Utc,
|
||||
);
|
||||
let old_file = chronicle_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
let exact_cutoff_file = chronicle_resources.join("2026-04-07T12-00-00-abcd-10min-cutoff.md");
|
||||
let recent_file = chronicle_resources.join("2026-04-08T12-00-00-abcd-10min-recent.md");
|
||||
let invalid_file = chronicle_resources.join("not-a-timestamp.md");
|
||||
for file in [&old_file, &exact_cutoff_file, &recent_file, &invalid_file] {
|
||||
tokio::fs::write(file, "resource")
|
||||
.await
|
||||
.expect("write chronicle resource");
|
||||
}
|
||||
|
||||
let ignored_resources = extensions_root.join("ignored/resources");
|
||||
tokio::fs::create_dir_all(&ignored_resources)
|
||||
.await
|
||||
.expect("create ignored resources");
|
||||
let ignored_old_file = ignored_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
tokio::fs::write(&ignored_old_file, "ignored")
|
||||
.await
|
||||
.expect("write ignored resource");
|
||||
|
||||
prune_old_extension_resources_with_now(&memory_root, now).await;
|
||||
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old file")
|
||||
);
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&exact_cutoff_file)
|
||||
.await
|
||||
.expect("check cutoff file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&recent_file)
|
||||
.await
|
||||
.expect("check recent file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&invalid_file)
|
||||
.await
|
||||
.expect("check invalid file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&ignored_old_file)
|
||||
.await
|
||||
.expect("check ignored file")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_timestamp_prefix_from_resource_file_name() {
|
||||
let parsed = resource_timestamp("2026-04-06T11-59-59-abcd-10min-old.md")
|
||||
.expect("timestamp should parse");
|
||||
|
||||
assert_eq!(parsed.timestamp(), 1_775_476_799);
|
||||
assert!(resource_timestamp("not-a-timestamp.md").is_none());
|
||||
}
|
||||
Reference in new issue
Block a user