hyperagents 1.2.3rc5.post3__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 (361) hide show
  1. agents/__init__.py +31 -0
  2. agents/background_monitor.py +453 -0
  3. agents/learning.py +460 -0
  4. agents/planner.py +421 -0
  5. agents/transcriber.py +341 -0
  6. civilization/__init__.py +51 -0
  7. civilization/api/__init__.py +7 -0
  8. civilization/api/civilization_api.py +361 -0
  9. civilization/graph/__init__.py +19 -0
  10. civilization/graph/knowledge_linker.py +348 -0
  11. civilization/graph/org_graph.py +348 -0
  12. civilization/graph/process_graph.py +386 -0
  13. civilization/ingestion/__init__.py +20 -0
  14. civilization/ingestion/chunker.py +273 -0
  15. civilization/ingestion/document_ingestor.py +358 -0
  16. civilization/ingestion/embedder.py +262 -0
  17. civilization/ingestion/extractor.py +224 -0
  18. civilization/interview/__init__.py +19 -0
  19. civilization/interview/gap_detector.py +380 -0
  20. civilization/interview/interviewer.py +323 -0
  21. civilization/interview/questioner.py +363 -0
  22. civilization/retrieval/__init__.py +17 -0
  23. civilization/retrieval/civilization_rag.py +316 -0
  24. civilization/retrieval/context_injector.py +342 -0
  25. civilization/retrieval/relevance_ranker.py +287 -0
  26. civilization/schema.py +299 -0
  27. civilization/store.py +177 -0
  28. civilization/sync/__init__.py +16 -0
  29. civilization/sync/confluence_sync.py +317 -0
  30. civilization/sync/gdrive_sync.py +262 -0
  31. civilization/sync/notion_sync.py +237 -0
  32. civilization/versioning/__init__.py +19 -0
  33. civilization/versioning/diff_engine.py +351 -0
  34. civilization/versioning/staleness_detector.py +303 -0
  35. civilization/versioning/version_manager.py +293 -0
  36. cli/__init__.py +1 -0
  37. cli/doctor.py +334 -0
  38. cli/hyperclaw.py +1357 -0
  39. cli/onboarding.py +469 -0
  40. core/__init__.py +1 -0
  41. core/connectors/__init__.py +119 -0
  42. core/connectors/infrastructure.py +772 -0
  43. core/connectors/messaging.py +586 -0
  44. core/connectors/productivity.py +764 -0
  45. core/connectors/smarthome.py +528 -0
  46. core/connectors/social.py +730 -0
  47. core/hyperrouter/__init__.py +23 -0
  48. core/hyperrouter/bandit.py +218 -0
  49. core/hyperrouter/fast_loop.py +68 -0
  50. core/hyperrouter/slow_loop.py +143 -0
  51. core/hyperstate/__init__.py +24 -0
  52. core/hyperstate/certifier.py +154 -0
  53. core/hyperstate/schema.py +160 -0
  54. core/hyperstate/state_manager.py +103 -0
  55. core/hyperstate/store.py +163 -0
  56. core/providers/__init__.py +121 -0
  57. core/providers/embeddings.py +389 -0
  58. core/providers/image.py +534 -0
  59. core/providers/llm.py +1935 -0
  60. core/providers/search.py +608 -0
  61. core/providers/voice.py +474 -0
  62. hyperagents-1.2.3rc5.post3.dist-info/METADATA +469 -0
  63. hyperagents-1.2.3rc5.post3.dist-info/RECORD +361 -0
  64. hyperagents-1.2.3rc5.post3.dist-info/WHEEL +5 -0
  65. hyperagents-1.2.3rc5.post3.dist-info/entry_points.txt +6 -0
  66. hyperagents-1.2.3rc5.post3.dist-info/licenses/LICENSE +21 -0
  67. hyperagents-1.2.3rc5.post3.dist-info/top_level.txt +13 -0
  68. hyperclaw/__init__.py +67 -0
  69. hyperclaw/__main__.py +220 -0
  70. hyperclaw/agent.py +775 -0
  71. hyperclaw/agent_coordinator.py +577 -0
  72. hyperclaw/anthropic_params.py +225 -0
  73. hyperclaw/api_utils.py +85 -0
  74. hyperclaw/approval_telegram.py +209 -0
  75. hyperclaw/approval_terminal.py +80 -0
  76. hyperclaw/attachments.py +177 -0
  77. hyperclaw/branding.py +55 -0
  78. hyperclaw/brief/__init__.py +11 -0
  79. hyperclaw/brief/cli.py +204 -0
  80. hyperclaw/brief/collect.py +146 -0
  81. hyperclaw/brief/compose.py +333 -0
  82. hyperclaw/brief/diff.py +110 -0
  83. hyperclaw/brief/errors.py +99 -0
  84. hyperclaw/brief/google.py +269 -0
  85. hyperclaw/brief/google_auth.py +294 -0
  86. hyperclaw/brief/links.py +124 -0
  87. hyperclaw/brief/service.py +424 -0
  88. hyperclaw/brief/store.py +205 -0
  89. hyperclaw/brief/tasks.py +200 -0
  90. hyperclaw/chat_scope.py +115 -0
  91. hyperclaw/cli_tui.py +497 -0
  92. hyperclaw/client_guard.py +717 -0
  93. hyperclaw/connection_check.py +113 -0
  94. hyperclaw/cost_guard.py +1728 -0
  95. hyperclaw/default_config/agents.yaml +534 -0
  96. hyperclaw/default_config/hyperclaw.yaml +27 -0
  97. hyperclaw/default_config/models.yaml +161 -0
  98. hyperclaw/deployment.py +39 -0
  99. hyperclaw/digest/__init__.py +9 -0
  100. hyperclaw/digest/cli.py +117 -0
  101. hyperclaw/digest/errors.py +100 -0
  102. hyperclaw/digest/gitlab.py +187 -0
  103. hyperclaw/digest/http.py +113 -0
  104. hyperclaw/digest/redact.py +32 -0
  105. hyperclaw/digest/service.py +271 -0
  106. hyperclaw/digest/slack.py +223 -0
  107. hyperclaw/digest/state.py +124 -0
  108. hyperclaw/digest/summarize.py +198 -0
  109. hyperclaw/digest/tokens.py +143 -0
  110. hyperclaw/distribution.py +46 -0
  111. hyperclaw/documents.py +351 -0
  112. hyperclaw/feeds.py +339 -0
  113. hyperclaw/google_workspace/__init__.py +5 -0
  114. hyperclaw/google_workspace/api.py +151 -0
  115. hyperclaw/google_workspace/auth.py +397 -0
  116. hyperclaw/google_workspace/calendar.py +157 -0
  117. hyperclaw/google_workspace/cli.py +72 -0
  118. hyperclaw/google_workspace/drive.py +236 -0
  119. hyperclaw/google_workspace/errors.py +58 -0
  120. hyperclaw/google_workspace/gmail.py +295 -0
  121. hyperclaw/google_workspace/meet.py +122 -0
  122. hyperclaw/google_workspace/tools.py +319 -0
  123. hyperclaw/hyperspeed.py +57 -0
  124. hyperclaw/integrations_layer.py +1427 -0
  125. hyperclaw/live_state.py +188 -0
  126. hyperclaw/managed_config.py +245 -0
  127. hyperclaw/media_hub.py +179 -0
  128. hyperclaw/memory_manager.py +584 -0
  129. hyperclaw/model_catalog.py +551 -0
  130. hyperclaw/model_policy.py +211 -0
  131. hyperclaw/model_router.py +595 -0
  132. hyperclaw/model_selector.py +72 -0
  133. hyperclaw/naming.py +284 -0
  134. hyperclaw/notices.py +65 -0
  135. hyperclaw/oai_stream.py +106 -0
  136. hyperclaw/orchestrator.py +591 -0
  137. hyperclaw/outbox.py +82 -0
  138. hyperclaw/persona.py +367 -0
  139. hyperclaw/prometheus.py +219 -0
  140. hyperclaw/prompt_assembly.py +157 -0
  141. hyperclaw/provider_setup.py +146 -0
  142. hyperclaw/providers.py +340 -0
  143. hyperclaw/qualification.py +341 -0
  144. hyperclaw/qualification_keys/hyper-d292fd127cef5a3e.pub +1 -0
  145. hyperclaw/qualification_records/anthropic--claude-sonnet-5-5.json +14 -0
  146. hyperclaw/reasoning_display.py +35 -0
  147. hyperclaw/scheduler.py +454 -0
  148. hyperclaw/server.py +847 -0
  149. hyperclaw/setup.py +627 -0
  150. hyperclaw/skills.py +717 -0
  151. hyperclaw/solomon.py +442 -0
  152. hyperclaw/swarm.py +533 -0
  153. hyperclaw/taint.py +832 -0
  154. hyperclaw/team/__init__.py +28 -0
  155. hyperclaw/team/dispatcher.py +431 -0
  156. hyperclaw/team/genesis.py +106 -0
  157. hyperclaw/team/lead.py +110 -0
  158. hyperclaw/team/model.py +135 -0
  159. hyperclaw/team/morning.py +238 -0
  160. hyperclaw/team/repos.py +66 -0
  161. hyperclaw/team/roster.py +264 -0
  162. hyperclaw/team/specialist.py +235 -0
  163. hyperclaw/team/status.py +163 -0
  164. hyperclaw/team/watchers.py +112 -0
  165. hyperclaw/teamsetup/__init__.py +17 -0
  166. hyperclaw/teamsetup/checks.py +208 -0
  167. hyperclaw/teamsetup/item.py +391 -0
  168. hyperclaw/teamsetup/oauth_loopback.py +84 -0
  169. hyperclaw/teamsetup/onboard.py +550 -0
  170. hyperclaw/teamsetup/profile.py +82 -0
  171. hyperclaw/teamsetup/secrets.py +313 -0
  172. hyperclaw/teamsetup/selfcheck.py +295 -0
  173. hyperclaw/teamsetup/service.py +495 -0
  174. hyperclaw/teamsetup/slack_oauth.py +442 -0
  175. hyperclaw/teamsetup/tty.py +62 -0
  176. hyperclaw/telegram_bot.py +1106 -0
  177. hyperclaw/telegram_intake.py +126 -0
  178. hyperclaw/telegram_pairing.py +360 -0
  179. hyperclaw/terminal_provider.py +323 -0
  180. hyperclaw/tools/__init__.py +1 -0
  181. hyperclaw/tools/approval.py +949 -0
  182. hyperclaw/tools/args.py +212 -0
  183. hyperclaw/tools/catalog.py +244 -0
  184. hyperclaw/tools/classify.py +266 -0
  185. hyperclaw/tools/effects.py +724 -0
  186. hyperclaw/tools/egress.py +210 -0
  187. hyperclaw/tools/envelope.py +188 -0
  188. hyperclaw/tools/guard.py +367 -0
  189. hyperclaw/tools/ledger.py +339 -0
  190. hyperclaw/tools/receipts.py +70 -0
  191. hyperclaw/tools/registry.py +318 -0
  192. hyperclaw/tools/service_guard.py +151 -0
  193. hyperclaw/tools/surfaces.py +54 -0
  194. hyperclaw/tools/workspace.py +87 -0
  195. hyperclaw/trading/__init__.py +77 -0
  196. hyperclaw/truth_guard.py +791 -0
  197. hyperclaw/tts.py +124 -0
  198. hyperclaw/tui.py +4255 -0
  199. hyperclaw/tui_bridge.py +938 -0
  200. hyperclaw/unattended.py +60 -0
  201. hyperclaw/updates/__init__.py +54 -0
  202. hyperclaw/updates/check.py +131 -0
  203. hyperclaw/updates/index.py +178 -0
  204. hyperclaw/updates/install.py +110 -0
  205. hyperclaw/updates/managed.py +161 -0
  206. hyperclaw/updates/manifest.py +138 -0
  207. hyperclaw/updates/notices.py +86 -0
  208. hyperclaw/updates/paths.py +53 -0
  209. hyperclaw/updates/release.py +146 -0
  210. hyperclaw/updates/upgrade.py +253 -0
  211. hyperclaw/updates/versioning.py +104 -0
  212. hyperclaw/userconfig.py +119 -0
  213. hyperclaw/xai.py +83 -0
  214. integrations/__init__.py +31 -0
  215. integrations/automation/__init__.py +1 -0
  216. integrations/automation/make.py +87 -0
  217. integrations/automation/zapier.py +87 -0
  218. integrations/base.py +476 -0
  219. integrations/communication/__init__.py +1 -0
  220. integrations/communication/twilio.py +279 -0
  221. integrations/communication/zoom.py +290 -0
  222. integrations/data/__init__.py +1 -0
  223. integrations/data/google_analytics.py +135 -0
  224. integrations/data/postgres.py +182 -0
  225. integrations/data/supabase_connector.py +202 -0
  226. integrations/data/weather_feeds.py +450 -0
  227. integrations/developer/__init__.py +9 -0
  228. integrations/developer/github.py +406 -0
  229. integrations/developer/gitlab.py +298 -0
  230. integrations/enterprise/__init__.py +13 -0
  231. integrations/enterprise/confluence.py +266 -0
  232. integrations/enterprise/hubspot.py +293 -0
  233. integrations/enterprise/jira.py +314 -0
  234. integrations/enterprise/salesforce.py +286 -0
  235. integrations/finance/__init__.py +1 -0
  236. integrations/finance/polymarket_client.py +247 -0
  237. integrations/finance/quickbooks.py +281 -0
  238. integrations/finance/stripe.py +261 -0
  239. integrations/gateway.py +238 -0
  240. integrations/messaging/__init__.py +32 -0
  241. integrations/messaging/discord.py +159 -0
  242. integrations/messaging/email.py +401 -0
  243. integrations/messaging/imessage.py +443 -0
  244. integrations/messaging/signal.py +175 -0
  245. integrations/messaging/slack.py +217 -0
  246. integrations/messaging/sms.py +175 -0
  247. integrations/messaging/teams.py +208 -0
  248. integrations/messaging/telegram.py +260 -0
  249. integrations/messaging/whatsapp.py +203 -0
  250. integrations/productivity/__init__.py +17 -0
  251. integrations/productivity/airtable.py +217 -0
  252. integrations/productivity/asana.py +248 -0
  253. integrations/productivity/google/__init__.py +19 -0
  254. integrations/productivity/google/base.py +89 -0
  255. integrations/productivity/google/calendar.py +272 -0
  256. integrations/productivity/google/docs.py +202 -0
  257. integrations/productivity/google/drive.py +268 -0
  258. integrations/productivity/google/gmail.py +279 -0
  259. integrations/productivity/google/meet.py +218 -0
  260. integrations/productivity/google/sheets.py +222 -0
  261. integrations/productivity/google/tasks.py +199 -0
  262. integrations/productivity/linear.py +279 -0
  263. integrations/productivity/microsoft/__init__.py +13 -0
  264. integrations/productivity/microsoft/base.py +93 -0
  265. integrations/productivity/microsoft/calendar.py +207 -0
  266. integrations/productivity/microsoft/onedrive.py +222 -0
  267. integrations/productivity/microsoft/outlook.py +241 -0
  268. integrations/productivity/microsoft/sharepoint.py +211 -0
  269. integrations/productivity/notion.py +243 -0
  270. integrations/productivity/todoist.py +250 -0
  271. integrations/productivity/trello.py +244 -0
  272. integrations/storage/__init__.py +1 -0
  273. integrations/storage/box.py +279 -0
  274. integrations/storage/dropbox.py +215 -0
  275. integrations/telegram_inbound.py +179 -0
  276. integrations/telegram_poller.py +151 -0
  277. memory/__init__.py +1 -0
  278. memory/causal_graph.py +125 -0
  279. memory/impact_tracker.py +88 -0
  280. memory/vector_memory.py +436 -0
  281. models/__init__.py +12 -0
  282. models/chatjimmy_client.py +210 -0
  283. models/claude_client.py +235 -0
  284. models/claude_code_subagent.py +192 -0
  285. models/openai_client.py +120 -0
  286. models/router.py +162 -0
  287. recursive/discovery_loop.py +118 -0
  288. recursive/optimization_loop.py +135 -0
  289. recursive/validation_loop.py +38 -0
  290. security/__init__.py +1 -0
  291. security/audit_logger.py +106 -0
  292. security/filesystem_guard.py +100 -0
  293. security/hypershield.py +99 -0
  294. security/network_guard.py +97 -0
  295. security/policy_engine.py +198 -0
  296. swarm/__init__.py +1 -0
  297. swarm/agents/__init__.py +0 -0
  298. swarm/agents/base.py +230 -0
  299. swarm/agents/business/__init__.py +0 -0
  300. swarm/agents/business/counsel.py +31 -0
  301. swarm/agents/business/herald.py +32 -0
  302. swarm/agents/business/ledger.py +26 -0
  303. swarm/agents/business/nexus.py +30 -0
  304. swarm/agents/business/ops.py +30 -0
  305. swarm/agents/business/pipeline.py +28 -0
  306. swarm/agents/business/revenue.py +30 -0
  307. swarm/agents/business/sovereign.py +31 -0
  308. swarm/agents/business/strategos.py +27 -0
  309. swarm/agents/business/talent.py +27 -0
  310. swarm/agents/business/venture.py +32 -0
  311. swarm/agents/comms/__init__.py +1 -0
  312. swarm/agents/comms/cipher.py +32 -0
  313. swarm/agents/comms/echo_comms.py +31 -0
  314. swarm/agents/comms/envoy.py +31 -0
  315. swarm/agents/comms/herald_scheduler.py +33 -0
  316. swarm/agents/comms/pulse.py +29 -0
  317. swarm/agents/creative/__init__.py +0 -0
  318. swarm/agents/creative/author.py +27 -0
  319. swarm/agents/creative/lens.py +25 -0
  320. swarm/agents/executive/__init__.py +1 -0
  321. swarm/agents/intelligence/__init__.py +1 -0
  322. swarm/agents/intelligence/arbiter.py +30 -0
  323. swarm/agents/intelligence/sentinel.py +30 -0
  324. swarm/agents/media/__init__.py +0 -0
  325. swarm/agents/personal/__init__.py +0 -0
  326. swarm/agents/personal/atlas.py +27 -0
  327. swarm/agents/personal/hearth.py +26 -0
  328. swarm/agents/personal/midas.py +27 -0
  329. swarm/agents/personal/navigator.py +27 -0
  330. swarm/agents/personal/nourish.py +31 -0
  331. swarm/agents/personal/vitals.py +33 -0
  332. swarm/agents/recursive/__init__.py +0 -0
  333. swarm/agents/recursive/alchemist.py +73 -0
  334. swarm/agents/recursive/calibrator.py +45 -0
  335. swarm/agents/recursive/scout.py +97 -0
  336. swarm/agents/scientific/__init__.py +0 -0
  337. swarm/agents/scientific/cosmos.py +36 -0
  338. swarm/agents/scientific/gaia.py +26 -0
  339. swarm/agents/scientific/medicus.py +33 -0
  340. swarm/agents/scientific/oracle_agent.py +39 -0
  341. swarm/agents/scientific/scribe.py +39 -0
  342. swarm/agents/talent/__init__.py +0 -0
  343. swarm/agents/talent/deal.py +28 -0
  344. swarm/agents/talent/roster.py +29 -0
  345. swarm/agents/talent/scout.py +29 -0
  346. swarm/agents/talent/stage.py +27 -0
  347. swarm/agents/tech/__init__.py +0 -0
  348. swarm/agents/tech/aegis.py +28 -0
  349. swarm/agents/tech/bridge.py +28 -0
  350. swarm/agents/tech/forge.py +28 -0
  351. swarm/agents/trading/__init__.py +1 -0
  352. swarm/agents/trading/global_prediction_engine.py +611 -0
  353. swarm/agents/trading/polymarket_trader.py +165 -0
  354. swarm/agents/trading/prediction_strategist.py +218 -0
  355. swarm/autogen_bridge.py +68 -0
  356. swarm/bid_protocol.py +196 -0
  357. swarm/dispatcher.py +527 -0
  358. swarm/nexus.py +181 -0
  359. swarm/registry.py +112 -0
  360. telegram_listener.py +403 -0
  361. ui/tui/app.py +214 -0
