arkaos 2.17.1 → 2.17.4

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 (77) hide show
  1. package/VERSION +1 -1
  2. package/arka/SKILL.md +9 -67
  3. package/arka/skills/comfyui/SKILL.md +50 -12
  4. package/arka/skills/conclave/SKILL.md +43 -141
  5. package/arka/skills/conclave/references/advisors.md +36 -0
  6. package/arka/skills/human-writing/SKILL.md +15 -100
  7. package/arka/skills/human-writing/references/forbidden-patterns.md +32 -0
  8. package/config/hooks/post-tool-use.sh +72 -0
  9. package/config/hooks/session-start.sh +16 -0
  10. package/config/hooks/user-prompt-submit.ps1 +2 -2
  11. package/config/hooks/user-prompt-submit.sh +102 -26
  12. package/core/agents/__pycache__/behavior_enforcer.cpython-313.pyc +0 -0
  13. package/core/agents/__pycache__/dna_registry.cpython-313.pyc +0 -0
  14. package/core/agents/adapters/__pycache__/disc_adapter.cpython-313.pyc +0 -0
  15. package/core/agents/adapters/disc_adapter.py +149 -0
  16. package/core/agents/behavior_enforcer.py +255 -0
  17. package/core/agents/dna_registry.py +235 -0
  18. package/core/forge/__init__.py +36 -0
  19. package/core/forge/__pycache__/__init__.cpython-313.pyc +0 -0
  20. package/core/forge/__pycache__/orchestrator.cpython-313.pyc +0 -0
  21. package/core/forge/__pycache__/runtime_dispatcher.cpython-313.pyc +0 -0
  22. package/core/forge/orchestrator.py +770 -0
  23. package/core/forge/runtime_dispatcher.py +465 -0
  24. package/core/governance/__pycache__/quality_api.cpython-313.pyc +0 -0
  25. package/core/governance/__pycache__/quality_router.cpython-313.pyc +0 -0
  26. package/core/governance/__pycache__/review_workflow.cpython-313.pyc +0 -0
  27. package/core/governance/quality_api.py +280 -0
  28. package/core/governance/quality_router.py +304 -0
  29. package/core/governance/review_workflow.py +386 -0
  30. package/core/memory/__pycache__/compressor.cpython-313.pyc +0 -0
  31. package/core/memory/__pycache__/rehydrator.cpython-313.pyc +0 -0
  32. package/core/memory/__pycache__/session_store.cpython-313.pyc +0 -0
  33. package/core/memory/compressor.py +269 -0
  34. package/core/memory/rehydrator.py +204 -0
  35. package/core/memory/session_store.py +256 -0
  36. package/core/runtime/__pycache__/context_compactor.cpython-313.pyc +0 -0
  37. package/core/runtime/__pycache__/subagent.cpython-313.pyc +0 -0
  38. package/core/synapse/__init__.py +10 -3
  39. package/core/synapse/__pycache__/__init__.cpython-313.pyc +0 -0
  40. package/core/synapse/__pycache__/engine.cpython-313.pyc +0 -0
  41. package/core/synapse/__pycache__/kb_cache.cpython-313.pyc +0 -0
  42. package/core/synapse/__pycache__/layers.cpython-313.pyc +0 -0
  43. package/core/synapse/engine.py +27 -16
  44. package/core/synapse/kb_cache.py +382 -0
  45. package/core/synapse/layers.py +253 -50
  46. package/core/sync/__pycache__/self_healing.cpython-313.pyc +0 -0
  47. package/core/workflow/__pycache__/announcer.cpython-313.pyc +0 -0
  48. package/core/workflow/__pycache__/dashboard.cpython-313.pyc +0 -0
  49. package/core/workflow/__pycache__/enforcer.cpython-313.pyc +0 -0
  50. package/core/workflow/__pycache__/rules_registry.cpython-313.pyc +0 -0
  51. package/core/workflow/__pycache__/state.cpython-313.pyc +0 -0
  52. package/core/workflow/announcer.py +246 -0
  53. package/core/workflow/dashboard.py +194 -0
  54. package/core/workflow/enforcer.py +234 -0
  55. package/core/workflow/recovery.py +196 -0
  56. package/core/workflow/rules_registry.py +484 -0
  57. package/core/workflow/session_summary.py +204 -0
  58. package/core/workflow/state.py +12 -2
  59. package/departments/dev/SKILL.md +10 -42
  60. package/departments/dev/skills/agent-design/SKILL.md +6 -26
  61. package/departments/dev/skills/ci-cd-pipeline/SKILL.md +6 -29
  62. package/departments/dev/skills/db-schema/SKILL.md +6 -24
  63. package/departments/dev/skills/dependency-audit/SKILL.md +1 -3
  64. package/departments/dev/skills/incident/SKILL.md +6 -24
  65. package/departments/dev/skills/mcp-builder/SKILL.md +4 -17
  66. package/departments/dev/skills/observability/SKILL.md +6 -29
  67. package/departments/dev/skills/performance-profiler/SKILL.md +5 -26
  68. package/departments/dev/skills/rag-architect/SKILL.md +5 -23
  69. package/departments/dev/skills/release/SKILL.md +4 -17
  70. package/departments/dev/skills/spec/SKILL.md +47 -148
  71. package/departments/landing/skills/landing-gen/SKILL.md +3 -15
  72. package/departments/marketing/skills/cold-email/SKILL.md +5 -17
  73. package/departments/marketing/skills/programmatic-seo/SKILL.md +6 -21
  74. package/departments/strategy/skills/board-advisor/SKILL.md +7 -21
  75. package/package.json +1 -1
  76. package/pyproject.toml +1 -1
  77. package/scripts/synapse-bridge.py +27 -3
