@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.
- package/AGENTS.md +7 -7
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +8 -8
- package/analyse-app-logs/SKILL.md +3 -3
- package/bin/apollo-toolkit.ts +7 -0
- package/codex/codex-memory-manager/SKILL.md +2 -2
- package/codex/learn-skill-from-conversations/SKILL.md +3 -3
- package/dist/bin/apollo-toolkit.d.ts +2 -0
- package/dist/bin/apollo-toolkit.js +7 -0
- package/dist/lib/cli.d.ts +41 -0
- package/dist/lib/cli.js +655 -0
- package/dist/lib/installer.d.ts +59 -0
- package/dist/lib/installer.js +404 -0
- package/dist/lib/tool-runner.d.ts +19 -0
- package/dist/lib/tool-runner.js +536 -0
- package/dist/lib/tools/architecture.d.ts +2 -0
- package/dist/lib/tools/architecture.js +23 -0
- package/dist/lib/tools/create-specs.d.ts +2 -0
- package/dist/lib/tools/create-specs.js +175 -0
- package/dist/lib/tools/docs-to-voice.d.ts +2 -0
- package/dist/lib/tools/docs-to-voice.js +705 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.d.ts +2 -0
- package/dist/lib/tools/enforce-video-aspect-ratio.js +312 -0
- package/dist/lib/tools/extract-conversations.d.ts +2 -0
- package/dist/lib/tools/extract-conversations.js +105 -0
- package/dist/lib/tools/extract-pdf-text.d.ts +2 -0
- package/dist/lib/tools/extract-pdf-text.js +92 -0
- package/dist/lib/tools/filter-logs.d.ts +2 -0
- package/dist/lib/tools/filter-logs.js +94 -0
- package/dist/lib/tools/find-github-issues.d.ts +2 -0
- package/dist/lib/tools/find-github-issues.js +176 -0
- package/dist/lib/tools/generate-storyboard-images.d.ts +2 -0
- package/dist/lib/tools/generate-storyboard-images.js +419 -0
- package/dist/lib/tools/log-cli-utils.d.ts +35 -0
- package/dist/lib/tools/log-cli-utils.js +233 -0
- package/dist/lib/tools/open-github-issue.d.ts +2 -0
- package/dist/lib/tools/open-github-issue.js +750 -0
- package/dist/lib/tools/read-github-issue.d.ts +2 -0
- package/dist/lib/tools/read-github-issue.js +134 -0
- package/dist/lib/tools/render-error-book.d.ts +2 -0
- package/dist/lib/tools/render-error-book.js +265 -0
- package/dist/lib/tools/render-katex.d.ts +2 -0
- package/dist/lib/tools/render-katex.js +294 -0
- package/dist/lib/tools/review-threads.d.ts +2 -0
- package/dist/lib/tools/review-threads.js +491 -0
- package/dist/lib/tools/search-logs.d.ts +2 -0
- package/dist/lib/tools/search-logs.js +164 -0
- package/dist/lib/tools/sync-memory-index.d.ts +2 -0
- package/dist/lib/tools/sync-memory-index.js +113 -0
- package/dist/lib/tools/validate-openai-agent-config.d.ts +2 -0
- package/dist/lib/tools/validate-openai-agent-config.js +190 -0
- package/dist/lib/tools/validate-skill-frontmatter.d.ts +2 -0
- package/dist/lib/tools/validate-skill-frontmatter.js +118 -0
- package/dist/lib/types.d.ts +82 -0
- package/dist/lib/types.js +2 -0
- package/dist/lib/updater.d.ts +34 -0
- package/dist/lib/updater.js +112 -0
- package/dist/lib/utils/format.d.ts +2 -0
- package/dist/lib/utils/format.js +6 -0
- package/dist/lib/utils/terminal.d.ts +12 -0
- package/dist/lib/utils/terminal.js +26 -0
- package/docs-to-voice/SKILL.md +0 -1
- package/generate-spec/SKILL.md +1 -1
- package/katex/SKILL.md +1 -2
- package/lib/cli.ts +780 -0
- package/lib/installer.ts +466 -0
- package/lib/tool-runner.ts +561 -0
- package/lib/tools/architecture.ts +20 -0
- package/lib/tools/create-specs.ts +204 -0
- package/lib/tools/docs-to-voice.ts +799 -0
- package/lib/tools/enforce-video-aspect-ratio.ts +368 -0
- package/lib/tools/extract-conversations.ts +114 -0
- package/lib/tools/extract-pdf-text.ts +99 -0
- package/lib/tools/filter-logs.ts +118 -0
- package/lib/tools/find-github-issues.ts +211 -0
- package/lib/tools/generate-storyboard-images.ts +455 -0
- package/lib/tools/log-cli-utils.ts +262 -0
- package/lib/tools/open-github-issue.ts +930 -0
- package/lib/tools/read-github-issue.ts +179 -0
- package/lib/tools/render-error-book.ts +300 -0
- package/lib/tools/render-katex.ts +325 -0
- package/lib/tools/review-threads.ts +590 -0
- package/lib/tools/search-logs.ts +200 -0
- package/lib/tools/sync-memory-index.ts +114 -0
- package/lib/tools/validate-openai-agent-config.ts +213 -0
- package/lib/tools/validate-skill-frontmatter.ts +124 -0
- package/lib/types.ts +90 -0
- package/lib/updater.ts +165 -0
- package/lib/utils/format.ts +7 -0
- package/lib/utils/terminal.ts +22 -0
- package/open-github-issue/SKILL.md +2 -2
- package/optimise-skill/SKILL.md +1 -1
- package/package.json +13 -4
- package/resources/project-architecture/assets/architecture.css +764 -0
- package/resources/project-architecture/assets/viewer.client.js +144 -0
- package/resources/project-architecture/index.html +42 -0
- package/review-spec-related-changes/SKILL.md +1 -1
- package/solve-issues-found-during-review/SKILL.md +2 -1
- package/tsconfig.json +28 -0
- package/analyse-app-logs/scripts/__pycache__/filter_logs_by_time.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/log_cli_utils.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/__pycache__/search_logs.cpython-312.pyc +0 -0
- package/analyse-app-logs/scripts/filter_logs_by_time.py +0 -64
- package/analyse-app-logs/scripts/log_cli_utils.py +0 -112
- package/analyse-app-logs/scripts/search_logs.py +0 -137
- package/analyse-app-logs/tests/test_filter_logs_by_time.py +0 -95
- package/analyse-app-logs/tests/test_search_logs.py +0 -100
- package/codex/codex-memory-manager/scripts/extract_recent_conversations.py +0 -369
- package/codex/codex-memory-manager/scripts/sync_memory_index.py +0 -130
- package/codex/codex-memory-manager/tests/test_extract_recent_conversations.py +0 -177
- package/codex/codex-memory-manager/tests/test_memory_template.py +0 -37
- package/codex/codex-memory-manager/tests/test_sync_memory_index.py +0 -84
- package/codex/learn-skill-from-conversations/scripts/extract_recent_conversations.py +0 -369
- package/codex/learn-skill-from-conversations/tests/test_extract_recent_conversations.py +0 -177
- package/docs-to-voice/scripts/__pycache__/docs_to_voice.cpython-312.pyc +0 -0
- package/docs-to-voice/scripts/docs_to_voice.py +0 -1385
- package/docs-to-voice/scripts/docs_to_voice.sh +0 -11
- package/docs-to-voice/tests/test_docs_to_voice_api_max_chars.py +0 -210
- package/docs-to-voice/tests/test_docs_to_voice_sentence_timeline.py +0 -115
- package/docs-to-voice/tests/test_docs_to_voice_settings.py +0 -43
- package/docs-to-voice/tests/test_docs_to_voice_shell_wrapper.py +0 -51
- package/docs-to-voice/tests/test_docs_to_voice_speech_rate.py +0 -57
- package/generate-spec/scripts/__pycache__/create-specscpython-312.pyc +0 -0
- package/generate-spec/scripts/create-specs +0 -215
- package/generate-spec/tests/test_create_specs.py +0 -200
- package/init-project-html/scripts/architecture-bootstrap-render.js +0 -16
- package/init-project-html/scripts/architecture.js +0 -296
- package/katex/scripts/__pycache__/render_katex.cpython-312.pyc +0 -0
- package/katex/scripts/render_katex.py +0 -247
- package/katex/scripts/render_katex.sh +0 -11
- package/katex/tests/test_render_katex.py +0 -174
- package/learning-error-book/scripts/render_error_book_json_to_pdf.py +0 -590
- package/learning-error-book/tests/test_render_error_book_json_to_pdf.py +0 -134
- package/open-github-issue/scripts/__pycache__/open_github_issue.cpython-312.pyc +0 -0
- package/open-github-issue/scripts/open_github_issue.py +0 -705
- package/open-github-issue/tests/test_open_github_issue.py +0 -381
- package/openai-text-to-image-storyboard/scripts/generate_storyboard_images.py +0 -763
- package/openai-text-to-image-storyboard/tests/test_generate_storyboard_images.py +0 -177
- package/read-github-issue/scripts/__pycache__/find_issues.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/__pycache__/read_issue.cpython-312.pyc +0 -0
- package/read-github-issue/scripts/find_issues.py +0 -148
- package/read-github-issue/scripts/read_issue.py +0 -108
- package/read-github-issue/tests/test_find_issues.py +0 -127
- package/read-github-issue/tests/test_read_issue.py +0 -109
- package/resolve-review-comments/scripts/__pycache__/review_threads.cpython-312.pyc +0 -0
- package/resolve-review-comments/scripts/review_threads.py +0 -425
- package/resolve-review-comments/tests/test_review_threads.py +0 -74
- package/scripts/validate_openai_agent_config.py +0 -209
- package/scripts/validate_skill_frontmatter.py +0 -131
- package/text-to-short-video/scripts/__pycache__/enforce_video_aspect_ratio.cpython-312.pyc +0 -0
- package/text-to-short-video/scripts/enforce_video_aspect_ratio.py +0 -350
- package/text-to-short-video/tests/test_enforce_video_aspect_ratio.py +0 -194
- package/weekly-financial-event-report/scripts/extract_pdf_text_pdfkit.swift +0 -99
- 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())
|