@laitszkin/apollo-toolkit 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +2 -0
- package/CHANGELOG.md +22 -0
- package/README.md +2 -0
- package/codex-memory-manager/LICENSE +21 -0
- package/codex-memory-manager/README.md +54 -0
- package/codex-memory-manager/SKILL.md +124 -0
- package/codex-memory-manager/agents/openai.yaml +4 -0
- package/codex-memory-manager/scripts/extract_recent_conversations.py +369 -0
- package/codex-memory-manager/scripts/sync_memory_index.py +130 -0
- package/codex-memory-manager/tests/test_extract_recent_conversations.py +176 -0
- package/codex-memory-manager/tests/test_sync_memory_index.py +84 -0
- package/codex-subagent-orchestration/LICENSE +21 -0
- package/codex-subagent-orchestration/README.md +39 -0
- package/codex-subagent-orchestration/SKILL.md +206 -0
- package/codex-subagent-orchestration/agents/openai.yaml +6 -0
- package/codex-subagent-orchestration/references/custom-agent-template.toml +40 -0
- package/codex-subagent-orchestration/references/routing-rubric.md +100 -0
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -16,6 +16,8 @@ This repository enables users to install and run a curated set of reusable agent
|
|
|
16
16
|
- Users can investigate application logs and produce evidence-backed root-cause findings.
|
|
17
17
|
- Users can answer repository-backed questions with additional web research when needed.
|
|
18
18
|
- Users can commit and push local changes without performing version or release work.
|
|
19
|
+
- Users can manage Codex user-preference memory by reviewing the last 24 hours of chats, storing categorized memory documents under `~/.codex/memory`, and syncing a memory index into `~/.codex/AGENTS.md`.
|
|
20
|
+
- Users can orchestrate Codex subagents for most non-trivial tasks by reusing or creating focused custom agents under `~/.codex/agents`, then delegating exploration, review, verification, and unrelated module work while keeping tightly coupled execution in the main agent.
|
|
19
21
|
- Users can research a topic deeply and produce evidence-based deliverables.
|
|
20
22
|
- Users can research the latest completed market week and produce a PDF watchlist of tradeable instruments for the coming week.
|
|
21
23
|
- Users can turn a marked weekly finance PDF into a concise evidence-based financial event report.
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this repository are documented in this file.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [v2.4.0] - 2026-03-19
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Add `codex-memory-manager` for reviewing the last 24 hours of Codex chats, storing durable preference memory, and syncing a managed memory index into `~/.codex/AGENTS.md`.
|
|
11
|
+
- Add extractor and index-sync helper scripts plus focused tests for the new Codex memory workflow.
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- Update `codex-subagent-orchestration` guidance, prompts, and routing notes to require explicit subagent spawning language for non-trivial tasks.
|
|
15
|
+
|
|
16
|
+
### Removed
|
|
17
|
+
- Remove the standalone OpenAI Codex subagent summary reference from `codex-subagent-orchestration` now that the skill documentation carries the needed guidance directly.
|
|
18
|
+
|
|
19
|
+
## [v2.3.0] - 2026-03-18
|
|
20
|
+
|
|
21
|
+
### Added
|
|
22
|
+
- Add `codex-subagent-orchestration` for default subagent routing on most non-trivial Codex tasks, including reusable custom-agent catalog inspection, creation, and persistence guidance.
|
|
23
|
+
- Add OpenAI-backed subagent references, a reusable custom-agent TOML template, and a routing rubric for splitting exploration, review, verification, and isolated implementation work.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- Restrict `codex-subagent-orchestration` starter model guidance to `gpt-5.4` and `gpt-5.3-codex`.
|
|
27
|
+
- Require reusable subagents to set `model_reasoning_effort` by delegated task complexity instead of using a single fixed effort.
|
|
28
|
+
|
|
7
29
|
## [v2.2.0] - 2026-03-18
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -8,6 +8,8 @@ A curated skill catalog for Codex, OpenClaw, and Trae with a managed installer t
|
|
|
8
8
|
- analyse-app-logs
|
|
9
9
|
- answering-questions-with-research
|
|
10
10
|
- commit-and-push
|
|
11
|
+
- codex-memory-manager
|
|
12
|
+
- codex-subagent-orchestration
|
|
11
13
|
- deep-research-topics
|
|
12
14
|
- develop-new-features
|
|
13
15
|
- discover-edge-cases
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 LaiTszKin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# codex-memory-manager
|
|
2
|
+
|
|
3
|
+
Persist durable user preferences from recent Codex conversations into categorized memory files and a synchronized AGENTS index.
|
|
4
|
+
|
|
5
|
+
## Highlights
|
|
6
|
+
|
|
7
|
+
- Reads the last 24 hours of `~/.codex/sessions` and `~/.codex/archived_sessions`
|
|
8
|
+
- Stores categorized preference memory under `~/.codex/memory/*.md`
|
|
9
|
+
- Keeps a normalized memory index at the end of `~/.codex/AGENTS.md`
|
|
10
|
+
- Adds new index entries automatically when new preference categories appear
|
|
11
|
+
- Preserves the existing language already used in `~/.codex/AGENTS.md`
|
|
12
|
+
|
|
13
|
+
## Project Structure
|
|
14
|
+
|
|
15
|
+
```text
|
|
16
|
+
.
|
|
17
|
+
├── SKILL.md
|
|
18
|
+
├── README.md
|
|
19
|
+
├── LICENSE
|
|
20
|
+
├── agents/
|
|
21
|
+
│ └── openai.yaml
|
|
22
|
+
├── scripts/
|
|
23
|
+
│ ├── extract_recent_conversations.py
|
|
24
|
+
│ └── sync_memory_index.py
|
|
25
|
+
└── tests/
|
|
26
|
+
├── test_extract_recent_conversations.py
|
|
27
|
+
└── test_sync_memory_index.py
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.9+
|
|
33
|
+
- Access to `~/.codex/sessions`
|
|
34
|
+
- Access to `~/.codex/archived_sessions`
|
|
35
|
+
- Write access to `~/.codex/AGENTS.md`
|
|
36
|
+
- Write access to `~/.codex/memory/`
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
Extract the recent conversations:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
python3 scripts/extract_recent_conversations.py --lookback-minutes 1440
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Refresh the AGENTS memory index after updating the memory files:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
python3 scripts/sync_memory_index.py --agents-file ~/.codex/AGENTS.md --memory-dir ~/.codex/memory
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT. See `LICENSE` for details.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codex-memory-manager
|
|
3
|
+
description: Manage persistent Codex user-preference memory from recent conversation history. Use when users ask to learn from the last 24 hours of chats, update `~/.codex/AGENTS.md`, maintain `~/.codex/memory/*.md`, or sync new preference categories discovered in `~/.codex/sessions` and `~/.codex/archived_sessions`.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Codex Memory Manager
|
|
7
|
+
|
|
8
|
+
## Dependencies
|
|
9
|
+
|
|
10
|
+
- Required: none.
|
|
11
|
+
- Conditional: `learn-skill-from-conversations` when the same conversation review should also evolve the skill catalog.
|
|
12
|
+
- Optional: none.
|
|
13
|
+
- Fallback: If `~/.codex/sessions`, `~/.codex/archived_sessions`, or `~/.codex/AGENTS.md` are unavailable, report the missing path and stop instead of guessing.
|
|
14
|
+
|
|
15
|
+
## Standards
|
|
16
|
+
|
|
17
|
+
- Evidence: Derive memory only from actual recent Codex conversations, and keep each stored preference tied to concrete chat evidence.
|
|
18
|
+
- Execution: Extract the last 24 hours first, classify durable user preferences into memory files, then refresh the AGENTS index section.
|
|
19
|
+
- Quality: Ignore one-off instructions, avoid duplicating categories, and preserve the existing language and tone already used in `~/.codex/AGENTS.md`.
|
|
20
|
+
- Output: Report which sessions were reviewed, which memory categories were created or updated, and whether the AGENTS index changed.
|
|
21
|
+
|
|
22
|
+
## Goal
|
|
23
|
+
|
|
24
|
+
Keep a durable, categorized memory of user preferences so future agents can quickly review relevant guidance before starting work.
|
|
25
|
+
|
|
26
|
+
## Required Resources
|
|
27
|
+
|
|
28
|
+
- `scripts/extract_recent_conversations.py` to read the last 24 hours of Codex sessions, including archived sessions.
|
|
29
|
+
- `scripts/sync_memory_index.py` to maintain a normalized memory index section at the end of `~/.codex/AGENTS.md`.
|
|
30
|
+
|
|
31
|
+
## Workflow
|
|
32
|
+
|
|
33
|
+
### 1) Extract the last 24 hours of Codex conversations
|
|
34
|
+
|
|
35
|
+
- Run:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
python3 ~/.codex/skills/codex-memory-manager/scripts/extract_recent_conversations.py --lookback-minutes 1440
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
- The extractor reads both `~/.codex/sessions` and `~/.codex/archived_sessions`.
|
|
42
|
+
- If output is exactly `NO_RECENT_CONVERSATIONS`, stop immediately and report that no memory update is needed.
|
|
43
|
+
- Review every returned `[USER]` and `[ASSISTANT]` block before deciding that a preference is stable.
|
|
44
|
+
- The extractor also cleans up stale session files after reading, matching the existing conversation-learning workflow.
|
|
45
|
+
|
|
46
|
+
### 2) Distill only stable user preferences
|
|
47
|
+
|
|
48
|
+
- Focus on preferences that are durable and reusable, such as:
|
|
49
|
+
- architecture and abstraction preferences
|
|
50
|
+
- code style and naming preferences
|
|
51
|
+
- workflow preferences
|
|
52
|
+
- testing expectations
|
|
53
|
+
- language- or ecosystem-specific preferences
|
|
54
|
+
- reporting and communication format preferences
|
|
55
|
+
- Ignore transient task details, secrets, and one-off requests that are not likely to generalize.
|
|
56
|
+
- Prefer explicit user instructions. Use assistant behavior as supporting context only when it clearly reflects repeated user guidance.
|
|
57
|
+
|
|
58
|
+
### 3) Classify preferences into memory documents
|
|
59
|
+
|
|
60
|
+
- Store memory files under `~/.codex/memory/*.md`.
|
|
61
|
+
- Reuse an existing category file when the new preference clearly belongs there.
|
|
62
|
+
- Create a new category file when the recent chats introduce a distinct new class of preferences. Example: if the existing files are Rust-focused and recent chats introduce stable Java preferences, add a new Java-oriented category file and index it.
|
|
63
|
+
- Keep filenames in kebab-case and scoped to a real category, for example:
|
|
64
|
+
- `architecture-preferences.md`
|
|
65
|
+
- `workflow-preferences.md`
|
|
66
|
+
- `java-preferences.md`
|
|
67
|
+
- Use this normalized structure inside each memory file:
|
|
68
|
+
|
|
69
|
+
```md
|
|
70
|
+
# Architecture Preferences
|
|
71
|
+
|
|
72
|
+
## Scope
|
|
73
|
+
User preferences about system design, reuse, abstractions, and code organization.
|
|
74
|
+
|
|
75
|
+
## Preferences
|
|
76
|
+
- Prefer extending existing modules over parallel implementations.
|
|
77
|
+
- Applies when: adding adjacent behavior in an existing codebase.
|
|
78
|
+
- Evidence: repeated direction from recent Codex conversations reviewed on 2026-03-18.
|
|
79
|
+
- Avoid speculative abstractions and over-engineering.
|
|
80
|
+
- Applies when: choosing between a focused edit and a broader refactor.
|
|
81
|
+
- Evidence: explicit repeated user guidance in recent sessions.
|
|
82
|
+
|
|
83
|
+
## Maintenance
|
|
84
|
+
- Keep entries concrete and action-guiding.
|
|
85
|
+
- Merge duplicates instead of restating the same preference.
|
|
86
|
+
- Replace older statements when newer evidence clearly supersedes them.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### 4) Refresh the AGENTS memory index at the end of `~/.codex/AGENTS.md`
|
|
90
|
+
|
|
91
|
+
- First inspect `~/.codex/AGENTS.md` and mirror its existing language in the memory section instructions.
|
|
92
|
+
- After updating memory files, run `scripts/sync_memory_index.py` to rewrite the managed section at the end of the file.
|
|
93
|
+
- The section must do both of these things explicitly:
|
|
94
|
+
- instruct future agents to review the index before starting work
|
|
95
|
+
- instruct future agents to update the matching memory files and refresh the index when a new category appears
|
|
96
|
+
- Example command in English AGENTS files:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python3 ~/.codex/skills/codex-memory-manager/scripts/sync_memory_index.py \
|
|
100
|
+
--agents-file ~/.codex/AGENTS.md \
|
|
101
|
+
--memory-dir ~/.codex/memory \
|
|
102
|
+
--section-title "## User Memory Index" \
|
|
103
|
+
--instruction-line "Before starting work, review the index below and open any relevant user preference files." \
|
|
104
|
+
--instruction-line "When a new preference category appears, create or update the matching memory file and refresh this index."
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- The script writes a managed block with markdown links to every indexed memory file.
|
|
108
|
+
- Keep the managed block at the tail of `~/.codex/AGENTS.md`; do not scatter memory links elsewhere in the file.
|
|
109
|
+
|
|
110
|
+
### 5) Report the memory update
|
|
111
|
+
|
|
112
|
+
- Summarize:
|
|
113
|
+
- how many sessions were reviewed
|
|
114
|
+
- which categories were created or updated
|
|
115
|
+
- whether a new category was introduced
|
|
116
|
+
- whether the AGENTS memory index changed
|
|
117
|
+
- If no durable preferences were found, say so explicitly and avoid creating placeholder memory files.
|
|
118
|
+
|
|
119
|
+
## Guardrails
|
|
120
|
+
|
|
121
|
+
- Do not store secrets, tokens, credentials, or personal data that should not persist.
|
|
122
|
+
- Do not invent preferences when the evidence is weak or ambiguous.
|
|
123
|
+
- Do not create duplicate categories when a current memory document already covers the same theme.
|
|
124
|
+
- Do not rewrite unrelated parts of `~/.codex/AGENTS.md`; only manage the memory index block at the end.
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
interface:
|
|
2
|
+
display_name: "Codex Memory Manager"
|
|
3
|
+
short_description: "Persist user preferences from recent Codex chats"
|
|
4
|
+
default_prompt: "Use $codex-memory-manager to review the last 24 hours of Codex sessions, update ~/.codex/memory/*.md, and refresh the memory index at the end of ~/.codex/AGENTS.md."
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract recent Codex conversation history from Codex session stores."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Iterable, List, Optional, Sequence, Tuple
|
|
12
|
+
|
|
13
|
+
DEFAULT_LOOKBACK_MINUTES = 24 * 60
|
|
14
|
+
DEFAULT_RETENTION_DAYS = 7
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SessionRecord:
|
|
19
|
+
path: Path
|
|
20
|
+
timestamp_utc: datetime
|
|
21
|
+
messages: Optional[List[Tuple[str, str]]] = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_iso_timestamp(raw: Optional[str]) -> Optional[datetime]:
|
|
25
|
+
if not raw:
|
|
26
|
+
return None
|
|
27
|
+
value = raw.strip()
|
|
28
|
+
if not value:
|
|
29
|
+
return None
|
|
30
|
+
if value.endswith("Z"):
|
|
31
|
+
value = value[:-1] + "+00:00"
|
|
32
|
+
try:
|
|
33
|
+
parsed = datetime.fromisoformat(value)
|
|
34
|
+
except ValueError:
|
|
35
|
+
return None
|
|
36
|
+
if parsed.tzinfo is None:
|
|
37
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
38
|
+
return parsed.astimezone(timezone.utc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def read_session_timestamp(path: Path) -> Optional[datetime]:
|
|
42
|
+
try:
|
|
43
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
44
|
+
first_line = handle.readline().strip()
|
|
45
|
+
except OSError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
if not first_line:
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
first_entry = json.loads(first_line)
|
|
53
|
+
except json.JSONDecodeError:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
if first_entry.get("type") != "session_meta":
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
payload = first_entry.get("payload", {})
|
|
60
|
+
if not isinstance(payload, dict):
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
return parse_iso_timestamp(payload.get("timestamp")) or parse_iso_timestamp(first_entry.get("timestamp"))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def iter_session_paths(root: Path) -> Iterable[Path]:
|
|
67
|
+
if not root.exists() or not root.is_dir():
|
|
68
|
+
return
|
|
69
|
+
yield from root.rglob("*.jsonl")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_recent_sessions(
|
|
73
|
+
session_roots: Sequence[Path],
|
|
74
|
+
cutoff_utc: datetime,
|
|
75
|
+
limit: Optional[int],
|
|
76
|
+
) -> List[SessionRecord]:
|
|
77
|
+
candidates: List[SessionRecord] = []
|
|
78
|
+
seen_paths = set()
|
|
79
|
+
|
|
80
|
+
for root in session_roots:
|
|
81
|
+
for path in iter_session_paths(root):
|
|
82
|
+
resolved_path = path.resolve()
|
|
83
|
+
if resolved_path in seen_paths:
|
|
84
|
+
continue
|
|
85
|
+
seen_paths.add(resolved_path)
|
|
86
|
+
|
|
87
|
+
timestamp_utc = read_session_timestamp(path)
|
|
88
|
+
if timestamp_utc is None:
|
|
89
|
+
continue
|
|
90
|
+
if timestamp_utc < cutoff_utc:
|
|
91
|
+
continue
|
|
92
|
+
candidates.append(SessionRecord(path=path, timestamp_utc=timestamp_utc))
|
|
93
|
+
|
|
94
|
+
candidates.sort(key=lambda record: record.timestamp_utc, reverse=True)
|
|
95
|
+
if limit is None:
|
|
96
|
+
return candidates
|
|
97
|
+
return candidates[:limit]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def sanitize_text(text: str, max_chars: int) -> str:
|
|
101
|
+
cleaned = text.replace("\r\n", "\n").replace("\r", "\n").strip()
|
|
102
|
+
if max_chars <= 0:
|
|
103
|
+
return cleaned
|
|
104
|
+
if len(cleaned) <= max_chars:
|
|
105
|
+
return cleaned
|
|
106
|
+
return cleaned[: max_chars - 1].rstrip() + "..."
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def looks_like_wrapper_message(text: str) -> bool:
|
|
110
|
+
stripped = text.strip()
|
|
111
|
+
if not stripped:
|
|
112
|
+
return True
|
|
113
|
+
lower = stripped.lower()
|
|
114
|
+
return (
|
|
115
|
+
stripped.startswith("# AGENTS.md instructions for")
|
|
116
|
+
or stripped.startswith("<environment_context>")
|
|
117
|
+
or "<collaboration_mode>" in lower
|
|
118
|
+
or stripped.startswith("<permissions instructions>")
|
|
119
|
+
or stripped.startswith("<app-context>")
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def extract_text_from_content(content: Sequence[object]) -> str:
|
|
124
|
+
texts: List[str] = []
|
|
125
|
+
for part in content:
|
|
126
|
+
if not isinstance(part, dict):
|
|
127
|
+
continue
|
|
128
|
+
part_type = part.get("type")
|
|
129
|
+
if part_type in {"input_text", "output_text", "text"}:
|
|
130
|
+
value = part.get("text", "")
|
|
131
|
+
if isinstance(value, str) and value.strip():
|
|
132
|
+
texts.append(value)
|
|
133
|
+
return "\n".join(texts).strip()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def extract_messages_from_event_entries(entries: Iterable[dict], max_chars: int) -> List[Tuple[str, str]]:
|
|
137
|
+
messages: List[Tuple[str, str]] = []
|
|
138
|
+
for entry in entries:
|
|
139
|
+
if entry.get("type") != "event_msg":
|
|
140
|
+
continue
|
|
141
|
+
payload = entry.get("payload", {})
|
|
142
|
+
if not isinstance(payload, dict):
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
payload_type = payload.get("type")
|
|
146
|
+
if payload_type == "user_message":
|
|
147
|
+
text = payload.get("message", "")
|
|
148
|
+
if isinstance(text, str) and text.strip():
|
|
149
|
+
messages.append(("user", sanitize_text(text, max_chars)))
|
|
150
|
+
elif payload_type == "agent_message":
|
|
151
|
+
text = payload.get("message", "")
|
|
152
|
+
if isinstance(text, str) and text.strip():
|
|
153
|
+
messages.append(("assistant", sanitize_text(text, max_chars)))
|
|
154
|
+
return messages
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_messages_from_response_items(entries: Iterable[dict], max_chars: int) -> List[Tuple[str, str]]:
|
|
158
|
+
messages: List[Tuple[str, str]] = []
|
|
159
|
+
for entry in entries:
|
|
160
|
+
if entry.get("type") != "response_item":
|
|
161
|
+
continue
|
|
162
|
+
payload = entry.get("payload", {})
|
|
163
|
+
if not isinstance(payload, dict):
|
|
164
|
+
continue
|
|
165
|
+
if payload.get("type") != "message":
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
role = payload.get("role")
|
|
169
|
+
if role not in {"user", "assistant"}:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
text = extract_text_from_content(payload.get("content", []))
|
|
173
|
+
if not text or looks_like_wrapper_message(text):
|
|
174
|
+
continue
|
|
175
|
+
messages.append((role, sanitize_text(text, max_chars)))
|
|
176
|
+
return messages
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def extract_session_messages(path: Path, max_chars: int) -> List[Tuple[str, str]]:
|
|
180
|
+
entries: List[dict] = []
|
|
181
|
+
try:
|
|
182
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
183
|
+
for line in handle:
|
|
184
|
+
line = line.strip()
|
|
185
|
+
if not line:
|
|
186
|
+
continue
|
|
187
|
+
try:
|
|
188
|
+
entries.append(json.loads(line))
|
|
189
|
+
except json.JSONDecodeError:
|
|
190
|
+
continue
|
|
191
|
+
except OSError:
|
|
192
|
+
return []
|
|
193
|
+
|
|
194
|
+
event_messages = extract_messages_from_event_entries(entries, max_chars)
|
|
195
|
+
if event_messages:
|
|
196
|
+
return event_messages
|
|
197
|
+
return extract_messages_from_response_items(entries, max_chars)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def delete_matching_files(root: Path, predicate) -> int:
|
|
201
|
+
if not root.exists() or not root.is_dir():
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
deleted_count = 0
|
|
205
|
+
for path in root.rglob("*.jsonl"):
|
|
206
|
+
if not predicate(path):
|
|
207
|
+
continue
|
|
208
|
+
try:
|
|
209
|
+
path.unlink()
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
deleted_count += 1
|
|
213
|
+
return deleted_count
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def path_is_same_or_nested(path: Path, root: Optional[Path]) -> bool:
|
|
217
|
+
if root is None:
|
|
218
|
+
return False
|
|
219
|
+
try:
|
|
220
|
+
path.resolve().relative_to(root.resolve())
|
|
221
|
+
return True
|
|
222
|
+
except ValueError:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def cleanup_session_history(
|
|
227
|
+
sessions_dir: Path,
|
|
228
|
+
archived_sessions_dir: Path,
|
|
229
|
+
retention_cutoff_utc: datetime,
|
|
230
|
+
) -> Tuple[int, int]:
|
|
231
|
+
sessions_root = sessions_dir.resolve() if sessions_dir.exists() else None
|
|
232
|
+
removed_old_sessions = delete_matching_files(
|
|
233
|
+
sessions_dir,
|
|
234
|
+
lambda path: (
|
|
235
|
+
(timestamp := read_session_timestamp(path)) is not None
|
|
236
|
+
and timestamp < retention_cutoff_utc
|
|
237
|
+
),
|
|
238
|
+
)
|
|
239
|
+
removed_archived_sessions = delete_matching_files(
|
|
240
|
+
archived_sessions_dir,
|
|
241
|
+
lambda path: not path_is_same_or_nested(path, sessions_root),
|
|
242
|
+
)
|
|
243
|
+
return removed_old_sessions, removed_archived_sessions
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def render_text_output(
|
|
247
|
+
records: Sequence[SessionRecord],
|
|
248
|
+
lookback_minutes: int,
|
|
249
|
+
max_message_chars: int,
|
|
250
|
+
removed_old_sessions: int,
|
|
251
|
+
removed_archived_sessions: int,
|
|
252
|
+
) -> str:
|
|
253
|
+
if not records:
|
|
254
|
+
return "NO_RECENT_CONVERSATIONS"
|
|
255
|
+
|
|
256
|
+
lines: List[str] = [
|
|
257
|
+
f"RECENT_CONVERSATIONS_FOUND={len(records)}",
|
|
258
|
+
f"LOOKBACK_MINUTES={lookback_minutes}",
|
|
259
|
+
"ARCHIVED_SESSIONS_INCLUDED=true",
|
|
260
|
+
f"CLEANUP_REMOVED_OLD_SESSIONS={removed_old_sessions}",
|
|
261
|
+
f"CLEANUP_REMOVED_ARCHIVED_SESSIONS={removed_archived_sessions}",
|
|
262
|
+
]
|
|
263
|
+
|
|
264
|
+
for index, record in enumerate(records, start=1):
|
|
265
|
+
lines.append(f"=== SESSION {index} ===")
|
|
266
|
+
lines.append(f"TIMESTAMP_UTC={record.timestamp_utc.isoformat()}")
|
|
267
|
+
lines.append(f"FILE={record.path}")
|
|
268
|
+
|
|
269
|
+
messages = record.messages
|
|
270
|
+
if messages is None:
|
|
271
|
+
messages = extract_session_messages(record.path, max_message_chars)
|
|
272
|
+
if not messages:
|
|
273
|
+
lines.append("MESSAGES=NONE")
|
|
274
|
+
continue
|
|
275
|
+
|
|
276
|
+
for role, message in messages:
|
|
277
|
+
tag = "USER" if role == "user" else "ASSISTANT"
|
|
278
|
+
lines.append(f"[{tag}]")
|
|
279
|
+
lines.append(message)
|
|
280
|
+
lines.append(f"[/{tag}]")
|
|
281
|
+
|
|
282
|
+
return "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def parse_args() -> argparse.Namespace:
|
|
286
|
+
parser = argparse.ArgumentParser(
|
|
287
|
+
description="Extract the latest conversation history from Codex session stores",
|
|
288
|
+
)
|
|
289
|
+
parser.add_argument(
|
|
290
|
+
"--sessions-dir",
|
|
291
|
+
default="~/.codex/sessions",
|
|
292
|
+
help="Path to the Codex sessions directory (default: ~/.codex/sessions)",
|
|
293
|
+
)
|
|
294
|
+
parser.add_argument(
|
|
295
|
+
"--archived-sessions-dir",
|
|
296
|
+
default="~/.codex/archived_sessions",
|
|
297
|
+
help="Path to archived Codex sessions (default: ~/.codex/archived_sessions)",
|
|
298
|
+
)
|
|
299
|
+
parser.add_argument(
|
|
300
|
+
"--lookback-minutes",
|
|
301
|
+
type=int,
|
|
302
|
+
default=DEFAULT_LOOKBACK_MINUTES,
|
|
303
|
+
help=f"How far back to look for sessions (default: {DEFAULT_LOOKBACK_MINUTES})",
|
|
304
|
+
)
|
|
305
|
+
parser.add_argument(
|
|
306
|
+
"--limit",
|
|
307
|
+
type=int,
|
|
308
|
+
default=None,
|
|
309
|
+
help="Maximum number of sessions to return (default: all within lookback window)",
|
|
310
|
+
)
|
|
311
|
+
parser.add_argument(
|
|
312
|
+
"--max-message-chars",
|
|
313
|
+
type=int,
|
|
314
|
+
default=1600,
|
|
315
|
+
help="Maximum characters per extracted message (default: 1600)",
|
|
316
|
+
)
|
|
317
|
+
parser.add_argument(
|
|
318
|
+
"--retention-days",
|
|
319
|
+
type=int,
|
|
320
|
+
default=DEFAULT_RETENTION_DAYS,
|
|
321
|
+
help=f"Delete sessions older than this many days after reading (default: {DEFAULT_RETENTION_DAYS})",
|
|
322
|
+
)
|
|
323
|
+
return parser.parse_args()
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def main() -> int:
|
|
327
|
+
args = parse_args()
|
|
328
|
+
|
|
329
|
+
sessions_dir = Path(args.sessions_dir).expanduser().resolve()
|
|
330
|
+
archived_sessions_dir = Path(args.archived_sessions_dir).expanduser().resolve()
|
|
331
|
+
lookback_minutes = max(args.lookback_minutes, 1)
|
|
332
|
+
limit = args.limit if args.limit is not None and args.limit > 0 else None
|
|
333
|
+
max_message_chars = max(args.max_message_chars, 100)
|
|
334
|
+
retention_days = max(args.retention_days, 1)
|
|
335
|
+
now_utc = datetime.now(timezone.utc)
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
(not sessions_dir.exists() or not sessions_dir.is_dir())
|
|
339
|
+
and (not archived_sessions_dir.exists() or not archived_sessions_dir.is_dir())
|
|
340
|
+
):
|
|
341
|
+
print("NO_RECENT_CONVERSATIONS")
|
|
342
|
+
return 0
|
|
343
|
+
|
|
344
|
+
cutoff_utc = now_utc - timedelta(minutes=lookback_minutes)
|
|
345
|
+
recent_records = find_recent_sessions((sessions_dir, archived_sessions_dir), cutoff_utc, limit)
|
|
346
|
+
for record in recent_records:
|
|
347
|
+
record.messages = extract_session_messages(record.path, max_message_chars)
|
|
348
|
+
|
|
349
|
+
retention_cutoff_utc = now_utc - timedelta(days=retention_days)
|
|
350
|
+
removed_old_sessions, removed_archived_sessions = cleanup_session_history(
|
|
351
|
+
sessions_dir,
|
|
352
|
+
archived_sessions_dir,
|
|
353
|
+
retention_cutoff_utc,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
print(
|
|
357
|
+
render_text_output(
|
|
358
|
+
recent_records,
|
|
359
|
+
lookback_minutes,
|
|
360
|
+
max_message_chars,
|
|
361
|
+
removed_old_sessions,
|
|
362
|
+
removed_archived_sessions,
|
|
363
|
+
)
|
|
364
|
+
)
|
|
365
|
+
return 0
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
raise SystemExit(main())
|