Compare commits

...

15 Commits

Author SHA1 Message Date
jif-oai
ee94382ba5 Add hello world file 2026-05-08 12:23:04 +02:00
jif-oai
762c8ae093 ok 2026-05-08 12:11:40 +02:00
jif-oai
1378f42e04 better example 2026-05-08 12:09:26 +02:00
jif-oai
aa9bb90611 refactor states 2026-05-08 12:03:42 +02:00
jif-oai
1f2badfde4 nit 2 2026-05-07 22:23:23 +01:00
jif-oai
27953de5b9 nit 2026-05-07 22:22:20 +01:00
jif-oai
2260be0983 Add state managemetn 2026-05-07 22:21:47 +01:00
jif-oai
7dd7ddb053 nit 3 2026-05-07 22:08:46 +01:00
jif-oai
22068b02b6 nit 2 2026-05-07 17:10:51 +01:00
jif-oai
4892ea4e53 nit 2026-05-07 15:59:31 +01:00
jif-oai
cffcb59f5b v4 2026-05-07 15:39:46 +01:00
jif-oai
ee1b490c0e v3 2026-05-07 15:21:24 +01:00
jif-oai
19dc3d202f one fmt 2026-05-07 14:26:53 +01:00
jif-oai
a7b6245e52 v2 2026-05-07 14:24:54 +01:00
jif-oai
1af814fb51 v1 2026-05-07 13:23:11 +01:00
26 changed files with 1297 additions and 0 deletions

49
codex-rs/Cargo.lock generated
View File

