gobby 0.2.5__py3-none-any.whl

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 (383) hide show
  1. gobby/__init__.py +3 -0
  2. gobby/adapters/__init__.py +30 -0
  3. gobby/adapters/base.py +93 -0
  4. gobby/adapters/claude_code.py +276 -0
  5. gobby/adapters/codex.py +1292 -0
  6. gobby/adapters/gemini.py +343 -0
  7. gobby/agents/__init__.py +37 -0
  8. gobby/agents/codex_session.py +120 -0
  9. gobby/agents/constants.py +112 -0
  10. gobby/agents/context.py +362 -0
  11. gobby/agents/definitions.py +133 -0
  12. gobby/agents/gemini_session.py +111 -0
  13. gobby/agents/registry.py +618 -0
  14. gobby/agents/runner.py +968 -0
  15. gobby/agents/session.py +259 -0
  16. gobby/agents/spawn.py +916 -0
  17. gobby/agents/spawners/__init__.py +77 -0
  18. gobby/agents/spawners/base.py +142 -0
  19. gobby/agents/spawners/cross_platform.py +266 -0
  20. gobby/agents/spawners/embedded.py +225 -0
  21. gobby/agents/spawners/headless.py +226 -0
  22. gobby/agents/spawners/linux.py +125 -0
  23. gobby/agents/spawners/macos.py +277 -0
  24. gobby/agents/spawners/windows.py +308 -0
  25. gobby/agents/tty_config.py +319 -0
  26. gobby/autonomous/__init__.py +32 -0
  27. gobby/autonomous/progress_tracker.py +447 -0
  28. gobby/autonomous/stop_registry.py +269 -0
  29. gobby/autonomous/stuck_detector.py +383 -0
  30. gobby/cli/__init__.py +67 -0
  31. gobby/cli/__main__.py +8 -0
  32. gobby/cli/agents.py +529 -0
  33. gobby/cli/artifacts.py +266 -0
  34. gobby/cli/daemon.py +329 -0
  35. gobby/cli/extensions.py +526 -0
  36. gobby/cli/github.py +263 -0
  37. gobby/cli/init.py +53 -0
  38. gobby/cli/install.py +614 -0
  39. gobby/cli/installers/__init__.py +37 -0
  40. gobby/cli/installers/antigravity.py +65 -0
  41. gobby/cli/installers/claude.py +363 -0
  42. gobby/cli/installers/codex.py +192 -0
  43. gobby/cli/installers/gemini.py +294 -0
  44. gobby/cli/installers/git_hooks.py +377 -0
  45. gobby/cli/installers/shared.py +737 -0
  46. gobby/cli/linear.py +250 -0
  47. gobby/cli/mcp.py +30 -0
  48. gobby/cli/mcp_proxy.py +698 -0
  49. gobby/cli/memory.py +304 -0
  50. gobby/cli/merge.py +384 -0
  51. gobby/cli/projects.py +79 -0
  52. gobby/cli/sessions.py +622 -0
  53. gobby/cli/tasks/__init__.py +30 -0
  54. gobby/cli/tasks/_utils.py +658 -0
  55. gobby/cli/tasks/ai.py +1025 -0
  56. gobby/cli/tasks/commits.py +169 -0
  57. gobby/cli/tasks/crud.py +685 -0
  58. gobby/cli/tasks/deps.py +135 -0
  59. gobby/cli/tasks/labels.py +63 -0
  60. gobby/cli/tasks/main.py +273 -0
  61. gobby/cli/tasks/search.py +178 -0
  62. gobby/cli/tui.py +34 -0
  63. gobby/cli/utils.py +513 -0
  64. gobby/cli/workflows.py +927 -0
  65. gobby/cli/worktrees.py +481 -0
  66. gobby/config/__init__.py +129 -0
  67. gobby/config/app.py +551 -0
  68. gobby/config/extensions.py +167 -0
  69. gobby/config/features.py +472 -0
  70. gobby/config/llm_providers.py +98 -0
  71. gobby/config/logging.py +66 -0
  72. gobby/config/mcp.py +346 -0
  73. gobby/config/persistence.py +247 -0
  74. gobby/config/servers.py +141 -0
  75. gobby/config/sessions.py +250 -0
  76. gobby/config/tasks.py +784 -0
  77. gobby/hooks/__init__.py +104 -0
  78. gobby/hooks/artifact_capture.py +213 -0
  79. gobby/hooks/broadcaster.py +243 -0
  80. gobby/hooks/event_handlers.py +723 -0
  81. gobby/hooks/events.py +218 -0
  82. gobby/hooks/git.py +169 -0
  83. gobby/hooks/health_monitor.py +171 -0
  84. gobby/hooks/hook_manager.py +856 -0
  85. gobby/hooks/hook_types.py +575 -0
  86. gobby/hooks/plugins.py +813 -0
  87. gobby/hooks/session_coordinator.py +396 -0
  88. gobby/hooks/verification_runner.py +268 -0
  89. gobby/hooks/webhooks.py +339 -0
  90. gobby/install/claude/commands/gobby/bug.md +51 -0
  91. gobby/install/claude/commands/gobby/chore.md +51 -0
  92. gobby/install/claude/commands/gobby/epic.md +52 -0
  93. gobby/install/claude/commands/gobby/eval.md +235 -0
  94. gobby/install/claude/commands/gobby/feat.md +49 -0
  95. gobby/install/claude/commands/gobby/nit.md +52 -0
  96. gobby/install/claude/commands/gobby/ref.md +52 -0
  97. gobby/install/claude/hooks/HOOK_SCHEMAS.md +632 -0
  98. gobby/install/claude/hooks/hook_dispatcher.py +364 -0
  99. gobby/install/claude/hooks/validate_settings.py +102 -0
  100. gobby/install/claude/hooks-template.json +118 -0
  101. gobby/install/codex/hooks/hook_dispatcher.py +153 -0
  102. gobby/install/codex/prompts/forget.md +7 -0
  103. gobby/install/codex/prompts/memories.md +7 -0
  104. gobby/install/codex/prompts/recall.md +7 -0
  105. gobby/install/codex/prompts/remember.md +13 -0
  106. gobby/install/gemini/hooks/hook_dispatcher.py +268 -0
  107. gobby/install/gemini/hooks-template.json +138 -0
  108. gobby/install/shared/plugins/code_guardian.py +456 -0
  109. gobby/install/shared/plugins/example_notify.py +331 -0
  110. gobby/integrations/__init__.py +10 -0
  111. gobby/integrations/github.py +145 -0
  112. gobby/integrations/linear.py +145 -0
  113. gobby/llm/__init__.py +40 -0
  114. gobby/llm/base.py +120 -0
  115. gobby/llm/claude.py +578 -0
  116. gobby/llm/claude_executor.py +503 -0
  117. gobby/llm/codex.py +322 -0
  118. gobby/llm/codex_executor.py +513 -0
  119. gobby/llm/executor.py +316 -0
  120. gobby/llm/factory.py +34 -0
  121. gobby/llm/gemini.py +258 -0
  122. gobby/llm/gemini_executor.py +339 -0
  123. gobby/llm/litellm.py +287 -0
  124. gobby/llm/litellm_executor.py +303 -0
  125. gobby/llm/resolver.py +499 -0
  126. gobby/llm/service.py +236 -0
  127. gobby/mcp_proxy/__init__.py +29 -0
  128. gobby/mcp_proxy/actions.py +175 -0
  129. gobby/mcp_proxy/daemon_control.py +198 -0
  130. gobby/mcp_proxy/importer.py +436 -0
  131. gobby/mcp_proxy/lazy.py +325 -0
  132. gobby/mcp_proxy/manager.py +798 -0
  133. gobby/mcp_proxy/metrics.py +609 -0
  134. gobby/mcp_proxy/models.py +139 -0
  135. gobby/mcp_proxy/registries.py +215 -0
  136. gobby/mcp_proxy/schema_hash.py +381 -0
  137. gobby/mcp_proxy/semantic_search.py +706 -0
  138. gobby/mcp_proxy/server.py +549 -0
  139. gobby/mcp_proxy/services/__init__.py +0 -0
  140. gobby/mcp_proxy/services/fallback.py +306 -0
  141. gobby/mcp_proxy/services/recommendation.py +224 -0
  142. gobby/mcp_proxy/services/server_mgmt.py +214 -0
  143. gobby/mcp_proxy/services/system.py +72 -0
  144. gobby/mcp_proxy/services/tool_filter.py +231 -0
  145. gobby/mcp_proxy/services/tool_proxy.py +309 -0
  146. gobby/mcp_proxy/stdio.py +565 -0
  147. gobby/mcp_proxy/tools/__init__.py +27 -0
  148. gobby/mcp_proxy/tools/agents.py +1103 -0
  149. gobby/mcp_proxy/tools/artifacts.py +207 -0
  150. gobby/mcp_proxy/tools/hub.py +335 -0
  151. gobby/mcp_proxy/tools/internal.py +337 -0
  152. gobby/mcp_proxy/tools/memory.py +543 -0
  153. gobby/mcp_proxy/tools/merge.py +422 -0
  154. gobby/mcp_proxy/tools/metrics.py +283 -0
  155. gobby/mcp_proxy/tools/orchestration/__init__.py +23 -0
  156. gobby/mcp_proxy/tools/orchestration/cleanup.py +619 -0
  157. gobby/mcp_proxy/tools/orchestration/monitor.py +380 -0
  158. gobby/mcp_proxy/tools/orchestration/orchestrate.py +746 -0
  159. gobby/mcp_proxy/tools/orchestration/review.py +736 -0
  160. gobby/mcp_proxy/tools/orchestration/utils.py +16 -0
  161. gobby/mcp_proxy/tools/session_messages.py +1056 -0
  162. gobby/mcp_proxy/tools/task_dependencies.py +219 -0
  163. gobby/mcp_proxy/tools/task_expansion.py +591 -0
  164. gobby/mcp_proxy/tools/task_github.py +393 -0
  165. gobby/mcp_proxy/tools/task_linear.py +379 -0
  166. gobby/mcp_proxy/tools/task_orchestration.py +77 -0
  167. gobby/mcp_proxy/tools/task_readiness.py +522 -0
  168. gobby/mcp_proxy/tools/task_sync.py +351 -0
  169. gobby/mcp_proxy/tools/task_validation.py +843 -0
  170. gobby/mcp_proxy/tools/tasks/__init__.py +25 -0
  171. gobby/mcp_proxy/tools/tasks/_context.py +112 -0
  172. gobby/mcp_proxy/tools/tasks/_crud.py +516 -0
  173. gobby/mcp_proxy/tools/tasks/_factory.py +176 -0
  174. gobby/mcp_proxy/tools/tasks/_helpers.py +129 -0
  175. gobby/mcp_proxy/tools/tasks/_lifecycle.py +517 -0
  176. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +301 -0
  177. gobby/mcp_proxy/tools/tasks/_resolution.py +55 -0
  178. gobby/mcp_proxy/tools/tasks/_search.py +215 -0
  179. gobby/mcp_proxy/tools/tasks/_session.py +125 -0
  180. gobby/mcp_proxy/tools/workflows.py +973 -0
  181. gobby/mcp_proxy/tools/worktrees.py +1264 -0
  182. gobby/mcp_proxy/transports/__init__.py +0 -0
  183. gobby/mcp_proxy/transports/base.py +95 -0
  184. gobby/mcp_proxy/transports/factory.py +44 -0
  185. gobby/mcp_proxy/transports/http.py +139 -0
  186. gobby/mcp_proxy/transports/stdio.py +213 -0
  187. gobby/mcp_proxy/transports/websocket.py +136 -0
  188. gobby/memory/backends/__init__.py +116 -0
  189. gobby/memory/backends/mem0.py +408 -0
  190. gobby/memory/backends/memu.py +485 -0
  191. gobby/memory/backends/null.py +111 -0
  192. gobby/memory/backends/openmemory.py +537 -0
  193. gobby/memory/backends/sqlite.py +304 -0
  194. gobby/memory/context.py +87 -0
  195. gobby/memory/manager.py +1001 -0
  196. gobby/memory/protocol.py +451 -0
  197. gobby/memory/search/__init__.py +66 -0
  198. gobby/memory/search/text.py +127 -0
  199. gobby/memory/viz.py +258 -0
  200. gobby/prompts/__init__.py +13 -0
  201. gobby/prompts/defaults/expansion/system.md +119 -0
  202. gobby/prompts/defaults/expansion/user.md +48 -0
  203. gobby/prompts/defaults/external_validation/agent.md +72 -0
  204. gobby/prompts/defaults/external_validation/external.md +63 -0
  205. gobby/prompts/defaults/external_validation/spawn.md +83 -0
  206. gobby/prompts/defaults/external_validation/system.md +6 -0
  207. gobby/prompts/defaults/features/import_mcp.md +22 -0
  208. gobby/prompts/defaults/features/import_mcp_github.md +17 -0
  209. gobby/prompts/defaults/features/import_mcp_search.md +16 -0
  210. gobby/prompts/defaults/features/recommend_tools.md +32 -0
  211. gobby/prompts/defaults/features/recommend_tools_hybrid.md +35 -0
  212. gobby/prompts/defaults/features/recommend_tools_llm.md +30 -0
  213. gobby/prompts/defaults/features/server_description.md +20 -0
  214. gobby/prompts/defaults/features/server_description_system.md +6 -0
  215. gobby/prompts/defaults/features/task_description.md +31 -0
  216. gobby/prompts/defaults/features/task_description_system.md +6 -0
  217. gobby/prompts/defaults/features/tool_summary.md +17 -0
  218. gobby/prompts/defaults/features/tool_summary_system.md +6 -0
  219. gobby/prompts/defaults/research/step.md +58 -0
  220. gobby/prompts/defaults/validation/criteria.md +47 -0
  221. gobby/prompts/defaults/validation/validate.md +38 -0
  222. gobby/prompts/loader.py +346 -0
  223. gobby/prompts/models.py +113 -0
  224. gobby/py.typed +0 -0
  225. gobby/runner.py +488 -0
  226. gobby/search/__init__.py +23 -0
  227. gobby/search/protocol.py +104 -0
  228. gobby/search/tfidf.py +232 -0
  229. gobby/servers/__init__.py +7 -0
  230. gobby/servers/http.py +636 -0
  231. gobby/servers/models.py +31 -0
  232. gobby/servers/routes/__init__.py +23 -0
  233. gobby/servers/routes/admin.py +416 -0
  234. gobby/servers/routes/dependencies.py +118 -0
  235. gobby/servers/routes/mcp/__init__.py +24 -0
  236. gobby/servers/routes/mcp/hooks.py +135 -0
  237. gobby/servers/routes/mcp/plugins.py +121 -0
  238. gobby/servers/routes/mcp/tools.py +1337 -0
  239. gobby/servers/routes/mcp/webhooks.py +159 -0
  240. gobby/servers/routes/sessions.py +582 -0
  241. gobby/servers/websocket.py +766 -0
  242. gobby/sessions/__init__.py +13 -0
  243. gobby/sessions/analyzer.py +322 -0
  244. gobby/sessions/lifecycle.py +240 -0
  245. gobby/sessions/manager.py +563 -0
  246. gobby/sessions/processor.py +225 -0
  247. gobby/sessions/summary.py +532 -0
  248. gobby/sessions/transcripts/__init__.py +41 -0
  249. gobby/sessions/transcripts/base.py +125 -0
  250. gobby/sessions/transcripts/claude.py +386 -0
  251. gobby/sessions/transcripts/codex.py +143 -0
  252. gobby/sessions/transcripts/gemini.py +195 -0
  253. gobby/storage/__init__.py +21 -0
  254. gobby/storage/agents.py +409 -0
  255. gobby/storage/artifact_classifier.py +341 -0
  256. gobby/storage/artifacts.py +285 -0
  257. gobby/storage/compaction.py +67 -0
  258. gobby/storage/database.py +357 -0
  259. gobby/storage/inter_session_messages.py +194 -0
  260. gobby/storage/mcp.py +680 -0
  261. gobby/storage/memories.py +562 -0
  262. gobby/storage/merge_resolutions.py +550 -0
  263. gobby/storage/migrations.py +860 -0
  264. gobby/storage/migrations_legacy.py +1359 -0
  265. gobby/storage/projects.py +166 -0
  266. gobby/storage/session_messages.py +251 -0
  267. gobby/storage/session_tasks.py +97 -0
  268. gobby/storage/sessions.py +817 -0
  269. gobby/storage/task_dependencies.py +223 -0
  270. gobby/storage/tasks/__init__.py +42 -0
  271. gobby/storage/tasks/_aggregates.py +180 -0
  272. gobby/storage/tasks/_crud.py +449 -0
  273. gobby/storage/tasks/_id.py +104 -0
  274. gobby/storage/tasks/_lifecycle.py +311 -0
  275. gobby/storage/tasks/_manager.py +889 -0
  276. gobby/storage/tasks/_models.py +300 -0
  277. gobby/storage/tasks/_ordering.py +119 -0
  278. gobby/storage/tasks/_path_cache.py +110 -0
  279. gobby/storage/tasks/_queries.py +343 -0
  280. gobby/storage/tasks/_search.py +143 -0
  281. gobby/storage/workflow_audit.py +393 -0
  282. gobby/storage/worktrees.py +547 -0
  283. gobby/sync/__init__.py +29 -0
  284. gobby/sync/github.py +333 -0
  285. gobby/sync/linear.py +304 -0
  286. gobby/sync/memories.py +284 -0
  287. gobby/sync/tasks.py +641 -0
  288. gobby/tasks/__init__.py +8 -0
  289. gobby/tasks/build_verification.py +193 -0
  290. gobby/tasks/commits.py +633 -0
  291. gobby/tasks/context.py +747 -0
  292. gobby/tasks/criteria.py +342 -0
  293. gobby/tasks/enhanced_validator.py +226 -0
  294. gobby/tasks/escalation.py +263 -0
  295. gobby/tasks/expansion.py +626 -0
  296. gobby/tasks/external_validator.py +764 -0
  297. gobby/tasks/issue_extraction.py +171 -0
  298. gobby/tasks/prompts/expand.py +327 -0
  299. gobby/tasks/research.py +421 -0
  300. gobby/tasks/tdd.py +352 -0
  301. gobby/tasks/tree_builder.py +263 -0
  302. gobby/tasks/validation.py +712 -0
  303. gobby/tasks/validation_history.py +357 -0
  304. gobby/tasks/validation_models.py +89 -0
  305. gobby/tools/__init__.py +0 -0
  306. gobby/tools/summarizer.py +170 -0
  307. gobby/tui/__init__.py +5 -0
  308. gobby/tui/api_client.py +281 -0
  309. gobby/tui/app.py +327 -0
  310. gobby/tui/screens/__init__.py +25 -0
  311. gobby/tui/screens/agents.py +333 -0
  312. gobby/tui/screens/chat.py +450 -0
  313. gobby/tui/screens/dashboard.py +377 -0
  314. gobby/tui/screens/memory.py +305 -0
  315. gobby/tui/screens/metrics.py +231 -0
  316. gobby/tui/screens/orchestrator.py +904 -0
  317. gobby/tui/screens/sessions.py +412 -0
  318. gobby/tui/screens/tasks.py +442 -0
  319. gobby/tui/screens/workflows.py +289 -0
  320. gobby/tui/screens/worktrees.py +174 -0
  321. gobby/tui/widgets/__init__.py +21 -0
  322. gobby/tui/widgets/chat.py +210 -0
  323. gobby/tui/widgets/conductor.py +104 -0
  324. gobby/tui/widgets/menu.py +132 -0
  325. gobby/tui/widgets/message_panel.py +160 -0
  326. gobby/tui/widgets/review_gate.py +224 -0
  327. gobby/tui/widgets/task_tree.py +99 -0
  328. gobby/tui/widgets/token_budget.py +166 -0
  329. gobby/tui/ws_client.py +258 -0
  330. gobby/utils/__init__.py +3 -0
  331. gobby/utils/daemon_client.py +235 -0
  332. gobby/utils/git.py +222 -0
  333. gobby/utils/id.py +38 -0
  334. gobby/utils/json_helpers.py +161 -0
  335. gobby/utils/logging.py +376 -0
  336. gobby/utils/machine_id.py +135 -0
  337. gobby/utils/metrics.py +589 -0
  338. gobby/utils/project_context.py +182 -0
  339. gobby/utils/project_init.py +263 -0
  340. gobby/utils/status.py +256 -0
  341. gobby/utils/validation.py +80 -0
  342. gobby/utils/version.py +23 -0
  343. gobby/workflows/__init__.py +4 -0
  344. gobby/workflows/actions.py +1310 -0
  345. gobby/workflows/approval_flow.py +138 -0
  346. gobby/workflows/artifact_actions.py +103 -0
  347. gobby/workflows/audit_helpers.py +110 -0
  348. gobby/workflows/autonomous_actions.py +286 -0
  349. gobby/workflows/context_actions.py +394 -0
  350. gobby/workflows/definitions.py +130 -0
  351. gobby/workflows/detection_helpers.py +208 -0
  352. gobby/workflows/engine.py +485 -0
  353. gobby/workflows/evaluator.py +669 -0
  354. gobby/workflows/git_utils.py +96 -0
  355. gobby/workflows/hooks.py +169 -0
  356. gobby/workflows/lifecycle_evaluator.py +613 -0
  357. gobby/workflows/llm_actions.py +70 -0
  358. gobby/workflows/loader.py +333 -0
  359. gobby/workflows/mcp_actions.py +60 -0
  360. gobby/workflows/memory_actions.py +272 -0
  361. gobby/workflows/premature_stop.py +164 -0
  362. gobby/workflows/session_actions.py +139 -0
  363. gobby/workflows/state_actions.py +123 -0
  364. gobby/workflows/state_manager.py +104 -0
  365. gobby/workflows/stop_signal_actions.py +163 -0
  366. gobby/workflows/summary_actions.py +344 -0
  367. gobby/workflows/task_actions.py +249 -0
  368. gobby/workflows/task_enforcement_actions.py +901 -0
  369. gobby/workflows/templates.py +52 -0
  370. gobby/workflows/todo_actions.py +84 -0
  371. gobby/workflows/webhook.py +223 -0
  372. gobby/workflows/webhook_executor.py +399 -0
  373. gobby/worktrees/__init__.py +5 -0
  374. gobby/worktrees/git.py +690 -0
  375. gobby/worktrees/merge/__init__.py +20 -0
  376. gobby/worktrees/merge/conflict_parser.py +177 -0
  377. gobby/worktrees/merge/resolver.py +485 -0
  378. gobby-0.2.5.dist-info/METADATA +351 -0
  379. gobby-0.2.5.dist-info/RECORD +383 -0
  380. gobby-0.2.5.dist-info/WHEEL +5 -0
  381. gobby-0.2.5.dist-info/entry_points.txt +2 -0
  382. gobby-0.2.5.dist-info/licenses/LICENSE.md +193 -0
  383. gobby-0.2.5.dist-info/top_level.txt +1 -0