@@ -0,0 +1,339 @@
1
+ """
2
+ The effect ledger (tool standard S3) and activity log (spec rule 6).
3
+
4
+ A local, append-only SQLite table at $HYPERCLAW_ROOT/ledger/effects.sqlite3
5
+ (default ~/.hyperclaw). Every non-read tool call leaves rows here:
6
+
7
+ - an intent row (status "pending") written BEFORE the tool runs, in the same
8
+ transaction as the idempotency check (BEGIN IMMEDIATE, so two processes —
9
+ the terminal and the Telegram bridge — cannot both pass the check);
10
+ - an outcome row (ok / failed / uncertain / ...) after it ran, pointing at
11
+ the intent (`ref`);
12
+ - a single row for a call the runtime refused (duplicate, needs_approval,
13
+ budget_exhausted).
14
+
15
+ UPDATE and DELETE are blocked by triggers. A pending row with no outcome
16
+ (the process died mid-call) is treated as uncertain: at-most-once.
17
+
18
+ Decisions (per conversation, within the window):
19
+ - same key, latest row ok -> duplicate (original receipt), not run
20
+ (file targets: only while the file still has the content that was written)
21
+ - same key, latest row uncertain/pending -> needs_approval ("may already have
22
+ been sent"), not run (file targets: checked on disk instead)
23
+ - same target, different payload, send/spend/delete, inside the shorter
24
+ conflict window -> needs_approval, not run
25
+ - otherwise -> intent row, run
26
+
27
+ Broker mapping: `key` is the agent-mode proposal `payloadDigest`; this table
28
+ is the local equivalent of `agent_action_ledger` (append-only, one row per
29
+ state change, receipt on the result).
30
+ """
31
+ from __future__ import annotations
32
+
33
+ import json
34
+ import os
35
+ import sqlite3
36
+ import threading
37
+ import time
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any, Dict, List, Optional
41
+
42
+ DEFAULT_WINDOW_S = 24 * 3600
43
+ DEFAULT_CONFLICT_WINDOW_S = 600
44
+
45
+ # Statuses that mean "an effect happened or may have happened".
46
+ DONE = ("ok",)
47
+ MAYBE = ("uncertain", "pending")
48
+ REFUSALS = ("duplicate", "needs_approval", "budget_exhausted")
49
+ CONFLICT_EFFECTS = ("send", "spend", "delete")
50
+
51
+ _SCHEMA = """
52
+ CREATE TABLE IF NOT EXISTS effect_ledger (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ ts REAL NOT NULL,
55
+ conversation TEXT NOT NULL,
56
+ turn TEXT,
57
+ surface TEXT,
58
+ tool TEXT NOT NULL,
59
+ effect TEXT NOT NULL,
60
+ target TEXT,
61
+ payload_hash TEXT,
62
+ key TEXT,
63
+ status TEXT NOT NULL,
64
+ receipt_id TEXT,
65
+ ref INTEGER,
66
+ post_hash TEXT,
67
+ args TEXT,
68
+ message TEXT
69
+ );
70
+ CREATE INDEX IF NOT EXISTS effect_ledger_key ON effect_ledger (conversation, key, id);
71
+ CREATE INDEX IF NOT EXISTS effect_ledger_target ON effect_ledger (conversation, target, id);
72
+ CREATE INDEX IF NOT EXISTS effect_ledger_ref ON effect_ledger (ref);
73
+ CREATE TRIGGER IF NOT EXISTS effect_ledger_no_update BEFORE UPDATE ON effect_ledger
74
+ BEGIN SELECT RAISE(ABORT, 'effect_ledger is append-only'); END;
75
+ CREATE TRIGGER IF NOT EXISTS effect_ledger_no_delete BEFORE DELETE ON effect_ledger
76
+ BEGIN SELECT RAISE(ABORT, 'effect_ledger is append-only'); END;
77
+ """
78
+
79
+ _COLUMNS = ("id", "ts", "conversation", "turn", "surface", "tool", "effect", "target",
80
+ "payload_hash", "key", "status", "receipt_id", "ref", "post_hash", "args", "message")
81
+
82
+
83
+ def _env_float(name: str, default: float) -> float:
84
+ try:
85
+ return float(os.environ.get(name, default))
86
+ except (TypeError, ValueError):
87
+ return float(default)
88
+
89
+
90
+ def default_path() -> Path:
91
+ """$HYPERCLAW_EFFECT_LEDGER if set, else $HYPERCLAW_ROOT/ledger/effects.sqlite3
92
+ (HYPERCLAW_ROOT defaults to ~/.hyperclaw)."""
93
+ explicit = os.environ.get("HYPERCLAW_EFFECT_LEDGER")
94
+ if explicit:
95
+ return Path(explicit).expanduser()
96
+ root = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
97
+ return root / "ledger" / "effects.sqlite3"
98
+
99
+
100
+ @dataclass
101
+ class Decision:
102
+ action: str # "proceed" | "duplicate" | "needs_approval"
103
+ intent_id: Optional[int] = None
104
+ prior: Optional[Dict[str, Any]] = None
105
+ reason: str = "" # "duplicate" | "uncertain" | "conflict"
106
+
107
+
108
+ class EffectLedger:
109
+ def __init__(self, path: Optional[os.PathLike] = None):
110
+ self.path = Path(path) if path else default_path()
111
+ self._init_lock = threading.Lock()
112
+ self._ready = False
113
+
114
+ # ── connection ──
115
+ def _connect(self) -> sqlite3.Connection:
116
+ if not self._ready:
117
+ with self._init_lock:
118
+ if not self._ready:
119
+ self.path.parent.mkdir(parents=True, exist_ok=True)
120
+ try:
121
+ os.chmod(self.path.parent, 0o700)
122
+ except OSError:
123
+ pass
124
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
125
+ try:
126
+ con.execute("PRAGMA journal_mode=WAL")
127
+ con.executescript(_SCHEMA)
128
+ finally:
129
+ con.close()
130
+ try:
131
+ os.chmod(self.path, 0o600)
132
+ except OSError:
133
+ pass
134
+ self._ready = True
135
+ con = sqlite3.connect(self.path, timeout=10, isolation_level=None)
136
+ con.row_factory = sqlite3.Row
137
+ return con
138
+
139
+ @staticmethod
140
+ def window_s() -> float:
141
+ return _env_float("HYPERCLAW_EFFECT_WINDOW_S", DEFAULT_WINDOW_S)
142
+
143
+ @staticmethod
144
+ def conflict_window_s() -> float:
145
+ return _env_float("HYPERCLAW_EFFECT_CONFLICT_WINDOW_S", DEFAULT_CONFLICT_WINDOW_S)
146
+
147
+ @staticmethod
148
+ def _insert(con, row: Dict[str, Any]) -> int:
149
+ row = dict(row)
150
+ row.setdefault("ts", time.time())
151
+ cols = [c for c in _COLUMNS if c != "id" and c in row]
152
+ cur = con.execute(
153
+ f"INSERT INTO effect_ledger ({','.join(cols)}) VALUES ({','.join('?' * len(cols))})",
154
+ [row[c] for c in cols])
155
+ return int(cur.lastrowid)
156
+
157
+ # ── the check + intent, in one transaction ──
158
+ def begin(self, *, conversation: str, turn: str, surface: str, tool: str, effect: str,
159
+ target: str, payload_hash: str, key: Optional[str], args: str = "",
160
+ current_file_hash: Optional[str] = None,
161
+ expected_post_hash: Optional[str] = None,
162
+ is_file: bool = False, allow_conflict: bool = False) -> Decision:
163
+ """allow_conflict: the owner approved this exact payload (approval
164
+ layer), so a different earlier payload to the same target does not
165
+ hold it. Exact duplicates and uncertain repeats are still held."""
166
+ now = time.time()
167
+ base = dict(ts=now, conversation=conversation, turn=turn, surface=surface, tool=tool,
168
+ effect=effect, target=target, payload_hash=payload_hash, key=key, args=args)
169
+ con = self._connect()
170
+ try:
171
+ con.execute("BEGIN IMMEDIATE")
172
+ decision = self._decide(con, now, conversation, effect, target, key,
173
+ current_file_hash, expected_post_hash, is_file)
174
+ if allow_conflict and decision.reason == "conflict":
175
+ decision = Decision("proceed")
176
+ if decision.action == "proceed":
177
+ decision.intent_id = self._insert(con, {**base, "status": "pending"})
178
+ else:
179
+ prior = decision.prior or {}
180
+ self._insert(con, {**base, "status": decision.action,
181
+ "receipt_id": prior.get("receipt_id"),
182
+ "ref": prior.get("id"),
183
+ "message": decision.reason})
184
+ con.execute("COMMIT")
185
+ return decision
186
+ except Exception:
187
+ try:
188
+ con.execute("ROLLBACK")
189
+ except sqlite3.Error:
190
+ pass
191
+ raise
192
+ finally:
193
+ con.close()
194
+
195
+ def check(self, *, conversation: str, effect: str, target: str, key: Optional[str],
196
+ current_file_hash: Optional[str] = None,
197
+ expected_post_hash: Optional[str] = None, is_file: bool = False) -> Decision:
198
+ """The same decision as begin(), without writing anything."""
199
+ con = self._connect()
200
+ try:
201
+ return self._decide(con, time.time(), conversation, effect, target, key,
202
+ current_file_hash, expected_post_hash, is_file)
203
+ finally:
204
+ con.close()
205
+
206
+ def _decide(self, con, now, conversation, effect, target, key, current_file_hash,
207
+ expected_post_hash, is_file) -> Decision:
208
+ if key:
209
+ row = con.execute(
210
+ "SELECT * FROM effect_ledger WHERE conversation=? AND key=? AND ts>=? "
211
+ f"AND status NOT IN ({','.join('?' * len(REFUSALS))}) ORDER BY id DESC LIMIT 1",
212
+ (conversation, key, now - self.window_s(), *REFUSALS)).fetchone()
213
+ if row is not None:
214
+ prior = dict(row)
215
+ st = prior["status"]
216
+ if st in DONE:
217
+ if not is_file:
218
+ return Decision("duplicate", prior=prior, reason="duplicate")
219
+ # A file effect is "already done" only while the file still
220
+ # holds what was written; edits since then make it new.
221
+ if prior.get("post_hash") and prior["post_hash"] == current_file_hash:
222
+ return Decision("duplicate", prior=prior, reason="duplicate")
223
+ elif st in MAYBE:
224
+ if is_file:
225
+ if expected_post_hash and expected_post_hash == current_file_hash:
226
+ return Decision("duplicate", prior=prior, reason="duplicate")
227
+ else:
228
+ return Decision("needs_approval", prior=prior, reason="uncertain")
229
+ if effect in CONFLICT_EFFECTS and target:
230
+ row = con.execute(
231
+ "SELECT * FROM effect_ledger r WHERE r.conversation=? AND r.target=? "
232
+ "AND r.effect=? AND r.ts>=? AND (r.key IS NULL OR r.key<>?) AND ("
233
+ " r.status IN ('ok','uncertain') OR (r.status='pending' AND NOT EXISTS "
234
+ " (SELECT 1 FROM effect_ledger o WHERE o.ref=r.id AND o.status NOT IN "
235
+ f"({','.join('?' * len(REFUSALS))})))) "
236
+ "ORDER BY r.id DESC LIMIT 1",
237
+ (conversation, target, effect, now - self.conflict_window_s(),
238
+ key or "", *REFUSALS)).fetchone()
239
+ if row is not None:
240
+ return Decision("needs_approval", prior=dict(row), reason="conflict")
241
+ return Decision("proceed")
242
+
243
+ # ── outcomes and plain records ──
244
+ def finish(self, intent_id: int, *, status: str, receipt_id: Optional[str] = None,
245
+ post_hash: Optional[str] = None, message: str = "") -> int:
246
+ con = self._connect()
247
+ try:
248
+ con.execute("BEGIN IMMEDIATE")
249
+ row = con.execute("SELECT * FROM effect_ledger WHERE id=?", (intent_id,)).fetchone()
250
+ if row is None:
251
+ con.execute("ROLLBACK")
252
+ raise KeyError(intent_id)
253
+ base = {k: row[k] for k in ("conversation", "turn", "surface", "tool", "effect",
254
+ "target", "payload_hash", "key", "args")}
255
+ rid = self._insert(con, {**base, "status": status, "receipt_id": receipt_id,
256
+ "ref": intent_id, "post_hash": post_hash,
257
+ "message": (message or "")[:500]})
258
+ con.execute("COMMIT")
259
+ return rid
260
+ finally:
261
+ con.close()
262
+
263
+ def record(self, **row) -> int:
264
+ """Append one finished row (bash file effects, budget refusals)."""
265
+ row.setdefault("message", "")
266
+ row["message"] = (row["message"] or "")[:500]
267
+ con = self._connect()
268
+ try:
269
+ return self._insert(con, row)
270
+ finally:
271
+ con.close()
272
+
273
+ # ── readers ──
274
+ def rows(self, conversation: Optional[str] = None, limit: int = 1000) -> List[Dict[str, Any]]:
275
+ con = self._connect()
276
+ try:
277
+ if conversation:
278
+ cur = con.execute("SELECT * FROM effect_ledger WHERE conversation=? "
279
+ "ORDER BY id DESC LIMIT ?", (conversation, limit))
280
+ else:
281
+ cur = con.execute("SELECT * FROM effect_ledger ORDER BY id DESC LIMIT ?", (limit,))
282
+ return [dict(r) for r in cur.fetchall()][::-1]
283
+ finally:
284
+ con.close()
285
+
286
+ def activity(self, conversation: Optional[str] = None, limit: int = 20) -> List[Dict[str, Any]]:
287
+ """The activity log: one entry per action (an intent and its outcome
288
+ collapsed), newest last. An intent with no outcome shows as
289
+ 'pending (outcome unknown)'."""
290
+ con = self._connect()
291
+ try:
292
+ where, params = ("WHERE r.conversation=?", [conversation]) if conversation else ("", [])
293
+ cur = con.execute(
294
+ "SELECT r.* FROM effect_ledger r "
295
+ f"{where} {'AND' if where else 'WHERE'} NOT (r.status='pending' AND EXISTS "
296
+ "(SELECT 1 FROM effect_ledger o WHERE o.ref=r.id AND o.status NOT IN "
297
+ f"({','.join('?' * len(REFUSALS))}))) "
298
+ "ORDER BY r.id DESC LIMIT ?", [*params, *REFUSALS, limit])
299
+ return [dict(r) for r in cur.fetchall()][::-1]
300
+ finally:
301
+ con.close()
302
+
303
+
304
+ def format_activity(rows: List[Dict[str, Any]]) -> str:
305
+ if not rows:
306
+ return "No activity recorded."
307
+ lines = []
308
+ for r in rows:
309
+ when = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(r["ts"]))
310
+ status = r["status"] if r["status"] != "pending" else "pending (outcome unknown)"
311
+ target = r.get("target") or ""
312
+ if target.startswith(r["tool"] + ":"):
313
+ target = target[len(r["tool"]) + 1:]
314
+ line = f"{when} {r['conversation']:<14} {r['tool']:<14} {r['effect']:<7} {status:<15}"
315
+ if target:
316
+ line += f" {target[:70]}"
317
+ if r.get("receipt_id"):
318
+ line += f" [{r['receipt_id']}]"
319
+ if r.get("message") and r["status"] != "ok":
320
+ line += f" — {r['message'][:120]}"
321
+ lines.append(line)
322
+ return "\n".join(lines)
323
+
324
+
325
+ def activity_json(rows: List[Dict[str, Any]]) -> str:
326
+ return json.dumps(rows, ensure_ascii=False, default=str, indent=1)
327
+
328
+
329
+ _LEDGERS: Dict[str, EffectLedger] = {}
330
+ _LEDGERS_LOCK = threading.Lock()
331
+
332
+
333
+ def get_ledger() -> EffectLedger:
334
+ """The ledger for the current HYPERCLAW_ROOT (read at call time)."""
335
+ p = str(default_path())
336
+ with _LEDGERS_LOCK:
337
+ if p not in _LEDGERS:
338
+ _LEDGERS[p] = EffectLedger(p)
339
+ return _LEDGERS[p]
@@ -0,0 +1,70 @@
1
+ """
2
+ Send receipts (benchmark 20.3, rc3).
3
+
4
+ A send tool reports success only with a provider-confirmed receipt:
5
+
6
+ - Telegram: HTTP 200 AND ``ok: true`` AND ``result.message_id`` -> the
7
+ message id is the receipt. ``ok: false`` (e.g. "Bad Request: chat not
8
+ found") or a missing id is a failure carrying the provider's description.
9
+ - Discord (webhook with ``?wait=true``): the returned message ``id``.
10
+ - Gmail: the sent message ``id``.
11
+
12
+ Success strings carry the provider id as ``[id:<id>]``; the classifier
13
+ (tools/classify.py) requires it for these channels and returns ``failed``
14
+ without it, so "Message sent" can never stand without a receipt.
15
+ Provider descriptions are redacted (no tokens or keys) and clipped.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Optional, Tuple
21
+
22
+ RECEIPT_RE = r"\[id:([^\]\s]+)\]"
23
+ _BOT_URL = re.compile(r"/bot[^/\s]+/")
24
+
25
+
26
+ def redact(text: str, limit: int = 200) -> str:
27
+ """Provider text safe to show and log: secrets removed, clipped."""
28
+ from hyperclaw.truth_guard import redact_secrets
29
+ t = _BOT_URL.sub("/bot[REDACTED]/", str(text or ""))
30
+ return redact_secrets(t).strip()[:limit]
31
+
32
+
33
+ def _json(resp) -> Optional[dict]:
34
+ try:
35
+ body = resp.json()
36
+ except Exception:
37
+ return None
38
+ return body if isinstance(body, dict) else None
39
+
40
+
41
+ def telegram_receipt(resp) -> Tuple[Optional[str], str]:
42
+ """(message_id, "") when Telegram confirmed delivery, else (None, why)."""
43
+ body = _json(resp)
44
+ status = getattr(resp, "status_code", None)
45
+ if body is None:
46
+ return None, redact(f"HTTP {status}: response was not Telegram JSON")
47
+ if body.get("ok") is True and status == 200:
48
+ res = body.get("result")
49
+ mid = res.get("message_id") if isinstance(res, dict) else None
50
+ if mid not in (None, ""):
51
+ return str(mid), ""
52
+ return None, "Telegram returned no message_id, so delivery is not confirmed"
53
+ desc = body.get("description") or f"HTTP {status}"
54
+ return None, redact(desc)
55
+
56
+
57
+ def discord_receipt(resp) -> Tuple[Optional[str], str]:
58
+ body = _json(resp)
59
+ status = getattr(resp, "status_code", None)
60
+ if status == 200 and body and body.get("id"):
61
+ return str(body["id"]), ""
62
+ if status == 204:
63
+ return None, "Discord accepted the message but returned no message id (use ?wait=true)"
64
+ desc = (body or {}).get("message") or getattr(resp, "text", "") or f"HTTP {status}"
65
+ return None, redact(f"HTTP {status}: {desc}")
66
+
67
+
68
+ def has_receipt(text: str) -> Optional[str]:
69
+ m = re.search(RECEIPT_RE, text or "")
70
+ return m.group(1) if m else None