@@ -2444,12 +2444,14 @@ dependencies = [
"codex-core-skills",
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-features",
"codex-feedback",
"codex-git-utils",
"codex-hooks",
"codex-login",
"codex-mcp",
"codex-memories",
"codex-memories-read",
"codex-model-provider",
"codex-model-provider-info",
@@ -2780,6 +2782,19 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"codex-git-attribution",
"codex-guardian",
"codex-memories",
"codex-multi-agent-v2",
"codex-tools",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
@@ -2858,6 +2873,13 @@ dependencies = [
"serde",
]
[[package]]
name = "codex-git-attribution"
version = "0.0.0"
dependencies = [
"codex-extension-api",
]
[[package]]
name = "codex-git-utils"
version = "0.0.0"
@@ -2882,6 +2904,13 @@ dependencies = [
"walkdir",
]
[[package]]
name = "codex-guardian"
version = "0.0.0"
dependencies = [
"codex-extension-api",
]
[[package]]
name = "codex-hooks"
version = "0.0.0"
@@ -3065,6 +3094,19 @@ dependencies = [
"wiremock",
]
[[package]]
name = "codex-memories"
version = "0.0.0"
dependencies = [
"codex-extension-api",
"codex-memories-read",
"codex-utils-absolute-path",
"rmcp",
"serde_json",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "codex-memories-mcp"
version = "0.0.0"
@@ -3206,6 +3248,13 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-multi-agent-v2"
version = "0.0.0"
dependencies = [
"codex-extension-api",
]
[[package]]
name = "codex-network-proxy"
version = "0.0.0"

View File

@@ -45,6 +45,11 @@ members = [
"exec-server",
"execpolicy",
"execpolicy-legacy",
"ext/extension-api",
"ext/guardian",
"ext/git-attribution",
"ext/memories",
"ext/multi-agent-v2",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -159,10 +164,15 @@ codex-exec = { path = "exec" }
codex-file-system = { path = "file-system" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
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" }
codex-features = { path = "features" }
codex-guardian = { path = "ext/guardian" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-memories = { path = "ext/memories" }
codex-multi-agent-v2 = { path = "ext/multi-agent-v2" }
codex-feedback = { path = "feedback" }
codex-install-context = { path = "install-context" }
codex-file-search = { path = "file-search" }

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "extension-api",
crate_name = "codex_extension_api",
)

View File

@@ -0,0 +1,18 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-extension-api"
version.workspace = true
[lib]
name = "codex_extension_api"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,68 @@
#[path = "enabled_extensions/shared_state_extension.rs"]
mod shared_state_extension;
use std::sync::Arc;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use shared_state_extension::SharedStateExtension;
use shared_state_extension::recorded_style_contributions;
use shared_state_extension::recorded_usage_contributions;
fn main() {
// 1. Build the extension value owned by the host.
let extension = Arc::new(SharedStateExtension);
// 2. Install it into the registry for the context type this host exposes.
let registry = ExtensionRegistryBuilder::<()>::new()
.with_extension(extension)
.build();
// 3. The host decides which stores are shared.
let session_store = ExtensionData::new();
let first_thread_store = ExtensionData::new();
let second_thread_store = ExtensionData::new();
// 4. Reusing the same session store shares session state across threads.
let first_thread_fragments = contribute_prompt(&registry, &session_store, &first_thread_store);
contribute_prompt(&registry, &session_store, &first_thread_store);
contribute_prompt(&registry, &session_store, &second_thread_store);
println!("first prompt fragments: {}", first_thread_fragments.len());
println!(
"session style contributions: {}",
recorded_style_contributions(&session_store)
);
println!(
"session usage contributions: {}",
recorded_usage_contributions(&session_store)
);
println!(
"first thread style contributions: {}",
recorded_style_contributions(&first_thread_store)
);
println!(
"first thread usage contributions: {}",
recorded_usage_contributions(&first_thread_store)
);
println!(
"second thread style contributions: {}",
recorded_style_contributions(&second_thread_store)
);
println!(
"second thread usage contributions: {}",
recorded_usage_contributions(&second_thread_store)
);
}
fn contribute_prompt(
registry: &codex_extension_api::ExtensionRegistry<()>,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<codex_extension_api::PromptFragment> {
registry
.context_contributors()
.iter()
.flat_map(|contributor| contributor.contribute(&(), session_store, thread_store))
.collect()
}

View File

@@ -0,0 +1,102 @@
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_extension_api::CodexExtension;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
/// Small tutorial extension that installs two prompt contributors.
#[derive(Debug, Default)]
pub struct SharedStateExtension;
impl CodexExtension<()> for SharedStateExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<()>) {
registry.prompt_contributor(Arc::new(StyleContributor));
registry.prompt_contributor(Arc::new(UsageContributor));
}
}
#[derive(Debug)]
struct StyleContributor;
impl ContextContributor<()> for StyleContributor {
fn contribute(
&self,
_context: &(),
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
contribution_counts(session_store).record_style();
contribution_counts(thread_store).record_style();
vec![PromptFragment::developer_policy(
"Prefer short answers unless the user asks for detail.",
)]
}
}
#[derive(Debug)]
struct UsageContributor;
impl ContextContributor<()> for UsageContributor {
fn contribute(
&self,
_context: &(),
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
contribution_counts(session_store).record_usage();
contribution_counts(thread_store).record_usage();
vec![PromptFragment::developer_capability(
"This extension can contribute more than one prompt fragment.",
)]
}
}
/// Returns how many style contributions were recorded in `store`.
pub fn recorded_style_contributions(store: &ExtensionData) -> u64 {
store
.get::<ContributionCounts>()
.map(|counts| counts.style())
.unwrap_or_default()
}
/// Returns how many usage contributions were recorded in `store`.
pub fn recorded_usage_contributions(store: &ExtensionData) -> u64 {
store
.get::<ContributionCounts>()
.map(|counts| counts.usage())
.unwrap_or_default()
}
#[derive(Debug, Default)]
struct ContributionCounts {
style: AtomicU64,
usage: AtomicU64,
}
impl ContributionCounts {
fn record_style(&self) {
self.style.fetch_add(1, Ordering::Relaxed);
}
fn record_usage(&self) {
self.usage.fetch_add(1, Ordering::Relaxed);
}
fn style(&self) -> u64 {
self.style.load(Ordering::Relaxed)
}
fn usage(&self) -> u64 {
self.usage.load(Ordering::Relaxed)
}
}
fn contribution_counts(store: &ExtensionData) -> Arc<ContributionCounts> {
store.get_or_init::<ContributionCounts>(Default::default)
}

View File

@@ -0,0 +1,14 @@
Everything becomes a good contributor design, which contributors do we need?
git attribution Context
memories Context + Tool + Output
guardian Context + Request
goal Tool + Runtime
image generation Tool + Output
skills Context + Turn
personality Context
plugins / apps / connectors Context + Turn
shell snapshot Runtime
web search Tool
AGENTS.md Context (Runtime too only if you want eager refresh/cache behavior)
future sandboxing probably Request + Runtime

View File

@@ -0,0 +1,62 @@
use std::future::Future;
use codex_protocol::items::TurnItem;
use crate::ExtensionData;
mod prompt;
mod tool;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use tool::ToolCallError;
pub use tool::ToolContribution;
pub use tool::ToolHandler;
/// Extension contribution that adds prompt fragments during prompt assembly.
pub trait ContextContributor<C>: Send + Sync {
fn contribute(
&self,
context: &C,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment>;
}
/// Extension contribution that exposes native tools owned by a feature.
pub trait ToolContributor<C>: Send + Sync {
/// Returns the native tools visible for the supplied runtime context.
fn tools(&self, context: &C, thread_store: &ExtensionData) -> Vec<ToolContribution<C>>;
}
/// Future returned by one ordered turn-item contribution.
pub type TurnItemContributionFuture<'a> =
std::pin::Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
/// Ordered post-processing contribution for one parsed turn item.
///
/// Implementations may mutate the item before it is emitted and may use the
/// explicitly exposed thread- and turn-lifetime stores when they need durable
/// extension-private state.
pub trait TurnItemContributor<C>: Send + Sync {
fn contribute<'a>(
&'a self,
context: &'a C,
thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> TurnItemContributionFuture<'a>;
}
// TODO: WIP (do not consider)
/// Extension contribution that can claim approval requests for a runtime context.
/// (ideally we can replace it by a session lifecycle thing or a request contributor?)
pub trait ApprovalInterceptorContributor<C>: Send + Sync {
/// Returns whether this contributor should intercept approvals in `context`.
fn intercepts_approvals(
&self,
context: &C,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
) -> bool;
}

View File

@@ -0,0 +1,50 @@
// All this file should be replaced by the existing fragment implementation ofc
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PromptSlot {
DeveloperPolicy,
DeveloperCapabilities,
ContextualUser,
SeparateDeveloper,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PromptFragment {
slot: PromptSlot,
text: String,
}
impl PromptFragment {
/// Creates a prompt fragment for the given slot.
pub fn new(slot: PromptSlot, text: impl Into<String>) -> Self {
Self {
slot,
text: text.into(),
}
}
/// Creates a developer-policy prompt fragment.
pub fn developer_policy(text: impl Into<String>) -> Self {
Self::new(PromptSlot::DeveloperPolicy, text)
}
/// Creates a developer-capabilities prompt fragment.
pub fn developer_capability(text: impl Into<String>) -> Self {
Self::new(PromptSlot::DeveloperCapabilities, text)
}
/// Creates a separate top-level developer prompt fragment.
pub fn separate_developer(text: impl Into<String>) -> Self {
Self::new(PromptSlot::SeparateDeveloper, text)
}
/// Returns the target prompt slot.
pub fn slot(&self) -> PromptSlot {
self.slot
}
/// Returns the model-visible text.
pub fn text(&self) -> &str {
&self.text
}
}

View File

@@ -0,0 +1,69 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_tools::ResponsesApiTool;
use serde_json::Value;
use thiserror::Error;
// TMP
#[derive(Clone)]
pub struct ToolContribution<C> {
spec: ResponsesApiTool,
handler: Arc<dyn ToolHandler<C>>,
supports_parallel_tool_calls: bool,
}
impl<C> ToolContribution<C> {
pub fn new(spec: ResponsesApiTool, handler: Arc<dyn ToolHandler<C>>) -> Self {
Self {
spec,
handler,
supports_parallel_tool_calls: false,
}
}
#[must_use]
pub fn allow_parallel_calls(mut self) -> Self {
self.supports_parallel_tool_calls = true;
self
}
pub fn spec(&self) -> &ResponsesApiTool {
&self.spec
}
pub fn supports_parallel_tool_calls(&self) -> bool {
self.supports_parallel_tool_calls
}
pub fn handler(&self) -> Arc<dyn ToolHandler<C>> {
Arc::clone(&self.handler)
}
}
//////// Just to make it compile ////////////////////////////////
pub trait ToolHandler<C>: Send + Sync {
/// Handles one JSON-encoded invocation for this tool.
fn handle<'a>(
&'a self,
context: &'a C,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>>;
}
/// Error returned by a contributed native tool handler.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("{message}")]
pub struct ToolCallError {
message: String,
}
impl ToolCallError {
/// Creates a contributed-tool error with the supplied model-visible text.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}

View File

@@ -0,0 +1,12 @@
use std::sync::Arc;
use crate::ExtensionRegistryBuilder;
/// First-party extension that can install one or more typed runtime contributions.
///
/// Implementations should use [`Self::install`] only to register the concrete
/// providers they own.
pub trait CodexExtension<C>: Send + Sync {
/// Registers this extension's concrete typed contributions.
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>);
}

View File

@@ -0,0 +1,19 @@
mod contributors;
mod extension;
mod registry;
mod state;
pub use contributors::ApprovalInterceptorContributor;
pub use contributors::ContextContributor;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ToolCallError;
pub use contributors::ToolContribution;
pub use contributors::ToolContributor;
pub use contributors::ToolHandler;
pub use contributors::TurnItemContributionFuture;
pub use contributors::TurnItemContributor;
pub use extension::CodexExtension;
pub use registry::ExtensionRegistry;
pub use registry::ExtensionRegistryBuilder;
pub use state::ExtensionData;

View File

@@ -0,0 +1,116 @@
use std::sync::Arc;
use crate::ApprovalInterceptorContributor;
use crate::CodexExtension;
use crate::ContextContributor;
use crate::ToolContributor;
use crate::TurnItemContributor;
/// Mutable registry used while extensions install their typed contributions.
pub struct ExtensionRegistryBuilder<C> {
context_contributors: Vec<Arc<dyn ContextContributor<C>>>,
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor<C>>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
}
impl<C> Default for ExtensionRegistryBuilder<C> {
fn default() -> Self {
Self {
approval_interceptor_contributors: Vec::new(),
context_contributors: Vec::new(),
tool_contributors: Vec::new(),
turn_item_contributors: Vec::new(),
}
}
}
impl<C> ExtensionRegistryBuilder<C> {
/// Creates an empty registry builder.
pub fn new() -> Self {
Self::default()
}
/// Installs one extension and returns the builder.
#[must_use]
pub fn with_extension<E>(mut self, extension: Arc<E>) -> Self
where
E: CodexExtension<C> + 'static,
{
self.install_extension(extension);
self
}
/// Installs one extension into the registry under construction.
pub fn install_extension<E>(&mut self, extension: Arc<E>)
where
E: CodexExtension<C> + 'static,
{
extension.install(self);
}
/// Registers one approval interceptor contributor.
pub fn approval_interceptor_contributor(
&mut self,
contributor: Arc<dyn ApprovalInterceptorContributor<C>>,
) {
self.approval_interceptor_contributors.push(contributor);
}
/// Registers one prompt contributor.
pub fn prompt_contributor(&mut self, contributor: Arc<dyn ContextContributor<C>>) {
self.context_contributors.push(contributor);
}
/// Registers one native tool contributor.
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor<C>>) {
self.tool_contributors.push(contributor);
}
/// Registers one ordered turn-item contributor.
pub fn turn_item_contributor(&mut self, contributor: Arc<dyn TurnItemContributor<C>>) {
self.turn_item_contributors.push(contributor);
}
/// Finishes construction and returns the immutable registry.
pub fn build(self) -> ExtensionRegistry<C> {
ExtensionRegistry {
approval_interceptor_contributors: self.approval_interceptor_contributors,
context_contributors: self.context_contributors,
tool_contributors: self.tool_contributors,
turn_item_contributors: self.turn_item_contributors,
}
}
}
/// Immutable typed registry produced after extensions are installed.
pub struct ExtensionRegistry<C> {
context_contributors: Vec<Arc<dyn ContextContributor<C>>>,
tool_contributors: Vec<Arc<dyn ToolContributor<C>>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor<C>>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor<C>>>,
}
impl<C> ExtensionRegistry<C> {
/// Returns the registered approval interceptor contributors.
pub fn approval_interceptor_contributors(
&self,
) -> &[Arc<dyn ApprovalInterceptorContributor<C>>] {
&self.approval_interceptor_contributors
}
/// Returns the registered prompt contributors.
pub fn context_contributors(&self) -> &[Arc<dyn ContextContributor<C>>] {
&self.context_contributors
}
/// Returns the registered native tool contributors.
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor<C>>] {
&self.tool_contributors
}
/// Returns the registered ordered turn-item contributors.
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor<C>>] {
&self.turn_item_contributors
}
}

View File

@@ -0,0 +1,77 @@
use std::any::Any;
use std::any::TypeId;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
type ErasedData = Arc<dyn Any + Send + Sync>;
/// Typed extension-owned data attached to one host object.
#[derive(Default, Debug)]
pub struct ExtensionData {
entries: Mutex<HashMap<TypeId, ErasedData>>,
}
impl ExtensionData {
/// Creates an empty attachment map.
pub fn new() -> Self {
Self::default()
}
/// Returns the attached value of type `T`, if one exists.
pub fn get<T>(&self) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
let value = self.entries().get(&TypeId::of::<T>())?.clone();
Some(downcast_data(value))
}
/// Returns the attached value of type `T`, inserting one from `init` when absent.
///
/// The initializer runs while this map is locked, so it should stay cheap;
/// heavyweight lazy work belongs inside the attached value itself.
pub fn get_or_init<T>(&self, init: impl FnOnce() -> T) -> Arc<T>
where
T: Any + Send + Sync,
{
let mut entries = self.entries();
let value = entries
.entry(TypeId::of::<T>())
.or_insert_with(|| Arc::new(init()));
downcast_data(Arc::clone(value))
}
/// Stores `value` as the attachment of type `T`, returning any previous value.
pub fn insert<T>(&self, value: T) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries()
.insert(TypeId::of::<T>(), Arc::new(value))
.map(downcast_data)
}
/// Removes and returns the attached value of type `T`, if one exists.
pub fn remove<T>(&self) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries().remove(&TypeId::of::<T>()).map(downcast_data)
}
fn entries(&self) -> std::sync::MutexGuard<'_, HashMap<TypeId, ErasedData>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn downcast_data<T>(value: ErasedData) -> Arc<T>
where
T: Any + Send + Sync,
{
let Ok(value) = value.downcast::<T>() else {
unreachable!("typed extension data stored an incompatible value");
};
value
}

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "git-attribution",
crate_name = "codex_git_attribution",
)