@@ -0,0 +1,341 @@
1
+ """Artifact type classifier.
2
+
3
+ Automatically classifies content into artifact types:
4
+ - code: Programming language code blocks
5
+ - file_path: File or directory paths
6
+ - error: Error messages and stack traces
7
+ - command_output: Terminal/shell command output
8
+ - structured_data: JSON, YAML, TOML, XML
9
+ - text: Plain text (default)
10
+
11
+ Also extracts relevant metadata for each type (language, extension, format, etc.)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from dataclasses import dataclass, field
19
+ from enum import Enum
20
+ from typing import Any
21
+
22
+ __all__ = ["ArtifactType", "ClassificationResult", "classify_artifact"]
23
+
24
+
25
+ class ArtifactType(str, Enum):
26
+ """Artifact type enumeration."""
27
+
28
+ CODE = "code"
29
+ FILE_PATH = "file_path"
30
+ ERROR = "error"
31
+ COMMAND_OUTPUT = "command_output"
32
+ STRUCTURED_DATA = "structured_data"
33
+ TEXT = "text"
34
+
35
+
36
+ @dataclass
37
+ class ClassificationResult:
38
+ """Result of artifact classification."""
39
+
40
+ artifact_type: ArtifactType
41
+ metadata: dict[str, Any] = field(default_factory=dict)
42
+
43
+ def to_dict(self) -> dict[str, Any]:
44
+ """Convert to dictionary."""
45
+ return {
46
+ "artifact_type": self.artifact_type.value,
47
+ "metadata": self.metadata,
48
+ }
49
+
50
+
51
+ # Language detection patterns (more specific patterns first)
52
+ _LANGUAGE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
53
+ # Python
54
+ (
55
+ "python",
56
+ re.compile(
57
+ r"^\s*(def\s+\w+|class\s+\w+|import\s+\w+|from\s+\w+\s+import|async\s+def\s+\w+|@\w+)",
58
+ re.MULTILINE,
59
+ ),
60
+ ),
61
+ # TypeScript (must be before JavaScript - has interface/type)
62
+ (
63
+ "typescript",
64
+ re.compile(
65
+ r"^\s*(interface\s+\w+|type\s+\w+\s*=|:\s*(string|number|boolean|any)\b)", re.MULTILINE
66
+ ),
67
+ ),
68
+ # JavaScript
69
+ (
70
+ "javascript",
71
+ re.compile(
72
+ r"^\s*(function\s+\w+|const\s+\w+\s*=|let\s+\w+\s*=|var\s+\w+\s*=|=>\s*\{)",
73
+ re.MULTILINE,
74
+ ),
75
+ ),
76
+ # Rust
77
+ (
78
+ "rust",
79
+ re.compile(
80
+ r"^\s*(fn\s+\w+|impl\s+|struct\s+\w+|enum\s+\w+|use\s+\w+|pub\s+fn)", re.MULTILINE
81
+ ),
82
+ ),
83
+ # Go
84
+ ("go", re.compile(r"^\s*(func\s+\w+|func\s+\(\w+|package\s+\w+|import\s+\()", re.MULTILINE)),
85
+ # SQL
86
+ (
87
+ "sql",
88
+ re.compile(
89
+ r"^\s*(SELECT\s+|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|CREATE\s+TABLE|DROP\s+TABLE)",
90
+ re.IGNORECASE | re.MULTILINE,
91
+ ),
92
+ ),
93
+ # Shell/Bash
94
+ (
95
+ "bash",
96
+ re.compile(
97
+ r"(^#!/bin/(ba)?sh|^\s*for\s+\w+\s+in\s+|^\s*if\s+\[\[?\s+|^\s*while\s+|echo\s+[\"'])",
98
+ re.MULTILINE,
99
+ ),
100
+ ),
101
+ ]
102
+
103
+ # Markdown code fence pattern
104
+ _CODE_FENCE_PATTERN = re.compile(r"^```(\w*).*?\n(.*?)```", re.DOTALL | re.MULTILINE)
105
+
106
+ # File path patterns
107
+ _UNIX_PATH_PATTERN = re.compile(r"^(/[\w./\-_]+|\.{1,2}/[\w./\-_]+)$")
108
+ _WINDOWS_PATH_PATTERN = re.compile(r"^[A-Za-z]:\\[\w\\/.\-_]+$")
109
+ _RELATIVE_PATH_PATTERN = re.compile(r"^[\w\-_]+/[\w./\-_]+\.\w+$")
110
+
111
+ # Error patterns
112
+ _ERROR_PATTERNS = [
113
+ re.compile(r"^Traceback \(most recent call last\):", re.MULTILINE),
114
+ re.compile(r"^\w+Error:\s+", re.MULTILINE),
115
+ re.compile(r"^TypeError:\s+", re.MULTILINE),
116
+ re.compile(r"^Exception\s+", re.MULTILINE),
117
+ re.compile(r"^Error:\s+", re.MULTILINE),
118
+ re.compile(r"thread\s+'.*'\s+panicked\s+at", re.MULTILINE),
119
+ re.compile(r"^\s+at\s+[\w.]+\([\w.]+:\d+\)$", re.MULTILINE), # JS stack trace line
120
+ ]
121
+
122
+ # Command output patterns
123
+ _COMMAND_OUTPUT_PATTERNS = [
124
+ re.compile(r"^On branch\s+\w+", re.MULTILINE), # git status
125
+ re.compile(r"^\$ \w+", re.MULTILINE), # shell prompt
126
+ re.compile(r"^npm\s+(WARN|ERR!?|notice)", re.MULTILINE), # npm
127
+ re.compile(r"^={3,}\s+test session starts\s+={3,}$", re.MULTILINE), # pytest
128
+ re.compile(r"^total\s+\d+\s*$", re.MULTILINE), # ls -l
129
+ re.compile(r"^(d|-)rwx", re.MULTILINE), # ls -l permissions
130
+ re.compile(r"^added\s+\d+\s+packages?", re.MULTILINE), # npm install
131
+ re.compile(r"^collected\s+\d+\s+items?", re.MULTILINE), # pytest
132
+ re.compile(r"^\d+\s+passed", re.MULTILINE), # pytest results
133
+ ]
134
+
135
+
136
+ def _detect_language(content: str) -> str | None:
137
+ """Detect programming language from content."""
138
+ for lang, pattern in _LANGUAGE_PATTERNS:
139
+ if pattern.search(content):
140
+ return lang
141
+ return None
142
+
143
+
144
+ def _is_file_path(content: str) -> tuple[bool, dict[str, Any]]:
145
+ """Check if content is a file path and extract metadata."""
146
+ content = content.strip()
147
+
148
+ # Don't classify multi-line content as a file path
149
+ if "\n" in content:
150
+ return False, {}
151
+
152
+ metadata: dict[str, Any] = {}
153
+
154
+ # Check patterns
155
+ if _UNIX_PATH_PATTERN.match(content):
156
+ pass
157
+ elif _WINDOWS_PATH_PATTERN.match(content):
158
+ pass
159
+ elif _RELATIVE_PATH_PATTERN.match(content):
160
+ pass
161
+ else:
162
+ return False, {}
163
+
164
+ # Extract filename and extension
165
+ parts = content.replace("\\", "/").split("/")
166
+ filename = parts[-1]
167
+ metadata["filename"] = filename
168
+
169
+ if "." in filename:
170
+ ext = filename.rsplit(".", 1)[-1]
171
+ metadata["extension"] = ext
172
+ else:
173
+ metadata["extension"] = None
174
+
175
+ return True, metadata
176
+
177
+
178
+ def _is_error(content: str) -> bool:
179
+ """Check if content is an error message or stack trace."""
180
+ for pattern in _ERROR_PATTERNS:
181
+ if pattern.search(content):
182
+ return True
183
+ return False
184
+
185
+
186
+ def _is_command_output(content: str) -> bool:
187
+ """Check if content is command output."""
188
+ for pattern in _COMMAND_OUTPUT_PATTERNS:
189
+ if pattern.search(content):
190
+ return True
191
+ return False
192
+
193
+
194
+ def _is_json(content: str) -> bool:
195
+ """Check if content is valid JSON."""
196
+ content = content.strip()
197
+ if not (content.startswith("{") or content.startswith("[")):
198
+ return False
199
+ try:
200
+ json.loads(content)
201
+ return True
202
+ except (json.JSONDecodeError, ValueError):
203
+ return False
204
+
205
+
206
+ def _is_yaml(content: str) -> bool:
207
+ """Check if content looks like YAML (simple heuristic)."""
208
+ content = content.strip()
209
+ lines = content.split("\n")
210
+
211
+ # YAML typically has key: value patterns
212
+ # Must have actual values after the colon (not just colons in prose)
213
+ yaml_kv_pattern = re.compile(r"^\s*[\w\-_]+:\s*\S")
214
+ yaml_list_with_kv_pattern = re.compile(r"^\s*-\s+[\w\-_]+:\s*")
215
+
216
+ yaml_kv_lines = 0
217
+ total_non_empty = 0
218
+ for line in lines:
219
+ line = line.strip()
220
+ if not line or line.startswith("#"):
221
+ continue
222
+ total_non_empty += 1
223
+ # Count lines with key: value pattern (not just list items)
224
+ if yaml_kv_pattern.match(line) or yaml_list_with_kv_pattern.match(line):
225
+ yaml_kv_lines += 1
226
+
227
+ # Need at least 2 key-value lines and they should be significant portion
228
+ return yaml_kv_lines >= 2 and (yaml_kv_lines / max(total_non_empty, 1)) > 0.3
229
+
230
+
231
+ def _is_toml(content: str) -> bool:
232
+ """Check if content looks like TOML."""
233
+ content = content.strip()
234
+
235
+ # TOML has [section] headers and key = value
236
+ section_pattern = re.compile(r"^\s*\[[\w.\-]+\]\s*$", re.MULTILINE)
237
+ kv_pattern = re.compile(r"^\s*[\w\-]+\s*=\s*", re.MULTILINE)
238
+
239
+ has_section = section_pattern.search(content) is not None
240
+ has_kv = kv_pattern.search(content) is not None
241
+
242
+ return has_section and has_kv
243
+
244
+
245
+ def _is_xml(content: str) -> bool:
246
+ """Check if content looks like XML."""
247
+ content = content.strip()
248
+
249
+ # XML starts with <?xml or <tag>
250
+ if content.startswith("<?xml"):
251
+ return True
252
+
253
+ # Check for matching opening/closing tags
254
+ tag_pattern = re.compile(r"^<(\w+)[^>]*>.*</\1>", re.DOTALL)
255
+ return tag_pattern.match(content) is not None
256
+
257
+
258
+ def _is_code_block(content: str) -> tuple[bool, dict[str, Any]]:
259
+ """Check if content is a markdown code block and extract language."""
260
+ match = _CODE_FENCE_PATTERN.match(content.strip())
261
+ if match:
262
+ lang = match.group(1).lower() if match.group(1) else None
263
+ return True, {"language": lang} if lang else {}
264
+ return False, {}
265
+
266
+
267
+ def classify_artifact(content: str) -> ClassificationResult:
268
+ """
269
+ Classify content into an artifact type with metadata.
270
+
271
+ Args:
272
+ content: The content to classify
273
+
274
+ Returns:
275
+ ClassificationResult with artifact_type and extracted metadata
276
+ """
277
+ if not content or not content.strip():
278
+ return ClassificationResult(artifact_type=ArtifactType.TEXT, metadata={})
279
+
280
+ # Check for markdown code fence first
281
+ is_code_fence, fence_metadata = _is_code_block(content)
282
+ if is_code_fence:
283
+ metadata = fence_metadata.copy()
284
+ # If no language in fence, try to detect from content
285
+ if "language" not in metadata or not metadata["language"]:
286
+ inner_content = _CODE_FENCE_PATTERN.match(content.strip())
287
+ if inner_content:
288
+ detected_lang = _detect_language(inner_content.group(2))
289
+ if detected_lang:
290
+ metadata["language"] = detected_lang
291
+ return ClassificationResult(artifact_type=ArtifactType.CODE, metadata=metadata)
292
+
293
+ # Check for file path (single line only)
294
+ is_path, path_metadata = _is_file_path(content)
295
+ if is_path:
296
+ return ClassificationResult(artifact_type=ArtifactType.FILE_PATH, metadata=path_metadata)
297
+
298
+ # Check for error messages/stack traces
299
+ if _is_error(content):
300
+ metadata = {}
301
+ # Try to extract error type
302
+ error_match = re.search(r"^(\w+Error):", content, re.MULTILINE)
303
+ if error_match:
304
+ metadata["error"] = error_match.group(1)
305
+ return ClassificationResult(artifact_type=ArtifactType.ERROR, metadata=metadata)
306
+
307
+ # Check for code patterns BEFORE structured data
308
+ # (TypeScript interfaces look like YAML otherwise)
309
+ detected_lang = _detect_language(content)
310
+ if detected_lang:
311
+ return ClassificationResult(
312
+ artifact_type=ArtifactType.CODE, metadata={"language": detected_lang}
313
+ )
314
+
315
+ # Check for structured data formats
316
+ if _is_json(content):
317
+ return ClassificationResult(
318
+ artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "json"}
319
+ )
320
+
321
+ if _is_xml(content):
322
+ return ClassificationResult(
323
+ artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "xml"}
324
+ )
325
+
326
+ if _is_toml(content):
327
+ return ClassificationResult(
328
+ artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "toml"}
329
+ )
330
+
331
+ if _is_yaml(content):
332
+ return ClassificationResult(
333
+ artifact_type=ArtifactType.STRUCTURED_DATA, metadata={"format": "yaml"}
334
+ )
335
+
336
+ # Check for command output
337
+ if _is_command_output(content):
338
+ return ClassificationResult(artifact_type=ArtifactType.COMMAND_OUTPUT, metadata={})
339
+
340
+ # Default to text
341
+ return ClassificationResult(artifact_type=ArtifactType.TEXT, metadata={})
@@ -0,0 +1,285 @@
1
+ """
2
+ Session artifacts storage module.
3
+
4
+ Stores code snippets, diffs, errors, and other artifacts from sessions
5
+ with optional FTS5 full-text search support.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ import sqlite3
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+ from datetime import UTC, datetime
14
+ from typing import Any
15
+
16
+ from gobby.storage.database import DatabaseProtocol
17
+ from gobby.utils.id import generate_prefixed_id
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ @dataclass
23
+ class Artifact:
24
+ """A session artifact representing code, diff, error, or other content."""
25
+
26
+ id: str
27
+ session_id: str
28
+ artifact_type: str
29
+ content: str
30
+ created_at: str
31
+ metadata: dict[str, Any] | None = None
32
+ source_file: str | None = None
33
+ line_start: int | None = None
34
+ line_end: int | None = None
35
+
36
+ @classmethod
37
+ def from_row(cls, row: sqlite3.Row) -> "Artifact":
38
+ """Create an Artifact from a database row."""
39
+ metadata_json = row["metadata_json"]
40
+ metadata = json.loads(metadata_json) if metadata_json else None
41
+
42
+ return cls(
43
+ id=row["id"],
44
+ session_id=row["session_id"],
45
+ artifact_type=row["artifact_type"],
46
+ content=row["content"],
47
+ created_at=row["created_at"],
48
+ metadata=metadata,
49
+ source_file=row["source_file"],
50
+ line_start=row["line_start"],
51
+ line_end=row["line_end"],
52
+ )
53
+
54
+ def to_dict(self) -> dict[str, Any]:
55
+ """Convert artifact to dictionary for serialization."""
56
+ return {
57
+ "id": self.id,
58
+ "session_id": self.session_id,
59
+ "artifact_type": self.artifact_type,
60
+ "content": self.content,
61
+ "created_at": self.created_at,
62
+ "metadata": self.metadata,
63
+ "source_file": self.source_file,
64
+ "line_start": self.line_start,
65
+ "line_end": self.line_end,
66
+ }
67
+
68
+
69
+ class LocalArtifactManager:
70
+ """Manages session artifacts in local SQLite database."""
71
+
72
+ def __init__(self, db: DatabaseProtocol):
73
+ self.db = db
74
+ self._change_listeners: list[Callable[[], Any]] = []
75
+
76
+ def add_change_listener(self, listener: Callable[[], Any]) -> None:
77
+ """Add a change listener that will be called on create/delete."""
78
+ self._change_listeners.append(listener)
79
+
80
+ def _notify_listeners(self) -> None:
81
+ """Notify all change listeners."""
82
+ for listener in self._change_listeners:
83
+ try:
84
+ listener()
85
+ except Exception as e:
86
+ logger.error(f"Error in artifact change listener: {e}")
87
+
88
+ def create_artifact(
89
+ self,
90
+ session_id: str,
91
+ artifact_type: str,
92
+ content: str,
93
+ metadata: dict[str, Any] | None = None,
94
+ source_file: str | None = None,
95
+ line_start: int | None = None,
96
+ line_end: int | None = None,
97
+ ) -> Artifact:
98
+ """Create a new artifact.
99
+
100
+ Args:
101
+ session_id: ID of the session this artifact belongs to
102
+ artifact_type: Type of artifact (code, diff, error, etc.)
103
+ content: The artifact content
104
+ metadata: Optional metadata dict
105
+ source_file: Optional source file path
106
+ line_start: Optional starting line number
107
+ line_end: Optional ending line number
108
+
109
+ Returns:
110
+ The created Artifact
111
+ """
112
+ now = datetime.now(UTC).isoformat()
113
+ artifact_id = generate_prefixed_id("art", content[:50] + session_id)
114
+
115
+ metadata_json = json.dumps(metadata) if metadata else None
116
+
117
+ with self.db.transaction() as conn:
118
+ conn.execute(
119
+ """
120
+ INSERT INTO session_artifacts (
121
+ id, session_id, artifact_type, content, metadata_json,
122
+ source_file, line_start, line_end, created_at
123
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
124
+ """,
125
+ (
126
+ artifact_id,
127
+ session_id,
128
+ artifact_type,
129
+ content,
130
+ metadata_json,
131
+ source_file,
132
+ line_start,
133
+ line_end,
134
+ now,
135
+ ),
136
+ )
137
+ # Also insert into FTS5 table for full-text search
138
+ conn.execute(
139
+ "INSERT INTO session_artifacts_fts (id, content) VALUES (?, ?)",
140
+ (artifact_id, content),
141
+ )
142
+
143
+ self._notify_listeners()
144
+ return self.get_artifact(artifact_id) # type: ignore[return-value]
145
+
146
+ def get_artifact(self, artifact_id: str) -> Artifact | None:
147
+ """Get an artifact by ID.
148
+
149
+ Args:
150
+ artifact_id: The artifact ID
151
+
152
+ Returns:
153
+ The Artifact if found, None otherwise
154
+ """
155
+ row = self.db.fetchone("SELECT * FROM session_artifacts WHERE id = ?", (artifact_id,))
156
+ if not row:
157
+ return None
158
+ return Artifact.from_row(row)
159
+
160
+ def list_artifacts(
161
+ self,
162
+ session_id: str | None = None,
163
+ artifact_type: str | None = None,
164
+ limit: int = 100,
165
+ offset: int = 0,
166
+ ) -> list[Artifact]:
167
+ """List artifacts with optional filters.
168
+
169
+ Args:
170
+ session_id: Filter by session ID
171
+ artifact_type: Filter by artifact type
172
+ limit: Maximum number of results
173
+ offset: Offset for pagination
174
+
175
+ Returns:
176
+ List of matching Artifacts
177
+ """
178
+ query = "SELECT * FROM session_artifacts WHERE 1=1"
179
+ params: list[Any] = []
180
+
181
+ if session_id:
182
+ query += " AND session_id = ?"
183
+ params.append(session_id)
184
+
185
+ if artifact_type:
186
+ query += " AND artifact_type = ?"
187
+ params.append(artifact_type)
188
+
189
+ query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
190
+ params.extend([limit, offset])
191
+
192
+ rows = self.db.fetchall(query, tuple(params))
193
+ return [Artifact.from_row(row) for row in rows]
194
+
195
+ def delete_artifact(self, artifact_id: str) -> bool:
196
+ """Delete an artifact by ID.
197
+
198
+ Args:
199
+ artifact_id: The artifact ID to delete
200
+
201
+ Returns:
202
+ True if deleted, False if not found
203
+ """
204
+ with self.db.transaction() as conn:
205
+ cursor = conn.execute("DELETE FROM session_artifacts WHERE id = ?", (artifact_id,))
206
+ if cursor.rowcount == 0:
207
+ return False
208
+ # Also delete from FTS5 table
209
+ conn.execute("DELETE FROM session_artifacts_fts WHERE id = ?", (artifact_id,))
210
+
211
+ self._notify_listeners()
212
+ return True
213
+
214
+ def search_artifacts(
215
+ self,
216
+ query_text: str,
217
+ session_id: str | None = None,
218
+ artifact_type: str | None = None,
219
+ limit: int = 50,
220
+ ) -> list[Artifact]:
221
+ """Search artifacts by content using FTS5 full-text search.
222
+
223
+ Uses FTS5 MATCH query on session_artifacts_fts with bm25 ranking.
224
+ Can optionally filter by session_id and/or artifact_type.
225
+
226
+ Args:
227
+ query_text: The search query text
228
+ session_id: Optional session ID filter
229
+ artifact_type: Optional artifact type filter
230
+ limit: Maximum number of results (default: 50)
231
+
232
+ Returns:
233
+ List of matching Artifacts ordered by relevance (bm25 ranking)
234
+ """
235
+ # Empty query returns empty results
236
+ if not query_text or not query_text.strip():
237
+ return []
238
+
239
+ # Escape FTS5 special characters and build query
240
+ # Split into words and add prefix matching for each term
241
+ words = query_text.strip().split()
242
+ if not words:
243
+ return []
244
+
245
+ # Build FTS5 query: each word becomes a prefix search term
246
+ # e.g., "calculate total" -> "calculate* total*"
247
+ fts_terms = []
248
+ for word in words:
249
+ # Remove FTS5 special chars that would break syntax: * ^ " ( ) AND OR NOT
250
+ # Keep only alphanumeric and safe punctuation
251
+ sanitized = ""
252
+ for char in word:
253
+ if char.isalnum() or char in "-_":
254
+ sanitized += char
255
+ if sanitized:
256
+ fts_terms.append(f"{sanitized}*")
257
+
258
+ if not fts_terms:
259
+ return []
260
+
261
+ fts_query = " ".join(fts_terms)
262
+
263
+ # Use FTS5 MATCH query with JOIN to main table
264
+ # Order by bm25() for relevance ranking (lower bm25 = more relevant)
265
+ sql = """
266
+ SELECT sa.*
267
+ FROM session_artifacts sa
268
+ INNER JOIN session_artifacts_fts fts ON sa.id = fts.id
269
+ WHERE fts.content MATCH ?
270
+ """
271
+ params: list[Any] = [fts_query]
272
+
273
+ if session_id:
274
+ sql += " AND sa.session_id = ?"
275
+ params.append(session_id)
276
+
277
+ if artifact_type:
278
+ sql += " AND sa.artifact_type = ?"
279
+ params.append(artifact_type)
280
+
281
+ sql += " ORDER BY bm25(session_artifacts_fts) LIMIT ?"
282
+ params.append(limit)
283
+
284
+ rows = self.db.fetchall(sql, tuple(params))
285
+ return [Artifact.from_row(row) for row in rows]
@@ -0,0 +1,67 @@
1
+ """Task compaction logic."""
2
+
3
+ from datetime import UTC, datetime, timedelta
4
+ from typing import Any
5
+
6
+ from gobby.storage.tasks import LocalTaskManager
7
+
8
+
9
+ class TaskCompactor:
10
+ """Handles compaction of old closed tasks."""
11
+
12
+ def __init__(self, task_manager: LocalTaskManager) -> None:
13
+ self.task_manager = task_manager
14
+
15
+ def find_candidates(self, days_closed: int = 30) -> list[dict[str, Any]]:
16
+ """
17
+ Find tasks that have been closed for longer than the specified days
18
+ and haven't been compacted yet.
19
+ """
20
+ cutoff = datetime.now(UTC) - timedelta(days=days_closed)
21
+ cutoff_str = cutoff.isoformat()
22
+
23
+ # Query directly since we need custom filtering not exposed by list_tasks
24
+ sql = """
25
+ SELECT * FROM tasks
26
+ WHERE status = 'closed'
27
+ AND updated_at < ?
28
+ AND compacted_at IS NULL
29
+ ORDER BY updated_at ASC
30
+ """
31
+ rows = self.task_manager.db.fetchall(sql, (cutoff_str,))
32
+ return [dict(row) for row in rows]
33
+
34
+ def compact_task(self, task_id: str, summary: str) -> None:
35
+ """
36
+ Compact a task by replacing its description with a summary.
37
+ """
38
+ # Update database directly to set compacted_at
39
+ now = datetime.now(UTC).isoformat()
40
+
41
+ # We preserve the title but replace description with summary
42
+ # and mark it as compacted.
43
+ sql = """
44
+ UPDATE tasks
45
+ SET description = ?,
46
+ summary = ?,
47
+ compacted_at = ?,
48
+ updated_at = ?
49
+ WHERE id = ?
50
+ """
51
+
52
+ self.task_manager.db.execute(sql, (summary, summary, now, now, task_id))
53
+ self.task_manager._notify_listeners()
54
+
55
+ def get_stats(self) -> dict[str, Any]:
56
+ """Get compaction statistics."""
57
+ sql_total = "SELECT COUNT(*) as c FROM tasks WHERE status = 'closed'"
58
+ sql_compacted = "SELECT COUNT(*) as c FROM tasks WHERE compacted_at IS NOT NULL"
59
+
60
+ total = (self.task_manager.db.fetchone(sql_total) or {"c": 0})["c"]
61
+ compacted = (self.task_manager.db.fetchone(sql_compacted) or {"c": 0})["c"]
62
+
63
+ return {
64
+ "total_closed": total,
65
+ "compacted": compacted,
66
+ "rate": round(compacted / total * 100, 1) if total > 0 else 0,
67
+ }