aria-code 4.1.3__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 (284) hide show
  1. agents/__init__.py +32 -0
  2. agents/base.py +190 -0
  3. agents/deep/__init__.py +37 -0
  4. agents/deep/calibration_loop.py +144 -0
  5. agents/deep/critic.py +125 -0
  6. agents/deep/deepen.py +193 -0
  7. agents/deep/models.py +149 -0
  8. agents/deep/pipeline.py +164 -0
  9. agents/deep/quant_fusion.py +192 -0
  10. agents/deep/themes.py +95 -0
  11. agents/deep/tiers.py +106 -0
  12. agents/financial/__init__.py +10 -0
  13. agents/financial/catalyst.py +279 -0
  14. agents/financial/debate.py +145 -0
  15. agents/financial/earnings.py +303 -0
  16. agents/financial/fundamental.py +159 -0
  17. agents/financial/macro.py +99 -0
  18. agents/financial/news.py +207 -0
  19. agents/financial/risk.py +132 -0
  20. agents/financial/sector.py +279 -0
  21. agents/financial/synthesis.py +274 -0
  22. agents/financial/technical.py +258 -0
  23. agents/portfolio_agent.py +333 -0
  24. agents/realty/__init__.py +62 -0
  25. agents/realty/asset_diagnosis.py +150 -0
  26. agents/realty/business_match.py +165 -0
  27. agents/realty/cashflow_verify.py +208 -0
  28. agents/realty/contract_rules.py +209 -0
  29. agents/realty/energy_anomaly.py +188 -0
  30. agents/realty/exit_settlement.py +207 -0
  31. agents/realty/fulfillment_risk.py +205 -0
  32. agents/realty/ops_optimize.py +159 -0
  33. agents/realty/revenue_share.py +214 -0
  34. agents/registry.py +144 -0
  35. agents/sports/__init__.py +0 -0
  36. agents/sports/football_agent.py +169 -0
  37. agents/team.py +289 -0
  38. aliyun_data_client.py +660 -0
  39. apps/README.md +12 -0
  40. apps/__init__.py +2 -0
  41. apps/channels/README.md +15 -0
  42. apps/cli/README.md +13 -0
  43. apps/cli/__init__.py +2 -0
  44. apps/cli/bootstrap.py +99 -0
  45. apps/cli/codegen_paths.py +29 -0
  46. apps/cli/commands/__init__.py +16 -0
  47. apps/cli/commands/analysis_cmds.py +288 -0
  48. apps/cli/commands/backtest_cmds.py +1887 -0
  49. apps/cli/commands/broker_cmds.py +1154 -0
  50. apps/cli/commands/business_workflow_cmds.py +289 -0
  51. apps/cli/commands/catalog.py +84 -0
  52. apps/cli/commands/data_cmds.py +405 -0
  53. apps/cli/commands/diagnostic_cmds.py +179 -0
  54. apps/cli/commands/diagnostic_ops_cmds.py +696 -0
  55. apps/cli/commands/finance_render.py +12 -0
  56. apps/cli/commands/market.py +399 -0
  57. apps/cli/commands/market_cmds.py +1276 -0
  58. apps/cli/commands/market_context.py +425 -0
  59. apps/cli/commands/market_render.py +7 -0
  60. apps/cli/commands/model_cmds.py +1579 -0
  61. apps/cli/commands/ops_cmds.py +668 -0
  62. apps/cli/commands/portfolio_cmds.py +962 -0
  63. apps/cli/commands/report.py +377 -0
  64. apps/cli/commands/scaffold_templates.py +617 -0
  65. apps/cli/commands/session_cmds.py +179 -0
  66. apps/cli/commands/session_ux_cmds.py +280 -0
  67. apps/cli/commands/team.py +588 -0
  68. apps/cli/commands/team_render.py +8 -0
  69. apps/cli/commands/ui_cmds.py +358 -0
  70. apps/cli/commands/workflow_cmds.py +279 -0
  71. apps/cli/commands/workspace_cmds.py +1414 -0
  72. apps/cli/config_paths.py +70 -0
  73. apps/cli/config_store.py +61 -0
  74. apps/cli/deterministic.py +122 -0
  75. apps/cli/direct.py +48 -0
  76. apps/cli/github_app_auth.py +135 -0
  77. apps/cli/handlers/__init__.py +11 -0
  78. apps/cli/handlers/broker_handlers.py +122 -0
  79. apps/cli/handlers/chart_handlers.py +1309 -0
  80. apps/cli/handlers/market_handlers.py +2509 -0
  81. apps/cli/handlers/realty_handlers.py +114 -0
  82. apps/cli/handlers/strategy_advice.py +82 -0
  83. apps/cli/hooks.py +180 -0
  84. apps/cli/i18n.py +284 -0
  85. apps/cli/intent.py +136 -0
  86. apps/cli/intent_router.py +217 -0
  87. apps/cli/lifecycle_hooks.py +48 -0
  88. apps/cli/main.py +29 -0
  89. apps/cli/market_metadata.py +135 -0
  90. apps/cli/market_universe.py +265 -0
  91. apps/cli/message_processing.py +257 -0
  92. apps/cli/plan_mode.py +139 -0
  93. apps/cli/plotly_html.py +15 -0
  94. apps/cli/prediction_feedback.py +202 -0
  95. apps/cli/preflight.py +497 -0
  96. apps/cli/project_aria.py +60 -0
  97. apps/cli/prompts/__init__.py +0 -0
  98. apps/cli/prompts/coding.py +658 -0
  99. apps/cli/prompts/system_prompts.py +531 -0
  100. apps/cli/prompts/ui.py +434 -0
  101. apps/cli/providers/__init__.py +1 -0
  102. apps/cli/providers/base.py +271 -0
  103. apps/cli/providers/chat_routing.py +80 -0
  104. apps/cli/providers/llm/__init__.py +1 -0
  105. apps/cli/providers/llm/ollama_stream.py +1170 -0
  106. apps/cli/providers/llm/sse_stream.py +216 -0
  107. apps/cli/providers/runtime_bridge.py +185 -0
  108. apps/cli/runtime_consumer.py +489 -0
  109. apps/cli/session_export.py +87 -0
  110. apps/cli/session_jsonl.py +207 -0
  111. apps/cli/session_store.py +112 -0
  112. apps/cli/todo_tracker.py +190 -0
  113. apps/cli/tools/__init__.py +40 -0
  114. apps/cli/tools/context.py +46 -0
  115. apps/cli/tools/file_tools.py +112 -0
  116. apps/cli/tools/market_tools.py +549 -0
  117. apps/cli/tools/notebook_tools.py +111 -0
  118. apps/cli/tools/system_tools.py +669 -0
  119. apps/cli/tools/write_tools.py +715 -0
  120. apps/cli/tradingview_bridge.py +434 -0
  121. apps/cli/update_check.py +152 -0
  122. apps/cli/utils/__init__.py +0 -0
  123. apps/cli/utils/market_detect.py +1578 -0
  124. apps/daemon/README.md +14 -0
  125. apps/vscode/README.md +115 -0
  126. apps/vscode/package.json +70 -0
  127. aria_cli.py +11636 -0
  128. aria_code-4.1.3.dist-info/METADATA +952 -0
  129. aria_code-4.1.3.dist-info/RECORD +284 -0
  130. aria_code-4.1.3.dist-info/WHEEL +5 -0
  131. aria_code-4.1.3.dist-info/entry_points.txt +2 -0
  132. aria_code-4.1.3.dist-info/licenses/LICENSE +121 -0
  133. aria_code-4.1.3.dist-info/top_level.txt +50 -0
  134. aria_daemon.py +1295 -0
  135. aria_feishu_bot.py +1359 -0
  136. aria_relay_client.py +182 -0
  137. aria_relay_server.py +405 -0
  138. aria_telegram_bot.py +202 -0
  139. ariarc.py +328 -0
  140. artifacts.py +491 -0
  141. backtest_report.py +472 -0
  142. brokers/__init__.py +72 -0
  143. brokers/base.py +207 -0
  144. brokers/capabilities.py +264 -0
  145. brokers/cn/__init__.py +10 -0
  146. brokers/cn/easytrader_broker.py +193 -0
  147. brokers/cn/futu_broker.py +194 -0
  148. brokers/cn/longbridge_broker.py +190 -0
  149. brokers/cn/tiger_broker.py +196 -0
  150. brokers/cn/xtquant_broker.py +175 -0
  151. brokers/config.py +364 -0
  152. brokers/intl/__init__.py +5 -0
  153. brokers/intl/alpaca_broker.py +183 -0
  154. brokers/intl/ibkr_broker.py +215 -0
  155. brokers/intl/webull_broker.py +156 -0
  156. brokers/paper_broker.py +259 -0
  157. brokers/planning.py +296 -0
  158. brokers/registry.py +181 -0
  159. brokers/trading.py +237 -0
  160. change_store.py +127 -0
  161. command_safety.py +19 -0
  162. computer_use_tools.py +504 -0
  163. dashboard_generator.py +578 -0
  164. data_analysis_tools.py +808 -0
  165. data_cleaner.py +483 -0
  166. data_service.py +481 -0
  167. datasources/__init__.py +23 -0
  168. datasources/base.py +166 -0
  169. datasources/router.py +221 -0
  170. datasources/sources/__init__.py +15 -0
  171. datasources/sources/akshare_source.py +269 -0
  172. datasources/sources/alpha_vantage_source.py +202 -0
  173. datasources/sources/edgar_source.py +218 -0
  174. datasources/sources/finnhub_source.py +197 -0
  175. datasources/sources/fred_source.py +219 -0
  176. datasources/sources/tushare_source.py +141 -0
  177. datasources/sources/web_scraper_source.py +278 -0
  178. datasources/sources/world_bank_source.py +205 -0
  179. datasources/sources/yfinance_source.py +152 -0
  180. demo_player.py +204 -0
  181. doctor.py +508 -0
  182. file_analysis_tools.py +734 -0
  183. finance_formulas.py +389 -0
  184. football_data_client.py +1670 -0
  185. intent_classifier.py +358 -0
  186. local_finance_tools.py +3221 -0
  187. local_llm_provider.py +552 -0
  188. macro_tools.py +368 -0
  189. market_data_client.py +1899 -0
  190. mcp_client.py +506 -0
  191. memory_manager.py +245 -0
  192. model_capability.py +416 -0
  193. notification_tools.py +248 -0
  194. packages/__init__.py +23 -0
  195. packages/aria_agents/__init__.py +5 -0
  196. packages/aria_agents/manifest.py +69 -0
  197. packages/aria_core/__init__.py +34 -0
  198. packages/aria_core/architecture.py +192 -0
  199. packages/aria_core/export.py +124 -0
  200. packages/aria_core/manifest.py +65 -0
  201. packages/aria_infra/__init__.py +15 -0
  202. packages/aria_infra/arthera.py +52 -0
  203. packages/aria_infra/doctor.py +246 -0
  204. packages/aria_infra/product.py +37 -0
  205. packages/aria_mcp/__init__.py +25 -0
  206. packages/aria_mcp/bridge.py +38 -0
  207. packages/aria_mcp/config.py +97 -0
  208. packages/aria_mcp/tools.py +61 -0
  209. packages/aria_sdk/__init__.py +19 -0
  210. packages/aria_sdk/client.py +396 -0
  211. packages/aria_sdk/providers.py +70 -0
  212. packages/aria_sdk/streaming.py +73 -0
  213. packages/aria_sdk/types.py +86 -0
  214. packages/aria_services/__init__.py +55 -0
  215. packages/aria_services/context.py +258 -0
  216. packages/aria_services/data.py +11 -0
  217. packages/aria_services/provider_health.py +189 -0
  218. packages/aria_services/registry.py +213 -0
  219. packages/aria_services/usage.py +138 -0
  220. packages/aria_skills/__init__.py +5 -0
  221. packages/aria_skills/registry.py +59 -0
  222. packages/aria_tools/__init__.py +5 -0
  223. packages/aria_tools/registry.py +128 -0
  224. packages/quant_engine/__init__.py +6 -0
  225. packages/quant_engine/sports/__init__.py +72 -0
  226. packages/quant_engine/sports/calibrator.py +353 -0
  227. packages/quant_engine/sports/dixon_coles.py +234 -0
  228. packages/quant_engine/sports/elo.py +299 -0
  229. packages/quant_engine/sports/form.py +188 -0
  230. packages/quant_engine/sports/h2h.py +195 -0
  231. packages/quant_engine/sports/ml_model.py +354 -0
  232. packages/quant_engine/sports/predictor.py +311 -0
  233. packages/quant_engine/sports/tracker.py +664 -0
  234. packages/quant_engine/stochastic/__init__.py +27 -0
  235. packages/quant_engine/stochastic/gbm_enhanced.py +195 -0
  236. packages/quant_engine/stochastic/ito_calculus.py +477 -0
  237. packages/quant_engine/stochastic/kelly_criterion.py +181 -0
  238. packages/quant_engine/stochastic/monte_carlo_advanced.py +95 -0
  239. packages/quant_engine/stochastic/options_pricing.py +573 -0
  240. packages/quant_engine/stochastic/stochastic_processes.py +90 -0
  241. plan_utils.py +194 -0
  242. plugin_loader.py +328 -0
  243. portfolio_ledger.py +262 -0
  244. privacy/__init__.py +5 -0
  245. privacy/feedback.py +123 -0
  246. project_tools.py +525 -0
  247. providers/__init__.py +30 -0
  248. providers/llm/__init__.py +19 -0
  249. providers/llm/anthropic.py +184 -0
  250. providers/llm/base.py +139 -0
  251. providers/llm/ollama.py +128 -0
  252. providers/llm/openai_compat.py +282 -0
  253. providers/llm/registry.py +358 -0
  254. realty_data_tools.py +659 -0
  255. report_generator.py +1314 -0
  256. runtime/__init__.py +103 -0
  257. runtime/agent_loop.py +1183 -0
  258. runtime/approval.py +51 -0
  259. runtime/events.py +102 -0
  260. runtime/gateway.py +128 -0
  261. runtime/lsp.py +346 -0
  262. runtime/subagent.py +258 -0
  263. runtime/tool_executor.py +104 -0
  264. runtime/tool_policy.py +106 -0
  265. safety/__init__.py +21 -0
  266. safety/permissions.py +275 -0
  267. setup_wizard.py +653 -0
  268. strategy_vault.py +420 -0
  269. ui/__init__.py +100 -0
  270. ui/banner.py +310 -0
  271. ui/completer.py +391 -0
  272. ui/console.py +271 -0
  273. ui/image_render.py +243 -0
  274. ui/input_box.py +376 -0
  275. ui/picker.py +195 -0
  276. ui/render/__init__.py +11 -0
  277. ui/render/finance.py +1480 -0
  278. ui/render/market.py +225 -0
  279. ui/render/output.py +681 -0
  280. ui/render/team.py +346 -0
  281. ui/robot.py +235 -0
  282. workspace/__init__.py +6 -0
  283. workspace/files.py +170 -0
  284. workspace/verify.py +113 -0
