extension: add memories extension

This commit is contained in:
jif-oai
2026-05-08 14:40:24 +02:00
parent b5029a6736
commit e8ca1d9de0
14 changed files with 719 additions and 0 deletions
+18
View File
@@ -3101,6 +3101,24 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
"codex-memories-mcp",
"codex-memories-read",
"codex-protocol",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-stream-parser",
"serde",
"serde_json",
"tokio",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
+3
View File
@@ -46,6 +46,7 @@ members = [
"execpolicy-legacy",
"ext/extension-api",
"ext/git-attribution",
"ext/memories",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -161,6 +162,7 @@ codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-memories = { path = "ext/memories" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -469,6 +471,7 @@ unwrap_used = "deny"
ignored = [
"codex-agent-graph-store",
"codex-extension-api",
"codex-memories",
"codex-memories-mcp",
"icu_provider",
"openssl-sys",
+6
View File
@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "memories",
crate_name = "codex_memories",
)
+26
View File
@@ -0,0 +1,26 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-memories"
version.workspace = true
[lib]
name = "codex_memories"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-memories-mcp = { workspace = true }
codex-memories-read = { workspace = true }
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-stream-parser = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
@@ -0,0 +1,34 @@
use codex_extension_api::ExtensionData;
use codex_extension_api::TurnItemContributionFuture;
use codex_extension_api::TurnItemContributor;
use codex_memories_read::citations::parse_memory_citation;
use codex_protocol::items::AgentMessageContent;
use codex_protocol::items::TurnItem;
use codex_utils_stream_parser::strip_citations;
#[derive(Debug)]
pub(crate) struct CitationContributor;
impl TurnItemContributor for CitationContributor {
fn contribute<'a>(
&'a self,
_thread_store: &'a ExtensionData,
_turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> TurnItemContributionFuture<'a> {
if let TurnItem::AgentMessage(agent_message) = item {
let text = agent_message
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect::<String>();
let (visible_text, citations) = strip_citations(&text);
agent_message.content = vec![AgentMessageContent::Text { text: visible_text }];
agent_message.memory_citation = parse_memory_citation(citations);
}
Box::pin(std::future::ready(Ok(())))
}
}
@@ -0,0 +1,7 @@
mod citations;
mod prompt;
mod tools;
pub(crate) use citations::CitationContributor;
pub(crate) use prompt::PromptContributor;
pub(crate) use tools::ToolsContributor;
@@ -0,0 +1,34 @@
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::PromptFragment;
use crate::ctx::read_surface_enabled;
#[derive(Debug)]
pub(crate) struct PromptContributor {
read_prompt: Option<String>,
}
impl PromptContributor {
pub(crate) fn new(read_prompt: Option<String>) -> Self {
Self { read_prompt }
}
}
impl ContextContributor for PromptContributor {
fn contribute(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
if !read_surface_enabled(thread_store) {
return Vec::new();
}
self.read_prompt
.as_deref()
.map(PromptFragment::developer_policy)
.into_iter()
.collect()
}
}
@@ -0,0 +1,27 @@
use codex_extension_api::ExtensionData;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolContributor;
use crate::ctx::read_surface_enabled;
use crate::tools::MemoriesTools;
#[derive(Debug)]
pub(crate) struct ToolsContributor {
tools: MemoriesTools,
}
impl ToolsContributor {
pub(crate) fn new(tools: MemoriesTools) -> Self {
Self { tools }
}
}
impl ToolContributor for ToolsContributor {
fn tools(&self, thread_store: &ExtensionData) -> Vec<ToolContribution> {
if !read_surface_enabled(thread_store) {
return Vec::new();
}
self.tools.contributions()
}
}
+12
View File
@@ -0,0 +1,12 @@
use codex_extension_api::ExtensionData;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct MemoriesReadConfig {
pub(crate) enabled: bool,
}
pub(crate) fn read_surface_enabled(thread_store: &ExtensionData) -> bool {
thread_store
.get::<MemoriesReadConfig>()
.is_some_and(|config| config.enabled)
}
+70
View File
@@ -0,0 +1,70 @@
mod contributors;
pub mod ctx;
mod tools;
use std::path::PathBuf;
use std::sync::Arc;
use crate::ctx::MemoriesReadConfig;
use codex_core::config::Config;
use codex_extension_api::CodexExtension;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadStartContributor;
use codex_features::Feature;
use codex_memories_read::build_memory_tool_developer_instructions;
use codex_memories_read::memory_root;
use codex_utils_absolute_path::AbsolutePathBuf;
use contributors::CitationContributor;
use contributors::PromptContributor;
use contributors::ToolsContributor;
use tools::MemoriesTools;
/// Extension that contributes memories read surfaces.
#[derive(Clone, Debug)]
pub struct MemoriesExtension {
prompt: Arc<PromptContributor>,
tools: Arc<ToolsContributor>,
citations: Arc<CitationContributor>,
}
impl CodexExtension<Config> for MemoriesExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<Config>) {
registry.thread_start_contributor(self.clone());
registry.prompt_contributor(self.prompt.clone());
registry.tool_contributor(self.tools.clone());
registry.turn_item_contributor(self.citations.clone());
}
}
impl ThreadStartContributor<Config> for MemoriesExtension {
fn contribute(
&self,
config: &Config,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) {
thread_store.insert(MemoriesReadConfig {
enabled: config.features.enabled(Feature::MemoryTool) && config.memories.use_memories,
});
}
}
impl MemoriesExtension {
/// Creates a memories extension from the prompt text and backing directory
/// it should expose.
pub fn new(read_prompt: Option<String>, memories_root: impl Into<PathBuf>) -> Self {
Self {
prompt: Arc::new(PromptContributor::new(read_prompt)),
tools: Arc::new(ToolsContributor::new(MemoriesTools::new(memories_root))),
citations: Arc::new(CitationContributor),
}
}
}
pub async fn extension(codex_home: &AbsolutePathBuf) -> Arc<MemoriesExtension> {
Arc::new(MemoriesExtension::new(
build_memory_tool_developer_instructions(codex_home).await,
memory_root(codex_home).to_path_buf(),
))
}
+109
View File
@@ -0,0 +1,109 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_extension_api::ToolCallError;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolHandler;
use codex_memories_mcp::LocalMemoriesBackend;
use codex_memories_mcp::backend::DEFAULT_LIST_MAX_RESULTS;
use codex_memories_mcp::backend::ListMemoriesRequest;
use codex_memories_mcp::backend::MAX_LIST_RESULTS;
use codex_memories_mcp::backend::MemoriesBackend;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use serde::Deserialize;
use serde_json::Value;
use super::backend_error;
use super::clamp_max_results;
use super::ensure_root;
use super::parse_arguments;
use super::serialize_output;
const TOOL_NAME: &str = "list_memories";
#[derive(Debug)]
pub(super) struct ListMemoriesTool {
backend: LocalMemoriesBackend,
}
impl ListMemoriesTool {
pub(super) fn new(backend: LocalMemoriesBackend) -> Self {
Self { backend }
}
pub(super) fn contribution(self: &Arc<Self>) -> ToolContribution {
let handler: Arc<dyn ToolHandler> = self.clone();
ToolContribution::new(tool_spec(), handler).allow_parallel_calls()
}
}
impl ToolHandler for ListMemoriesTool {
fn handle<'a>(
&'a self,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>> {
Box::pin(async move {
let args: ListArgs = parse_arguments(arguments)?;
ensure_root(&self.backend).await?;
let response = self
.backend
.list(ListMemoriesRequest {
path: args.path,
cursor: args.cursor,
max_results: clamp_max_results(
args.max_results,
DEFAULT_LIST_MAX_RESULTS,
MAX_LIST_RESULTS,
),
})
.await
.map_err(backend_error)?;
serialize_output(response)
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListArgs {
path: Option<String>,
cursor: Option<String>,
max_results: Option<usize>,
}
fn tool_spec() -> ResponsesApiTool {
let properties = BTreeMap::from([
(
"path".to_string(),
JsonSchema::string(Some(
"Optional relative path to list inside the Codex memories store.".to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Optional cursor returned by a previous list_memories call.".to_string(),
)),
),
(
"max_results".to_string(),
JsonSchema::integer(Some(
"Optional maximum number of entries to return.".to_string(),
)),
),
]);
ResponsesApiTool {
name: TOOL_NAME.to_string(),
description:
"List immediate files and directories under a path in the Codex memories store."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())),
output_schema: None,
}
}
+73
View File
@@ -0,0 +1,73 @@
mod list;
mod read;
mod search;
use std::fmt::Display;
use std::path::PathBuf;
use std::sync::Arc;
use codex_extension_api::ToolCallError;
use codex_extension_api::ToolContribution;
use codex_memories_mcp::LocalMemoriesBackend;
use serde::Serialize;
use serde::de::DeserializeOwned;
use serde_json::Value;
use self::list::ListMemoriesTool;
use self::read::ReadMemoryTool;
use self::search::SearchMemoriesTool;
#[derive(Debug)]
pub(crate) struct MemoriesTools {
list: Arc<ListMemoriesTool>,
read: Arc<ReadMemoryTool>,
search: Arc<SearchMemoriesTool>,
}
impl MemoriesTools {
pub(crate) fn new(memories_root: impl Into<PathBuf>) -> Self {
let backend = LocalMemoriesBackend::from_memory_root(memories_root);
Self {
list: Arc::new(ListMemoriesTool::new(backend.clone())),
read: Arc::new(ReadMemoryTool::new(backend.clone())),
search: Arc::new(SearchMemoriesTool::new(backend)),
}
}
pub(crate) fn contributions(&self) -> Vec<ToolContribution> {
vec![
self.list.contribution(),
self.read.contribution(),
self.search.contribution(),
]
}
}
async fn ensure_root(backend: &LocalMemoriesBackend) -> Result<(), ToolCallError> {
tokio::fs::create_dir_all(backend.root())
.await
.map_err(|err| {
ToolCallError::new(format!(
"failed to create memories root at {}: {err}",
backend.root().display()
))
})
}
fn parse_arguments<T: DeserializeOwned>(arguments: Value) -> Result<T, ToolCallError> {
serde_json::from_value(arguments)
.map_err(|err| ToolCallError::new(format!("invalid arguments: {err}")))
}
fn serialize_output<T: Serialize>(output: T) -> Result<Value, ToolCallError> {
serde_json::to_value(output)
.map_err(|err| ToolCallError::new(format!("failed to serialize output: {err}")))
}
fn backend_error(err: impl Display) -> ToolCallError {
ToolCallError::new(err.to_string())
}
fn clamp_max_results(requested: Option<usize>, default: usize, max: usize) -> usize {
requested.unwrap_or(default).clamp(1, max)
}
+108
View File
@@ -0,0 +1,108 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_extension_api::ToolCallError;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolHandler;
use codex_memories_mcp::LocalMemoriesBackend;
use codex_memories_mcp::backend::DEFAULT_READ_MAX_TOKENS;
use codex_memories_mcp::backend::MemoriesBackend;
use codex_memories_mcp::backend::ReadMemoryRequest;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use serde::Deserialize;
use serde_json::Value;
use super::backend_error;
use super::ensure_root;
use super::parse_arguments;
use super::serialize_output;
const TOOL_NAME: &str = "read_memory";
#[derive(Debug)]
pub(super) struct ReadMemoryTool {
backend: LocalMemoriesBackend,
}
impl ReadMemoryTool {
pub(super) fn new(backend: LocalMemoriesBackend) -> Self {
Self { backend }
}
pub(super) fn contribution(self: &Arc<Self>) -> ToolContribution {
let handler: Arc<dyn ToolHandler> = self.clone();
ToolContribution::new(tool_spec(), handler).allow_parallel_calls()
}
}
impl ToolHandler for ReadMemoryTool {
fn handle<'a>(
&'a self,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>> {
Box::pin(async move {
let args: ReadArgs = parse_arguments(arguments)?;
ensure_root(&self.backend).await?;
let response = self
.backend
.read(ReadMemoryRequest {
path: args.path,
line_offset: args.line_offset.unwrap_or(1),
max_lines: args.max_lines,
max_tokens: DEFAULT_READ_MAX_TOKENS,
})
.await
.map_err(backend_error)?;
serialize_output(response)
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadArgs {
path: String,
line_offset: Option<usize>,
max_lines: Option<usize>,
}
fn tool_spec() -> ResponsesApiTool {
let properties = BTreeMap::from([
(
"path".to_string(),
JsonSchema::string(Some(
"Relative path of the Codex memory file to read.".to_string(),
)),
),
(
"line_offset".to_string(),
JsonSchema::integer(Some(
"Optional 1-indexed line number to start reading from.".to_string(),
)),
),
(
"max_lines".to_string(),
JsonSchema::integer(Some(
"Optional maximum number of lines to return.".to_string(),
)),
),
]);
ResponsesApiTool {
name: TOOL_NAME.to_string(),
description:
"Read a Codex memory file by relative path, optionally starting at a 1-indexed line offset and limiting the number of lines returned."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["path".to_string()]),
Some(false.into()),
),
output_schema: None,
}
}
+192
View File
@@ -0,0 +1,192 @@
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_extension_api::ToolCallError;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolHandler;
use codex_memories_mcp::LocalMemoriesBackend;
use codex_memories_mcp::backend::DEFAULT_SEARCH_MAX_RESULTS;
use codex_memories_mcp::backend::MAX_SEARCH_RESULTS;
use codex_memories_mcp::backend::MemoriesBackend;
use codex_memories_mcp::backend::SearchMatchMode;
use codex_memories_mcp::backend::SearchMemoriesRequest;
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use serde::Deserialize;
use serde_json::Value;
use serde_json::json;
use super::backend_error;
use super::clamp_max_results;
use super::ensure_root;
use super::parse_arguments;
use super::serialize_output;
const TOOL_NAME: &str = "search_memories";
#[derive(Debug)]
pub(super) struct SearchMemoriesTool {
backend: LocalMemoriesBackend,
}
impl SearchMemoriesTool {
pub(super) fn new(backend: LocalMemoriesBackend) -> Self {
Self { backend }
}
pub(super) fn contribution(self: &Arc<Self>) -> ToolContribution {
let handler: Arc<dyn ToolHandler> = self.clone();
ToolContribution::new(tool_spec(), handler).allow_parallel_calls()
}
}
impl ToolHandler for SearchMemoriesTool {
fn handle<'a>(
&'a self,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>> {
Box::pin(async move {
let args: SearchArgs = parse_arguments(arguments)?;
ensure_root(&self.backend).await?;
let response = self
.backend
.search(args.into_request())
.await
.map_err(backend_error)?;
serialize_output(response)
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct SearchArgs {
queries: Vec<String>,
match_mode: Option<SearchMatchMode>,
path: Option<String>,
cursor: Option<String>,
context_lines: Option<usize>,
case_sensitive: Option<bool>,
normalized: Option<bool>,
max_results: Option<usize>,
}
impl SearchArgs {
fn into_request(self) -> SearchMemoriesRequest {
SearchMemoriesRequest {
queries: self.queries,
match_mode: self.match_mode.unwrap_or(SearchMatchMode::Any),
path: self.path,
cursor: self.cursor,
context_lines: self.context_lines.unwrap_or(0),
case_sensitive: self.case_sensitive.unwrap_or(true),
normalized: self.normalized.unwrap_or(false),
max_results: clamp_max_results(
self.max_results,
DEFAULT_SEARCH_MAX_RESULTS,
MAX_SEARCH_RESULTS,
),
}
}
}
fn tool_spec() -> ResponsesApiTool {
let properties = BTreeMap::from([
(
"queries".to_string(),
JsonSchema::array(
JsonSchema::string(None),
Some("Substrings to search for.".to_string()),
),
),
("match_mode".to_string(), search_match_mode_schema()),
(
"path".to_string(),
JsonSchema::string(Some(
"Optional relative path to search inside the Codex memories store.".to_string(),
)),
),
(
"cursor".to_string(),
JsonSchema::string(Some(
"Optional cursor returned by a previous search_memories call.".to_string(),
)),
),
(
"context_lines".to_string(),
JsonSchema::integer(Some(
"Optional number of context lines to include around each match.".to_string(),
)),
),
(
"case_sensitive".to_string(),
JsonSchema::boolean(Some(
"Whether matching should be case-sensitive.".to_string(),
)),
),
(
"normalized".to_string(),
JsonSchema::boolean(Some(
"Whether to normalize separators while matching.".to_string(),
)),
),
(
"max_results".to_string(),
JsonSchema::integer(Some(
"Optional maximum number of matches to return.".to_string(),
)),
),
]);
ResponsesApiTool {
name: TOOL_NAME.to_string(),
description:
"Search Codex memory files for substring matches, optionally normalizing separators or requiring all query substrings on the same line or within a line window."
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(
properties,
Some(vec!["queries".to_string()]),
Some(false.into()),
),
output_schema: None,
}
}
fn search_match_mode_schema() -> JsonSchema {
JsonSchema::any_of(
vec![
tagged_match_mode_schema("any", None),
tagged_match_mode_schema("all_on_same_line", None),
tagged_match_mode_schema(
"all_within_lines",
Some((
"line_count".to_string(),
JsonSchema::integer(Some(
"Positive number of lines in the matching window.".to_string(),
)),
)),
),
],
Some("How multiple queries must match.".to_string()),
)
}
fn tagged_match_mode_schema(
name: &str,
extra_property: Option<(String, JsonSchema)>,
) -> JsonSchema {
let mut properties = BTreeMap::from([(
"type".to_string(),
JsonSchema::string_enum(vec![json!(name)], None),
)]);
let mut required = vec!["type".to_string()];
if let Some((name, schema)) = extra_property {
required.push(name.clone());
properties.insert(name, schema);
}
JsonSchema::object(properties, Some(required), Some(false.into()))
}