@laitszkin/apollo-toolkit 3.13.2 → 3.14.1

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.
Files changed (154) hide show
  1. package/AGENTS.md +7 -7
  2. package/CHANGELOG.md +36 -0
  3. package/CLAUDE.md +8 -8
  4. package/analyse-app-logs/SKILL.md +3 -3
  5. package/bin/apollo-toolkit.ts +7 -0
  6. package/codex/codex-memory-manager/SKILL.md +2 -2
  7. package/codex/learn-skill-from-conversations/SKILL.md +3 -3
  8. package/dist/bin/apollo-toolkit.d.ts +2 -0
  9. package/dist/bin/apollo-toolkit.js +7 -0
  10. package/dist/lib/cli.d.ts +41 -0
  11. package/dist/lib/cli.js +655 -0
  12. package/dist/lib/installer.d.ts +59 -0
  13. package/dist/lib/installer.js +404 -0
  14. package/dist/lib/tool-runner.d.ts +19 -0
  15. package/dist/lib/tool-runner.js +536 -0
  16. package/dist/lib/tools/architecture.d.ts +2 -0
  17. package/dist/lib/tools/architecture.js +23 -0
  18. package/dist/lib/tools/create-specs.d.ts +2 -0
  19. package/dist/lib/tools/create-specs.js +175 -0
  20. package/dist/lib/tools/docs-to-voice.d.ts +2 -0
  21. package/dist/lib/tools/docs-to-voice.js +705 -0
  22. package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
  23. package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
  24. package/dist/lib/tools/extract-conversations.d.ts +2 -0
  25. package/dist/lib/tools/extract-conversations.js +105 -0
  26. package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
  27. package/dist/lib/tools/extract-pdf-text.js +92 -0
  28. package/dist/lib/tools/filter-logs.d.ts +2 -0
  29. package/dist/lib/tools/filter-logs.js +94 -0
  30. package/dist/lib/tools/find-github-issues.d.ts +2 -0
  31. package/dist/lib/tools/find-github-issues.js +176 -0
  32. package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
  33. package/dist/lib/tools/generate-storyboard-images.js +419 -0
  34. package/dist/lib/tools/log-cli-utils.d.ts +35 -0
  35. package/dist/lib/tools/log-cli-utils.js +233 -0
  36. package/dist/lib/tools/open-github-issue.d.ts +2 -0
  37. package/dist/lib/tools/open-github-issue.js +750 -0
  38. package/dist/lib/tools/read-github-issue.d.ts +2 -0
  39. package/dist/lib/tools/read-github-issue.js +134 -0
  40. package/dist/lib/tools/render-error-book.d.ts +2 -0
  41. package/dist/lib/tools/render-error-book.js +265 -0
  42. package/dist/lib/tools/render-katex.d.ts +2 -0
  43. package/dist/lib/tools/render-katex.js +294 -0
  44. package/dist/lib/tools/review-threads.d.ts +2 -0
  45. package/dist/lib/tools/review-threads.js +491 -0
  46. package/dist/lib/tools/search-logs.d.ts +2 -0
  47. package/dist/lib/tools/search-logs.js +164 -0
  48. package/dist/lib/tools/sync-memory-index.d.ts +2 -0
  49. package/dist/lib/tools/sync-memory-index.js +113 -0
  50. package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
  51. package/dist/lib/tools/validate-openai-agent-config.js +190 -0
  52. package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
  53. package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
  54. package/dist/lib/types.d.ts +82 -0
  55. package/dist/lib/types.js +2 -0
  56. package/dist/lib/updater.d.ts +34 -0
  57. package/dist/lib/updater.js +112 -0
  58. package/dist/lib/utils/format.d.ts +2 -0
  59. package/dist/lib/utils/format.js +6 -0
  60. package/dist/lib/utils/terminal.d.ts +12 -0
  61. package/dist/lib/utils/terminal.js +26 -0
  62. package/docs-to-voice/SKILL.md +0 -1
  63. package/generate-spec/SKILL.md +1 -1
  64. package/katex/SKILL.md +1 -2
  65. package/lib/cli.ts +780 -0
  66. package/lib/installer.ts +466 -0
  67. package/lib/tool-runner.ts +561 -0
  68. package/lib/tools/architecture.ts +20 -0
  69. package/lib/tools/create-specs.ts +204 -0
  70. package/lib/tools/docs-to-voice.ts +799 -0
  71. package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
  72. package/lib/tools/extract-conversations.ts +114 -0
  73. package/lib/tools/extract-pdf-text.ts +99 -0
  74. package/lib/tools/filter-logs.ts +118 -0
  75. package/lib/tools/find-github-issues.ts +211 -0
  76. package/lib/tools/generate-storyboard-images.ts +455 -0
  77. package/lib/tools/log-cli-utils.ts +262 -0
  78. package/lib/tools/open-github-issue.ts +930 -0
  79. package/lib/tools/read-github-issue.ts +179 -0
  80. package/lib/tools/render-error-book.ts +300 -0
  81. package/lib/tools/render-katex.ts +325 -0
  82. package/lib/tools/review-threads.ts +590 -0
  83. package/lib/tools/search-logs.ts +200 -0
  84. package/lib/tools/sync-memory-index.ts +114 -0
  85. package/lib/tools/validate-openai-agent-config.ts +213 -0
  86. package/lib/tools/validate-skill-frontmatter.ts +124 -0
  87. package/lib/types.ts +90 -0
  88. package/lib/updater.ts +165 -0
  89. package/lib/utils/format.ts +7 -0
  90. package/lib/utils/terminal.ts +22 -0
  91. package/open-github-issue/SKILL.md +2 -2
  92. package/optimise-skill/SKILL.md +1 -1
  93. package/package.json +13 -4
  94. package/resources/project-architecture/assets/architecture.css +764 -0
  95. package/resources/project-architecture/assets/viewer.client.js +144 -0
  96. package/resources/project-architecture/index.html +42 -0
  97. package/review-spec-related-changes/SKILL.md +1 -1
  98. package/solve-issues-found-during-review/SKILL.md +2 -1
  99. package/tsconfig.json +28 -0
  100. package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
  101. package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
  102. package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
  103. package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
  104. package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
  105. package/analyse-app-logs/scripts/search_logs.py +0 -137
  106. package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
  107. package/analyse-app-logs/tests/test_search_logs.py +0 -100
  108. package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
  109. package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
  110. package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
  111. package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
  112. package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
  113. package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
  114. package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
  115. package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
  116. package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
  117. package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
  118. package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
  119. package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
  120. package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
  121. package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
  122. package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
  123. package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
  124. package/generate-spec/scripts/create-specs +0 -215
  125. package/generate-spec/tests/test_create_specs.py +0 -200
  126. package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
  127. package/init-project-html/scripts/architecture.js +0 -296
  128. package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
  129. package/katex/scripts/render_katex.py +0 -247
  130. package/katex/scripts/render_katex.sh +0 -11
  131. package/katex/tests/test_render_katex.py +0 -174
  132. package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
  133. package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
  134. package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
  135. package/open-github-issue/scripts/open_github_issue.py +0 -705
  136. package/open-github-issue/tests/test_open_github_issue.py +0 -381
  137. package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
  138. package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
  139. package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
  140. package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
  141. package/read-github-issue/scripts/find_issues.py +0 -148
  142. package/read-github-issue/scripts/read_issue.py +0 -108
  143. package/read-github-issue/tests/test_find_issues.py +0 -127
  144. package/read-github-issue/tests/test_read_issue.py +0 -109
  145. package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
  146. package/resolve-review-comments/scripts/review_threads.py +0 -425
  147. package/resolve-review-comments/tests/test_review_threads.py +0 -74
  148. package/scripts/validate_openai_agent_config.py +0 -209
  149. package/scripts/validate_skill_frontmatter.py +0 -131
  150. package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
  151. package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
  152. package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
  153. package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
  154. package/weekly-financial-event-report/tests/test_extract_pdf_text_pdfkit.py +0 -64
