R/agent_context.R

Defines functions same_file agent_context_soul agent_context_global agent_context_read agent_context_native_project agent_context_project agent_context_codex_memory_dir agent_context_codex_memory agent_context_memory agent_context_defaults agent_context

Documented in agent_context

# Agent context assembly
# Assemble context from memory, instructions, and identity files
# for AI coding agents.

#' Load context files for an AI coding agent
#'
#' Returns assembled context (memory, project/global instructions, agent
#' identity files) tailored to a specific consumer. Files autoloaded by the
#' agent natively are skipped to avoid duplication.
#'
#' Defaults per agent:
#' \itemize{
#'   \item \code{"claude"} - skips Claude Code project memory and CLAUDE.md
#'     files (autoloaded by 'Claude Code'). Loads Codex memories, AGENTS.md,
#'     USER.md, and SOUL.md when present.
#'   \item \code{"codex"} - skips AGENTS.md and Codex memories (autoloaded by
#'     Codex). Loads Claude Code project memory, CLAUDE.md, USER.md, and
#'     SOUL.md when present.
#'   \item \code{"corteza"} or \code{NULL} - loads everything available.
#' }
#'
#' Project instructions merge \code{CLAUDE.md} and \code{AGENTS.md}. The
#' file the consumer autoloads is left out, and so is any heading-delimited
#' section whose text the consumer already holds. Two names for one file, or
#' two identical copies, add nothing; a consumer with no autoloaded file
#' receives \code{CLAUDE.md} followed by the sections only \code{AGENTS.md}
#' has. Sections are compared verbatim, so the same rule in different words
#' is loaded from both files.
#'
#' Global instructions are resolved by trying both naming conventions and
#' picking the file relevant to the consumer: \code{~/.claude/CLAUDE.md} or
#' \code{<workspace_dir>/USER.md}.
#'
#' Override the defaults with the \code{include_*} parameters.
#'
#' @param agent Consumer identifier: \code{"claude"}, \code{"codex"},
#'   \code{"corteza"}, or \code{NULL} (interactive / unknown). The legacy
#'   \code{"llamar"} identifier is accepted as an alias for \code{"corteza"}.
#' @param project_dir Project directory to scan for CLAUDE.md / AGENTS.md.
#' @param workspace_dir Optional directory containing SOUL.md and USER.md
#'   (e.g. \code{~/.corteza/workspace}). If \code{NULL}, those files are
#'   skipped.
#' @param memory_base Base directory for 'Claude Code' project memory files.
#' @param claude_global_path Path to the global 'Claude Code' instructions
#'   file. Defaults to \code{~/.claude/CLAUDE.md}.
#' @param include_memory Override default for memory inclusion. Use
#'   \code{TRUE}/\code{FALSE} to force, or \code{NULL} for the agent default.
#' @param include_project Override default for project instructions
#'   (CLAUDE.md / AGENTS.md).
#' @param include_global Override default for global instructions
#'   (~/.claude/CLAUDE.md / USER.md).
#' @param include_soul Override default for SOUL.md inclusion.
#' @param max_memory_lines Maximum lines to include from each memory source.
#' @return Character string of assembled context, or empty string if no
#'   context applies.
#' @examples
#' \donttest{
#' # Codex agent in current project
#' saber::agent_context(agent = "codex")
#'
#' # Corteza with workspace files
#' saber::agent_context(agent = "corteza",
#'                      workspace_dir = "~/.corteza/workspace")
#'
#' # Force-include memory regardless of agent default
#' saber::agent_context(agent = "claude", include_memory = TRUE)
#' }
#' @export
agent_context <- function(agent = NULL, project_dir = getwd(),
                          workspace_dir = NULL,
                          memory_base = file.path(path.expand("~"), ".claude", "projects"),
                          claude_global_path = file.path(path.expand("~"), ".claude", "CLAUDE.md"),
                          include_memory = NULL, include_project = NULL,
                          include_global = NULL, include_soul = NULL,
                          max_memory_lines = 100L) {
    if (is.null(agent)) {
        agent_key <- NA_character_
    } else {
        agent_key <- as.character(agent)[1L]
    }

    defaults <- agent_context_defaults(agent_key)
    incl_mem <- include_memory %||% defaults$memory
    incl_codex_mem <- include_memory %||% defaults$codex_memory
    incl_proj <- include_project %||% defaults$project
    incl_glob <- include_global %||% defaults$global
    incl_soul <- include_soul %||% defaults$soul

    parts <- character(0L)

    if (isTRUE(incl_mem)) {
        mem <- agent_context_memory(project_dir, memory_base, max_memory_lines)
        if (length(mem) > 0L) {
            parts <- c(parts, mem, "")
        }
    }

    if (isTRUE(incl_codex_mem)) {
        codex_mem <- agent_context_codex_memory(max_memory_lines)
        if (length(codex_mem) > 0L) {
            parts <- c(parts, codex_mem, "")
        }
    }

    if (isTRUE(incl_proj)) {
        proj <- agent_context_project(project_dir, agent_key,
                                      forced = !is.null(include_project))
        if (length(proj) > 0L) {
            parts <- c(parts, proj, "")
        }
    }

    if (isTRUE(incl_glob)) {
        glob <- agent_context_global(workspace_dir, agent_key,
                                     claude_global_path,
                                     forced = !is.null(include_global))
        if (length(glob) > 0L) {
            parts <- c(parts, glob, "")
        }
    }

    if (isTRUE(incl_soul) && !is.null(workspace_dir)) {
        soul <- agent_context_soul(workspace_dir)
        if (length(soul) > 0L) {
            parts <- c(parts, soul, "")
        }
    }

    paste(parts, collapse = "\n")
}

