@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 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())