@@ -1,177 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Edge-case tests for extract_recent_conversations.py."""
3
-
4
- from __future__ import annotations
5
-
6
- import json
7
- import subprocess
8
- import sys
9
- import tempfile
10
- import unittest
11
- from datetime import datetime, timedelta, timezone
12
- from pathlib import Path
13
-
14
-
15
- SCRIPT_PATH = (
16
- Path(__file__).resolve().parents[1] / "scripts" / "extract_recent_conversations.py"
17
- )
18
-
19
-
20
- def write_session(path: Path, timestamp: datetime) -> None:
21
- path.parent.mkdir(parents=True, exist_ok=True)
22
- entries = [
23
- {
24
- "type": "session_meta",
25
- "payload": {"timestamp": timestamp.isoformat()},
26
- },
27
- {
28
- "type": "event_msg",
29
- "payload": {"type": "user_message", "message": f"user:{path.stem}"},
30
- },
31
- {
32
- "type": "event_msg",
33
- "payload": {"type": "agent_message", "message": f"assistant:{path.stem}"},
34
- },
35
- ]
36
- with path.open("w", encoding="utf-8") as handle:
37
- for entry in entries:
38
- handle.write(json.dumps(entry) + "\n")
39
-
40
-
41
- def run_extractor(
42
- sessions_dir: Path,
43
- archived_dir: Path | None = None,
44
- *extra_args: str,
45
- ) -> str:
46
- effective_archived_dir = archived_dir or (sessions_dir.parent / "__isolated_archived_sessions__")
47
- cmd = [
48
- sys.executable,
49
- str(SCRIPT_PATH),
50
- "--sessions-dir",
51
- str(sessions_dir),
52
- "--archived-sessions-dir",
53
- str(effective_archived_dir),
54
- ]
55
- cmd.extend(extra_args)
56
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
57
- return result.stdout
58
-
59
-
60
- class ExtractRecentConversationsTests(unittest.TestCase):
61
- def test_default_lookback_covers_last_24_hours(self) -> None:
62
- with tempfile.TemporaryDirectory() as tmp:
63
- sessions_root = Path(tmp) / "sessions"
64
- now = datetime.now(timezone.utc)
65
- write_session(sessions_root / "recent.jsonl", now - timedelta(hours=3))
66
-
67
- output = run_extractor(sessions_root)
68
-
69
- self.assertIn("RECENT_CONVERSATIONS_FOUND=1", output)
70
- self.assertIn("LOOKBACK_MINUTES=1440", output)
71
-
72
- def test_limit_zero_is_treated_as_unlimited(self) -> None:
73
- with tempfile.TemporaryDirectory() as tmp:
74
- sessions_root = Path(tmp) / "sessions"
75
- now = datetime.now(timezone.utc)
76
- write_session(sessions_root / "a.jsonl", now - timedelta(minutes=5))
77
- write_session(sessions_root / "b.jsonl", now - timedelta(minutes=10))
78
- write_session(sessions_root / "c.jsonl", now - timedelta(minutes=15))
79
-
80
- output = run_extractor(
81
- sessions_root,
82
- None,
83
- "--lookback-minutes",
84
- "60",
85
- "--limit",
86
- "0",
87
- )
88
-
89
- self.assertIn("RECENT_CONVERSATIONS_FOUND=3", output)
90
-
91
- def test_archived_sessions_are_read_before_cleanup(self) -> None:
92
- with tempfile.TemporaryDirectory() as tmp:
93
- sessions_root = Path(tmp) / "sessions"
94
- archived_root = Path(tmp) / "archived_sessions"
95
- now = datetime.now(timezone.utc)
96
- archived_file = archived_root / "archived.jsonl"
97
- write_session(archived_file, now - timedelta(minutes=30))
98
-
99
- output = run_extractor(
100
- sessions_root,
101
- archived_root,
102
- "--lookback-minutes",
103
- "60",
104
- )
105
-
106
- self.assertIn("RECENT_CONVERSATIONS_FOUND=1", output)
107
- self.assertIn("ARCHIVED_SESSIONS_INCLUDED=true", output)
108
- self.assertIn("user:archived", output)
109
- self.assertIn("assistant:archived", output)
110
- self.assertIn("CLEANUP_REMOVED_ARCHIVED_SESSIONS=1", output)
111
- self.assertFalse(archived_file.exists())
112
-
113
- def test_cleanup_removes_only_old_sessions_from_sessions_dir(self) -> None:
114
- with tempfile.TemporaryDirectory() as tmp:
115
- sessions_root = Path(tmp) / "sessions"
116
- now = datetime.now(timezone.utc)
117
- recent_file = sessions_root / "recent.jsonl"
118
- old_file = sessions_root / "old.jsonl"
119
- write_session(recent_file, now - timedelta(hours=6))
120
- write_session(old_file, now - timedelta(days=8))
121
-
122
- output = run_extractor(
123
- sessions_root,
124
- None,
125
- "--lookback-minutes",
126
- "1440",
127
- "--retention-days",
128
- "7",
129
- )
130
-
131
- self.assertIn("RECENT_CONVERSATIONS_FOUND=1", output)
132
- self.assertIn("CLEANUP_REMOVED_OLD_SESSIONS=1", output)
133
- self.assertTrue(recent_file.exists())
134
- self.assertFalse(old_file.exists())
135
-
136
- def test_archived_cleanup_skips_active_sessions_when_paths_overlap(self) -> None:
137
- with tempfile.TemporaryDirectory() as tmp:
138
- sessions_root = Path(tmp) / "sessions"
139
- now = datetime.now(timezone.utc)
140
- recent_file = sessions_root / "recent.jsonl"
141
- write_session(recent_file, now - timedelta(minutes=5))
142
-
143
- output = run_extractor(
144
- sessions_root,
145
- sessions_root,
146
- "--lookback-minutes",
147
- "60",
148
- )
149
-
150
- self.assertIn("RECENT_CONVERSATIONS_FOUND=1", output)
151
- self.assertIn("CLEANUP_REMOVED_ARCHIVED_SESSIONS=0", output)
152
- self.assertTrue(recent_file.exists())
153
-
154
- def test_limit_one_only_returns_latest_session_across_sources(self) -> None:
155
- with tempfile.TemporaryDirectory() as tmp:
156
- sessions_root = Path(tmp) / "sessions"
157
- archived_root = Path(tmp) / "archived_sessions"
158
- now = datetime.now(timezone.utc)
159
- write_session(sessions_root / "older.jsonl", now - timedelta(minutes=20))
160
- newest = archived_root / "newest.jsonl"
161
- write_session(newest, now - timedelta(minutes=5))
162
-
163
- output = run_extractor(
164
- sessions_root,
165
- archived_root,
166
- "--lookback-minutes",
167
- "60",
168
- "--limit",
169
- "1",
170
- )
171
-
172
- self.assertIn("RECENT_CONVERSATIONS_FOUND=1", output)
173
- self.assertIn("newest.jsonl", output)
174
-
175
-
176
- if __name__ == "__main__":
177
- unittest.main()
@@ -1,37 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for the codex-memory-manager memory template reference."""
3
-
4
- from __future__ import annotations
5
-
6
- import unittest
7
- from pathlib import Path
8
-
9
-
10
- TEMPLATE_PATH = (
11
- Path(__file__).resolve().parents[1]
12
- / "references"
13
- / "templates"
14
- / "memory-file.md"
15
- )
16
-
17
-
18
- class MemoryTemplateTests(unittest.TestCase):
19
- def test_template_contains_required_sections(self) -> None:
20
- content = TEMPLATE_PATH.read_text(encoding="utf-8")
21
-
22
- self.assertIn("# User Memory - [Category Name]", content)
23
- self.assertIn("## Scope", content)
24
- self.assertIn("## Preferences", content)
25
- self.assertIn("## Maintenance", content)
26
- self.assertIn("## Evidence notes", content)
27
-
28
- def test_template_enforces_preference_first_generalization_rules(self) -> None:
29
- content = TEMPLATE_PATH.read_text(encoding="utf-8")
30
-
31
- self.assertIn("preference-heavy and reusable across projects", content)
32
- self.assertIn("Remove or rewrite project names, issue numbers, branch names", content)
33
- self.assertIn("Split the file when it starts mixing unrelated decision domains", content)
34
-
35
-
36
- if __name__ == "__main__":
37
- unittest.main()
@@ -1,84 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Tests for sync_memory_index.py."""
3
-
4
- from __future__ import annotations
5
-
6
- import subprocess
7
- import sys
8
- import tempfile
9
- import unittest
10
- from pathlib import Path
11
-
12
- SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "sync_memory_index.py"
13
-
14
-
15
- def run_sync(agents_file: Path, memory_dir: Path, *extra_args: str) -> str:
16
- cmd = [
17
- sys.executable,
18
- str(SCRIPT_PATH),
19
- "--agents-file",
20
- str(agents_file),
21
- "--memory-dir",
22
- str(memory_dir),
23
- ]
24
- cmd.extend(extra_args)
25
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
26
- return result.stdout
27
-
28
-
29
- class SyncMemoryIndexTests(unittest.TestCase):
30
- def test_appends_memory_index_with_sorted_links(self) -> None:
31
- with tempfile.TemporaryDirectory() as tmp:
32
- root = Path(tmp)
33
- agents_file = root / "AGENTS.md"
34
- agents_file.write_text("# Base Instructions\n", encoding="utf-8")
35
- memory_dir = root / "memory"
36
- memory_dir.mkdir()
37
- (memory_dir / "workflow-preferences.md").write_text("# Workflow Preferences\n", encoding="utf-8")
38
- (memory_dir / "architecture-preferences.md").write_text("# Architecture Preferences\n", encoding="utf-8")
39
-
40
- run_sync(agents_file, memory_dir)
41
-
42
- content = agents_file.read_text(encoding="utf-8")
43
- self.assertIn("## User Memory Index", content)
44
- self.assertIn("Before starting work, review the index below", content)
45
- self.assertLess(
46
- content.index("[Architecture Preferences]"),
47
- content.index("[Workflow Preferences]"),
48
- )
49
-
50
- def test_replaces_existing_managed_section_without_duplication(self) -> None:
51
- with tempfile.TemporaryDirectory() as tmp:
52
- root = Path(tmp)
53
- agents_file = root / "AGENTS.md"
54
- agents_file.write_text(
55
- "# Base\n\n<!-- codex-memory-manager:start -->\nold\n<!-- codex-memory-manager:end -->\n",
56
- encoding="utf-8",
57
- )
58
- memory_dir = root / "memory"
59
- memory_dir.mkdir()
60
- (memory_dir / "style-preferences.md").write_text("# Style Preferences\n", encoding="utf-8")
61
-
62
- run_sync(agents_file, memory_dir, "--instruction-line", "Read this first.")
63
-
64
- content = agents_file.read_text(encoding="utf-8")
65
- self.assertEqual(content.count("<!-- codex-memory-manager:start -->"), 1)
66
- self.assertIn("Read this first.", content)
67
- self.assertNotIn("\nold\n", content)
68
-
69
- def test_uses_filename_when_heading_is_missing(self) -> None:
70
- with tempfile.TemporaryDirectory() as tmp:
71
- root = Path(tmp)
72
- agents_file = root / "AGENTS.md"
73
- memory_dir = root / "memory"
74
- memory_dir.mkdir()
75
- (memory_dir / "java-preferences.md").write_text("No heading here\n", encoding="utf-8")
76
-
77
- run_sync(agents_file, memory_dir)
78
-
79
- content = agents_file.read_text(encoding="utf-8")
80
- self.assertIn("[Java Preferences]", content)
81
-
82
-
83
- if __name__ == "__main__":
84
- unittest.main()
@@ -1,369 +0,0 @@
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())