#' Per-agent default inclusion flags
#' @noRd
agent_context_defaults <- function(agent) {
    if (is.na(agent)) {
        return(list(memory = TRUE, codex_memory = TRUE, project = TRUE,
                    global = TRUE, soul = TRUE))
    }
    switch(agent,
           claude = list(memory = FALSE, codex_memory = TRUE, project = TRUE,
                         global = TRUE, soul = TRUE),
           codex = list(memory = TRUE, codex_memory = FALSE, project = TRUE,
                        global = TRUE, soul = TRUE),
           corteza = list(memory = TRUE, codex_memory = TRUE, project = TRUE,
                          global = TRUE, soul = TRUE),
           llamar = list(memory = TRUE, codex_memory = TRUE, project = TRUE,
                         global = TRUE, soul = TRUE),
           list(memory = TRUE, codex_memory = TRUE, project = TRUE,
                global = TRUE, soul = TRUE)
    )
}

#' Load project memory section ('Claude Code' per-project memory)
#' @noRd
agent_context_memory <- function(project_dir, memory_base, max_lines) {
    if (is.null(memory_base) || !dir.exists(memory_base)) {
        return(character(0L))
    }

    project <- basename(normalizePath(project_dir, mustWork = FALSE))
    mem_file <- NULL
    mem_dirs <- list.dirs(memory_base, recursive = FALSE, full.names = TRUE)
    for (md in mem_dirs) {
        proj_encoded <- basename(md)
        proj_name <- sub("^.*-home-[^-]+-", "", proj_encoded)
        if (proj_name == project) {
            candidate <- file.path(md, "memory", "MEMORY.md")
            if (file.exists(candidate)) {
                mem_file <- candidate
                break
            }
        }
    }

    if (is.null(mem_file)) {
        return(character(0L))
    }

    mem_lines <- readLines(mem_file, warn = FALSE)
    lines <- "## Memory"
    if (length(mem_lines) > max_lines) {
        lines <- c(lines, mem_lines[seq_len(max_lines)],
                   sprintf("_... truncated (%d more lines)_",
                           length(mem_lines) - max_lines))
    } else {
        lines <- c(lines, mem_lines)
    }
    lines
}

#' Load Codex memories for non-Codex agents
#' @noRd
agent_context_codex_memory <- function(max_lines) {
    mem_dir <- agent_context_codex_memory_dir()
    if (!dir.exists(mem_dir)) {
        return(character(0L))
    }

    files <- list.files(mem_dir, recursive = TRUE, full.names = TRUE)
    if (length(files) == 0L) {
        return(character(0L))
    }

    info <- file.info(files)
    keep <- !is.na(info$isdir) & !info$isdir & !is.na(info$size) &
    info$size > 0L
    files <- sort(files[keep])
    if (length(files) == 0L) {
        return(character(0L))
    }

    max_lines <- max(1L, as.integer(max_lines)[1L])
    remaining <- max_lines
    out <- "## Codex Memories"
    truncated <- FALSE

    for (i in seq_along(files)) {
        if (remaining <= 0L) {
            truncated <- TRUE
            break
        }

        content <- tryCatch(readLines(files[[i]], warn = FALSE),
                            error = function(e) character(0L))
        if (length(content) == 0L) {
            next
        }

        take <- min(length(content), remaining)
        label <- substring(files[[i]], nchar(mem_dir) + 2L)
        label <- gsub("\\\\", "/", label)
        out <- c(out, "", sprintf("### %s", label), "", content[seq_len(take)])
        remaining <- remaining - take

        if (take < length(content)) {
            truncated <- TRUE
            break
        }
    }

    if (length(out) == 1L) {
        return(character(0L))
    }
    if (truncated) {
        out <- c(out, sprintf("_... truncated after %d lines_", max_lines))
    }
    out
}