View File

@@ -0,0 +1,15 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-git-attribution"
version.workspace = true
[lib]
name = "codex_git_attribution"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-extension-api = { workspace = true }

View File

@@ -0,0 +1,72 @@
use std::sync::Arc;
use codex_extension_api::CodexExtension;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::Stores;
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
pub trait GitAttributionContext {
fn commit_attribution(&self) -> Option<&str>;
}
/// Prompt-only extension that contributes the configured git-attribution instruction.
#[derive(Clone, Copy, Debug, Default)]
pub struct GitAttributionExtension;
impl GitAttributionExtension {
/// Creates an extension instance.
pub fn new() -> Self {
Self
}
/// Returns the model-visible trailer instruction, if attribution is enabled.
pub fn instruction<C: GitAttributionContext>(&self, context: &C) -> Option<String> {
let trailer = build_commit_message_trailer(context.commit_attribution())?;
Some(format!(
"When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block."
))
}
}
impl<C: GitAttributionContext> ContextContributor<C> for GitAttributionExtension {
fn contribute(&self, context: &C, _stores: &Stores<'_>) -> Vec<PromptFragment> {
self.instruction(context)
.map(PromptFragment::developer_capability)
.into_iter()
.collect()
}
}
impl<C: GitAttributionContext> CodexExtension<C> for GitAttributionExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
registry.prompt_contributor(self);
}
}
/// Creates a shared git-attribution extension instance.
pub fn extension() -> Arc<GitAttributionExtension> {
Arc::new(GitAttributionExtension::new())
}
// This is just a copy/paste
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
let value = resolve_attribution_value(config_attribution)?;
Some(format!("Co-authored-by: {value}"))
}
fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String> {
match config_attribution {
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()),
}
}

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "memories",
crate_name = "codex_memories",
)

