stark-ai-de/agent-skillsReview behavior before running
SKILL DETAIL
codex-memory-curator
stark-ai-de/agent-skills/codex-memory-curator
Audit and safely curate Codex memory, including stale claims, cross-repo leakage, sensitive entries, and memory configuration. Use when reviewing or cleaning Codex memory, not generic repository docs.
Installs · 1,610View source
Installation
npx skills add https://github.com/stark-ai-de/agent-skills --skill codex-memory-curator
Skill files
SKILL.md
Last synced · Oct 1, 2026
agents/openai.yaml›
interface:
display_name: "Codex Memory Curator"
short_description: "Audits, prunes, and rewrites Codex memory state"
icon_small: "./assets/openai-icon.png"
icon_large: "./assets/openai-icon.png"
default_prompt: "Use $codex-memory-curator for my Codex memories. Show all eight workflows compactly, including plan-run-cleanup-file, and recommend the route matching my requested outcome; select and announce the intent-bound route when my outcome and authority are clear, otherwise ask me to choose."
policy:
products:
- CODEX
allow_implicit_invocation: false
assets/cleanup-plan-template.md›
# Cleanup Plan Template
Embed this structure in the `Plan` section of the selected route's chat result or single file record when the user needs to approve individual memory cleanup actions. Do not persist it as a second curation artifact.
Populate approval fields from the actual current or earlier approval; the unapproved values below are placeholders. Record approval evidence and actual host write permission alongside the plan, and preserve approval across mode changes unless the approved action materially changed.
An explicit `--backup-root` also requires a stable non-sensitive `--backup-root-alias` for the portable storage locator.
```json
{
"schema_version": "1.0",
"codex_home": "<codex-home>",
"created_at": "<iso-8601-timestamp>",
"source": {
"memory_files": [],
"config_file": null,
"repo_context_files": []
},
"summary": {
"entries_extracted": 0,
"keep": 0,
"rewrite": 0,
"move_to_agents_md": 0,
"move_to_repo_docs": 0,
"move_to_skill": 0,
"move_to_config": 0,
"delete": 0,
"ask_user": 0
},
"decisions": [
{
"id": "M-1",
"source_file": "memories/MEMORY.md",
"line": 1,
"current_memory_redacted": "<short redacted memory claim>",
"primary_classification": "ASK USER",
"risk_tags": [],
"confidence": "low",
"conflicts": [],
"proposed_action": "<leave unchanged, rewrite, move, delete, or ask>",
"proposed_replacement": null,
"approved": false
}
],
"apply_requirements": {
"requires_explicit_user_approval": true,
"requires_backup_first": true,
"unknown_schema_action": "defer_without_writing",
"backup": {
"mode": "exact",
"storage_policy": "outside-git-worktree",
"storage_root": "deterministic-user-state-or-verified-external-override",
"backup_root_alias": null,
"persisted_locator_policy": "script-reported-portable-storage-locator-and-manifest-relative-paths-only",
"include_paths": [],
"manifest": "backup-manifest.json",
"receipt_rule": "Every pre-existing changed file must match exactly one verified manifest source entry; an approved new file is created-no-preimage with an explicit rollback."
}
}
}
```
assets/review-report-template.md›
# Codex Memory Curation Record
Use this entire template as the one redacted record for file routes. Use `not applicable` with a reason for phases the route does not perform. Never create a separate report, plan, or receipt file for the same selection.
## Review
## Top Decisions
1.
2.
3.
## Summary
- Memory files inspected:
- Entries extracted:
- Keep:
- Rewrite:
- Move to AGENTS.md:
- Move to repo docs:
- Move to skill:
- Move to config:
- Delete:
- Ask user:
## Highest-Risk Memories
| ID | Entry | Risk | Recommendation |
| --- | ----- | ---- | -------------- |
## Proposed Cleanup Table
| ID | Current memory | Classification | Risk tags | Confidence | Reason | Proposed action |
| --- | -------------- | -------------- | --------- | ---------- | ------ | --------------- |
## Conflict Notes
| ID | Higher source | Conflict | Recommendation |
| --- | ------------- | -------- | -------------- |
## Plan
- Status:
- Approved entries:
- Stop conditions:
## Execution Receipt
- Applied entries:
- Files changed:
- Manifest reconciliation and unmatched paths: each changed file matched exactly one verified source entry; unmatched paths:
- New paths without a preimage (`created-no-preimage`) and rollback:
- Trimmed diff summary:
| Changed path | Backup destination | Bytes | SHA-256 | Verification |
| ------------ | ------------------ | ----: | ------- | ------------ |
Use only manifest-relative backup destinations in this table. Never persist absolute local paths.
## Deferred Work
- Entry:
- Reason:
## Backup
- Portable backup ID (directory basename) or not applicable:
- Portable storage locator (`user-state/.../<backup-id>` or `external-root/<alias>/<backup-id>`):
- Storage policy / root safety (`outside-git-worktree`):
- Mode (`exact` for cleanup):
- Portable manifest locator (`<storage-locator>/backup-manifest.json`):
- Manifest file count / changed-file match count:
Report exact absolute backup and manifest paths only in non-persisted chat, never in this repository record.
## Verification
- Re-read results:
- Backup integrity result:
- Residual risks:
## Config Recommendation
## Recommended Next Action
references/classification-rubric.md›
# Memory Classification Rubric
Use this reference when the classification decision is not obvious from `SKILL.md`.
## Decision Tree
1. Does the entry contain a secret, credential, customer data, private identifier, or sensitive personal data?
- Classify `DELETE`.
- Tag `sensitive`.
- Do not print the full value.
2. Is the entry false, stale, one-off, duplicated, or tied to an old branch/debugging session?
- Classify `DELETE` unless a scoped rewrite would preserve durable value.
3. Is it a repo-specific rule, command, path, workflow, test gate, coding convention, or generated-file restriction?
- Classify `MOVE TO AGENTS.md` if it is short and operational.
- Classify `MOVE TO REPO DOCS` if it needs detailed explanation.
4. Is it a reusable procedure that should load only when asked?
- Classify `MOVE TO SKILL`.
5. Is it a Codex behavior setting?
- Classify `MOVE TO CONFIG`.
6. Is it a stable, cross-repo personal preference that is short and accurate?
- Classify `KEEP`.
7. Is the useful idea too broad, too strong, ambiguous, or missing scope?
- Classify `KEEP BUT REWRITE`.
8. If evidence is insufficient, classify `ASK USER`.
## Primary Classifications
| Classification | Use when | Typical action |
| ------------------- | -------------------------------------------------------------------------- | -------------------------------------------- |
| `KEEP` | Stable personal preference, durable across repos, short, accurate | Leave as-is |
| `KEEP BUT REWRITE` | Useful but too broad, stale, vague, long, or forceful | Rewrite with scope and verification language |
| `MOVE TO AGENTS.md` | Repo-specific rule, command, path, convention, ownership, or restriction | Add or move to current repo `AGENTS.md` |
| `MOVE TO REPO DOCS` | Longer architecture or operations context useful to humans and agents | Move to a repo document |
| `MOVE TO SKILL` | Reusable workflow or checklist that should load on demand | Create/update a skill |
| `MOVE TO CONFIG` | Memory behavior, model setting, tool toggle, or agent runtime config | Move to config and remove from memory |
| `DELETE` | Harmful, stale, duplicated, sensitive, too narrow, one-off, or conflicting | Remove after approval and backup |
| `ASK USER` | Cannot be safely classified from available evidence | Ask one focused question before editing |
## Destination Decision Matrix
Choose one destination for each atomic claim:
| Claim type | Destination | Reason |
| ----------------------------------------------- | ----------------- | ------------------------------------------------------------------------------------- |
| Stable cross-repo user preference | Memory | Useful across many sessions and not mandatory team policy. |
| Mandatory team or repo rule | `AGENTS.md` | Must load with the repo and override stale personal context. |
| Detailed architecture or operations context | Repo docs or ADRs | Too long or too human-facing for memory. |
| Repeatable on-demand workflow | Skill | Should load only when the task matches. |
| Runtime behavior toggle | Config | Should be enforced by Codex settings, not recalled as advice. |
| Temporary state, stale branch, obsolete command | Delete | Harmful or noisy when carried forward. |
| Sensitive value or private identifier | Delete | Should not live in memory; redact output and recommend rotation for real credentials. |
## Review Questions
Ask these internally for every atomic entry:
1. Is this stable for months?
2. Is this a personal preference or a repo rule?
3. Could this mislead Codex in another repository?
4. Is it phrased too strongly?
5. Does it contain `always`, `never`, or `must` when the rule is contextual?
6. Is it duplicated?
7. Does it conflict with `AGENTS.md`, repo docs, package files, or the current user prompt?
8. Does it contain secrets, customer data, private identifiers, or sensitive personal data?
9. Would this be more precise as a skill, config setting, or repo document?
10. Is it short enough to be a memory?
## Confidence
Add confidence to cleanup plans:
| Confidence | Use when |
| ---------- | -------------------------------------------------------------------------------------------------- |
| `high` | Current files prove the claim is correct, stale, or misplaced. |
| `medium` | Evidence strongly suggests an action, but the user may know intent. |
| `low` | The entry is ambiguous or could encode a preference; classify `ASK USER` unless risk is sensitive. |
## Good Memory Examples
```text
User prefers pnpm when a repo supports pnpm.
```
Why it works: it is personal, short, and conditional.
```text
User prefers review findings to lead before summaries.
```
Why it works: it describes a stable communication preference.
```text
When using memory-derived facts that were not verified in the current turn, briefly say they may be stale.
```
Why it works: it is cross-repo and protects accuracy.
## Bad Memory Examples
```text
Always use service classes.
```
Problem: too broad and architectural. It may fight repo conventions.
Better:
```text
User often prefers explicit service boundaries in TypeScript apps, but current repo conventions and source code should decide the final structure.
```
```text
Run pnpm turbo test in this repo.
```
Problem: repo-specific command.
Better destination: `AGENTS.md`.
```text
The migration branch is fix/api-client-2024.
```
Problem: temporary branch state.
Better classification: `DELETE`.
## Always, Never, Must Rules
Hard words are allowed only when the rule is truly durable and global, such as "never print secrets".
Most memory rules should be softened:
| Over-strong memory | Safer rewrite |
| ------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Always use pnpm. | Prefer pnpm when the repo supports it or the user requests it. |
| Never run tests without asking. | Ask before long-running or destructive validation; run cheap focused checks when appropriate. |
| Must use AGENTS.md for all repo rules | Put repo-specific durable instructions in `AGENTS.md`; short user-wide preferences may remain. |
| Always avoid edits in dirty trees. | In dirty trees, inspect existing changes and avoid reverting user work. |
## Scope Tests
- Cross-repo and personal: usually memory.
- Current-repo only and operational: `AGENTS.md`.
- Current-repo only and detailed: repo docs.
- Repeatable procedure with many steps: skill.
- Runtime behavior switch: config.
- One-off, stale, sensitive, or conflicting: delete.
## Ask-User Examples
Ask the user when:
- A memory appears to encode a strong preference, but it conflicts with recent behavior.
- A repo-specific rule may actually be a user-wide preference.
- Deleting an entry could remove a preference the user intentionally relies on.
Ask one focused question:
```text
Should this be a global preference, or should I move it into this repo's AGENTS.md?
```
references/config-modes.md›
# Memory Config Modes
Use this reference when the user asks whether to disable, tune, or audit memories.
For exact TOML edits, verify current Codex config docs when network access is available; memory config keys are a moving surface.
## Active Mode Classification
Locate relevant lines across the entire document without exposing values:
```bash
node scripts/locate-memory-config.mjs --codex-home "${CODEX_HOME:-$HOME/.codex}" --json
```
The locator returns only line numbers and fixed signal names. It does not parse TOML, resolve profiles, or determine effective settings; comments and multiline strings may also match. Inspect bounded relevant sections with their table context, redact values before presenting excerpts, and resolve dotted/quoted keys, active profiles, command-line overrides, and duplicates before classification. Never truncate discovery at an arbitrary line count. Missing or ambiguous evidence remains unknown; a lexical match alone is not an active setting.
Classify the observed state:
| Mode | Signals |
| ------------------------------------ | ----------------------------------------------------------------------- |
| Disabled | `[features] memories = false` |
| Enabled but not injected | `features.memories = true` and `memories.use_memories = false` |
| Enabled and injected | `features.memories = true` and `memories.use_memories = true` |
| External-context generation disabled | `memories.disable_on_external_context = true` |
| Unknown | Missing config, conflicting values, app-controlled settings, or unclear |
When `use_memories` or `generate_memories` is absent, use the documented default `true` only as a recommendation aid; still report that the setting is implicit.
If config is missing, report:
```text
No config.toml was found. Memory behavior may be controlled by app settings or defaults.
```
## Audit Mode
Use when testing whether memory injection worsens Codex output.
```toml
[features]
memories = true
[memories]
use_memories = false
generate_memories = true
```
Effect: Codex may generate or update memories, but existing memories are not injected into work.
## Safer Normal Mode
Use for daily work while reducing noisy memory generation from external context.
```toml
[features]
memories = true
[memories]
use_memories = true
generate_memories = true
disable_on_external_context = true
min_rate_limit_remaining_percent = 50
```
Effect: useful memories remain available, while context-heavy external workflows are less likely to create noisy memories.
## Exact Repo Work Mode
Use for migrations, refactors, debugging, and architecture work where stale assumptions are harmful.
```toml
[features]
memories = true
[memories]
use_memories = false
generate_memories = false
```
Effect: memory is kept available for later, but neither injection nor generation affects the exact repo task.
## Off Mode
Use when memory behavior is actively harming output and the user wants a full reset.
```toml
[features]
memories = false
```
Effect: memory features are disabled.
## Tuning Keys
Use these when the user asks for more precise memory behavior. Do not recommend model overrides unless the user explicitly asks for model-level tuning.
| Key | Use |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `[features].memories` | Enables or disables the memory feature. |
| `memories.use_memories` | Controls whether existing memories are injected into new sessions. |
| `memories.generate_memories` | Controls whether new threads can become memory-generation inputs. |
| `memories.disable_on_external_context` | Excludes threads using external context such as MCP, web, or tool search from memory generation. |
| `memories.min_rate_limit_remaining_percent` | Raises or lowers the quota threshold for background generation. |
| `memories.min_rollout_idle_hours` | Requires a thread to sit idle before memory generation. |
| `memories.max_rollout_age_days` | Limits how old a thread can be and still be considered for memory generation. |
| `memories.max_rollouts_per_startup` | Caps how many candidate threads are processed per startup pass. |
| `memories.max_unused_days` | Limits unused memories considered during consolidation. |
| `memories.max_raw_memories_for_consolidation` | Caps retained raw memories used for consolidation. |
| `memories.extract_model` | Optional model override for per-thread extraction. |
| `memories.consolidation_model` | Optional model override for global consolidation. |
Legacy alias: `memories.no_memories_if_mcp_or_web_search` may appear in older configs for `memories.disable_on_external_context`. Prefer the current key in recommendations.
## More Conservative Generation
Use when memory pollution is the main concern but existing useful memories should remain available.
```toml
[features]
memories = true
[memories]
use_memories = true
generate_memories = true
disable_on_external_context = true
min_rate_limit_remaining_percent = 50
min_rollout_idle_hours = 12
max_rollout_age_days = 14
max_rollouts_per_startup = 8
```
Effect: keeps memory injection available while slowing and narrowing background generation.
## Location Guidance
| Information type | Better location |
| ---------------------------- | --------------------- |
| User-wide stable preference | Codex memory |
| Repo-specific workflow rule | `AGENTS.md` |
| Detailed public repo context | Repository docs |
| Reusable on-demand workflow | Skill |
| Runtime behavior setting | Config |
| Secret, token, credential | Nowhere in memory |
| Temporary task state | Handoff file or issue |
When a fact may drift, keep it scoped and require cheap verification.
references/conflict-resolution.md›
# Conflict Resolution
Use this when a memory claim may conflict with the current task, repo instructions, docs, scripts, or config.
## Precedence
Prefer sources in this order:
1. Current user request and direct conversation instructions.
2. Active `AGENTS.md` or `AGENTS.override.md` guidance for the current directory.
3. Current repository files that define behavior, including package scripts, ADRs, docs, and source code.
4. Current Codex config.
5. Codex memory.
Memory is useful context, not proof. When a memory conflicts with a higher-precedence source, classify it as `KEEP BUT REWRITE`, `MOVE TO AGENTS.md`, `MOVE TO REPO DOCS`, `MOVE TO CONFIG`, or `DELETE`.
## Checks
- Read only the repo files needed to verify the disputed claim.
- Prefer live files over remembered commands or branch names.
- Treat scoped repo guidance as local; do not generalize it into global memory.
- If two current repo sources disagree, report the conflict and classify the memory only after identifying the stronger source.
- If evidence is insufficient, classify `ASK USER`.
## Report Shape
Include a short conflict note in the review report or cleanup plan:
| Memory ID | Memory claim | Higher source | Conflict | Recommendation |
| --------- | -------------------- | -------------- | ------------------------------------- | ----------------------------------------------------------- |
| M-1 | `Run npm test here.` | `package.json` | Current script is `npm run validate`. | Move current command to `AGENTS.md` or delete stale memory. |
references/example-review-report.md›
# Example Review Report
Use this as a shape reference, not as a fixed answer.
Selected workflow: `review-chat`. This example creates no durable curation report.
# Codex Memory Review
## Top Decisions
1. Move the repo-specific test command to `AGENTS.md` if it is still current.
2. Delete stale branch state after backup and approval.
3. Rewrite the service-boundary preference so it is scoped to repo conventions.
## Summary
- Memory files inspected: 2
- Entries extracted: 6
- Keep: 1
- Rewrite: 1
- Move to AGENTS.md: 1
- Move to repo docs: 1
- Move to skill: 1
- Move to config: 0
- Delete: 1
- Ask user: 0
## Highest-Risk Memories
| ID | Entry | Risk | Recommendation |
| --- | ----------------------------- | ----------------------------------------- | --------------------------------- |
| M-4 | `Always use service classes.` | Too broad; may fight current repo design. | Rewrite with scope and caveat. |
| M-6 | `old-branch-name migration` | One-off branch state. | Delete after approval and backup. |
## Proposed Cleanup Table
| ID | Current memory | Classification | Risk tags | Confidence | Reason | Proposed action |
| --- | -------------------------------- | ------------------- | ----------------- | ---------- | -------------------------------------- | --------------------------------------------------------------------------------- |
| M-1 | `User prefers pnpm.` | `KEEP` | `useful` | high | Stable user preference. | Leave unchanged. |
| M-2 | `Run pnpm turbo test here.` | `MOVE TO AGENTS.md` | `repo-specific` | medium | Repo-specific validation command. | Move to current repo `AGENTS.md` if still current. |
| M-3 | `Long refactors need handoffs.` | `MOVE TO SKILL` | `workflow` | medium | Reusable workflow with steps. | Move to a handoff/refactor skill. |
| M-4 | `Always use service classes.` | `KEEP BUT REWRITE` | `too-broad` | medium | Useful preference but too absolute. | Rewrite: `Prefer explicit service boundaries when repo conventions support them.` |
| M-5 | `Architecture decision details.` | `MOVE TO REPO DOCS` | `too-specific` | high | Too long and repo-specific for memory. | Move to `docs/architecture.md`. |
| M-6 | `old-branch-name migration` | `DELETE` | `temporary,stale` | high | Temporary, stale branch detail. | Remove after backup. |
## Conflict Notes
| ID | Higher source | Conflict | Recommendation |
| --- | -------------- | ----------------------------------------- | ------------------------------------------- |
| M-2 | `package.json` | Current validation command may differ. | Verify before moving into `AGENTS.md`. |
| M-4 | Current source | Current repo may not use service classes. | Rewrite as a preference, not a global rule. |
## Plan
- Not applicable: `review-chat` does not plan or execute cleanup.
## Execution Receipt
- Not applicable: read-only route.
## Deferred Work
- M-2 through M-6 remain recommendations only.
## Backup
- Not applicable: no context mutation.
## Verification
- Review classifications reconciled against the cited repository evidence.
## Config Recommendation
Use Exact Repo Work Mode for the current refactor because stale memory assumptions are likely to be harmful.
## Recommended Next Action
Invoke a cleanup route explicitly if these recommendations should be applied; `plan-run-cleanup-file` matches cleanup with a persisted plan and receipt.
references/memory-store-anatomy.md›
# Memory Store Anatomy
Use this when the memory directory contains multiple file types or generated evidence.
## Discovery Rule
Discover the local layout instead of assuming one fixed schema. Treat files under `<codex-home>/memories` as generated state unless a local instruction says otherwise.
Use `node scripts/inventory-memories.mjs --json` when file-kind grouping would make the review safer. The `kind` field is best-effort and should not override file contents or local instructions.
## Common Buckets
| Bucket | Examples | Default stance |
| ------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| Curated summaries | `memory_summary.md`, `MEMORY.md`, durable preference files | Review and propose focused rewrites or removals. |
| Recent/raw inputs | recent prompts, extracted memories, raw candidates | Inspect only as evidence; avoid hand edits unless sensitive data requires approved cleanup. |
| Supporting evidence | rollout/session summaries, JSONL traces, citations | Treat as append-only evidence; do not rewrite to fix a stale curated claim. |
| Skill or extension notes | skill-specific memories, ad hoc update notes | Follow the local note/update workflow; do not bypass it. |
| Backups | `memories.backup.*` | Do not scan as active memory unless the user asks to review backups. |
## Editing Guidance
- Prefer changing the smallest curated claim that affects future behavior.
- Do not rewrite generated evidence just because a summary is stale.
- If a generated evidence file contains sensitive data, propose removal or redaction only after backup and explicit approval.
- If schema ownership is unclear, defer the proposal in chat or the single curation record instead of editing the original or creating a sibling memory file.
references/plan-lifecycle.md›
# Curation Plan Lifecycle
Read for a `plan-*` route, a pending material decision, or a native Plan transition.
The four `plan-*` routes require a reviewable cleanup plan and approval, not a mandatory manual mode switch:
1. Detect native Plan mode and actual permissions separately. Respect active or explicitly requested native Plan mode. Recommend it for substantial ambiguous planning, but continue permissible read-only discovery and conversation when it is inactive, unavailable, declined, or indeterminate; do not claim a mode change.
2. Use the same conversational planning contract without suitable native controls. Unknown mode or permission state never permits writes. Native Plan restrictions apply to reports, backups, and cleanup alike; defer file delivery until writes are permitted.
3. Resolve material choices using available structured or asynchronous question tools, otherwise ordinary conversation. While an answer is pending, continue only independent authorized work. Silence, timeouts, and preselected options are not approval.
4. Prepare the complete reviewable plan with exact paths, report creation, backup, and cleanup actions before requesting any missing approval. Reuse earlier approval of the unchanged plan and named actions. A native approval can cover this checkpoint when it confirms both content and write scope; a mode toggle alone cannot.
5. Before any write, recheck target files and protected state and exit active Plan mode through the host's supported control. Preserve approval across that transition. If content, scope, destination, or target state materially changes, reconfirm only the affected action; otherwise proceed within actual permissions without a second approval.
Plan-only routes still stop after delivering the approved plan. Execution authority, file delivery authority, and host write permission remain separate.
references/safe-editing-procedure.md›
# Safe Editing Procedure
Use this before modifying any Codex memory file.
## Route Authority Gate
Do not mutate unless the selected cleanup route is supported by an explicit user cleanup request for the exact Codex memory scope. A direct cleanup route authorizes only high-confidence atomic changes. A plan-run route authorizes only the unchanged user-approved plan after the state recheck and exit from active Plan mode, with actual write permission. Earlier approval of the same unchanged plan and named writes remains valid; only material content, scope, destination, or target-state drift requires a new decision. Do not add a generic second cleanup question after those gates.
Whole-file deletion, destructive recovery, config changes, external actions, and scope expansion retain separate approval boundaries.
## Backup
Run the bundled script:
```bash
node scripts/backup-memories.mjs --repo . --include PATH [--include PATH ...] [--backup-root PATH --backup-root-alias NAME]
```
Pass every file that may change as an exact repeatable `--include PATH`. Supplying any include selects exact mode and excludes the legacy default memory inventory; zero includes are reserved for a full legacy-discovery backup. Exact paths must be readable regular files. Any symlink path component, unreadable discovery directory, or traversal failure stops before backup-root creation. Before editing, verify that `backup-manifest.json` contains exactly one source entry for every intended changed file and that the copied size and SHA-256 match.
Unredacted backup payloads and their manifests must remain outside Git worktrees and outside the resolved `<codex-home>/memories` source tree, including when `CODEX_HOME` is project-local. The script defaults to a deterministic per-Codex-home root below the portable user state directory (`XDG_STATE_HOME` when configured) and reports `outside-git-worktree`. A derived or explicit root equal to or below the memories tree is rejected by physical path, including through a symlink alias, before legacy discovery can recursively include backup content or create a directory. Use `--backup-root PATH --backup-root-alias NAME` only for an external durable location and a stable non-sensitive operator-known alias; an override inside the memories tree, the target, or any other Git worktree, a missing alias, or a path-like alias is rejected before root creation or copying. Verify the reported storage root and policy before editing, and never place a backup below the repository report directory.
Report exact absolute backup and manifest paths only in non-persisted chat. A file-route receipt persists the script-reported `user-state/agent-memory-curator-backups/codex/<identity-hash>/<backup-id>` or `external-root/<alias>/<backup-id>` storage locator, `<storage-locator>/backup-manifest.json`, and manifest-relative destinations; never copy absolute home, repository, source, or storage-root paths into a repository artifact.
Before editing, record the authorized memory IDs from the direct-cleanup set or approved plan. Do not edit an entry outside that set.
## Editing Rules
- Apply only changes authorized by the selected route and exact scope.
- Apply changes by memory ID from the review report or cleanup plan, not by broad pattern.
- Prefer the smallest edit that removes risk.
- Preserve the original file format, headings, and ordering where practical.
- Never delete the only copy of a memory file.
- Do not print secrets or full sensitive values in diffs or summaries.
- If a line contains a real secret, remove or redact it after backup and tell the user to rotate it.
- Re-read each changed section after editing to verify the approved action was applied.
## Unknown Schema
If the memory file format is unclear, report:
```text
The memory file format is unclear. I will not edit it directly. I will defer proposed replacements in the current curation result.
```
Record replacement entries or deletion notes in chat or the single curation record. Do not create a sibling memory file during direct cleanup.
## Diff
After approved edits, show only a trimmed diff or summarize the changed paths and actions.
If the diff includes sensitive values, summarize the location and action instead of printing the value.
Reconcile the final changed-file set against the manifest. Stop and report the mismatch when any pre-existing changed file lacks exactly one manifest entry or any manifest source was outside the approved edit set. Mark an approved new path `created-no-preimage`, record its rollback, and never describe it as backed up.
Include the approved memory IDs in the summary:
```text
Applied: M-2 MOVE TO AGENTS.md, M-6 DELETE
Skipped: M-4 ASK USER
```
## Recovery
- If backup creation fails, do not edit.
- For each pre-existing changed file, locate the one manifest row whose `source` is that file. Before restore, verify that its copied `destination` still matches the recorded `size` and `sha256`; restore that file preimage only to the row's exact `source`, then verify the restored size and SHA-256 again.
- A `created-no-preimage` path has no backup. Roll it back separately according to the approved plan; never restore it from another manifest row.
- If manifest lookup, backup verification, restore, or restored-file verification fails, stop immediately and report the manifest path and command output.
- If the user changes scope mid-cleanup, stop and re-confirm the remaining edit set.
scripts/backup-memories.mjs›
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const manifestName = "backup-manifest.json";
const forceNoFollowFallback =
process.env.NODE_ENV === "test" &&
process.env.AGENT_MEMORY_CURATOR_TEST_FORCE_NO_NOFOLLOW === "1";
const noFollowFlag = forceNoFollowFallback ? undefined : fs.constants.O_NOFOLLOW;
const nonBlockFlag = fs.constants.O_NONBLOCK;
const directoryFlag = fs.constants.O_DIRECTORY;
const testCheckpointName = process.env.AGENT_MEMORY_CURATOR_TEST_CHECKPOINT;
let testCheckpointOccurrences = 0;
function usage() {
fs.writeSync(
1,
`Usage: backup-memories.mjs [--repo PATH] [--codex-home PATH] [--backup-root PATH --backup-root-alias NAME] [--include PATH ...]
Create a no-clobber backup of Codex memory files.
Options:
--repo PATH Identify the target repository instead of the current directory.
--codex-home PATH Use PATH instead of CODEX_HOME or ~/.codex.
--backup-root PATH Store backups below this directory instead of the deterministic
per-Codex-home user state directory. The root must be outside
the Codex memories tree, the target, and every other Git
worktree.
--backup-root-alias NAME
Required with --backup-root. Use a stable, non-sensitive name
for portable recovery records; path separators are rejected.
--include PATH Back up one exact regular file. May be passed more than once.
Globs, directories, symlinks, and symlinked parents are rejected.
-h, --help Show this help.
One or more --include values select exact mode: only the deduplicated explicit
files are copied. With no --include, legacy mode discovers ~/.codex/memories.
All selected paths must be readable. Legacy discovery rejects symlinks, symlinked
parents, and directory traversal errors before backup-root creation. By default,
backups use a deterministic per-Codex-home root below XDG_STATE_HOME (or the user's
portable local-state directory). The script rejects any default or explicit backup
root equal to or inside the resolved Codex memories tree, including through a
symlink alias, before discovery can include it or any directory is created. It also
rejects roots inside a Git worktree before copying. It atomically creates
memories.backup.<timestamp>.<suffix>, never overwrites a file, and writes
${manifestName} with size and SHA-256 evidence. It does not edit or delete memory
files.\n`,
);
}
function fail(message, code = 2) {
fs.writeSync(2, `Error: ${message}\n`);
process.exit(code);
}
function output(message) {
fs.writeSync(1, `${message}\n`);
}
function expandHome(value) {
if (value === "~") return os.homedir();
if (value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
return path.resolve(value);
}
function parseArgs() {
let codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
let repo = process.cwd();
let backupRoot = null;
let backupRootAlias = null;
const includePaths = [];
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
}
if (arg === "--codex-home") {
const value = args[i + 1];
if (!value) fail("--codex-home requires a path.");
codexHome = value;
i += 1;
continue;
}
if (arg === "--repo") {
const value = args[i + 1];
if (!value) fail("--repo requires a path.");
repo = value;
i += 1;
continue;
}
if (arg === "--backup-root") {
const value = args[i + 1];
if (!value) fail("--backup-root requires a path.");
backupRoot = value;
i += 1;
continue;
}
if (arg === "--backup-root-alias") {
const value = args[i + 1];
if (!value) fail("--backup-root-alias requires a name.");
backupRootAlias = value;
i += 1;
continue;
}
if (arg === "--include") {
const value = args[i + 1];
if (!value) fail("--include requires a path.");
includePaths.push(expandHome(value));
i += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
if (backupRoot && !backupRootAlias) {
fail("--backup-root requires --backup-root-alias for portable recovery.");
}
if (!backupRoot && backupRootAlias) {
fail("--backup-root-alias requires --backup-root.");
}
if (backupRootAlias && !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(backupRootAlias)) {
fail("--backup-root-alias must be a portable name without path separators.");
}
return {
backupRoot: backupRoot ? expandHome(backupRoot) : null,
backupRootAlias,
codexHome: expandHome(codexHome),
includePaths,
repo: path.resolve(repo),
};
}
function pad(value) {
return String(value).padStart(2, "0");
}
function timestamp() {
const now = new Date();
return [
now.getFullYear(),
pad(now.getMonth() + 1),
pad(now.getDate()),
"-",
pad(now.getHours()),
pad(now.getMinutes()),
pad(now.getSeconds()),
].join("");
}
function stateHome() {
const configured = process.env.XDG_STATE_HOME;
if (!configured) return path.join(os.homedir(), ".local", "state");
if (!path.isAbsolute(configured)) fail("XDG_STATE_HOME must be an absolute path.", 1);
return path.resolve(configured);
}
function identityHash(value) {
return crypto.createHash("sha256").update(path.resolve(value)).digest("hex").slice(0, 20);
}
function defaultBackupRoot(codexHome) {
return path.join(stateHome(), "agent-memory-curator-backups", "codex", identityHash(codexHome));
}
function statIdentity(stat) {
return `${stat.dev}:${stat.ino}:${stat.mode & BigInt(fs.constants.S_IFMT)}`;
}
function statVersion(stat) {
return `${statIdentity(stat)}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
}
function sameIdentity(left, right) {
return statIdentity(left) === statIdentity(right);
}
function capturePathSnapshot(value, label) {
const resolved = path.resolve(value);
const filesystemRoot = path.parse(resolved).root;
const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
const snapshots = [];
let current = filesystemRoot;
for (const component of [null, ...components]) {
if (component !== null) current = path.join(current, component);
let stat;
try {
stat = fs.lstatSync(current, { bigint: true });
} catch (error) {
throw new Error(`${label} is not accessible: ${current}: ${error.message}`);
}
if (stat.isSymbolicLink()) throw new Error(`${label} contains symlink component: ${current}`);
snapshots.push({ identity: statIdentity(stat), path: current });
}
return snapshots;
}
function assertPathSnapshot(value, expected, label) {
const current = capturePathSnapshot(value, label);
if (
current.length !== expected.length ||
current.some(
(entry, index) =>
entry.path !== expected[index]?.path || entry.identity !== expected[index]?.identity,
)
) {
throw new Error(`${label} path identity changed during backup: ${path.resolve(value)}`);
}
}
function secureOpenFlags(base, label, { directory = false, nonBlocking = false } = {}) {
let flags = base;
if (Number.isInteger(noFollowFlag)) flags |= noFollowFlag;
if (nonBlocking) {
if (!Number.isInteger(nonBlockFlag)) {
throw new Error(`${label} cannot be opened safely because O_NONBLOCK is unavailable`);
}
flags |= nonBlockFlag;
}
if (directory && Number.isInteger(directoryFlag)) flags |= directoryFlag;
return flags;
}
function openStableRegularFile(value, label) {
const resolved = path.resolve(value);
const before = capturePathSnapshot(resolved, label);
let descriptor;
try {
descriptor = fs.openSync(
resolved,
secureOpenFlags(fs.constants.O_RDONLY, label, { nonBlocking: true }),
);
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
const after = capturePathSnapshot(resolved, label);
const leafStat = fs.lstatSync(resolved, { bigint: true });
if (
!descriptorStat.isFile() ||
!sameIdentity(descriptorStat, leafStat) ||
before.length !== after.length ||
before.some(
(entry, index) =>
entry.path !== after[index]?.path || entry.identity !== after[index]?.identity,
)
) {
throw new Error(`${label} changed while it was opened: ${resolved}`);
}
return { descriptor, descriptorStat, pathSnapshot: after, source: resolved };
} catch (error) {
if (descriptor !== undefined) fs.closeSync(descriptor);
throw error;
}
}
function readStableDescriptor(opened, label) {
const before = fs.fstatSync(opened.descriptor, { bigint: true });
const chunks = [];
const buffer = Buffer.allocUnsafe(64 * 1024);
let position = 0;
while (true) {
const bytesRead = fs.readSync(opened.descriptor, buffer, 0, buffer.length, position);
if (bytesRead === 0) break;
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
position += bytesRead;
}
const after = fs.fstatSync(opened.descriptor, { bigint: true });
if (statVersion(before) !== statVersion(after)) {
throw new Error(`${label} changed while it was read: ${opened.source}`);
}
return Buffer.concat(chunks);
}
function readStablePath(value, label) {
const opened = openStableRegularFile(value, label);
try {
const content = readStableDescriptor(opened, label);
assertPathSnapshot(opened.source, opened.pathSnapshot, label);
return content;
} finally {
fs.closeSync(opened.descriptor);
}
}
function testCheckpoint(name) {
if (testCheckpointName !== name) return;
if (process.env.NODE_ENV !== "test") {
throw new Error("test checkpoints require NODE_ENV=test");
}
testCheckpointOccurrences += 1;
const requestedOccurrence = Number.parseInt(
process.env.AGENT_MEMORY_CURATOR_TEST_CHECKPOINT_OCCURRENCE ?? "1",
10,
);
if (!Number.isSafeInteger(requestedOccurrence) || requestedOccurrence < 1) {
throw new Error("test checkpoint occurrence must be a positive integer");
}
if (testCheckpointOccurrences !== requestedOccurrence) return;
fs.writeSync(2, `TEST_CHECKPOINT:${name}\n`);
const resume = Buffer.alloc(1);
if (fs.readSync(0, resume, 0, 1, null) !== 1 || resume[0] !== 0x63) {
throw new Error(`test checkpoint did not receive continue byte: ${name}`);
}
}
function inspectPath(value, label, { allowMissing = false } = {}) {
const resolved = path.resolve(value);
const filesystemRoot = path.parse(resolved).root;
const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
let current = filesystemRoot;
let stat = fs.lstatSync(filesystemRoot);
for (const component of components) {
current = path.join(current, component);
try {
stat = fs.lstatSync(current);
} catch (error) {
if (allowMissing && error.code === "ENOENT") return null;
throw new Error(`${label} is not accessible: ${current}: ${error.message}`);
}
if (stat.isSymbolicLink()) {
throw new Error(`${label} contains symlink component: ${current}`);
}
}
return stat;
}
function requireReadableDirectory(value, label) {
try {
const stat = inspectPath(value, label);
if (!stat.isDirectory()) throw new Error(`${label} must be an existing directory: ${value}`);
fs.accessSync(value, fs.constants.R_OK | fs.constants.X_OK);
return stat;
} catch (error) {
fail(error.message, 1);
}
}
function requireReadableRegularFile(value, label) {
try {
const opened = openStableRegularFile(value, label);
fs.closeSync(opened.descriptor);
return opened.descriptorStat;
} catch (error) {
if (!error.message.includes("regular")) {
try {
const stat = inspectPath(value, label);
if (!stat.isFile()) {
throw new Error(`${label} must name an existing regular, non-symlink file: ${value}`);
}
} catch (typeError) {
fail(typeError.message, 1);
}
}
fail(error.message, 1);
}
}
function walkRegularFiles(directory) {
const files = [];
function visit(current) {
requireReadableDirectory(current, "legacy discovery directory");
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch (error) {
fail(`legacy discovery could not read directory: ${current}: ${error.message}`, 1);
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isDirectory()) visit(fullPath);
if (entry.isFile()) files.push(path.resolve(fullPath));
if (entry.isSymbolicLink()) fail(`legacy discovery found symlink: ${fullPath}`, 1);
}
}
visit(directory);
return files.sort();
}
function isWithin(root, candidate) {
const relative = path.relative(root, candidate);
return (
relative === "" ||
(!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))
);
}
function resolvePhysicalPathWithMissingTail(value, label) {
const resolved = path.resolve(value);
const missingTail = [];
let current = resolved;
while (true) {
try {
const real = fs.realpathSync.native(current);
return path.resolve(real, ...missingTail.reverse());
} catch (error) {
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
throw new Error(`${label} is not physically resolvable: ${current}: ${error.message}`);
}
const parent = path.dirname(current);
if (parent === current) {
throw new Error(`${label} has no resolvable filesystem ancestor: ${resolved}`);
}
missingTail.push(path.basename(current));
current = parent;
}
}
}
function rejectBackupRootWithinMemories(backupRoot, memoriesRoot) {
try {
const physicalBackupRoot = resolvePhysicalPathWithMissingTail(backupRoot, "backup root");
const physicalMemoriesRoot = resolvePhysicalPathWithMissingTail(
memoriesRoot,
"Codex memories root",
);
if (isWithin(physicalMemoriesRoot, physicalBackupRoot)) {
fail(
`backup root must be outside the resolved Codex memories tree; rejected ${path.resolve(backupRoot)}`,
1,
);
}
} catch (error) {
fail(error.message, 1);
}
}
function findGitWorktreeRoot(start) {
let current = path.resolve(start);
while (true) {
try {
const markerPath = path.join(current, ".git");
const marker = fs.lstatSync(markerPath);
const head = marker.isDirectory() ? fs.lstatSync(path.join(markerPath, "HEAD")) : null;
if (
marker.isSymbolicLink() ||
(head && (head.isFile() || head.isSymbolicLink())) ||
(marker.isFile() && /^gitdir:\s*\S+/m.test(readStablePath(markerPath, "Git marker")))
) {
return current;
}
} catch (error) {
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") {
fail(`could not inspect Git worktree boundary at ${current}: ${error.message}`, 1);
}
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
}
function enterDirectory(name, { checkpoint = null, create = false, label }) {
if (!name || name === "." || name === ".." || name.includes(path.sep)) {
throw new Error(`${label} has an unsafe directory segment: ${name}`);
}
const parentBefore = fs.statSync(".", { bigint: true });
const childPath = path.resolve(name);
if (create) {
try {
fs.mkdirSync(name, { mode: 0o700 });
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
}
const before = fs.lstatSync(name, { bigint: true });
if (before.isSymbolicLink() || !before.isDirectory()) {
throw new Error(`${label} must contain only real directories: ${name}`);
}
process.chdir(name);
try {
if (checkpoint) testCheckpoint(checkpoint);
const current = fs.statSync(".", { bigint: true });
const after = fs.lstatSync(childPath, { bigint: true });
const parentAfter = fs.statSync("..", { bigint: true });
if (
!current.isDirectory() ||
!sameIdentity(before, current) ||
!sameIdentity(after, current) ||
!sameIdentity(parentBefore, parentAfter)
) {
throw new Error(`${label} directory identity changed while entering: ${name}`);
}
return { path: childPath, stat: current };
} catch (error) {
let unwindError = null;
try {
process.chdir("..");
const restoredParent = fs.statSync(".", { bigint: true });
if (!sameIdentity(parentBefore, restoredParent)) {
unwindError = new Error(`${label} parent identity changed while unwinding: ${name}`);
}
} catch (candidate) {
unwindError = candidate;
}
if (unwindError) {
throw new Error(
`${error.message}; could not return to the verified ${label} parent: ${unwindError.message}`,
);
}
throw error;
}
}
function assertStorageCustody(storage, targetRepo) {
assertPathSnapshot(storage.path, storage.pathSnapshot, "backup root");
const leafStat = fs.lstatSync(storage.path, { bigint: true });
if (!sameIdentity(leafStat, storage.stat)) {
throw new Error(`backup root path no longer names the anchored directory: ${storage.path}`);
}
const physicalPath = fs.realpathSync.native(storage.path);
if (physicalPath !== storage.physicalPath) {
throw new Error(`backup root physical custody changed: ${storage.path}`);
}
const targetBoundary = findGitWorktreeRoot(targetRepo) ?? path.resolve(targetRepo);
if (isWithin(targetBoundary, physicalPath) || findGitWorktreeRoot(physicalPath)) {
throw new Error(`backup root moved inside a Git worktree: ${physicalPath}`);
}
if (storage.forbiddenPhysicalRoot) {
const forbidden = resolvePhysicalPathWithMissingTail(
storage.forbiddenPhysicalRoot,
"Codex memories root",
);
if (isWithin(forbidden, physicalPath)) {
throw new Error(`backup root moved inside the resolved Codex memories tree: ${physicalPath}`);
}
}
}
function ensurePrivateBackupRoot(value, targetRepo, forbiddenPhysicalRoot = null) {
const resolved = path.resolve(value);
const targetBoundary = findGitWorktreeRoot(targetRepo) ?? path.resolve(targetRepo);
const containingWorktree = findGitWorktreeRoot(resolved);
if (isWithin(targetBoundary, resolved) || containingWorktree) {
fail(`backup root must be outside Git worktrees; rejected ${resolved}`, 1);
}
const filesystemRoot = path.parse(resolved).root;
const components = path.relative(filesystemRoot, resolved).split(path.sep).filter(Boolean);
const originalCwd = process.cwd();
const originalCwdSnapshot = capturePathSnapshot(originalCwd, "original working directory");
const originalCwdStat = fs.statSync(originalCwd, { bigint: true });
try {
const filesystemRootSnapshot = capturePathSnapshot(filesystemRoot, "filesystem root");
process.chdir(filesystemRoot);
const filesystemRootStat = fs.statSync(".", { bigint: true });
if (
!filesystemRootStat.isDirectory() ||
filesystemRootSnapshot.at(-1)?.identity !== statIdentity(filesystemRootStat)
) {
throw new Error(`filesystem root identity changed while opening: ${filesystemRoot}`);
}
for (const component of components) {
enterDirectory(component, {
create: true,
label: "backup root",
});
}
const storageStat = fs.statSync(".", { bigint: true });
fs.accessSync(".", fs.constants.R_OK | fs.constants.W_OK | fs.constants.X_OK);
const pathSnapshot = capturePathSnapshot(resolved, "backup root");
const leafStat = fs.lstatSync(resolved, { bigint: true });
if (!sameIdentity(leafStat, storageStat)) {
throw new Error(`backup root path identity changed while opening: ${resolved}`);
}
const storage = {
forbiddenPhysicalRoot,
originalCwd,
originalCwdSnapshot,
originalCwdStat,
path: resolved,
pathSnapshot,
physicalPath: fs.realpathSync.native("."),
stat: storageStat,
};
assertStorageCustody(storage, targetRepo);
return storage;
} catch (error) {
try {
process.chdir(originalCwd);
} catch {
// The original error remains authoritative.
}
fail(`backup root is not writable: ${resolved}: ${error.message}`, 1);
}
}
function sanitizeSegment(value) {
return value.replace(/[^a-zA-Z0-9._-]+/g, "_").slice(0, 120) || "artifact";
}
function sha256(content) {
return crypto.createHash("sha256").update(content).digest("hex");
}
function toManifestPath(value) {
return value.split(path.sep).join("/");
}
function buildPlan(files, destinationFor) {
const sources = [...new Set(files.map((file) => path.resolve(file)))].sort();
const destinations = new Map();
return sources.map((source, index) => {
const destination = path.normalize(destinationFor(source, index));
if (!destination || path.isAbsolute(destination) || !isWithin(".", destination)) {
fail(`unsafe backup destination for ${source}: ${destination}`);
}
for (const [existing, otherSource] of destinations) {
if (isWithin(existing, destination) || isWithin(destination, existing)) {
fail(
`destination collision: ${source} and ${otherSource} map to ${destination} and ${existing}`,
1,
);
}
}
destinations.set(destination, source);
let opened;
try {
opened = openStableRegularFile(source, "source");
const content = readStableDescriptor(opened, "source");
assertPathSnapshot(source, opened.pathSnapshot, "source");
const descriptorStat = fs.fstatSync(opened.descriptor, { bigint: true });
return {
content,
destination: toManifestPath(path.join("files", destination)),
sha256: sha256(content),
size: content.length,
source,
sourceObservation: {
identity: statIdentity(descriptorStat),
pathSnapshot: opened.pathSnapshot,
version: statVersion(descriptorStat),
},
};
} catch (error) {
fail(error.message, 1);
} finally {
if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
}
});
}
function revalidateSource(entry) {
let opened;
try {
opened = openStableRegularFile(entry.source, "source revalidation");
const content = readStableDescriptor(opened, "source revalidation");
assertPathSnapshot(entry.source, entry.sourceObservation.pathSnapshot, "source revalidation");
const descriptorStat = fs.fstatSync(opened.descriptor, { bigint: true });
if (
statIdentity(descriptorStat) !== entry.sourceObservation.identity ||
statVersion(descriptorStat) !== entry.sourceObservation.version ||
content.length !== entry.size ||
sha256(content) !== entry.sha256
) {
throw new Error(`source changed after immutable snapshot: ${entry.source}`);
}
} finally {
if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
}
}
function revalidatePlanSources(plan) {
for (const entry of plan) revalidateSource(entry);
}
function writeExclusiveFile(name, content, expectedHash, label, relativePath, taskOwnedFiles) {
if (!name || name === "." || name === ".." || name.includes(path.sep)) {
throw new Error(`${label} has an unsafe file segment: ${name}`);
}
const parentBefore = fs.statSync(".", { bigint: true });
const destination = path.resolve(name);
let descriptor;
try {
descriptor = fs.openSync(
name,
secureOpenFlags(fs.constants.O_RDWR | fs.constants.O_CREAT | fs.constants.O_EXCL, label),
0o600,
);
const before = fs.fstatSync(descriptor, { bigint: true });
const pathBefore = fs.lstatSync(name, { bigint: true });
if (!before.isFile() || !sameIdentity(before, pathBefore) || before.size !== 0n) {
throw new Error(`${label} destination identity was not exclusively created: ${name}`);
}
const observation = {
identity: statIdentity(before),
path: relativePath,
sha256: sha256(Buffer.alloc(0)),
size: 0,
};
taskOwnedFiles.set(relativePath, observation);
testCheckpoint(label === "backup payload" ? "before-destination-write" : "");
let written = 0;
while (written < content.length) {
const count = fs.writeSync(descriptor, content, written, content.length - written, written);
if (count === 0) throw new Error(`${label} destination made no write progress: ${name}`);
written += count;
}
fs.fsyncSync(descriptor);
const afterWrite = fs.fstatSync(descriptor, { bigint: true });
const pathAfter = fs.lstatSync(name, { bigint: true });
const parentAfter = fs.statSync(".", { bigint: true });
if (
!sameIdentity(before, afterWrite) ||
!sameIdentity(afterWrite, pathAfter) ||
!sameIdentity(parentBefore, parentAfter) ||
afterWrite.size !== BigInt(content.length)
) {
throw new Error(`${label} destination identity changed while writing: ${name}`);
}
const copied = readStableDescriptor(
{ descriptor, source: destination },
`${label} destination`,
);
if (sha256(copied) !== expectedHash) {
throw new Error(`${label} content failed verification: ${name}`);
}
observation.sha256 = expectedHash;
observation.size = content.length;
return observation;
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
}
function observeDirectory(observations, relativePath, stat) {
const identity = statIdentity(stat);
const existing = observations.get(relativePath);
if (existing && existing !== identity) {
throw new Error(`backup directory identity changed: ${relativePath}`);
}
observations.set(relativePath, identity);
}
function enterTrackedDirectory(name, relativePath, directoryObservations, directoryStack) {
const entered = enterDirectory(name, {
checkpoint: "after-backup-destination-chdir",
create: true,
label: "backup destination",
});
directoryStack.push({ identity: statIdentity(entered.stat), relativePath });
observeDirectory(directoryObservations, relativePath, entered.stat);
}
function ascendToBackupRoot(directoryStack, backup) {
while (directoryStack.length > 1) {
const currentWitness = directoryStack.at(-1);
const current = fs.statSync(".", { bigint: true });
if (statIdentity(current) !== currentWitness.identity) {
throw new Error(`backup traversal identity changed: ${currentWitness.relativePath}`);
}
process.chdir("..");
directoryStack.pop();
const parentWitness = directoryStack.at(-1);
const parent = fs.statSync(".", { bigint: true });
if (statIdentity(parent) !== parentWitness.identity) {
throw new Error(`backup parent identity changed: ${parentWitness.relativePath || "."}`);
}
}
if (!sameIdentity(fs.statSync(".", { bigint: true }), backup.stat)) {
throw new Error(`could not return to anchored backup root: ${backup.path}`);
}
}
function scanAnchoredBackupTree() {
const tree = { directories: new Map(), files: new Map() };
function visit(relativeDirectory) {
const parentStat = fs.statSync(".", { bigint: true });
const entries = fs
.readdirSync(".", { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
const before = fs.lstatSync(entry.name, { bigint: true });
if (before.isSymbolicLink()) {
throw new Error(`backup reconciliation found symlink: ${relativePath}`);
}
if (before.isDirectory()) {
const childPath = path.resolve(entry.name);
process.chdir(entry.name);
try {
const current = fs.statSync(".", { bigint: true });
const after = fs.lstatSync(childPath, { bigint: true });
if (!sameIdentity(before, current) || !sameIdentity(after, current)) {
throw new Error(`backup directory changed during reconciliation: ${relativePath}`);
}
tree.directories.set(relativePath, statIdentity(current));
visit(relativePath);
} finally {
process.chdir("..");
}
if (!sameIdentity(parentStat, fs.statSync(".", { bigint: true }))) {
throw new Error(`backup parent changed during reconciliation: ${relativeDirectory}`);
}
continue;
}
if (!before.isFile()) {
throw new Error(`backup reconciliation found unsupported entry: ${relativePath}`);
}
let opened;
try {
opened = openStableRegularFile(path.resolve(entry.name), "backup reconciliation");
const content = readStableDescriptor(opened, "backup reconciliation");
const after = fs.fstatSync(opened.descriptor, { bigint: true });
tree.files.set(relativePath, {
identity: statIdentity(after),
sha256: sha256(content),
size: content.length,
});
} finally {
if (opened?.descriptor !== undefined) fs.closeSync(opened.descriptor);
}
}
}
visit("");
return tree;
}
function reconcileBackupTree(directoryObservations, fileObservations) {
const tree = scanAnchoredBackupTree();
const expectedDirectories = [...directoryObservations.keys()].sort();
const actualDirectories = [...tree.directories.keys()].sort();
const expectedFiles = [...fileObservations.keys()].sort();
const actualFiles = [...tree.files.keys()].sort();
if (expectedDirectories.join("\n") !== actualDirectories.join("\n")) {
throw new Error("backup directory set differs from recorded observations");
}
if (expectedFiles.join("\n") !== actualFiles.join("\n")) {
throw new Error("backup file set differs from recorded observations");
}
for (const [relativePath, identity] of directoryObservations) {
if (tree.directories.get(relativePath) !== identity) {
throw new Error(`backup directory identity differs: ${relativePath}`);
}
}
for (const [relativePath, expected] of fileObservations) {
const actual = tree.files.get(relativePath);
if (
!actual ||
actual.identity !== expected.identity ||
actual.size !== expected.size ||
actual.sha256 !== expected.sha256
) {
throw new Error(`backup leaf differs from recorded observation: ${relativePath}`);
}
}
}
function scrubTaskOwnedFiles(taskOwnedFiles) {
const ownedIdentities = new Set(
[...taskOwnedFiles.values()].map((observation) => observation.identity),
);
const sensitiveHashes = new Set(
[...taskOwnedFiles.values()]
.filter((observation) => observation.size > 0)
.map((observation) => observation.sha256),
);
function visit() {
const parentStat = fs.statSync(".", { bigint: true });
const entries = fs
.readdirSync(".", { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const before = fs.lstatSync(entry.name, { bigint: true });
if (before.isSymbolicLink()) continue;
if (before.isDirectory()) {
const childPath = path.resolve(entry.name);
process.chdir(entry.name);
try {
const current = fs.statSync(".", { bigint: true });
if (
!sameIdentity(before, current) ||
!sameIdentity(current, fs.lstatSync(childPath, { bigint: true }))
) {
throw new Error(`scrub directory identity changed: ${childPath}`);
}
visit();
} finally {
process.chdir("..");
}
if (!sameIdentity(parentStat, fs.statSync(".", { bigint: true }))) {
throw new Error("scrub parent identity changed");
}
continue;
}
if (!before.isFile() || !ownedIdentities.has(statIdentity(before))) continue;
let descriptor;
try {
descriptor = fs.openSync(entry.name, secureOpenFlags(fs.constants.O_RDWR, "backup scrub"));
const opened = fs.fstatSync(descriptor, { bigint: true });
if (!sameIdentity(before, opened)) throw new Error("scrub leaf identity changed");
const zeroes = Buffer.alloc(64 * 1024);
let position = 0;
while (position < Number(opened.size)) {
const length = Math.min(zeroes.length, Number(opened.size) - position);
const written = fs.writeSync(descriptor, zeroes, 0, length, position);
if (written === 0) throw new Error("scrub made no write progress");
position += written;
}
fs.fsyncSync(descriptor);
fs.ftruncateSync(descriptor, 0);
fs.fsyncSync(descriptor);
const scrubbed = fs.fstatSync(descriptor, { bigint: true });
if (!sameIdentity(opened, scrubbed) || scrubbed.size !== 0n) {
throw new Error("scrub verification failed");
}
} finally {
if (descriptor !== undefined) fs.closeSync(descriptor);
}
const after = fs.lstatSync(entry.name, { bigint: true });
if (!sameIdentity(before, after)) throw new Error("scrub leaf changed before unlink");
fs.unlinkSync(entry.name);
}
}
visit();
const remaining = scanAnchoredBackupTree();
for (const file of remaining.files.values()) {
if (ownedIdentities.has(file.identity) || sensitiveHashes.has(file.sha256)) {
throw new Error("sensitive task-owned payload remained after scrub");
}
}
}
function restoreOriginalWorkingDirectory(storage) {
process.chdir(storage.originalCwd);
if (!sameIdentity(fs.statSync(".", { bigint: true }), storage.originalCwdStat)) {
throw new Error("original working directory identity changed before restoration");
}
assertPathSnapshot(
storage.originalCwd,
storage.originalCwdSnapshot,
"original working directory",
);
}
function createBackup(storage, prefix, storageLocatorRoot, mode, plan, repo) {
let backupId;
let backupDir;
let backup;
let result;
let failure;
const directoryObservations = new Map();
const taskOwnedFiles = new Map();
const directoryStack = [];
try {
assertStorageCustody(storage, repo);
testCheckpoint("before-backup-mkdtemp");
const temporaryPath = fs.mkdtempSync(`./${prefix}.${timestamp()}.`);
backupId = path.basename(temporaryPath);
enterDirectory(backupId, {
label: "backup directory",
});
fs.chmodSync(".", 0o700);
backupDir = path.join(storage.path, backupId);
assertStorageCustody(storage, repo);
const backupPathSnapshot = capturePathSnapshot(backupDir, "backup directory");
const backupStat = fs.statSync(".", { bigint: true });
if (!sameIdentity(backupStat, fs.lstatSync(backupDir, { bigint: true }))) {
throw new Error(`backup directory path identity changed: ${backupDir}`);
}
backup = { id: backupId, path: backupDir, pathSnapshot: backupPathSnapshot, stat: backupStat };
directoryStack.push({ identity: statIdentity(backupStat), relativePath: "" });
for (const entry of plan) {
assertStorageCustody(storage, repo);
assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
revalidateSource(entry);
const parts = entry.destination.split("/");
const name = parts.pop();
let relativeDirectory = "";
try {
for (const component of parts) {
relativeDirectory = relativeDirectory ? `${relativeDirectory}/${component}` : component;
enterTrackedDirectory(
component,
relativeDirectory,
directoryObservations,
directoryStack,
);
}
writeExclusiveFile(
name,
entry.content,
entry.sha256,
"backup payload",
entry.destination,
taskOwnedFiles,
);
} finally {
ascendToBackupRoot(directoryStack, backup);
}
revalidateSource(entry);
assertStorageCustody(storage, repo);
assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
}
testCheckpoint("before-pre-manifest-reconciliation");
reconcileBackupTree(directoryObservations, taskOwnedFiles);
assertStorageCustody(storage, repo);
const storageLocator = toManifestPath(path.join(storageLocatorRoot, backupId));
const manifestFiles = plan.map(({ destination, sha256: hash, size, source }) => ({
destination,
sha256: hash,
size,
source,
}));
const manifest = {
schemaVersion: 1,
mode,
storagePolicy: "outside-git-worktree",
backupId,
storageLocator,
fileCount: plan.length,
files: manifestFiles,
};
const manifestContent = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
writeExclusiveFile(
manifestName,
manifestContent,
sha256(manifestContent),
"backup manifest",
manifestName,
taskOwnedFiles,
);
testCheckpoint("after-manifest-publication");
reconcileBackupTree(directoryObservations, taskOwnedFiles);
assertStorageCustody(storage, repo);
assertPathSnapshot(backupDir, backupPathSnapshot, "backup directory");
result = { backupDir, backupId, storageLocator };
} catch (error) {
failure = error;
if (backup) {
try {
ascendToBackupRoot(directoryStack, backup);
scrubTaskOwnedFiles(taskOwnedFiles);
} catch (scrubError) {
failure = new Error(`${error.message}; anchored scrub failed: ${scrubError.message}`);
}
}
} finally {
try {
restoreOriginalWorkingDirectory(storage);
} catch (restoreError) {
failure ??= restoreError;
}
}
if (failure) fail(`Could not create complete backup: ${failure.message}`, 1);
return result;
}
const {
backupRoot: requestedBackupRoot,
backupRootAlias,
codexHome,
includePaths,
repo,
} = parseArgs();
const memoriesDir = path.join(codexHome, "memories");
const exactMode = includePaths.length > 0;
requireReadableDirectory(repo, "repo path");
for (const file of includePaths) {
requireReadableRegularFile(file, "--include");
}
if (!exactMode) {
requireReadableDirectory(codexHome, "Codex home");
requireReadableDirectory(memoriesDir, "legacy memories directory");
}
const backupRootCandidate = requestedBackupRoot ?? defaultBackupRoot(codexHome);
rejectBackupRootWithinMemories(backupRootCandidate, memoriesDir);
const files = exactMode ? includePaths : walkRegularFiles(memoriesDir);
if (files.length === 0) fail("No Codex memory files found to back up.", 1);
const plan = buildPlan(files, (file, index) => {
if (exactMode) {
return path.join(
"exact-includes",
`${String(index + 1).padStart(3, "0")}-${sanitizeSegment(path.basename(file))}`,
);
}
const relative = path.relative(memoriesDir, file);
if (!isWithin(memoriesDir, file)) fail(`discovered memory escaped memories root: ${file}`, 1);
return path.join("memories", relative);
});
testCheckpoint("after-source-plan");
try {
revalidatePlanSources(plan);
} catch (error) {
fail(`source revalidation failed before backup-root creation: ${error.message}`, 1);
}
const backupStorage = ensurePrivateBackupRoot(backupRootCandidate, repo, memoriesDir);
const codexHomeIdentityHash = identityHash(codexHome);
const storageLocatorRoot = requestedBackupRoot
? path.join("external-root", backupRootAlias)
: path.join("user-state", "agent-memory-curator-backups", "codex", codexHomeIdentityHash);
const backup = createBackup(
backupStorage,
"memories.backup",
storageLocatorRoot,
exactMode ? "exact" : "legacy",
plan,
repo,
);
output(`Backup created at ${backup.backupDir}`);
output(`Backup ID: ${backup.backupId}`);
output(`Backup storage root: ${backupStorage.path}`);
output(`Backup storage locator: ${backup.storageLocator}`);
output("Backup storage policy: outside-git-worktree");
output(`Backup mode: ${exactMode ? "exact" : "legacy"}`);
output(`Files backed up: ${plan.length}`);
output(`Exact includes backed up: ${exactMode ? plan.length : 0}`);
output(`Manifest: ${path.join(backup.backupDir, manifestName)}`);
scripts/inventory-memories.mjs›
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function usage() {
console.log(`Usage: inventory-memories.mjs [--codex-home PATH] [--json]
List Codex memory files with size and modification metadata.
Options:
--codex-home PATH Use PATH instead of CODEX_HOME or ~/.codex.
--json Print structured JSON instead of text.
-h, --help Show this help.
This script is read-only. It does not print memory file contents.`);
}
function fail(message) {
console.error(`Error: ${message}`);
process.exit(2);
}
function parseArgs() {
let codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
let format = "text";
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
}
if (arg === "--codex-home") {
const value = args[i + 1];
if (!value) fail("--codex-home requires a path.");
codexHome = value;
i += 1;
continue;
}
if (arg === "--json") {
format = "json";
continue;
}
fail(`unknown argument: ${arg}`);
}
return { codexHome, format };
}
function collectFiles(dir, maxDepth) {
const files = [];
function visit(current, depth) {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
const childDepth = depth + 1;
if (entry.isFile() && childDepth <= maxDepth) {
files.push(fullPath);
} else if (entry.isDirectory() && childDepth < maxDepth) {
visit(fullPath, childDepth);
}
}
}
visit(dir, 0);
return files.sort();
}
function formatBytes(bytes) {
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${unitIndex === 0 ? value : value.toFixed(1)}${units[unitIndex]}`;
}
function isDirectory(dir) {
try {
return fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
function classifyKind(file, memoriesDir) {
const relative = path.relative(memoriesDir, file).replaceAll("\\", "/").toLowerCase();
const name = path.basename(relative);
if (relative.includes("rollout_summaries/") || relative.includes("session")) {
return "generated-evidence";
}
if (relative.includes("raw") || relative.includes("candidate") || relative.includes("recent")) {
return "raw-input";
}
if (relative.includes("extensions/") || relative.includes("ad_hoc/notes/")) {
return "update-note";
}
if (relative.includes("skills/")) {
return "skill-memory";
}
if (name === "memory_summary.md" || name === "memory.md" || name.includes("summary")) {
return "curated-summary";
}
if (relative.includes("backup")) {
return "backup";
}
return "unknown";
}
function printJson(payload) {
console.log(JSON.stringify(payload, null, 2));
}
const { codexHome, format } = parseArgs();
const memoriesDir = path.join(codexHome, "memories");
if (!isDirectory(memoriesDir)) {
const payload = {
codex_home: codexHome,
memories_dir: memoriesDir,
files: [],
message: `No memories directory found at: ${memoriesDir}`,
};
if (format === "json") {
printJson(payload);
} else {
console.log(`Codex home: ${codexHome}`);
console.log(payload.message);
}
process.exit(0);
}
const files = collectFiles(memoriesDir, 2);
if (files.length === 0) {
const payload = {
codex_home: codexHome,
memories_dir: memoriesDir,
files: [],
message: `No memory files found under: ${memoriesDir}`,
};
if (format === "json") {
printJson(payload);
} else {
console.log(`Codex home: ${codexHome}`);
console.log(payload.message);
}
process.exit(0);
}
const records = [];
for (const file of files) {
let stats;
try {
stats = fs.statSync(file);
} catch {
continue;
}
records.push({
path: file,
relative_path: path.relative(memoriesDir, file),
kind: classifyKind(file, memoriesDir),
size_bytes: stats.size,
size: formatBytes(stats.size),
modified_at: stats.mtime.toISOString(),
});
}
if (format === "json") {
printJson({
codex_home: codexHome,
memories_dir: memoriesDir,
files: records,
});
} else {
console.log(`Codex home: ${codexHome}`);
for (const record of records) {
console.log(`${record.size} ${record.modified_at} ${record.kind} ${record.path}`);
}
}
scripts/locate-memory-config.mjs›
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function fail(message) {
console.error(`Error: ${message}`);
process.exit(2);
}
let codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
let json = false;
const args = process.argv.slice(2);
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
console.log(`Usage: locate-memory-config.mjs [--codex-home PATH] [--json]
Read-only locator for memory and profile signals anywhere in config.toml.
Output contains line numbers and fixed signal names, never source values.
This is not a TOML parser: comments and strings can match. Inspect matching
sections and their table context before interpreting effective settings.
Exit 0: scanned or missing config; exit 2: invalid arguments or unreadable input.`);
process.exit(0);
} else if (arg === "--codex-home") {
if (!args[index + 1] || args[index + 1].startsWith("--")) {
fail("--codex-home requires a path.");
}
codexHome = args[++index];
} else if (arg === "--json") {
json = true;
} else {
fail("unknown argument; use --help.");
}
}
// Fixed identifiers only: arbitrary keys, profile names, and values never escape.
const signals = [
"features",
"memories",
"profile",
"profiles",
"use_memories",
"generate_memories",
"disable_on_external_context",
"no_memories_if_mcp_or_web_search",
"min_rate_limit_remaining_percent",
"min_rollout_idle_hours",
"max_rollout_age_days",
"max_rollouts_per_startup",
"max_unused_days",
"max_raw_memories_for_consolidation",
"extract_model",
"consolidation_model",
];
const patterns = signals.map((signal) => [signal, new RegExp(`\\b${signal}\\b`)]);
let text;
let status = "scanned";
try {
text = fs.readFileSync(path.join(codexHome, "config.toml"), "utf8");
} catch (error) {
if (error.code !== "ENOENT") fail("config.toml could not be read.");
status = "missing";
text = "";
}
const lines = text.split(/\r?\n/);
const candidates = [];
for (let index = 0; index < lines.length; index += 1) {
const found = patterns.filter(([, pattern]) => pattern.test(lines[index])).map(([key]) => key);
if (found.length) candidates.push({ line: index + 1, signals: found });
}
const result = {
status,
interpretation: "candidate-locations-only",
lines_scanned: status === "missing" ? 0 : lines.length,
candidates,
};
if (json) {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(`Config: ${status}; candidate locations only, not effective settings.`);
for (const candidate of candidates) {
console.log(`${candidate.line}: ${candidate.signals.join(", ")}`);
}
}
scripts/scan-memory-risks.mjs›
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
function usage() {
console.log(`Usage: scan-memory-risks.mjs [--codex-home PATH] [--json] [--max-findings N] [--include-generated-evidence]
Scan Codex memory files for high-risk terms that deserve review.
Options:
--codex-home PATH Use PATH instead of CODEX_HOME or ~/.codex.
--json Print structured JSON instead of text.
--max-findings N Limit returned findings; default is 100.
--include-generated-evidence Also scan rollout/session evidence files.
-h, --help Show this help.
This script is read-only. Output is redacted by default and never includes
the raw matching memory line.
Exit codes:
0 scan completed and no risky lines were found, or no memories directory exists
1 scan completed and one or more risky lines were found
2 usage or input error`);
}
function fail(message) {
console.error(`Error: ${message}`);
process.exit(2);
}
const DEFAULT_MAX_FINDINGS = 100;
function parseArgs() {
let codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
let format = "text";
let includeGeneratedEvidence = false;
let maxFindings = DEFAULT_MAX_FINDINGS;
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
}
if (arg === "--codex-home") {
const value = args[i + 1];
if (!value) fail("--codex-home requires a path.");
codexHome = value;
i += 1;
continue;
}
if (arg === "--json") {
format = "json";
continue;
}
if (arg === "--include-generated-evidence") {
includeGeneratedEvidence = true;
continue;
}
if (arg === "--max-findings") {
const value = args[i + 1];
if (!value) fail("--max-findings requires a positive integer.");
maxFindings = Number.parseInt(value, 10);
if (!Number.isSafeInteger(maxFindings) || maxFindings < 1) {
fail("--max-findings requires a positive integer.");
}
i += 1;
continue;
}
fail(`unknown argument: ${arg}`);
}
return { codexHome, format, includeGeneratedEvidence, maxFindings };
}
function collectFiles(dir) {
const files = [];
function visit(current) {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(current, entry.name);
if (entry.isFile()) {
files.push(fullPath);
} else if (entry.isDirectory()) {
visit(fullPath);
}
}
}
visit(dir);
return files.sort();
}
function isDirectory(dir) {
try {
return fs.statSync(dir).isDirectory();
} catch {
return false;
}
}
const riskRules = [
{
category: "sensitive",
pattern:
/\b(token|secret|password|passwd|api[_-]?key|private key|credential|client[_-]?secret|access[_-]?token|refresh[_-]?token|bearer)\b/i,
},
{
category: "absolute-language",
pattern: /\b(always|never|must|required|forbidden)\b/i,
},
{
category: "temporary",
pattern: /\b(branch|temporary|one-off|todo|debug|old branch|migration branch)\b/i,
},
{
category: "repo-specific",
pattern:
/\b(apps[\\/]|packages[\\/]|pnpm|npm run|yarn|bun|turbo|workspace|repo|repository|pr #|issue #)\b/i,
},
{
category: "local-environment",
pattern: /\b(localhost|127\.0\.0\.1|\/home\/|\/users\/|[a-z]:\\|wsl|\.env)\b/i,
},
{
category: "config",
pattern:
/\b(config\.toml|\[features\]|\[memories\]|use_memories|generate_memories|disable_on_external_context|min_rate_limit_remaining_percent)\b/i,
},
];
function categoriesFor(line) {
return riskRules.filter((rule) => rule.pattern.test(line)).map((rule) => rule.category);
}
function redact(line) {
return line
.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, "$1[REDACTED]@")
.replace(
/\b(api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential)\b(\s*[:=]\s*)(["']?)[^"',\s]+(["']?)/gi,
"$1$2$3[REDACTED]$4",
)
.replace(
/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential)\b[^`'":=]{0,80}\b(?:is|was)\s+)([`'"]?)[^`'"\s.,;]+([`'"]?)/gi,
"$1$2[REDACTED]$3",
)
.replace(
/(\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|client[_-]?secret|secret|token|password|passwd|credential|private key)\b[^`'"]{0,60}[`'"])[^`'"]+([`'"])/gi,
"$1[REDACTED]$2",
)
.replace(/-----BEGIN [^-]*PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY_BEGIN]")
.replace(/-----END [^-]*PRIVATE KEY-----/gi, "[REDACTED_PRIVATE_KEY_END]")
.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "[REDACTED_EMAIL]");
}
function trimLine(line) {
const normalized = line.trim();
if (normalized.length <= 360) return normalized;
return `${normalized.slice(0, 360)} ... [truncated]`;
}
function summarize(findings) {
const counts = {};
for (const finding of findings) {
for (const category of finding.categories) {
counts[category] = (counts[category] ?? 0) + 1;
}
}
return counts;
}
function printJson(payload) {
console.log(JSON.stringify(payload, null, 2));
}
function isGeneratedEvidence(file, memoriesDir) {
const relative = path.relative(memoriesDir, file).replaceAll("\\", "/").toLowerCase();
return (
relative.includes("rollout_summaries/") ||
relative.includes("sessions/") ||
relative.includes("session")
);
}
const { codexHome, format, includeGeneratedEvidence, maxFindings } = parseArgs();
const memoriesDir = path.join(codexHome, "memories");
if (!isDirectory(memoriesDir)) {
const payload = {
codex_home: codexHome,
memories_dir: memoriesDir,
files_considered: 0,
files_scanned: 0,
files_skipped: 0,
skipped_generated_evidence_count: 0,
max_findings: maxFindings,
truncated: false,
findings_count: 0,
summary_by_category: {},
findings: [],
message: `No memories directory found at: ${memoriesDir}`,
};
if (format === "json") {
printJson(payload);
} else {
console.log(payload.message);
}
process.exit(0);
}
const files = collectFiles(memoriesDir);
const findings = [];
let filesScanned = 0;
let filesSkipped = 0;
let skippedGeneratedEvidenceCount = 0;
let truncated = false;
for (const file of files) {
if (!includeGeneratedEvidence && isGeneratedEvidence(file, memoriesDir)) {
filesSkipped += 1;
skippedGeneratedEvidenceCount += 1;
continue;
}
let text;
try {
text = fs.readFileSync(file, "utf8");
} catch {
continue;
}
filesScanned += 1;
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
const categories = categoriesFor(line);
if (categories.length === 0) continue;
findings.push({
id: `R-${String(findings.length + 1).padStart(3, "0")}`,
path: file,
relative_path: path.relative(memoriesDir, file),
line: index + 1,
categories,
redacted_line: trimLine(redact(line)),
});
if (findings.length >= maxFindings) {
truncated = true;
break;
}
}
if (truncated) break;
}
const payload = {
codex_home: codexHome,
memories_dir: memoriesDir,
files_considered: files.length,
files_scanned: filesScanned,
files_skipped: filesSkipped,
skipped_generated_evidence_count: skippedGeneratedEvidenceCount,
max_findings: maxFindings,
truncated,
findings_count: findings.length,
summary_by_category: summarize(findings),
findings,
};
if (format === "json") {
printJson(payload);
} else if (findings.length === 0) {
console.log(`No risky memory lines found under: ${memoriesDir}`);
if (skippedGeneratedEvidenceCount > 0) {
console.log(
`Skipped ${skippedGeneratedEvidenceCount} generated evidence file(s). Re-run with --include-generated-evidence if needed.`,
);
}
} else {
for (const finding of findings) {
console.log(
`${finding.id} ${finding.path}:${finding.line} [${finding.categories.join(",")}] ${finding.redacted_line}`,
);
}
if (truncated) {
console.log(
`Output truncated at ${maxFindings} finding(s). Re-run with --max-findings N if needed.`,
);
}
if (skippedGeneratedEvidenceCount > 0) {
console.log(
`Skipped ${skippedGeneratedEvidenceCount} generated evidence file(s). Re-run with --include-generated-evidence if needed.`,
);
}
console.log(`Summary: ${JSON.stringify(payload.summary_by_category)}`);
}
process.exit(findings.length > 0 ? 1 : 0);
SKILL.md›
---
name: codex-memory-curator
description: Audit and safely curate Codex memory, including stale claims, cross-repo leakage, sensitive entries, and memory configuration. Use when reviewing or cleaning Codex memory, not generic repository docs.
license: Apache-2.0
metadata:
author: stark-ai-de
category: codex-operations
version: "0.2.3"
---
# Codex Memory Curator
## Goal
Audit Codex memory as user-owned durable state; classify stale, unsafe, duplicated, or misplaced claims and route review, planning, persistence, and cleanup. Even when invoked from Cursor, inspect only Codex memory/config, not Cursor state.
## Core principle
Memory is context, not truth. The latest user request, current repo files, `AGENTS.md`, package files, ADRs, and live evidence override stored memories.
## When to use
- Use for review, placement, configuration, planning, or cleanup of Codex memory and its durable configuration.
- Use when memory is stale, conflicting, sensitive, noisy, cross-repository, or causing degraded behavior.
## When not to use
- Do not use for ordinary docs, generic prompt work, or Cursor state unless Codex memory/config is explicitly involved.
- Keep review requests read-only and inspect no personal files beyond Codex memory/config plus the minimum repository evidence needed for conflicts.
## Workflow selection
Expose all eight workflows in this stable order, compactly when intent is clear. Recommend the route matching the requested outcome and delivery; for a bare invocation recommend `review-chat` while asking the user to choose:
| Workflow | Delivery and result |
| ----------------------- | ---------------------------------------------------------------------------- |
| `plan-run-cleanup-file` | One record: review, approved plan, backup, cleanup, verification. |
| `review-chat` | Chat: read-only review and recommendations. |
| `review-file` | One record: read-only review and recommendations. |
| `cleanup-chat` | Chat and backup: review, high-confidence atomic cleanup, verification. |
| `cleanup-file` | One record and backup: review, high-confidence atomic cleanup, verification. |
| `plan-cleanup-chat` | Chat: review and approved plan; no cleanup. |
| `plan-cleanup-file` | One record: review and approved plan; no cleanup. |
| `plan-run-cleanup-chat` | Chat and backup: review, approved plan, cleanup, verification. |
Route from intent instead of adding an `auto` workflow:
- Direct review defaults to `review-chat`; explicit persistence selects `review-file`.
- Explicit cleanup without a delivery preference selects `plan-run-cleanup-file`.
- A clear direct request may select another matching route when delivery and execution intent are explicit.
- Agent-initiated activation may select only a relevant read-only route. Use `review-file` only when the existing task already requests persistence; never infer cleanup.
- A bare invocation, conflicting cues, or ambiguity about review versus cleanup, chat versus file, execution, Codex home, or mutation authority exposes the table and asks the user to choose.
- A mutating route may be selected only when the user already requested cleanup of the identified Codex memory scope.
Before substantive inspection, disclose the complete finite options once and state `Selected`, `Reason`, Codex home/repo target, write scope, expected artifacts, protected state, Plan-mode capability, and unresolved approvals. Reuse facts and exact authority already established in the task; combine known fields into a short announcement. If selection is unambiguous, announce it and proceed. If it is ambiguous, stop before inventory and ask only the unresolved choice. Do not repeat the selector or ask for an unchanged selection again.
Workflow selection does not authorize whole-file deletion, destructive recovery, config changes, paid or external actions, deployment, publication, or scope expansion.
## Inputs to inspect
- Resolve the user-provided Codex home or `${CODEX_HOME:-$HOME/.codex}`, then inspect the requested `memories` surfaces. Inspect `config.toml` only when configuration is requested or needed to resolve an in-scope conflict.
- Inspect current repository evidence only as needed to verify a disputed claim.
- Load the classification, conflict, config-mode, store-anatomy, and safe-editing references below only when their decision is active. Load the report or plan asset whenever producing that artifact.
## Safety rules
- Do not inspect when the route is unresolved, and do not mutate unless the selected route and user request authorize cleanup of the exact target scope.
- Never silently delete, rewrite, truncate, or move memory files.
- Back up every exact file before an approved edit and report the backup path.
- Do not print full secrets, tokens, credentials, customer data, private identifiers, or sensitive personal data.
- If secret-like data is found, redact values in output, identify file and line when possible, recommend removal, and recommend rotation for real credentials.
- If the memory schema is unclear, do not edit it directly. Defer it in the current record or chat result.
- Treat memory files as generated state unless local instructions prove otherwise. Do not rewrite append-only evidence to fix a stale curated claim.
- Do not apply repo-specific assumptions globally. Prefer `AGENTS.md` or repo docs for repo rules.
- Do not run broad destructive commands.
## Workflow
Every route applies the same review quality to the explicitly requested scope before planning or cleanup. A request about one file or claim does not authorize inspecting the entire store: use targeted reads instead of whole-root inventory/scanner commands, and consult other evidence only to resolve an in-scope conflict. Delivery never reduces review quality:
1. Resolve the selected route, Codex home, repo target, persistence path when applicable, and protected state. Discover Codex home as follows:
Use `${CODEX_HOME}` when set; otherwise use the user's home directory plus `.codex`.
2. For requests that explicitly cover the entire memory store, inventory all memory files without dumping contents:
```bash
node scripts/inventory-memories.mjs
```
For a narrower request, skip this whole-store command and inventory only the explicitly requested paths with targeted reads.
3. For a store-wide request, run the redacted risk scanner when looking for sensitive, stale, broad, local, repo-specific, or config-like entries:
```bash
node scripts/scan-memory-risks.mjs --json
```
For a single-file or single-claim review, use targeted reads in the requested scope; do not run this whole-store scanner because it has no file selector. Exit code `1` means findings were found, not that the scan failed. The scanner caps returned findings and skips generated evidence by default; raise `--max-findings` or add `--include-generated-evidence` only when needed.
Use scanner JSON as evidence; report counts and the highest-signal redacted findings instead of pasting the full payload.
4. If configuration is requested or needed to resolve an in-scope conflict, locate memory/config signals across the entire `<codex-home>/config.toml` with `node scripts/locate-memory-config.mjs --json`. The locator emits line numbers and fixed signal names, never values. Inspect only relevant bounded sections, including their table/profile context; redact sensitive values. Candidate matches are not parsed TOML or effective configuration proof.
5. Classify memory mode as disabled, enabled but not injected, enabled and injected, external-context generation disabled, or unknown. Load `references/config-modes.md` for exact mode signals.
6. If multiple memory file types are present, load `references/memory-store-anatomy.md` before deciding what is safe to edit.
7. Read memory files in small chunks; avoid huge dumps and redact sensitive values.
8. Extract one atomic claim per row. Split compound entries before classification.
9. Verify conflicts against only the current repo files needed for the disputed claim. Load `references/conflict-resolution.md` when precedence is unclear.
10. Assign exactly one primary classification per atomic claim: `KEEP`, `KEEP BUT REWRITE`, `MOVE TO AGENTS.md`, `MOVE TO REPO DOCS`, `MOVE TO SKILL`, `MOVE TO CONFIG`, `DELETE`, or `ASK USER`.
11. Tag high-risk entries as useful context only: `stale`, `duplicated`, `too-broad`, `too-specific`, `repo-specific`, `workflow`, `config`, `sensitive`, `conflicting`, or `useful`.
12. Add confidence (`high`, `medium`, or `low`) and a proposed action to every entry.
13. Produce the complete review before planning or editing. Route delivery must not reduce review quality or expand the requested scope.
## Route execution
- `review-chat`: return the review in chat and create no durable curation report.
- `review-file`: persist the single curation record and make no memory or config change.
- `cleanup-chat`: derive only high-confidence atomic actions from the completed review, back up every exact file to be changed, apply them, re-read changed sections, and report verification in chat. Create no durable curation report.
- `cleanup-file`: create the curation record before mutation; if persistence fails, stop. Then back up exact files, apply only high-confidence atomic actions, and complete the same record with execution and verification.
- `plan-cleanup-chat` and `plan-cleanup-file`: enter the Plan lifecycle, resolve the cleanup plan with the user, and stop after approval without changing Codex state.
- `plan-run-cleanup-chat` and `plan-run-cleanup-file`: enter the Plan lifecycle, resolve and approve the complete cleanup plan, recheck state, exit Plan mode, back up exact files, execute only the unchanged plan, and verify. Do not ask a generic second cleanup question after plan approval.
Direct cleanup (`cleanup-chat` or `cleanup-file`) is limited to high-confidence atomic edits, moves, or entry deletion in existing, editable, runtime-owned Codex memory. Defer whole-file deletion, new context files, config, `AGENTS.md`, repository docs, skills, generated append-only evidence, uncertain schemas, medium/low-confidence changes, and any scope expansion. A plan-run route may execute broader curation changes only when the approved plan names each destination, write path, backup, rollback, and separate approval boundary.
## Plan lifecycle
Respect active/requested native Plan mode and actual write permissions on every route: no report, backup, or cleanup write while Plan is active or permission is unknown. Planning may continue read-only without a manual mode switch. For `plan-*` routes or pending material questions, read [references/plan-lifecycle.md](references/plan-lifecycle.md). Reuse unchanged approval; mode exit alone is not approval, and unanswered questions grant no authority. Recheck state before writes and reconfirm only material changes. Plan-only routes never authorize cleanup.
Do not invoke `codex-spec-interviewer` inside this curation workflow. If findings require a broader durable rule, repository spec, or unresolved product decision, finish the selected curation route and offer the interviewer as a separate follow-up.
## File delivery contract
File routes persist exactly one redacted curation record. Prefer an existing repository-native report location; otherwise use `<repo>/.agent-reports/codex-memory-curation/<UTC timestamp>-<selection-id>.md`. Create a new path without overwriting and keep all route output in that record.
The record contains `Review`, `Plan`, `Execution Receipt`, `Deferred Work`, `Backup`, and `Verification`. Use `not applicable` with a reason for phases the route does not perform. Create the record before mutation for `cleanup-file` and `plan-run-cleanup-file`; persistence failure blocks cleanup. Chat routes create no report file. Backup directories remain mandatory safety artifacts and do not count as curation reports.
`Explicit --backup-root requires a stable non-sensitive --backup-root-alias; file routes persist the script-reported portable storage locator and <storage-locator>/backup-manifest.json.` Report exact absolute backup and manifest paths only in non-persisted chat, never in repository artifacts.
## Classification checks
For each atomic claim, test stability, scope, portability, strength, duplication, staleness, sensitivity, higher-precedence conflicts, concise phrasing, and whether `AGENTS.md`, repo docs, a skill, config, or deletion is more precise. Load `references/classification-rubric.md` for the detailed rules and examples.
## References
Read only when needed:
- Classification and conflict: `references/classification-rubric.md`, `references/conflict-resolution.md`.
- Config and store boundaries: `references/config-modes.md`, `references/memory-store-anatomy.md`.
- Safe mutation and examples: `references/safe-editing-procedure.md`, `references/example-review-report.md`.
- Output artifacts: `assets/review-report-template.md`, `assets/cleanup-plan-template.md`.
## Scripts
Use only when needed. All scripts are non-interactive, use Node.js stdlib only, and accept `--help`.
```bash
node scripts/inventory-memories.mjs [--codex-home PATH] [--json]
node scripts/locate-memory-config.mjs [--codex-home PATH] [--json]
node scripts/scan-memory-risks.mjs [--codex-home PATH] [--json] [--max-findings N] [--include-generated-evidence]
node scripts/backup-memories.mjs [--repo PATH] [--codex-home PATH] [--backup-root PATH --backup-root-alias NAME] [--include PATH ...]
```
- The config locator is read-only and scans the complete document, including late tables and dotted/quoted keys. It is a discovery aid; comments and strings can match, so resolve TOML scope, active profiles, and overrides before classifying mode.
- Inventory is read-only. The scanner is read-only, redacts by default, bounds findings, skips generated evidence unless requested, and uses exit `1` for findings rather than execution failure.
- `backup-memories.mjs` creates a no-clobber backup plus `backup-manifest.json` without editing sources. Unredacted backup payloads and manifests stay outside Git worktrees and outside the resolved Codex memories tree, including physical aliases. Preflight rejects unsafe roots and every symlink path component; any traversal error fails before root creation. Use exact `--include` paths for edits; zero includes retains legacy discovery. Before invoking it, read `references/safe-editing-procedure.md` for root safety, preflight, manifest reconciliation, and recovery.
## Output format
Use the workflow announcement above. There, report only unresolved approval gates; in the selected route's result, report its required deliverables and verification according to the route execution and file-delivery contracts below.
Before producing a report, load and follow [`assets/review-report-template.md`](assets/review-report-template.md) as the canonical heading and field contract. File routes copy that complete template into the one curation record; chat routes render only applicable sections in chat and create no report file. Populate every applicable field, use `not applicable` with a reason for skipped phases, and redact sensitive values.
Before edits, complete the review and decision tables. After edits, complete the same record's receipt, including Manifest reconciliation and unmatched paths; New paths (`created-no-preimage`) and rollback; Backup mode and manifest path; Backup integrity result; and the row schema `| Changed path | Backup destination | Bytes | SHA-256 | Verification |`.
## Completion criteria
- One of the eight canonical workflows was selected from clear authority or resolved ambiguity; agent activation never inferred cleanup.
- Applicable memory/config was inventoried or reported missing; generated evidence changed only for explicitly authorized sensitive-data cleanup.
- Every atomic claim has one classification, risk tags, confidence, action, and higher-precedence conflict evidence when applicable.
- Plan/direct-cleanup and destination boundaries remain satisfied.
- Delivery matches the canonical template; every edit has exact backup, manifest reconciliation, re-read, and integrity proof.
## Failure modes
- Missing memory/config: report what is unavailable and whether enablement, app defaults, or a different Codex home may explain it.
- Unknown schema: defer instead of editing or creating sibling state.
- Sensitive or conflicting content: redact; cite location and higher source; recommend removal/rotation or classify for rewrite, move, or deletion.
- Persistence, backup, or approved-state recheck failure: stop before mutation (or before further mutation), report the failure, and return to planning after drift.