#' Resolve the Codex memory directory
#' @noRd
agent_context_codex_memory_dir <- function() {
    codex_home <- Sys.getenv("CODEX_HOME", unset = "")
    if (nchar(codex_home) == 0L) {
        codex_home <- file.path(path.expand("~"), ".codex")
    }
    file.path(path.expand(codex_home), "memories")
}

#' Merge and load project instructions (CLAUDE.md and AGENTS.md)
#'
#' Loads both files, CLAUDE.md first, without the one the consumer
#' autoloads. A section whose text the consumer already holds, from the
#' autoloaded file or the file loaded before it, is dropped. Aliases and
#' identical copies therefore add nothing.
#' @noRd
agent_context_project <- function(project_dir, agent, forced = FALSE) {
    paths <- file.path(project_dir, c("CLAUDE.md", "AGENTS.md"))
    native <- agent_context_native_project(paths, agent, forced)
    seen <- md_section_keys(agent_context_read(native))

    out <- character(0L)
    for (path in setdiff(paths, native)) {
        new <- md_new_sections(agent_context_read(path), seen)
        if (length(new$lines) == 0L) {
            next
        }
        seen <- c(seen, new$keys)
        label <- basename(path)
        if (new$dropped > 0L) {
            label <- paste(label, "(sections not already loaded)")
        }
        out <- c(out, if (length(out) > 0L) "", sprintf("## %s", label), "",
                 new$lines)
    }
    out
}

#' Project file the consumer autoloads, or NULL
#'
#' A forced include or an unknown consumer has no autoloaded file.
#' @noRd
agent_context_native_project <- function(paths, agent, forced) {
    if (forced || is.na(agent)) {
        return(NULL)
    }
    switch(agent, claude = paths[[1L]], codex = paths[[2L]], NULL)
}

#' Read an instruction file, or nothing when it is absent or unreadable
#' @noRd
agent_context_read <- function(path) {
    if (is.null(path) || !file.exists(path) || dir.exists(path)) {
        return(character(0L))
    }
    tryCatch(readLines(path, warn = FALSE), error = function(e) character(0L))
}

#' Resolve and load global instructions
#'
#' Picks claude_global_path or workspace_dir/USER.md based on consumer.
#' @noRd
agent_context_global <- function(workspace_dir, agent, claude_global,
                                 forced = FALSE) {
    user_path <- if (!is.null(workspace_dir)) {
        file.path(workspace_dir, "USER.md")
    } else {
        NULL
    }

    claude_exists <- file.exists(claude_global)
    user_exists <- !is.null(user_path) && file.exists(user_path)

    if (!claude_exists && !user_exists) {
        return(character(0L))
    }

    file_to_load <- NULL
    if (forced || is.na(agent)) {
        if (claude_exists) {
            file_to_load <- claude_global
        } else {
            file_to_load <- user_path
        }
    } else if (identical(agent, "claude")) {
        # claude autoloads ~/.claude/CLAUDE.md; only load USER.md
        if (user_exists && !same_file(claude_global, user_path)) {
            file_to_load <- user_path
        }
    } else {
        # codex / corteza / legacy aliases / unknown: prefer claude global,
        # fall back to USER.md
        if (claude_exists) {
            file_to_load <- claude_global
        } else {
            file_to_load <- user_path
        }
    }

    if (is.null(file_to_load)) {
        return(character(0L))
    }

    content <- tryCatch(readLines(file_to_load, warn = FALSE),
                        error = function(e) character(0L))
    if (length(content) == 0L) {
        return(character(0L))
    }

    label <- if (identical(file_to_load, claude_global)) {
        "Global Instructions (~/.claude/CLAUDE.md)"
    } else {
        "User Preferences (USER.md)"
    }
    c(sprintf("## %s", label), "", content)
}

#' Load SOUL.md from workspace
#' @noRd
agent_context_soul <- function(workspace_dir) {
    soul_path <- file.path(workspace_dir, "SOUL.md")
    if (!file.exists(soul_path)) {
        return(character(0L))
    }
    content <- tryCatch(readLines(soul_path, warn = FALSE),
                        error = function(e) character(0L))
    if (length(content) == 0L) {
        return(character(0L))
    }
    c("## Agent Identity (SOUL.md)", "", content)
}

#' Check if two paths resolve to the same file
#'
#' Returns FALSE if either path doesn't exist.
#' @noRd
same_file <- function(a, b) {
    if (is.null(a) || is.null(b)) {
        return(FALSE)
    }
    if (!file.exists(a) || !file.exists(b)) {
        return(FALSE)
    }
    norm_a <- tryCatch(normalizePath(a, mustWork = FALSE),
                       error = function(e) a)
    norm_b <- tryCatch(normalizePath(b, mustWork = FALSE),
                       error = function(e) b)
    identical(norm_a, norm_b)
}

Try the saber package in your browser

Any scripts or data that you put into this service are public.

saber documentation built on Nov. 10, 2026, 9:06 a.m.