View File

@@ -0,0 +1,25 @@
[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-extension-api = { 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 }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
tracing = { workspace = true }

View File

@@ -0,0 +1,70 @@
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_extension_api::OutputContributionFuture;
use codex_extension_api::OutputContributor;
use codex_extension_api::Stores;
use codex_extension_api::scopes::Thread;
use codex_extension_api::scopes::Turn;
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;
use crate::MemoriesExtension;
#[derive(Debug, Default)]
struct ThreadMemoriesState {
turns_with_citations: AtomicU64,
}
#[derive(Debug, Default)]
struct TurnMemoriesState {
had_citation: AtomicBool,
}
impl<C> OutputContributor<C, TurnItem> for MemoriesExtension {
fn contribute<'a>(
&'a self,
_context: &'a C,
stores: &'a Stores<'a>,
output: &'a mut TurnItem,
) -> OutputContributionFuture<'a> {
if let TurnItem::AgentMessage(agent_message) = output {
let combined = agent_message
.content
.iter()
.map(|entry| match entry {
AgentMessageContent::Text { text } => text.as_str(),
})
.collect::<String>();
let (visible_text, citations) = strip_citations(&combined);
agent_message.content = vec![AgentMessageContent::Text { text: visible_text }];
agent_message.memory_citation = parse_memory_citation(citations);
if agent_message.memory_citation.is_some()
&& let Some(turns_with_citations) = record_citation_seen(stores)
{
tracing::info!(turns_with_citations, "memory citation seen in turn");
}
}
Box::pin(std::future::ready(Ok(())))
}
}
fn record_citation_seen(stores: &Stores<'_>) -> Option<u64> {
let turn_state = stores.get_or_init::<Turn, TurnMemoriesState>(Default::default);
if turn_state.had_citation.swap(true, Ordering::Relaxed) {
return None;
}
let thread_stats = stores.get_or_init::<Thread, ThreadMemoriesState>(Default::default);
Some(
thread_stats
.turns_with_citations
.fetch_add(1, Ordering::Relaxed)
+ 1,
)
}