@@ -0,0 +1,386 @@
1
+ """Review Workflow Engine — structured review phases for Quality Gate.
2
+
3
+ Phases:
4
+ 1. Submission — deliverable submitted for review
5
+ 2. Triage — initial assessment, assign reviewers
6
+ 3. Review — individual reviewer assessment
7
+ 4. Deliberation — reviewers discuss findings
8
+ 5. Verdict — final decision
9
+ 6. Delivery — approved items proceed
10
+
11
+ Verdicts:
12
+ - APPROVED — passes all criteria, proceed to delivery
13
+ - REJECTED — fails critical criteria, must fix and resubmit
14
+ - REQUEST_CHANGES — minor issues, can fix and re-review
15
+ - ESCALATE — complex issue, requires Tier 0 decision
16
+ """
17
+
18
+ import uuid
19
+ from dataclasses import dataclass, field
20
+ from datetime import datetime, timezone
21
+ from enum import Enum
22
+ from typing import Callable
23
+
24
+
25
+ class ReviewPhase(str, Enum):
26
+ SUBMISSION = "submission"
27
+ TRIAGE = "triage"
28
+ REVIEW = "review"
29
+ DELIBERATION = "deliberation"
30
+ VERDICT = "verdict"
31
+ DELIVERY = "delivery"
32
+
33
+
34
+ class Verdict(str, Enum):
35
+ APPROVED = "APPROVED"
36
+ REJECTED = "REJECTED"
37
+ REQUEST_CHANGES = "REQUEST_CHANGES"
38
+ ESCALATE = "ESCALATE"
39
+
40
+
41
+ class ReviewerOpinion(str, Enum):
42
+ APPROVE = "APPROVE"
43
+ REJECT = "REJECT"
44
+ CHANGES = "REQUEST_CHANGES"
45
+ ABSTAIN = "ABSTAIN"
46
+
47
+
48
+ @dataclass
49
+ class ReviewerVote:
50
+ """A single reviewer's vote."""
51
+
52
+ reviewer: str
53
+ opinion: ReviewerOpinion
54
+ comments: str = ""
55
+ flagged_rules: list[str] = field(default_factory=list)
56
+ voted_at: str = ""
57
+
58
+
59
+ @dataclass
60
+ class ReviewWorkflow:
61
+ """A complete review workflow instance."""
62
+
63
+ id: str
64
+ deliverable_title: str
65
+ deliverable_type: str
66
+ submitter: str
67
+ phase: ReviewPhase = ReviewPhase.SUBMISSION
68
+ verdict: Verdict | None = None
69
+ submitted_at: str = ""
70
+ triage_at: str = ""
71
+ review_started_at: str = ""
72
+ deliberation_at: str = ""
73
+ verdict_at: str = ""
74
+ delivered_at: str = ""
75
+ votes: list[ReviewerVote] = field(default_factory=list)
76
+ feedback: str = ""
77
+ metadata: dict = field(default_factory=dict)
78
+
79
+ def __post_init__(self):
80
+ if not self.submitted_at:
81
+ self.submitted_at = datetime.now(timezone.utc).isoformat()
82
+
83
+
84
+ @dataclass
85
+ class PhaseResult:
86
+ """Result of executing a review phase."""
87
+
88
+ phase: ReviewPhase
89
+ success: bool
90
+ output: str = ""
91
+ next_phase: ReviewPhase | None = None
92
+
93
+
94
+ class ReviewWorkflowEngine:
95
+ """Executes structured review workflows through Quality Gate.
96
+
97
+ Enforces Constitution rules:
98
+ - No delivery without Quality Gate approval
99
+ - BLOCK violations require escalation to Tier 0
100
+ - Mandatory QA runs before verdict
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ on_phase_start: Callable[[ReviewPhase, str], None] | None = None,
106
+ on_phase_complete: Callable[[ReviewPhase, PhaseResult], None] | None = None,
107
+ on_verdict: Callable[[Verdict, str], None] | None = None,
108
+ ):
109
+ self._on_phase_start = on_phase_start
110
+ self._on_phase_complete = on_phase_complete
111
+ self._on_verdict = on_verdict
112
+ self._workflows: dict[str, ReviewWorkflow] = {}
113
+
114
+ def submit(
115
+ self,
116
+ deliverable_title: str,
117
+ deliverable_type: str,
118
+ submitter: str,
119
+ metadata: dict | None = None,
120
+ ) -> ReviewWorkflow:
121
+ """Submit a deliverable for Quality Gate review.
122
+
123
+ Args:
124
+ deliverable_title: Title of the deliverable
125
+ deliverable_type: Type of deliverable (code, copy, architecture, etc.)
126
+ submitter: Agent ID who submitted
127
+ metadata: Additional context
128
+
129
+ Returns:
130
+ ReviewWorkflow in SUBMISSION phase
131
+ """
132
+ workflow = ReviewWorkflow(
133
+ id=str(uuid.uuid4()),
134
+ deliverable_title=deliverable_title,
135
+ deliverable_type=deliverable_type,
136
+ submitter=submitter,
137
+ metadata=metadata or {},
138
+ )
139
+ self._workflows[workflow.id] = workflow
140
+ return workflow
141
+
142
+ def start_triage(self, workflow_id: str) -> PhaseResult:
143
+ """Start triage phase — assign reviewers.
144
+
145
+ Args:
146
+ workflow_id: ID of the workflow to triage
147
+
148
+ Returns:
149
+ PhaseResult with triage outcome
150
+ """
151
+ workflow = self._workflows.get(workflow_id)
152
+ if not workflow:
153
+ return PhaseResult(phase=ReviewPhase.TRIAGE, success=False, output="Workflow not found")
154
+
155
+ self._announce_phase_start(ReviewPhase.TRIAGE, workflow_id)
156
+
157
+ workflow.phase = ReviewPhase.TRIAGE
158
+ workflow.triage_at = datetime.now(timezone.utc).isoformat()
159
+
160
+ result = PhaseResult(
161
+ phase=ReviewPhase.TRIAGE,
162
+ success=True,
163
+ output=f"Triage complete for: {workflow.deliverable_title}",
164
+ next_phase=ReviewPhase.REVIEW,
165
+ )
166
+
167
+ self._announce_phase_complete(ReviewPhase.TRIAGE, result)
168
+ return result
169
+
170
+ def start_review(self, workflow_id: str) -> PhaseResult:
171
+ """Start the review phase.
172
+
173
+ Args:
174
+ workflow_id: ID of the workflow to review
175
+
176
+ Returns:
177
+ PhaseResult with review start info
178
+ """
179
+ workflow = self._workflows.get(workflow_id)
180
+ if not workflow:
181
+ return PhaseResult(phase=ReviewPhase.REVIEW, success=False, output="Workflow not found")
182
+
183
+ if workflow.phase != ReviewPhase.TRIAGE:
184
+ return PhaseResult(
185
+ phase=ReviewPhase.REVIEW,
186
+ success=False,
187
+ output="Must complete triage before review",
188
+ )
189
+
190
+ self._announce_phase_start(ReviewPhase.REVIEW, workflow_id)
191
+
192
+ workflow.phase = ReviewPhase.REVIEW
193
+ workflow.review_started_at = datetime.now(timezone.utc).isoformat()
194
+
195
+ result = PhaseResult(
196
+ phase=ReviewPhase.REVIEW,
197
+ success=True,
198
+ output=f"Review started for: {workflow.deliverable_title}",
199
+ next_phase=ReviewPhase.DELIBERATION,
200
+ )
201
+
202
+ self._announce_phase_complete(ReviewPhase.REVIEW, result)
203
+ return result
204
+
205
+ def record_vote(
206
+ self,
207
+ workflow_id: str,
208
+ reviewer: str,
209
+ opinion: ReviewerOpinion,
210
+ comments: str = "",
211
+ flagged_rules: list[str] | None = None,
212
+ ) -> bool:
213
+ """Record a reviewer's vote.
214
+
215
+ Args:
216
+ workflow_id: ID of the workflow
217
+ reviewer: Agent ID of reviewer
218
+ opinion: Their vote
219
+ comments: Optional comments
220
+ flagged_rules: Constitution rules that were violated
221
+
222
+ Returns:
223
+ True if vote recorded successfully
224
+ """
225
+ workflow = self._workflows.get(workflow_id)
226
+ if not workflow:
227
+ return False
228
+
229
+ if workflow.phase not in (ReviewPhase.REVIEW, ReviewPhase.DELIBERATION):
230
+ return False
231
+
232
+ vote = ReviewerVote(
233
+ reviewer=reviewer,
234
+ opinion=opinion,
235
+ comments=comments,
236
+ flagged_rules=flagged_rules or [],
237
+ voted_at=datetime.now(timezone.utc).isoformat(),
238
+ )
239
+ workflow.votes.append(vote)
240
+ return True
241
+
242
+ def start_deliberation(self, workflow_id: str) -> PhaseResult:
243
+ """Start deliberation phase — reviewers discuss findings.
244
+
245
+ Args:
246
+ workflow_id: ID of the workflow
247
+
248
+ Returns:
249
+ PhaseResult with deliberation outcome
250
+ """
251
+ workflow = self._workflows.get(workflow_id)
252
+ if not workflow:
253
+ return PhaseResult(
254
+ phase=ReviewPhase.DELIBERATION, success=False, output="Workflow not found"
255
+ )
256
+
257
+ if not workflow.votes:
258
+ return PhaseResult(
259
+ phase=ReviewPhase.DELIBERATION,
260
+ success=False,
261
+ output="No votes recorded yet",
262
+ )
263
+
264
+ self._announce_phase_start(ReviewPhase.DELIBERATION, workflow_id)
265
+
266
+ workflow.phase = ReviewPhase.DELIBERATION
267
+ workflow.deliberation_at = datetime.now(timezone.utc).isoformat()
268
+
269
+ result = PhaseResult(
270
+ phase=ReviewPhase.DELIBERATION,
271
+ success=True,
272
+ output=f"Deliberation started with {len(workflow.votes)} votes",
273
+ next_phase=ReviewPhase.VERDICT,
274
+ )
275
+
276
+ self._announce_phase_complete(ReviewPhase.DELIBERATION, result)
277
+ return result
278
+
279
+ def reach_verdict(self, workflow_id: str, verdict: Verdict, feedback: str = "") -> PhaseResult:
280
+ """Record the final verdict.
281
+
282
+ Args:
283
+ workflow_id: ID of the workflow
284
+ verdict: Final verdict
285
+ feedback: Feedback for the submitter
286
+
287
+ Returns:
288
+ PhaseResult with verdict outcome
289
+ """
290
+ workflow = self._workflows.get(workflow_id)
291
+ if not workflow:
292
+ return PhaseResult(
293
+ phase=ReviewPhase.VERDICT, success=False, output="Workflow not found"
294
+ )
295
+
296
+ self._announce_phase_start(ReviewPhase.VERDICT, workflow_id)
297
+
298
+ workflow.phase = ReviewPhase.VERDICT
299
+ workflow.verdict = verdict
300
+ workflow.feedback = feedback
301
+ workflow.verdict_at = datetime.now(timezone.utc).isoformat()
302
+
303
+ self._announce_verdict(verdict, workflow_id)
304
+
305
+ next_phase = ReviewPhase.DELIVERY if verdict == Verdict.APPROVED else None
306
+
307
+ result = PhaseResult(
308
+ phase=ReviewPhase.VERDICT,
309
+ success=True,
310
+ output=f"Verdict: {verdict.value} — {feedback}",
311
+ next_phase=next_phase,
312
+ )
313
+
314
+ self._announce_phase_complete(ReviewPhase.VERDICT, result)
315
+ return result
316
+
317
+ def deliver(self, workflow_id: str) -> PhaseResult:
318
+ """Mark workflow as delivered (post-approval).
319
+
320
+ Args:
321
+ workflow_id: ID of the workflow
322
+
323
+ Returns:
324
+ PhaseResult with delivery outcome
325
+ """
326
+ workflow = self._workflows.get(workflow_id)
327
+ if not workflow:
328
+ return PhaseResult(
329
+ phase=ReviewPhase.DELIVERY, success=False, output="Workflow not found"
330
+ )
331
+
332
+ if workflow.verdict != Verdict.APPROVED:
333
+ return PhaseResult(
334
+ phase=ReviewPhase.DELIVERY,
335
+ success=False,
336
+ output=f"Cannot deliver: verdict is {workflow.verdict}",
337
+ )
338
+
339
+ workflow.phase = ReviewPhase.DELIVERY
340
+ workflow.delivered_at = datetime.now(timezone.utc).isoformat()
341
+
342
+ result = PhaseResult(
343
+ phase=ReviewPhase.DELIVERY,
344
+ success=True,
345
+ output=f"Delivered: {workflow.deliverable_title}",
346
+ )
347
+
348
+ self._announce_phase_complete(ReviewPhase.DELIVERY, result)
349
+ return result
350
+
351
+ def get_workflow(self, workflow_id: str) -> ReviewWorkflow | None:
352
+ """Get a workflow by ID."""
353
+ return self._workflows.get(workflow_id)
354
+
355
+ def get_all_workflows(self) -> list[ReviewWorkflow]:
356
+ """Get all workflows."""
357
+ return list(self._workflows.values())
358
+
359
+ def get_workflows_by_phase(self, phase: ReviewPhase) -> list[ReviewWorkflow]:
360
+ """Get all workflows in a specific phase."""
361
+ return [w for w in self._workflows.values() if w.phase == phase]
362
+
363
+ def _announce_phase_start(self, phase: ReviewPhase, workflow_id: str) -> None:
364
+ if self._on_phase_start:
365
+ self._on_phase_start(phase, workflow_id)
366
+
367
+ def _announce_phase_complete(self, phase: ReviewPhase, result: PhaseResult) -> None:
368
+ if self._on_phase_complete:
369
+ self._on_phase_complete(phase, result)
370
+
371
+ def _announce_verdict(self, verdict: Verdict, workflow_id: str) -> None:
372
+ if self._on_verdict:
373
+ self._on_verdict(verdict, workflow_id)
374
+
375
+
376
+ def create_review(
377
+ deliverable_title: str,
378
+ deliverable_type: str,
379
+ submitter: str,
380
+ ) -> ReviewWorkflow:
381
+ """Convenience function to create a review workflow."""
382
+ engine = ReviewWorkflowEngine()
383
+ workflow = engine.submit(deliverable_title, deliverable_type, submitter)
384
+ engine.start_triage(workflow.id)
385
+ engine.start_review(workflow.id)
386
+ return workflow
@@ -0,0 +1,269 @@
1
+ """Memory Compression — archive old sessions, keep hot data accessible.
2
+
3
+ Hot data (last 7 days) stays uncompressed in ~/.arkaos/sessions/
4
+ Cold data (older than 7 days) gets compressed to ~/.arkaos/archive/
5
+
6
+ Compression is lossless — all JSON is preserved, just compressed with gzip.
7
+ """
8
+
9
+ import gzip
10
+ import json
11
+ import shutil
12
+ from datetime import datetime, timedelta, timezone
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+
17
+ HOT_DAYS = 7
18
+ SESSIONS_DIR = Path.home() / ".arkaos" / "sessions"
19
+ ARCHIVE_DIR = Path.home() / ".arkaos" / "archive"
20
+
21
+
22
+ def _ensure_archive_dir() -> Path:
23
+ ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
24
+ return ARCHIVE_DIR
25
+
26
+
27
+ def _is_hot(session_dir: Path) -> bool:
28
+ """Check if a session is still hot (within hot days)."""
29
+ meta_file = session_dir / "session-meta.json"
30
+ if not meta_file.exists():
31
+ return False
32
+
33
+ try:
34
+ data = json.loads(meta_file.read_text(encoding="utf-8"))
35
+ ended_at = data.get("ended_at", data.get("started_at", ""))
36
+ if not ended_at:
37
+ return True
38
+
39
+ dt = datetime.fromisoformat(ended_at.replace("Z", "+00:00"))
40
+ cutoff = datetime.now(timezone.utc) - timedelta(days=HOT_DAYS)
41
+ return dt > cutoff
42
+ except (json.JSONDecodeError, ValueError, KeyError, OSError):
43
+ return False
44
+
45
+
46
+ def _compress_session(session_dir: Path) -> Path:
47
+ """Compress a session directory to archive.
48
+
49
+ Args:
50
+ session_dir: Path to session directory
51
+
52
+ Returns:
53
+ Path to compressed archive file
54
+ """
55
+ archive_dir = _ensure_archive_dir()
56
+ archive_file = archive_dir / f"{session_dir.name}.tar.gz"
57
+
58
+ if archive_file.exists():
59
+ archive_file.unlink()
60
+
61
+ shutil.make_archive(
62
+ str(archive_dir / session_dir.name),
63
+ format="gztar",
64
+ root_dir=session_dir.parent,
65
+ base_dir=session_dir.name,
66
+ )
67
+
68
+ return archive_file
69
+
70
+
71
+ def _decompress_session(archive_file: Path, target_dir: Path) -> Path:
72
+ """Decompress a session from archive.
73
+
74
+ Args:
75
+ archive_file: Path to .tar.gz archive
76
+ target_dir: Directory to extract into (should be SESSIONS_DIR)
77
+
78
+ Returns:
79
+ Path to decompressed session directory (target_dir / session_id)
80
+ """
81
+ target_dir.mkdir(parents=True, exist_ok=True)
82
+ shutil.unpack_archive(archive_file, target_dir)
83
+
84
+ import tarfile
85
+
86
+ with tarfile.open(archive_file, "r:gz") as tf:
87
+ names = tf.getnames()
88
+ if names:
89
+ session_name = names[0]
90
+ return target_dir / session_name
91
+
92
+ return target_dir
93
+
94
+
95
+ def compress_old_sessions() -> dict[str, Any]:
96
+ """Compress all cold sessions to archive.
97
+
98
+ Returns:
99
+ Dict with counts of archived and remaining sessions
100
+ """
101
+ if not SESSIONS_DIR.exists():
102
+ return {"archived": 0, "remaining": 0, "errors": []}
103
+
104
+ archived = 0
105
+ remaining = 0
106
+ errors = []
107
+
108
+ for session_dir in list(SESSIONS_DIR.iterdir()):
109
+ if not session_dir.is_dir():
110
+ continue
111
+
112
+ if _is_hot(session_dir):
113
+ remaining += 1
114
+ continue
115
+
116
+ try:
117
+ archive_file = _compress_session(session_dir)
118
+ shutil.rmtree(session_dir)
119
+ archived += 1
120
+ except Exception as e:
121
+ errors.append(f"{session_dir.name}: {str(e)}")
122
+
123
+ return {
124
+ "archived": archived,
125
+ "remaining": remaining,
126
+ "errors": errors,
127
+ }
128
+
129
+
130
+ def restore_session(session_id: str) -> Path | None:
131
+ """Restore a session from archive.
132
+
133
+ Args:
134
+ session_id: Session UUID to restore
135
+
136
+ Returns:
137
+ Path to restored session directory, or None if not found
138
+ """
139
+ archive_file = ARCHIVE_DIR / f"{session_id}.tar.gz"
140
+ if not archive_file.exists():
141
+ return None
142
+
143
+ target_dir = SESSIONS_DIR / session_id
144
+ if target_dir.exists():
145
+ return target_dir
146
+
147
+ return _decompress_session(archive_file, SESSIONS_DIR)
148
+
149
+
150
+ def list_archived_sessions(limit: int = 20) -> list[dict[str, Any]]:
151
+ """List sessions in archive.
152
+
153
+ Args:
154
+ limit: Maximum number to return
155
+
156
+ Returns:
157
+ List of archived session info dicts
158
+ """
159
+ if not ARCHIVE_DIR.exists():
160
+ return []
161
+
162
+ archived = []
163
+ for archive_file in sorted(
164
+ ARCHIVE_DIR.glob("*.tar.gz"),
165
+ key=lambda p: p.stat().st_mtime,
166
+ reverse=True,
167
+ )[:limit]:
168
+ try:
169
+ stat = archive_file.stat()
170
+ archived.append(
171
+ {
172
+ "session_id": archive_file.name[:-7],
173
+ "archived_at": datetime.fromtimestamp(
174
+ stat.st_mtime, tz=timezone.utc
175
+ ).isoformat(),
176
+ "size_bytes": stat.st_size,
177
+ }
178
+ )
179
+ except OSError:
180
+ pass
181
+
182
+ return archived
183
+
184
+
185
+ def prune_archive(older_than_days: int = 90) -> int:
186
+ """Delete archives older than specified days.
187
+
188
+ Args:
189
+ older_than_days: Delete archives older than this many days
190
+
191
+ Returns:
192
+ Number of archives deleted
193
+ """
194
+ if not ARCHIVE_DIR.exists():
195
+ return 0
196
+
197
+ cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
198
+ deleted = 0
199
+
200
+ for archive_file in list(ARCHIVE_DIR.glob("*.tar.gz")):
201
+ try:
202
+ mtime = datetime.fromtimestamp(archive_file.stat().st_mtime, tz=timezone.utc)
203
+ if mtime < cutoff:
204
+ archive_file.unlink()
205
+ deleted += 1
206
+ except OSError:
207
+ pass
208
+
209
+ return deleted
210
+
211
+
212
+ def get_storage_stats() -> dict[str, Any]:
213
+ """Get storage usage statistics.
214
+
215
+ Returns:
216
+ Dict with session counts, archive size, etc.
217
+ """
218
+ sessions_count = 0
219
+ sessions_size = 0
220
+ hot_count = 0
221
+
222
+ if SESSIONS_DIR.exists():
223
+ for session_dir in SESSIONS_DIR.iterdir():
224
+ if session_dir.is_dir():
225
+ sessions_count += 1
226
+ if _is_hot(session_dir):
227
+ hot_count += 1
228
+ sessions_size += sum(
229
+ f.stat().st_size for f in session_dir.rglob("*") if f.is_file()
230
+ )
231
+
232
+ archive_size = 0
233
+ archive_count = 0
234
+ if ARCHIVE_DIR.exists():
235
+ for archive_file in ARCHIVE_DIR.glob("*.tar.gz"):
236
+ archive_count += 1
237
+ archive_size += archive_file.stat().st_size
238
+
239
+ return {
240
+ "sessions": {
241
+ "total": sessions_count,
242
+ "hot": hot_count,
243
+ "cold": sessions_count - hot_count,
244
+ "size_bytes": sessions_size,
245
+ },
246
+ "archive": {
247
+ "count": archive_count,
248
+ "size_bytes": archive_size,
249
+ },
250
+ }
251
+
252
+
253
+ class SessionCompressor:
254
+ """Class-based interface for compression operations."""
255
+
256
+ def compress_old(self) -> dict[str, Any]:
257
+ return compress_old_sessions()
258
+
259
+ def restore(self, session_id: str) -> Path | None:
260
+ return restore_session(session_id)
261
+
262
+ def list_archived(self, limit: int = 20) -> list[dict[str, Any]]:
263
+ return list_archived_sessions(limit)
264
+
265
+ def prune(self, older_than_days: int = 90) -> int:
266
+ return prune_archive(older_than_days)
267
+
268
+ def stats(self) -> dict[str, Any]:
269
+ return get_storage_stats()