artifacts.py ADDED
@@ -0,0 +1,491 @@
1
+ """Local artifact paths for reports, charts, projects, and strategy output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+
14
+ def slugify_topic(topic: Optional[str], fallback: str = "general") -> str:
15
+ raw = str(topic or "").strip()
16
+ if not raw:
17
+ raw = fallback
18
+ raw = re.sub(r"[^A-Za-z0-9._\-\u4e00-\u9fff]+", "-", raw)
19
+ raw = raw.strip("-._")
20
+ return raw[:80] or fallback
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ArtifactRecord:
25
+ """Resolved paths for one generated artifact bundle."""
26
+
27
+ category: str
28
+ topic: str
29
+ directory: Path
30
+ path: Path
31
+ metadata_path: Path
32
+ raw_data_path: Path
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ArtifactEntry:
37
+ """Parsed artifact metadata with resolved paths and file state."""
38
+
39
+ metadata_path: Path
40
+ path: Path
41
+ raw_data_path: Path
42
+ kind: str
43
+ status: str
44
+ topic: str
45
+ created_at: str
46
+ mtime: float
47
+ size_bytes: int
48
+
49
+
50
+ def _safe_stat(path: Path) -> Optional[os.stat_result]:
51
+ try:
52
+ return path.stat()
53
+ except Exception:
54
+ return None
55
+
56
+
57
+ def _artifact_entry_from_metadata(path: Path) -> Optional[ArtifactEntry]:
58
+ try:
59
+ data = json.loads(path.read_text(encoding="utf-8"))
60
+ artifact = data.get("artifact") or {}
61
+ output_path = Path(str(artifact.get("path") or ""))
62
+ metadata_path = Path(str(artifact.get("metadata_path") or path))
63
+ raw_path = Path(str(artifact.get("raw_data_path") or path.with_suffix(".raw_data.json")))
64
+ output_stat = _safe_stat(output_path)
65
+ meta_stat = _safe_stat(metadata_path)
66
+ raw_stat = _safe_stat(raw_path)
67
+ mtime = 0.0
68
+ for stat in (output_stat, meta_stat, raw_stat):
69
+ if stat is not None:
70
+ mtime = max(mtime, float(stat.st_mtime))
71
+ size_bytes = 0
72
+ for stat in (output_stat, meta_stat, raw_stat):
73
+ if stat is not None:
74
+ size_bytes += int(stat.st_size)
75
+ return ArtifactEntry(
76
+ metadata_path=metadata_path,
77
+ path=output_path,
78
+ raw_data_path=raw_path,
79
+ kind=str(data.get("kind") or artifact.get("category") or "artifact"),
80
+ status=str(data.get("status") or "unknown"),
81
+ topic=str(artifact.get("topic") or data.get("symbol") or ""),
82
+ created_at=str(data.get("created_at") or ""),
83
+ mtime=mtime or float(meta_stat.st_mtime if meta_stat else 0),
84
+ size_bytes=size_bytes,
85
+ )
86
+ except Exception:
87
+ return None
88
+
89
+
90
+ def _cleanup_empty_dirs(start: Path, stop: Path) -> None:
91
+ current = start
92
+ try:
93
+ stop = stop.resolve()
94
+ except Exception:
95
+ return
96
+ while True:
97
+ try:
98
+ current = current.resolve()
99
+ except Exception:
100
+ break
101
+ if current == stop or stop not in current.parents:
102
+ break
103
+ try:
104
+ current.rmdir()
105
+ except Exception:
106
+ break
107
+ current = current.parent
108
+
109
+
110
+ def _project_artifact_root() -> Optional[Path]:
111
+ """Return project-level artifact root from .ariarc when configured."""
112
+ try:
113
+ from ariarc import AriaRC
114
+
115
+ rc = AriaRC.load()
116
+ source = rc.source_path
117
+ if not source:
118
+ return None
119
+ data = getattr(rc, "_data", None)
120
+ if not isinstance(data, dict):
121
+ text = source.read_text(encoding="utf-8")
122
+ import json as _json
123
+ import re as _re
124
+ text = _re.sub(r"/\*.*?\*/", "", text, flags=_re.DOTALL)
125
+ text = _re.sub(r'(?<!:)(?<!https)//[^\n]*', "", text)
126
+ data = _json.loads(text)
127
+ configured = data.get("artifact_root") or data.get("output_dir")
128
+ if configured:
129
+ path = Path(str(configured)).expanduser()
130
+ if not path.is_absolute():
131
+ path = source.parent / path
132
+ return path
133
+ return source.parent / "aria-output"
134
+ except Exception:
135
+ return None
136
+ return None
137
+
138
+
139
+ def artifact_root() -> Path:
140
+ """Return the per-user local artifact root.
141
+
142
+ Override with ARIA_ARTIFACT_ROOT when a user wants reports/projects under a
143
+ specific workspace. Defaults to a product-owned folder under that user's
144
+ Documents directory, not the developer's Arthera repo.
145
+ """
146
+ configured = os.getenv("ARIA_ARTIFACT_ROOT")
147
+ if configured:
148
+ return Path(configured).expanduser()
149
+ project_root = _project_artifact_root()
150
+ if project_root:
151
+ return project_root
152
+ return Path.home() / "Documents" / "Aria Code"
153
+
154
+
155
+ def user_output_root() -> Path:
156
+ """Return a user-owned output root that never falls back to project cwd.
157
+
158
+ Use this for generated code, strategies, and project scaffolds. Reports and
159
+ charts may still honor project `.ariarc` via `artifact_root()`, but user
160
+ code should not silently land in the Aria source checkout.
161
+ """
162
+ configured = os.getenv("ARIA_USER_OUTPUT_ROOT")
163
+ if configured:
164
+ return Path(configured).expanduser()
165
+ return Path.home() / "Documents" / "Aria Code"
166
+
167
+
168
+ def user_projects_dir(create: bool = True) -> Path:
169
+ path = user_output_root() / "projects"
170
+ if create:
171
+ path.mkdir(parents=True, exist_ok=True)
172
+ return path
173
+
174
+
175
+ def user_generated_dir(create: bool = True) -> Path:
176
+ path = user_output_root() / "generated"
177
+ if create:
178
+ path.mkdir(parents=True, exist_ok=True)
179
+ return path
180
+
181
+
182
+ def artifact_roots(*, include_user_generated: bool = True) -> list[Path]:
183
+ """Return unique artifact roots that should be visible to users."""
184
+ roots: list[Path] = []
185
+ for root in (artifact_root(), user_generated_dir(create=False) if include_user_generated else None):
186
+ if root is None:
187
+ continue
188
+ try:
189
+ resolved = root.expanduser().resolve()
190
+ except Exception:
191
+ resolved = root.expanduser()
192
+ if not any(existing == resolved for existing in roots):
193
+ roots.append(resolved)
194
+ return roots
195
+
196
+
197
+ def artifact_dir(category: str, topic: Optional[str] = None, create: bool = True) -> Path:
198
+ parts = [slugify_topic(part) for part in str(category or "artifacts").split("/") if part]
199
+ base = artifact_root().joinpath(*parts)
200
+ if topic:
201
+ base = base / slugify_topic(topic)
202
+ if create:
203
+ base.mkdir(parents=True, exist_ok=True)
204
+ return base
205
+
206
+
207
+ def create_artifact(
208
+ category: str,
209
+ topic: Optional[str],
210
+ stem: str,
211
+ suffix: str,
212
+ *,
213
+ timestamp: Optional[datetime] = None,
214
+ create: bool = True,
215
+ ) -> ArtifactRecord:
216
+ """Create a dated artifact bundle path and its sidecar metadata paths.
217
+
218
+ Output layout:
219
+
220
+ <root>/<category>/<topic>/YYYY-MM-DD/<HHMMSS>_<stem><suffix>
221
+ <root>/<category>/<topic>/YYYY-MM-DD/<HHMMSS>_<stem>.metadata.json
222
+ <root>/<category>/<topic>/YYYY-MM-DD/<HHMMSS>_<stem>.raw_data.json
223
+ """
224
+
225
+ ts = timestamp or datetime.now()
226
+ topic_slug = slugify_topic(topic)
227
+ stem_slug = slugify_topic(stem, fallback="artifact")
228
+ suffix = suffix if suffix.startswith(".") else f".{suffix}"
229
+ directory = artifact_dir(category, topic_slug, create=False) / ts.strftime("%Y-%m-%d")
230
+ prefix = ts.strftime("%H%M%S")
231
+ base_name = f"{prefix}_{stem_slug}"
232
+ if create:
233
+ directory.mkdir(parents=True, exist_ok=True)
234
+ return ArtifactRecord(
235
+ category=category,
236
+ topic=topic_slug,
237
+ directory=directory,
238
+ path=directory / f"{base_name}{suffix}",
239
+ metadata_path=directory / f"{base_name}.metadata.json",
240
+ raw_data_path=directory / f"{base_name}.raw_data.json",
241
+ )
242
+
243
+
244
+ def create_user_artifact(
245
+ category: str,
246
+ topic: Optional[str],
247
+ stem: str,
248
+ suffix: str,
249
+ *,
250
+ timestamp: Optional[datetime] = None,
251
+ create: bool = True,
252
+ ) -> ArtifactRecord:
253
+ """Create an artifact under the user-owned output root.
254
+
255
+ Unlike `create_artifact`, this intentionally ignores project `.ariarc`
256
+ output settings. Use it for charts, scripts, and generated assets the user
257
+ expects to find in their local Aria output folder rather than the source
258
+ checkout.
259
+ """
260
+ ts = timestamp or datetime.now()
261
+ parts = [slugify_topic(part) for part in str(category or "generated").split("/") if part]
262
+ topic_slug = slugify_topic(topic)
263
+ stem_slug = slugify_topic(stem, fallback="artifact")
264
+ suffix = suffix if suffix.startswith(".") else f".{suffix}"
265
+ directory = user_generated_dir(create=False).joinpath(*parts, topic_slug, ts.strftime("%Y-%m-%d"))
266
+ prefix = ts.strftime("%H%M%S")
267
+ base_name = f"{prefix}_{stem_slug}"
268
+ if create:
269
+ directory.mkdir(parents=True, exist_ok=True)
270
+ return ArtifactRecord(
271
+ category=f"generated/{'/'.join(parts)}" if parts else "generated",
272
+ topic=topic_slug,
273
+ directory=directory,
274
+ path=directory / f"{base_name}{suffix}",
275
+ metadata_path=directory / f"{base_name}.metadata.json",
276
+ raw_data_path=directory / f"{base_name}.raw_data.json",
277
+ )
278
+
279
+
280
+ def write_artifact_metadata(record: ArtifactRecord, metadata: Dict[str, Any]) -> Path:
281
+ payload = {
282
+ "artifact": {
283
+ "category": record.category,
284
+ "topic": record.topic,
285
+ "path": str(record.path),
286
+ "metadata_path": str(record.metadata_path),
287
+ "raw_data_path": str(record.raw_data_path),
288
+ },
289
+ **metadata,
290
+ }
291
+ record.metadata_path.write_text(
292
+ json.dumps(payload, ensure_ascii=False, indent=2, default=str),
293
+ encoding="utf-8",
294
+ )
295
+ return record.metadata_path
296
+
297
+
298
+ def write_artifact_raw_data(record: ArtifactRecord, data: Any) -> Path:
299
+ record.raw_data_path.write_text(
300
+ json.dumps(data, ensure_ascii=False, indent=2, default=str),
301
+ encoding="utf-8",
302
+ )
303
+ return record.raw_data_path
304
+
305
+
306
+ def recent_artifacts(limit: int = 20, root: Optional[Path] = None) -> list[Dict[str, Any]]:
307
+ """Return recent artifact metadata records, newest first."""
308
+ base = root or artifact_root()
309
+ if not base.exists():
310
+ return []
311
+ items: list[Dict[str, Any]] = []
312
+ for path in base.rglob("*.metadata.json"):
313
+ entry = _artifact_entry_from_metadata(path)
314
+ if entry is None:
315
+ continue
316
+ items.append({
317
+ "kind": entry.kind,
318
+ "status": entry.status,
319
+ "topic": entry.topic,
320
+ "path": str(entry.path) if str(entry.path) else "",
321
+ "metadata_path": str(entry.metadata_path),
322
+ "raw_data_path": str(entry.raw_data_path),
323
+ "created_at": entry.created_at,
324
+ "mtime": entry.mtime,
325
+ "size_bytes": entry.size_bytes,
326
+ })
327
+ items.sort(key=lambda item: item.get("mtime") or 0, reverse=True)
328
+ return items[: max(0, int(limit))]
329
+
330
+
331
+ def recent_artifacts_all(limit: int = 20) -> list[Dict[str, Any]]:
332
+ """Return recent artifacts across project and user-generated roots."""
333
+ items: list[Dict[str, Any]] = []
334
+ seen: set[str] = set()
335
+ for root in artifact_roots(include_user_generated=True):
336
+ for item in recent_artifacts(limit=max(limit, 20), root=root):
337
+ marker = str(item.get("metadata_path") or item.get("path") or "")
338
+ if marker and marker in seen:
339
+ continue
340
+ if marker:
341
+ seen.add(marker)
342
+ item = dict(item)
343
+ item["root"] = str(root)
344
+ items.append(item)
345
+ items.sort(key=lambda item: item.get("mtime") or 0, reverse=True)
346
+ return items[: max(0, int(limit))]
347
+
348
+
349
+ def artifact_summary(root: Optional[Path] = None) -> Dict[str, Any]:
350
+ """Return a lightweight inventory of artifacts under root."""
351
+ base = root or artifact_root()
352
+ if not base.exists():
353
+ return {
354
+ "root": str(base),
355
+ "total": 0,
356
+ "total_size_bytes": 0,
357
+ "by_kind": {},
358
+ "newest_mtime": 0.0,
359
+ "oldest_mtime": 0.0,
360
+ }
361
+ entries: list[ArtifactEntry] = []
362
+ for path in base.rglob("*.metadata.json"):
363
+ entry = _artifact_entry_from_metadata(path)
364
+ if entry is not None:
365
+ entries.append(entry)
366
+ by_kind: Dict[str, int] = {}
367
+ newest = 0.0
368
+ oldest = 0.0
369
+ total_size = 0
370
+ for entry in entries:
371
+ by_kind[entry.kind] = by_kind.get(entry.kind, 0) + 1
372
+ total_size += entry.size_bytes
373
+ if entry.mtime:
374
+ newest = max(newest, entry.mtime)
375
+ oldest = entry.mtime if not oldest else min(oldest, entry.mtime)
376
+ return {
377
+ "root": str(base),
378
+ "total": len(entries),
379
+ "total_size_bytes": total_size,
380
+ "by_kind": dict(sorted(by_kind.items(), key=lambda item: (-item[1], item[0]))),
381
+ "newest_mtime": newest,
382
+ "oldest_mtime": oldest,
383
+ }
384
+
385
+
386
+ def artifact_summary_all() -> Dict[str, Any]:
387
+ """Return artifact inventory across project and user-generated roots."""
388
+ roots = artifact_roots(include_user_generated=True)
389
+ summaries = [artifact_summary(root) for root in roots]
390
+ by_kind: Dict[str, int] = {}
391
+ total = 0
392
+ total_size = 0
393
+ newest = 0.0
394
+ oldest = 0.0
395
+ for summary in summaries:
396
+ total += int(summary.get("total") or 0)
397
+ total_size += int(summary.get("total_size_bytes") or 0)
398
+ newest = max(newest, float(summary.get("newest_mtime") or 0))
399
+ old = float(summary.get("oldest_mtime") or 0)
400
+ if old:
401
+ oldest = old if not oldest else min(oldest, old)
402
+ for kind, count in (summary.get("by_kind") or {}).items():
403
+ by_kind[str(kind)] = by_kind.get(str(kind), 0) + int(count)
404
+ return {
405
+ "roots": [str(root) for root in roots],
406
+ "total": total,
407
+ "total_size_bytes": total_size,
408
+ "by_kind": dict(sorted(by_kind.items(), key=lambda item: (-item[1], item[0]))),
409
+ "newest_mtime": newest,
410
+ "oldest_mtime": oldest,
411
+ "summaries": summaries,
412
+ }
413
+
414
+
415
+ def prune_artifacts(keep: int = 20, root: Optional[Path] = None, dry_run: bool = False) -> Dict[str, Any]:
416
+ """Delete artifact bundles older than the newest `keep` entries."""
417
+ base = root or artifact_root()
418
+ keep = max(0, int(keep))
419
+ if not base.exists():
420
+ return {
421
+ "root": str(base),
422
+ "keep": keep,
423
+ "scanned": 0,
424
+ "removed": 0,
425
+ "dry_run": dry_run,
426
+ "deleted": [],
427
+ }
428
+ entries: list[ArtifactEntry] = []
429
+ for path in base.rglob("*.metadata.json"):
430
+ entry = _artifact_entry_from_metadata(path)
431
+ if entry is not None:
432
+ entries.append(entry)
433
+ entries.sort(key=lambda entry: entry.mtime, reverse=True)
434
+ to_remove = entries[keep:]
435
+ deleted: list[Dict[str, Any]] = []
436
+ for entry in to_remove:
437
+ targets = [entry.path, entry.metadata_path, entry.raw_data_path]
438
+ removed_files: list[str] = []
439
+ if not dry_run:
440
+ for target in targets:
441
+ try:
442
+ if target.exists():
443
+ target.unlink()
444
+ removed_files.append(str(target))
445
+ except Exception:
446
+ continue
447
+ for target in targets:
448
+ if target.exists():
449
+ continue
450
+ _cleanup_empty_dirs(target.parent, base)
451
+ else:
452
+ removed_files = [str(target) for target in targets if target.exists()]
453
+ deleted.append(
454
+ {
455
+ "kind": entry.kind,
456
+ "status": entry.status,
457
+ "topic": entry.topic,
458
+ "metadata_path": str(entry.metadata_path),
459
+ "path": str(entry.path) if str(entry.path) else "",
460
+ "removed_files": removed_files,
461
+ }
462
+ )
463
+ return {
464
+ "root": str(base),
465
+ "keep": keep,
466
+ "scanned": len(entries),
467
+ "removed": len(deleted),
468
+ "dry_run": dry_run,
469
+ "deleted": deleted,
470
+ }
471
+
472
+
473
+ def prune_artifacts_all(keep: int = 20, dry_run: bool = False) -> Dict[str, Any]:
474
+ """Prune artifact bundles in every visible artifact root."""
475
+ results = [prune_artifacts(keep=keep, root=root, dry_run=dry_run) for root in artifact_roots(include_user_generated=True)]
476
+ deleted: list[Dict[str, Any]] = []
477
+ for result in results:
478
+ root = result.get("root") or ""
479
+ for item in result.get("deleted") or []:
480
+ item = dict(item)
481
+ item["root"] = root
482
+ deleted.append(item)
483
+ return {
484
+ "roots": [result.get("root") for result in results],
485
+ "keep": max(0, int(keep)),
486
+ "scanned": sum(int(result.get("scanned") or 0) for result in results),
487
+ "removed": sum(int(result.get("removed") or 0) for result in results),
488
+ "dry_run": dry_run,
489
+ "deleted": deleted,
490
+ "results": results,
491
+ }