View File

@@ -0,0 +1,8 @@
/// Runtime facts needed to decide whether read-memory surfaces are visible.
///
/// Hosts should expose the current effective values for the thread being
/// assembled. The extension owns the policy that combines those values.
pub trait MemoriesContext {
fn memory_tool_enabled(&self) -> bool;
fn use_memories(&self) -> bool;
}

View File

@@ -0,0 +1,63 @@
mod citation_output;
pub mod ctx;
mod list_tool;
mod prompt_contributor;
mod tool_contributor;
use std::path::PathBuf;
use std::sync::Arc;
use crate::ctx::MemoriesContext;
use codex_extension_api::CodexExtension;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_memories_read::build_memory_tool_developer_instructions;
use codex_memories_read::memory_root;
use codex_protocol::items::TurnItem;
use codex_utils_absolute_path::AbsolutePathBuf;
use list_tool::ListMemoriesTool;
/// Extension that contributes memories read surfaces.
#[derive(Clone, Debug)]
pub struct MemoriesExtension {
read_prompt: Option<String>,
pub(crate) list_tool: Arc<ListMemoriesTool>, // This is just to have useful examples, it will disappear
}
impl<C> CodexExtension<C> for MemoriesExtension
where
C: MemoriesContext + Send + Sync + 'static,
{
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>) {
registry.tool_contributor(self.clone());
registry.output_contributor::<TurnItem>(self.clone());
registry.prompt_contributor(self);
}
}
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 {
read_prompt,
list_tool: Arc::new(ListMemoriesTool::new(memories_root)),
}
}
/// Returns the rendered developer instruction for read access, if available.
pub fn read_prompt(&self) -> Option<&str> {
self.read_prompt.as_deref()
}
// Just for example
pub(crate) fn is_read_surface_enabled<C: MemoriesContext>(&self, context: &C) -> bool {
context.memory_tool_enabled() && context.use_memories()
}
}
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(),
))
}

