diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml deleted file mode 100644 index b2ef107ca..000000000 --- a/.github/workflows/bazel.yml +++ /dev/null @@ -1,231 +0,0 @@ -name: Bazel (experimental) - -# Note this workflow was originally derived from: -# https://github.com/cerisier/toolchains_llvm_bootstrapped/blob/main/.github/workflows/ci.yaml - -on: - pull_request: {} - push: - branches: - - main - workflow_dispatch: - -concurrency: - # Cancel previous actions from the same PR or branch except 'main' branch. - # See https://docs.github.com/en/actions/using-jobs/using-concurrency and https://docs.github.com/en/actions/learn-github-actions/contexts for more info. - group: concurrency-group::${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}${{ github.ref_name == 'main' && format('::{0}', github.run_id) || ''}} - cancel-in-progress: ${{ github.ref_name != 'main' }} -jobs: - test: - strategy: - fail-fast: false - matrix: - include: - # macOS - - os: macos-15-xlarge - target: aarch64-apple-darwin - - os: macos-15-xlarge - target: x86_64-apple-darwin - - # Linux - - os: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - os: ubuntu-24.04 - target: x86_64-unknown-linux-musl - # 2026-02-27 Bazel tests have been flaky on arm in CI. - # Disable until we can investigate and stabilize them. - # - os: ubuntu-24.04-arm - # target: aarch64-unknown-linux-musl - # - os: ubuntu-24.04-arm - # target: aarch64-unknown-linux-gnu - - # TODO: Enable Windows once we fix the toolchain issues there. - #- os: windows-latest - # target: x86_64-pc-windows-gnullvm - runs-on: ${{ matrix.os }} - - # Configure a human readable name for each job - name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Node.js for js_repl tests - uses: actions/setup-node@v6 - with: - node-version-file: codex-rs/node-version.txt - - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - name: Make DotSlash available in PATH (Unix) - if: runner.os != 'Windows' - run: cp "$(which dotslash)" /usr/local/bin - - - name: Make DotSlash available in PATH (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" - - # Install Bazel via Bazelisk - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Check MODULE.bazel.lock is up to date - if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' - shell: bash - run: ./scripts/check-module-bazel-lock.sh - - # TODO(mbolin): Bring this back once we have caching working. Currently, - # we never seem to get a cache hit but we still end up paying the cost of - # uploading at the end of the build, which takes over a minute! - # - # Cache build and external artifacts so that the next ci build is incremental. - # Because github action caches cannot be updated after a build, we need to - # store the contents of each build in a unique cache key, then fall back to loading - # it on the next ci run. We use hashFiles(...) in the key and restore-keys- with - # the prefix to load the most recent cache for the branch on a cache miss. You - # should customize the contents of hashFiles to capture any bazel input sources, - # although this doesn't need to be perfect. If none of the input sources change - # then a cache hit will load an existing cache and bazel won't have to do any work. - # In the case of a cache miss, you want the fallback cache to contain most of the - # previously built artifacts to minimize build time. The more precise you are with - # hashFiles sources the less work bazel will have to do. - # - name: Mount bazel caches - # uses: actions/cache@v5 - # with: - # path: | - # ~/.cache/bazel-repo-cache - # ~/.cache/bazel-repo-contents-cache - # key: bazel-cache-${{ matrix.os }}-${{ hashFiles('**/BUILD.bazel', '**/*.bzl', 'MODULE.bazel') }} - # restore-keys: | - # bazel-cache-${{ matrix.os }} - - - name: Configure Bazel startup args (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - # Use a very short path to reduce argv/path length issues. - "BAZEL_STARTUP_ARGS=--output_user_root=C:\" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - - - name: bazel test //... - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - shell: bash - run: | - set -o pipefail - - bazel_console_log="$(mktemp)" - - print_failed_bazel_test_logs() { - local console_log="$1" - local testlogs_dir - - testlogs_dir="$(bazel $BAZEL_STARTUP_ARGS info bazel-testlogs 2>/dev/null || echo bazel-testlogs)" - - local failed_targets=() - while IFS= read -r target; do - failed_targets+=("$target") - done < <( - grep -E '^FAIL: //' "$console_log" \ - | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ - | sort -u - ) - - if [[ ${#failed_targets[@]} -eq 0 ]]; then - echo "No failed Bazel test targets were found in console output." - return - fi - - for target in "${failed_targets[@]}"; do - local rel_path="${target#//}" - rel_path="${rel_path/:/\/}" - local test_log="${testlogs_dir}/${rel_path}/test.log" - - echo "::group::Bazel test log tail for ${target}" - if [[ -f "$test_log" ]]; then - tail -n 200 "$test_log" - else - echo "Missing test log: $test_log" - fi - echo "::endgroup::" - done - } - - bazel_args=( - test - --test_verbose_timeout_warnings - --build_metadata=REPO_URL=https://github.com/openai/codex.git - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - --build_metadata=ROLE=CI - --build_metadata=VISIBILITY=PUBLIC - ) - - bazel_targets=( - //... - # Keep V8 out of the ordinary Bazel CI path. Only the dedicated - # canary and release workflows should build `third_party/v8`. - -//third_party/v8:all - ) - - if [[ "${RUNNER_OS:-}" != "Windows" ]]; then - # Bazel test sandboxes on macOS may resolve an older Homebrew `node` - # before the `actions/setup-node` runtime on PATH. - node_bin="$(which node)" - bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}") - fi - - if [[ -n "${BUILDBUDDY_API_KEY:-}" ]]; then - echo "BuildBuddy API key is available; using remote Bazel configuration." - # Work around Bazel 9 remote repo contents cache / overlay materialization failures - # seen in CI (for example "is not a symlink" or permission errors while - # materializing external repos such as rules_perl). We still use BuildBuddy for - # remote execution/cache; this only disables the startup-level repo contents cache. - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=$BUILDBUDDY_API_KEY" \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e - else - echo "BuildBuddy API key is not available; using local Bazel configuration." - # Keep fork/community PRs on Bazel but disable remote services that are - # configured in .bazelrc and require auth. - # - # Flag docs: - # - Command-line reference: https://bazel.build/reference/command-line-reference - # - Remote caching overview: https://bazel.build/remote/caching - # - Remote execution overview: https://bazel.build/remote/rbe - # - Build Event Protocol overview: https://bazel.build/remote/bep - # - # --noexperimental_remote_repo_contents_cache: - # disable remote repo contents cache enabled in .bazelrc startup options. - # https://bazel.build/reference/command-line-reference#startup_options-flag--experimental_remote_repo_contents_cache - # --remote_cache= and --remote_executor=: - # clear remote cache/execution endpoints configured in .bazelrc. - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_cache - # https://bazel.build/reference/command-line-reference#common_options-flag--remote_executor - set +e - bazel $BAZEL_STARTUP_ARGS \ - --noexperimental_remote_repo_contents_cache \ - "${bazel_args[@]}" \ - --remote_cache= \ - --remote_executor= \ - -- \ - "${bazel_targets[@]}" \ - 2>&1 | tee "$bazel_console_log" - bazel_status=${PIPESTATUS[0]} - set -e - fi - - if [[ ${bazel_status:-0} -ne 0 ]]; then - print_failed_bazel_test_logs "$bazel_console_log" - exit "$bazel_status" - fi diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml deleted file mode 100644 index bce6e4979..000000000 --- a/.github/workflows/blob-size-policy.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: blob-size-policy - -on: - pull_request: {} - -jobs: - check: - name: Blob size policy - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: Determine PR comparison range - id: range - shell: bash - run: | - set -euo pipefail - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT" - - - name: Check changed blob sizes - env: - BASE_SHA: ${{ steps.range.outputs.base }} - HEAD_SHA: ${{ steps.range.outputs.head }} - run: | - python3 scripts/check_blob_size.py \ - --base "$BASE_SHA" \ - --head "$HEAD_SHA" \ - --max-bytes 512000 \ - --allowlist .github/blob-size-allowlist.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index bbd2df27c..000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,66 +0,0 @@ -name: ci - -on: - pull_request: {} - push: { branches: [main] } - -jobs: - build-test: - runs-on: ubuntu-latest - timeout-minutes: 10 - env: - NODE_OPTIONS: --max-old-space-size=4096 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 - - - name: Stage npm package - id: stage_npm_package - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - # Use a rust-release version that includes all native binaries. - CODEX_VERSION=0.115.0 - OUTPUT_DIR="${RUNNER_TEMP}" - python3 ./scripts/stage_npm_packages.py \ - --release-version "$CODEX_VERSION" \ - --package codex \ - --output-dir "$OUTPUT_DIR" - PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" - echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - - - name: Upload staged npm package artifact - uses: actions/upload-artifact@v7 - with: - name: codex-npm-staging - path: ${{ steps.stage_npm_package.outputs.pack_output }} - - - name: Ensure root README.md contains only ASCII and certain Unicode code points - run: ./scripts/asciicheck.py README.md - - name: Check root README ToC - run: python3 scripts/readme_toc.py README.md - - - name: Ensure codex-cli/README.md contains only ASCII and certain Unicode code points - run: ./scripts/asciicheck.py codex-cli/README.md - - name: Check codex-cli/README ToC - run: python3 scripts/readme_toc.py codex-cli/README.md - - - name: Prettier (run `pnpm run format:fix` to fix) - run: pnpm run format diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml deleted file mode 100644 index bab34d036..000000000 --- a/.github/workflows/cla.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: CLA Assistant -on: - issue_comment: - types: [created] - pull_request_target: - types: [opened, closed, synchronize] - -permissions: - actions: write - contents: write - pull-requests: write - statuses: write - -jobs: - cla: - # Only run the CLA assistant for the canonical openai repo so forks are not blocked - # and contributors who signed previously do not receive duplicate CLA notifications. - if: ${{ github.repository_owner == 'openai' }} - runs-on: ubuntu-latest - steps: - - uses: contributor-assistant/github-action@v2.6.1 - # Run on close only if the PR was merged. This will lock the PR to preserve - # the CLA agreement. We don't want to lock PRs that have been closed without - # merging because the contributor may want to respond with additional comments. - # This action has a "lock-pullrequest-aftermerge" option that can be set to false, - # but that would unconditionally skip locking even in cases where the PR was merged. - if: | - ( - github.event_name == 'pull_request_target' && - ( - github.event.action == 'opened' || - github.event.action == 'synchronize' || - (github.event.action == 'closed' && github.event.pull_request.merged == true) - ) - ) || - ( - github.event_name == 'issue_comment' && - ( - github.event.comment.body == 'recheck' || - github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA' - ) - ) - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - path-to-document: https://github.com/openai/codex/blob/main/docs/CLA.md - path-to-signatures: signatures/cla.json - branch: cla-signatures - allowlist: codex,dependabot,dependabot[bot],github-actions[bot] diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml deleted file mode 100644 index 43e699288..000000000 --- a/.github/workflows/close-stale-contributor-prs.yml +++ /dev/null @@ -1,107 +0,0 @@ -name: Close stale contributor PRs - -on: - workflow_dispatch: - schedule: - - cron: "0 6 * * *" - -permissions: - contents: read - issues: write - pull-requests: write - -jobs: - close-stale-contributor-prs: - # Prevent scheduled runs on forks - if: github.repository == 'openai/codex' - runs-on: ubuntu-latest - steps: - - name: Close inactive PRs from contributors - uses: actions/github-script@v8 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const DAYS_INACTIVE = 14; - const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000); - const { owner, repo } = context.repo; - const dryRun = false; - const stalePrs = []; - - core.info(`Dry run mode: ${dryRun}`); - - const prs = await github.paginate(github.rest.pulls.list, { - owner, - repo, - state: "open", - per_page: 100, - sort: "updated", - direction: "asc", - }); - - for (const pr of prs) { - const lastUpdated = new Date(pr.updated_at); - if (lastUpdated > cutoff) { - core.info(`PR ${pr.number} is fresh`); - continue; - } - - if (!pr.user || pr.user.type !== "User") { - core.info(`PR ${pr.number} wasn't created by a user`); - continue; - } - - let permission; - try { - const permissionResponse = await github.rest.repos.getCollaboratorPermissionLevel({ - owner, - repo, - username: pr.user.login, - }); - permission = permissionResponse.data.permission; - } catch (error) { - if (error.status === 404) { - core.info(`Author ${pr.user.login} is not a collaborator; skipping #${pr.number}`); - continue; - } - throw error; - } - - const hasContributorAccess = ["admin", "maintain", "write"].includes(permission); - if (!hasContributorAccess) { - core.info(`Author ${pr.user.login} has ${permission} access; skipping #${pr.number}`); - continue; - } - - stalePrs.push(pr); - } - - if (!stalePrs.length) { - core.info("No stale contributor pull requests found."); - return; - } - - for (const pr of stalePrs) { - const issue_number = pr.number; - const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`; - - if (dryRun) { - core.info(`[dry-run] Would close contributor PR #${issue_number} from ${pr.user.login}`); - continue; - } - - await github.rest.issues.createComment({ - owner, - repo, - issue_number, - body: closeComment, - }); - - await github.rest.pulls.update({ - owner, - repo, - pull_number: issue_number, - state: "closed", - }); - - core.info(`Closed contributor PR #${issue_number} from ${pr.user.login}`); - } diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml deleted file mode 100644 index bbbb06d06..000000000 --- a/.github/workflows/codespell.yml +++ /dev/null @@ -1,27 +0,0 @@ -# Codespell configuration is within .codespellrc ---- -name: Codespell - -on: - push: - branches: [main] - pull_request: - branches: [main] - -permissions: - contents: read - -jobs: - codespell: - name: Check for spelling errors - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Annotate locations with typos - uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 - - name: Codespell - uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 - with: - ignore_words_file: .codespellignore diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml deleted file mode 100644 index 6f4df87f4..000000000 --- a/.github/workflows/issue-deduplicator.yml +++ /dev/null @@ -1,402 +0,0 @@ -name: Issue Deduplicator - -on: - issues: - types: - - opened - - labeled - -jobs: - gather-duplicates-all: - name: Identify potential duplicates (all issues) - # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) - if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate')) - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - issues_json: ${{ steps.normalize-all.outputs.issues_json }} - reason: ${{ steps.normalize-all.outputs.reason }} - has_matches: ${{ steps.normalize-all.outputs.has_matches }} - steps: - - uses: actions/checkout@v6 - - - name: Prepare Codex inputs - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - CURRENT_ISSUE_FILE=codex-current-issue.json - EXISTING_ALL_FILE=codex-existing-issues-all.json - - gh issue list --repo "$REPO" \ - --json number,title,body,createdAt,updatedAt,state,labels \ - --limit 1000 \ - --state all \ - --search "sort:created-desc" \ - | jq '[.[] | { - number, - title, - body: ((.body // "")[0:4000]), - createdAt, - updatedAt, - state, - labels: ((.labels // []) | map(.name)) - }]' \ - > "$EXISTING_ALL_FILE" - - gh issue view "$ISSUE_NUMBER" \ - --repo "$REPO" \ - --json number,title,body \ - | jq '{number, title, body: ((.body // "")[0:4000])}' \ - > "$CURRENT_ISSUE_FILE" - - echo "Prepared duplicate detection input files." - echo "all_issue_count=$(jq 'length' "$EXISTING_ALL_FILE")" - - # Prompt instructions are intentionally inline in this workflow. The old - # .github/prompts/issue-deduplicator.txt file is obsolete and removed. - - id: codex-all - name: Find duplicates (pass 1, all issues) - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that triages new GitHub issues by identifying potential duplicates. - - You will receive the following JSON files located in the current working directory: - - `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body). - - `codex-existing-issues-all.json`: JSON array of recent issues with states, timestamps, and labels. - - Instructions: - - Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request. - - Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent. - - Prefer active unresolved issues when confidence is similar. - - Closed issues can still be valid duplicates if they clearly match. - - Return fewer matches rather than speculative ones. - - If confidence is low, return an empty list. - - Include at most five issue numbers. - - After analysis, provide a short reason for your decision. - - output-schema: | - { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { "type": "string" } - }, - "required": ["issues", "reason"], - "additionalProperties": false - } - - - id: normalize-all - name: Normalize pass 1 output - env: - CODEX_OUTPUT: ${{ steps.codex-all.outputs.final-message }} - CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - raw=${CODEX_OUTPUT//$'\r'/} - parsed=false - issues='[]' - reason='' - - if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then - parsed=true - issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]') - reason=$(printf '%s' "$raw" | jq -r '.reason // ""') - else - reason='Pass 1 output was empty or invalid JSON.' - fi - - filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[ - $issues[] - | tostring - | select(. != $current) - ] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]') - - has_matches=false - if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then - has_matches=true - fi - - echo "Pass 1 parsed: $parsed" - echo "Pass 1 matches after filtering: $(jq 'length' <<< "$filtered")" - echo "Pass 1 reason: $reason" - - { - echo "issues_json=$filtered" - echo "reason<> "$GITHUB_OUTPUT" - - gather-duplicates-open: - name: Identify potential duplicates (open issues fallback) - # Pass 1 may drop sudo on the runner, so run the fallback in a fresh job. - needs: gather-duplicates-all - if: ${{ needs.gather-duplicates-all.result == 'success' && needs.gather-duplicates-all.outputs.has_matches != 'true' }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - issues_json: ${{ steps.normalize-open.outputs.issues_json }} - reason: ${{ steps.normalize-open.outputs.reason }} - has_matches: ${{ steps.normalize-open.outputs.has_matches }} - steps: - - uses: actions/checkout@v6 - - - name: Prepare Codex inputs - env: - GH_TOKEN: ${{ github.token }} - REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - CURRENT_ISSUE_FILE=codex-current-issue.json - EXISTING_OPEN_FILE=codex-existing-issues-open.json - - gh issue list --repo "$REPO" \ - --json number,title,body,createdAt,updatedAt,state,labels \ - --limit 1000 \ - --state open \ - --search "sort:created-desc" \ - | jq '[.[] | { - number, - title, - body: ((.body // "")[0:4000]), - createdAt, - updatedAt, - state, - labels: ((.labels // []) | map(.name)) - }]' \ - > "$EXISTING_OPEN_FILE" - - gh issue view "$ISSUE_NUMBER" \ - --repo "$REPO" \ - --json number,title,body \ - | jq '{number, title, body: ((.body // "")[0:4000])}' \ - > "$CURRENT_ISSUE_FILE" - - echo "Prepared fallback duplicate detection input files." - echo "open_issue_count=$(jq 'length' "$EXISTING_OPEN_FILE")" - - - id: codex-open - name: Find duplicates (pass 2, open issues) - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that triages new GitHub issues by identifying potential duplicates. - - This is a fallback pass because a broad search did not find convincing matches. - - You will receive the following JSON files located in the current working directory: - - `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body). - - `codex-existing-issues-open.json`: JSON array of open issues only. - - Instructions: - - Search only these active unresolved issues for duplicates of the current issue. - - Prioritize concrete overlap in symptoms, reproduction details, error signatures, and user intent. - - Prefer fewer, higher-confidence matches. - - If confidence is low, return an empty list. - - Include at most five issue numbers. - - After analysis, provide a short reason for your decision. - - output-schema: | - { - "type": "object", - "properties": { - "issues": { - "type": "array", - "items": { - "type": "string" - } - }, - "reason": { "type": "string" } - }, - "required": ["issues", "reason"], - "additionalProperties": false - } - - - id: normalize-open - name: Normalize pass 2 output - env: - CODEX_OUTPUT: ${{ steps.codex-open.outputs.final-message }} - CURRENT_ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - set -eo pipefail - - raw=${CODEX_OUTPUT//$'\r'/} - parsed=false - issues='[]' - reason='' - - if [ -n "$raw" ] && printf '%s' "$raw" | jq -e 'type == "object" and (.issues | type == "array")' >/dev/null 2>&1; then - parsed=true - issues=$(printf '%s' "$raw" | jq -c '[.issues[] | tostring]') - reason=$(printf '%s' "$raw" | jq -r '.reason // ""') - else - reason='Pass 2 output was empty or invalid JSON.' - fi - - filtered=$(jq -cn --argjson issues "$issues" --arg current "$CURRENT_ISSUE_NUMBER" '[ - $issues[] - | tostring - | select(. != $current) - ] | reduce .[] as $issue ([]; if index($issue) then . else . + [$issue] end) | .[:5]') - - has_matches=false - if [ "$(jq 'length' <<< "$filtered")" -gt 0 ]; then - has_matches=true - fi - - echo "Pass 2 parsed: $parsed" - echo "Pass 2 matches after filtering: $(jq 'length' <<< "$filtered")" - echo "Pass 2 reason: $reason" - - { - echo "issues_json=$filtered" - echo "reason<> "$GITHUB_OUTPUT" - - select-final: - name: Select final duplicate set - needs: - - gather-duplicates-all - - gather-duplicates-open - if: ${{ always() && needs.gather-duplicates-all.result == 'success' && (needs.gather-duplicates-open.result == 'success' || needs.gather-duplicates-open.result == 'skipped') }} - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - codex_output: ${{ steps.select-final.outputs.codex_output }} - steps: - - id: select-final - name: Select final duplicate set - env: - PASS1_ISSUES: ${{ needs.gather-duplicates-all.outputs.issues_json }} - PASS1_REASON: ${{ needs.gather-duplicates-all.outputs.reason }} - PASS2_ISSUES: ${{ needs.gather-duplicates-open.outputs.issues_json }} - PASS2_REASON: ${{ needs.gather-duplicates-open.outputs.reason }} - PASS1_HAS_MATCHES: ${{ needs.gather-duplicates-all.outputs.has_matches }} - PASS2_HAS_MATCHES: ${{ needs.gather-duplicates-open.outputs.has_matches }} - run: | - set -eo pipefail - - selected_issues='[]' - selected_reason='No plausible duplicates found.' - selected_pass='none' - - if [ "$PASS1_HAS_MATCHES" = "true" ]; then - selected_issues=${PASS1_ISSUES:-'[]'} - selected_reason=${PASS1_REASON:-'Pass 1 found duplicates.'} - selected_pass='all' - fi - - if [ "$PASS2_HAS_MATCHES" = "true" ]; then - selected_issues=${PASS2_ISSUES:-'[]'} - selected_reason=${PASS2_REASON:-'Pass 2 found duplicates.'} - selected_pass='open-fallback' - fi - - final_json=$(jq -cn \ - --argjson issues "$selected_issues" \ - --arg reason "$selected_reason" \ - --arg pass "$selected_pass" \ - '{issues: $issues, reason: $reason, pass: $pass}') - - echo "Final pass used: $selected_pass" - echo "Final duplicate count: $(jq '.issues | length' <<< "$final_json")" - echo "Final reason: $(jq -r '.reason' <<< "$final_json")" - - { - echo "codex_output<> "$GITHUB_OUTPUT" - - comment-on-issue: - name: Comment with potential duplicates - needs: select-final - if: ${{ always() && needs.select-final.result == 'success' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - name: Comment on issue - uses: actions/github-script@v8 - env: - CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }} - with: - github-token: ${{ github.token }} - script: | - const raw = process.env.CODEX_OUTPUT ?? ''; - let parsed; - try { - parsed = JSON.parse(raw); - } catch (error) { - core.info(`Codex output was not valid JSON. Raw output: ${raw}`); - core.info(`Parse error: ${error.message}`); - return; - } - - const issues = Array.isArray(parsed?.issues) ? parsed.issues : []; - const currentIssueNumber = String(context.payload.issue.number); - const passUsed = typeof parsed?.pass === 'string' ? parsed.pass : 'unknown'; - const reason = typeof parsed?.reason === 'string' ? parsed.reason : ''; - - console.log(`Current issue number: ${currentIssueNumber}`); - console.log(`Pass used: ${passUsed}`); - if (reason) { - console.log(`Reason: ${reason}`); - } - console.log(issues); - - const filteredIssues = [...new Set(issues.map((value) => String(value)))].filter((value) => value !== currentIssueNumber).slice(0, 5); - - if (filteredIssues.length === 0) { - core.info('Codex reported no potential duplicates.'); - return; - } - - const lines = [ - 'Potential duplicates detected. Please review them and close your issue if it is a duplicate.', - '', - ...filteredIssues.map((value) => `- #${String(value)}`), - '', - '*Powered by [Codex Action](https://github.com/openai/codex-action)*']; - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.payload.issue.number, - body: lines.join("\n"), - }); - - - name: Remove codex-deduplicate label - if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }} - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label codex-deduplicate || true - echo "Attempted to remove label: codex-deduplicate" diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml deleted file mode 100644 index 174b219de..000000000 --- a/.github/workflows/issue-labeler.yml +++ /dev/null @@ -1,133 +0,0 @@ -name: Issue Labeler - -on: - issues: - types: - - opened - - labeled - -jobs: - gather-labels: - name: Generate label suggestions - # Prevent runs on forks (requires OpenAI API key, wastes Actions minutes) - if: github.repository == 'openai/codex' && (github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label')) - runs-on: ubuntu-latest - permissions: - contents: read - outputs: - codex_output: ${{ steps.codex.outputs.final-message }} - steps: - - uses: actions/checkout@v6 - - - id: codex - uses: openai/codex-action@main - with: - openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} - allow-users: "*" - prompt: | - You are an assistant that reviews GitHub issues for the repository. - - Your job is to choose the most appropriate labels for the issue described later in this prompt. - Follow these rules: - - - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. - 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). - 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. - 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). - - - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. - 1. CLI — the Codex command line interface. - 2. extension — VS Code (or other IDE) extension-specific issues. - 3. app - Issues related to the Codex desktop application. - 4. codex-web — Issues targeting the Codex web UI/Cloud experience. - 5. github-action — Issues with the Codex GitHub action. - 6. iOS — Issues with the Codex iOS app. - - - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. - 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). - 2. mcp — Topics involving Model Context Protocol servers/clients. - 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. - 4. azure — Problems or requests tied to Azure OpenAI deployments. - 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. - 6. code-review — Issues related to the code review feature or functionality. - 7. safety-check - Issues related to cyber risk detection or trusted access verification. - 8. auth - Problems related to authentication, login, or access tokens. - 9. codex-exec - Problems related to the "codex exec" command or functionality. - 10. context-management - Problems related to compaction, context windows, or available context reporting. - 11. custom-model - Problems that involve using custom model providers, local models, or OSS models. - 12. rate-limits - Problems related to token limits, rate limits, or token usage reporting. - 13. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. - 14. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. - 15. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. - - Issue number: ${{ github.event.issue.number }} - - Issue title: - ${{ github.event.issue.title }} - - Issue body: - ${{ github.event.issue.body }} - - Repository full name: - ${{ github.repository }} - - output-schema: | - { - "type": "object", - "properties": { - "labels": { - "type": "array", - "items": { - "type": "string" - } - } - }, - "required": ["labels"], - "additionalProperties": false - } - - apply-labels: - name: Apply labels from Codex output - needs: gather-labels - if: ${{ needs.gather-labels.result != 'skipped' }} - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - env: - GH_TOKEN: ${{ github.token }} - GH_REPO: ${{ github.repository }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} - steps: - - name: Apply labels - run: | - json=${CODEX_OUTPUT//$'\r'/} - if [ -z "$json" ]; then - echo "Codex produced no output. Skipping label application." - exit 0 - fi - - if ! printf '%s' "$json" | jq -e 'type == "object" and (.labels | type == "array")' >/dev/null 2>&1; then - echo "Codex output did not include a labels array. Raw output: $json" - exit 0 - fi - - labels=$(printf '%s' "$json" | jq -r '.labels[] | tostring') - if [ -z "$labels" ]; then - echo "Codex returned an empty array. Nothing to do." - exit 0 - fi - - cmd=(gh issue edit "$ISSUE_NUMBER") - while IFS= read -r label; do - cmd+=(--add-label "$label") - done <<< "$labels" - - "${cmd[@]}" || true - - - name: Remove codex-label trigger - if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} - run: | - gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true - echo "Attempted to remove label: codex-label" diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index c203e2b74..abfe28fd7 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -67,7 +67,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: components: rustfmt - name: cargo fmt @@ -83,7 +83,7 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 with: tool: cargo-shear @@ -98,7 +98,7 @@ jobs: if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' || github.event_name == 'push' }} steps: - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -148,7 +148,7 @@ jobs: run: | sudo DEBIAN_FRONTEND=noninteractive apt-get update sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src @@ -272,7 +272,7 @@ jobs: fi sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "${packages[@]}" fi - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: targets: ${{ matrix.target }} components: clippy @@ -615,7 +615,7 @@ jobs: - name: Install DotSlash uses: facebook/install-dotslash@v2 - - uses: dtolnay/rust-toolchain@1.93.0 + - uses: dtolnay/rust-toolchain@1.100.0 with: targets: ${{ matrix.target }} diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml deleted file mode 100644 index a0d12d6db..000000000 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ /dev/null @@ -1,103 +0,0 @@ -name: rust-release-argument-comment-lint - -on: - workflow_call: - inputs: - publish: - required: true - type: boolean - -jobs: - skip: - if: ${{ !inputs.publish }} - runs-on: ubuntu-latest - steps: - - run: echo "Skipping argument-comment-lint release assets for prerelease tag" - - build: - if: ${{ inputs.publish }} - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - archive_name: argument-comment-lint-aarch64-apple-darwin.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-apple-darwin.dylib - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - archive_name: argument-comment-lint-x86_64-unknown-linux-gnu.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-x86_64-unknown-linux-gnu.so - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - archive_name: argument-comment-lint-aarch64-unknown-linux-gnu.tar.gz - lib_name: libargument_comment_lint@nightly-2025-09-18-aarch64-unknown-linux-gnu.so - runner_binary: argument-comment-lint - cargo_dylint_binary: cargo-dylint - - runner: windows-x64 - target: x86_64-pc-windows-msvc - archive_name: argument-comment-lint-x86_64-pc-windows-msvc.zip - lib_name: argument_comment_lint@nightly-2025-09-18-x86_64-pc-windows-msvc.dll - runner_binary: argument-comment-lint.exe - cargo_dylint_binary: cargo-dylint.exe - runs_on: - group: codex-runners - labels: codex-windows-x64 - - steps: - - uses: actions/checkout@v6 - - - uses: dtolnay/rust-toolchain@1.93.0 - with: - toolchain: nightly-2025-09-18 - targets: ${{ matrix.target }} - components: llvm-tools-preview, rustc-dev, rust-src - - - name: Install tooling - shell: bash - run: | - install_root="${RUNNER_TEMP}/argument-comment-lint-tools" - cargo install --locked cargo-dylint --root "$install_root" - cargo install --locked dylint-link - echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV" - - - name: Cargo build - working-directory: tools/argument-comment-lint - shell: bash - run: cargo build --release --target ${{ matrix.target }} - - - name: Stage artifact - shell: bash - run: | - dest="dist/argument-comment-lint/${{ matrix.target }}" - mkdir -p "$dest" - package_root="${RUNNER_TEMP}/argument-comment-lint" - rm -rf "$package_root" - mkdir -p "$package_root/bin" "$package_root/lib" - - cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.runner_binary }}" \ - "$package_root/bin/${{ matrix.runner_binary }}" - cp "${INSTALL_ROOT}/bin/${{ matrix.cargo_dylint_binary }}" \ - "$package_root/bin/${{ matrix.cargo_dylint_binary }}" - cp "tools/argument-comment-lint/target/${{ matrix.target }}/release/${{ matrix.lib_name }}" \ - "$package_root/lib/${{ matrix.lib_name }}" - - archive_path="$dest/${{ matrix.archive_name }}" - if [[ "${{ runner.os }}" == "Windows" ]]; then - (cd "${RUNNER_TEMP}" && 7z a "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint >/dev/null) - else - (cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint) - fi - - - uses: actions/upload-artifact@v7 - with: - name: argument-comment-lint-${{ matrix.target }} - path: dist/argument-comment-lint/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml deleted file mode 100644 index c9f11f54f..000000000 --- a/.github/workflows/rust-release-prepare.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: rust-release-prepare -on: - workflow_dispatch: - schedule: - - cron: "0 */4 * * *" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - -permissions: - contents: write - pull-requests: write - -jobs: - prepare: - # Prevent scheduled runs on forks (no secrets, wastes Actions minutes) - if: github.repository == 'openai/codex' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - with: - ref: main - fetch-depth: 0 - - - name: Update models.json - env: - OPENAI_API_KEY: ${{ secrets.CODEX_OPENAI_API_KEY }} - run: | - set -euo pipefail - - client_version="99.99.99" - terminal_info="github-actions" - user_agent="codex_cli_rs/99.99.99 (Linux $(uname -r); $(uname -m)) ${terminal_info}" - base_url="${OPENAI_BASE_URL:-https://chatgpt.com/backend-api/codex}" - - headers=( - -H "Authorization: Bearer ${OPENAI_API_KEY}" - -H "User-Agent: ${user_agent}" - ) - - url="${base_url%/}/models?client_version=${client_version}" - curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/core/models.json - - - name: Open pull request (if changed) - uses: peter-evans/create-pull-request@v8 - with: - commit-message: "Update models.json" - title: "Update models.json" - body: "Automated update of models.json." - branch: "bot/update-models-json" - reviewers: "pakrym-oai,aibrahim-oai" - delete-branch: true diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml deleted file mode 100644 index f762fbc4b..000000000 --- a/.github/workflows/rust-release-windows.yml +++ /dev/null @@ -1,264 +0,0 @@ -name: rust-release-windows - -on: - workflow_call: - inputs: - release-lto: - required: true - type: string - secrets: - AZURE_TRUSTED_SIGNING_CLIENT_ID: - required: true - AZURE_TRUSTED_SIGNING_TENANT_ID: - required: true - AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID: - required: true - AZURE_TRUSTED_SIGNING_ENDPOINT: - required: true - AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: - required: true - AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME: - required: true - -jobs: - build-windows-binaries: - name: Build Windows binaries - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} - runs-on: ${{ matrix.runs_on }} - timeout-minutes: 60 - permissions: - contents: read - defaults: - run: - working-directory: codex-rs - env: - CARGO_PROFILE_RELEASE_LTO: ${{ inputs.release-lto }} - - strategy: - fail-fast: false - matrix: - include: - - runner: windows-x64 - target: x86_64-pc-windows-msvc - bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - runner: windows-x64 - target: x86_64-pc-windows-msvc - bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - name: Print runner specs (Windows) - shell: powershell - run: | - $computer = Get-CimInstance Win32_ComputerSystem - $cpu = Get-CimInstance Win32_Processor | Select-Object -First 1 - $ramGiB = [math]::Round($computer.TotalPhysicalMemory / 1GB, 1) - Write-Host "Runner: $env:RUNNER_NAME" - Write-Host "OS: $([System.Environment]::OSVersion.VersionString)" - Write-Host "CPU: $($cpu.Name)" - Write-Host "Logical CPUs: $($computer.NumberOfLogicalProcessors)" - Write-Host "Physical CPUs: $($computer.NumberOfProcessors)" - Write-Host "Total RAM: $ramGiB GiB" - Write-Host "Disk usage:" - Get-PSDrive -PSProvider FileSystem | Format-Table -AutoSize Name, @{Name='Size(GB)';Expression={[math]::Round(($_.Used + $_.Free) / 1GB, 1)}}, @{Name='Free(GB)';Expression={[math]::Round($_.Free / 1GB, 1)}} - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - - name: Cargo build (Windows binaries) - shell: bash - run: | - cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }} - - - name: Upload Cargo timings - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - name: Stage Windows binaries - shell: bash - run: | - output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" - mkdir -p "$output_dir" - if [[ "${{ matrix.bundle }}" == "primary" ]]; then - cp target/${{ matrix.target }}/release/codex.exe "$output_dir/codex.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$output_dir/codex-responses-api-proxy.exe" - else - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$output_dir/codex-windows-sandbox-setup.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$output_dir/codex-command-runner.exe" - fi - - - name: Upload Windows binaries - uses: actions/upload-artifact@v7 - with: - name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }} - path: | - codex-rs/target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}/* - - build-windows: - needs: - - build-windows-binaries - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on }} - timeout-minutes: 60 - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: codex-rs - - strategy: - fail-fast: false - matrix: - include: - - runner: windows-x64 - target: x86_64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-x64 - - runner: windows-arm64 - target: aarch64-pc-windows-msvc - runs_on: - group: codex-runners - labels: codex-windows-arm64 - - steps: - - uses: actions/checkout@v6 - - - name: Download prebuilt Windows primary binaries - uses: actions/download-artifact@v8 - with: - name: windows-binaries-${{ matrix.target }}-primary - path: codex-rs/target/${{ matrix.target }}/release - - - name: Download prebuilt Windows helper binaries - uses: actions/download-artifact@v8 - with: - name: windows-binaries-${{ matrix.target }}-helpers - path: codex-rs/target/${{ matrix.target }}/release - - - name: Verify binaries - shell: bash - run: | - set -euo pipefail - ls -lh target/${{ matrix.target }}/release/codex.exe - ls -lh target/${{ matrix.target }}/release/codex-responses-api-proxy.exe - ls -lh target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe - ls -lh target/${{ matrix.target }}/release/codex-command-runner.exe - - - name: Sign Windows binaries with Azure Trusted Signing - uses: ./.github/actions/windows-code-sign - with: - target: ${{ matrix.target }} - client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} - tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} - subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} - endpoint: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }} - account-name: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }} - certificate-profile-name: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE_NAME }} - - - name: Stage artifacts - shell: bash - run: | - dest="dist/${{ matrix.target }}" - mkdir -p "$dest" - - cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" - - - name: Install DotSlash - uses: facebook/install-dotslash@v2 - - - name: Compress artifacts - shell: bash - run: | - # Path that contains the uncompressed binaries for the current - # ${{ matrix.target }} - dest="dist/${{ matrix.target }}" - repo_root=$PWD - - # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` and `.zip` for every Windows binary. - # The end result is: - # codex-.zst - # codex-.tar.gz - # codex-.zip - for f in "$dest"/*; do - base="$(basename "$f")" - # Skip files that are already archives (shouldn't happen, but be - # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - # Don't try to compress signature bundles. - if [[ "$base" == *.sigstore ]]; then - continue - fi - - # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Create zip archive for Windows binaries. - # Must run from inside the dest dir so 7z won't embed the - # directory path inside the zip. - if [[ "$base" == "codex-${{ matrix.target }}.exe" ]]; then - # Bundle the sandbox helper binaries into the main codex zip so - # WinGet installs include the required helpers next to codex.exe. - # Fall back to the single-binary zip if the helpers are missing - # to avoid breaking releases. - bundle_dir="$(mktemp -d)" - runner_src="$dest/codex-command-runner-${{ matrix.target }}.exe" - setup_src="$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - if [[ -f "$runner_src" && -f "$setup_src" ]]; then - cp "$dest/$base" "$bundle_dir/$base" - cp "$runner_src" "$bundle_dir/codex-command-runner.exe" - cp "$setup_src" "$bundle_dir/codex-windows-sandbox-setup.exe" - # Use an absolute path so bundle zips land in the real dist - # dir even when 7z runs from a temp directory. - (cd "$bundle_dir" && 7z a "$repo_root/$dest/${base}.zip" .) - else - echo "warning: missing sandbox binaries; falling back to single-binary zip" - echo "warning: expected $runner_src and $setup_src" - (cd "$dest" && 7z a "${base}.zip" "$base") - fi - rm -rf "$bundle_dir" - else - (cd "$dest" && 7z a "${base}.zip" "$base") - fi - - # Keep raw executables and produce .zst alongside them. - "${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base" - done - - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.target }} - path: | - codex-rs/dist/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml deleted file mode 100644 index a0f71aa73..000000000 --- a/.github/workflows/rust-release-zsh.yml +++ /dev/null @@ -1,95 +0,0 @@ -name: rust-release-zsh - -on: - workflow_call: - -env: - ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6 - ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch - -jobs: - linux: - name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - container: - image: ${{ matrix.image }} - - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - variant: ubuntu-24.04 - image: ubuntu:24.04 - archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - variant: ubuntu-24.04 - image: arm64v8/ubuntu:24.04 - archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz - - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y \ - autoconf \ - bison \ - build-essential \ - ca-certificates \ - gettext \ - git \ - libncursesw5-dev - - - uses: actions/checkout@v6 - - - name: Build, smoke-test, and stage zsh artifact - shell: bash - run: | - "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ - "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - - uses: actions/upload-artifact@v7 - with: - name: codex-zsh-${{ matrix.target }} - path: dist/zsh/${{ matrix.target }}/* - - darwin: - name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} - runs-on: ${{ matrix.runner }} - timeout-minutes: 30 - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - variant: macos-15 - archive_name: codex-zsh-aarch64-apple-darwin.tar.gz - - steps: - - name: Install build prerequisites - shell: bash - run: | - set -euo pipefail - if ! command -v autoconf >/dev/null 2>&1; then - brew install autoconf - fi - - - uses: actions/checkout@v6 - - - name: Build, smoke-test, and stage zsh artifact - shell: bash - run: | - "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ - "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - - uses: actions/upload-artifact@v7 - with: - name: codex-zsh-${{ matrix.target }} - path: dist/zsh/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml deleted file mode 100644 index 1ec9bd28b..000000000 --- a/.github/workflows/rust-release.yml +++ /dev/null @@ -1,722 +0,0 @@ -# Release workflow for codex-rs. -# To release, follow a workflow like: -# ``` -# git tag -a rust-v0.1.0 -m "Release 0.1.0" -# git push origin rust-v0.1.0 -# ``` - -name: rust-release -on: - push: - tags: - - "rust-v*.*.*" - -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: true - -jobs: - tag-check: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v6 - - uses: dtolnay/rust-toolchain@1.92 - - name: Validate tag matches Cargo.toml version - shell: bash - run: | - set -euo pipefail - echo "::group::Tag validation" - - # 1. Must be a tag and match the regex - [[ "${GITHUB_REF_TYPE}" == "tag" ]] \ - || { echo "❌ Not a tag push"; exit 1; } - [[ "${GITHUB_REF_NAME}" =~ ^rust-v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta)(\.[0-9]+)?)?$ ]] \ - || { echo "❌ Tag '${GITHUB_REF_NAME}' doesn't match expected format"; exit 1; } - - # 2. Extract versions - tag_ver="${GITHUB_REF_NAME#rust-v}" - cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \ - | sed -E 's/version *= *"([^"]+)".*/\1/')" - - # 3. Compare - [[ "${tag_ver}" == "${cargo_ver}" ]] \ - || { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; } - - echo "✅ Tag and Cargo.toml agree (${tag_ver})" - echo "::endgroup::" - - build: - needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 - permissions: - contents: read - id-token: write - defaults: - run: - working-directory: codex-rs - env: - # 2026-03-04: temporarily change releases to use thin LTO because - # Ubuntu ARM is timing out at 60 minutes. - CARGO_PROFILE_RELEASE_LTO: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'thin' }} - - strategy: - fail-fast: false - matrix: - include: - - runner: macos-15-xlarge - target: aarch64-apple-darwin - - runner: macos-15-xlarge - target: x86_64-apple-darwin - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04 - target: x86_64-unknown-linux-gnu - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-musl - - runner: ubuntu-24.04-arm - target: aarch64-unknown-linux-gnu - - steps: - - uses: actions/checkout@v6 - - name: Print runner specs (Linux) - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - cpu_model="$(lscpu | awk -F: '/Model name/ {gsub(/^[ \t]+/, "", $2); print $2; exit}')" - total_ram="$(awk '/MemTotal/ {printf "%.1f GiB\n", $2 / 1024 / 1024}' /proc/meminfo)" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(uname -a)" - echo "CPU model: ${cpu_model}" - echo "Logical CPUs: $(nproc)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Print runner specs (macOS) - if: ${{ runner.os == 'macOS' }} - shell: bash - run: | - set -euo pipefail - total_ram="$(sysctl -n hw.memsize | awk '{printf "%.1f GiB\n", $1 / 1024 / 1024 / 1024}')" - echo "Runner: ${RUNNER_NAME:-unknown}" - echo "OS: $(sw_vers -productName) $(sw_vers -productVersion)" - echo "Hardware model: $(sysctl -n hw.model)" - echo "CPU architecture: $(uname -m)" - echo "Logical CPUs: $(sysctl -n hw.logicalcpu)" - echo "Physical CPUs: $(sysctl -n hw.physicalcpu)" - echo "Total RAM: ${total_ram}" - echo "Disk usage:" - df -h . - - name: Install Linux bwrap build dependencies - if: ${{ runner.os == 'Linux' }} - shell: bash - run: | - set -euo pipefail - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - name: Install UBSan runtime (musl) - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - shell: bash - run: | - set -euo pipefail - if command -v apt-get >/dev/null 2>&1; then - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y libubsan1 - fi - - uses: dtolnay/rust-toolchain@1.93.0 - with: - targets: ${{ matrix.target }} - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Use hermetic Cargo home (musl) - shell: bash - run: | - set -euo pipefail - cargo_home="${GITHUB_WORKSPACE}/.cargo-home" - mkdir -p "${cargo_home}/bin" - echo "CARGO_HOME=${cargo_home}" >> "$GITHUB_ENV" - echo "${cargo_home}/bin" >> "$GITHUB_PATH" - : > "${cargo_home}/config.toml" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 - with: - version: 0.14.0 - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Install musl build tools - env: - TARGET: ${{ matrix.target }} - run: bash "${GITHUB_WORKSPACE}/.github/scripts/install-musl-build-tools.sh" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Configure rustc UBSan wrapper (musl host) - shell: bash - run: | - set -euo pipefail - ubsan="" - if command -v ldconfig >/dev/null 2>&1; then - ubsan="$(ldconfig -p | grep -m1 'libubsan\.so\.1' | sed -E 's/.*=> (.*)$/\1/')" - fi - wrapper_root="${RUNNER_TEMP:-/tmp}" - wrapper="${wrapper_root}/rustc-ubsan-wrapper" - cat > "${wrapper}" <> "$GITHUB_ENV" - echo "RUSTC_WORKSPACE_WRAPPER=" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} - name: Clear sanitizer flags (musl) - shell: bash - run: | - set -euo pipefail - # Avoid problematic aws-lc jitter entropy code path on musl builders. - echo "AWS_LC_SYS_NO_JITTER_ENTROPY=1" >> "$GITHUB_ENV" - target_no_jitter="AWS_LC_SYS_NO_JITTER_ENTROPY_${{ matrix.target }}" - target_no_jitter="${target_no_jitter//-/_}" - echo "${target_no_jitter}=1" >> "$GITHUB_ENV" - - # Clear global Rust flags so host/proc-macro builds don't pull in UBSan. - echo "RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_ENCODED_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "RUSTDOCFLAGS=" >> "$GITHUB_ENV" - # Override any runner-level Cargo config rustflags as well. - echo "CARGO_BUILD_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - echo "CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_RUSTFLAGS=" >> "$GITHUB_ENV" - - sanitize_flags() { - local input="$1" - input="${input//-fsanitize=undefined/}" - input="${input//-fno-sanitize-recover=undefined/}" - input="${input//-fno-sanitize-trap=undefined/}" - echo "$input" - } - - cflags="$(sanitize_flags "${CFLAGS-}")" - cxxflags="$(sanitize_flags "${CXXFLAGS-}")" - echo "CFLAGS=${cflags}" >> "$GITHUB_ENV" - echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV" - - - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }} - name: Configure musl rusty_v8 artifact overrides - env: - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)" - release_tag="rusty-v8-v${version}" - base_url="https://github.com/openai/codex/releases/download/${release_tag}" - archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz" - binding_dir="${RUNNER_TEMP}/rusty_v8" - binding_path="${binding_dir}/src_binding_release_${TARGET}.rs" - mkdir -p "${binding_dir}" - curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}" - echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV" - echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV" - - - name: Cargo build - shell: bash - run: | - echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy - - - name: Upload Cargo timings - uses: actions/upload-artifact@v7 - with: - name: cargo-timings-rust-release-${{ matrix.target }} - path: codex-rs/target/**/cargo-timings/cargo-timing.html - if-no-files-found: warn - - - if: ${{ contains(matrix.target, 'linux') }} - name: Cosign Linux artifacts - uses: ./.github/actions/linux-code-sign - with: - target: ${{ matrix.target }} - artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release - - - if: ${{ runner.os == 'macOS' }} - name: MacOS code signing (binaries) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - sign-binaries: "true" - sign-dmg: "false" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - - if: ${{ runner.os == 'macOS' }} - name: Build macOS dmg - shell: bash - run: | - set -euo pipefail - - target="${{ matrix.target }}" - release_dir="target/${target}/release" - dmg_root="${RUNNER_TEMP}/codex-dmg-root" - volname="Codex (${target})" - dmg_path="${release_dir}/codex-${target}.dmg" - - # The previous "MacOS code signing (binaries)" step signs + notarizes the - # built artifacts in `${release_dir}`. This step packages *those same* - # signed binaries into a dmg. - codex_binary_path="${release_dir}/codex" - proxy_binary_path="${release_dir}/codex-responses-api-proxy" - - rm -rf "$dmg_root" - mkdir -p "$dmg_root" - - if [[ ! -f "$codex_binary_path" ]]; then - echo "Binary $codex_binary_path not found" - exit 1 - fi - if [[ ! -f "$proxy_binary_path" ]]; then - echo "Binary $proxy_binary_path not found" - exit 1 - fi - - ditto "$codex_binary_path" "${dmg_root}/codex" - ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" - - rm -f "$dmg_path" - hdiutil create \ - -volname "$volname" \ - -srcfolder "$dmg_root" \ - -format UDZO \ - -ov \ - "$dmg_path" - - if [[ ! -f "$dmg_path" ]]; then - echo "dmg $dmg_path not found after build" - exit 1 - fi - - - if: ${{ runner.os == 'macOS' }} - name: MacOS code signing (dmg) - uses: ./.github/actions/macos-code-sign - with: - target: ${{ matrix.target }} - sign-binaries: "false" - sign-dmg: "true" - apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} - apple-certificate-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - apple-notarization-key-p8: ${{ secrets.APPLE_NOTARIZATION_KEY_P8 }} - apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} - apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - - name: Stage artifacts - shell: bash - run: | - dest="dist/${{ matrix.target }}" - mkdir -p "$dest" - - cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" - - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" - fi - - if [[ "${{ matrix.target }}" == *apple-darwin ]]; then - cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" - fi - - - name: Compress artifacts - shell: bash - run: | - # Path that contains the uncompressed binaries for the current - # ${{ matrix.target }} - dest="dist/${{ matrix.target }}" - - # For compatibility with environments that lack the `zstd` tool we - # additionally create a `.tar.gz` alongside every binary we publish. - # The end result is: - # codex-.zst (existing) - # codex-.tar.gz (new) - - # 1. Produce a .tar.gz for every file in the directory *before* we - # run `zstd --rm`, because that flag deletes the original files. - for f in "$dest"/*; do - base="$(basename "$f")" - # Skip files that are already archives (shouldn't happen, but be - # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then - continue - fi - - # Don't try to compress signature bundles. - if [[ "$base" == *.sigstore ]]; then - continue - fi - - # Create per-binary tar.gz - tar -C "$dest" -czf "$dest/${base}.tar.gz" "$base" - - # Also create .zst and remove the uncompressed binaries to keep - # non-Windows artifact directories small. - zstd -T0 -19 --rm "$dest/$base" - done - - - uses: actions/upload-artifact@v7 - with: - name: ${{ matrix.target }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. - path: | - codex-rs/dist/${{ matrix.target }}/* - - build-windows: - needs: tag-check - uses: ./.github/workflows/rust-release-windows.yml - with: - release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }} - secrets: inherit - - argument-comment-lint-release-assets: - name: argument-comment-lint release assets - needs: tag-check - uses: ./.github/workflows/rust-release-argument-comment-lint.yml - with: - publish: true - - zsh-release-assets: - name: zsh release assets - needs: tag-check - uses: ./.github/workflows/rust-release-zsh.yml - - release: - needs: - - build - - build-windows - - argument-comment-lint-release-assets - - zsh-release-assets - name: release - runs-on: ubuntu-latest - permissions: - contents: write - actions: read - outputs: - version: ${{ steps.release_name.outputs.name }} - tag: ${{ github.ref_name }} - should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} - npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} - - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Generate release notes from tag commit message - id: release_notes - shell: bash - run: | - set -euo pipefail - - # On tag pushes, GITHUB_SHA may be a tag object for annotated tags; - # peel it to the underlying commit. - commit="$(git rev-parse "${GITHUB_SHA}^{commit}")" - notes_path="${RUNNER_TEMP}/release-notes.md" - - # Use the commit message for the commit the tag points at (not the - # annotated tag message). - git log -1 --format=%B "${commit}" > "${notes_path}" - # Ensure trailing newline so GitHub's markdown renderer doesn't - # occasionally run the last line into subsequent content. - echo >> "${notes_path}" - - echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - - uses: actions/download-artifact@v8 - with: - path: dist - - - name: List - run: ls -R dist/ - - - name: Delete entries from dist/ that should not go in the release - run: | - rm -rf dist/windows-binaries* - # cargo-timing.html appears under multiple target-specific directories. - # If included in files: dist/**, release upload races on duplicate - # asset names and can fail with 404s. - find dist -type f -name 'cargo-timing.html' -delete - find dist -type d -empty -delete - - ls -R dist/ - - - name: Add config schema release asset - run: | - cp codex-rs/core/config.schema.json dist/config-schema.json - - - name: Define release name - id: release_name - run: | - # Extract the version from the tag name, which is in the format - # "rust-v0.1.0". - version="${GITHUB_REF_NAME#rust-v}" - echo "name=${version}" >> $GITHUB_OUTPUT - - - name: Determine npm publish settings - id: npm_publish_settings - env: - VERSION: ${{ steps.release_name.outputs.name }} - run: | - set -euo pipefail - version="${VERSION}" - - if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - echo "npm_tag=alpha" >> "$GITHUB_OUTPUT" - else - echo "should_publish=false" >> "$GITHUB_OUTPUT" - echo "npm_tag=" >> "$GITHUB_OUTPUT" - fi - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js for npm packaging - uses: actions/setup-node@v6 - with: - node-version: 22 - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - # stage_npm_packages.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 - - name: Stage npm packages - env: - GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.release_name.outputs.name }} - run: | - ./scripts/stage_npm_packages.py \ - --release-version "$RELEASE_VERSION" \ - --package codex \ - --package codex-responses-api-proxy \ - --package codex-sdk - - - name: Stage installer scripts - run: | - cp scripts/install/install.sh dist/install.sh - cp scripts/install/install.ps1 dist/install.ps1 - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - name: ${{ steps.release_name.outputs.name }} - tag_name: ${{ github.ref_name }} - body_path: ${{ steps.release_notes.outputs.path }} - files: dist/** - # Mark as prerelease only when the version has a suffix after x.y.z - # (e.g. -alpha, -beta). Otherwise publish a normal release. - prerelease: ${{ contains(steps.release_name.outputs.name, '-') }} - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-config.json - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-zsh-config.json - - - uses: facebook/dotslash-publish-release@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag: ${{ github.ref_name }} - config: .github/dotslash-argument-comment-lint-config.json - - - name: Trigger developers.openai.com deploy - # Only trigger the deploy if the release is not a pre-release. - # The deploy is used to update the developers.openai.com website with the new config schema json file. - if: ${{ !contains(steps.release_name.outputs.name, '-') }} - continue-on-error: true - env: - DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL: ${{ secrets.DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL }} - run: | - if ! curl -sS -f -o /dev/null -X POST "$DEV_WEBSITE_VERCEL_DEPLOY_HOOK_URL"; then - echo "::warning title=developers.openai.com deploy hook failed::Vercel deploy hook POST failed for ${GITHUB_REF_NAME}" - exit 1 - fi - - # Publish to npm using OIDC authentication. - # July 31, 2025: https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/ - # npm docs: https://docs.npmjs.com/trusted-publishers - publish-npm: - # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.outputs.should_publish_npm == 'true' }} - name: publish-npm - needs: release - runs-on: ubuntu-latest - permissions: - id-token: write # Required for OIDC - contents: read - - steps: - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - registry-url: "https://registry.npmjs.org" - scope: "@openai" - - # Trusted publishing requires npm CLI version 11.5.1 or later. - - name: Update npm - run: npm install -g npm@latest - - - name: Download npm tarballs from release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ needs.release.outputs.tag }} - RELEASE_VERSION: ${{ needs.release.outputs.version }} - run: | - set -euo pipefail - version="$RELEASE_VERSION" - tag="$RELEASE_TAG" - mkdir -p dist/npm - patterns=( - "codex-npm-${version}.tgz" - "codex-npm-linux-*-${version}.tgz" - "codex-npm-darwin-*-${version}.tgz" - "codex-npm-win32-*-${version}.tgz" - "codex-responses-api-proxy-npm-${version}.tgz" - "codex-sdk-npm-${version}.tgz" - ) - for pattern in "${patterns[@]}"; do - gh release download "$tag" \ - --repo "${GITHUB_REPOSITORY}" \ - --pattern "$pattern" \ - --dir dist/npm - done - - # No NODE_AUTH_TOKEN needed because we use OIDC. - - name: Publish to npm - env: - VERSION: ${{ needs.release.outputs.version }} - NPM_TAG: ${{ needs.release.outputs.npm_tag }} - run: | - set -euo pipefail - prefix="" - if [[ -n "${NPM_TAG}" ]]; then - prefix="${NPM_TAG}-" - fi - - shopt -s nullglob - tarballs=(dist/npm/*-"${VERSION}".tgz) - if [[ ${#tarballs[@]} -eq 0 ]]; then - echo "No npm tarballs found in dist/npm for version ${VERSION}" - exit 1 - fi - - for tarball in "${tarballs[@]}"; do - filename="$(basename "${tarball}")" - tag="" - - case "${filename}" in - codex-npm-linux-*-"${VERSION}".tgz|codex-npm-darwin-*-"${VERSION}".tgz|codex-npm-win32-*-"${VERSION}".tgz) - platform="${filename#codex-npm-}" - platform="${platform%-${VERSION}.tgz}" - tag="${prefix}${platform}" - ;; - codex-npm-"${VERSION}".tgz|codex-responses-api-proxy-npm-"${VERSION}".tgz|codex-sdk-npm-"${VERSION}".tgz) - tag="${NPM_TAG}" - ;; - *) - echo "Unexpected npm tarball: ${filename}" - exit 1 - ;; - esac - - publish_cmd=(npm publish "${GITHUB_WORKSPACE}/${tarball}") - if [[ -n "${tag}" ]]; then - publish_cmd+=(--tag "${tag}") - fi - - echo "+ ${publish_cmd[*]}" - set +e - publish_output="$("${publish_cmd[@]}" 2>&1)" - publish_status=$? - set -e - - echo "${publish_output}" - if [[ ${publish_status} -eq 0 ]]; then - continue - fi - - if grep -qiE "previously published|cannot publish over|version already exists" <<< "${publish_output}"; then - echo "Skipping already-published package version for ${filename}" - continue - fi - - exit "${publish_status}" - done - - winget: - name: winget - needs: release - # Only publish stable/mainline releases to WinGet; pre-releases include a - # '-' in the semver string (e.g., 1.2.3-alpha.1). - if: ${{ !contains(needs.release.outputs.version, '-') }} - # This job only invokes a GitHub Action to open/update the winget-pkgs PR; - # it does not execute Windows-only tooling, so Linux is sufficient. - runs-on: ubuntu-latest - permissions: - contents: read - - steps: - - name: Publish to WinGet - uses: vedantmgoyal9/winget-releaser@7bd472be23763def6e16bd06cc8b1cdfab0e2fd5 - with: - identifier: OpenAI.Codex - version: ${{ needs.release.outputs.version }} - release-tag: ${{ needs.release.outputs.tag }} - fork-user: openai-oss-forks - installers-regex: '^codex-(?:x86_64|aarch64)-pc-windows-msvc\.exe\.zip$' - token: ${{ secrets.WINGET_PUBLISH_PAT }} - - update-branch: - name: Update latest-alpha-cli branch - permissions: - contents: write - needs: release - runs-on: ubuntu-latest - - steps: - - name: Update latest-alpha-cli branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - gh api \ - repos/${GITHUB_REPOSITORY}/git/refs/heads/latest-alpha-cli \ - -X PATCH \ - -f sha="${GITHUB_SHA}" \ - -F force=true diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml deleted file mode 100644 index bb191b88c..000000000 --- a/.github/workflows/rusty-v8-release.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: rusty-v8-release - -on: - workflow_dispatch: - inputs: - release_tag: - description: Optional release tag. Defaults to rusty-v8-v. - required: false - type: string - publish: - description: Publish the staged musl artifacts to a GitHub release. - required: false - default: true - type: boolean - -concurrency: - group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }} - cancel-in-progress: false - -jobs: - metadata: - runs-on: ubuntu-latest - outputs: - release_tag: ${{ steps.release_tag.outputs.release_tag }} - v8_version: ${{ steps.v8_version.outputs.version }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Resolve exact v8 crate version - id: v8_version - shell: bash - run: | - set -euo pipefail - version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)" - echo "version=${version}" >> "$GITHUB_OUTPUT" - - - name: Resolve release tag - id: release_tag - env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} - V8_VERSION: ${{ steps.v8_version.outputs.version }} - shell: bash - run: | - set -euo pipefail - - release_tag="${RELEASE_TAG_INPUT}" - if [[ -z "${release_tag}" ]]; then - release_tag="rusty-v8-v${V8_VERSION}" - fi - - echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - permissions: - contents: read - actions: read - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04-arm - platform: linux_arm64_musl - target: aarch64-unknown-linux-musl - - steps: - - uses: actions/checkout@v6 - - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Build Bazel V8 release pair - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=() - if [[ "${TARGET}" == *-unknown-linux-musl ]]; then - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) - fi - - bazel_args=( - build - -c - opt - "--platforms=@llvm//platforms:${PLATFORM}" - "${pair_target}" - "${extra_targets[@]}" - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - ) - - bazel \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - - - name: Stage release pair - env: - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --compilation-mode opt \ - --output-dir "dist/${TARGET}" - - - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 - with: - name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} - path: dist/${{ matrix.target }}/* - - publish-release: - if: ${{ inputs.publish }} - needs: - - metadata - - build - runs-on: ubuntu-latest - permissions: - contents: write - actions: read - - steps: - - name: Ensure publishing from default branch - if: ${{ github.ref_name != github.event.repository.default_branch }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2 - exit 1 - - - name: Ensure release tag is new - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ needs.metadata.outputs.release_tag }} - shell: bash - run: | - set -euo pipefail - - if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then - echo "Release tag ${RELEASE_TAG} already exists; musl artifact tags are immutable." >&2 - exit 1 - fi - - - uses: actions/download-artifact@v8 - with: - path: dist - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ needs.metadata.outputs.release_tag }} - name: ${{ needs.metadata.outputs.release_tag }} - files: dist/** - # Keep V8 artifact releases out of Codex's normal "latest release" channel. - prerelease: true diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml deleted file mode 100644 index c5026fe8c..000000000 --- a/.github/workflows/sdk.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: sdk - -on: - push: - branches: [main] - pull_request: {} - -jobs: - sdks: - runs-on: - group: codex-runners - labels: codex-linux-x64 - timeout-minutes: 10 - steps: - - name: Checkout repository - uses: actions/checkout@v6 - - - name: Install Linux bwrap build dependencies - shell: bash - run: | - set -euo pipefail - sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev - - - name: Setup pnpm - uses: pnpm/action-setup@v5 - with: - run_install: false - - - name: Setup Node.js - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - - uses: dtolnay/rust-toolchain@1.93.0 - - - name: build codex - run: cargo build --bin codex - working-directory: codex-rs - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Build SDK packages - run: pnpm -r --filter ./sdk/typescript run build - - - name: Lint SDK packages - run: pnpm -r --filter ./sdk/typescript run lint - - - name: Test SDK packages - run: pnpm -r --filter ./sdk/typescript run test diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml deleted file mode 100644 index 213c6a7b6..000000000 --- a/.github/workflows/v8-canary.yml +++ /dev/null @@ -1,132 +0,0 @@ -name: v8-canary - -on: - pull_request: - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" - push: - branches: - - main - paths: - - ".github/scripts/rusty_v8_bazel.py" - - ".github/workflows/rusty-v8-release.yml" - - ".github/workflows/v8-canary.yml" - - "MODULE.bazel" - - "MODULE.bazel.lock" - - "codex-rs/Cargo.toml" - - "patches/BUILD.bazel" - - "patches/v8_*.patch" - - "third_party/v8/**" - workflow_dispatch: - -concurrency: - group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} - cancel-in-progress: ${{ github.ref_name != 'main' }} - -jobs: - metadata: - runs-on: ubuntu-latest - outputs: - v8_version: ${{ steps.v8_version.outputs.version }} - - steps: - - uses: actions/checkout@v6 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Resolve exact v8 crate version - id: v8_version - shell: bash - run: | - set -euo pipefail - version="$(python3 .github/scripts/rusty_v8_bazel.py resolved-v8-crate-version)" - echo "version=${version}" >> "$GITHUB_OUTPUT" - - build: - name: Build ${{ matrix.target }} - needs: metadata - runs-on: ${{ matrix.runner }} - permissions: - contents: read - actions: read - strategy: - fail-fast: false - matrix: - include: - - runner: ubuntu-24.04 - platform: linux_amd64_musl - target: x86_64-unknown-linux-musl - - runner: ubuntu-24.04-arm - platform: linux_arm64_musl - target: aarch64-unknown-linux-musl - - steps: - - uses: actions/checkout@v6 - - - name: Set up Bazel - uses: bazelbuild/setup-bazelisk@v3 - - - name: Set up Python - uses: actions/setup-python@v6 - with: - python-version: "3.12" - - - name: Build Bazel V8 release pair - env: - BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - target_suffix="${TARGET//-/_}" - pair_target="//third_party/v8:rusty_v8_release_pair_${target_suffix}" - extra_targets=( - "@llvm//runtimes/libcxx:libcxx.static" - "@llvm//runtimes/libcxx:libcxxabi.static" - ) - - bazel_args=( - build - "--platforms=@llvm//platforms:${PLATFORM}" - "${pair_target}" - "${extra_targets[@]}" - --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) - ) - - bazel \ - --noexperimental_remote_repo_contents_cache \ - --bazelrc=.github/workflows/v8-ci.bazelrc \ - "${bazel_args[@]}" \ - "--remote_header=x-buildbuddy-api-key=${BUILDBUDDY_API_KEY}" - - - name: Stage release pair - env: - PLATFORM: ${{ matrix.platform }} - TARGET: ${{ matrix.target }} - shell: bash - run: | - set -euo pipefail - - python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ - --platform "${PLATFORM}" \ - --target "${TARGET}" \ - --output-dir "dist/${TARGET}" - - - name: Upload staged musl artifacts - uses: actions/upload-artifact@v7 - with: - name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} - path: dist/${{ matrix.target }}/* diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 3ca1e5e96..a78c03705 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -980,6 +980,7 @@ "libdbus-sys_0.2.7": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.0.78\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3\"}],\"features\":{\"default\":[\"pkg-config\"],\"vendored\":[\"cc\"]}}", "libloading_0.8.9": "{\"dependencies\":[{\"name\":\"cfg-if\",\"req\":\"^1\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"static_assertions\",\"req\":\"^1.1\"},{\"name\":\"windows-link\",\"req\":\"^0.2\",\"target\":\"cfg(windows)\"},{\"features\":[\"Win32_Foundation\"],\"kind\":\"dev\",\"name\":\"windows-sys\",\"req\":\"^0.61\",\"target\":\"cfg(windows)\"}],\"features\":{}}", "libm_0.2.16": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"no-panic\",\"req\":\"^0.1.35\"}],\"features\":{\"arch\":[],\"default\":[\"arch\"],\"force-soft-floats\":[],\"unstable\":[\"unstable-intrinsics\",\"unstable-float\"],\"unstable-float\":[],\"unstable-intrinsics\":[],\"unstable-public-internals\":[]}}", + "libmimalloc-sys_0.1.44": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0\"},{\"name\":\"cty\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"libc\",\"req\":\"^0.2\"}],\"features\":{\"arena\":[],\"debug\":[],\"debug_in_debug\":[],\"extended\":[\"cty\"],\"local_dynamic_tls\":[],\"no_thp\":[],\"override\":[],\"secure\":[],\"v3\":[]}}", "libredox_0.1.12": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"call\":[],\"default\":[\"call\",\"std\",\"redox_syscall\"],\"mkns\":[\"ioslice\"],\"std\":[]}}", "libsqlite3-sys_0.30.1": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.69\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.1.6\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.72\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"default\":[\"min_sqlite_version_3_14_0\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_14_0\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", @@ -1010,6 +1011,7 @@ "memchr_2.7.6": "{\"dependencies\":[{\"name\":\"core\",\"optional\":true,\"package\":\"rustc-std-workspace-core\",\"req\":\"^1.0.0\"},{\"name\":\"log\",\"optional\":true,\"req\":\"^0.4.20\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"libc\":[],\"logging\":[\"dep:log\"],\"rustc-dep-of-std\":[\"core\"],\"std\":[\"alloc\"],\"use_std\":[\"std\"]}}", "memoffset_0.6.5": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[]}}", "memoffset_0.9.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"autocfg\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"}],\"features\":{\"default\":[],\"unstable_const\":[],\"unstable_offset_of\":[]}}", + "mimalloc_0.1.48": "{\"dependencies\":[{\"default_features\":false,\"name\":\"libmimalloc-sys\",\"req\":\"^0.1.44\"}],\"features\":{\"debug\":[\"libmimalloc-sys/debug\"],\"debug_in_debug\":[\"libmimalloc-sys/debug_in_debug\"],\"default\":[],\"extended\":[\"libmimalloc-sys/extended\"],\"local_dynamic_tls\":[\"libmimalloc-sys/local_dynamic_tls\"],\"no_thp\":[\"libmimalloc-sys/no_thp\"],\"override\":[\"libmimalloc-sys/override\"],\"secure\":[\"libmimalloc-sys/secure\"],\"v3\":[\"libmimalloc-sys/v3\"]}}", "mime_0.3.17": "{\"dependencies\":[],\"features\":{}}", "mime_guess_2.0.5": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"name\":\"mime\",\"req\":\"^0.3\"},{\"name\":\"unicase\",\"req\":\"^2.4.0\"},{\"kind\":\"build\",\"name\":\"unicase\",\"req\":\"^2.4.0\"}],\"features\":{\"default\":[\"rev-mappings\"],\"rev-mappings\":[]}}", "minimal-lexical_0.2.1": "{\"dependencies\":[],\"features\":{\"alloc\":[],\"compact\":[],\"default\":[\"std\"],\"lint\":[],\"nightly\":[],\"std\":[]}}", diff --git a/NOTICE b/NOTICE index 2a64a45aa..8418de352 100644 --- a/NOTICE +++ b/NOTICE @@ -1,9 +1,19 @@ OpenAI Codex -Copyright 2025 OpenAI +Copyright 2025 OpenAI -This project includes code derived from [Ratatui](https://github.com/ratatui/ratatui), licensed under the MIT license. +This project includes code derived from Ratatui (https://github.com/ratatui/ratatui), +licensed under the MIT license. Copyright (c) 2016-2022 Florian Dehau Copyright (c) 2023-2025 The Ratatui Developers -This project includes Meriyah parser assets from [meriyah](https://github.com/meriyah/meriyah), licensed under the ISC license. +This project includes Windows PTY support code copied from WezTerm +(https://github.com/wezterm/wezterm), licensed under the MIT license. +Copyright (c) 2018-Present Wez Furlong + +This project includes Meriyah parser assets from meriyah +(https://github.com/meriyah/meriyah), licensed under the ISC license. Copyright (c) 2019 and later, KFlash and others. + +This project vendors bubblewrap source code under codex-rs/vendor/bubblewrap, +licensed under LGPL-2.0-or-later. See codex-rs/vendor/bubblewrap/COPYING for +the full license text and upstream copyright notices. diff --git a/README.md b/README.md index 1e44875f2..59c1da1d6 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,10 @@ You can also use Codex with an API key, but this requires [additional setup](htt - [**Codex Documentation**](https://developers.openai.com/codex) - [**Contributing**](./docs/contributing.md) - [**Installing & building**](./docs/install.md) +- [**Fork Intent**](./docs/fork-intent.md) +- [**Public Release Notes**](./docs/public-release-notes.md) - [**Open source fund**](./docs/open-source-fund.md) -This repository is licensed under the [Apache-2.0 License](LICENSE). +This repository is licensed under the [Apache-2.0 License](LICENSE). See +[NOTICE](NOTICE) for bundled third-party attributions and +[docs/license.md](docs/license.md) for repo-specific licensing notes. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 7a554c2bc..6e40a7cb7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1659,6 +1659,7 @@ dependencies = [ "codex-utils-cli", "codex-windows-sandbox", "libc", + "mimalloc", "owo-colors", "predicates", "pretty_assertions", @@ -5882,6 +5883,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667f4fec20f29dfc6bc7357c582d91796c169ad7e2fce709468aefeb2c099870" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libredox" version = "0.1.12" @@ -6163,6 +6174,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mimalloc" +version = "0.1.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1ee66a4b64c74f4ef288bcbb9192ad9c3feaad75193129ac8509af543894fd8" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "mime" version = "0.3.17" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 556976fc1..e8d36e961 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -244,6 +244,7 @@ log = "0.4" lru = "0.16.3" maplit = "1.0.2" mime_guess = "2.0.5" +mimalloc = "0.1" multimap = "0.10.0" notify = "8.2.0" nucleo = { git = "https://github.com/helix-editor/nucleo.git", rev = "4253de9faabb4e5c6d81d946a5e35a90f87347ee" } diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 9227a97d5..25a2837c9 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2697,6 +2697,45 @@ }, "type": "object" }, + "ThreadMemoryDropParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadMemoryRecallParams": { + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { @@ -3661,6 +3700,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 8ca93137c..b232f35bf 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1207,9 +1207,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, @@ -1620,6 +1627,73 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "source", + "status", + "summary", + "threadId" + ], + "type": "object" + }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MessagePhase": { "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", "oneOf": [ @@ -4158,6 +4232,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 6253c3929..88776d487 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -503,6 +503,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { @@ -3876,6 +3948,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { @@ -8222,9 +8314,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, @@ -9051,6 +9150,75 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/v2/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/v2/MemoryOperationSource" + }, + "status": { + "$ref": "#/definitions/v2/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "source", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" + }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", @@ -13112,6 +13280,66 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMemoryDropParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" + }, + "ThreadMemoryDropResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" + }, + "ThreadMemoryRecallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" + }, + "ThreadMemoryRecallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" + }, + "ThreadMemoryUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5d053604f..67506d83b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -1033,6 +1033,78 @@ "title": "Thread/compact/startRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/drop" + ], + "title": "Thread/memory/dropRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryDropParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/dropRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/update" + ], + "title": "Thread/memory/updateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryUpdateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/updateRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "thread/memory/recall" + ], + "title": "Thread/memory/recallRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadMemoryRecallParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Thread/memory/recallRequest", + "type": "object" + }, { "properties": { "id": { @@ -4893,9 +4965,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, @@ -5766,6 +5845,75 @@ ], "type": "object" }, + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "source", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" + }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + }, "MergeStrategy": { "enum": [ "replace", @@ -8472,6 +8620,26 @@ "title": "Item/completedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/memory/operation" + ], + "title": "Thread/memory/operationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/MemoryOperationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/memory/operationNotification", + "type": "object" + }, { "properties": { "method": { @@ -10859,6 +11027,66 @@ "title": "ThreadLoadedListResponse", "type": "object" }, + "ThreadMemoryDropParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" + }, + "ThreadMemoryDropResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" + }, + "ThreadMemoryRecallParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" + }, + "ThreadMemoryRecallResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" + }, + "ThreadMemoryUpdateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" + }, + "ThreadMemoryUpdateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" + }, "ThreadMetadataGitInfoUpdateParams": { "properties": { "branch": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index bce797086..2c9a51026 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -5,9 +5,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 72f32d0d9..ccf3fff13 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -5,9 +5,16 @@ "enum": [ "preToolUse", "postToolUse", + "postToolUseFailure", + "preCompact", "sessionStart", + "subagentStart", + "subagentStop", + "notification", + "taskCompleted", "userPromptSubmit", - "stop" + "stop", + "sessionEnd" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json new file mode 100644 index 000000000..fa9a8faa0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/MemoryOperationNotification.json @@ -0,0 +1,71 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "MemoryOperationKind": { + "enum": [ + "recall", + "update", + "drop" + ], + "type": "string" + }, + "MemoryOperationSource": { + "enum": [ + "human", + "assistant" + ], + "type": "string" + }, + "MemoryOperationStatus": { + "enum": [ + "pending", + "ready", + "empty", + "error" + ], + "type": "string" + } + }, + "properties": { + "contextInjected": { + "type": "boolean" + }, + "detail": { + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/definitions/MemoryOperationKind" + }, + "query": { + "type": [ + "string", + "null" + ] + }, + "source": { + "$ref": "#/definitions/MemoryOperationSource" + }, + "status": { + "$ref": "#/definitions/MemoryOperationStatus" + }, + "summary": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "contextInjected", + "operation", + "source", + "status", + "summary", + "threadId" + ], + "title": "MemoryOperationNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json new file mode 100644 index 000000000..81397e7be --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryDropParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json new file mode 100644 index 000000000..c2153610d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryDropResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryDropResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json new file mode 100644 index 000000000..564d2ce1f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallParams.json @@ -0,0 +1,19 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "query": { + "type": [ + "string", + "null" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryRecallParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json new file mode 100644 index 000000000..aca6179da --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryRecallResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryRecallResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json new file mode 100644 index 000000000..a5b97be12 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateParams.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadMemoryUpdateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json new file mode 100644 index 000000000..3bbb1036e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMemoryUpdateResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadMemoryUpdateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e33a98635..f13173319 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -47,6 +47,9 @@ import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams"; import type { ThreadForkParams } from "./v2/ThreadForkParams"; import type { ThreadListParams } from "./v2/ThreadListParams"; import type { ThreadLoadedListParams } from "./v2/ThreadLoadedListParams"; +import type { ThreadMemoryDropParams } from "./v2/ThreadMemoryDropParams"; +import type { ThreadMemoryRecallParams } from "./v2/ThreadMemoryRecallParams"; +import type { ThreadMemoryUpdateParams } from "./v2/ThreadMemoryUpdateParams"; import type { ThreadMetadataUpdateParams } from "./v2/ThreadMetadataUpdateParams"; import type { ThreadReadParams } from "./v2/ThreadReadParams"; import type { ThreadResumeParams } from "./v2/ThreadResumeParams"; @@ -64,4 +67,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/memory/drop", id: RequestId, params: ThreadMemoryDropParams, } | { "method": "thread/memory/update", id: RequestId, params: ThreadMemoryUpdateParams, } | { "method": "thread/memory/recall", id: RequestId, params: ThreadMemoryRecallParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 85ebe847f..70db58062 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -25,6 +25,7 @@ import type { ItemStartedNotification } from "./v2/ItemStartedNotification"; import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOauthLoginCompletedNotification"; import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; +import type { MemoryOperationNotification } from "./v2/MemoryOperationNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; @@ -57,4 +58,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "thread/memory/operation", "params": MemoryOperationNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index b97c709b9..1e27ff589 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "postToolUse" | "postToolUseFailure" | "preCompact" | "sessionStart" | "subagentStart" | "subagentStop" | "notification" | "taskCompleted" | "userPromptSubmit" | "stop" | "sessionEnd"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts new file mode 100644 index 000000000..a093a7f67 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationKind = "recall" | "update" | "drop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts new file mode 100644 index 000000000..860711b13 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationNotification.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MemoryOperationKind } from "./MemoryOperationKind"; +import type { MemoryOperationSource } from "./MemoryOperationSource"; +import type { MemoryOperationStatus } from "./MemoryOperationStatus"; + +export type MemoryOperationNotification = { threadId: string, source: MemoryOperationSource, operation: MemoryOperationKind, status: MemoryOperationStatus, query: string | null, summary: string, detail: string | null, contextInjected: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts new file mode 100644 index 000000000..59a91c34c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationSource = "human" | "assistant"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts new file mode 100644 index 000000000..b611b9a4b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MemoryOperationStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type MemoryOperationStatus = "pending" | "ready" | "empty" | "error"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts new file mode 100644 index 000000000..a05738c9f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryDropParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts new file mode 100644 index 000000000..1b9ef88bb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryDropResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryDropResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts new file mode 100644 index 000000000..176406cb6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryRecallParams = { threadId: string, query?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts new file mode 100644 index 000000000..c06b2c108 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryRecallResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryRecallResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts new file mode 100644 index 000000000..129a95bc9 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryUpdateParams = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts new file mode 100644 index 000000000..3dddb3fb4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadMemoryUpdateResponse.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadMemoryUpdateResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index d0b1b8819..68f98e89b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -188,6 +188,10 @@ export type { McpToolCallResult } from "./McpToolCallResult"; export type { McpToolCallStatus } from "./McpToolCallStatus"; export type { MemoryCitation } from "./MemoryCitation"; export type { MemoryCitationEntry } from "./MemoryCitationEntry"; +export type { MemoryOperationKind } from "./MemoryOperationKind"; +export type { MemoryOperationNotification } from "./MemoryOperationNotification"; +export type { MemoryOperationSource } from "./MemoryOperationSource"; +export type { MemoryOperationStatus } from "./MemoryOperationStatus"; export type { MergeStrategy } from "./MergeStrategy"; export type { Model } from "./Model"; export type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; @@ -278,6 +282,12 @@ export type { ThreadListParams } from "./ThreadListParams"; export type { ThreadListResponse } from "./ThreadListResponse"; export type { ThreadLoadedListParams } from "./ThreadLoadedListParams"; export type { ThreadLoadedListResponse } from "./ThreadLoadedListResponse"; +export type { ThreadMemoryDropParams } from "./ThreadMemoryDropParams"; +export type { ThreadMemoryDropResponse } from "./ThreadMemoryDropResponse"; +export type { ThreadMemoryRecallParams } from "./ThreadMemoryRecallParams"; +export type { ThreadMemoryRecallResponse } from "./ThreadMemoryRecallResponse"; +export type { ThreadMemoryUpdateParams } from "./ThreadMemoryUpdateParams"; +export type { ThreadMemoryUpdateResponse } from "./ThreadMemoryUpdateResponse"; export type { ThreadMetadataGitInfoUpdateParams } from "./ThreadMetadataGitInfoUpdateParams"; export type { ThreadMetadataUpdateParams } from "./ThreadMetadataUpdateParams"; export type { ThreadMetadataUpdateResponse } from "./ThreadMetadataUpdateResponse"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 5a9215f2e..fb19fcd04 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -257,6 +257,18 @@ client_request_definitions! { params: v2::ThreadCompactStartParams, response: v2::ThreadCompactStartResponse, }, + ThreadMemoryDrop => "thread/memory/drop" { + params: v2::ThreadMemoryDropParams, + response: v2::ThreadMemoryDropResponse, + }, + ThreadMemoryUpdate => "thread/memory/update" { + params: v2::ThreadMemoryUpdateParams, + response: v2::ThreadMemoryUpdateResponse, + }, + ThreadMemoryRecall => "thread/memory/recall" { + params: v2::ThreadMemoryRecallParams, + response: v2::ThreadMemoryRecallResponse, + }, ThreadShellCommand => "thread/shellCommand" { params: v2::ThreadShellCommandParams, response: v2::ThreadShellCommandResponse, @@ -894,6 +906,7 @@ server_notification_definitions! { ItemGuardianApprovalReviewStarted => "item/autoApprovalReview/started" (v2::ItemGuardianApprovalReviewStartedNotification), ItemGuardianApprovalReviewCompleted => "item/autoApprovalReview/completed" (v2::ItemGuardianApprovalReviewCompletedNotification), ItemCompleted => "item/completed" (v2::ItemCompletedNotification), + MemoryOperation => "thread/memory/operation" (v2::MemoryOperationNotification), /// This event is internal-only. Used by Codex Cloud. RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification), AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), @@ -1553,6 +1566,71 @@ mod tests { Ok(()) } + #[test] + fn serialize_thread_memory_drop() -> Result<()> { + let request = ClientRequest::ThreadMemoryDrop { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryDropParams { + thread_id: "thr_123".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/drop", + "id": 8, + "params": { + "threadId": "thr_123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_memory_update() -> Result<()> { + let request = ClientRequest::ThreadMemoryUpdate { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryUpdateParams { + thread_id: "thr_123".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/update", + "id": 8, + "params": { + "threadId": "thr_123" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_thread_memory_recall() -> Result<()> { + let request = ClientRequest::ThreadMemoryRecall { + request_id: RequestId::Integer(8), + params: v2::ThreadMemoryRecallParams { + thread_id: "thr_123".to_string(), + query: Some("search term".to_string()), + }, + }; + assert_eq!( + json!({ + "method": "thread/memory/recall", + "id": 8, + "params": { + "threadId": "thr_123", + "query": "search term" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_start() -> Result<()> { let request = ClientRequest::ThreadRealtimeStart { diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 48fa56d68..4173061a1 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -187,6 +187,7 @@ impl ThreadHistoryBuilder { EventMsg::ItemStarted(payload) => self.handle_item_started(payload), EventMsg::ItemCompleted(payload) => self.handle_item_completed(payload), EventMsg::HookStarted(_) | EventMsg::HookCompleted(_) => {} + EventMsg::MemoryOperation(_) => {} EventMsg::Error(payload) => self.handle_error(payload), EventMsg::TokenCount(_) => {} EventMsg::ThreadRolledBack(payload) => self.handle_thread_rollback(payload), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 147c91db2..a5cb4c571 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -26,6 +26,8 @@ use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::MemoryOperationKind as CoreMemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus as CoreMemoryOperationStatus; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::mcp::Resource as McpResource; use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; @@ -64,6 +66,7 @@ use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::MemoryOperationSource as CoreMemoryOperationSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; @@ -377,7 +380,7 @@ v2_enum_from_core!( v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { - PreToolUse, PostToolUse, SessionStart, UserPromptSubmit, Stop + PreToolUse, PostToolUse, PostToolUseFailure, PreCompact, SessionStart, SubagentStart, SubagentStop, Notification, TaskCompleted, UserPromptSubmit, Stop, SessionEnd } ); @@ -2973,6 +2976,101 @@ pub struct ThreadCompactStartParams { #[ts(export_to = "v2/")] pub struct ThreadCompactStartResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryDropParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryDropResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryUpdateParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryUpdateResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryRecallParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub query: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryRecallResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +impl From for MemoryOperationKind { + fn from(value: CoreMemoryOperationKind) -> Self { + match value { + CoreMemoryOperationKind::Recall => Self::Recall, + CoreMemoryOperationKind::Update => Self::Update, + CoreMemoryOperationKind::Drop => Self::Drop, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationStatus { + Pending, + Ready, + Empty, + Error, +} + +impl From for MemoryOperationStatus { + fn from(value: CoreMemoryOperationStatus) -> Self { + match value { + CoreMemoryOperationStatus::Pending => Self::Pending, + CoreMemoryOperationStatus::Ready => Self::Ready, + CoreMemoryOperationStatus::Empty => Self::Empty, + CoreMemoryOperationStatus::Error => Self::Error, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum MemoryOperationSource { + Human, + Assistant, +} + +impl From for MemoryOperationSource { + fn from(value: CoreMemoryOperationSource) -> Self { + match value { + CoreMemoryOperationSource::Human => Self::Human, + CoreMemoryOperationSource::Assistant => Self::Assistant, + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4980,6 +5078,20 @@ pub struct ItemCompletedNotification { pub turn_id: String, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryOperationNotification { + pub thread_id: String, + pub source: MemoryOperationSource, + pub operation: MemoryOperationKind, + pub status: MemoryOperationStatus, + pub query: Option, + pub summary: String, + pub detail: Option, + pub context_injected: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1ca468827..0400b469b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -79,7 +79,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. - Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. -- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). +- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, memory-operation notifications, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. ## Initialization @@ -145,6 +145,9 @@ Example with notification opt-out: - `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread. - `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`. - `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications. +- `thread/memory/drop` — clear the active memory store using the thread's configured memory backend; returns `{}` when the request is accepted. +- `thread/memory/update` — trigger a memory sync/consolidation pass using the thread's configured memory backend; returns `{}` when the request is accepted. +- `thread/memory/recall` — retrieve memory context for a thread and inject it into that thread as developer instructions; accepts optional `query` and returns `{}` when the recall request is accepted. - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. @@ -454,6 +457,30 @@ If the thread does not already have an active turn, the server starts a standalo { "id": 26, "result": {} } ``` +### Example: Manage thread memory + +Use the thread-scoped memory methods to mirror the legacy TUI slash commands: + +- `thread/memory/drop` clears the active memory store for the configured backend. +- `thread/memory/update` triggers a backend-specific sync/consolidation pass. +- `thread/memory/recall` retrieves memory context and injects it into the thread as developer instructions. + +All three requests return immediately with `{}`. Result details surface through the thread event stream as `thread/memory/operation` notifications carrying the structured source, operation, status, optional query, summary, optional detail, and whether recalled context was injected into the thread. + +```json +{ "method": "thread/memory/drop", "id": 27, "params": { "threadId": "thr_b" } } +{ "id": 27, "result": {} } + +{ "method": "thread/memory/update", "id": 28, "params": { "threadId": "thr_b" } } +{ "id": 28, "result": {} } + +{ "method": "thread/memory/recall", "id": 29, "params": { + "threadId": "thr_b", + "query": "recent auth failures" +} } +{ "id": 29, "result": {} } +``` + ### Example: Start a turn (send user input) Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: @@ -915,6 +942,7 @@ All items emit shared lifecycle events: - `item/completed` — sends the final `item` once that work itself finishes (for example, after a tool call or message completes); treat this as the authoritative execution/result state. - `item/autoApprovalReview/started` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review begins. This shape is expected to change soon. - `item/autoApprovalReview/completed` — [UNSTABLE] temporary guardian notification carrying `{threadId, turnId, targetItemId, review, action?}` when guardian approval review resolves. This shape is expected to change soon. +- `thread/memory/operation` — sends structured outcomes for `thread/memory/drop`, `thread/memory/update`, and `thread/memory/recall` with `{threadId, source, operation, status, query?, summary, detail?, contextInjected}`. `review` is [UNSTABLE] and currently has `{status, riskScore?, riskLevel?, rationale?}`, where `status` is one of `inProgress`, `approved`, `denied`, or `aborted`. `action` is the guardian action summary payload from core when available and is intended to support temporary standalone pending-review UI. These notifications are separate from the target item's own `item/completed` lifecycle and are intentionally temporary while the guardian app protocol is still being designed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 15484d3d5..246aaad33 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -62,6 +62,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::MemoryOperationNotification; use codex_app_server_protocol::ModelReroutedNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; @@ -342,6 +343,23 @@ pub(crate) async fn apply_bespoke_event_handling( } } EventMsg::Warning(_warning_event) => {} + EventMsg::MemoryOperation(event) => { + if let ApiVersion::V2 = api_version { + let notification = MemoryOperationNotification { + thread_id: conversation_id.to_string(), + source: event.source.into(), + operation: event.operation.into(), + status: event.status.into(), + query: event.query, + summary: event.summary, + detail: event.detail, + context_injected: event.context_injected, + }; + outgoing + .send_server_notification(ServerNotification::MemoryOperation(notification)) + .await; + } + } EventMsg::GuardianAssessment(assessment) => { if let ApiVersion::V2 = api_version { let notification = guardian_auto_approval_review_notification( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 620a85a09..323f60fe6 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -128,6 +128,12 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateResponse; @@ -711,6 +717,18 @@ impl CodexMessageProcessor { self.thread_compact_start(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadMemoryDrop { request_id, params } => { + self.thread_memory_drop(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadMemoryUpdate { request_id, params } => { + self.thread_memory_update(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadMemoryRecall { request_id, params } => { + self.thread_memory_recall(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadBackgroundTerminalsClean { request_id, params } => { self.thread_background_terminals_clean( to_connection_request_id(request_id), @@ -3053,6 +3071,99 @@ impl CodexMessageProcessor { } } + async fn thread_memory_drop( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryDropParams, + ) { + let ThreadMemoryDropParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::DropMemories) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryDropResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to drop memories: {err}")) + .await; + } + } + } + + async fn thread_memory_update( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryUpdateParams, + ) { + let ThreadMemoryUpdateParams { thread_id } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::UpdateMemories) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryUpdateResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to update memories: {err}")) + .await; + } + } + } + + async fn thread_memory_recall( + &self, + request_id: ConnectionRequestId, + params: ThreadMemoryRecallParams, + ) { + let ThreadMemoryRecallParams { thread_id, query } = params; + + let (_, thread) = match self.load_thread(&thread_id).await { + Ok(v) => v, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .submit_core_op(&request_id, thread.as_ref(), Op::RecallMemories { query }) + .await + { + Ok(_) => { + self.outgoing + .send_response(request_id, ThreadMemoryRecallResponse {}) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to recall memories: {err}")) + .await; + } + } + } + async fn thread_shell_command( &self, request_id: ConnectionRequestId, diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 7e703efe1..cc2837739 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -12,6 +12,10 @@ path = "src/main.rs" name = "codex_cli" path = "src/lib.rs" +[features] +default = [] +mimalloc = ["dep:mimalloc"] + [lints] workspace = true @@ -43,6 +47,7 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-tui-app-server = { workspace = true } libc = { workspace = true } +mimalloc = { workspace = true, optional = true } owo-colors = { workspace = true } regex-lite = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 12a531d35..e3d625b46 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -42,6 +42,10 @@ mod mcp_cmd; #[cfg(not(windows))] mod wsl_paths; +#[cfg(feature = "mimalloc")] +#[global_allocator] +static GLOBAL_ALLOCATOR: mimalloc::MiMalloc = mimalloc::MiMalloc; + use crate::mcp_cmd::McpCli; use codex_core::config::Config; @@ -1095,6 +1099,13 @@ async fn run_debug_clear_memories_command( let config = Config::load_with_cli_overrides_and_harness_overrides(cli_kv_overrides, overrides).await?; + if config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory { + let adapter = codex_core::agentmemory::AgentmemoryAdapter::new(); + adapter.drop_memories().await.map_err(anyhow::Error::msg)?; + println!("Cleared Agentmemory store."); + return Ok(()); + } + let state_path = state_db_path(config.sqlite_home.as_path()); let mut cleared_state_db = false; if tokio::fs::try_exists(&state_path).await? { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 300716dc1..858607da0 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -761,6 +761,14 @@ "additionalProperties": false, "description": "Memories settings loaded from config.toml.", "properties": { + "backend": { + "allOf": [ + { + "$ref": "#/definitions/MemoryBackend" + } + ], + "description": "The backend to use for memories." + }, "consolidation_model": { "description": "Model used for memory consolidation.", "type": "string" @@ -811,6 +819,13 @@ }, "type": "object" }, + "MemoryBackend": { + "enum": [ + "native", + "agentmemory" + ], + "type": "string" + }, "ModelAvailabilityNuxConfig": { "additionalProperties": { "format": "uint32", diff --git a/codex-rs/core/src/agentmemory/mod.rs b/codex-rs/core/src/agentmemory/mod.rs new file mode 100644 index 000000000..d8b9d8a3a --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs @@ -0,0 +1,874 @@ +//! Agentmemory integration adapter. +//! +//! This module provides the seam for integrating the `agentmemory` service +//! as a replacement for Codex's native memory engine. + +use serde::Serialize; +use serde_json::json; +use std::path::Path; +use std::sync::OnceLock; + +/// A placeholder adapter struct for agentmemory integration. +#[derive(Debug, Default, Clone)] +pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. +} + +/// A shared, pooled HTTP client for agentmemory interactions. +/// Reusing the client allows connection pooling (keep-alive) for high throughput. +static CLIENT: OnceLock = OnceLock::new(); + +pub(crate) const DEFAULT_RUNTIME_RECALL_TOKEN_BUDGET: usize = 2_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub(crate) struct MemoryRecallResult { + pub(crate) recalled: bool, + pub(crate) context: String, +} + +fn get_client() -> &'static reqwest::Client { + CLIENT.get_or_init(|| reqwest::Client::builder().build().unwrap_or_default()) +} + +impl AgentmemoryAdapter { + pub fn new() -> Self { + Self::default() + } + + fn api_base(&self) -> String { + if let Some(url) = std::env::var("AGENTMEMORY_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + { + return url; + } + + std::env::var("III_REST_PORT") + .map(|port| format!("http://127.0.0.1:{port}")) + .unwrap_or_else(|_| "http://127.0.0.1:3111".to_string()) + } + + /// Builds the developer instructions for startup memory injection + /// using the `agentmemory` retrieval stack. + /// + /// This retrieves context bounded by a token budget and explicitly + /// uses hybrid search semantics rather than loading large static artifacts. + pub async fn build_startup_developer_instructions( + &self, + codex_home: &Path, + token_budget: usize, + ) -> Option { + let client = get_client(); + let url = format!("{}/agentmemory/context", self.api_base()); + let project = std::env::current_dir() + .unwrap_or_else(|_| codex_home.to_path_buf()) + .to_string_lossy() + .into_owned(); + + let request_body = json!({ + "sessionId": "startup", // We don't have a session ID at this exact moment easily accessible, but "startup" excludes it safely. + "project": project, + "budget": token_budget + }); + + let context_result = client.post(&url).json(&request_body).send().await; + + let mut instructions = + "Use the `memory_recall` tool when the user asks about prior work, earlier decisions, previous failures, resumed threads, or other historical context that is not fully present in the current thread.\n\ + Agentmemory startup context may be attached below when available.\n\ + Prefer targeted recall queries naming the feature, file, bug, or decision you need.\n\ + Do not call `memory_recall` on every turn; first use the current thread context, then recall memory when that context appears insufficient." + .to_string(); + + if let Ok(res) = context_result + && let Ok(json_res) = res.json::().await + && let Some(context_str) = json_res.get("context").and_then(|v| v.as_str()) + && !context_str.is_empty() + { + instructions.push_str("\n\n"); + instructions.push_str(context_str); + } + + Some(instructions) + } + + /// Attempts to parse a tool command string as JSON to recover structured + /// arguments. Falls back to the original string value on parse failure. + fn parse_structured_tool_input(raw: &serde_json::Value) -> serde_json::Value { + if let Some(s) = raw.as_str() + && let Ok(parsed) = serde_json::from_str::(s) + && parsed.is_object() + { + return parsed; + } + raw.clone() + } + + /// Extracts file paths and search terms from structured tool arguments + /// so that Agentmemory observations mention the relevant paths and queries. + fn extract_file_enrichment(tool_input: &serde_json::Value) -> (Vec, Vec) { + let mut files: Vec = Vec::new(); + let mut search_terms: Vec = Vec::new(); + + if let Some(obj) = tool_input.as_object() { + // File path fields + for key in &["file_path", "path", "dir_path"] { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) + && !v.is_empty() + { + files.push(v.to_string()); + } + } + // Array of paths + if let Some(arr) = obj.get("paths").and_then(|v| v.as_array()) { + for item in arr { + if let Some(s) = item.as_str() + && !s.is_empty() + { + files.push(s.to_string()); + } + } + } + // Search / pattern fields + for key in &["query", "pattern", "glob"] { + if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) + && !v.is_empty() + { + search_terms.push(v.to_string()); + } + } + } + + (files, search_terms) + } + + /// Maximum length for assistant text stored in observations. + const ASSISTANT_TEXT_MAX_BYTES: usize = 4096; + + /// Truncates text to a safe size for observation storage. + fn truncate_text(text: &str, max_bytes: usize) -> &str { + if text.len() <= max_bytes { + return text; + } + // Find a char boundary at or before max_bytes + let mut end = max_bytes; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] + } + + /// Transforms Codex hook payloads into the canonical Agentmemory hook schema. + fn format_agentmemory_payload( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> serde_json::Value { + let payload_map = payload.as_object().cloned().unwrap_or_default(); + let session_id = payload_map + .get("session_id") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + let cwd = payload_map + .get("cwd") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| ".".to_string()); + let timestamp = chrono::Utc::now().to_rfc3339(); + + // Parse structured tool input from the command string when possible. + let raw_tool_input = payload_map + .get("command") + .cloned() + .unwrap_or(serde_json::Value::Null); + let tool_input = Self::parse_structured_tool_input(&raw_tool_input); + + let tool_output = payload_map + .get("tool_response") + .cloned() + .unwrap_or(serde_json::Value::Null); + let error = payload_map + .get("tool_response") + .and_then(|value| value.get("error")) + .cloned() + .unwrap_or_else(|| tool_output.clone()); + + // Extract file paths and search terms for enrichment. + let (files, search_terms) = Self::extract_file_enrichment(&tool_input); + + let (hook_type, data) = match event_name { + "SessionStart" => ( + "session_start", + json!({ + "session_id": session_id, + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "transcript_path": payload_map.get("transcript_path").cloned().unwrap_or(serde_json::Value::Null), + "source": payload_map.get("source").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + "UserPromptSubmit" => ( + "prompt_submit", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "prompt": payload_map.get("prompt").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + "PreToolUse" => { + let mut data = json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + }); + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("pre_tool_use", data) + } + "PostToolUse" => { + let mut data = json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + "tool_output": tool_output, + }); + // File-aware enrichment: surface paths and search terms. + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("post_tool_use", data) + } + "PostToolUseFailure" => { + let mut data = json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "tool_name": payload_map.get("tool_name").cloned().unwrap_or(serde_json::Value::Null), + "tool_use_id": payload_map.get("tool_use_id").cloned().unwrap_or(serde_json::Value::Null), + "tool_input": tool_input, + "error": error, + }); + if !files.is_empty() { + data["files"] = json!(files); + } + if !search_terms.is_empty() { + data["search_terms"] = json!(search_terms); + } + ("post_tool_failure", data) + } + "Stop" => ( + "stop", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "permission_mode": payload_map.get("permission_mode").cloned().unwrap_or(serde_json::Value::Null), + "last_assistant_message": payload_map.get("last_assistant_message").cloned().unwrap_or(serde_json::Value::Null), + }), + ), + "AssistantResult" => { + let assistant_text = payload_map + .get("assistant_text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let truncated = Self::truncate_text(assistant_text, Self::ASSISTANT_TEXT_MAX_BYTES); + ( + "assistant_result", + json!({ + "session_id": session_id, + "turn_id": payload_map.get("turn_id").cloned().unwrap_or(serde_json::Value::Null), + "cwd": cwd, + "model": payload_map.get("model").cloned().unwrap_or(serde_json::Value::Null), + "assistant_text": truncated, + "is_final": payload_map.get("is_final").cloned().unwrap_or(json!(true)), + }), + ) + } + _ => (event_name, serde_json::Value::Object(payload_map.clone())), + }; + + json!({ + "sessionId": session_id, + "hookType": hook_type, + "project": cwd, + "cwd": payload_map.get("cwd").cloned().unwrap_or_else(|| serde_json::Value::String(".".to_string())), + "timestamp": timestamp, + "data": data, + }) + } + + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); + let client = get_client(); + + let body = self.format_agentmemory_payload(event_name, payload_json); + + if let Err(e) = client.post(&url).json(&body).send().await { + // Log a warning instead of failing silently. This won't crash the session, + // but will alert developers that memory observation is degraded. + tracing::warn!( + "Agentmemory observation failed: could not send {} event to {}: {}", + event_name, + url, + e + ); + } + } + + /// Retrieves memory context mid-session via `agentmemory`'s hybrid search. + /// + /// Unlike `build_startup_developer_instructions`, this uses the real + /// session ID and an optional query to scope retrieval. + pub async fn recall_context( + &self, + session_id: &str, + project: &Path, + query: Option<&str>, + token_budget: usize, + ) -> Result { + let client = get_client(); + let url = format!("{}/agentmemory/context", self.api_base()); + + let mut body = json!({ + "sessionId": session_id, + "project": project.to_string_lossy(), + "budget": token_budget, + }); + if let Some(q) = query { + body["query"] = serde_json::Value::String(q.to_string()); + } + + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + + if !res.status().is_success() { + return Err(format!( + "Context retrieval failed with status {}", + res.status() + )); + } + + let json_res: serde_json::Value = res.json().await.map_err(|e| e.to_string())?; + Ok(json_res + .get("context") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string()) + } + + pub(crate) async fn recall_for_runtime( + &self, + session_id: &str, + project: &Path, + query: Option<&str>, + ) -> Result { + let context = self + .recall_context( + session_id, + project, + query, + DEFAULT_RUNTIME_RECALL_TOKEN_BUDGET, + ) + .await?; + + Ok(MemoryRecallResult { + recalled: !context.trim().is_empty(), + context, + }) + } + + /// Registers a session so Agentmemory's session-backed views can discover it. + pub async fn start_session( + &self, + session_id: &str, + project: &Path, + cwd: &Path, + ) -> Result<(), String> { + let url = format!("{}/agentmemory/session/start", self.api_base()); + let client = get_client(); + let body = json!({ + "sessionId": session_id, + "project": project.display().to_string(), + "cwd": cwd.display().to_string(), + }); + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Session start failed with status {}", res.status())); + } + Ok(()) + } + + /// Marks a session completed so Agentmemory's viewer can stop showing it as active. + pub async fn end_session(&self, session_id: &str) -> Result<(), String> { + let url = format!("{}/agentmemory/session/end", self.api_base()); + let client = get_client(); + let body = json!({ "sessionId": session_id }); + let res = client + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Session end failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/consolidate", self.api_base()); + let client = get_client(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/forget", self.api_base()); + let client = get_client(); + let res = client + .post(&url) + .json(&json!({"all": true})) + .send() + .await + .map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); + } + Ok(()) + } +} +#[cfg(test)] +#[allow(clippy::await_holding_lock)] +mod tests { + use super::*; + use serde_json::json; + use std::ffi::OsString; + use std::sync::Mutex; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::body_json; + use wiremock::matchers::method; + use wiremock::matchers::path; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } + + fn unset(key: &'static str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::remove_var(key); + } + Self { key, original } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_startup_instructions_describe_current_runtime_surface() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _guard = ENV_LOCK.lock().expect("lock env"); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/context")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "context": "" + }))) + .expect(1) + .mount(&server) + .await; + + let instructions = adapter + .build_startup_developer_instructions(Path::new("/tmp/project"), 256) + .await + .expect("instructions should be returned"); + + assert!(instructions.contains("Use the `memory_recall` tool")); + assert!(instructions.contains("prior work, earlier decisions, previous failures")); + assert!(instructions.contains("Agentmemory startup context may be attached below")); + assert!(instructions.contains( + "Prefer targeted recall queries naming the feature, file, bug, or decision you need" + )); + assert!(instructions.contains("Do not call `memory_recall` on every turn")); + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_startup_instructions_append_retrieved_context() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _guard = ENV_LOCK.lock().expect("lock env"); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/context")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "context": "remember this" + }))) + .expect(1) + .mount(&server) + .await; + + let instructions = adapter + .build_startup_developer_instructions(Path::new("/tmp/project"), 256) + .await + .expect("instructions should be returned"); + + assert!(instructions.contains("remember this")); + } + + #[test] + fn test_format_agentmemory_payload_maps_prompt_submit_shape() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "cwd": "/tmp/project", + "prompt": "ship it" + }); + + let formatted = adapter.format_agentmemory_payload("UserPromptSubmit", raw_payload); + + assert_eq!(formatted["sessionId"], "1234"); + assert_eq!(formatted["hookType"], "prompt_submit"); + assert_eq!(formatted["project"], "/tmp/project"); + assert_eq!(formatted["cwd"], "/tmp/project"); + assert!(formatted.get("timestamp").is_some()); + assert_eq!(formatted["data"]["prompt"], "ship it"); + assert_eq!(formatted["data"]["turn_id"], "turn-5"); + } + + #[test] + fn test_format_agentmemory_payload_maps_post_tool_use_shape() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "cwd": "/tmp/project", + "tool_name": "shell_command", + "tool_use_id": "tool-1", + "command": "printf hi", + "tool_response": { "output": "hi" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + assert_eq!(formatted["sessionId"], "1234"); + assert_eq!(formatted["hookType"], "post_tool_use"); + assert_eq!(formatted["data"]["tool_name"], "shell_command"); + assert_eq!(formatted["data"]["tool_input"], "printf hi"); + assert_eq!(formatted["data"]["tool_output"]["output"], "hi"); + } + + #[test] + fn test_pre_tool_use_includes_structured_args_and_enrichment() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "grep", + "tool_use_id": "tu-0", + "command": r#"{"path":"/proj/src","pattern":"fn main","glob":"*.rs"}"#, + }); + + let formatted = adapter.format_agentmemory_payload("PreToolUse", raw_payload); + + assert_eq!(formatted["hookType"], "pre_tool_use"); + assert_eq!(formatted["data"]["tool_input"]["path"], "/proj/src"); + assert_eq!(formatted["data"]["tool_input"]["pattern"], "fn main"); + assert_eq!(formatted["data"]["files"][0], "/proj/src"); + assert_eq!(formatted["data"]["search_terms"][0], "fn main"); + assert_eq!(formatted["data"]["search_terms"][1], "*.rs"); + } + + #[test] + fn test_structured_tool_input_parsed_from_json_command() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "read_file", + "tool_use_id": "tu-1", + "command": r#"{"file_path":"/proj/src/main.rs","offset":1,"limit":50}"#, + "tool_response": { "text": "fn main() {}" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + // tool_input should be the parsed object, not the raw string. + assert_eq!( + formatted["data"]["tool_input"]["file_path"], + "/proj/src/main.rs" + ); + assert_eq!(formatted["data"]["tool_input"]["offset"], 1); + // File enrichment should surface the path. + assert_eq!(formatted["data"]["files"][0], "/proj/src/main.rs"); + } + + #[test] + fn test_non_json_command_preserved_as_string() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "shell", + "tool_use_id": "tu-2", + "command": "ls -la /tmp", + "tool_response": { "output": "total 0" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + assert_eq!(formatted["data"]["tool_input"], "ls -la /tmp"); + // No file enrichment for plain commands. + assert!(formatted["data"].get("files").is_none()); + } + + #[test] + fn test_file_enrichment_extracts_paths_and_search_terms() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "grep", + "tool_use_id": "tu-3", + "command": r#"{"path":"/proj/src","pattern":"TODO","glob":"*.rs"}"#, + "tool_response": { "matches": [] } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + assert_eq!(formatted["data"]["files"][0], "/proj/src"); + assert_eq!(formatted["data"]["search_terms"][0], "TODO"); + assert_eq!(formatted["data"]["search_terms"][1], "*.rs"); + } + + #[test] + fn test_file_enrichment_on_failure_event() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "read_file", + "tool_use_id": "tu-4", + "command": r#"{"file_path":"/proj/missing.rs"}"#, + "tool_response": { "error": "file not found" } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUseFailure", raw_payload); + + assert_eq!(formatted["hookType"], "post_tool_failure"); + assert_eq!(formatted["data"]["files"][0], "/proj/missing.rs"); + assert_eq!(formatted["data"]["error"], "file not found"); + } + + #[test] + fn test_assistant_result_payload_shape() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "model": "claude-opus-4-6", + "assistant_text": "The build succeeded with no warnings.", + "is_final": true, + }); + + let formatted = adapter.format_agentmemory_payload("AssistantResult", raw_payload); + + assert_eq!(formatted["hookType"], "assistant_result"); + assert_eq!(formatted["sessionId"], "s1"); + assert_eq!( + formatted["data"]["assistant_text"], + "The build succeeded with no warnings." + ); + assert_eq!(formatted["data"]["is_final"], true); + assert_eq!(formatted["data"]["turn_id"], "t1"); + assert_eq!(formatted["data"]["model"], "claude-opus-4-6"); + } + + #[test] + fn test_assistant_result_truncates_long_text() { + let adapter = AgentmemoryAdapter::new(); + let long_text = "x".repeat(8000); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "model": "test", + "assistant_text": long_text, + }); + + let formatted = adapter.format_agentmemory_payload("AssistantResult", raw_payload); + let stored_text = formatted["data"]["assistant_text"].as_str().unwrap(); + assert!(stored_text.len() <= AgentmemoryAdapter::ASSISTANT_TEXT_MAX_BYTES); + } + + #[test] + fn test_paths_array_enrichment() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "s1", + "turn_id": "t1", + "cwd": "/proj", + "tool_name": "multi_edit", + "tool_use_id": "tu-5", + "command": r#"{"paths":["/proj/a.rs","/proj/b.rs"]}"#, + "tool_response": { "ok": true } + }); + + let formatted = adapter.format_agentmemory_payload("PostToolUse", raw_payload); + + let files = formatted["data"]["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert_eq!(files[0], "/proj/a.rs"); + assert_eq!(files[1], "/proj/b.rs"); + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_start_session_posts_expected_payload() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/start")) + .and(body_json(json!({ + "sessionId": "session-1", + "project": "/tmp/project", + "cwd": "/tmp/project", + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + adapter + .start_session( + "session-1", + Path::new("/tmp/project"), + Path::new("/tmp/project"), + ) + .await + .expect("session start should succeed"); + } + + #[tokio::test] + #[serial_test::serial(agentmemory_env)] + async fn test_end_session_posts_expected_payload() { + let server = MockServer::start().await; + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/end")) + .and(body_json(json!({ + "sessionId": "session-1", + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + adapter + .end_session("session-1") + .await + .expect("session end should succeed"); + } + + #[test] + fn test_api_base_prefers_explicit_agentmemory_url() { + let _guard = ENV_LOCK.lock().expect("lock env"); + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", "http://127.0.0.1:9999"); + let _rest_port_guard = EnvVarGuard::set("III_REST_PORT", "3111"); + + assert_eq!(adapter.api_base(), "http://127.0.0.1:9999"); + } + + #[test] + fn test_api_base_defaults_to_ipv4_loopback() { + let _guard = ENV_LOCK.lock().expect("lock env"); + let adapter = AgentmemoryAdapter::new(); + let _agentmemory_url_guard = EnvVarGuard::unset("AGENTMEMORY_URL"); + let _rest_port_guard = EnvVarGuard::set("III_REST_PORT", "4242"); + + assert_eq!(adapter.api_base(), "http://127.0.0.1:4242"); + } +} diff --git a/codex-rs/core/src/agentmemory/mod.rs.orig b/codex-rs/core/src/agentmemory/mod.rs.orig new file mode 100644 index 000000000..fb1810db9 --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs.orig @@ -0,0 +1,91 @@ +//! Agentmemory integration adapter. +//! +//! This module provides the seam for integrating the `agentmemory` service +//! as a replacement for Codex's native memory engine. + +use std::path::Path; +use serde_json::json; + +/// A placeholder adapter struct for agentmemory integration. +#[derive(Debug, Default, Clone)] +pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. +} + +impl AgentmemoryAdapter { + pub fn new() -> Self { + Self::default() + } + + fn api_base(&self) -> String { + std::env::var("III_REST_PORT") + .map(|p| format!("http://localhost:{}", p)) + .unwrap_or_else(|_| "http://localhost:3111".to_string()) + } + + /// Builds the developer instructions for startup memory injection + /// using the `agentmemory` retrieval stack. + /// + /// This retrieves context bounded by a token budget and explicitly + /// uses hybrid search semantics rather than loading large static artifacts. + pub async fn build_startup_developer_instructions( + &self, + _codex_home: &Path, + _token_budget: usize, + ) -> Option { + let client = reqwest::Client::new(); + let url = format!("{}/agentmemory/profile", self.api_base()); + let profile_result = client.get(&url).send().await; + + let mut instructions = "Use the `AgentMemory` tools to search and retrieve relevant memory.\n\ + Your context is bounded; use targeted queries to expand details as needed.".to_string(); + + if let Ok(res) = profile_result { + if let Ok(text) = res.text().await { + if !text.is_empty() { + instructions.push_str("\n\n\n"); + instructions.push_str(&text); + instructions.push_str("\n"); + } + } + } + + Some(instructions) + } + + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); + let client = reqwest::Client::new(); + let body = json!({ + "event": event_name, + "payload": payload_json, + }); + let _ = client.post(&url).json(&body).send().await; + } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/consolidate", self.api_base()); + let client = reqwest::Client::new(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/forget", self.api_base()); + let client = reqwest::Client::new(); + let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); + } + Ok(()) + } +} \ No newline at end of file diff --git a/codex-rs/core/src/agentmemory/mod.rs.patch b/codex-rs/core/src/agentmemory/mod.rs.patch new file mode 100644 index 000000000..bec703782 --- /dev/null +++ b/codex-rs/core/src/agentmemory/mod.rs.patch @@ -0,0 +1,27 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -118,22 +118,21 @@ + Ok(()) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use serde_json::json; + + #[test] + fn test_format_claude_parity_payload() { + let adapter = AgentmemoryAdapter::new(); + let raw_payload = json!({ + "session_id": "1234", + "turn_id": "turn-5", + "command": "echo hello" + }); + + let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); + + assert_eq!(formatted["event"], "PreToolUse"); + assert_eq!(formatted["payload"], raw_payload); + } + } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f29d37fe2..3ea4ed39a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -937,6 +937,7 @@ impl TurnContext { .with_unified_exec_shell_mode(self.tools_config.unified_exec_shell_mode.clone()) .with_web_search_config(self.tools_config.web_search_config.clone()) .with_allow_login_shell(self.tools_config.allow_login_shell) + .with_memory_backend(config.memories.backend.clone()) .with_agent_roles(config.agent_roles.clone()); Self { @@ -1272,6 +1273,40 @@ impl Session { Ok((network_proxy, session_network_proxy)) } + async fn start_agentmemory_session(&self, cwd: &Path) -> Result<(), String> { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = self.conversation_id.to_string(); + adapter.start_session(session_id.as_str(), cwd, cwd).await + } + + async fn end_agentmemory_session_if_needed(&self) { + if self.get_config().await.memories.backend + != crate::config::types::MemoryBackend::Agentmemory + { + return; + } + + let should_end = { + let state = self.state.lock().await; + !state.agentmemory_session_ended() + }; + if !should_end { + return; + } + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = self.conversation_id.to_string(); + match adapter.end_session(session_id.as_str()).await { + Ok(()) => { + let mut state = self.state.lock().await; + state.set_agentmemory_session_ended(/*ended*/ true); + } + Err(err) => { + warn!("Agentmemory session end failed for {session_id}: {err}"); + } + } + } + /// Don't expand the number of mutated arguments on config. We are in the process of getting rid of it. pub(crate) fn build_per_turn_config(session_configuration: &SessionConfiguration) -> Config { // todo(aibrahim): store this state somewhere else so we don't need to mut config @@ -1387,6 +1422,7 @@ impl Session { ) .with_web_search_config(per_turn_config.web_search_config.clone()) .with_allow_login_shell(per_turn_config.permissions.allow_login_shell) + .with_memory_backend(per_turn_config.memories.backend.clone()) .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); @@ -1950,6 +1986,17 @@ impl Session { sess.send_event_raw(event).await; } + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory + && let Err(err) = sess + .start_agentmemory_session(session_configuration.cwd.as_path()) + .await + { + warn!( + "Agentmemory session start failed for {}: {err}", + sess.conversation_id + ); + } + // Start the watcher after SessionConfigured so it cannot emit earlier events. sess.start_skills_watcher_listener(); // Construct sandbox_state before MCP startup so it can be sent to each @@ -2043,11 +2090,13 @@ impl Session { state.set_pending_session_start_source(Some(session_start_source)); } - memories::start_memories_startup_task( - &sess, - Arc::clone(&config), - &session_configuration.session_source, - ); + if config.memories.backend == crate::config::types::MemoryBackend::Native { + memories::start_memories_startup_task( + &sess, + Arc::clone(&config), + &session_configuration.session_source, + ); + } Ok(sess) } @@ -3509,11 +3558,28 @@ impl Session { } // Add developer instructions for memories. if turn_context.features.enabled(Feature::MemoryTool) - && turn_context.config.memories.use_memories - && let Some(memory_prompt) = - build_memory_tool_developer_instructions(&turn_context.config.codex_home).await + && (turn_context.config.memories.use_memories + || turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory) { - developer_sections.push(memory_prompt); + let memory_prompt_opt = match turn_context.config.memories.backend { + crate::config::types::MemoryBackend::Agentmemory => { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + // Provide a default explicit token budget for the startup query context + adapter + .build_startup_developer_instructions( + &turn_context.config.codex_home, + /*token_budget*/ 2000, + ) + .await + } + crate::config::types::MemoryBackend::Native => { + build_memory_tool_developer_instructions(&turn_context.config.codex_home).await + } + }; + if let Some(memory_prompt) = memory_prompt_opt { + developer_sections.push(memory_prompt); + } } // Add developer instructions from collaboration_mode if they exist and are non-empty if let Some(collab_instructions) = @@ -4422,6 +4488,10 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handlers::update_memories(&sess, &config, sub.id.clone()).await; false } + Op::RecallMemories { query } => { + handlers::recall_memories(&sess, &config, sub.id.clone(), query).await; + false + } Op::ThreadRollback { num_turns } => { handlers::thread_rollback(&sess, sub.id.clone(), num_turns).await; false @@ -4468,6 +4538,7 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv } // Also drain cached guardian state if the submission loop exits because // the channel closed without receiving an explicit shutdown op. + sess.end_agentmemory_session_if_needed().await; sess.guardian_review_session.shutdown().await; debug!("Agent loop exited"); } @@ -4528,6 +4599,10 @@ mod handlers { use crate::tasks::UserShellCommandTask; use crate::tasks::execute_user_shell_command; use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::items::MemoryOperationKind; + use codex_protocol::items::MemoryOperationStatus; + use codex_protocol::models::DeveloperInstructions; + use codex_protocol::models::ResponseItem; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::Event; @@ -4536,6 +4611,8 @@ mod handlers { use codex_protocol::protocol::ListCustomPromptsResponseEvent; use codex_protocol::protocol::ListSkillsResponseEvent; use codex_protocol::protocol::McpServerRefreshConfig; + use codex_protocol::protocol::MemoryOperationEvent; + use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; @@ -5047,7 +5124,82 @@ mod handlers { .await; } + struct MemoryOperationEventArgs { + source: MemoryOperationSource, + operation: MemoryOperationKind, + status: MemoryOperationStatus, + query: Option, + summary: String, + detail: Option, + context_injected: bool, + } + + async fn send_memory_operation_event( + sess: &Session, + sub_id: &str, + args: MemoryOperationEventArgs, + ) { + let MemoryOperationEventArgs { + source, + operation, + status, + query, + summary, + detail, + context_injected, + } = args; + sess.send_event_raw(Event { + id: sub_id.to_string(), + msg: EventMsg::MemoryOperation(MemoryOperationEvent { + source, + operation, + status, + query, + summary, + detail, + context_injected, + }), + }) + .await; + } + pub async fn drop_memories(sess: &Arc, config: &Arc, sub_id: String) { + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + if let Err(e) = adapter.drop_memories().await { + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory drop failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, + ) + .await; + return; + } + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Cleared Agentmemory contents.".to_string(), + detail: None, + context_injected: false, + }, + ) + .await; + return; + } + let mut errors = Vec::new(); if let Some(state_db) = sess.services.state_db.as_deref() { @@ -5067,26 +5219,39 @@ mod handlers { } if errors.is_empty() { - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Warning(WarningEvent { - message: format!( - "Dropped memories at {} and cleared memory rows from state db.", + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Dropped stored memories for this workspace.".to_string(), + detail: Some(format!( + "Cleared memory rows from the state db and removed stored memory files at {}.", memory_root.display() - ), - }), - }) + )), + context_injected: false, + }, + ) .await; return; } - sess.send_event_raw(Event { - id: sub_id, - msg: EventMsg::Error(ErrorEvent { - message: format!("Memory drop completed with errors: {}", errors.join("; ")), - codex_error_info: Some(CodexErrorInfo::Other), - }), - }) + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Drop, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory drop completed with errors.".to_string(), + detail: Some(errors.join("; ")), + context_injected: false, + }, + ) .await; } @@ -5096,17 +5261,155 @@ mod handlers { state.session_configuration.session_source.clone() }; - crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); + if config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + if let Err(e) = adapter.update_memories().await { + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Error, + query: None, + summary: "Memory update failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, + ) + .await; + return; + } + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Agentmemory sync triggered.".to_string(), + detail: Some( + "Updated observations will appear in future memory recalls once consolidation completes.".to_string(), + ), + context_injected: false, + }, + ) + .await; + return; + } - sess.send_event_raw(Event { - id: sub_id.clone(), - msg: EventMsg::Warning(WarningEvent { - message: "Memory update triggered.".to_string(), - }), - }) + if config.memories.backend == crate::config::types::MemoryBackend::Native { + crate::memories::start_memories_startup_task(sess, Arc::clone(config), &session_source); + } + + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Update, + status: MemoryOperationStatus::Ready, + query: None, + summary: "Memory update triggered.".to_string(), + detail: Some("Consolidation is running in the background.".to_string()), + context_injected: false, + }, + ) .await; } + pub async fn recall_memories( + sess: &Arc, + config: &Arc, + sub_id: String, + query: Option, + ) { + if config.memories.backend != crate::config::types::MemoryBackend::Agentmemory { + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query, + summary: "Memory recall requires agentmemory backend.".to_string(), + detail: None, + context_injected: false, + }, + ) + .await; + return; + } + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let session_id = sess.conversation_id.to_string(); + + match adapter + .recall_for_runtime(&session_id, config.cwd.as_ref(), query.as_deref()) + .await + { + Ok(result) if result.recalled => { + let context = result.context; + let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; + let message: ResponseItem = DeveloperInstructions::new(format!( + "\n{context}\n" + )) + .into(); + sess.record_conversation_items(&turn_context, std::slice::from_ref(&message)) + .await; + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query, + summary: "Recalled memory context and injected it into the current thread." + .to_string(), + detail: Some(context), + context_injected: true, + }, + ) + .await; + } + Ok(_) => { + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Empty, + query, + summary: "No relevant memory context was found.".to_string(), + detail: None, + context_injected: false, + }, + ) + .await; + } + Err(e) => { + send_memory_operation_event( + sess, + &sub_id, + MemoryOperationEventArgs { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query, + summary: "Memory recall failed.".to_string(), + detail: Some(e.to_string()), + context_injected: false, + }, + ) + .await; + } + } + } + pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) { if num_turns == 0 { sess.send_event_raw(Event { @@ -5279,6 +5582,7 @@ mod handlers { pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + sess.end_agentmemory_session_if_needed().await; let _ = sess.conversation.shutdown().await; sess.services .unified_exec_manager @@ -5402,6 +5706,7 @@ async fn spawn_review_thread( sess.services.main_execve_wrapper_exe.as_ref(), ) .with_web_search_config(/*web_search_config*/ None) + .with_memory_backend(config.memories.backend.clone()) .with_allow_login_shell(config.permissions.allow_login_shell) .with_agent_roles(config.agent_roles.clone()); @@ -5939,6 +6244,42 @@ pub(crate) async fn run_turn( stop_hook_active, last_assistant_message: last_agent_message.clone(), }; + + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let stop_payload = serde_json::to_value(&stop_request).unwrap_or_default(); + + // Emit an assistant_result observation when the turn + // produced a meaningful assistant conclusion. This + // ensures sessions with little tool usage still create + // useful memory records. + let assistant_result_payload = if let Some(ref text) = last_agent_message { + if !text.trim().is_empty() { + Some(serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": text, + "is_final": true, + })) + } else { + None + } + } else { + None + }; + + tokio::spawn(async move { + adapter.capture_event("Stop", stop_payload).await; + if let Some(ar_payload) = assistant_result_payload { + adapter.capture_event("AssistantResult", ar_payload).await; + } + }); + } + for run in sess.hooks().preview_stop(&stop_request) { sess.send_event( &turn_context, @@ -6809,6 +7150,7 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option { }, EventMsg::Error(_) | EventMsg::Warning(_) + | EventMsg::MemoryOperation(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) @@ -7140,6 +7482,24 @@ async fn handle_assistant_item_done_in_plan_mode( record_completed_response_item(sess, turn_context, item).await; if let Some(agent_message) = last_assistant_message_from_item(item, /*plan_mode*/ true) { + // Capture intermediate assistant text in plan mode. + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + && !agent_message.trim().is_empty() + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": agent_message, + "is_final": false, + }); + tokio::spawn(async move { + adapter.capture_event("AssistantResult", payload).await; + }); + } *last_agent_message = Some(agent_message); } return true; @@ -7295,6 +7655,26 @@ async fn try_run_sampling_request( in_flight.push_back(tool_future); } if let Some(agent_message) = output_result.last_agent_message { + // Capture intermediate assistant text to agentmemory as it + // streams (is_final=false). The final capture happens at + // turn completion with is_final=true. + if turn_context.config.memories.backend + == crate::config::types::MemoryBackend::Agentmemory + && !agent_message.trim().is_empty() + { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = serde_json::json!({ + "session_id": sess.conversation_id.to_string(), + "turn_id": turn_context.sub_id.clone(), + "cwd": turn_context.cwd.to_string_lossy().to_string(), + "model": turn_context.model_info.slug.clone(), + "assistant_text": agent_message, + "is_final": false, + }); + tokio::spawn(async move { + adapter.capture_event("AssistantResult", payload).await; + }); + } last_agent_message = Some(agent_message); } needs_follow_up |= output_result.needs_follow_up; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index de6489946..644d9b160 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -105,9 +105,16 @@ use rmcp::model::JsonObject; use rmcp::model::Tool; use serde::Deserialize; use serde_json::json; +use std::ffi::OsString; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::body_json; +use wiremock::matchers::method; +use wiremock::matchers::path; #[path = "codex_tests_guardian.rs"] mod guardian_tests; @@ -118,6 +125,34 @@ fn expect_text_tool_output(output: &FunctionToolOutput) -> String { function_call_output_content_items_to_text(&output.body).unwrap_or_default() } +struct EnvVarGuard { + key: &'static str, + original: Option, +} + +impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, original } + } +} + +impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.original.take() { + Some(value) => unsafe { + std::env::set_var(self.key, value); + }, + None => unsafe { + std::env::remove_var(self.key); + }, + } + } +} + struct InstructionsTestCase { slug: &'static str, expects_apply_patch_instructions: bool, @@ -3289,6 +3324,37 @@ async fn shutdown_and_wait_waits_when_shutdown_is_already_in_progress() { .expect("shutdown waiter"); } +#[tokio::test] +#[serial_test::serial(agentmemory_env)] +async fn submission_loop_closes_agentmemory_session_when_channel_closes() { + let server = MockServer::start().await; + let _agentmemory_url_guard = EnvVarGuard::set("AGENTMEMORY_URL", server.uri().as_str()); + + let (session, _turn_context) = make_session_and_context().await; + let mut config = (*session.get_config().await).clone(); + config.memories.backend = crate::config::types::MemoryBackend::Agentmemory; + { + let mut state = session.state.lock().await; + state.session_configuration.original_config_do_not_use = Arc::new(config.clone()); + } + let session = Arc::new(session); + + Mock::given(method("POST")) + .and(path("/agentmemory/session/end")) + .and(body_json(json!({ + "sessionId": session.conversation_id.to_string(), + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let (tx_sub, rx_sub) = async_channel::bounded::(1); + drop(tx_sub); + + submission_loop(session, Arc::new(config), rx_sub).await; +} + #[tokio::test] async fn shutdown_and_wait_shuts_down_cached_guardian_subagent() { let (parent_session, parent_turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d2f6c573..bb7c175ff 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -149,6 +149,7 @@ consolidation_model = "gpt-5" toml::from_str::(memories).expect("TOML deserialization should succeed"); assert_eq!( Some(MemoriesToml { + backend: None, no_memories_if_mcp_or_web_search: Some(true), generate_memories: Some(false), use_memories: Some(false), @@ -172,6 +173,7 @@ consolidation_model = "gpt-5" assert_eq!( config.memories, MemoriesConfig { + backend: crate::config::types::MemoryBackend::default(), no_memories_if_mcp_or_web_search: true, generate_memories: false, use_memories: false, diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index a69fec343..b88318e47 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -412,10 +412,20 @@ pub struct ToolSuggestConfig { pub discoverables: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum MemoryBackend { + #[default] + Native, + Agentmemory, +} + /// Memories settings loaded from config.toml. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct MemoriesToml { + /// The backend to use for memories. + pub backend: Option, /// When `true`, web searches and MCP tool calls mark the thread `memory_mode` as `"polluted"`. pub no_memories_if_mcp_or_web_search: Option, /// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB. @@ -441,6 +451,7 @@ pub struct MemoriesToml { /// Effective memories settings after defaults are applied. #[derive(Debug, Clone, PartialEq, Eq)] pub struct MemoriesConfig { + pub backend: MemoryBackend, pub no_memories_if_mcp_or_web_search: bool, pub generate_memories: bool, pub use_memories: bool, @@ -456,6 +467,7 @@ pub struct MemoriesConfig { impl Default for MemoriesConfig { fn default() -> Self { Self { + backend: MemoryBackend::default(), no_memories_if_mcp_or_web_search: false, generate_memories: true, use_memories: true, @@ -474,6 +486,7 @@ impl From for MemoriesConfig { fn from(toml: MemoriesToml) -> Self { let defaults = Self::default(); Self { + backend: toml.backend.unwrap_or(defaults.backend), no_memories_if_mcp_or_web_search: toml .no_memories_if_mcp_or_web_search .unwrap_or(defaults.no_memories_if_mcp_or_web_search), diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 3c508e05f..f4ba75f4a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1509,6 +1509,38 @@ fn normalize_adds_missing_output_for_tool_search_call() { ); } +#[test] +fn normalize_adds_missing_output_for_custom_tool_call() { + let items = vec![ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "custom-call-x".to_string(), + name: "custom_tool".to_string(), + input: "{}".to_string(), + }]; + let mut h = create_history_with_items(items); + + h.normalize_history(&default_input_modalities()); + + assert_eq!( + h.raw_items(), + vec![ + ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "custom-call-x".to_string(), + name: "custom_tool".to_string(), + input: "{}".to_string(), + }, + ResponseItem::CustomToolCallOutput { + call_id: "custom-call-x".to_string(), + name: None, + output: FunctionCallOutputPayload::from_text("aborted".to_string()), + }, + ] + ); +} + #[cfg(debug_assertions)] #[test] #[should_panic] diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 839bae331..366244fc4 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -72,9 +72,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { }); if !has_output { - error_or_panic(format!( - "Custom tool call output is missing for call id: {call_id}" - )); + info!("Custom tool call output is missing for call id: {call_id}"); missing_outputs_to_insert.push(( idx, ResponseItem::CustomToolCallOutput { diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index ece93b1da..3b88f9981 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -101,6 +101,20 @@ pub(crate) async fn run_pending_session_start_hooks( permission_mode: hook_permission_mode(turn_context), source: session_start_source, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter + .capture_event( + "SessionStart", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; + }); + } + let preview_runs = sess.hooks().preview_session_start(&request); run_context_injecting_hook( sess, @@ -117,6 +131,7 @@ pub(crate) async fn run_pending_session_start_hooks( pub(crate) async fn run_pre_tool_use_hooks( sess: &Arc, turn_context: &Arc, + tool_name: String, tool_use_id: String, command: String, ) -> Option { @@ -127,10 +142,24 @@ pub(crate) async fn run_pre_tool_use_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - tool_name: "Bash".to_string(), + tool_name, tool_use_id, command, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter + .capture_event( + "PreToolUse", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; + }); + } + let preview_runs = sess.hooks().preview_pre_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -147,6 +176,7 @@ pub(crate) async fn run_pre_tool_use_hooks( pub(crate) async fn run_post_tool_use_hooks( sess: &Arc, turn_context: &Arc, + tool_name: String, tool_use_id: String, command: String, tool_response: Value, @@ -158,11 +188,25 @@ pub(crate) async fn run_post_tool_use_hooks( transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), - tool_name: "Bash".to_string(), + tool_name, tool_use_id, command, tool_response, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter + .capture_event( + "PostToolUse", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; + }); + } + let preview_runs = sess.hooks().preview_post_tool_use(&request); emit_hook_started_events(sess, turn_context, preview_runs).await; @@ -171,6 +215,41 @@ pub(crate) async fn run_post_tool_use_hooks( outcome } +pub(crate) async fn run_post_tool_use_failure_hooks( + sess: &Arc, + turn_context: &Arc, + tool_name: String, + tool_use_id: String, + command: String, + error_message: String, +) { + let request = PostToolUseRequest { + session_id: sess.conversation_id, + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.to_path_buf(), + transcript_path: sess.hook_transcript_path().await, + model: turn_context.model_info.slug.clone(), + permission_mode: hook_permission_mode(turn_context), + tool_name, + tool_use_id, + command, + tool_response: serde_json::json!({ "error": error_message }), + }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request; + tokio::spawn(async move { + adapter + .capture_event( + "PostToolUseFailure", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; + }); + } +} + pub(crate) async fn run_user_prompt_submit_hooks( sess: &Arc, turn_context: &Arc, @@ -185,6 +264,20 @@ pub(crate) async fn run_user_prompt_submit_hooks( permission_mode: hook_permission_mode(turn_context), prompt, }; + + if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let payload = request.clone(); + tokio::spawn(async move { + adapter + .capture_event( + "UserPromptSubmit", + serde_json::to_value(&payload).unwrap_or_default(), + ) + .await; + }); + } + let preview_runs = sess.hooks().preview_user_prompt_submit(&request); run_context_injecting_hook( sess, diff --git a/codex-rs/core/src/hook_runtime.rs.patch b/codex-rs/core/src/hook_runtime.rs.patch new file mode 100644 index 000000000..037765ff4 --- /dev/null +++ b/codex-rs/core/src/hook_runtime.rs.patch @@ -0,0 +1,37 @@ +--- codex-rs/core/src/hook_runtime.rs ++++ codex-rs/core/src/hook_runtime.rs +@@ -194,6 +194,29 @@ + outcome + } + ++pub(crate) async fn run_post_tool_use_failure_hooks( ++ sess: &Arc, ++ turn_context: &Arc, ++ tool_name: String, ++ tool_use_id: String, ++ command: String, ++) { ++ let request = codex_hooks::PostToolUseRequest { ++ session_id: sess.conversation_id, ++ turn_id: turn_context.sub_id.clone(), ++ cwd: turn_context.cwd.to_path_buf(), ++ transcript_path: sess.hook_transcript_path().await, ++ model: turn_context.model_info.slug.clone(), ++ permission_mode: hook_permission_mode(turn_context), ++ tool_name, ++ tool_use_id, ++ command, ++ tool_response: serde_json::json!({ "error": "tool failed" }), // TODO: capture actual error ++ }; ++ ++ if turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { ++ let adapter = crate::agentmemory::AgentmemoryAdapter::new(); ++ let payload = request.clone(); ++ tokio::spawn(async move { ++ adapter.capture_event("PostToolUseFailure", payload).await; ++ }); ++ } ++} ++ + pub(crate) async fn run_user_prompt_submit_hooks( + sess: &Arc, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 1ed3250f3..ccbaa4abd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -57,6 +57,7 @@ pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY; pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD; pub use mcp_connection_manager::SandboxState; pub use text_encoding::bytes_to_string_smart; +pub mod agentmemory; mod mcp_tool_call; mod memories; pub mod mention_syntax; diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 9855481b0..20a121d5c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -390,6 +390,7 @@ async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &T .config .memories .no_memories_if_mcp_or_web_search + || turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory { return; } diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs index c3a721871..b4ebac068 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout.rs @@ -33,6 +33,7 @@ impl codex_rollout::RolloutConfigView for Config { fn generate_memories(&self) -> bool { self.memories.generate_memories + || self.memories.backend == crate::config::types::MemoryBackend::Agentmemory } } diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 1a1423616..c1e1f7b8b 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -32,6 +32,7 @@ pub(crate) struct SessionState { pub(crate) startup_prewarm: Option, pub(crate) active_connector_selection: HashSet, pub(crate) pending_session_start_source: Option, + agentmemory_session_ended: bool, granted_permissions: Option, } @@ -50,6 +51,7 @@ impl SessionState { startup_prewarm: None, active_connector_selection: HashSet::new(), pending_session_start_source: None, + agentmemory_session_ended: false, granted_permissions: None, } } @@ -206,6 +208,14 @@ impl SessionState { self.pending_session_start_source.take() } + pub(crate) fn agentmemory_session_ended(&self) -> bool { + self.agentmemory_session_ended + } + + pub(crate) fn set_agentmemory_session_ended(&mut self, ended: bool) { + self.agentmemory_session_ended = ended; + } + pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) { self.granted_permissions = merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions)); diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index cd77f1d5a..44b5fa436 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -142,6 +142,7 @@ async fn maybe_mark_thread_memory_mode_polluted_from_web_search( .config .memories .no_memories_if_mcp_or_web_search + || turn_context.config.memories.backend == crate::config::types::MemoryBackend::Agentmemory || !matches!(item, ResponseItem::WebSearchCall { .. }) { return; diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 58d040cbe..cbab87d01 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -111,6 +111,10 @@ impl ToolOutput for CallToolResult { } } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + serde_json::to_value(self).ok() + } + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { serde_json::to_value(self).unwrap_or_else(|err| { JsonValue::String(format!("failed to serialize mcp result: {err}")) @@ -157,6 +161,10 @@ impl ToolOutput for ToolSearchOutput { .collect(), } } + + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + serde_json::to_value(&self.tools).ok() + } } pub struct FunctionToolOutput { @@ -206,7 +214,9 @@ impl ToolOutput for FunctionToolOutput { } fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { - self.post_tool_use_response.clone() + self.post_tool_use_response + .clone() + .or_else(|| serde_json::to_value(&self.body).ok()) } } @@ -240,6 +250,10 @@ impl ToolOutput for ApplyPatchToolOutput { ) } + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + Some(JsonValue::String(self.text.clone())) + } + fn code_mode_result(&self, _payload: &ToolPayload) -> JsonValue { JsonValue::Object(serde_json::Map::new()) } @@ -280,6 +294,10 @@ impl ToolOutput for AbortedToolOutput { ), } } + + fn post_tool_use_response(&self, _call_id: &str, _payload: &ToolPayload) -> Option { + Some(JsonValue::String(self.message.clone())) + } } #[derive(Debug, Clone, PartialEq)] diff --git a/codex-rs/core/src/tools/handlers/memory_recall.rs b/codex-rs/core/src/tools/handlers/memory_recall.rs new file mode 100644 index 000000000..b07afe3ca --- /dev/null +++ b/codex-rs/core/src/tools/handlers/memory_recall.rs @@ -0,0 +1,135 @@ +use async_trait::async_trait; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; +use serde::Deserialize; + +use crate::config::types::MemoryBackend; +use crate::function_tool::FunctionCallError; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::items::MemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus; + +#[derive(Debug, Deserialize)] +struct MemoryRecallArgs { + #[serde(default)] + query: Option, +} + +pub struct MemoryRecallHandler; + +#[async_trait] +impl ToolHandler for MemoryRecallHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "memory_recall handler received unsupported payload".to_string(), + )); + } + }; + + if turn.config.memories.backend != MemoryBackend::Agentmemory { + return Err(FunctionCallError::RespondToModel( + "memory_recall requires agentmemory backend".to_string(), + )); + } + + let args: MemoryRecallArgs = parse_arguments(&arguments)?; + let query = args + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()); + + let adapter = crate::agentmemory::AgentmemoryAdapter::new(); + let response = match adapter + .recall_for_runtime( + &session.conversation_id.to_string(), + turn.cwd.as_path(), + query, + ) + .await + { + Ok(response) => response, + Err(err) => { + session + .send_event( + turn.as_ref(), + EventMsg::MemoryOperation(MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Error, + query: args.query.clone(), + summary: "Assistant memory recall failed.".to_string(), + detail: Some(err.to_string()), + context_injected: false, + }), + ) + .await; + return Err(FunctionCallError::RespondToModel(format!( + "memory_recall failed: {err}" + ))); + } + }; + + session + .send_event( + turn.as_ref(), + EventMsg::MemoryOperation(if response.recalled { + MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query: args.query.clone(), + summary: "Assistant recalled memory context for this turn.".to_string(), + detail: Some(response.context.clone()), + context_injected: false, + } + } else { + MemoryOperationEvent { + source: MemoryOperationSource::Assistant, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Empty, + query: args.query.clone(), + summary: "Assistant found no relevant memory context for this turn." + .to_string(), + detail: None, + context_injected: false, + } + }), + ) + .await; + + let content = serde_json::to_string(&response).map_err(|err| { + FunctionCallError::Fatal(format!("failed to serialize memory_recall response: {err}")) + })?; + + let mut output = FunctionToolOutput::from_text(content, Some(true)); + output.post_tool_use_response = Some(serde_json::to_value(&response).map_err(|err| { + FunctionCallError::Fatal(format!( + "failed to encode memory_recall post-tool response: {err}" + )) + })?); + Ok(output) + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 3241b323b..612bba143 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -5,6 +5,7 @@ mod js_repl; mod list_dir; mod mcp; mod mcp_resource; +mod memory_recall; pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; @@ -42,6 +43,7 @@ pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; +pub use memory_recall::MemoryRecallHandler; pub use plan::PlanHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 7f97f419d..fc3fa7135 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -206,18 +206,22 @@ impl ToolHandler for ShellHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { command }) + shell_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - command: shell_payload_command(payload)?, + tool_name: invocation.tool_name.clone(), + command: shell_payload_command(&invocation.payload)?, tool_response, }) } @@ -313,19 +317,22 @@ impl ToolHandler for ShellCommandHandler { } fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { - shell_command_payload_command(&invocation.payload) - .map(|command| PreToolUsePayload { command }) + shell_command_payload_command(&invocation.payload).map(|command| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { - command: shell_command_payload_command(payload)?, + tool_name: invocation.tool_name.clone(), + command: shell_command_payload_command(&invocation.payload)?, tool_response, }) } diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index fdc015c5e..2069416b8 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -218,6 +218,7 @@ async fn shell_pre_tool_use_payload_uses_joined_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { + tool_name: "shell".to_string(), command: "bash -lc 'printf hi'".to_string(), }) ); @@ -244,13 +245,14 @@ async fn shell_command_pre_tool_use_payload_uses_raw_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { + tool_name: "shell_command".to_string(), command: "printf shell command".to_string(), }) ); } -#[test] -fn build_post_tool_use_payload_uses_tool_output_wire_value() { +#[tokio::test] +async fn build_post_tool_use_payload_uses_tool_output_wire_value() { let payload = ToolPayload::Function { arguments: json!({ "command": "printf shell command" }).to_string(), }; @@ -263,9 +265,21 @@ fn build_post_tool_use_payload_uses_tool_output_wire_value() { backend: super::ShellCommandBackend::Classic, }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-42".to_string(), + tool_name: "shell_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - handler.post_tool_use_payload("call-42", &payload, &output), + handler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_name: "shell_command".to_string(), command: "printf shell command".to_string(), tool_response: json!("shell output"), }) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index b512a878b..11876d087 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -133,16 +133,18 @@ impl ToolHandler for UnifiedExecHandler { parse_arguments::(arguments) .ok() - .map(|args| PreToolUsePayload { command: args.cmd }) + .map(|args| PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: args.cmd, + }) } fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - let ToolPayload::Function { arguments } = payload else { + let ToolPayload::Function { arguments } = &invocation.payload else { return None; }; @@ -151,8 +153,10 @@ impl ToolHandler for UnifiedExecHandler { return None; } - let tool_response = result.post_tool_use_response(call_id, payload)?; + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; Some(PostToolUsePayload { + tool_name: invocation.tool_name.clone(), command: args.cmd, tool_response, }) diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 2390068cc..b0bf4d8cc 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -210,6 +210,7 @@ async fn exec_command_pre_tool_use_payload_uses_raw_command() { payload, }), Some(crate::tools::registry::PreToolUsePayload { + tool_name: "exec_command".to_string(), command: "printf exec command".to_string(), }) ); @@ -237,8 +238,8 @@ async fn exec_command_pre_tool_use_payload_skips_write_stdin() { ); } -#[test] -fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_commands() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(), }; @@ -258,17 +259,29 @@ fn exec_command_post_tool_use_payload_uses_output_for_noninteractive_one_shot_co ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-43".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-43", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), Some(crate::tools::registry::PostToolUsePayload { + tool_name: "exec_command".to_string(), command: "echo three".to_string(), tool_response: serde_json::json!("three"), }) ); } -#[test] -fn exec_command_post_tool_use_payload_skips_interactive_exec() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_skips_interactive_exec() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": true }).to_string(), }; @@ -288,14 +301,25 @@ fn exec_command_post_tool_use_payload_skips_interactive_exec() { ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-44".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-44", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), None ); } -#[test] -fn exec_command_post_tool_use_payload_skips_running_sessions() { +#[tokio::test] +async fn exec_command_post_tool_use_payload_skips_running_sessions() { let payload = ToolPayload::Function { arguments: serde_json::json!({ "cmd": "echo three", "tty": false }).to_string(), }; @@ -315,8 +339,19 @@ fn exec_command_post_tool_use_payload_skips_running_sessions() { ]), }; + let (session, turn) = make_session_and_context().await; + let invocation = ToolInvocation { + session: session.into(), + turn: turn.into(), + tracker: Arc::new(Mutex::new(TurnDiffTracker::new())), + call_id: "call-45".to_string(), + tool_name: "exec_command".to_string(), + tool_namespace: None, + payload, + }; + assert_eq!( - UnifiedExecHandler.post_tool_use_payload("call-45", &payload, &output), + UnifiedExecHandler.post_tool_use_payload(&invocation, &output), None ); } diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index aa7f40109..0fed55514 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -57,17 +57,25 @@ pub trait ToolHandler: Send + Sync { false } - fn pre_tool_use_payload(&self, _invocation: &ToolInvocation) -> Option { - None + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { + Some(PreToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: invocation.payload.log_payload().into_owned(), + }) } fn post_tool_use_payload( &self, - _call_id: &str, - _payload: &ToolPayload, - _result: &dyn ToolOutput, + invocation: &ToolInvocation, + result: &dyn ToolOutput, ) -> Option { - None + let tool_response = + result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + Some(PostToolUsePayload { + tool_name: invocation.tool_name.clone(), + command: invocation.payload.log_payload().into_owned(), + tool_response, + }) } /// Perform the actual [ToolInvocation] and returns a [ToolOutput] containing @@ -99,14 +107,15 @@ impl AnyToolResult { result.code_mode_result(&payload) } } - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PreToolUsePayload { + pub(crate) tool_name: String, pub(crate) command: String, } #[derive(Debug, Clone, PartialEq)] pub(crate) struct PostToolUsePayload { + pub(crate) tool_name: String, pub(crate) command: String, pub(crate) tool_response: Value, } @@ -121,8 +130,7 @@ trait AnyToolHandler: Send + Sync { fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option; @@ -151,11 +159,10 @@ where fn post_tool_use_payload( &self, - call_id: &str, - payload: &ToolPayload, + invocation: &ToolInvocation, result: &dyn ToolOutput, ) -> Option { - ToolHandler::post_tool_use_payload(self, call_id, payload, result) + ToolHandler::post_tool_use_payload(self, invocation, result) } async fn handle_any( @@ -295,18 +302,20 @@ impl ToolRegistry { return Err(FunctionCallError::Fatal(message)); } - if let Some(pre_tool_use_payload) = handler.pre_tool_use_payload(&invocation) + let pre_tool_use_payload = handler.pre_tool_use_payload(&invocation); + if let Some(ref payload) = pre_tool_use_payload && let Some(reason) = run_pre_tool_use_hooks( &invocation.session, &invocation.turn, + payload.tool_name.clone(), invocation.call_id.clone(), - pre_tool_use_payload.command.clone(), + payload.command.clone(), ) .await { return Err(FunctionCallError::RespondToModel(format!( "Command blocked by PreToolUse hook: {reason}. Command: {}", - pre_tool_use_payload.command + payload.command ))); } @@ -355,11 +364,7 @@ impl ToolRegistry { let post_tool_use_payload = if success { let guard = response_cell.lock().await; guard.as_ref().and_then(|result| { - handler.post_tool_use_payload( - &result.call_id, - &result.payload, - result.result.as_ref(), - ) + handler.post_tool_use_payload(&invocation, result.result.as_ref()) }) } else { None @@ -369,6 +374,7 @@ impl ToolRegistry { run_post_tool_use_hooks( &invocation.session, &invocation.turn, + post_tool_use_payload.tool_name.clone(), invocation.call_id.clone(), post_tool_use_payload.command, post_tool_use_payload.tool_response, @@ -376,6 +382,17 @@ impl ToolRegistry { .await, ) } else { + if !success && let Some(ref payload) = pre_tool_use_payload { + crate::hook_runtime::run_post_tool_use_failure_hooks( + &invocation.session, + &invocation.turn, + payload.tool_name.clone(), + invocation.call_id.clone(), + payload.command.clone(), + output_preview.clone(), + ) + .await; + } None }; // Deprecated: this is the legacy AfterToolUse hook. Prefer the new PostToolUse diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 7c8fb0d9b..3584e1394 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -3,6 +3,7 @@ use crate::client_common::tools::FreeformToolFormat; use crate::client_common::tools::ResponsesApiTool; use crate::client_common::tools::ToolSpec; use crate::config::AgentRoleConfig; +use crate::config::types::MemoryBackend; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp_connection_manager::ToolInfo; use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig; @@ -167,6 +168,24 @@ fn send_input_output_schema() -> JsonValue { }) } +fn memory_recall_output_schema() -> JsonValue { + json!({ + "type": "object", + "properties": { + "recalled": { + "type": "boolean", + "description": "Whether agentmemory returned any context for this request." + }, + "context": { + "type": "string", + "description": "Recalled memory context. Empty when nothing relevant was found." + } + }, + "required": ["recalled", "context"], + "additionalProperties": false + }) +} + fn list_agents_output_schema() -> JsonValue { json!({ "type": "object", @@ -313,6 +332,8 @@ impl UnifiedExecShellMode { #[derive(Debug, Clone)] pub(crate) struct ToolsConfig { pub available_models: Vec, + pub memory_backend: MemoryBackend, + pub memory_tool_enabled: bool, pub shell_type: ConfigShellToolType, shell_command_backend: ShellCommandBackendConfig, pub unified_exec_shell_mode: UnifiedExecShellMode, @@ -447,6 +468,8 @@ impl ToolsConfig { Self { available_models: available_models_ref.to_vec(), + memory_backend: MemoryBackend::Native, + memory_tool_enabled: features.enabled(Feature::MemoryTool), shell_type, shell_command_backend, unified_exec_shell_mode: UnifiedExecShellMode::Direct, @@ -481,6 +504,11 @@ impl ToolsConfig { self } + pub fn with_memory_backend(mut self, memory_backend: MemoryBackend) -> Self { + self.memory_backend = memory_backend; + self + } + pub fn with_allow_login_shell(mut self, allow_login_shell: bool) -> Self { self.allow_login_shell = allow_login_shell; self @@ -1740,6 +1768,32 @@ fn create_request_permissions_tool() -> ToolSpec { }) } +fn create_memory_recall_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "query".to_string(), + JsonSchema::String { + description: Some( + "Optional targeted memory recall query. When omitted, recall uses the current thread and project context only." + .to_string(), + ), + }, + )]); + + ToolSpec::Function(ResponsesApiTool { + name: "memory_recall".to_string(), + description: "Recall relevant agentmemory context for the current thread and project. Use this when prior work, design rationale, earlier failures, or cross-session continuity matter and the current thread does not already contain enough context. Prefer targeted queries naming the feature, file, bug, or decision you need." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::Object { + properties, + required: None, + additional_properties: Some(false.into()), + }, + output_schema: Some(memory_recall_output_schema()), + }) +} + fn create_close_agent_tool() -> ToolSpec { let mut properties = BTreeMap::new(); properties.insert( @@ -2565,6 +2619,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; + use crate::tools::handlers::MemoryRecallHandler; use crate::tools::handlers::PlanHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; @@ -2602,6 +2657,7 @@ pub(crate) fn build_specs_with_discoverable_tools( let request_user_input_handler = Arc::new(RequestUserInputHandler { default_mode_request_user_input: config.default_mode_request_user_input, }); + let memory_recall_handler = Arc::new(MemoryRecallHandler); let tool_suggest_handler = Arc::new(ToolSuggestHandler); let code_mode_handler = Arc::new(CodeModeExecuteHandler); let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); @@ -2781,6 +2837,16 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("request_permissions", request_permissions_handler); } + if config.memory_tool_enabled && config.memory_backend == MemoryBackend::Agentmemory { + push_tool_spec( + &mut builder, + create_memory_recall_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + builder.register_handler("memory_recall", memory_recall_handler); + } + if config.search_tool && let Some(app_tools) = app_tools { diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 0cd8c2e1f..78f696d93 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1,5 +1,6 @@ use crate::client_common::tools::FreeformTool; use crate::config::test_config; +use crate::config::types::MemoryBackend; use crate::models_manager::manager::ModelsManager; use crate::models_manager::model_info::with_config_overrides; use crate::shell::Shell; @@ -914,7 +915,7 @@ fn request_permissions_tool_is_independent_from_additional_permissions() { } #[test] -fn get_memory_requires_feature_flag() { +fn memory_recall_requires_feature_flag() { let config = test_config(); let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); let mut features = Features::with_defaults(); @@ -930,10 +931,50 @@ fn get_memory_requires_feature_flag() { windows_sandbox_level: WindowsSandboxLevel::Disabled, }); let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); - assert!( - !tools.iter().any(|t| t.spec.name() == "get_memory"), - "get_memory should be disabled when memory_tool feature is off" - ); + assert_lacks_tool_name(&tools, "memory_recall"); +} + +#[test] +fn memory_recall_is_absent_with_memory_tool_feature_on_and_native_backend() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::MemoryTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + assert_lacks_tool_name(&tools, "memory_recall"); +} + +#[test] +fn memory_recall_is_exposed_with_memory_tool_feature_on_and_agentmemory_backend() { + let config = test_config(); + let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config); + let mut features = Features::with_defaults(); + features.enable(Feature::MemoryTool); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }) + .with_memory_backend(MemoryBackend::Agentmemory); + let (tools, _) = build_specs(&tools_config, None, None, &[]).build(); + + let memory_recall_tool = find_tool(&tools, "memory_recall"); + assert_eq!(memory_recall_tool.spec, create_memory_recall_tool()); } #[test] diff --git a/codex-rs/deny.toml b/codex-rs/deny.toml index 3dd27c8f9..ccd510e7e 100644 --- a/codex-rs/deny.toml +++ b/codex-rs/deny.toml @@ -128,9 +128,6 @@ allow = [ # MPL-2.0 - https://www.mozilla.org/MPL/2.0/ # Used by: nucleo-matcher "MPL-2.0", - # OpenSSL - https://spdx.org/licenses/OpenSSL.html - # Used by: aws-lc-sys - "OpenSSL", # Unicode-3.0 - https://opensource.org/license/unicode # Used by: icu_decimal, icu_locale_core, icu_provider "Unicode-3.0", diff --git a/codex-rs/docs/agentmemory_followup_spec.md b/codex-rs/docs/agentmemory_followup_spec.md new file mode 100644 index 000000000..5d84619ee --- /dev/null +++ b/codex-rs/docs/agentmemory_followup_spec.md @@ -0,0 +1,235 @@ +# Agentmemory Follow-Up Spec + +## Status + +Proposed follow-up backlog after the runtime-surface, proactive-guidance, and +visual-memory-UI lanes. + +Priority 1 is now implemented for the human-triggered memory control plane: +structured memory events replace string-matched warning/error parsing for +`/memory-recall`, `/memory-update`, and `/memory-drop` across both TUIs. +Priority 2 and Priority 3 are also now implemented: +`/memory-*` operations collapse to a single coherent card per operation, and +assistant-triggered `memory_recall` is visible to the human transcript. +Priority 5 is also implemented with a lightweight ambient availability +indicator in the TUI footer. The remaining backlog starts at richer metadata +and broader end-to-end confidence work. + +This document exists to answer one practical question: + +- what is still worth doing after the current `agentmemory` integration work, +- in what order, +- and what should explicitly not be done yet. + +## Current State + +The fork already has: + +- `agentmemory` as the active long-term memory backend +- an assistant-facing `memory_recall` tool +- human-facing `/memory-recall`, `/memory-update`, and `/memory-drop` +- proactive runtime guidance for when the assistant should use recall +- dedicated visual memory history cells in both TUIs +- structured memory outcome events for human-triggered recall/update/drop +- a single coherent memory card per human-triggered operation +- visible assistant-triggered `memory_recall` events in both TUIs +- a lightweight ambient memory-availability indicator in the footer + +That means the system is functionally good. What remains is mostly structural +cleanup, richer human visibility, and better retrieval/capture quality. + +## Goal + +Turn the current good private-fork integration into the cleanest durable shape: + +- fewer duplicated UI heuristics +- clearer human visibility for all memory activity +- more structured memory events and metadata +- better long-term retrieval quality +- better end-to-end confidence + +## Priority Order + +### 1. Replace string-matched memory UI with structured memory events + +Current state: + +- both TUIs recognize memory outcomes by parsing core-emitted warning/error + strings + +Why this is next: + +- it removes duplicated parsing logic across `tui` and `tui_app_server` +- it makes the visual memory UI more robust against copy changes +- it creates the right hook point for surfacing assistant-triggered memory use + +Target shape: + +- a dedicated protocol event carrying: + - operation + - status + - query + - whether context was injected + - preview/detail payload + +Do not do: + +- broad protocol redesign beyond the memory event itself + +### 2. Replace append-only pending memory cards with in-place completion updates + +Current state: + +- the human sees a `Pending` card at submit time and then a second final card + +Why this is next: + +- the current UX is correct but noisy +- users can misread the persistent pending card as a stuck operation + +Target shape: + +- one memory card per operation +- pending transitions to ready/empty/error in place + +### 3. Surface assistant-triggered `memory_recall` to the human + +Current state: + +- human-triggered memory actions are visually shown +- assistant-triggered `memory_recall` is functionally real but not given the + same polished human-facing transcript treatment + +Why this matters: + +- users should be able to see when the assistant consulted long-term memory +- this improves trust and debuggability + +Target shape: + +- assistant-triggered memory recall produces the same visual memory event style + as human-triggered recall +- the UI should distinguish: + - tool returned context to the assistant + - context was injected into the current thread + +### 4. Add richer memory metadata to the human UI + +Current state: + +- memory cells show operation/query/status/preview +- they do not yet show richer recall metadata + +Useful additions: + +- block count +- token count +- backend/source label +- timestamp or relative freshness label + +Why this matters: + +- it helps users understand whether memory was broad, sparse, or stale + +### 5. Add a lightweight memory-availability indicator in the TUI + +Current state: + +- memory is visible when explicitly used +- there is no ambient signal that the current runtime has `agentmemory` recall + available + +Target shape: + +- a subtle status-line or bottom-pane indication when: + - backend is `agentmemory` + - `memory_recall` tool is available + +Do not do: + +- a large always-on panel + +### 6. Add end-to-end regression coverage for assistant memory use + +Current state: + +- focused tool/spec/TUI tests exist +- there is no single end-to-end regression proving the assistant actually calls + `memory_recall` in a realistic run and that the human can observe the right + result path + +Target additions: + +- a `codex exec`-style regression for assistant tool recall +- a TUI/app-server regression for visual memory event rendering + +### 7. Finish the payload-quality backlog + +This stays important even though the runtime surface is now solid. + +Still open from the payload-quality spec: + +- add tool-output size caps for `post_tool_use` +- selectively filter low-value `pre_tool_use` traffic +- create real-session quality evaluation fixtures + +Why this still matters: + +- retrieval quality will eventually matter more than UI polish + +### 8. Consider selective auto-recall only after the above is done + +Current state: + +- recall is explicit and targeted +- assistant guidance is now better + +This is intentionally not earlier in the order because: + +- auto-recall before structured events and better observability is harder to + trust +- over-eager recall can create noise, token waste, and hard-to-debug behavior + +If done later, it should be narrow: + +- only when current-thread context is obviously insufficient +- only with targeted queries +- only after the human can clearly see that memory was consulted + +## Non-Goals + +Do not do these in the next lane unless requirements change: + +- MCP-based memory exposure +- a second competing memory backend +- broad automatic recall on every turn +- large static memory dumps into the prompt +- major UI chrome like a full separate memory sidebar + +## Acceptance Gates For The Next Meaningful Lane + +The next follow-up lane should count as complete only if: + +- memory events are structured rather than inferred from strings +- the human sees a single coherent memory card per operation +- assistant-triggered recall is visible to the human +- the UI still stays aligned between `tui` and `tui_app_server` + +## Recommendation + +If choosing only one next lane, do this: + +- implement structured memory protocol events and use them to replace the + current string-parsing visual UI path + +That is the best leverage point because it improves: + +- UI clarity +- assistant transparency +- maintainability +- long-term extensibility + +If choosing two lanes, do these in order: + +1. structured memory events +2. payload-quality backlog (`post_tool_use` caps + `pre_tool_use` filtering) diff --git a/codex-rs/docs/agentmemory_payload_quality_spec.md b/codex-rs/docs/agentmemory_payload_quality_spec.md new file mode 100644 index 000000000..67aea35ea --- /dev/null +++ b/codex-rs/docs/agentmemory_payload_quality_spec.md @@ -0,0 +1,214 @@ +# Agentmemory Payload Quality Spec + +## Goal + +Improve the usefulness of Agentmemory-derived observations, timelines, and +retrieval context for Codex sessions. + +The current integration is functionally working, but memory quality is still +limited by: + +- lifecycle-heavy noise dominating the observation stream +- incomplete structured tool payloads +- missing assistant-result capture +- weaker file-aware enrichment than the standalone Agentmemory hook scripts + +## Current State + +What is already working: + +- Codex sessions are registered in Agentmemory and appear in the viewer. +- Session lifecycle is closed out on Codex shutdown. +- Hook payloads now use Agentmemory-compatible hook names: + - `session_start` + - `prompt_submit` + - `pre_tool_use` + - `post_tool_use` + - `post_tool_failure` + - `assistant_result` + - `stop` +- Prompt, tool input, tool output, and error fields are mapped into the + canonical Agentmemory schema. +- Structured tool arguments are parsed from JSON command strings into + searchable top-level fields (`file_path`, `path`, `pattern`, `query`, etc.). +- File-aware enrichment surfaces `files[]` and `search_terms[]` on tool events. +- Assistant conclusions are captured at turn completion (`is_final: true`) and + progressively as each message block completes streaming (`is_final: false`). +- Mid-session memory retrieval via `/memory-recall [query]` injects recalled + context as developer messages into the active conversation. +- Token-budgeted context injection at session startup via `/agentmemory/context`. +- All event capture is non-blocking via `tokio::spawn`. +- Assistant text truncated to 4096 bytes respecting UTF-8 boundaries. + +Remaining gaps: + +- Tool output payloads (`post_tool_use`) have no size cap and may cause + memory bloat for large file reads. +- `pre_tool_use` fires unconditionally for all tools; no selective filtering + to reduce timeline noise for low-signal events. +- Real-session quality evaluation fixtures are deferred (unit tests exist). + +## Desired Outcomes + +1. Agentmemory timelines should be dominated by user intent, important tool +results, failures, decisions, and conclusions instead of routine lifecycle +markers. +2. Retrieval context should help a later agent answer: + - what the user asked + - what the agent tried + - what succeeded or failed + - what conclusion or decision mattered +3. File-sensitive tasks should yield observations and memories that mention the + relevant paths and search terms when available. +4. Manual memory recall should be inspectable by the human in the TUI, not only + injected into assistant context. + +## Proposed Changes + +### 1. Reduce lifecycle noise + +Default policy: + +- Keep: + - `prompt_submit` + - `post_tool_use` + - `post_tool_failure` + - `session_start` + - `stop` +- Suppress or aggressively gate: + - `pre_tool_use` + +Preferred rule: + +- Do not emit `pre_tool_use` for routine shell or exec traffic by default. +- Only emit `pre_tool_use` when it carries unique high-signal metadata that + will not appear in the corresponding post-tool observation. + +Acceptance criteria: + +- A typical session timeline should contain substantially fewer lifecycle-only + observations. +- Repeated pre-tool lifecycle entries should no longer dominate the top of the + timeline for normal sessions. + +### 2. Preserve structured tool arguments where available + +For tool-use events, prefer structured payloads over flattened command strings +when the source event includes them. + +Examples of desired fields: + +- file-oriented tools: + - `file_path` + - `path` + - `paths` + - `pattern` +- search-oriented tools: + - `query` + - `pattern` + - `glob` +- execution tools: + - structured command arguments when available + +If both structured fields and a command string exist: + +- preserve the structured fields in `tool_input` +- optionally keep the command string under a separate field if it adds value + +Acceptance criteria: + +- Agentmemory compressed observations for file/search/edit tasks should more + often include exact file paths and more task-specific titles. + +### 3. Capture assistant result / conclusion payloads + +Add a new observation path for assistant-visible conclusions, not only tool +activity. + +Possible event classes: + +- final assistant message at turn completion +- synthesized task result / conclusion +- important stop-summary payload when the agent has a meaningful last answer + +Minimum useful fields: + +- assistant text, truncated to a safe size +- turn id +- session id +- cwd +- optional tags for whether the text is final, partial, or summary content + +Acceptance criteria: + +- Sessions with little or no tool usage still produce useful memory records. +- Retrieval can surface what the agent concluded, not just what tools ran. + +### 4. Improve file-aware enrichment parity + +Bring the Rust integration closer to the standalone Agentmemory JavaScript hook +behavior for file-aware tools. + +When structured file/search arguments are available, enable the same sort of +file-context enrichment that the JavaScript `pre-tool-use` hook performs. + +Acceptance criteria: + +- Memory observations for file edits/searches are more likely to mention the + touched paths and relevant search terms. + +### 5. Add quality evaluation fixtures + +Create a small regression corpus of real Codex sessions and evaluate: + +- timeline readability +- compressed observation usefulness +- retrieval usefulness for follow-up questions + +Suggested evaluation checks: + +1. Timeline signal ratio + - proportion of useful task observations vs lifecycle-only observations +2. Retrieval usefulness + - given a follow-up question, does returned context contain the task, action, + result, and conclusion? +3. File recall quality + - for file-sensitive sessions, do observations and retrieval mention the + correct paths? + +Acceptance criteria: + +- At least one representative multi-tool session becomes obviously more useful + to inspect in the viewer after the changes. +- Retrieval answers improve on a fixed before/after comparison for the same + session set. + +## Non-Goals + +- perfect semantic summarization of every session +- preserving every lifecycle marker in the durable memory stream +- storing unbounded tool outputs +- introducing heavy blocking calls on the hot path of Codex tool execution + +## Rollout Order + +1. ~~suppress or gate low-value lifecycle observations~~ — kept all events including pre_tool_use; enriched with structured args instead of gating +2. ~~forward richer structured tool input~~ — implemented +3. ~~add assistant-result capture~~ — implemented; streaming intermediate capture added (`is_final: false` per completed message block) +4. ~~add evaluation fixtures and compare before/after quality~~ — unit tests added; real-session fixture comparison is deferred +5. ~~mid-session memory retrieval~~ — implemented via `/memory-recall [query]` slash command and `Op::RecallMemories` +6. tool output size caps — not yet implemented +7. selective pre_tool_use filtering — not yet implemented + +## Risks + +- over-filtering may remove useful debugging evidence +- assistant-result capture may duplicate information already present in tool + outputs if not scoped carefully +- richer structured payloads may increase observation size and compression cost + +## Mitigations + +- keep raw observation size limits and truncation +- prefer targeted gating over blanket event deletion +- evaluate with real-session fixtures before expanding the payload surface diff --git a/codex-rs/docs/agentmemory_runtime_surface_spec.md b/codex-rs/docs/agentmemory_runtime_surface_spec.md new file mode 100644 index 000000000..ff7d4dc6e --- /dev/null +++ b/codex-rs/docs/agentmemory_runtime_surface_spec.md @@ -0,0 +1,346 @@ +# Agentmemory Runtime Surface Spec + +## Status + +Proposed implementation handoff for the runtime-surface lane. + +This document is intentionally narrow. It is the canonical handoff for how +`agentmemory` should appear at runtime in this fork. + +It does not replace the broader architecture decisions in: + +- `docs/agentmemory_payload_quality_spec.md` +- `/Users/ericjuta/Projects/codex/docs/agentmemory-codex-memory-replacement-spec.md` + +Those documents answer whether `agentmemory` should be the primary memory +engine and how capture/retrieval quality should work. This document answers the +next question: + +- what human-facing and assistant-facing runtime surfaces should exist, +- what they should call internally, +- what should be visible in the TUI, +- what should not be built. + +## Goal + +Present one coherent memory system to both: + +- the human user in the TUI, and +- the assistant at runtime. + +The design must avoid hidden memory mutations, duplicate retrieval paths, and +assistant confusion about whether memory is actually available. + +## Current Problem + +The fork currently has an awkward split: + +- the human can explicitly trigger `/memory-recall`, `/memory-update`, and + `/memory-drop` from the TUI, +- core already knows how to call the `agentmemory` adapter, +- the assistant often cannot see an equivalent first-class callable memory + surface, +- successful recall can inject developer instructions into the thread without + giving the human a strong visible explanation of what happened. + +That shape is product-incoherent. It causes both: + +- user confusion: "I pressed Enter and nothing happened" +- assistant confusion: "memory is not available here" even though the human + command exists and the backend is healthy + +## Decision + +The correct runtime design for this fork is: + +1. `agentmemory` is the one authoritative runtime memory backend. +2. There is one canonical core recall/update/drop implementation. +3. The human gets an explicit TUI slash-command control plane. +4. The assistant gets a first-class internal recall tool. +5. Both surfaces reuse the same core semantics and backend adapter. +6. MCP is not part of this lane. + +## Canonical Core Path + +All runtime memory retrieval in this fork should route through one shared core +path backed by `AgentmemoryAdapter`. + +Minimum shared inputs: + +- `session_id` +- `project` / `cwd` +- `query: Option` +- internal token budget + +Current relevant implementation points: + +- adapter transport and endpoint selection: + - `core/src/agentmemory/mod.rs` +- current slash-command recall implementation: + - `core/src/codex.rs` +- current TUI slash-command dispatch: + - `tui/src/chatwidget.rs` + - `tui_app_server/src/chatwidget.rs` + +Design rule: + +- do not create separate retrieval implementations for: + - slash-command recall + - assistant-facing recall tool + - startup retrieval + +Instead, create or retain one small shared core helper and have all public +surfaces call that helper. + +## Runtime Surfaces + +### Human Surface + +Keep these slash commands: + +- `/memory-recall [query]` +- `/memory-update` +- `/memory-drop` + +They remain explicit human controls. + +Required UX behavior: + +- on submit: + - show immediate local feedback in history so the UI never feels inert +- on recall success: + - show that memory was recalled + - show the recalled context itself, or a faithful preview of it + - make it clear that the context was injected into the active thread +- on recall empty result: + - show an explicit "no relevant memory context found" message +- on recall error: + - show an error event +- on update/drop success: + - show a concrete completion message, not only a vague "triggered" message +- on recall without a thread: + - show an explicit thread/session requirement message + +### Visual Memory UI + +The human-facing memory actions should render as dedicated memory UI cells, not +generic warning/info text. + +Minimum visual fields: + +- operation: + - recall + - update + - drop +- status: + - pending + - ready + - empty + - error +- query when present +- whether recalled context was injected into the current thread +- a wrapped preview body for recalled context or error detail + +Design rule: + +- do not make the human infer memory activity from generic bullets or warning + styling alone +- memory actions should be visually recognizable at a glance in the transcript + +Human-surface principle: + +- memory actions must be observable by the human, not only by the assistant. + +### Assistant Surface + +Add one first-class internal tool for recall. + +Recommended initial tool: + +- `memory_recall` + +Recommended initial parameters: + +- `query: Option` + +Recommended initial output: + +- structured output containing recalled context and whether anything was found + +Example shape: + +```json +{ + "recalled": true, + "context": "..." +} +``` + +If nothing is found: + +```json +{ + "recalled": false, + "context": "" +} +``` + +Design rule: + +- expose recall to the assistant first +- do not expose destructive memory-drop behavior to the assistant in this lane +- do not expose memory-update to the assistant unless a concrete product need + emerges later + +Rationale: + +- recall helps the assistant reason +- update is operational and low-value per turn +- drop is destructive and should remain explicit human control unless policy + changes later + +## Enablement Gates + +The assistant-facing recall tool should be exposed only when both are true: + +- `Feature::MemoryTool` is enabled +- `config.memories.backend == Agentmemory` + +Do not add a new feature flag unless rollout isolation is necessary. + +Do not gate the assistant-facing recall tool on `memories.use_memories`. + +Rationale: + +- current `agentmemory` startup behavior already special-cases + `backend == Agentmemory` +- using `use_memories` here would create inconsistent behavior between startup + retrieval and mid-session retrieval + +## Tool/Slash Semantics + +The slash command and assistant tool should share the same backend semantics: + +- same backend +- same query behavior +- same token-budget policy +- same session scoping + +They should differ only in presentation: + +- slash command: + - inject recalled context into the active conversation + - show user-visible history output +- assistant tool: + - return recalled context to the assistant as tool output + - let the assistant decide how to use it in the current turn + +This means the human and assistant surfaces are parallel views over one memory +engine, not separate systems. + +## TUI Requirements + +The TUI must treat memory commands as visible product actions, not invisible +internal mutations. + +Required behavior: + +- immediate submit acknowledgment in history +- visible completion/result message in history +- dedicated visual memory cells for submit and completion states +- no success path that only changes assistant context silently + +This applies to both: + +- `tui` +- `tui_app_server` + +The two implementations must stay behaviorally aligned. + +## Documentation Requirements + +Once the assistant-facing tool exists, documentation and runtime instructions +must stop implying a tool exists when none is callable. + +Required follow-up: + +- align developer/runtime instructions with the actual callable tool surface +- avoid telling the assistant to use "AgentMemory tools" unless such tools are + actually present in the current runtime + +## Non-Goals + +This lane does not include: + +- MCP exposure +- a second memory backend +- hidden auto-recall on arbitrary turns +- assistant-facing memory-drop +- assistant-facing memory-update by default +- reintroducing static `MEMORY.md`-style loading on top of `agentmemory` + +## Rollout Order + +1. Stabilize human-visible slash-command behavior for recall/update/drop. +2. Factor or confirm one shared core recall path backed by `AgentmemoryAdapter`. +3. Add the assistant-facing `memory_recall` internal tool. +4. Align runtime instructions and tool-surface documentation with reality. +5. Add focused tests for: + - human submit feedback + - human success/empty/error rendering + - assistant tool exposure gates + - assistant tool recall output + +## Acceptance Criteria + +The lane is done when all of the following are true: + +- a human pressing Enter on `/memory-recall` sees immediate feedback in the TUI +- a human pressing Enter on successful `/memory-recall` sees recalled context in + the TUI history +- the recall/update/drop history entries are visually distinct memory cells, not + generic warnings or info bullets +- `/memory-update` and `/memory-drop` visibly acknowledge both submission and + completion +- the assistant can call a first-class internal recall tool when memory is + enabled for `agentmemory` +- the assistant no longer has to infer memory availability from unrelated MCP + surfaces +- there is one canonical core recall path, not parallel retrieval + implementations + +## File Plan + +Expected primary files for this lane: + +- `core/src/agentmemory/mod.rs` +- `core/src/codex.rs` +- `core/src/tools/spec.rs` +- `core/src/tools/handlers/mod.rs` +- `core/src/tools/handlers/`: + add a dedicated memory-recall handler module +- `core/src/tools/spec_tests.rs` +- `tui/src/chatwidget.rs` +- `tui/src/history_cell.rs` +- `tui/src/chatwidget/tests.rs` +- `tui_app_server/src/chatwidget.rs` +- `tui_app_server/src/history_cell.rs` +- `tui_app_server/src/chatwidget/tests.rs` + +## Practical Recommendation + +Do not rush into auto-recall heuristics or extra protocols. + +Build the lane in this order: + +- make the human-visible slash-command path truthful and obvious +- expose one assistant-facing recall tool on top of the same core path +- only then evaluate whether proactive or automatic recall behavior is worth + adding + +That preserves a coherent product model: + +- one memory engine +- one core implementation +- two explicit runtime surfaces +- zero MCP dependency for this lane diff --git a/codex-rs/docs/github_actions_private_fork_spec.md b/codex-rs/docs/github_actions_private_fork_spec.md new file mode 100644 index 000000000..6a5437699 --- /dev/null +++ b/codex-rs/docs/github_actions_private_fork_spec.md @@ -0,0 +1,114 @@ +# GitHub Actions Private Fork Spec + +## Goal + +Define which GitHub Actions workflows are worth keeping in a private personal +fork of this repository and which should usually be disabled or removed. + +## Non-Goals + +- changing upstream CI policy for the canonical repo +- designing a public release process +- replacing local development checks with hosted CI + +## Workflow Inventory + +Observed workflows fall into these buckets: + +- product CI +- dependency and security checks +- release automation +- contributor governance +- issue and PR triage +- vendored upstream CI + +Examples of likely keep candidates: + +- rust-ci.yml +- bazel.yml +- cargo-deny.yml +- codex-rs/.github/workflows/cargo-audit.yml +- codespell.yml +- sdk.yml + +Examples of workflows that are usually unnecessary in a private fork: + +- cla.yml +- close-stale-contributor-prs.yml +- issue-deduplicator.yml +- issue-labeler.yml +- blob-size-policy.yml +- rust-release-argument-comment-lint.yml +- rust-release-prepare.yml +- rust-release-windows.yml +- rust-release-zsh.yml +- rust-release.yml +- rusty-v8-release.yml +- v8-canary.yml +- codex-rs/vendor/bubblewrap/.github/workflows/check.yml + +## Desired Outcomes + +1. Keep enough CI to catch regressions in code paths the fork owner actually + uses. +2. Avoid wasting Actions minutes on public-maintainer workflows that have no + value in a private repo. +3. Keep the retained workflow set easy to understand and maintain. + +## Classification + +### Keep + +- rust-ci.yml when Cargo and Rust are the main development path +- codex-rs/.github/workflows/cargo-audit.yml for vulnerability visibility +- cargo-deny.yml for dependency and license policy signal + +### Optional + +- bazel.yml if Bazel is the real source of truth for the fork +- codespell.yml if cheap docs hygiene is still useful +- sdk.yml if the SDK packages are actively used +- ci.yml if the root JS/npm package and docs packaging flow matter to the fork + +### Usually Remove Or Disable + +- contributor governance workflows +- issue triage workflows +- PR governance workflows +- release and publishing workflows +- vendored upstream workflows + +## Recommended Baselines + +### Minimal Cargo-First Fork + +- keep rust-ci.yml +- keep codex-rs/.github/workflows/cargo-audit.yml +- keep cargo-deny.yml +- optionally keep codespell.yml +- disable or remove the rest + +### Bazel-First Fork + +- keep bazel.yml +- keep codex-rs/.github/workflows/cargo-audit.yml +- keep cargo-deny.yml +- optionally keep rust-ci.yml and codespell.yml + +## Acceptance Criteria + +1. At least one real product CI lane remains enabled. +2. Security and dependency visibility remains available through cargo-audit or + an equivalent workflow. +3. CLA, stale PR, issue triage, and release workflows no longer consume Actions + runs unless the fork explicitly needs them. +4. The retained workflow set is documented in terms of why each workflow still + exists. + +## Recommendation + +For this repository as a private personal fork, the default recommendation is +to keep rust-ci.yml, codex-rs/.github/workflows/cargo-audit.yml, and +cargo-deny.yml; optionally keep bazel.yml and codespell.yml; and disable or +remove the contributor, release, issue-triage, and vendored-upstream +workflows. diff --git a/codex-rs/hooks/README.md b/codex-rs/hooks/README.md new file mode 100644 index 000000000..395a60774 --- /dev/null +++ b/codex-rs/hooks/README.md @@ -0,0 +1,157 @@ +# Codex Hooks + +Codex supports lifecycle hooks configured through `hooks.json` files discovered +from the active config layers. + +For repo-local usage, put the config in: + +```text +/.codex/hooks.json +``` + +and store any helper scripts beside it, for example: + +```text +/.codex/hooks/allium-check.mjs +``` + +This is the Codex equivalent of Claude-style repo hooks such as +`.claude/hooks/...`. + +## Scope And Discovery + +Hook configs are discovered from config folders in precedence order. + +Common locations: + +- User/global: `~/.codex/hooks.json` +- Project/repo: `/.codex/hooks.json` +- System config folder: `hooks.json` beside the system config layer + +Project-level hooks are supported because project config layers resolve to the +repo `.codex/` directory. + +## File Format + +`hooks.json` uses this shape: + +```json +{ + "hooks": { + "EventName": [ + { + "matcher": "optional-regex-or-*", + "hooks": [ + { + "type": "command", + "command": "node ./.codex/hooks/example.mjs", + "timeout": 60, + "statusMessage": "Running repo hook" + } + ] + } + ] + } +} +``` + +## Supported Events + +- `SessionStart` +- `UserPromptSubmit` +- `PreToolUse` +- `PostToolUse` +- `PostToolUseFailure` +- `PreCompact` +- `SubagentStart` +- `SubagentStop` +- `Notification` +- `TaskCompleted` +- `Stop` +- `SessionEnd` + +## Supported Hook Types + +Currently supported: + +- `command` + +Recognized but currently skipped: + +- `prompt` +- `agent` + +Also note: + +- `async: true` is parsed but not supported yet, so async hooks are skipped. +- On Windows, `hooks.json` lifecycle hooks are currently disabled. + +## Matcher Behavior + +`matcher` is mainly useful for event families that naturally carry a target, +such as tool names or session-start source names. + +Examples: + +- `PreToolUse` / `PostToolUse` / `PostToolUseFailure`: match against the tool + name, such as `Bash`, `Edit`, or `Write` +- `SessionStart`: match against startup source names +- `UserPromptSubmit` and `Stop`: matcher is ignored + +`*` means match-all. + +## Repo-Local Example + +Run an Allium validation script after edit-like tools: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write|apply_patch", + "hooks": [ + { + "type": "command", + "command": "node ./.codex/hooks/allium-check.mjs", + "timeout": 60, + "statusMessage": "Running Allium checks" + } + ] + } + ] + } +} +``` + +## Another Example + +Run a lightweight repo bootstrap check at session start: + +```json +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "./.codex/hooks/session-start-check.sh", + "timeout": 30, + "statusMessage": "Checking repo environment" + } + ] + } + ] + } +} +``` + +## Notes + +- Hook discovery is config-layer based, not `AGENTS.md` based. +- `AGENTS.md` is for instructions; `hooks.json` is for executable lifecycle + hooks. +- If multiple config layers define hooks, lower-precedence layers are loaded + first and higher-precedence layers are appended later. diff --git a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json index dc0425a75..b0c6f7f55 100644 --- a/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/post-tool-use.command.output.schema.json @@ -12,7 +12,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json index a7ff1d5f7..6aab0eb0f 100644 --- a/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/pre-tool-use.command.output.schema.json @@ -6,7 +6,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json index d79ab2a9a..b44c19e70 100644 --- a/codex-rs/hooks/schema/generated/session-start.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/session-start.command.output.schema.json @@ -6,7 +6,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json index 4f63bec89..a95f272be 100644 --- a/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json +++ b/codex-rs/hooks/schema/generated/user-prompt-submit.command.output.schema.json @@ -12,7 +12,14 @@ "enum": [ "PreToolUse", "PostToolUse", + "PostToolUseFailure", + "PreCompact", "SessionStart", + "SessionEnd", + "SubagentStart", + "SubagentStop", + "Notification", + "TaskCompleted", "UserPromptSubmit", "Stop" ], diff --git a/codex-rs/hooks/src/engine/config.rs b/codex-rs/hooks/src/engine/config.rs index 839fee825..5307226dc 100644 --- a/codex-rs/hooks/src/engine/config.rs +++ b/codex-rs/hooks/src/engine/config.rs @@ -8,16 +8,30 @@ pub(crate) struct HooksFile { #[derive(Debug, Default, Deserialize)] pub(crate) struct HookEvents { - #[serde(rename = "PreToolUse", default)] - pub pre_tool_use: Vec, - #[serde(rename = "PostToolUse", default)] - pub post_tool_use: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] pub user_prompt_submit: Vec, + #[serde(rename = "PreToolUse", default)] + pub pre_tool_use: Vec, + #[serde(rename = "PostToolUse", default)] + pub post_tool_use: Vec, + #[serde(rename = "PostToolUseFailure", default)] + pub post_tool_use_failure: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "SubagentStart", default)] + pub subagent_start: Vec, + #[serde(rename = "SubagentStop", default)] + pub subagent_stop: Vec, + #[serde(rename = "Notification", default)] + pub notification: Vec, + #[serde(rename = "TaskCompleted", default)] + pub task_completed: Vec, #[serde(rename = "Stop", default)] pub stop: Vec, + #[serde(rename = "SessionEnd", default)] + pub session_end: Vec, } #[derive(Debug, Default, Deserialize)] diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index f39eb7743..27985dbe7 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -74,9 +74,16 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - let super::config::HookEvents { pre_tool_use, post_tool_use, + post_tool_use_failure, + pre_compact, session_start, + subagent_start, + subagent_stop, + notification, + task_completed, user_prompt_submit, stop, + session_end, } = parsed.hooks; for (event_name, groups) in [ @@ -88,15 +95,43 @@ pub(crate) fn discover_handlers(config_layer_stack: Option<&ConfigLayerStack>) - codex_protocol::protocol::HookEventName::PostToolUse, post_tool_use, ), + ( + codex_protocol::protocol::HookEventName::PostToolUseFailure, + post_tool_use_failure, + ), + ( + codex_protocol::protocol::HookEventName::PreCompact, + pre_compact, + ), ( codex_protocol::protocol::HookEventName::SessionStart, session_start, ), + ( + codex_protocol::protocol::HookEventName::SubagentStart, + subagent_start, + ), + ( + codex_protocol::protocol::HookEventName::SubagentStop, + subagent_stop, + ), + ( + codex_protocol::protocol::HookEventName::Notification, + notification, + ), + ( + codex_protocol::protocol::HookEventName::TaskCompleted, + task_completed, + ), ( codex_protocol::protocol::HookEventName::UserPromptSubmit, user_prompt_submit, ), (codex_protocol::protocol::HookEventName::Stop, stop), + ( + codex_protocol::protocol::HookEventName::SessionEnd, + session_end, + ), ] { append_matcher_groups( &mut handlers, diff --git a/codex-rs/hooks/src/engine/dispatcher.rs b/codex-rs/hooks/src/engine/dispatcher.rs index 133fc4898..309005f26 100644 --- a/codex-rs/hooks/src/engine/dispatcher.rs +++ b/codex-rs/hooks/src/engine/dispatcher.rs @@ -33,7 +33,14 @@ pub(crate) fn select_handlers( .filter(|handler| match event_name { HookEventName::PreToolUse | HookEventName::PostToolUse - | HookEventName::SessionStart => { + | HookEventName::PostToolUseFailure + | HookEventName::PreCompact + | HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification + | HookEventName::TaskCompleted => { matches_matcher(handler.matcher.as_deref(), matcher_input) } HookEventName::UserPromptSubmit | HookEventName::Stop => true, @@ -107,9 +114,16 @@ pub(crate) fn completed_summary( fn scope_for_event(event_name: HookEventName) -> HookScope { match event_name { - HookEventName::SessionStart => HookScope::Thread, + HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::PreCompact + | HookEventName::TaskCompleted => HookScope::Thread, HookEventName::PreToolUse | HookEventName::PostToolUse + | HookEventName::PostToolUseFailure + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification | HookEventName::UserPromptSubmit | HookEventName::Stop => HookScope::Turn, } diff --git a/codex-rs/hooks/src/engine/mod.rs b/codex-rs/hooks/src/engine/mod.rs index f91fee24c..ded1cb05c 100644 --- a/codex-rs/hooks/src/engine/mod.rs +++ b/codex-rs/hooks/src/engine/mod.rs @@ -52,9 +52,16 @@ impl ConfiguredHandler { match self.event_name { codex_protocol::protocol::HookEventName::PreToolUse => "pre-tool-use", codex_protocol::protocol::HookEventName::PostToolUse => "post-tool-use", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "post-tool-use-failure", + codex_protocol::protocol::HookEventName::PreCompact => "pre-compact", codex_protocol::protocol::HookEventName::SessionStart => "session-start", + codex_protocol::protocol::HookEventName::SubagentStart => "subagent-start", + codex_protocol::protocol::HookEventName::SubagentStop => "subagent-stop", + codex_protocol::protocol::HookEventName::Notification => "notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "task-completed", codex_protocol::protocol::HookEventName::UserPromptSubmit => "user-prompt-submit", codex_protocol::protocol::HookEventName::Stop => "stop", + codex_protocol::protocol::HookEventName::SessionEnd => "session-end", } } } diff --git a/codex-rs/hooks/src/events/common.rs b/codex-rs/hooks/src/events/common.rs index 48fbed242..7213dbddb 100644 --- a/codex-rs/hooks/src/events/common.rs +++ b/codex-rs/hooks/src/events/common.rs @@ -74,9 +74,16 @@ pub(crate) fn matcher_pattern_for_event( matcher: Option<&str>, ) -> Option<&str> { match event_name { - HookEventName::PreToolUse | HookEventName::PostToolUse | HookEventName::SessionStart => { - matcher - } + HookEventName::PreToolUse + | HookEventName::PostToolUse + | HookEventName::PostToolUseFailure + | HookEventName::SessionStart + | HookEventName::SessionEnd + | HookEventName::PreCompact + | HookEventName::SubagentStart + | HookEventName::SubagentStop + | HookEventName::Notification + | HookEventName::TaskCompleted => matcher, HookEventName::UserPromptSubmit | HookEventName::Stop => None, } } diff --git a/codex-rs/hooks/src/events/post_tool_use.rs b/codex-rs/hooks/src/events/post_tool_use.rs index 3af9bef5e..13c365b7d 100644 --- a/codex-rs/hooks/src/events/post_tool_use.rs +++ b/codex-rs/hooks/src/events/post_tool_use.rs @@ -18,7 +18,7 @@ use crate::engine::output_parser; use crate::schema::PostToolUseCommandInput; use crate::schema::PostToolUseToolInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct PostToolUseRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/pre_tool_use.rs b/codex-rs/hooks/src/events/pre_tool_use.rs index 8366bb632..7329f2a1b 100644 --- a/codex-rs/hooks/src/events/pre_tool_use.rs +++ b/codex-rs/hooks/src/events/pre_tool_use.rs @@ -16,7 +16,7 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::PreToolUseCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct PreToolUseRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/session_start.rs b/codex-rs/hooks/src/events/session_start.rs index 6b8fcad1e..d2a025cba 100644 --- a/codex-rs/hooks/src/events/session_start.rs +++ b/codex-rs/hooks/src/events/session_start.rs @@ -16,7 +16,7 @@ use crate::engine::dispatcher; use crate::engine::output_parser; use crate::schema::SessionStartCommandInput; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, serde::Serialize)] pub enum SessionStartSource { Startup, Resume, @@ -31,7 +31,7 @@ impl SessionStartSource { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct SessionStartRequest { pub session_id: ThreadId, pub cwd: PathBuf, diff --git a/codex-rs/hooks/src/events/stop.rs b/codex-rs/hooks/src/events/stop.rs index 3d94e321c..ff12ad402 100644 --- a/codex-rs/hooks/src/events/stop.rs +++ b/codex-rs/hooks/src/events/stop.rs @@ -18,7 +18,7 @@ use crate::engine::output_parser; use crate::schema::NullableString; use crate::schema::StopCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct StopRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/events/user_prompt_submit.rs b/codex-rs/hooks/src/events/user_prompt_submit.rs index b909c183b..f4a4b5090 100644 --- a/codex-rs/hooks/src/events/user_prompt_submit.rs +++ b/codex-rs/hooks/src/events/user_prompt_submit.rs @@ -17,7 +17,7 @@ use crate::engine::output_parser; use crate::schema::NullableString; use crate::schema::UserPromptSubmitCommandInput; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize)] pub struct UserPromptSubmitRequest { pub session_id: ThreadId, pub turn_id: String, diff --git a/codex-rs/hooks/src/schema.rs b/codex-rs/hooks/src/schema.rs index b1a5017cd..06f05a113 100644 --- a/codex-rs/hooks/src/schema.rs +++ b/codex-rs/hooks/src/schema.rs @@ -71,8 +71,22 @@ pub(crate) enum HookEventNameWire { PreToolUse, #[serde(rename = "PostToolUse")] PostToolUse, + #[serde(rename = "PostToolUseFailure")] + PostToolUseFailure, + #[serde(rename = "PreCompact")] + PreCompact, #[serde(rename = "SessionStart")] SessionStart, + #[serde(rename = "SessionEnd")] + SessionEnd, + #[serde(rename = "SubagentStart")] + SubagentStart, + #[serde(rename = "SubagentStop")] + SubagentStop, + #[serde(rename = "Notification")] + Notification, + #[serde(rename = "TaskCompleted")] + TaskCompleted, #[serde(rename = "UserPromptSubmit")] UserPromptSubmit, #[serde(rename = "Stop")] diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 780a80803..8e66a97ad 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -368,6 +368,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::SkillsUpdateAvailable + | EventMsg::MemoryOperation(_) | EventMsg::UndoStarted(_) | EventMsg::UndoCompleted(_) | EventMsg::ExitedReviewMode(_) diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 36c8cdbae..9f7ebfe59 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -126,6 +126,25 @@ pub struct ImageGenerationItem { pub saved_path: Option, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +#[derive(Debug, Clone, Copy, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationStatus { + Pending, + Ready, + Empty, + Error, +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ContextCompactionItem { pub id: String, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2e67fa5d7..11296ee56 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -28,6 +28,8 @@ use crate::dynamic_tools::DynamicToolCallOutputContentItem; use crate::dynamic_tools::DynamicToolCallRequest; use crate::dynamic_tools::DynamicToolResponse; use crate::dynamic_tools::DynamicToolSpec; +use crate::items::MemoryOperationKind; +use crate::items::MemoryOperationStatus; use crate::items::TurnItem; use crate::mcp::CallToolResult; use crate::mcp::RequestId; @@ -479,6 +481,13 @@ pub enum Op { /// Trigger a single pass of the startup memory pipeline. UpdateMemories, + /// Retrieve memory context mid-session and inject as developer message. + RecallMemories { + /// Optional query string to scope the retrieval. + #[serde(default, skip_serializing_if = "Option::is_none")] + query: Option, + }, + /// Set a user-facing thread name in the persisted rollout metadata. /// This is a local-only operation handled by codex-core; it does not /// involve the model. @@ -592,6 +601,7 @@ impl Op { Self::Compact => "compact", Self::DropMemories => "drop_memories", Self::UpdateMemories => "update_memories", + Self::RecallMemories { .. } => "recall_memories", Self::SetThreadName { .. } => "set_thread_name", Self::Undo => "undo", Self::ThreadRollback { .. } => "thread_rollback", @@ -1209,6 +1219,9 @@ pub enum EventMsg { /// indicates the turn continued but the user should still be notified. Warning(WarningEvent), + /// Structured memory operation outcome for human-visible recall/update/drop actions. + MemoryOperation(MemoryOperationEvent), + /// Realtime conversation lifecycle start event. RealtimeConversationStarted(RealtimeConversationStartedEvent), @@ -1409,9 +1422,16 @@ pub enum EventMsg { pub enum HookEventName { PreToolUse, PostToolUse, + PostToolUseFailure, + PreCompact, SessionStart, + SubagentStart, + SubagentStop, + Notification, + TaskCompleted, UserPromptSubmit, Stop, + SessionEnd, } #[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] @@ -1827,6 +1847,26 @@ pub struct WarningEvent { pub message: String, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MemoryOperationSource { + Human, + Assistant, +} + +#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct MemoryOperationEvent { + pub source: MemoryOperationSource, + pub operation: MemoryOperationKind, + pub status: MemoryOperationStatus, + pub query: Option, + pub summary: String, + pub detail: Option, + pub context_injected: bool, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "snake_case")] #[ts(rename_all = "snake_case")] diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index c4b4b8c33..5e5af4460 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -105,6 +105,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::TurnAborted(_) | EventMsg::TurnStarted(_) | EventMsg::TurnComplete(_) + | EventMsg::MemoryOperation(_) | EventMsg::ImageGenerationEnd(_) => Some(EventPersistenceMode::Limited), EventMsg::ItemCompleted(event) => { // Plan items are derived from streaming tags and are not part of the diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 6d5c92d49..33757c12d 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -409,6 +409,7 @@ pub(crate) struct ChatComposer { realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, + agentmemory_enabled: bool, status_line_value: Option>, status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. @@ -448,6 +449,7 @@ impl ChatComposer { realtime_conversation_enabled: self.realtime_conversation_enabled, audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, + agentmemory_enabled: self.agentmemory_enabled, } } @@ -533,6 +535,7 @@ impl ChatComposer { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -662,6 +665,10 @@ impl ChatComposer { pub fn set_windows_degraded_sandbox_active(&mut self, enabled: bool) { self.windows_degraded_sandbox_active = enabled; } + + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.agentmemory_enabled = enabled; + } fn layout_areas(&self, area: Rect) -> [Rect; 4] { let footer_props = self.footer_props(); let footer_hint_height = self @@ -3196,6 +3203,16 @@ impl ChatComposer { } }; + let mut status_line_value = self.status_line_value.clone(); + if self.agentmemory_enabled { + if let Some(existing) = status_line_value.as_mut() { + existing.spans.push(" · ".into()); + existing.spans.push("Agentmemory".dim()); + } else { + status_line_value = Some(Line::from("Agentmemory".dim())); + } + } + FooterProps { mode, esc_backtrack_hint: self.esc_backtrack_hint, @@ -3206,7 +3223,7 @@ impl ChatComposer { is_wsl, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, - status_line_value: self.status_line_value.clone(), + status_line_value, status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), } @@ -3500,6 +3517,7 @@ impl ChatComposer { realtime_conversation_enabled, audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, + agentmemory_enabled: self.agentmemory_enabled, }, ); command_popup.on_composer_text_change(first_line.to_string()); @@ -4753,6 +4771,19 @@ mod tests { }); } + #[test] + fn footer_status_line_shows_agentmemory_indicator() { + snapshot_composer_state( + "footer_status_line_with_agentmemory_indicator", + true, + |composer| { + composer.set_status_line_enabled(true); + composer.set_status_line(Some(Line::from("Status line content".to_string()))); + composer.set_agentmemory_enabled(true); + }, + ); + } + #[test] fn footer_collapse_snapshots() { fn setup_collab_footer( diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index ef73450f3..3921de4b3 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, + pub(crate) agentmemory_enabled: bool, } impl From for slash_commands::BuiltinCommandFlags { @@ -57,6 +58,7 @@ impl From for slash_commands::BuiltinCommandFlags { realtime_conversation_enabled: value.realtime_conversation_enabled, audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, + agentmemory_enabled: value.agentmemory_enabled, } } } @@ -518,6 +520,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/collab".to_string()); @@ -541,6 +544,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/plan".to_string()); @@ -564,6 +568,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/pers".to_string()); @@ -595,6 +600,7 @@ mod tests { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/personality".to_string()); @@ -618,6 +624,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + agentmemory_enabled: false, }, ); popup.on_composer_text_change("/aud".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index eda37fe9c..f7515a0a1 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -302,6 +302,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.composer.set_agentmemory_enabled(enabled); + self.request_redraw(); + } + pub fn set_collaboration_mode_indicator( &mut self, indicator: Option, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index 54b1a8cf4..c02ff44d9 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -20,6 +20,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, + pub(crate) agentmemory_enabled: bool, } /// Return the built-ins that should be visible/usable for the current input. @@ -37,6 +38,15 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| { + flags.agentmemory_enabled + || !matches!( + *cmd, + SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall + ) + }) .collect() } @@ -71,6 +81,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: true, allow_elevate_sandbox: true, + agentmemory_enabled: true, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68497f4e0..2d731064a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -135,6 +135,8 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2217,6 +2219,55 @@ impl ChatWidget { self.request_redraw(); } + fn on_memory_operation(&mut self, event: MemoryOperationEvent) { + if self.try_complete_pending_memory_operation(&event) { + self.request_redraw(); + return; + } + self.add_to_history(history_cell::new_memory_operation_event(event)); + self.request_redraw(); + } + + fn show_pending_memory_operation(&mut self, cell: history_cell::MemoryHistoryCell) { + self.flush_active_cell(); + self.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn try_complete_pending_memory_operation(&mut self, event: &MemoryOperationEvent) -> bool { + if event.source != MemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + if !memory.is_human_pending_submission(event.operation, event.query.as_deref()) { + return false; + } + memory.apply_event(event.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + + fn ensure_memory_recall_thread(&mut self) -> bool { + if self.thread_id.is_some() { + return true; + } + + self.add_to_history(history_cell::new_memory_recall_thread_requirement()); + self.request_redraw(); + self.bottom_pane.drain_pending_submission_state(); + false + } + fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); if let McpStartupStatus::Failed { error } = &ev.status { @@ -3833,6 +3884,10 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -4037,6 +4092,10 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -4233,6 +4292,10 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -4789,11 +4852,22 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { + self.show_pending_memory_operation(history_cell::new_memory_drop_submission()); self.submit_op(Op::DropMemories); } SlashCommand::MemoryUpdate => { + self.show_pending_memory_operation(history_cell::new_memory_update_submission()); self.submit_op(Op::UpdateMemories); } + SlashCommand::MemoryRecall => { + if !self.ensure_memory_recall_thread() { + return; + } + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + /*query*/ None, + )); + self.submit_op(Op::RecallMemories { query: None }); + } SlashCommand::Mcp => { self.add_mcp_output(); } @@ -4985,6 +5059,18 @@ impl ChatWidget { }); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::MemoryRecall if !trimmed.is_empty() => { + if !self.ensure_memory_recall_thread() { + return; + } + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + Some(trimmed.to_string()), + )); + self.submit_op(Op::RecallMemories { + query: Some(trimmed.to_string()), + }); + self.bottom_pane.drain_pending_submission_state(); + } _ => self.dispatch_command(cmd), } } @@ -5500,6 +5586,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::MemoryOperation(event) => self.on_memory_operation(event), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { @@ -9641,7 +9728,14 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "PostToolUseFailure", + codex_protocol::protocol::HookEventName::PreCompact => "PreCompact", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::SessionEnd => "SessionEnd", + codex_protocol::protocol::HookEventName::SubagentStart => "SubagentStart", + codex_protocol::protocol::HookEventName::SubagentStop => "SubagentStop", + codex_protocol::protocol::HookEventName::Notification => "Notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "TaskCompleted", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index cba1b285e..c8a713ad8 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -6513,22 +6513,141 @@ async fn slash_clear_is_disabled_while_task_running() { #[tokio::test] async fn slash_memory_drop_submits_drop_memories_op() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryDrop); + assert!( + active_blob(&chat).contains("Memory Drop"), + "expected active memory drop card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); } #[tokio::test] async fn slash_memory_update_submits_update_memories_op() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(None).await; + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; chat.dispatch_command(SlashCommand::MemoryUpdate); + assert!( + active_blob(&chat).contains("Memory Update"), + "expected active memory update card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); } +#[tokio::test] +async fn slash_memory_recall_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_submits_recall_memories_op() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + assert!( + active_blob(&chat).contains("Memory Recall"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); + assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_submits_query() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert!( + active_blob(&chat).contains("Query: retrieval freshness"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); +} + #[tokio::test] async fn slash_resume_opens_picker() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; @@ -11478,6 +11597,87 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn memory_operation_event_adds_memory_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::MemoryOperation(codex_protocol::protocol::MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Assistant, + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Assistant recalled memory context for this turn.".to_string(), + detail: Some("remember this".to_string()), + context_injected: false, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one memory history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Memory Recall"), + "missing memory title: {rendered}" + ); + assert!( + rendered.contains("Query: retrieval freshness"), + "missing memory query: {rendered}" + ); + assert!( + rendered.contains("Source: assistant tool"), + "missing assistant source label: {rendered}" + ); + assert!( + rendered.contains("remember this"), + "missing memory detail: {rendered}" + ); +} + +#[tokio::test] +async fn human_memory_recall_completion_replaces_pending_card_in_place() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); + assert!( + rx.try_recv().is_err(), + "expected pending card to stay active" + ); + + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::MemoryOperation(codex_protocol::protocol::MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Human, + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single completed memory card"); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("Memory Recall Ready")); + assert!(rendered.contains("Query: retrieval freshness")); + assert!(rx.try_recv().is_err(), "expected no extra history insert"); +} + #[tokio::test] async fn status_line_invalid_items_warn_once() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 2dee1e846..0b53c5cf0 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -47,6 +47,8 @@ use codex_core::web_search::web_search_detail; use codex_otel::RuntimeMetricsSummary; use codex_protocol::account::PlanType; use codex_protocol::config_types::ServiceTier; +use codex_protocol::items::MemoryOperationKind; +use codex_protocol::items::MemoryOperationStatus; use codex_protocol::mcp::Resource; use codex_protocol::mcp::ResourceTemplate; use codex_protocol::models::WebSearchAction; @@ -58,6 +60,8 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::MemoryOperationEvent; +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -1983,6 +1987,184 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; + +#[derive(Debug)] +pub(crate) struct MemoryHistoryCell { + source: MemoryOperationSource, + operation: MemoryOperationKind, + status: MemoryOperationStatus, + query: Option, + summary: String, + detail: Option, +} + +impl MemoryHistoryCell { + fn new( + source: MemoryOperationSource, + operation: MemoryOperationKind, + status: MemoryOperationStatus, + query: Option, + summary: String, + detail: Option, + ) -> Self { + Self { + source, + operation, + status, + query, + summary, + detail: detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()), + } + } + + fn title(&self) -> &'static str { + match self.operation { + MemoryOperationKind::Recall => "Memory Recall", + MemoryOperationKind::Update => "Memory Update", + MemoryOperationKind::Drop => "Memory Drop", + } + } + + fn state_span(&self) -> Span<'static> { + match self.status { + MemoryOperationStatus::Pending => "Pending".cyan().bold(), + MemoryOperationStatus::Ready => "Ready".green().bold(), + MemoryOperationStatus::Empty => "Empty".magenta().bold(), + MemoryOperationStatus::Error => "Error".red().bold(), + } + } + + fn preview_detail(&self) -> Option { + self.detail + .as_deref() + .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) + } + + pub(crate) fn is_human_pending_submission( + &self, + operation: MemoryOperationKind, + query: Option<&str>, + ) -> bool { + self.source == MemoryOperationSource::Human + && self.status == MemoryOperationStatus::Pending + && self.operation == operation + && self.query.as_deref().map(str::trim) == query.map(str::trim) + } + + pub(crate) fn apply_event(&mut self, event: MemoryOperationEvent) { + self.source = event.source; + self.operation = event.operation; + self.status = event.status; + self.query = event.query; + self.summary = event.summary; + self.detail = event + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } +} + +impl HistoryCell for MemoryHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let wrap_width = width.max(1) as usize; + let mut lines = vec![ + vec![ + "🧠 ".into(), + self.title().bold(), + " ".into(), + self.state_span(), + ] + .into(), + ]; + + if let Some(query) = &self.query { + let query_line = Line::from(vec![" Query: ".dim(), query.clone().into()]); + let wrapped = adaptive_wrap_line(&query_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); + } + + if self.source == MemoryOperationSource::Assistant { + lines.push(Line::from(vec![" Source: ".dim(), "assistant tool".dim()])); + } + + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); + let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped_summary, &mut lines); + + if let Some(detail) = self.preview_detail() { + lines.push(Line::from(vec![" Preview:".dim()])); + let detail_line = Line::from(detail); + let wrapped_detail = adaptive_wrap_line( + &detail_line, + RtOptions::new(wrap_width) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")), + ); + push_owned_lines(&wrapped_detail, &mut lines); + } + + lines + } +} + +pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Recall, + MemoryOperationStatus::Pending, + query, + "Recalling memory context for the current thread.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Update, + MemoryOperationStatus::Pending, + /*query*/ None, + "Requesting a memory refresh.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Drop, + MemoryOperationStatus::Pending, + /*query*/ None, + "Dropping stored memories for this workspace.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Recall, + MemoryOperationStatus::Error, + /*query*/ None, + "Start a new chat or resume an existing thread before using /memory-recall.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { + MemoryHistoryCell::new( + event.source, + event.operation, + event.status, + event.query, + event.summary, + event.detail, + ) +} + /// Renders a completed (or interrupted) request_user_input exchange in history. #[derive(Debug)] pub(crate) struct RequestUserInputResultCell { @@ -2892,6 +3074,38 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn memory_recall_submission_snapshot() { + let cell = new_memory_recall_submission(Some("retrieval freshness".to_string())); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Pending + Query: retrieval freshness + Recalling memory context for the current thread. +"###); + } + + #[test] + fn memory_recall_result_snapshot() { + let cell = new_memory_operation_event(MemoryOperationEvent { + source: MemoryOperationSource::Human, + operation: MemoryOperationKind::Recall, + status: MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Ready + Query: retrieval freshness + Recalled memory context and injected it into the current thread. + Preview: + remember this +"###); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index ec624d3fb..b25578f9e 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -59,11 +59,13 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. - #[strum(serialize = "debug-m-drop")] + // Memory commands. + #[strum(serialize = "memory-drop")] MemoryDrop, - #[strum(serialize = "debug-m-update")] + #[strum(serialize = "memory-update")] MemoryUpdate, + #[strum(serialize = "memory-recall")] + MemoryRecall, } impl SlashCommand { @@ -92,8 +94,9 @@ impl SlashCommand { SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", + SlashCommand::MemoryDrop => "clear the active memory store", + SlashCommand::MemoryUpdate => "sync and consolidate memories", + SlashCommand::MemoryRecall => "recall memories and inject into context", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", @@ -133,6 +136,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot + | SlashCommand::MemoryRecall ) } @@ -156,9 +160,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Plan | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, + | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Rename @@ -168,6 +170,9 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index dbef11ebc..37b2fa9c0 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -1030,6 +1030,10 @@ fn active_turn_missing_steer_error(error: &TypedRequestError) -> bool { source.message == "no active turn to steer" } +fn thread_op_error_message(target: &str, op: &AppCommand, err: &color_eyre::Report) -> String { + format!("Failed to run {} for {target}: {err}", op.kind()) +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -2131,6 +2135,20 @@ impl App { app_server.thread_compact_start(thread_id).await?; Ok(true) } + AppCommandView::MemoryDrop => { + app_server.thread_memory_drop(thread_id).await?; + Ok(true) + } + AppCommandView::MemoryUpdate => { + app_server.thread_memory_update(thread_id).await?; + Ok(true) + } + AppCommandView::MemoryRecall { query } => { + app_server + .thread_memory_recall(thread_id, query.clone()) + .await?; + Ok(true) + } AppCommandView::SetThreadName { name } => { app_server .thread_set_name(thread_id, name.to_string()) @@ -3824,11 +3842,24 @@ impl App { return Ok(AppRunControl::Exit(ExitReason::Fatal(message))); } AppEvent::CodexOp(op) => { - self.submit_active_thread_op(app_server, op.into()).await?; + let op: AppCommand = op.into(); + if let Err(err) = self.submit_active_thread_op(app_server, op.clone()).await { + let message = thread_op_error_message("current thread", &op, &err); + tracing::warn!(error = %err, op = op.kind(), "{message}"); + self.chat_widget.add_error_message(message); + } } AppEvent::SubmitThreadOp { thread_id, op } => { - self.submit_thread_op(app_server, thread_id, op.into()) - .await?; + let op: AppCommand = op.into(); + if let Err(err) = self + .submit_thread_op(app_server, thread_id, op.clone()) + .await + { + let target = format!("thread {thread_id}"); + let message = thread_op_error_message(&target, &op, &err); + tracing::warn!(error = %err, op = op.kind(), thread_id = %thread_id, "{message}"); + self.chat_widget.add_error_message(message); + } } AppEvent::ThreadHistoryEntryResponse { thread_id, event } => { self.enqueue_thread_history_entry_response(thread_id, event) @@ -5902,6 +5933,20 @@ mod tests { ); } + #[test] + fn thread_op_error_message_mentions_op_kind_and_target() { + let message = thread_op_error_message( + "current thread", + &AppCommand::memory_recall(Some("retrieval freshness".to_string())), + &color_eyre::eyre::eyre!("connection reset by peer"), + ); + + assert_eq!( + message, + "Failed to run recall_memories for current thread: connection reset by peer" + ); + } + #[tokio::test] async fn enqueue_primary_thread_session_replays_buffered_approval_after_attach() -> Result<()> { let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 719efc177..dd647eb74 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -79,6 +79,8 @@ use codex_protocol::protocol::ItemCompletedEvent; #[cfg(test)] use codex_protocol::protocol::ItemStartedEvent; #[cfg(test)] +use codex_protocol::protocol::MemoryOperationEvent; +#[cfg(test)] use codex_protocol::protocol::PlanDeltaEvent; #[cfg(test)] use codex_protocol::protocol::RealtimeConversationClosedEvent; @@ -328,6 +330,7 @@ fn server_notification_thread_target( Some(notification.thread_id.as_str()) } ServerNotification::ItemCompleted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::MemoryOperation(notification) => Some(notification.thread_id.as_str()), ServerNotification::RawResponseItemCompleted(notification) => { Some(notification.thread_id.as_str()) } @@ -548,6 +551,51 @@ fn server_notification_thread_events( }), }], )), + ServerNotification::MemoryOperation(notification) => Some(( + ThreadId::from_string(¬ification.thread_id).ok()?, + vec![Event { + id: String::new(), + msg: EventMsg::MemoryOperation(MemoryOperationEvent { + source: match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => { + codex_protocol::protocol::MemoryOperationSource::Human + } + codex_app_server_protocol::MemoryOperationSource::Assistant => { + codex_protocol::protocol::MemoryOperationSource::Assistant + } + }, + operation: match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => { + codex_protocol::items::MemoryOperationKind::Recall + } + codex_app_server_protocol::MemoryOperationKind::Update => { + codex_protocol::items::MemoryOperationKind::Update + } + codex_app_server_protocol::MemoryOperationKind::Drop => { + codex_protocol::items::MemoryOperationKind::Drop + } + }, + status: match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + codex_protocol::items::MemoryOperationStatus::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + codex_protocol::items::MemoryOperationStatus::Ready + } + codex_app_server_protocol::MemoryOperationStatus::Empty => { + codex_protocol::items::MemoryOperationStatus::Empty + } + codex_app_server_protocol::MemoryOperationStatus::Error => { + codex_protocol::items::MemoryOperationStatus::Error + } + }, + query: notification.query, + summary: notification.summary, + detail: notification.detail, + context_injected: notification.context_injected, + }), + }], + )), ServerNotification::PlanDelta(notification) => Some(( ThreadId::from_string(¬ification.thread_id).ok()?, vec![Event { diff --git a/codex-rs/tui_app_server/src/app_command.rs b/codex-rs/tui_app_server/src/app_command.rs index e01a25027..dea7b2b48 100644 --- a/codex-rs/tui_app_server/src/app_command.rs +++ b/codex-rs/tui_app_server/src/app_command.rs @@ -95,6 +95,11 @@ pub(crate) enum AppCommandView<'a> { force_reload: bool, }, Compact, + MemoryDrop, + MemoryUpdate, + MemoryRecall { + query: &'a Option, + }, SetThreadName { name: &'a str, }, @@ -256,6 +261,18 @@ impl AppCommand { Self(Op::Compact) } + pub(crate) fn memory_drop() -> Self { + Self(Op::DropMemories) + } + + pub(crate) fn memory_update() -> Self { + Self(Op::UpdateMemories) + } + + pub(crate) fn memory_recall(query: Option) -> Self { + Self(Op::RecallMemories { query }) + } + pub(crate) fn set_thread_name(name: String) -> Self { Self(Op::SetThreadName { name }) } @@ -388,6 +405,9 @@ impl AppCommand { force_reload: *force_reload, }, Op::Compact => AppCommandView::Compact, + Op::DropMemories => AppCommandView::MemoryDrop, + Op::UpdateMemories => AppCommandView::MemoryUpdate, + Op::RecallMemories { query } => AppCommandView::MemoryRecall { query }, Op::SetThreadName { name } => AppCommandView::SetThreadName { name }, Op::Shutdown => AppCommandView::Shutdown, Op::ThreadRollback { num_turns } => AppCommandView::ThreadRollback { diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index cf325786b..3b69ee114 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -31,6 +31,12 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryDropParams; +use codex_app_server_protocol::ThreadMemoryDropResponse; +use codex_app_server_protocol::ThreadMemoryRecallParams; +use codex_app_server_protocol::ThreadMemoryRecallResponse; +use codex_app_server_protocol::ThreadMemoryUpdateParams; +use codex_app_server_protocol::ThreadMemoryUpdateResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -524,6 +530,56 @@ impl AppServerSession { Ok(()) } + pub(crate) async fn thread_memory_drop(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryDropResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryDrop { + request_id, + params: ThreadMemoryDropParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/memory/drop failed in app-server TUI")?; + Ok(()) + } + + pub(crate) async fn thread_memory_update(&mut self, thread_id: ThreadId) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryUpdateResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryUpdate { + request_id, + params: ThreadMemoryUpdateParams { + thread_id: thread_id.to_string(), + }, + }) + .await + .wrap_err("thread/memory/update failed in app-server TUI")?; + Ok(()) + } + + pub(crate) async fn thread_memory_recall( + &mut self, + thread_id: ThreadId, + query: Option, + ) -> Result<()> { + let request_id = self.next_request_id(); + let _: ThreadMemoryRecallResponse = self + .client + .request_typed(ClientRequest::ThreadMemoryRecall { + request_id, + params: ThreadMemoryRecallParams { + thread_id: thread_id.to_string(), + query, + }, + }) + .await + .wrap_err("thread/memory/recall failed in app-server TUI")?; + Ok(()) + } + pub(crate) async fn thread_shell_command( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index fea3b8c2a..e797481eb 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -406,6 +406,7 @@ pub(crate) struct ChatComposer { plugins_command_enabled: bool, fast_command_enabled: bool, personality_command_enabled: bool, + agentmemory_enabled: bool, realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, @@ -445,6 +446,7 @@ impl ChatComposer { plugins_command_enabled: self.plugins_command_enabled, fast_command_enabled: self.fast_command_enabled, personality_command_enabled: self.personality_command_enabled, + agentmemory_enabled: self.agentmemory_enabled, realtime_conversation_enabled: self.realtime_conversation_enabled, audio_device_selection_enabled: self.audio_device_selection_enabled, allow_elevate_sandbox: self.windows_degraded_sandbox_active, @@ -530,6 +532,7 @@ impl ChatComposer { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -619,6 +622,10 @@ impl ChatComposer { self.personality_command_enabled = enabled; } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.agentmemory_enabled = enabled; + } + pub fn set_realtime_conversation_enabled(&mut self, enabled: bool) { self.realtime_conversation_enabled = enabled; } @@ -3210,6 +3217,16 @@ impl ChatComposer { } }; + let mut status_line_value = self.status_line_value.clone(); + if self.agentmemory_enabled { + if let Some(existing) = status_line_value.as_mut() { + existing.spans.push(" · ".into()); + existing.spans.push("Agentmemory".dim()); + } else { + status_line_value = Some(Line::from("Agentmemory".dim())); + } + } + FooterProps { mode, esc_backtrack_hint: self.esc_backtrack_hint, @@ -3220,7 +3237,7 @@ impl ChatComposer { is_wsl, context_window_percent: self.context_window_percent, context_window_used_tokens: self.context_window_used_tokens, - status_line_value: self.status_line_value.clone(), + status_line_value, status_line_enabled: self.status_line_enabled, active_agent_label: self.active_agent_label.clone(), } @@ -3501,6 +3518,7 @@ impl ChatComposer { let plugins_command_enabled = self.plugins_command_enabled; let fast_command_enabled = self.fast_command_enabled; let personality_command_enabled = self.personality_command_enabled; + let agentmemory_enabled = self.agentmemory_enabled; let realtime_conversation_enabled = self.realtime_conversation_enabled; let audio_device_selection_enabled = self.audio_device_selection_enabled; let mut command_popup = CommandPopup::new( @@ -3511,6 +3529,7 @@ impl ChatComposer { plugins_command_enabled, fast_command_enabled, personality_command_enabled, + agentmemory_enabled, realtime_conversation_enabled, audio_device_selection_enabled, windows_degraded_sandbox_active: self.windows_degraded_sandbox_active, @@ -4768,6 +4787,19 @@ mod tests { }); } + #[test] + fn footer_status_line_shows_agentmemory_indicator() { + snapshot_composer_state( + "footer_status_line_with_agentmemory_indicator", + true, + |composer| { + composer.set_status_line_enabled(true); + composer.set_status_line(Some(Line::from("Status line content".to_string()))); + composer.set_agentmemory_enabled(true); + }, + ); + } + #[test] fn footer_collapse_snapshots() { fn setup_collab_footer( diff --git a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs index 5ad3df5c2..06c65d44e 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/command_popup.rs @@ -44,6 +44,7 @@ pub(crate) struct CommandPopupFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) windows_degraded_sandbox_active: bool, + pub(crate) agentmemory_enabled: bool, } impl From for slash_commands::BuiltinCommandFlags { @@ -57,6 +58,7 @@ impl From for slash_commands::BuiltinCommandFlags { realtime_conversation_enabled: value.realtime_conversation_enabled, audio_device_selection_enabled: value.audio_device_selection_enabled, allow_elevate_sandbox: value.windows_degraded_sandbox_active, + agentmemory_enabled: value.agentmemory_enabled, } } } @@ -516,6 +518,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -539,6 +542,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -562,6 +566,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: false, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -593,6 +598,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, @@ -616,6 +622,7 @@ mod tests { plugins_command_enabled: false, fast_command_enabled: false, personality_command_enabled: true, + agentmemory_enabled: false, realtime_conversation_enabled: true, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, diff --git a/codex-rs/tui_app_server/src/bottom_pane/mod.rs b/codex-rs/tui_app_server/src/bottom_pane/mod.rs index dd90bc11b..75abc117d 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/mod.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/mod.rs @@ -311,6 +311,11 @@ impl BottomPane { self.request_redraw(); } + pub fn set_agentmemory_enabled(&mut self, enabled: bool) { + self.composer.set_agentmemory_enabled(enabled); + self.request_redraw(); + } + pub fn set_fast_command_enabled(&mut self, enabled: bool) { self.composer.set_fast_command_enabled(enabled); self.request_redraw(); diff --git a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs index 54b1a8cf4..8751da73c 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/slash_commands.rs @@ -20,6 +20,7 @@ pub(crate) struct BuiltinCommandFlags { pub(crate) realtime_conversation_enabled: bool, pub(crate) audio_device_selection_enabled: bool, pub(crate) allow_elevate_sandbox: bool, + pub(crate) agentmemory_enabled: bool, } /// Return the built-ins that should be visible/usable for the current input. @@ -37,6 +38,15 @@ pub(crate) fn builtins_for_input(flags: BuiltinCommandFlags) -> Vec<(&'static st .filter(|(_, cmd)| flags.personality_command_enabled || *cmd != SlashCommand::Personality) .filter(|(_, cmd)| flags.realtime_conversation_enabled || *cmd != SlashCommand::Realtime) .filter(|(_, cmd)| flags.audio_device_selection_enabled || *cmd != SlashCommand::Settings) + .filter(|(_, cmd)| { + flags.agentmemory_enabled + || !matches!( + *cmd, + SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall + ) + }) .collect() } @@ -71,6 +81,7 @@ mod tests { realtime_conversation_enabled: true, audio_device_selection_enabled: true, allow_elevate_sandbox: true, + agentmemory_enabled: true, } } @@ -132,4 +143,11 @@ mod tests { flags.audio_device_selection_enabled = false; assert_eq!(find_builtin_command("settings", flags), None); } + + #[test] + fn memory_recall_is_hidden_when_agentmemory_is_disabled() { + let mut flags = all_enabled_flags(); + flags.agentmemory_enabled = false; + assert_eq!(find_builtin_command("memory-recall", flags), None); + } } diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index deb378ea2..8ad50c372 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -71,6 +71,8 @@ use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::FileChangeRequestApprovalParams; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; +use codex_app_server_protocol::MemoryOperationNotification; +use codex_app_server_protocol::MemoryOperationSource as AppServerMemoryOperationSource; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -173,6 +175,10 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +#[cfg(test)] +use codex_protocol::protocol::MemoryOperationEvent; +#[cfg(test)] +use codex_protocol::protocol::MemoryOperationSource; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitSnapshot; @@ -2692,6 +2698,107 @@ impl ChatWidget { self.request_redraw(); } + fn on_memory_operation_notification(&mut self, notification: MemoryOperationNotification) { + if self.try_complete_pending_memory_operation_notification(¬ification) { + self.request_redraw(); + return; + } + self.add_to_history(history_cell::new_memory_operation_notification( + notification, + )); + self.request_redraw(); + } + + #[cfg(test)] + fn on_memory_operation_event(&mut self, event: MemoryOperationEvent) { + if self.try_complete_pending_memory_operation_event(&event) { + self.request_redraw(); + return; + } + self.add_to_history(history_cell::new_memory_operation_event(event)); + self.request_redraw(); + } + + fn show_pending_memory_operation(&mut self, cell: history_cell::MemoryHistoryCell) { + self.flush_active_cell(); + self.active_cell = Some(Box::new(cell)); + self.bump_active_cell_revision(); + self.request_redraw(); + } + + fn try_complete_pending_memory_operation_notification( + &mut self, + notification: &MemoryOperationNotification, + ) -> bool { + if notification.source != AppServerMemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + let operation = match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => { + history_cell::MemoryOperationKind::Recall + } + codex_app_server_protocol::MemoryOperationKind::Update => { + history_cell::MemoryOperationKind::Update + } + codex_app_server_protocol::MemoryOperationKind::Drop => { + history_cell::MemoryOperationKind::Drop + } + }; + if !memory.is_human_pending_submission(operation, notification.query.as_deref()) { + return false; + } + memory.apply_notification(notification.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + + #[cfg(test)] + fn try_complete_pending_memory_operation_event( + &mut self, + event: &MemoryOperationEvent, + ) -> bool { + if event.source != MemoryOperationSource::Human { + return false; + } + let Some(active) = self.active_cell.as_mut() else { + return false; + }; + let Some(memory) = active + .as_any_mut() + .downcast_mut::() + else { + return false; + }; + let operation = match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => { + history_cell::MemoryOperationKind::Recall + } + codex_protocol::items::MemoryOperationKind::Update => { + history_cell::MemoryOperationKind::Update + } + codex_protocol::items::MemoryOperationKind::Drop => { + history_cell::MemoryOperationKind::Drop + } + }; + if !memory.is_human_pending_submission(operation, event.query.as_deref()) { + return false; + } + memory.apply_event(event.clone()); + self.bump_active_cell_revision(); + self.flush_active_cell(); + true + } + #[cfg(test)] fn on_mcp_startup_update(&mut self, ev: McpStartupUpdateEvent) { let mut status = self.mcp_startup_status.take().unwrap_or_default(); @@ -4497,6 +4604,10 @@ impl ChatWidget { widget.bottom_pane.set_voice_transcription_enabled( widget.config.features.enabled(Feature::VoiceTranscription), ); + widget.bottom_pane.set_agentmemory_enabled( + widget.config.memories.backend == codex_core::config::types::MemoryBackend::Agentmemory + && widget.config.features.enabled(Feature::MemoryTool), + ); widget .bottom_pane .set_realtime_conversation_enabled(widget.realtime_conversation_enabled()); @@ -5049,10 +5160,21 @@ impl ChatWidget { self.clean_background_terminals(); } SlashCommand::MemoryDrop => { - self.add_app_server_stub_message("Memory maintenance"); + self.show_pending_memory_operation(history_cell::new_memory_drop_submission()); + self.submit_op(AppCommand::memory_drop()); } SlashCommand::MemoryUpdate => { - self.add_app_server_stub_message("Memory maintenance"); + self.show_pending_memory_operation(history_cell::new_memory_update_submission()); + self.submit_op(AppCommand::memory_update()); + } + SlashCommand::MemoryRecall => { + if !self.ensure_memory_recall_thread() { + return; + } + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + /*query*/ None, + )); + self.submit_op(AppCommand::memory_recall(/*query*/ None)); } SlashCommand::Mcp => { self.add_mcp_output(); @@ -5222,6 +5344,22 @@ impl ChatWidget { })); self.bottom_pane.drain_pending_submission_state(); } + SlashCommand::MemoryRecall if !trimmed.is_empty() => { + if !self.ensure_memory_recall_thread() { + return; + } + let Some((prepared_args, _prepared_elements)) = self + .bottom_pane + .prepare_inline_args_submission(/*record_history*/ false) + else { + return; + }; + self.show_pending_memory_operation(history_cell::new_memory_recall_submission( + Some(trimmed.to_string()), + )); + self.submit_op(AppCommand::memory_recall(Some(prepared_args))); + self.bottom_pane.drain_pending_submission_state(); + } SlashCommand::SandboxReadRoot if !trimmed.is_empty() => { let Some((prepared_args, _prepared_elements)) = self .bottom_pane @@ -6102,6 +6240,9 @@ impl ChatWidget { ServerNotification::ItemCompleted(notification) => { self.handle_item_completed_notification(notification, replay_kind); } + ServerNotification::MemoryOperation(notification) => { + self.on_memory_operation_notification(notification); + } ServerNotification::AgentMessageDelta(notification) => { self.on_agent_message_delta(notification.delta); } @@ -6631,6 +6772,7 @@ impl ChatWidget { self.on_rate_limit_snapshot(ev.rate_limits); } EventMsg::Warning(WarningEvent { message }) => self.on_warning(message), + EventMsg::MemoryOperation(event) => self.on_memory_operation_event(event), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} EventMsg::Error(ErrorEvent { @@ -9790,9 +9932,15 @@ impl ChatWidget { self.request_redraw(); } - fn add_app_server_stub_message(&mut self, feature: &str) { - warn!(feature, "stubbed unsupported app-server TUI feature"); - self.add_error_message(format!("{feature}: {APP_SERVER_TUI_STUB_MESSAGE}")); + fn ensure_memory_recall_thread(&mut self) -> bool { + if self.thread_id.is_some() { + return true; + } + + self.add_to_history(history_cell::new_memory_recall_thread_requirement()); + self.request_redraw(); + self.bottom_pane.drain_pending_submission_state(); + false } fn rename_confirmation_cell(name: &str, thread_id: Option) -> PlainHistoryCell { @@ -10917,7 +11065,14 @@ fn hook_event_label(event_name: codex_protocol::protocol::HookEventName) -> &'st match event_name { codex_protocol::protocol::HookEventName::PreToolUse => "PreToolUse", codex_protocol::protocol::HookEventName::PostToolUse => "PostToolUse", + codex_protocol::protocol::HookEventName::PostToolUseFailure => "PostToolUseFailure", + codex_protocol::protocol::HookEventName::PreCompact => "PreCompact", codex_protocol::protocol::HookEventName::SessionStart => "SessionStart", + codex_protocol::protocol::HookEventName::SessionEnd => "SessionEnd", + codex_protocol::protocol::HookEventName::SubagentStart => "SubagentStart", + codex_protocol::protocol::HookEventName::SubagentStop => "SubagentStop", + codex_protocol::protocol::HookEventName::Notification => "Notification", + codex_protocol::protocol::HookEventName::TaskCompleted => "TaskCompleted", codex_protocol::protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_protocol::protocol::HookEventName::Stop => "Stop", } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index a74b43fa8..09272dfae 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7283,23 +7283,23 @@ async fn slash_clear_is_disabled_while_task_running() { } #[tokio::test] -async fn slash_memory_drop_reports_stubbed_feature() { +async fn slash_memory_drop_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.dispatch_command(SlashCommand::MemoryDrop); - let event = rx.try_recv().expect("expected unsupported-feature error"); - match event { - AppEvent::InsertHistoryCell(cell) => { - let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!(rendered.contains("Memory maintenance: Not available in app-server TUI yet.")); - } - other => panic!("expected InsertHistoryCell error, got {other:?}"), - } assert!( - op_rx.try_recv().is_err(), - "expected no memory op to be sent" + active_blob(&chat).contains("Memory Drop"), + "expected active memory drop card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" ); + assert_matches!(op_rx.try_recv(), Ok(Op::DropMemories)); + assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] @@ -7314,23 +7314,126 @@ async fn slash_mcp_requests_inventory_via_app_server() { } #[tokio::test] -async fn slash_memory_update_reports_stubbed_feature() { +async fn slash_memory_update_submits_core_op() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); chat.dispatch_command(SlashCommand::MemoryUpdate); - let event = rx.try_recv().expect("expected unsupported-feature error"); + assert!( + active_blob(&chat).contains("Memory Update"), + "expected active memory update card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); + assert_matches!(op_rx.try_recv(), Ok(Op::UpdateMemories)); + assert!(rx.try_recv().is_err(), "expected no stub message"); +} + +#[tokio::test] +async fn slash_memory_recall_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.dispatch_command(SlashCommand::MemoryRecall); + + let event = rx.try_recv().expect("expected missing-thread error"); match event { AppEvent::InsertHistoryCell(cell) => { let rendered = lines_to_single_string(&cell.display_lines(80)); - assert!(rendered.contains("Memory maintenance: Not available in app-server TUI yet.")); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); } other => panic!("expected InsertHistoryCell error, got {other:?}"), } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_submits_core_op() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.dispatch_command(SlashCommand::MemoryRecall); + assert!( - op_rx.try_recv().is_err(), - "expected no memory op to be sent" + active_blob(&chat).contains("Memory Recall"), + "expected active memory recall card, got {:?}", + active_blob(&chat) + ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); + assert_matches!(op_rx.try_recv(), Ok(Op::RecallMemories { query: None })); + assert!(rx.try_recv().is_err(), "expected no stub message"); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_requires_existing_thread() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let event = rx.try_recv().expect("expected missing-thread error"); + match event { + AppEvent::InsertHistoryCell(cell) => { + let rendered = lines_to_single_string(&cell.display_lines(80)); + assert!( + rendered.contains( + "Start a new chat or resume an existing thread before using /memory-recall." + ), + "expected /memory-recall thread requirement error, got {rendered:?}" + ); + } + other => panic!("expected InsertHistoryCell error, got {other:?}"), + } + assert!(op_rx.try_recv().is_err(), "expected no core op to be sent"); +} + +#[tokio::test] +async fn slash_memory_recall_with_inline_args_submits_query() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert!( + active_blob(&chat).contains("Query: retrieval freshness"), + "expected active memory recall card, got {:?}", + active_blob(&chat) ); + assert!( + rx.try_recv().is_err(), + "expected no committed history cell yet" + ); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); + assert!(rx.try_recv().is_err(), "expected no stub message"); } #[tokio::test] @@ -11338,6 +11441,99 @@ async fn app_server_guardian_review_started_sets_review_status() { ); } +#[tokio::test] +async fn app_server_memory_operation_notification_adds_memory_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_server_notification( + ServerNotification::MemoryOperation( + codex_app_server_protocol::MemoryOperationNotification { + thread_id: "thread-1".to_string(), + source: codex_app_server_protocol::MemoryOperationSource::Assistant, + operation: codex_app_server_protocol::MemoryOperationKind::Recall, + status: codex_app_server_protocol::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Assistant recalled memory context for this turn.".to_string(), + detail: Some( + "remember this".to_string(), + ), + context_injected: false, + }, + ), + None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one memory history cell"); + let rendered = lines_to_single_string(&cells[0]); + assert!( + rendered.contains("Memory Recall"), + "missing memory title: {rendered}" + ); + assert!( + rendered.contains("Query: retrieval freshness"), + "missing memory query: {rendered}" + ); + assert!( + rendered.contains("Source: assistant tool"), + "missing assistant source label: {rendered}" + ); + assert!( + rendered.contains("remember this"), + "missing memory detail: {rendered}" + ); +} + +#[tokio::test] +async fn app_server_human_memory_recall_completion_replaces_pending_card_in_place() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.bottom_pane.set_agentmemory_enabled(true); + chat.thread_id = Some(ThreadId::new()); + + chat.bottom_pane.set_composer_text( + "/memory-recall retrieval freshness".to_string(), + Vec::new(), + Vec::new(), + ); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + assert_matches!( + op_rx.try_recv(), + Ok(Op::RecallMemories { + query: Some(query) + }) if query == "retrieval freshness" + ); + assert!( + rx.try_recv().is_err(), + "expected pending card to stay active" + ); + + chat.handle_server_notification( + ServerNotification::MemoryOperation( + codex_app_server_protocol::MemoryOperationNotification { + thread_id: "thread-1".to_string(), + source: codex_app_server_protocol::MemoryOperationSource::Human, + operation: codex_app_server_protocol::MemoryOperationKind::Recall, + status: codex_app_server_protocol::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread." + .to_string(), + detail: Some( + "remember this".to_string(), + ), + context_injected: true, + }, + ), + None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected a single completed memory card"); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("Memory Recall Ready")); + assert!(rendered.contains("Query: retrieval freshness")); + assert!(rx.try_recv().is_err(), "expected no extra history insert"); +} + #[tokio::test] async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 7e37a6bf9..836a1e9df 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -40,6 +40,7 @@ use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; use base64::Engine; use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::MemoryOperationNotification; use codex_core::config::Config; use codex_core::config::types::McpServerTransportConfig; #[cfg(test)] @@ -63,6 +64,8 @@ use codex_protocol::plan_tool::UpdatePlanArgs; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::McpAuthStatus; use codex_protocol::protocol::McpInvocation; +#[cfg(test)] +use codex_protocol::protocol::MemoryOperationEvent; use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::request_user_input::RequestUserInputAnswer; use codex_protocol::request_user_input::RequestUserInputQuestion; @@ -2163,6 +2166,298 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +const MEMORY_PREVIEW_MAX_GRAPHEMES: usize = 1_200; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MemoryOperationKind { + Recall, + Update, + Drop, +} + +impl MemoryOperationKind { + fn title(self) -> &'static str { + match self { + Self::Recall => "Memory Recall", + Self::Update => "Memory Update", + Self::Drop => "Memory Drop", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryOperationState { + Pending, + Success, + Empty, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MemoryOperationSource { + Human, + Assistant, +} + +#[derive(Debug)] +pub(crate) struct MemoryHistoryCell { + source: MemoryOperationSource, + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, +} + +impl MemoryHistoryCell { + fn new( + source: MemoryOperationSource, + operation: MemoryOperationKind, + state: MemoryOperationState, + query: Option, + summary: String, + detail: Option, + ) -> Self { + Self { + source, + operation, + state, + query, + summary, + detail: detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()), + } + } + + fn state_span(&self) -> Span<'static> { + match self.state { + MemoryOperationState::Pending => "Pending".cyan().bold(), + MemoryOperationState::Success => "Ready".green().bold(), + MemoryOperationState::Empty => "Empty".magenta().bold(), + MemoryOperationState::Error => "Error".red().bold(), + } + } + + fn preview_detail(&self) -> Option { + self.detail + .as_deref() + .map(|detail| truncate_text(detail, MEMORY_PREVIEW_MAX_GRAPHEMES)) + } + + pub(crate) fn is_human_pending_submission( + &self, + operation: MemoryOperationKind, + query: Option<&str>, + ) -> bool { + self.source == MemoryOperationSource::Human + && self.state == MemoryOperationState::Pending + && self.operation == operation + && self.query.as_deref().map(str::trim) == query.map(str::trim) + } + + #[cfg(test)] + pub(crate) fn apply_event(&mut self, event: MemoryOperationEvent) { + self.source = match event.source { + codex_protocol::protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_protocol::protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }; + self.operation = match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_protocol::items::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_protocol::items::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }; + self.state = match event.status { + codex_protocol::items::MemoryOperationStatus::Pending => MemoryOperationState::Pending, + codex_protocol::items::MemoryOperationStatus::Ready => MemoryOperationState::Success, + codex_protocol::items::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_protocol::items::MemoryOperationStatus::Error => MemoryOperationState::Error, + }; + self.query = event.query; + self.summary = event.summary; + self.detail = event + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } + + pub(crate) fn apply_notification(&mut self, notification: MemoryOperationNotification) { + self.source = match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_app_server_protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }; + self.operation = match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_app_server_protocol::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_app_server_protocol::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }; + self.state = match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + MemoryOperationState::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + MemoryOperationState::Success + } + codex_app_server_protocol::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_app_server_protocol::MemoryOperationStatus::Error => MemoryOperationState::Error, + }; + self.query = notification.query; + self.summary = notification.summary; + self.detail = notification + .detail + .map(|detail| detail.trim().to_string()) + .filter(|detail| !detail.is_empty()); + } +} + +impl HistoryCell for MemoryHistoryCell { + fn display_lines(&self, width: u16) -> Vec> { + let wrap_width = width.max(1) as usize; + let mut lines = vec![ + vec![ + "🧠 ".into(), + self.operation.title().bold(), + " ".into(), + self.state_span(), + ] + .into(), + ]; + + if let Some(query) = &self.query { + let query_line = Line::from(vec![" Query: ".dim(), query.clone().into()]); + let wrapped = adaptive_wrap_line(&query_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped, &mut lines); + } + + if self.source == MemoryOperationSource::Assistant { + lines.push(Line::from(vec![" Source: ".dim(), "assistant tool".dim()])); + } + + let summary_line = Line::from(vec![" ".into(), self.summary.clone().into()]); + let wrapped_summary = adaptive_wrap_line(&summary_line, RtOptions::new(wrap_width)); + push_owned_lines(&wrapped_summary, &mut lines); + + if let Some(detail) = self.preview_detail() { + lines.push(Line::from(vec![" Preview:".dim()])); + let detail_line = Line::from(detail); + let wrapped_detail = adaptive_wrap_line( + &detail_line, + RtOptions::new(wrap_width) + .initial_indent(Line::from(" ")) + .subsequent_indent(Line::from(" ")), + ); + push_owned_lines(&wrapped_detail, &mut lines); + } + + lines + } +} + +pub(crate) fn new_memory_recall_submission(query: Option) -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Recall, + MemoryOperationState::Pending, + query, + "Recalling memory context for the current thread.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_update_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Update, + MemoryOperationState::Pending, + /*query*/ None, + "Requesting a memory refresh.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_drop_submission() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Drop, + MemoryOperationState::Pending, + /*query*/ None, + "Dropping stored memories for this workspace.".to_string(), + /*detail*/ None, + ) +} + +pub(crate) fn new_memory_recall_thread_requirement() -> MemoryHistoryCell { + MemoryHistoryCell::new( + MemoryOperationSource::Human, + MemoryOperationKind::Recall, + MemoryOperationState::Error, + /*query*/ None, + "Start a new chat or resume an existing thread before using /memory-recall.".to_string(), + /*detail*/ None, + ) +} + +#[cfg(test)] +pub(crate) fn new_memory_operation_event(event: MemoryOperationEvent) -> MemoryHistoryCell { + MemoryHistoryCell::new( + match event.source { + codex_protocol::protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_protocol::protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }, + match event.operation { + codex_protocol::items::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_protocol::items::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_protocol::items::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }, + match event.status { + codex_protocol::items::MemoryOperationStatus::Pending => MemoryOperationState::Pending, + codex_protocol::items::MemoryOperationStatus::Ready => MemoryOperationState::Success, + codex_protocol::items::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_protocol::items::MemoryOperationStatus::Error => MemoryOperationState::Error, + }, + event.query, + event.summary, + event.detail, + ) +} + +pub(crate) fn new_memory_operation_notification( + notification: MemoryOperationNotification, +) -> MemoryHistoryCell { + MemoryHistoryCell::new( + match notification.source { + codex_app_server_protocol::MemoryOperationSource::Human => MemoryOperationSource::Human, + codex_app_server_protocol::MemoryOperationSource::Assistant => { + MemoryOperationSource::Assistant + } + }, + match notification.operation { + codex_app_server_protocol::MemoryOperationKind::Recall => MemoryOperationKind::Recall, + codex_app_server_protocol::MemoryOperationKind::Update => MemoryOperationKind::Update, + codex_app_server_protocol::MemoryOperationKind::Drop => MemoryOperationKind::Drop, + }, + match notification.status { + codex_app_server_protocol::MemoryOperationStatus::Pending => { + MemoryOperationState::Pending + } + codex_app_server_protocol::MemoryOperationStatus::Ready => { + MemoryOperationState::Success + } + codex_app_server_protocol::MemoryOperationStatus::Empty => MemoryOperationState::Empty, + codex_app_server_protocol::MemoryOperationStatus::Error => MemoryOperationState::Error, + }, + notification.query, + notification.summary, + notification.detail, + ) +} + /// A transient history cell that shows an animated spinner while the MCP /// inventory RPC is in flight. /// @@ -3121,6 +3416,38 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn memory_recall_submission_snapshot() { + let cell = new_memory_recall_submission(Some("retrieval freshness".to_string())); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Pending + Query: retrieval freshness + Recalling memory context for the current thread. +"###); + } + + #[test] + fn memory_recall_result_snapshot() { + let cell = new_memory_operation_event(MemoryOperationEvent { + source: codex_protocol::protocol::MemoryOperationSource::Human, + operation: codex_protocol::items::MemoryOperationKind::Recall, + status: codex_protocol::items::MemoryOperationStatus::Ready, + query: Some("retrieval freshness".to_string()), + summary: "Recalled memory context and injected it into the current thread.".to_string(), + detail: Some("remember this".to_string()), + context_injected: true, + }); + let rendered = render_lines(&cell.display_lines(80)).join("\n"); + insta::assert_snapshot!(rendered, @r###" +🧠 Memory Recall Ready + Query: retrieval freshness + Recalled memory context and injected it into the current thread. + Preview: + remember this +"###); + } + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; diff --git a/codex-rs/tui_app_server/src/slash_command.rs b/codex-rs/tui_app_server/src/slash_command.rs index 228120400..89a234737 100644 --- a/codex-rs/tui_app_server/src/slash_command.rs +++ b/codex-rs/tui_app_server/src/slash_command.rs @@ -58,11 +58,13 @@ pub enum SlashCommand { TestApproval, #[strum(serialize = "subagents")] MultiAgents, - // Debugging commands. - #[strum(serialize = "debug-m-drop")] + // Memory commands. + #[strum(serialize = "memory-drop")] MemoryDrop, - #[strum(serialize = "debug-m-update")] + #[strum(serialize = "memory-update")] MemoryUpdate, + #[strum(serialize = "memory-recall")] + MemoryRecall, } impl SlashCommand { @@ -90,8 +92,9 @@ impl SlashCommand { SlashCommand::Theme => "choose a syntax highlighting theme", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", - SlashCommand::MemoryDrop => "DO NOT USE", - SlashCommand::MemoryUpdate => "DO NOT USE", + SlashCommand::MemoryDrop => "clear the active memory store", + SlashCommand::MemoryUpdate => "sync and consolidate memories", + SlashCommand::MemoryRecall => "recall memories and inject into context", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Fast => "toggle Fast mode to enable fastest inference at 2X plan usage", SlashCommand::Personality => "choose a communication style for Codex", @@ -131,6 +134,7 @@ impl SlashCommand { | SlashCommand::Plan | SlashCommand::Fast | SlashCommand::SandboxReadRoot + | SlashCommand::MemoryRecall ) } @@ -154,9 +158,7 @@ impl SlashCommand { | SlashCommand::Review | SlashCommand::Plan | SlashCommand::Clear - | SlashCommand::Logout - | SlashCommand::MemoryDrop - | SlashCommand::MemoryUpdate => false, + | SlashCommand::Logout => false, SlashCommand::Diff | SlashCommand::Copy | SlashCommand::Rename @@ -166,6 +168,9 @@ impl SlashCommand { | SlashCommand::DebugConfig | SlashCommand::Ps | SlashCommand::Stop + | SlashCommand::MemoryDrop + | SlashCommand::MemoryUpdate + | SlashCommand::MemoryRecall | SlashCommand::Mcp | SlashCommand::Apps | SlashCommand::Plugins diff --git a/codex-rs/vendor/bubblewrap/.github/workflows/check.yml b/codex-rs/vendor/bubblewrap/.github/workflows/check.yml deleted file mode 100644 index 8a747d52c..000000000 --- a/codex-rs/vendor/bubblewrap/.github/workflows/check.yml +++ /dev/null @@ -1,105 +0,0 @@ -name: CI checks - -on: - push: - branches: - - main - pull_request: - branches: - - main - -jobs: - meson: - name: Build with Meson and gcc, and test - runs-on: ubuntu-latest - steps: - - name: Check out - uses: actions/checkout@v4 - - name: Install build-dependencies - run: sudo ./ci/builddeps.sh - - name: Enable user namespaces - run: sudo ./ci/enable-userns.sh - - name: Create logs dir - run: mkdir test-logs - - name: setup - run: | - meson _build - env: - CFLAGS: >- - -O2 - -Wp,-D_FORTIFY_SOURCE=2 - -fsanitize=address - -fsanitize=undefined - - name: compile - run: ninja -C _build -v - - name: smoke-test - run: | - set -x - ./_build/bwrap --bind / / --tmpfs /tmp true - env: - ASAN_OPTIONS: detect_leaks=0 - - name: test - run: | - BWRAP_MUST_WORK=1 meson test -C _build - env: - ASAN_OPTIONS: detect_leaks=0 - - name: Collect overall test logs on failure - if: failure() - run: mv _build/meson-logs/testlog.txt test-logs/ || true - - name: install - run: | - DESTDIR="$(pwd)/DESTDIR" meson install -C _build - ( cd DESTDIR && find -ls ) - - name: dist - run: | - BWRAP_MUST_WORK=1 meson dist -C _build - - name: Collect dist test logs on failure - if: failure() - run: mv _build/meson-private/dist-build/meson-logs/testlog.txt test-logs/disttestlog.txt || true - - name: use as subproject - run: | - mkdir tests/use-as-subproject/subprojects - tar -C tests/use-as-subproject/subprojects -xf _build/meson-dist/bubblewrap-*.tar.xz - mv tests/use-as-subproject/subprojects/bubblewrap-* tests/use-as-subproject/subprojects/bubblewrap - ( cd tests/use-as-subproject && meson _build ) - ninja -C tests/use-as-subproject/_build -v - meson test -C tests/use-as-subproject/_build - DESTDIR="$(pwd)/DESTDIR-as-subproject" meson install -C tests/use-as-subproject/_build - ( cd DESTDIR-as-subproject && find -ls ) - test -x DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap - test ! -e DESTDIR-as-subproject/usr/local/bin/bwrap - test ! -e DESTDIR-as-subproject/usr/local/libexec/bwrap - tests/use-as-subproject/assert-correct-rpath.py DESTDIR-as-subproject/usr/local/libexec/not-flatpak-bwrap - - name: Upload test logs - uses: actions/upload-artifact@v4 - if: failure() || cancelled() - with: - name: test logs - path: test-logs - - clang: - name: Build with clang and analyze - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - language: - - cpp - steps: - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - name: Check out - uses: actions/checkout@v4 - - name: Install build-dependencies - run: sudo ./ci/builddeps.sh --clang - - run: meson build -Dselinux=enabled - env: - CC: clang - CFLAGS: >- - -O2 - -Werror=unused-variable - - run: meson compile -C build - - name: CodeQL analysis - uses: github/codeql-action/analyze@v2 diff --git a/docs/agentmemory-codex-memory-replacement-spec.md b/docs/agentmemory-codex-memory-replacement-spec.md new file mode 100644 index 000000000..e9c8c5e64 --- /dev/null +++ b/docs/agentmemory-codex-memory-replacement-spec.md @@ -0,0 +1,1196 @@ +# agentmemory replacement spec for Codex native memory + +**Status: Implemented** +The integration of `agentmemory` has been completed across the Codex codebase. The native memory backend has been bypassed when the `agentmemory` backend is configured, and all lifecycle events, high-signal tool payloads, and memory operations (Update, Drop) have been routed to the `agentmemory` adapter. + +This document evaluates whether a forked Codex should disable the current +first-party memory system and replace it with +`~/Projects/agentmemory` as the primary memory engine. + +It is intended to be the canonical decision and implementation handoff for this +specific question: + +- is `agentmemory` materially more capable than Codex native memory, +- is it likely more token-efficient over time, +- if so, what would be lost by replacing Codex native memory, +- what replacement shape is worth building in a fork. + +This is an architecture and product-integration spec, not a request to +implement the replacement immediately. + +## Executive summary + +`agentmemory` is materially more advanced than Codex native memory as a +capture and retrieval engine. + +The strongest deltas are: + +- broader lifecycle capture through a wider hook surface, +- hybrid retrieval (BM25 + vector + graph), +- pluggable embeddings including Gemini, +- cross-agent MCP and REST exposure, +- retrieval bounded by top-K / token-budgeted context instead of relying on a + prebuilt local memory summary alone. + +Codex native memory is still stronger in first-party runtime integration: + +- startup memory generation is built into the core session lifecycle, +- memory artifacts are deeply integrated into prompt construction, +- memory citations already flow through protocol and app-server surfaces, +- there are explicit local operations for memory refresh and memory removal, +- native memory state includes thread-level `memory_mode` semantics such as + `disabled` and `polluted`. + +Conclusion: + +- `agentmemory` is a material capability superset for memory retrieval and + capture quality. +- It is not a strict end-to-end product superset of Codex native memory. +- A replacement is defensible, but only if the fork rebuilds a thin Codex + integration layer for the native semantics that matter. + +Recommended direction: + +- do not pursue "full Claude parity first", +- do pursue "agentmemory as the primary memory engine with Codex-specific + shims", +- disable Codex native memory generation only after startup injection, + replacement memory ops, and a clear citation story are decided. + +## Target end state + +The target end state is not "agentmemory instead of Codex" in a narrow sense. +The target end state is: + +- \`agentmemory\` is the primary memory engine, +- Codex-native memory generation and consolidation are disabled, +- Codex retains or rebuilds only the product-level semantics that still add + value, +- the fork uses the full \`agentmemory\` retrieval stack in steady state: + BM25 + vector + graph, +- embeddings are enabled by default in steady state; BM25-only mode is a + fallback, not the target architecture, +- lifecycle capture uses the widest useful hook surface rather than the minimum + viable subset, +- the fork presents one coherent memory system to users, +- the resulting system is a functional superset of both: + - \`agentmemory\` capture and retrieval strengths, + - Codex-native prompt/runtime/protocol integration where it materially helps. + +In other words, the desired architecture is a Venn-diagram merge with one +authoritative engine, not permanent coexistence of two competing memory stacks. + +## Maximum-performance policy + +The intended end state should maximize \`agentmemory\`, not merely adopt it. + +That means: + +- use hybrid retrieval as the primary retrieval path, +- enable embeddings by default in the intended production configuration, +- preserve graph retrieval and relation-aware retrieval as first-class + capabilities, +- use progressive disclosure and token budgets instead of large static memory + injections wherever possible, +- implement enough hook coverage that the observation stream is rich rather + than sparse, +- treat BM25-only mode as an acceptable degraded mode, not as the target. + +Provider policy: + +- support all current \`agentmemory\` embedding providers, +- keep Gemini embeddings available as a first-class provider, +- prefer the best available embedding backend for the environment rather than + hardcoding a low-capability default in the architecture, +- avoid designing the replacement around a no-embeddings baseline. + +## Scope + +This spec compares: + +- `/private/tmp/codex` +- `/Users/ericjuta/Projects/agentmemory` + +This spec is based on the current implementation shape in those checkouts, +including user-local plugin and hook configuration files present in the +`agentmemory` repo. + +## Read order + +Read these sources in order if implementing against this spec: + +1. `docs/agentmemory-codex-memory-replacement-spec.md` +2. `docs/claude-code-hooks-parity.md` +3. `codex-rs/core/src/memories/README.md` +4. `codex-rs/core/src/memories/prompts.rs` +5. `codex-rs/core/templates/memories/read_path.md` +6. `codex-rs/core/src/codex.rs` +7. `codex-rs/hooks/src/engine/config.rs` +8. `codex-rs/hooks/src/engine/discovery.rs` +9. `plugin/hooks/hooks.json` in `agentmemory` +10. `src/hooks/*.ts` in `agentmemory` +11. `src/providers/embedding/*.ts` and `src/state/hybrid-search.ts` in + `agentmemory` +12. `README.md` and `benchmark/*.md` in `agentmemory` + +## Current source snapshot + +### Codex + +Codex currently has: + +- a first-party startup memory pipeline in + `codex-rs/core/src/memories/README.md`, +- phase-1 extraction and phase-2 consolidation into `MEMORY.md`, + `memory_summary.md`, and rollout summary artifacts, +- developer-prompt injection of memory read-path instructions built from + `memory_summary.md`, +- protocol-level memory citations, +- memory-management operations such as `UpdateMemories` and + `DropMemories`, +- thread-level memory-mode state such as `disabled` and `polluted`, +- an under-development `codex_hooks` feature with five public hook events. + +### agentmemory + +The `agentmemory` checkout currently contains: + +- a plugin manifest in `plugin/plugin.json`, +- a Claude-oriented hook bundle in `plugin/hooks/hooks.json`, +- TypeScript hook entrypoints under `src/hooks/`, +- multiple embedding providers under `src/providers/embedding/`, +- hybrid retrieval under `src/state/hybrid-search.ts`, +- REST and MCP surfaces, +- benchmarking and retrieval claims in `README.md` and `benchmark/`. + +The local `agentmemory` checkout is currently dirty. This matters only as a +reminder not to treat the local repo state as release-tagged truth; the source +shape is still adequate for architectural comparison. + +### Current env alignment + +The live worker configuration is not sourced from +`~/Projects/agentmemory/.env`. In this checkout, `docker-compose.yml` points +the worker at: + +- `\${HOME}/.agentmemory/.env` + +Current externally loaded env alignment, verified in redacted form: + +- `GEMINI_API_KEY` is present, +- `GEMINI_MODEL` is present, +- `GEMINI_EMBEDDING_MODEL` is present, +- `GEMINI_EMBEDDING_DIMENSIONS` is present, +- `GRAPH_EXTRACTION_ENABLED` is present, +- `CONSOLIDATION_ENABLED` is present. + +Implications: + +- the current live environment already aligns with Gemini-first provider + selection, +- embedding auto-detection should resolve to Gemini unless explicitly + overridden, +- graph extraction and consolidation are already enabled in the current + external env, +- the current external env does not explicitly pin `EMBEDDING_PROVIDER`, + `TOKEN_BUDGET`, `BM25_WEIGHT`, `VECTOR_WEIGHT`, or + `FALLBACK_PROVIDERS`, so those currently rely on code defaults rather than + explicit ops policy. + +For a maximum-performance steady state, that last point should be treated as a +configuration gap, not as the desired final setup. + +## Codex native memory: what it is + +Codex native memory is a core-managed memory pipeline, not just a retrieval +plugin. + +### Pipeline shape + +Codex native memory runs in two phases: + +1. Phase 1 extracts structured memory from eligible rollouts and stores + stage-1 outputs in the state DB. +2. Phase 2 consolidates those stage-1 outputs into durable memory artifacts on + disk and spawns an internal consolidation subagent. + +This is documented in `codex-rs/core/src/memories/README.md`. + +### Prompt integration + +Codex adds memory usage instructions directly into developer instructions when: + +- the memory feature is enabled, +- `config.memories.use_memories` is true, +- memory summary content exists. + +This is wired in `codex-rs/core/src/codex.rs` via +`build_memory_tool_developer_instructions(...)`. + +### Artifact model + +Codex memory produces and maintains: + +- `memory_summary.md` +- `MEMORY.md` +- `raw_memories.md` +- `rollout_summaries/*` +- optional `skills/*` + +These artifacts are not just storage. They are part of how Codex routes future +memory reads and citations. + +### Operational integration + +Codex exposes native memory operations: + +- `UpdateMemories` +- `DropMemories` + +and memory-state controls: + +- `generate_memories` +- `use_memories` +- `no_memories_if_mcp_or_web_search` + +Codex also tracks thread memory-mode transitions such as `polluted`. + +### Citation integration + +Codex has protocol and app-server support for structured memory citations. +Those citations are already part of assistant-message rendering and transport. + +## agentmemory: what it is + +`agentmemory` is not just a memory file or summary generator. It is a +capture, indexing, retrieval, consolidation, MCP, and REST system. + +### Capture model + +The working Claude-oriented setup uses 12 hooks: + +- `SessionStart` +- `UserPromptSubmit` +- `PreToolUse` +- `PostToolUse` +- `PostToolUseFailure` +- `PreCompact` +- `SubagentStart` +- `SubagentStop` +- `Notification` +- `TaskCompleted` +- `Stop` +- `SessionEnd` + +The hook bundle is defined in `plugin/hooks/hooks.json`. + +### Observation flow + +The core runtime pattern is: + +- hooks send observations to REST endpoints, +- observations are deduplicated and privacy-filtered, +- observations are compressed and indexed, +- retrieval returns bounded context back into future sessions. + +The important thing is that capture happens at many lifecycle points, not just +after a Codex-style rollout completes. + +### Retrieval model + +agentmemory uses: + +- BM25, +- vector retrieval, +- graph retrieval, +- Reciprocal Rank Fusion, +- session diversification, +- progressive disclosure. + +This is a genuine retrieval stack, not just a durable handbook. + +### Embeddings + +agentmemory supports multiple embedding providers, including: + +- local embeddings, +- Gemini embeddings, +- OpenAI embeddings, +- Voyage, +- Cohere, +- OpenRouter. + +Gemini embedding support is real in this checkout, not hypothetical. + +### Cross-agent model + +agentmemory is designed as a shared external service: + +- Claude hooks can write to it, +- MCP clients can query it, +- REST clients can integrate with it, +- multiple agent products can share one instance. + +This is a major design difference from Codex native memory. + +## Capability comparison + +### Capture breadth + +Codex native memory: + +- captures memory from rollouts selected by startup pipeline rules, +- is optimized around per-rollout extraction and later consolidation, +- does not expose comparable public lifecycle capture breadth in the current + hook surface. + +agentmemory: + +- captures at many lifecycle points, +- can record prompts, tool usage, failures, compaction moments, and subagent + lifecycle events, +- better matches the event stream of real coding work. + +Verdict: + +- `agentmemory` is materially stronger. + +### Retrieval quality + +Codex native memory: + +- primarily relies on generated memory artifacts, +- injects a read-path and memory summary into the prompt, +- does not show comparable semantic retrieval, vector search, BM25 ranking, or + graph traversal in the native memory path from the current source scan. + +agentmemory: + +- provides hybrid search, +- supports embeddings, +- supports graph-aware retrieval, +- uses token-bounded context assembly. + +Verdict: + +- `agentmemory` is materially stronger. + +### Consolidation sophistication + +Codex native memory: + +- has a robust two-phase extraction and consolidation pipeline, +- uses a dedicated consolidation subagent, +- maintains curated memory artifacts intended for future prompt routing. + +agentmemory: + +- claims 4-tier consolidation and memory evolution, +- versioning, semantic/procedural layers, relation graphs, and cascading + staleness. + +Verdict: + +- `agentmemory` is likely more ambitious and broader, +- Codex native memory is more tightly integrated and operationally simpler + inside Codex. + +### First-party runtime integration + +Codex native memory: + +- is already first-party, +- already has prompt integration, +- already has memory commands, +- already has citations, +- already participates in internal policy/state flows. + +agentmemory: + +- does not automatically provide those Codex-native product behaviors, +- would need a Codex-specific bridge layer to replace them cleanly. + +Verdict: + +- Codex native memory is stronger here. + +### Cross-agent reuse + +Codex native memory: + +- is local to Codex runtime and artifacts. + +agentmemory: + +- is designed for multi-agent reuse through MCP and REST. + +Verdict: + +- `agentmemory` is materially stronger. + +## Is agentmemory a material superset? + +### Yes, in these senses + +agentmemory is a material superset of Codex native memory for: + +- retrieval breadth, +- semantic search, +- embedding-backed lookup, +- graph-backed lookup, +- cross-agent sharing, +- hook-based observation capture. + +### No, in these senses + +agentmemory is not a strict product-level superset of Codex native memory for: + +- first-party startup prompt integration, +- native memory operations (`UpdateMemories`, `DropMemories`), +- native memory citation protocol plumbing, +- thread-level memory-mode semantics such as `polluted`, +- deep alignment with Codex’s state DB and app-server/TUI surfaces. + +The correct judgment is: + +- `agentmemory` is a material capability superset for retrieval and capture, +- not a strict end-to-end replacement unless shims are added. + +The desired fork outcome therefore is: + +- replace Codex native memory internals, +- preserve or rebuild the useful Codex-native user-facing semantics as shims, +- end with a product-level superset even though `agentmemory` alone is not a + strict superset today. + +## Token efficiency + +This is the strongest practical argument in favor of `agentmemory`. + +### Strong evidence in favor of agentmemory + +The `agentmemory` repo explicitly claims and benchmarks token savings: + +- `~1,900` tokens instead of loading all memory into context in + `README.md`, +- `92%` savings in `benchmark/REAL-EMBEDDINGS.md`, +- `86%` savings in `benchmark/QUALITY.md`, +- essentially corpus-size-stable top-K retrieval in `benchmark/SCALE.md`. + +The architectural reason is coherent: + +- retrieval returns top-K results, +- context assembly is bounded, +- compact result-first progressive disclosure reduces unnecessary expansion. + +### Codex native memory token profile + +Codex native memory is not obviously awful on tokens, but it is shaped +differently: + +- `memory_summary.md` injection is truncated to `5,000` tokens in + `codex-rs/core/src/memories/mod.rs`, +- stage-1 rollout processing can consume large inputs because it is an offline + extraction pipeline, not a lightweight query-time retrieval layer, +- the memory read-path instructs the model to query local memory artifacts + rather than receiving a purpose-built top-K retrieval result from a hybrid + search engine. + +### Apples-to-oranges caution + +The token comparison is not perfectly head-to-head. + +agentmemory benchmarks compare against "load everything into context" and +built-in-memory patterns such as monolithic `CLAUDE.md`-style memory files. +Codex native memory is more curated than that: + +- it injects a bounded `memory_summary.md`, +- it exposes a read-path for progressive on-disk lookup, +- it does not appear to simply dump all historical memory into every turn. + +So it would be wrong to claim the benchmark proves "agentmemory is 92% more +token-efficient than Codex native memory" as a verified current fact. + +### Bottom-line token judgment + +Even with that caveat, `agentmemory` is still likely more token-efficient over +the long term than Codex native memory for large corpora because: + +- query-time retrieval is explicitly bounded, +- corpus growth does not force proportional prompt growth, +- embedding + hybrid retrieval reduces the need to over-inject summaries "just + in case", +- progressive disclosure lets the system fetch more only when needed. + +Codex native memory likely remains acceptable for modest corpus sizes, but it +does not appear to have the same query-time retrieval efficiency model. + +## Replacement architecture + +### Option 1: hard replacement + +Disable Codex native memory generation and injection entirely. Make +`agentmemory` the only memory engine. + +Benefits: + +- cleaner mental model, +- no duplicate memory systems, +- retrieval quality and token efficiency become `agentmemory`-driven, +- cross-agent memory reuse becomes first-class. + +Costs: + +- must rebuild startup prompt integration, +- must replace or remove `UpdateMemories` and `DropMemories`, +- must decide what to do about native memory citations, +- must replace or drop `polluted`/thread memory-mode semantics, +- must extend Codex hooks enough to make capture quality fully competitive with + the `agentmemory` model rather than merely acceptable. + +Risk: + +- highest. + +## Native Codex behaviors that replacement must preserve or intentionally drop + +### Must preserve or replace + +- startup injection into developer instructions, +- user-facing operations to refresh or clear memory state, +- some citation strategy if memory provenance is important, +- protocol/app-server awareness of whatever replaces native memory, +- a clear policy for memory invalidation / pollution. + +### Safe to drop if explicitly accepted + +- on-disk `MEMORY.md` / `memory_summary.md` artifact format compatibility, +- the exact current phase-1 / phase-2 internal implementation, +- native Codex consolidation subagent if `agentmemory` becomes authoritative, +- native artifact grooming and rollout summary persistence if the fork no longer + treats those as the canonical memory store. + +## Key risks + +### Duplicate system ambiguity + +If both systems remain partially active, it becomes unclear: + +- which system is authoritative, +- which one should inject context, +- which one should be cited, +- which one should be cleared by a user-facing "drop memories" action. + +Avoid this. + +### Hook-surface insufficiency + +Current Codex hooks are not enough to reproduce Claude-style `agentmemory` +capture quality: + +- only five public events, +- sync command handlers only, +- narrower tool coverage, +- missing public equivalents for several useful lifecycle events. + +If the fork does not extend hooks, the replacement will still leave value on +the table. + +### Protocol and UX regressions + +Dropping native Codex memory without replacing its protocol-level behaviors can +regress: + +- assistant memory citations, +- memory-management commands, +- app-server/TUI expectations around memory-aware behavior. + +### Benchmark over-claiming + +Do not claim: + +- that the `agentmemory` benchmarks directly prove a specific percentage gain + over Codex native memory, +- or that Gemini embeddings alone guarantee better results. + +The right claim is narrower: + +- `agentmemory` has a more scalable retrieval architecture and published token + savings versus all-in-context memory loading approaches, +- and that architecture is likely better long-term than Codex native memory for + large memory corpora. + +### Performance-oriented token policy + +The intended architecture should optimize for query-time token efficiency, not +artifact compatibility. + +That means: + +- prefer top-K retrieval over broad handbook injection, +- keep startup context bounded and relevance-ranked, +- expand details only on demand, +- avoid recreating a large static `MEMORY.md`-style injection layer on top of + `agentmemory`, +- measure steady-state tokens/query as a first-class success metric. + +## Recommendation + +Target hard replacement as the end state. + +That means: + +1. make `agentmemory` the sole authoritative memory engine, +2. disable Codex native memory generation and consolidation in the final + architecture, +3. rebuild only the Codex-native product semantics worth preserving as shims on + top of `agentmemory`, +4. remove or deprecate native Codex memory artifacts and workflows in the fork + once those shims exist. + +This is the recommended path because it matches the explicit desired outcome: + +- one memory authority, +- no split-brain behavior, +- `agentmemory` for the stronger retrieval and capture substrate, +- Codex integration retained only where it improves the product. + +The fork can still phase the work, but every phase should point toward hard +replacement rather than toward permanent coexistence. + +## Recommended implementation phases + +### Phase 1: decision and contract + +- Decide that `agentmemory` is the primary memory authority. +- Freeze which native Codex behaviors will be preserved. +- Define how startup context injection will work in the fork. +- Decide whether native memory citations remain required. +- Define the end-state explicitly as a functional superset, not a partial port. + +### Phase 2: Codex integration adapter + +- Add a Codex-specific `agentmemory` integration layer. +- Replace startup memory prompt generation with `agentmemory` retrieval. +- Add equivalent user-facing operations for refresh and clear. +- Decide whether these call into `agentmemory` REST/MCP or a local adapter. +- Route startup injection through the bounded `agentmemory` retrieval path + rather than recreating Codex-native memory artifact loading. +- Make token budget, retrieval mode, and expansion behavior explicit parts of + the adapter contract. + +### Phase 3: hook expansion + +- Extend Codex hook coverage to support the full useful `agentmemory` + observation model, not just a minimum subset. +- Target the full current `agentmemory` hook set: + - `SessionStart` + - `UserPromptSubmit` + - `PreToolUse` + - `PostToolUse` + - `PostToolUseFailure` + - `PreCompact` + - `SubagentStart` + - `SubagentStop` + - `Notification` + - `TaskCompleted` + - `Stop` + - `SessionEnd` +- Broaden `PreToolUse` and `PostToolUse` beyond the current shell-centric + path so file tools, command tools, and other high-signal tool classes are + observed consistently. +- Do not treat hook expansion as optional polish; it is core to achieving the + high-performance end state. + +### Phase 4: native memory deprecation + +- Turn off Codex native memory generation by default in the fork. +- Remove or quarantine old native memory artifacts once the adapter is stable. +- Preserve migration tooling only if existing users need it. + +### Phase 5: superset hardening + +- Verify that every retained Codex-native memory affordance has an + `agentmemory`-backed implementation or an intentional deletion note. +- Verify that token usage remains bounded as corpus size grows. +- Verify that there is only one authoritative memory source in the runtime. +- Remove any remaining code paths that can accidentally re-enable split-brain + behavior. +- Verify that embeddings, graph retrieval, and progressive disclosure are + active in the intended steady-state configuration. +- Verify that the system is not silently falling back to a lower-capability + retrieval mode in normal operation. + +### Phase 6: optional advanced alignment + +- Add memory citation mapping from `agentmemory` results into Codex protocol + structures. +- Add richer protocol and app-server visibility if needed. +- Reassess whether any remaining native memory logic should survive. + +## Execution plan + +This section turns the replacement architecture into a low-rebase execution +plan. + +The key rule is: + +- keep invasive edits concentrated in a few upstream-hot orchestration files, +- keep most new logic in fork-owned modules, +- gate native behavior off before deleting it. + +### Allowed write boundaries + +The preferred fork seam is: + +- small edits in: + - `codex-rs/core/src/codex.rs` + - `codex-rs/core/src/hook_runtime.rs` + - `codex-rs/hooks/src/engine/config.rs` + - `codex-rs/hooks/src/engine/discovery.rs` + - hook event/schema files only when required for new public events +- most new implementation in new fork-owned modules, for example: + - `codex-rs/core/src/agentmemory/` + - `codex-rs/hooks/src/agentmemory/` or equivalent hook-translation module + +### Intentionally untouched until cutover + +Do not broadly rewrite these early: + +- `codex-rs/core/src/memories/*` +- `codex-rs/core/templates/memories/*` +- native memory artifact generation logic +- broad protocol/app-server surfaces unrelated to memory provenance + +Early phases should gate or bypass these paths, not delete or refactor them. + +### Branch order + +Use a short stack of focused branches / PRs. + +#### PR 1: backend selector and fork seam + +Goal: + +- introduce a clear memory backend selector, +- add the new `agentmemory` adapter module skeleton, +- make no user-visible behavior change yet. + +Write scope: + +- config wiring, +- new adapter modules, +- minimal callsite plumbing only where needed. + +Must not do: + +- no native memory deletion, +- no protocol changes, +- no hook expansion yet. + +Merge gate: + +- no behavior regression with native memory still active by default, +- docs updated to describe the seam. + +#### PR 2: startup injection replacement + +Goal: + +- route startup memory injection through the `agentmemory` adapter, +- make bounded retrieval the new startup path, +- stop depending on native memory artifact loading for startup context. + +Write scope: + +- `codex-rs/core/src/codex.rs` +- adapter module +- minimal config/docs updates + +Must not do: + +- do not delete native memories yet, +- do not add broad protocol changes, +- do not expand hook coverage in the same PR. + +Merge gate: + +- startup context is sourced from `agentmemory`, +- token budget and retrieval mode are explicit and tested, +- no static `MEMORY.md`-style reinjection layer is recreated on top. + +#### PR 3: public hook event expansion + +Goal: + +- expand Codex hooks to cover the full useful `agentmemory` hook set: + - `SessionStart` + - `UserPromptSubmit` + - `PreToolUse` + - `PostToolUse` + - `PostToolUseFailure` + - `PreCompact` + - `SubagentStart` + - `SubagentStop` + - `Notification` + - `TaskCompleted` + - `Stop` + - `SessionEnd` + +Write scope: + +- hook config/schema/discovery/runtime files, +- TUI/app-server visibility only where hook runs need surfacing. + +Must not do: + +- do not mix in native memory deletion, +- do not mix in citation replacement. + +Merge gate: + +- each event has runtime dispatch, +- each event is documented, +- hook run visibility remains coherent. + +#### PR 4: tool coverage broadening + +Goal: + +- broaden `PreToolUse` and `PostToolUse` beyond the shell-centric path, +- ensure file tools, command tools, and other high-signal tool classes are + observed consistently for `agentmemory`. + +Write scope: + +- `codex-rs/core/src/hook_runtime.rs` +- tool handler payload plumbing +- hook translation layer + +Must not do: + +- do not mix in memory command replacement, +- do not delete native memory paths yet. + +Merge gate: + +- high-signal tool classes emit useful observation payloads, +- no regression in existing shell-hook flows. + +#### PR 5: memory ops and provenance replacement + +Goal: + +- replace or redefine `UpdateMemories` and `DropMemories`, +- decide and implement provenance behavior, +- define the replacement for native `polluted` semantics. + +Write scope: + +- memory command handlers, +- provenance/citation integration, +- minimal protocol additions if absolutely required. + +Must not do: + +- do not combine this with broad deletion of native memory code. + +Merge gate: + +- user-facing memory refresh/clear actions still exist or are intentionally + documented as removed, +- provenance behavior is explicit, +- no ambiguity remains about memory invalidation rules. + +#### PR 6: hard cutover + +Goal: + +- disable native memory generation and consolidation in normal runtime paths, +- make `agentmemory` the only authoritative memory backend, +- quarantine or deprecate native memory artifacts. + +Write scope: + +- backend selection defaults, +- final cutover gating, +- cleanup of callsites that can still route to native memory. + +Must not do: + +- do not do broad code deletion unless the fork is already stable after cutover, +- do not remove debug/rollback switches until at least one successful rebase + cycle after cutover. + +Merge gate: + +- one memory authority in runtime, +- no split-brain injection, +- no accidental native fallback in standard flows. + +#### PR 7: post-cutover cleanup + +Goal: + +- remove dead native-memory paths only after the hard cutover has stabilized. + +Write scope: + +- native memory code and docs that are no longer reachable, +- migration notes if existing users need them. + +Merge gate: + +- cleanup produces less rebase churn than it creates, +- rollback path is no longer needed. + +### Rebase policy + +- Rebase frequently; do not let this stack drift for long. +- Rebase before opening each PR and after any upstream changes touching: + - `codex-rs/core/src/codex.rs` + - `codex-rs/core/src/hook_runtime.rs` + - hook engine config/discovery/schema/runtime files +- Prefer new modules over editing existing modules repeatedly. +- If a behavior can live in the adapter, keep it out of upstream-hot files. +- Do not delete upstream code early; disabling is cheaper to rebase than + removal. + +### Success metrics by PR + +- PR 1: seam exists with no behavior regression. +- PR 2: startup injection is `agentmemory`-backed and token-bounded. +- PR 3: hook surface matches the intended `agentmemory` event model. +- PR 4: observation capture is rich across the important tool classes. +- PR 5: memory ops and provenance no longer depend on native memory internals. +- PR 6: runtime has one authoritative memory backend. +- PR 7: dead code removal does not increase future rebase cost materially. + +### Handoff prompts by PR + +These are intended as copy-paste prompts for future sessions, child agents, or +parallel worker swarms. Each prompt is deliberately scoped to one PR-sized +slice. + +#### PR 1 handoff prompt + +```text +Implement PR 1 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- introduce a clear memory backend selector +- add the new agentmemory adapter seam +- make no user-visible behavior change yet + +Constraints: +- keep invasive edits concentrated +- do not delete or broadly rewrite codex-rs/core/src/memories/* +- do not change protocol shapes +- do not expand hooks yet + +Write scope: +- config wiring +- new fork-owned adapter modules +- minimal callsite plumbing only where needed + +Acceptance: +- native memory remains default and behaviorally unchanged +- the seam exists and is documented +- code is structured so later PRs can route through the adapter without large rewrites +``` + +#### PR 2 handoff prompt + +```text +Implement PR 2 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- replace startup memory prompt generation with agentmemory-backed retrieval +- make startup context bounded, relevance-ranked, and token-budgeted + +Constraints: +- do not recreate static MEMORY.md-style loading on top of agentmemory +- do not expand hooks in this PR +- do not delete native memory code yet + +Write scope: +- codex-rs/core/src/codex.rs +- agentmemory adapter module +- small config/docs updates if required + +Acceptance: +- startup injection is sourced through the adapter +- retrieval mode and token budget are explicit +- native memory still exists only as a gated fallback path, not the main path +``` + +#### PR 3 handoff prompt + +```text +Implement PR 3 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- expand Codex public hooks to support the full useful agentmemory event model + +Target events: +- SessionStart +- UserPromptSubmit +- PreToolUse +- PostToolUse +- PostToolUseFailure +- PreCompact +- SubagentStart +- SubagentStop +- Notification +- TaskCompleted +- Stop +- SessionEnd + +Constraints: +- keep handler semantics coherent +- do not mix in native memory deletion +- do not mix in provenance/citation replacement + +Acceptance: +- each target event is represented in config/discovery/runtime +- documentation and hook-run visibility are updated +- new events do not regress existing hook behavior +``` + +#### PR 4 handoff prompt + +```text +Implement PR 4 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- broaden PreToolUse and PostToolUse beyond the shell-centric path +- ensure high-signal tool classes produce useful agentmemory observations + +Constraints: +- prioritize file tools, command tools, and other high-signal tool classes +- do not mix in memory command replacement +- do not cut over the backend here + +Acceptance: +- important tool classes emit observation payloads consistently +- shell-hook behavior still works +- capture quality is materially closer to the Claude-side agentmemory model +``` + +#### PR 5 handoff prompt + +```text +Implement PR 5 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- replace or redefine UpdateMemories and DropMemories +- decide and implement provenance behavior +- define the replacement for native polluted semantics + +Constraints: +- keep protocol churn minimal unless required +- make user-facing behavior explicit +- do not delete native memory paths in this PR + +Acceptance: +- memory refresh/clear actions still exist or are intentionally removed with docs +- provenance behavior is explicit +- invalidation rules are no longer ambiguous +``` + +#### PR 6 handoff prompt + +```text +Implement PR 6 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- make agentmemory the only authoritative runtime memory backend +- disable native memory generation and consolidation in normal runtime paths + +Constraints: +- do not do broad dead-code deletion yet +- keep rollback/debug switches until cutover is validated + +Acceptance: +- one memory authority remains in runtime +- no split-brain injection is possible in standard flows +- native paths are gated off rather than accidentally still active +``` + +#### PR 7 handoff prompt + +```text +Implement PR 7 from docs/agentmemory-codex-memory-replacement-spec.md. + +Goal: +- perform post-cutover cleanup only after the hard replacement is stable + +Constraints: +- prefer cleanup that reduces future rebase cost +- do not remove rollback/debug tooling prematurely + +Acceptance: +- dead native-memory paths are removed only when safe +- cleanup does not create more rebase drag than it removes +``` + +#### Cross-PR reviewer prompt + +```text +Review the current PR against docs/agentmemory-codex-memory-replacement-spec.md. + +Focus: +- does this PR stay within its assigned write boundary +- does it reduce or increase future rebase drag +- does it preserve the hard-replacement target +- does it accidentally introduce split-brain behavior +- does it move the system toward maximum-performance agentmemory usage rather than a degraded fallback +``` + +## Do not do + +- Do not run Codex native memory injection and `agentmemory` injection as + equal peers long term. +- Do not claim a strict superset without rebuilding missing Codex-native + semantics. +- Do not clone Claude plugin infrastructure into Codex just to make the + replacement work. +- Do not overfit to Claude-specific bridge behavior such as + `~/.claude/projects/*/memory/MEMORY.md` if Codex is becoming the primary + target. +- Do not remove native memory citations or memory operations accidentally; if + they are dropped, document that as an intentional product change. + +## Acceptance criteria for a forked replacement + +The replacement is successful only if all of these are true: + +- `agentmemory` is the authoritative source for retrieved memory context, +- Codex native memory is no longer an independent competing authority, +- Codex startup injection still works reliably, +- memory refresh and memory clearing remain user-visible operations or are + intentionally removed with docs, +- hook/event coverage is sufficient to produce materially useful observations, +- token usage stays bounded as the corpus grows, +- the intended steady state uses embeddings and hybrid retrieval rather than a + degraded BM25-only baseline, +- Gemini or another high-quality embedding provider remains available as a + first-class configuration path, +- the fork has a clear provenance story for memory-derived output, +- there is no ambiguity about which memory system is active, +- the resulting user-facing behavior is a practical superset of the two source + systems rather than a regression-heavy swap. + +## Final judgment + +If the question is "is `agentmemory` materially more advanced than Codex +native memory?", the answer is yes. + +If the question is "should a fork disable Codex native memory and replace it +with `agentmemory`?", the answer is: + +- yes, +- with the condition that the fork also rebuild the Codex-native integration + semantics that matter, +- and with the explicit goal of a single authoritative memory system rather + than a permanent hybrid. diff --git a/docs/claude-code-hooks-parity.md b/docs/claude-code-hooks-parity.md new file mode 100644 index 000000000..02c3dd7ae --- /dev/null +++ b/docs/claude-code-hooks-parity.md @@ -0,0 +1,346 @@ +# Claude Code hooks parity + +This document captures the current Codex hooks surface and the remaining +feature-parity gap versus Claude Code's documented hooks system. It is intended +to be the canonical planning doc for expanding `codex_hooks`. + +## Goal + +Bring Codex's public `hooks.json` lifecycle hooks close enough to Claude +Code's model that Claude-oriented hook setups can be ported with predictable, +documented edits rather than custom runtime patches. + +This does not require byte-for-byte compatibility in one step. It does require: + +- matching the major public event categories users expect, +- supporting the handler types those configurations rely on, +- honoring documented decision-control fields when they are accepted by schema, +- documenting any intentional deltas that remain. + +## Read order + +If you are implementing against this doc, read the current source in this order: + +1. `docs/claude-code-hooks-parity.md` +2. `codex-rs/hooks/src/engine/config.rs` +3. `codex-rs/hooks/src/engine/discovery.rs` +4. `codex-rs/hooks/src/schema.rs` +5. `codex-rs/hooks/src/engine/output_parser.rs` +6. `codex-rs/core/src/hook_runtime.rs` +7. `codex-rs/core/src/codex.rs` +8. `codex-rs/core/src/tools/registry.rs` + +This order moves from public contract to discovery, then schema, then parser, +then runtime wiring, then legacy behavior. + +## Current source snapshot + +This doc is based on the current implementation shape in this checkout: + +- public `hooks.json` event groups are defined in + `codex-rs/hooks/src/engine/config.rs`, +- handler discovery and unsupported-handler warnings live in + `codex-rs/hooks/src/engine/discovery.rs`, +- public wire schema lives in `codex-rs/hooks/src/schema.rs`, +- output acceptance and rejection behavior lives in + `codex-rs/hooks/src/engine/output_parser.rs`, +- runtime dispatch for start, prompt-submit, pre-tool, and post-tool hooks + lives in `codex-rs/core/src/hook_runtime.rs`, +- `Stop` hook wiring lives in `codex-rs/core/src/codex.rs`, +- deprecated legacy `AfterToolUse` dispatch still exists in + `codex-rs/core/src/tools/registry.rs`, +- no repository-local `hooks.json` files are checked into this tree today. + +## Current Codex surface + +Today Codex exposes five public `hooks.json` event groups: + +- `PreToolUse` +- `PostToolUse` +- `SessionStart` +- `UserPromptSubmit` +- `Stop` + +The current engine only executes synchronous command handlers. `prompt`, +`agent`, and `async` configurations are parsed but skipped with warnings. + +The current runtime also has narrower execution coverage than Claude Code: + +- `PreToolUse` and `PostToolUse` are currently wired through the shell path, + with runtime requests using `tool_name: "Bash"`. +- `UserPromptSubmit` and `Stop` ignore matchers. +- some wire fields are present in schema but are rejected by the output parser + as unsupported. + +Legacy internal paths still exist for notification-style hooks +(`AfterAgent` / deprecated `AfterToolUse`), but they are not part of the +public `hooks.json` contract. + +## Claude Code parity gap + +Claude Code's current hooks reference documents a larger event surface and more +handler modes than Codex currently supports. + +### Missing event coverage + +Codex does not yet expose public `hooks.json` support for these documented +Claude Code event families: + +- `InstructionsLoaded` +- `PermissionRequest` +- `PostToolUseFailure` +- `Notification` +- `SubagentStart` +- `SubagentStop` +- `StopFailure` +- `TeammateIdle` +- `TaskCompleted` +- `ConfigChange` +- `CwdChanged` +- `FileChanged` +- `WorktreeCreate` +- `WorktreeRemove` +- `PreCompact` +- `PostCompact` +- `SessionEnd` +- `Elicitation` +- `ElicitationResult` + +### Missing handler coverage + +Codex does not yet support these Claude Code hook handler categories in the +public engine: + +- async command hooks, +- HTTP hooks, +- prompt hooks, +- agent hooks. + +### Partial decision-control coverage + +Codex schema already models some advanced fields, but runtime support is still +partial: + +- `PreToolUse.updatedInput` is rejected. +- `PreToolUse.additionalContext` is rejected. +- `PreToolUse.permissionDecision: allow` is rejected. +- `PreToolUse.permissionDecision: ask` is rejected. +- `PostToolUse.updatedMCPToolOutput` is rejected. +- `suppressOutput` is rejected for `PreToolUse` and `PostToolUse`. +- `stopReason` and `continue: false` are rejected for `PreToolUse`. + +This creates a confusing state where the schema shape suggests broader support +than the runtime actually honors. + +### Tool and matcher parity gaps + +- `PreToolUse` and `PostToolUse` should evolve from shell-centric wiring to + a consistent tool-event contract across relevant tool classes. +- matcher support should be explicit and consistent across all events that + Claude users expect to filter. +- MCP-aware hook behavior should be designed as first-class runtime behavior, + not as a schema placeholder. + +## Non-goals + +- Reproducing Claude Code internals exactly where Codex architecture differs. +- Preserving every existing partial or deprecated behavior forever. +- Adding public hook types without app-server, TUI, and docs visibility for the + resulting runs. + +## Design principles + +- **Public contract first**: do not expose schema fields that the runtime will + immediately reject unless they are clearly marked unsupported. +- **Event completeness over aliases**: add real lifecycle events before adding + compatibility shims. +- **One event, one payload contract**: every public event needs stable input and + output schema fixtures, runtime execution, and surfaced hook-run reporting. +- **Fail-open unless explicitly blocking**: invalid hook output should not cause + surprising hard failures outside events whose contract is intentionally + blocking. +- **No hidden UI drift**: hook additions must be visible in the TUI and + app-server surfaces anywhere hook runs are rendered today. + +## Do not do + +- Do not add a new public event without input schema, runtime dispatch, + hook-run reporting, and docs in the same lane. +- Do not keep wire fields in public schema as if they are live when the parser + still rejects them. +- Do not use deprecated `AfterAgent` or legacy `AfterToolUse` internals as + the long-term public parity path. +- Do not widen event coverage while leaving handler type and execution mode + reporting misleading in run summaries. +- Do not make hook support TUI-only; app-server and protocol surfaces must stay + aligned. + +## Implementation plan + +### Branch and PR order + +Prefer this implementation order: + +1. contract cleanup for the existing five events, +2. runtime event expansion on the command-hook engine, +3. handler-type and execution-mode expansion, +4. advanced decision-control support, +5. pre/post tool-class parity work, +6. final doc consolidation and examples. + +Do not mix all six into one change. Keep each lane reviewable. + +### Phase 1: make the current public surface coherent + +Goal: remove misleading partial support inside the existing five events. + +Required work: + +- align schema and parser behavior for the five existing events, +- either implement or remove unsupported schema fields that are already emitted + in fixtures, +- document matcher behavior explicitly, +- document current shell-centric tool coverage explicitly, +- add a dedicated user-facing reference doc for `hooks.json` behavior if the + main docs site still only mentions legacy notification hooks. + +Acceptance: + +- no schema field is silently accepted but runtime-rejected without explicit + documentation, +- the docs explain exactly which event fields and decisions are live, +- existing five-event behavior is covered by tests and schema fixtures. + +### Phase 2: expand event coverage on the existing command-hook engine + +Goal: add missing lifecycle events before broadening handler types. + +Priority order: + +1. `PermissionRequest` +2. `Notification` +3. `SubagentStart` and `SubagentStop` +4. `PostToolUseFailure` and `StopFailure` +5. `SessionEnd` +6. `ConfigChange`, `CwdChanged`, and `FileChanged` +7. `PreCompact` and `PostCompact` +8. `TaskCompleted` and `TeammateIdle` +9. `InstructionsLoaded` +10. `WorktreeCreate` and `WorktreeRemove` +11. `Elicitation` and `ElicitationResult` + +Acceptance: + +- each event has an input schema fixture, +- each event has runtime dispatch wiring, +- each event emits `HookStarted` and `HookCompleted` consistently, +- each event has an explicit matcher story, +- docs list the event as supported. + +### Phase 3: broaden handler types + +Goal: match the main Claude Code hook execution modes. + +Required work: + +- implement async command hooks, +- add HTTP hook handlers, +- add prompt hook handlers, +- add agent hook handlers, +- surface handler type and execution mode accurately in run summaries. + +Acceptance: + +- discovery no longer skips supported handler types with warnings, +- `HookRunSummary` reports real handler type and execution mode, +- command, HTTP, prompt, and agent handlers have stable input/output contracts, +- async execution semantics are documented, especially ordering and failure + behavior. + +### Phase 4: close decision-control parity gaps + +Goal: implement or explicitly drop advanced output fields. + +Required work: + +- decide whether `PreToolUse.updatedInput` will be supported in Codex, +- decide whether `PreToolUse.permissionDecision: ask` maps to an approval + prompt, a model-visible continuation, or remains unsupported, +- implement `additionalContext` anywhere the contract claims it exists, +- decide whether `PostToolUse.updatedMCPToolOutput` is part of the public + runtime contract, +- review event-specific `continue`, `stopReason`, and `suppressOutput` + semantics for consistency. + +Acceptance: + +- advanced hook output fields are either implemented end-to-end or removed from + public schema, +- runtime behavior matches docs and tests, +- no event-specific decision-control behavior relies on undocumented parser + special cases. + +### Phase 5: tool-class parity for pre/post tool hooks + +Goal: make tool hooks genuinely tool-aware rather than shell-specific. + +Required work: + +- define which Codex tool classes participate in `PreToolUse` and + `PostToolUse`, +- expose stable tool identifiers and input payloads for those classes, +- define MCP-tool matcher behavior explicitly, +- preserve backward compatibility for existing Bash-oriented hooks where + feasible. + +Acceptance: + +- users can target more than the shell path with pre/post tool hooks, +- tool names and payloads are documented and stable, +- MCP tool behavior is implemented rather than placeholder-only. + +## Required cross-cutting work + +- update docs under `docs/` when public behavior changes, +- keep generated schema fixtures in sync, +- extend TUI and app-server visibility for new hook events when needed, +- add focused tests for parser behavior, discovery behavior, and runtime + dispatch, +- decide whether legacy notification hooks remain supported long term or are + explicitly deprecated in docs. + +## Acceptance gates for any implementation PR + +Every parity PR should satisfy all of these before merge: + +- docs updated for the newly supported behavior, +- generated hook schema fixtures updated if the public schema changed, +- focused tests added or updated for discovery, parser, and runtime behavior, +- hook run summaries still render correctly in TUI and app-server surfaces, +- unsupported behavior is either removed from schema or clearly documented as + unsupported. + +## Open decisions + +- Should Codex aim for Claude-compatible field names and semantics wherever + possible, or only for event-name parity? +- Should prompt and agent hooks be first-class in the initial public contract, + or stay experimental behind feature flags after implementation? +- Should unsupported advanced fields be removed now to reduce confusion, or kept + in schema as forward-compatibility placeholders? +- Which events should be thread-scoped versus turn-scoped in app-server and TUI + reporting? + +## Recommended first implementation slice + +If this work is started incrementally, the highest-leverage first slice is: + +1. publish a real user-facing hooks reference for Codex, +2. make the existing five events internally coherent, +3. add `PermissionRequest`, `Notification`, `SubagentStart`, + `SubagentStop`, and `SessionEnd`, +4. then add async and HTTP handler support. + +That sequence closes the largest user-visible parity gaps without mixing event +expansion, execution-model expansion, and advanced mutation semantics into one +hard-to-review change. diff --git a/docs/fork-intent.md b/docs/fork-intent.md new file mode 100644 index 000000000..fff270177 --- /dev/null +++ b/docs/fork-intent.md @@ -0,0 +1,65 @@ +# Fork Intent + +This repository is a public fork of +[openai/codex](https://github.com/openai/codex). + +The intent of this fork is not to rename or replace the upstream project. The +intent is to keep a Codex-compatible fork while adapting the runtime to use +`agentmemory` as the primary long-term memory backend, expand the public hook +surface toward Claude-style lifecycle parity, keep the memory UX coherent +across both TUIs, and trim hosted CI and release machinery to the lanes this +fork actually needs. + +This document explains why this fork exists and which parts of the tree changed +to support that goal. + +For the release and legal posture for making the repository public and for +shipping public release artifacts, see +[`docs/public-release-notes.md`](./public-release-notes.md). + +## Fork Goals + +1. Make `agentmemory` the authoritative memory engine instead of keeping two + competing long-term memory systems. +2. Expose a runtime memory surface that is coherent for both humans and the + assistant across `tui` and `tui_app_server`. +3. Move Codex hooks closer to Claude Code's documented lifecycle model so + existing hook setups port with fewer custom patches. +4. Keep the fork operationally legible by retaining only the CI, docs, and + release machinery that still matters here. +5. Keep provenance, licensing, and release constraints explicit rather than + burying fork-specific decisions in commit history. + +## Change Map + +| Area | Intent | Key files | +|---|---|---| +| Memory backend replacement | Make `agentmemory` the primary long-term memory engine and bypass native Codex memory generation when that backend is selected. | [`docs/agentmemory-codex-memory-replacement-spec.md`](./agentmemory-codex-memory-replacement-spec.md), [`../codex-rs/core/src/agentmemory/mod.rs`](../codex-rs/core/src/agentmemory/mod.rs), [`../codex-rs/core/src/codex.rs`](../codex-rs/core/src/codex.rs), [`../codex-rs/core/src/memories/phase2.rs`](../codex-rs/core/src/memories/phase2.rs), [`../codex-rs/core/src/memories/tests.rs`](../codex-rs/core/src/memories/tests.rs) | +| Runtime memory surface and UX | Keep memory recall/update/drop visible and coherent across both TUIs, with assistant-triggered recall routed through the same backend semantics. | [`../codex-rs/docs/agentmemory_runtime_surface_spec.md`](../codex-rs/docs/agentmemory_runtime_surface_spec.md), [`../codex-rs/docs/agentmemory_followup_spec.md`](../codex-rs/docs/agentmemory_followup_spec.md), [`../codex-rs/tui/src/chatwidget.rs`](../codex-rs/tui/src/chatwidget.rs), [`../codex-rs/tui/src/bottom_pane/footer.rs`](../codex-rs/tui/src/bottom_pane/footer.rs), [`../codex-rs/tui_app_server/src/chatwidget.rs`](../codex-rs/tui_app_server/src/chatwidget.rs), [`../codex-rs/tui_app_server/src/bottom_pane/footer.rs`](../codex-rs/tui_app_server/src/bottom_pane/footer.rs) | +| Hook parity and lifecycle capture | Expand the public `hooks.json` surface so Claude-oriented hook configurations map onto Codex with fewer surprises and clearer runtime contracts. | [`./claude-code-hooks-parity.md`](./claude-code-hooks-parity.md), [`../codex-rs/hooks/README.md`](../codex-rs/hooks/README.md), [`../codex-rs/hooks/src/engine/config.rs`](../codex-rs/hooks/src/engine/config.rs), [`../codex-rs/hooks/src/engine/discovery.rs`](../codex-rs/hooks/src/engine/discovery.rs), [`../codex-rs/hooks/src/engine/dispatcher.rs`](../codex-rs/hooks/src/engine/dispatcher.rs), [`../codex-rs/hooks/src/schema.rs`](../codex-rs/hooks/src/schema.rs) | +| Fork-scoped CI and release posture | Remove or narrow upstream maintainer workflows that do not add value in this fork, while keeping enough CI and packaging signal for the paths still used here. | [`../codex-rs/docs/github_actions_private_fork_spec.md`](../codex-rs/docs/github_actions_private_fork_spec.md), [`../.github/workflows/rust-ci.yml`](../.github/workflows/rust-ci.yml), [`../.github/workflows/cargo-deny.yml`](../.github/workflows/cargo-deny.yml), [`../.github/workflows/ci.bazelrc`](../.github/workflows/ci.bazelrc), [`../.github/workflows/v8-ci.bazelrc`](../.github/workflows/v8-ci.bazelrc), [`../scripts/stage_npm_packages.py`](../scripts/stage_npm_packages.py) | +| Public source and licensing clarity | Keep the fork publishable as source, preserve third-party notices, and document the remaining constraints around public binary distribution. | [`../README.md`](../README.md), [`./license.md`](./license.md), [`../NOTICE`](../NOTICE), [`../LICENSE`](../LICENSE) | + +## What This Fork Is Not + +- It is not a claim to authorship over upstream `openai/codex`. +- It is not a separate product with a new license or package identity. +- It is not a promise that upstream release automation or contributor-governance + workflows remain enabled here. +- It is not a statement that this repository currently provides official public + Linux binaries. + +## Related Docs + +- [`README.md`](../README.md) +- [`docs/agentmemory-codex-memory-replacement-spec.md`](./agentmemory-codex-memory-replacement-spec.md) +- [`docs/claude-code-hooks-parity.md`](./claude-code-hooks-parity.md) +- [`codex-rs/docs/agentmemory_runtime_surface_spec.md`](../codex-rs/docs/agentmemory_runtime_surface_spec.md) +- [`codex-rs/docs/github_actions_private_fork_spec.md`](../codex-rs/docs/github_actions_private_fork_spec.md) +- [`docs/public-release-notes.md`](./public-release-notes.md) +- [`docs/license.md`](./license.md) + +## Public Release Notes + +The release and legal guidance now lives in +[`docs/public-release-notes.md`](./public-release-notes.md). diff --git a/docs/license.md b/docs/license.md index 18ad62af0..be3047ad9 100644 --- a/docs/license.md +++ b/docs/license.md @@ -1,3 +1,42 @@ ## License -This repository is licensed under the [Apache-2.0 License](../LICENSE). +This repository is licensed under the [Apache-2.0 License](../LICENSE). Unless +an individual file or bundled third-party directory says otherwise, that is the +license that applies to repository-authored code. + +## Bundled Third-Party Material + +Some files shipped in this repository remain under their upstream licenses and +keep their original notices: + +- codex-rs/tui/src/custom_terminal.rs and + codex-rs/tui_app_server/src/custom_terminal.rs are derived from Ratatui and + retain MIT notices inline. +- codex-rs/utils/pty/src/win/ contains Windows PTY support code copied from + WezTerm and retains MIT notices inline. A copy of the upstream license is + available at third_party/wezterm/LICENSE. +- codex-rs/core/src/tools/js_repl/meriyah.umd.min.js bundles a Meriyah parser + asset under the ISC license. A copy of that license is available at + third_party/meriyah/LICENSE. +- codex-rs/vendor/bubblewrap/ vendors bubblewrap source code under + LGPL-2.0-or-later. The full license text is at + codex-rs/vendor/bubblewrap/COPYING. + +The root [NOTICE](../NOTICE) file summarizes the bundled third-party materials +that currently require explicit attribution in this source tree. + +For release and publication guidance, including the current recommendation for +vendored bubblewrap, see [public-release-notes.md](./public-release-notes.md). + +## Package Metadata + +Published Rust crates under codex-rs, the JavaScript packages under codex-cli, +and the Python and TypeScript SDK packages under sdk/ declare Apache-2.0 in +their package metadata. + +## Dependency Auditing + +Rust dependency licenses are checked with cargo-deny: + + cd codex-rs + cargo deny check licenses diff --git a/docs/public-release-notes.md b/docs/public-release-notes.md new file mode 100644 index 000000000..fd4e5813e --- /dev/null +++ b/docs/public-release-notes.md @@ -0,0 +1,118 @@ +# Public Release Notes + +This document records the current licensing and release posture for making the +repository public and for shipping public release artifacts. + +For the fork-rationale document that explains why this repository diverges from +upstream `openai/codex`, see [fork-intent.md](./fork-intent.md). + +Current intent: this is a publicly available source repository. Official public +Linux binary releases are not currently provided by this document. + +## Scope + +There are two separate compliance questions: + +1. Can the source repository be public? +2. Can we distribute public binaries built from this repository? + +The answer to the first is mostly a documentation and attribution question. The +answer to the second also depends on what third-party code is compiled into the +released artifacts. + +## Repository Authored Code + +Repository-authored code is licensed under [Apache-2.0](../LICENSE), unless a +particular file or bundled third-party subtree states otherwise. + +The root [NOTICE](../NOTICE) file and [license.md](./license.md) document the +non-Apache material currently kept in-tree. + +## Bundled Third-Party Material + +The repository currently includes the following notable third-party materials: + +- Ratatui-derived files in `codex-rs/tui/src/custom_terminal.rs` and + `codex-rs/tui_app_server/src/custom_terminal.rs`, with inline MIT notices. +- WezTerm-derived Windows PTY files in `codex-rs/utils/pty/src/win/`, with + inline MIT notices and local-modification notes. +- A bundled Meriyah parser asset at + `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`, with its license kept + at `third_party/meriyah/LICENSE`. +- A vendored bubblewrap source tree at `codex-rs/vendor/bubblewrap`, under + `LGPL-2.0-or-later`. + +## Source Repository Publication + +For a public source repository, the baseline requirements are: + +- Keep the root `LICENSE` file. +- Keep the root `NOTICE` file accurate when new bundled third-party material is + added or removed. +- Preserve inline notices on copied or derived files. +- Keep bundled third-party license texts in-tree when referenced by shipped + assets. +- Continue running `cargo deny check licenses` for the Rust workspace. + +This repository currently meets that baseline more cleanly than before, but it +still requires release discipline when third-party code is updated. + +## Binary Distribution + +Binary distribution needs a stricter release gate than source publication. +Public binaries should ship with: + +- `LICENSE` +- `NOTICE` +- any third-party license texts required by bundled or compiled components +- release notes that explain material third-party inclusions when relevant + +If a release artifact includes code under obligations beyond simple attribution, +the release process must explicitly account for that component. + +## Vendored Bubblewrap + +This is the main component that needs product and legal clarity before broad +public Linux binary distribution. + +Current state: + +- `codex-rs/linux-sandbox/build.rs` compiles vendored bubblewrap C sources on + Linux targets. +- `codex-rs/linux-sandbox/src/vendored_bwrap.rs` exposes that compiled entry + point for runtime use. +- `codex-rs/linux-sandbox/README.md` documents that the helper prefers system + `/usr/bin/bwrap`, but falls back to the vendored build path when needed. + +That means vendored bubblewrap is not just present in source form; it can also +be part of Linux builds and therefore affects binary-distribution compliance. + +## Recommendation + +Default recommendation: do not ship public Linux release binaries that rely on +the vendored bubblewrap fallback until that lane has an explicit legal and +release-process owner. + +Preferred short-term approach: + +- Make public Linux release builds rely on system `bwrap`, or otherwise disable + the vendored fallback in distributed binaries. +- Keep the vendored bubblewrap tree in source if it is still useful for local + development, CI, or non-public builds. +- Revisit vendored-bubblewrap distribution only with a dedicated compliance + review. + +If the project later decides to ship vendored bubblewrap in public binaries, the +release process should be updated deliberately rather than relying on the source +repository notices alone. + +## Working Rule + +Until a separate decision is recorded, treat these as the default release rules: + +- Public source repo: allowed with current notices and license files kept up to + date. +- Public Linux binaries using vendored bubblewrap: not allowed by default. +- Public Linux binaries using system bubblewrap only: preferred interim path, + subject to normal release review. +- No official public Linux release build pipeline is assumed by this document. diff --git a/justfile b/justfile index 5c9fa5e6a..7a1abd91c 100644 --- a/justfile +++ b/justfile @@ -75,6 +75,44 @@ bazel-remote-test: build-for-release: bazel build //codex-rs/cli:release_binaries --config=remote +# Build a machine-local codex binary with native CPU tuning, panic=abort, +# profile-guided optimization, and mimalloc on top of the existing release +# profile's LTO and codegen-units settings. +# +# You can provide additional representative training commands via: +# CODEX_PGO_TRAIN=' +# ./target/release/codex --version >/dev/null +# ./target/release/codex exec --help >/dev/null +# ' just perf-build-local +perf-build-local: + #!/usr/bin/env bash + set -euo pipefail + PGO_DIR="${TMPDIR:-/tmp}/codex-pgo" + rm -rf "$PGO_DIR" + mkdir -p "$PGO_DIR" + LLVM_PROFDATA="$(command -v llvm-profdata || xcrun --find llvm-profdata)" + COMMON_RUSTFLAGS="-C target-cpu=native" + if command -v ld64.lld >/dev/null 2>&1 || command -v lld >/dev/null 2>&1; then + COMMON_RUSTFLAGS="$COMMON_RUSTFLAGS -C link-arg=-fuse-ld=lld" + fi + CARGO_PROFILE_RELEASE_LTO=fat \ + CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \ + CARGO_PROFILE_RELEASE_PANIC=abort \ + RUSTFLAGS="$COMMON_RUSTFLAGS -C profile-generate=$PGO_DIR" \ + cargo build -p codex-cli --release --features mimalloc + ./target/release/codex --help >/dev/null + ./target/release/codex exec --help >/dev/null + ./target/release/codex mcp --help >/dev/null + if [ -n "${CODEX_PGO_TRAIN:-}" ]; then sh -lc "$CODEX_PGO_TRAIN"; fi + "$LLVM_PROFDATA" merge -output="$PGO_DIR/merged.profdata" "$PGO_DIR" + CARGO_PROFILE_RELEASE_LTO=fat \ + CARGO_PROFILE_RELEASE_OPT_LEVEL=3 \ + CARGO_PROFILE_RELEASE_CODEGEN_UNITS=1 \ + CARGO_PROFILE_RELEASE_PANIC=abort \ + RUSTFLAGS="$COMMON_RUSTFLAGS -C profile-use=$PGO_DIR/merged.profdata -C llvm-args=-pgo-warn-missing-function" \ + cargo build -p codex-cli --release --features mimalloc + # Run the MCP server mcp-server-run *args: cargo run -p codex-mcp-server -- "$@" @@ -94,11 +132,11 @@ write-hooks-schema: # Run the argument-comment Dylint checks across codex-rs. [no-cd] argument-comment-lint *args: - ./tools/argument-comment-lint/run-prebuilt-linter.sh "$@" + {{justfile_directory()}}/tools/argument-comment-lint/run-prebuilt-linter.sh "$@" [no-cd] argument-comment-lint-from-source *args: - ./tools/argument-comment-lint/run.sh "$@" + {{justfile_directory()}}/tools/argument-comment-lint/run.sh "$@" # Tail logs from the state SQLite database log *args: diff --git a/package.json b/package.json index 504b12bee..42266471c 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "codex-monorepo", "private": true, "description": "Tools for repo-wide maintenance.", + "license": "Apache-2.0", "scripts": { "format": "prettier --check *.json *.md docs/*.md .github/workflows/*.yml **/*.js", "format:fix": "prettier --write *.json *.md docs/*.md .github/workflows/*.yml **/*.js", diff --git a/patch3.diff b/patch3.diff new file mode 100644 index 000000000..d5d4a8368 --- /dev/null +++ b/patch3.diff @@ -0,0 +1,25 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -5,6 +5,7 @@ + //! as a replacement for Codex's native memory engine. + + use std::path::Path; ++use serde::Serialize; + + /// A placeholder adapter struct for agentmemory integration. + #[derive(Debug, Default, Clone)] +@@ -34,5 +35,16 @@ + .to_string(), + ) + } ++ ++ /// Asynchronously captures and stores lifecycle events in `agentmemory`. ++ /// ++ /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to ++ /// be transmitted without blocking the hot path of the shell or model output. ++ pub async fn capture_event(&self, _event_name: &str, _payload: P) { ++ // TODO: Transmit the event to agentmemory's ingestion endpoint. ++ // The payload will typically be a hook request (e.g. `PostToolUseRequest`). ++ // This is a stub for future PRs. ++ } + } diff --git a/patch_agentmemory.diff b/patch_agentmemory.diff new file mode 100644 index 000000000..dd9a699fb --- /dev/null +++ b/patch_agentmemory.diff @@ -0,0 +1,99 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -4,6 +4,7 @@ + //! as a replacement for Codex's native memory engine. + + use std::path::Path; ++use std::sync::OnceLock; + use serde_json::json; + + /// A placeholder adapter struct for agentmemory integration. +@@ -11,6 +12,16 @@ + pub struct AgentmemoryAdapter { + // Configuration and state will be added here in subsequent PRs. + } ++ ++/// A shared, pooled HTTP client for agentmemory interactions. ++/// Reusing the client allows connection pooling (keep-alive) for high throughput. ++static CLIENT: OnceLock = OnceLock::new(); ++ ++fn get_client() -> &'static reqwest::Client { ++ CLIENT.get_or_init(|| { ++ reqwest::Client::builder().build().unwrap_or_default() ++ }) ++} + + impl AgentmemoryAdapter { + pub fn new() -> Self { +@@ -32,7 +43,7 @@ + _codex_home: &Path, + _token_budget: usize, + ) -> Option { +- let client = reqwest::Client::new(); ++ let client = get_client(); + let url = format!("{}/agentmemory/profile", self.api_base()); + let profile_result = client.get(&url).send().await; + +@@ -53,35 +64,54 @@ + Some(instructions) + } + ++ /// Transforms Codex's internal hook payloads into Claude-parity structures ++ /// expected by the `agentmemory` REST API. This provides a central, malleable ++ /// place to adjust mapping logic in the future without touching the hooks engine. ++ fn format_claude_parity_payload(&self, event_name: &str, mut payload: serde_json::Value) -> serde_json::Value { ++ // TODO: As agentmemory evolves, perform explicit property mapping here. ++ // For example, mapping Codex `turn_id` to Claude `message_id` or extracting specific nested fields. ++ ++ json!({ ++ "event": event_name, ++ "payload": payload, ++ }) ++ } ++ + /// Asynchronously captures and stores lifecycle events in `agentmemory`. + /// + /// This method allows Codex hooks (like `SessionStart`, `PostToolUse`) to + /// be transmitted without blocking the hot path of the shell or model output. + pub async fn capture_event(&self, event_name: &str, payload_json: serde_json::Value) { + let url = format!("{}/agentmemory/observe", self.api_base()); +- let client = reqwest::Client::new(); +- let body = json!({ +- "event": event_name, +- "payload": payload_json, +- }); +- let _ = client.post(&url).json(&body).send().await; ++ let client = get_client(); ++ ++ let body = self.format_claude_parity_payload(event_name, payload_json); ++ ++ if let Err(e) = client.post(&url).json(&body).send().await { ++ // Log a warning instead of failing silently. This won't crash the session, ++ // but will alert developers that memory observation is degraded. ++ tracing::warn!( ++ "Agentmemory observation failed: could not send {} event to {}: {}", ++ event_name, url, e ++ ); ++ } + } + + /// Asynchronously triggers a memory refresh/update operation in `agentmemory`. + pub async fn update_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/consolidate", self.api_base()); +- let client = reqwest::Client::new(); ++ let client = get_client(); + let res = client.post(&url).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Consolidate failed with status {}", res.status())); + } + Ok(()) + } + + /// Asynchronously drops/clears the memory store in `agentmemory`. + pub async fn drop_memories(&self) -> Result<(), String> { + let url = format!("{}/agentmemory/forget", self.api_base()); +- let client = reqwest::Client::new(); ++ let client = get_client(); + let res = client.post(&url).json(&json!({"all": true})).send().await.map_err(|e| e.to_string())?; + if !res.status().is_success() { + return Err(format!("Forget failed with status {}", res.status())); diff --git a/patch_tests.diff b/patch_tests.diff new file mode 100644 index 000000000..6ba86a5d7 --- /dev/null +++ b/patch_tests.diff @@ -0,0 +1,43 @@ +--- codex-rs/core/src/agentmemory/mod.rs ++++ codex-rs/core/src/agentmemory/mod.rs +@@ -118,3 +118,48 @@ + Ok(()) + } + } ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ use serde_json::json; ++ ++ #[test] ++ fn test_api_base_default() { ++ // Ensure env var is not set ++ std::env::remove_var("III_REST_PORT"); ++ let adapter = AgentmemoryAdapter::new(); ++ assert_eq!(adapter.api_base(), "http://localhost:3111"); ++ } ++ ++ #[test] ++ fn test_api_base_custom_port() { ++ std::env::set_var("III_REST_PORT", "4000"); ++ let adapter = AgentmemoryAdapter::new(); ++ assert_eq!(adapter.api_base(), "http://localhost:4000"); ++ std::env::remove_var("III_REST_PORT"); ++ } ++ ++ #[test] ++ fn test_format_claude_parity_payload() { ++ let adapter = AgentmemoryAdapter::new(); ++ let raw_payload = json!({ ++ "session_id": "1234", ++ "turn_id": "turn-5", ++ "command": "echo hello" ++ }); ++ ++ let formatted = adapter.format_claude_parity_payload("PreToolUse", raw_payload.clone()); ++ ++ assert_eq!(formatted["event"], "PreToolUse"); ++ assert_eq!(formatted["payload"], raw_payload); ++ } ++} diff --git a/scripts/stage_npm_packages.py b/scripts/stage_npm_packages.py index 5bbee755e..0c5c4d51b 100755 --- a/scripts/stage_npm_packages.py +++ b/scripts/stage_npm_packages.py @@ -16,7 +16,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent BUILD_SCRIPT = REPO_ROOT / "codex-cli" / "scripts" / "build_npm_package.py" INSTALL_NATIVE_DEPS = REPO_ROOT / "codex-cli" / "scripts" / "install_native_deps.py" -WORKFLOW_NAME = ".github/workflows/rust-release.yml" +DEFAULT_WORKFLOW_NAME = ".github/workflows/rust-release.yml" GITHUB_REPO = "openai/codex" _SPEC = importlib.util.spec_from_file_location("codex_build_npm_package", BUILD_SCRIPT) @@ -78,7 +78,7 @@ def expand_packages(packages: list[str]) -> list[str]: return expanded -def resolve_release_workflow(version: str) -> dict: +def resolve_release_workflow(version: str, workflow_name: str) -> dict: stdout = subprocess.check_output( [ "gh", @@ -89,7 +89,7 @@ def resolve_release_workflow(version: str) -> dict: "--json", "workflowName,url,headSha", "--workflow", - WORKFLOW_NAME, + workflow_name, "--jq", "first(.[])", ], @@ -98,7 +98,12 @@ def resolve_release_workflow(version: str) -> dict: ) workflow = json.loads(stdout or "null") if not workflow: - raise RuntimeError(f"Unable to find rust-release workflow for version {version}.") + raise RuntimeError( + "Unable to find a release workflow run for version " + f"{version} using {workflow_name}. " + "If this fork does not keep the upstream release workflows, pass " + "--workflow-url explicitly." + ) return workflow @@ -106,7 +111,8 @@ def resolve_workflow_url(version: str, override: str | None) -> tuple[str, str | if override: return override, None - workflow = resolve_release_workflow(version) + workflow_name = DEFAULT_WORKFLOW_NAME + workflow = resolve_release_workflow(version, workflow_name) return workflow["url"], workflow.get("headSha") diff --git a/third_party/v8/README.md b/third_party/v8/README.md index 9ad37c6f0..9f1f51a9a 100644 --- a/third_party/v8/README.md +++ b/third_party/v8/README.md @@ -30,7 +30,9 @@ with these raw asset names: - `librusty_v8_release_.a.gz` - `src_binding_release_.rs` -The dedicated publishing workflow is `.github/workflows/rusty-v8-release.yml`. +In the upstream repository, the dedicated publishing workflow is +.github/workflows/rusty-v8-release.yml. +Private forks may remove that workflow and stage artifacts by other means. It builds musl release pairs from source and keeps the release artifacts as the statically linked form: diff --git a/tools/argument-comment-lint/run-prebuilt-linter.sh b/tools/argument-comment-lint/run-prebuilt-linter.sh index 3828e06d9..88562986b 100755 --- a/tools/argument-comment-lint/run-prebuilt-linter.sh +++ b/tools/argument-comment-lint/run-prebuilt-linter.sh @@ -73,12 +73,8 @@ fi lint_args+=("$@") if ! command -v dotslash >/dev/null 2>&1; then - cat >&2 <&2 + exec "$repo_root/tools/argument-comment-lint/run.sh" "$@" fi if command -v rustup >/dev/null 2>&1; then