mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Add ad-hoc memory note tool (#24562)
## Why Codex memory updates currently rely on instructions that tell agents to create ad-hoc note files directly in the memory workspace. The memories extension already has a `MemoriesBackend` abstraction for local storage and future non-filesystem backends, so the ad-hoc note writer should live behind that same interface instead of baking local filesystem assumptions into the tool shape. ## What - Adds a `memories/add_ad_hoc_note` tool to the existing memories tool bundle. - Extends `MemoriesBackend` with `add_ad_hoc_note` plus request/response types so remote memory stores can implement the same operation later. - Implements the local backend by creating append-only notes under `extensions/ad_hoc/notes`. - Validates the tool-provided filename contract (`YYYY-MM-DDTHH-MM-SS-<slug>.md`), rejects path-like filenames, rejects empty notes, and uses create-new semantics so existing notes are never overwritten. - Keeps memories tool contribution behind the existing commented-out registration path; this defines the tool surface without newly exposing it through app-server. ## Test Plan - `just test -p codex-memories-extension`
This commit is contained in:
1 parent
de513a83f3
commit
3936ed221d
7 files changed
+361
-1
No files matched your search
@@ -0,0 +1,147 @@
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::backend::AddAdHocMemoryNoteRequest;
|
||||
use crate::backend::AddAdHocMemoryNoteResponse;
|
||||
use crate::backend::MemoriesBackendError;
|
||||
|
||||
use super::LocalMemoriesBackend;
|
||||
use super::path::reject_symlink;
|
||||
|
||||
const AD_HOC_NOTES_DIR: &[&str] = &["extensions", "ad_hoc", "notes"];
|
||||
const AD_HOC_NOTE_FILENAME_MAX_BYTES: usize = 128;
|
||||
const AD_HOC_NOTE_SLUG_MAX_BYTES: usize = 80;
|
||||
const TIMESTAMP_PREFIX_LEN: usize = "YYYY-MM-DDTHH-MM-SS-".len();
|
||||
|
||||
pub(super) async fn add_ad_hoc_note(
|
||||
backend: &LocalMemoriesBackend,
|
||||
request: AddAdHocMemoryNoteRequest,
|
||||
) -> Result<AddAdHocMemoryNoteResponse, MemoriesBackendError> {
|
||||
validate_filename(&request.filename)?;
|
||||
if request.note.trim().is_empty() {
|
||||
return Err(MemoriesBackendError::EmptyAdHocNote);
|
||||
}
|
||||
|
||||
let notes_dir = ensure_notes_dir(backend).await?;
|
||||
let path = notes_dir.join(&request.filename);
|
||||
let mut file = match OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => file,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(MemoriesBackendError::AdHocNoteAlreadyExists {
|
||||
filename: request.filename,
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
file.write_all(request.note.as_bytes())?;
|
||||
|
||||
Ok(AddAdHocMemoryNoteResponse {})
|
||||
}
|
||||
|
||||
async fn ensure_notes_dir(
|
||||
backend: &LocalMemoriesBackend,
|
||||
) -> Result<std::path::PathBuf, MemoriesBackendError> {
|
||||
ensure_directory(&backend.root).await?;
|
||||
let mut path = backend.root.clone();
|
||||
for component in AD_HOC_NOTES_DIR {
|
||||
path.push(component);
|
||||
ensure_directory(&path).await?;
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
async fn ensure_directory(path: &Path) -> Result<(), MemoriesBackendError> {
|
||||
match LocalMemoriesBackend::metadata_or_none(path).await? {
|
||||
Some(metadata) => {
|
||||
reject_symlink(&path.display().to_string(), &metadata)?;
|
||||
if metadata.is_dir() {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(MemoriesBackendError::invalid_path(
|
||||
path.display().to_string(),
|
||||
"must be a directory",
|
||||
));
|
||||
}
|
||||
None => tokio::fs::create_dir(path).await?,
|
||||
}
|
||||
|
||||
let Some(metadata) = LocalMemoriesBackend::metadata_or_none(path).await? else {
|
||||
return Err(MemoriesBackendError::NotFound {
|
||||
path: path.display().to_string(),
|
||||
});
|
||||
};
|
||||
reject_symlink(&path.display().to_string(), &metadata)?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(MemoriesBackendError::invalid_path(
|
||||
path.display().to_string(),
|
||||
"must be a directory",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_filename(filename: &str) -> Result<(), MemoriesBackendError> {
|
||||
if filename.len() > AD_HOC_NOTE_FILENAME_MAX_BYTES {
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"must be at most 128 bytes",
|
||||
));
|
||||
}
|
||||
let Some(stem) = filename.strip_suffix(".md") else {
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"must end with .md",
|
||||
));
|
||||
};
|
||||
let Some(slug) = stem.get(TIMESTAMP_PREFIX_LEN..) else {
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"must use YYYY-MM-DDTHH-MM-SS-<slug>.md",
|
||||
));
|
||||
};
|
||||
if !has_valid_timestamp_prefix(stem) {
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"must use YYYY-MM-DDTHH-MM-SS-<slug>.md",
|
||||
));
|
||||
}
|
||||
if slug.is_empty() || slug.len() > AD_HOC_NOTE_SLUG_MAX_BYTES {
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"slug must be 1 to 80 bytes",
|
||||
));
|
||||
}
|
||||
if !slug
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
{
|
||||
return Err(MemoriesBackendError::invalid_filename(
|
||||
filename,
|
||||
"slug must contain only lowercase ASCII letters, digits, or hyphens",
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn has_valid_timestamp_prefix(stem: &str) -> bool {
|
||||
let bytes = stem.as_bytes();
|
||||
bytes.len() > TIMESTAMP_PREFIX_LEN
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes[10] == b'T'
|
||||
&& bytes[13] == b'-'
|
||||
&& bytes[16] == b'-'
|
||||
&& bytes[19] == b'-'
|
||||
&& are_digits(&bytes[0..4])
|
||||
&& are_digits(&bytes[5..7])
|
||||
&& are_digits(&bytes[8..10])
|
||||
&& are_digits(&bytes[11..13])
|
||||
&& are_digits(&bytes[14..16])
|
||||
&& are_digits(&bytes[17..19])
|
||||
}
|
||||
|
||||
fn are_digits(bytes: &[u8]) -> bool {
|
||||
bytes.iter().all(u8::is_ascii_digit)
|
||||
}
|
||||
Reference in new issue
Block a user