View File

@@ -0,0 +1,323 @@
// This file must be replaced by tools from `codex-memories-mcp` once the extracted tools land. This is just a vibe-coded copy/paster for now.
use std::borrow::Cow;
use std::future::Future;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
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_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
const LIST_MEMORIES_TOOL_NAME: &str = "list_memories";
const DEFAULT_LIST_MAX_RESULTS: usize = 2_000;
const MAX_LIST_RESULTS: usize = 2_000;
#[derive(Debug)]
pub(super) struct ListMemoriesTool {
memories_root: PathBuf,
}
impl ListMemoriesTool {
pub(super) fn new(memories_root: impl Into<PathBuf>) -> Self {
Self {
memories_root: memories_root.into(),
}
}
pub(super) fn contribution<C>(self: &Arc<Self>) -> ToolContribution<C>
where
C: Send + Sync + 'static,
{
let handler: Arc<dyn ToolHandler<C>> = self.clone();
ToolContribution::new(create_list_memories_tool(), handler).allow_parallel_calls()
}
}
impl<C> ToolHandler<C> for ListMemoriesTool
where
C: Send + Sync,
{
fn handle<'a>(
&'a self,
_context: &'a C,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>> {
Box::pin(async move {
let args: ListMemoriesArgs = serde_json::from_value(arguments)
.map_err(|err| ToolCallError::new(format!("invalid arguments: {err}")))?;
tokio::fs::create_dir_all(&self.memories_root)
.await
.map_err(|err| {
ToolCallError::new(format!(
"failed to create memories root at {}: {err}",
self.memories_root.display()
))
})?;
let response = list_memories(&self.memories_root, args)
.await
.map_err(|err| ToolCallError::new(err.to_string()))?;
serde_json::to_value(response)
.map_err(|err| ToolCallError::new(format!("failed to serialize output: {err}")))
})
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ListMemoriesArgs {
path: Option<String>,
cursor: Option<String>,
max_results: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ListMemoriesResponse {
path: Option<String>,
entries: Vec<MemoryEntry>,
next_cursor: Option<String>,
truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct MemoryEntry {
path: String,
entry_type: MemoryEntryType,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
enum MemoryEntryType {
File,
Directory,
}
#[derive(Debug, thiserror::Error)]
enum ListMemoriesError {
#[error("path '{path}' {reason}")]
InvalidPath { path: String, reason: String },
#[error("cursor '{cursor}' {reason}")]
InvalidCursor { cursor: String, reason: String },
#[error("path '{path}' was not found")]
NotFound { path: String },
#[error("I/O error while reading memories: {0}")]
Io(#[from] std::io::Error),
}
async fn list_memories(
memories_root: &Path,
args: ListMemoriesArgs,
) -> Result<ListMemoriesResponse, ListMemoriesError> {
let max_results = args
.max_results
.unwrap_or(DEFAULT_LIST_MAX_RESULTS)
.min(MAX_LIST_RESULTS);
let start = resolve_scoped_path(memories_root, args.path.as_deref()).await?;
let start_index = match args.cursor.as_deref() {
Some(cursor) => cursor
.parse::<usize>()
.map_err(|_| ListMemoriesError::InvalidCursor {
cursor: cursor.to_string(),
reason: "must be a non-negative integer".to_string(),
})?,
None => 0,
};
let Some(metadata) = metadata_or_none(&start).await? else {
return Err(ListMemoriesError::NotFound {
path: args.path.unwrap_or_default(),
});
};
reject_symlink(&display_relative_path(memories_root, &start), &metadata)?;
let mut entries = if metadata.is_file() {
vec![MemoryEntry {
path: display_relative_path(memories_root, &start),
entry_type: MemoryEntryType::File,
}]
} else if metadata.is_dir() {
let mut entries = Vec::new();
for path in read_sorted_dir_paths(&start).await? {
if is_hidden_path(&path) {
continue;
}
let Some(metadata) = metadata_or_none(&path).await? else {
continue;
};
if metadata.file_type().is_symlink() {
continue;
}
let entry_type = if metadata.is_dir() {
MemoryEntryType::Directory
} else if metadata.is_file() {
MemoryEntryType::File
} else {
continue;
};
entries.push(MemoryEntry {
path: display_relative_path(memories_root, &path),
entry_type,
});
}
entries
} else {
Vec::new()
};
if start_index > entries.len() {
return Err(ListMemoriesError::InvalidCursor {
cursor: start_index.to_string(),
reason: "exceeds result count".to_string(),
});
}
let end_index = start_index.saturating_add(max_results).min(entries.len());
let next_cursor = (end_index < entries.len()).then(|| end_index.to_string());
let truncated = next_cursor.is_some();
Ok(ListMemoriesResponse {
path: args.path,
entries: entries.drain(start_index..end_index).collect(),
next_cursor,
truncated,
})
}
async fn resolve_scoped_path(
memories_root: &Path,
relative_path: Option<&str>,
) -> Result<PathBuf, ListMemoriesError> {
let Some(relative_path) = relative_path else {
return Ok(memories_root.to_path_buf());
};
let relative = Path::new(relative_path);
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "must stay within the memories root".to_string(),
});
}
if relative.components().any(is_hidden_component) {
return Err(ListMemoriesError::NotFound {
path: relative_path.to_string(),
});
}
let components = relative.components().collect::<Vec<_>>();
let mut scoped_path = memories_root.to_path_buf();
for (index, component) in components.iter().enumerate() {
scoped_path.push(component.as_os_str());
let Some(metadata) = metadata_or_none(&scoped_path).await? else {
for remaining_component in components.iter().skip(index + 1) {
scoped_path.push(remaining_component.as_os_str());
}
return Ok(scoped_path);
};
reject_symlink(
&display_relative_path(memories_root, &scoped_path),
&metadata,
)?;
if index + 1 < components.len() && !metadata.is_dir() {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "traverses through a non-directory path component".to_string(),
});
}
}
Ok(scoped_path)
}
async fn metadata_or_none(path: &Path) -> Result<Option<std::fs::Metadata>, ListMemoriesError> {
match tokio::fs::symlink_metadata(path).await {
Ok(metadata) => Ok(Some(metadata)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err.into()),
}
}
fn reject_symlink(
relative_path: &str,
metadata: &std::fs::Metadata,
) -> Result<(), ListMemoriesError> {
if metadata.file_type().is_symlink() {
return Err(ListMemoriesError::InvalidPath {
path: relative_path.to_string(),
reason: "must not be a symlink".to_string(),
});
}
Ok(())
}
fn display_relative_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.map_or(Cow::Borrowed(path), Cow::Borrowed)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
fn is_hidden_path(path: &Path) -> bool {
path.file_name()
.is_some_and(|name| name.to_string_lossy().starts_with('.'))
}
fn is_hidden_component(component: Component<'_>) -> bool {
matches!(component, Component::Normal(name) if name.to_string_lossy().starts_with('.'))
}
async fn read_sorted_dir_paths(path: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
let mut entries = tokio::fs::read_dir(path).await?;
let mut paths = Vec::new();
while let Some(entry) = entries.next_entry().await? {
paths.push(entry.path());
}
paths.sort();
Ok(paths)
}
fn create_list_memories_tool() -> ResponsesApiTool {
let properties = std::collections::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: LIST_MEMORIES_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,
}
}

View File

@@ -0,0 +1,19 @@
use codex_extension_api::ContextContributor;
use codex_extension_api::PromptFragment;
use codex_extension_api::Stores;
use crate::MemoriesExtension;
use crate::ctx::MemoriesContext;
impl<C: MemoriesContext> ContextContributor<C> for MemoriesExtension {
fn contribute(&self, context: &C, _stores: &Stores<'_>) -> Vec<PromptFragment> {
if !self.is_read_surface_enabled(context) {
return Vec::new();
}
self.read_prompt()
.map(PromptFragment::developer_policy)
.into_iter()
.collect()
}
}

View File

@@ -0,0 +1,17 @@
use codex_extension_api::Stores;
use codex_extension_api::ToolContribution;
use codex_extension_api::ToolContributor;
use crate::MemoriesExtension;
use crate::ctx::MemoriesContext;
impl<C: MemoriesContext + Send + Sync + 'static> ToolContributor<C> for MemoriesExtension {
fn tools(&self, context: &C, _stores: &Stores<'_>) -> Vec<ToolContribution<C>> {
if !self.is_read_surface_enabled(context) {
return Vec::new();
}
// TODO(jif) add more tools ofc
vec![self.list_tool.contribution()]
}
}

1
hello.txt Normal file
View File

@@ -0,0 +1 @@
hello world