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
hyperclaw/taint.py ADDED
@@ -0,0 +1,832 @@
1
+ """
2
+ Connector-content taint guard.
3
+
4
+ Vaughn's rule: "connector-sourced content can never enter any training,
5
+ corpus, eval-data or self-improvement proposal path." The reason is Google's
6
+ Limited Use policy for Google user data; the same rule is applied to every
7
+ connector (Slack, GitLab, Telegram messages from other people, documents,
8
+ web pages, iMessage).
9
+
10
+ Three parts:
11
+
12
+ - **Producers** wrap what a connector read returns in ``TaintedText`` (a
13
+ ``str`` that carries ``origin`` and ``connector_id``) with ``mark()`` or
14
+ the ``@source(...)`` decorator. Every tainted read is also recorded in the
15
+ conversation's ``TaintLedger``.
16
+ - **TaintLedger** (one per conversation/session): the 64-character shingles
17
+ (hashed) of every tainted read, and PER-MESSAGE provenance: which message
18
+ (turn) read which connector (rc5, HC-038). The conversation's "tainted"
19
+ flag is derived from it (some message in it read connector content).
20
+ Shingles catch connector text after it has lost its type tag (sliced,
21
+ concatenated, JSON-encoded, quoted in a model's summary). The ledger is
22
+ persisted in ``$HYPERCLAW_ROOT/taint/taint.sqlite3`` (hashes only, never the
23
+ text), so a reloaded conversation is re-flagged.
24
+ - **Sinks**: ``@training_sink(name)`` on every writer to a learning, memory,
25
+ skills, self-improvement, knowledge-graph, civilization, corpus or eval
26
+ store. A sink refuses tainted input by raising ``TaintedInput``, writes an
27
+ activity-log row ("refused: connector content") and never truncates or
28
+ drops anything silently.
29
+
30
+ What a sink refuses:
31
+ 1. a ``TaintedText`` anywhere in its arguments (type tag);
32
+ 2. any argument text that shares a 64-character shingle with a tainted read
33
+ (or equals a short tainted string) in a loaded ledger;
34
+ 3. any call made while the active conversation is tainted, unless the sink is
35
+ ``own_words=True``. Own-words sinks are the user-directed memory stores
36
+ (``memory_store``, ``MemoryManager.remember``): a memory the user asks for
37
+ in their own words still saves in a conversation that read email, while
38
+ connector text itself (rules 1 and 2) is still refused there.
39
+
40
+ Memory (rc5): ``memory_decision`` decides, per message, whether a
41
+ ``memory_store`` call is the person's own words (saved without a prompt) or
42
+ something learned from email, pages or other people (it waits for the
43
+ person's tap on an approval card that names the source). The message counts,
44
+ not the conversation: an email read three messages ago no longer puts a tap
45
+ on a memory the person states in their own words now. Limit (documented by a
46
+ test): a PARAPHRASE of connector content shares no 64-character shingle with
47
+ it, so rules 1-2 do not catch it; the per-message check and the own-words
48
+ overlap are what put it in front of the person.
49
+
50
+ ``tests/unit/test_taint_guard.py`` holds an AST scan that fails the build on
51
+ any undecorated writer to these stores (see ``SCAN_ALLOWLIST`` there).
52
+ """
53
+ from __future__ import annotations
54
+
55
+ import contextvars
56
+ import dataclasses
57
+ import functools
58
+ import hashlib
59
+ import inspect
60
+ import logging
61
+ import os
62
+ import re
63
+ import sqlite3
64
+ import threading
65
+ from contextlib import contextmanager
66
+ from pathlib import Path
67
+ from typing import Any, Callable, Dict, Iterable, Iterator, Optional, Set, Tuple
68
+
69
+ log = logging.getLogger("hyperclaw.taint")
70
+
71
+ SHINGLE = 64 # characters per shingle (normalised text)
72
+ MIN_EXACT = 24 # shorter tainted strings are matched whole, not by shingle
73
+ BACKGROUND = "background" # reads outside any conversation (scheduled brief/digest)
74
+ MAX_DEPTH = 8
75
+
76
+ # origin -> how the user knows that source
77
+ LABELS = {
78
+ "gmail": "Gmail", "google_calendar": "Google Calendar", "google_docs": "Google Docs",
79
+ "google_sheets": "Google Sheets", "google_drive": "Google Drive", "slack": "Slack",
80
+ "gitlab": "GitLab", "telegram": "Telegram", "imessage": "iMessage", "web": "web",
81
+ "document": "document", "email": "email", "gdrive": "Google Drive", "google": "Google",
82
+ "notion": "Notion", "confluence": "Confluence", "google_slides": "Google Slides",
83
+ "google_meet": "Google Meet",
84
+ }
85
+ GOOGLE_ORIGINS = {"gmail", "google_calendar", "google_docs", "google_sheets", "google_drive",
86
+ "gdrive", "google", "google_slides", "google_meet"}
87
+ # `source=` names that mean the content came from a connector (civilization sync).
88
+ CONNECTOR_SOURCES = {"gdrive", "google_drive", "gmail", "google", "notion", "confluence",
89
+ "slack", "gitlab", "telegram", "imessage", "jira", "hubspot",
90
+ "salesforce", "dropbox", "box"}
91
+
92
+
93
+ # ───────────────────────── the type tag ─────────────────────────
94
+
95
+ class TaintedText(str):
96
+ """A str read from a connector. Slicing or joining returns a plain str;
97
+ the ledger's shingles still recognise it."""
98
+
99
+ def __new__(cls, value: str = "", origin: str = "connector", connector_id: str = ""):
100
+ obj = super().__new__(cls, value)
101
+ obj.origin = origin
102
+ obj.connector_id = connector_id
103
+ return obj
104
+
105
+ def __reduce__(self):
106
+ return (TaintedText, (str(self), self.origin, self.connector_id))
107
+
108
+ def __repr__(self) -> str:
109
+ return f"TaintedText({str.__repr__(self)}, origin={self.origin!r})"
110
+
111
+
112
+ class TaintedInput(Exception):
113
+ """A training/corpus/eval/self-improvement sink was given connector content."""
114
+
115
+ def __init__(self, sink: str, reason: str, origin: str = "", conversation: str = ""):
116
+ self.sink = sink
117
+ self.reason = reason # "tagged" | "shingle" | "conversation" | "unavailable"
118
+ self.origin = origin or "connector"
119
+ self.conversation = conversation
120
+ super().__init__(self.user_message)
121
+
122
+ @property
123
+ def label(self) -> str:
124
+ return LABELS.get(self.origin, self.origin.replace("_", " "))
125
+
126
+ @property
127
+ def user_message(self) -> str:
128
+ policy = ("Google's Limited Use policy for Google data, and HyperAgents applies the same "
129
+ "rule to every connected account" if self.origin in GOOGLE_ORIGINS else
130
+ "HyperAgents' Limited Use rule for every connected account, the same rule "
131
+ "Google sets for Google data")
132
+ if self.reason == "conversation":
133
+ return (f"Not done ({self.sink}): this conversation includes content read from "
134
+ f"{self.label}, so it is not used for learning, reflections, skill proposals "
135
+ f"or any training or evaluation data ({policy}). Nothing was saved. Start a "
136
+ f"new conversation (/reset) to turn these back on.")
137
+ if self.reason == "unavailable":
138
+ return (f"Not saved ({self.sink}): the connector-content check could not run, so "
139
+ f"nothing was saved. Try again; if it keeps failing, check the HyperAgents "
140
+ f"data folder.")
141
+ return (f"Not saved: this is content from {self.label}. Connector content is never kept "
142
+ f"in memory, learning, training or evaluation data ({policy}). Keep it in the "
143
+ f"source email or document instead; I can read it from there whenever you ask. "
144
+ f"I can still remember things you tell me in your own words.")
145
+
146
+
147
+ # ───────────────────────── shingles ─────────────────────────
148
+
149
+ _ESCAPES = re.compile(r"\\u[0-9a-fA-F]{4}|\\[nrtbf\"'\\/]")
150
+ _NON_WORD = re.compile(r"[\W_]+", re.UNICODE)
151
+
152
+
153
+ def normalize(text: str) -> str:
154
+ """Lower case, JSON escapes and punctuation/whitespace runs -> one space,
155
+ so a shingle survives re-wrapping, markdown and JSON encoding."""
156
+ t = _ESCAPES.sub(" ", str(text))
157
+ return _NON_WORD.sub(" ", t.lower()).strip()
158
+
159
+
160
+ def _hash(s: str) -> int:
161
+ return int.from_bytes(hashlib.blake2b(s.encode("utf-8"), digest_size=8).digest(),
162
+ "big", signed=True)
163
+
164
+
165
+ def shingles(text: str) -> Set[int]:
166
+ n = normalize(text)
167
+ if len(n) < SHINGLE:
168
+ return set()
169
+ return {_hash(n[i:i + SHINGLE]) for i in range(len(n) - SHINGLE + 1)}
170
+
171
+
172
+ def candidate_hashes(text: str) -> Set[int]:
173
+ """Every shingle of `text`, plus its whole-text hash when it is short."""
174
+ out = shingles(text)
175
+ ex = _exact(text)
176
+ if ex is not None:
177
+ out.add(ex)
178
+ return out
179
+
180
+
181
+ def _exact(text: str) -> Optional[int]:
182
+ n = normalize(text)
183
+ if MIN_EXACT <= len(n) < SHINGLE:
184
+ return _hash("=" + n)
185
+ return None
186
+
187
+
188
+ # ───────────────────────── the ledger ─────────────────────────
189
+
190
+ def taint_dir() -> Path:
191
+ explicit = os.environ.get("HYPERCLAW_TAINT_DIR")
192
+ if explicit:
193
+ return Path(explicit).expanduser()
194
+ root = Path(os.environ.get("HYPERCLAW_ROOT", Path.home() / ".hyperclaw")).expanduser()
195
+ return root / "taint"
196
+
197
+
198
+ _SCHEMA = """
199
+ CREATE TABLE IF NOT EXISTS taint_sessions (
200
+ session TEXT PRIMARY KEY,
201
+ tainted INTEGER NOT NULL DEFAULT 0,
202
+ origins TEXT NOT NULL DEFAULT ''
203
+ );
204
+ CREATE TABLE IF NOT EXISTS taint_shingles (
205
+ session TEXT NOT NULL,
206
+ h INTEGER NOT NULL,
207
+ PRIMARY KEY (session, h)
208
+ ) WITHOUT ROWID;
209
+ CREATE TABLE IF NOT EXISTS taint_messages (
210
+ session TEXT NOT NULL,
211
+ msg TEXT NOT NULL,
212
+ origin TEXT NOT NULL,
213
+ connector TEXT NOT NULL DEFAULT '',
214
+ PRIMARY KEY (session, msg, origin, connector)
215
+ ) WITHOUT ROWID;
216
+ """
217
+ # Reads with no message id (scheduled jobs, direct calls) are recorded under
218
+ # this id: they count for the conversation, never for a specific message.
219
+ UNSCOPED = "*"
220
+
221
+
222
+ class _Store:
223
+ """Hashes only (never the text), one SQLite file per HYPERCLAW_ROOT."""
224
+
225
+ def __init__(self, path: Path):
226
+ self.path = path
227
+ self._ready = False
228
+ self._lock = threading.Lock()
229
+
230
+ def connect(self) -> sqlite3.Connection:
231
+ if not self._ready:
232
+ with self._lock:
233
+ if not self._ready:
234
+ self.path.parent.mkdir(parents=True, exist_ok=True)
235
+ try:
236
+ os.chmod(self.path.parent, 0o700)
237
+ except OSError:
238
+ pass
239
+ con = sqlite3.connect(self.path, timeout=10)
240
+ try:
241
+ con.executescript(_SCHEMA)
242
+ finally:
243
+ con.close()
244
+ try:
245
+ os.chmod(self.path, 0o600)
246
+ except OSError:
247
+ pass
248
+ self._ready = True
249
+ return sqlite3.connect(self.path, timeout=10)
250
+
251
+
252
+ class TaintLedger:
253
+ """Per-conversation record of tainted reads: shingle hashes, short-string
254
+ hashes, the tainted flag and which sources were read."""
255
+
256
+ def __init__(self, session: str, store: Optional[_Store] = None):
257
+ self.session = session
258
+ self.hashes: Set[int] = set()
259
+ self.tainted = False
260
+ self.origins: Set[Tuple[str, str]] = set()
261
+ # message id -> {(origin, connector_id)}: which message read what.
262
+ self.messages: Dict[str, Set[Tuple[str, str]]] = {}
263
+ self._store = store
264
+ self._lock = threading.Lock()
265
+
266
+ # persistence
267
+ def load(self) -> "TaintLedger":
268
+ """Read this conversation's record. A store that cannot be read is
269
+ logged and the ledger works in memory (reads are never blocked)."""
270
+ if self._store is None:
271
+ return self
272
+ try:
273
+ self._load()
274
+ except (sqlite3.Error, OSError) as e:
275
+ log.warning("taint ledger for %s not loaded (%s); in memory only", self.session, e)
276
+ return self
277
+
278
+ def _load(self) -> None:
279
+ con = self._store.connect()
280
+ try:
281
+ row = con.execute("SELECT tainted, origins FROM taint_sessions WHERE session=?",
282
+ (self.session,)).fetchone()
283
+ if row:
284
+ self.tainted = bool(row[0])
285
+ self.origins = {tuple(o.split("|", 1)) if "|" in o else (o, "")
286
+ for o in (row[1] or "").split(",") if o}
287
+ self.hashes = {r[0] for r in con.execute(
288
+ "SELECT h FROM taint_shingles WHERE session=?", (self.session,))}
289
+ for msg, origin, cid in con.execute(
290
+ "SELECT msg, origin, connector FROM taint_messages WHERE session=?",
291
+ (self.session,)):
292
+ self.messages.setdefault(msg, set()).add((origin, cid))
293
+ finally:
294
+ con.close()
295
+
296
+ def _persist(self, new: Iterable[int], msg: Optional[Tuple[str, str, str]] = None) -> None:
297
+ if self._store is None:
298
+ return
299
+ try:
300
+ self._write(new, msg)
301
+ except (sqlite3.Error, OSError) as e: # the in-memory record still applies
302
+ log.warning("taint ledger for %s not saved (%s); in memory only", self.session, e)
303
+
304
+ def _write(self, new: Iterable[int], msg: Optional[Tuple[str, str, str]] = None) -> None:
305
+ con = self._store.connect()
306
+ try:
307
+ with con:
308
+ con.execute("INSERT INTO taint_sessions (session, tainted, origins) VALUES (?,?,?) "
309
+ "ON CONFLICT (session) DO UPDATE SET tainted=excluded.tainted, "
310
+ "origins=excluded.origins",
311
+ (self.session, int(self.tainted),
312
+ ",".join(sorted(f"{o}|{c}" for o, c in self.origins))))
313
+ con.executemany("INSERT OR IGNORE INTO taint_shingles (session, h) VALUES (?, ?)",
314
+ ((self.session, h) for h in new))
315
+ if msg is not None:
316
+ con.execute("INSERT OR IGNORE INTO taint_messages (session, msg, origin, "
317
+ "connector) VALUES (?,?,?,?)", (self.session,) + msg)
318
+ finally:
319
+ con.close()
320
+
321
+ def _note_message(self, origin: str, connector_id: str,
322
+ message: Optional[str]) -> Optional[Tuple[str, str, str]]:
323
+ msg = str(message or current_message() or UNSCOPED)
324
+ seen = self.messages.setdefault(msg, set())
325
+ if (origin, connector_id) in seen:
326
+ return None
327
+ seen.add((origin, connector_id))
328
+ return (msg, origin, connector_id)
329
+
330
+ def add(self, text: str, origin: str, connector_id: str = "",
331
+ message: Optional[str] = None) -> None:
332
+ new = candidate_hashes(text)
333
+ with self._lock:
334
+ fresh = new - self.hashes
335
+ mrow = self._note_message(origin, connector_id, message)
336
+ changed = bool(fresh) or not self.tainted or \
337
+ (origin, connector_id) not in self.origins or mrow is not None
338
+ self.hashes |= fresh
339
+ self.tainted = True
340
+ self.origins.add((origin, connector_id))
341
+ if changed:
342
+ self._persist(fresh, mrow)
343
+
344
+ def flag(self, origin: str, connector_id: str = "", message: Optional[str] = None) -> None:
345
+ with self._lock:
346
+ mrow = self._note_message(origin, connector_id, message)
347
+ if self.tainted and (origin, connector_id) in self.origins and mrow is None:
348
+ return
349
+ self.tainted = True
350
+ self.origins.add((origin, connector_id))
351
+ self._persist((), mrow)
352
+
353
+ def message_origins(self, message: Optional[str]) -> Set[str]:
354
+ """The connectors read in this one message (turn)."""
355
+ if not message:
356
+ return set()
357
+ return {o for o, _ in self.messages.get(str(message), set())}
358
+
359
+ def matches(self, text: str) -> bool:
360
+ return bool(self.hashes) and not self.hashes.isdisjoint(candidate_hashes(text))
361
+
362
+ def first_origin(self) -> str:
363
+ return sorted(self.origins)[0][0] if self.origins else "connector"
364
+
365
+ def clear(self) -> None:
366
+ with self._lock:
367
+ self.hashes.clear()
368
+ self.tainted = False
369
+ self.origins.clear()
370
+ self.messages.clear()
371
+ if self._store is None:
372
+ return
373
+ try:
374
+ con = self._store.connect()
375
+ try:
376
+ with con:
377
+ con.execute("DELETE FROM taint_sessions WHERE session=?", (self.session,))
378
+ con.execute("DELETE FROM taint_shingles WHERE session=?", (self.session,))
379
+ con.execute("DELETE FROM taint_messages WHERE session=?", (self.session,))
380
+ finally:
381
+ con.close()
382
+ except (sqlite3.Error, OSError) as e:
383
+ log.warning("taint ledger for %s not cleared on disk (%s)", self.session, e)
384
+
385
+
386
+ # ───────────────────────── process registry + active conversation ─────────────────────────
387
+
388
+ _LOCK = threading.RLock()
389
+ _STORES: Dict[str, _Store] = {}
390
+ _LEDGERS: Dict[Tuple[str, str], TaintLedger] = {}
391
+ _ACTIVE: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_taint_conversation",
392
+ default=None)
393
+ # The message (turn) a read belongs to: the TurnGuard's turn id, set by the
394
+ # registry around every tool call.
395
+ _MESSAGE: contextvars.ContextVar = contextvars.ContextVar("hyperclaw_taint_message",
396
+ default=None)
397
+
398
+
399
+ def current_message() -> Optional[str]:
400
+ return _MESSAGE.get()
401
+
402
+
403
+ @contextmanager
404
+ def message(msg_id: Optional[str]):
405
+ """Run a block as part of one message (turn): reads inside are recorded
406
+ against it (per-message provenance)."""
407
+ if not msg_id:
408
+ yield
409
+ return
410
+ token = _MESSAGE.set(str(msg_id))
411
+ try:
412
+ yield
413
+ finally:
414
+ _MESSAGE.reset(token)
415
+
416
+
417
+ def _store() -> _Store:
418
+ p = str(taint_dir() / "taint.sqlite3")
419
+ with _LOCK:
420
+ if p not in _STORES:
421
+ _STORES[p] = _Store(Path(p))
422
+ return _STORES[p]
423
+
424
+
425
+ def ledger(session: Optional[str] = None) -> TaintLedger:
426
+ """The ledger for `session` (default: the active conversation, else the
427
+ background ledger), loaded from disk on first use in this process."""
428
+ sid = session or active() or BACKGROUND
429
+ st = _store()
430
+ key = (str(st.path), sid)
431
+ with _LOCK:
432
+ led = _LEDGERS.get(key)
433
+ if led is None:
434
+ led = TaintLedger(sid, st).load()
435
+ _LEDGERS[key] = led
436
+ return led
437
+
438
+
439
+ def _loaded() -> Iterator[TaintLedger]:
440
+ st = str(_store().path)
441
+ with _LOCK:
442
+ items = [led for (p, _), led in _LEDGERS.items() if p == st]
443
+ return iter(items)
444
+
445
+
446
+ def active() -> Optional[str]:
447
+ return _ACTIVE.get()
448
+
449
+
450
+ @contextmanager
451
+ def conversation(session: Optional[str]):
452
+ """Run a block as part of conversation `session`: reads made inside are
453
+ recorded in its ledger and sinks check its flag."""
454
+ if not session:
455
+ yield
456
+ return
457
+ token = _ACTIVE.set(str(session))
458
+ try:
459
+ yield
460
+ finally:
461
+ _ACTIVE.reset(token)
462
+
463
+
464
+ def is_tainted(session: Optional[str] = None) -> bool:
465
+ sid = session or active()
466
+ if not sid:
467
+ return False
468
+ return ledger(sid).tainted
469
+
470
+
471
+ def clear(session: str) -> None:
472
+ """Forget a conversation's taint: only when its history is cleared
473
+ (/reset, /clear), since the content is then gone from it."""
474
+ ledger(session).clear()
475
+
476
+
477
+ def reset_process_state() -> None:
478
+ """Drop the in-memory ledgers (tests; a restarted process). The on-disk
479
+ store is untouched, so a reloaded conversation is re-flagged."""
480
+ with _LOCK:
481
+ _LEDGERS.clear()
482
+ _STORES.clear()
483
+
484
+
485
+ # ───────────────────────── producers ─────────────────────────
486
+
487
+ def mark(value: Any, origin: str, connector_id: str = "",
488
+ session: Optional[str] = None, message: Optional[str] = None) -> Any:
489
+ """Wrap every string in `value` (str, dict values, lists, tuples) in
490
+ TaintedText and record it in the conversation's ledger, against the
491
+ message (turn) that read it. Returns the wrapped value (dicts and lists
492
+ are copied, never mutated)."""
493
+ led = ledger(session)
494
+ out = _wrap(value, origin, connector_id or origin, led, 0, message)
495
+ led.flag(origin, connector_id or origin, message)
496
+ return out
497
+
498
+
499
+ def _wrap(value: Any, origin: str, cid: str, led: TaintLedger, depth: int,
500
+ msg: Optional[str] = None) -> Any:
501
+ if depth > MAX_DEPTH or value is None:
502
+ return value
503
+ if isinstance(value, str):
504
+ if value:
505
+ led.add(value, origin, cid, msg)
506
+ if isinstance(value, TaintedText):
507
+ return value
508
+ return TaintedText(value, origin, cid)
509
+ if isinstance(value, dict):
510
+ return {k: _wrap(v, origin, cid, led, depth + 1, msg) for k, v in value.items()}
511
+ if isinstance(value, list):
512
+ return [_wrap(v, origin, cid, led, depth + 1, msg) for v in value]
513
+ if isinstance(value, tuple) and not hasattr(value, "_fields"):
514
+ return tuple(_wrap(v, origin, cid, led, depth + 1, msg) for v in value)
515
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
516
+ for f in dataclasses.fields(value):
517
+ try:
518
+ object.__setattr__(value, f.name,
519
+ _wrap(getattr(value, f.name), origin, cid, led, depth + 1,
520
+ msg))
521
+ except Exception: # an exotic field: its text is still in the ledger
522
+ pass
523
+ return value
524
+ return value
525
+
526
+
527
+ def source(origin: str, connector_id: str = "") -> Callable:
528
+ """Decorator for a connector read: its return value is marked."""
529
+ def deco(fn: Callable) -> Callable:
530
+ if inspect.iscoroutinefunction(fn):
531
+ @functools.wraps(fn)
532
+ async def awrapper(*a, **kw):
533
+ return mark(await fn(*a, **kw), origin, connector_id)
534
+ awrapper.__taint_source__ = origin
535
+ return awrapper
536
+
537
+ @functools.wraps(fn)
538
+ def wrapper(*a, **kw):
539
+ return mark(fn(*a, **kw), origin, connector_id)
540
+ wrapper.__taint_source__ = origin
541
+ return wrapper
542
+ return deco
543
+
544
+
545
+ # Tool-result envelopes: model tools whose OK result is connector content.
546
+ CONNECTOR_TOOLS = {
547
+ "email_read": "gmail", "email_search": "gmail", "calendar_read": "google_calendar",
548
+ "imessage_read": "imessage", "web_fetch": "web", "document_read": "document",
549
+ "pdf_read": "document", "gitlab_review_queue": "gitlab",
550
+ # Google Workspace tools (hyperclaw/google_workspace/tools.py ORIGIN)
551
+ "gmail_search": "gmail", "gmail_read_thread": "gmail", "gmail_read_attachment": "gmail",
552
+ "calendar_list_events": "google_calendar", "calendar_freebusy": "google_calendar",
553
+ "drive_search": "google_drive", "drive_read": "google_drive", "docs_read": "google_docs",
554
+ "sheets_read": "google_sheets", "slides_read": "google_slides",
555
+ "meet_list_records": "google_meet", "meet_read_transcript": "google_meet",
556
+ }
557
+
558
+
559
+ def mark_tool_result(tool: str, env: Any, session: Optional[str],
560
+ message: Optional[str] = None) -> None:
561
+ """Record a connector tool's envelope (data and message) in the
562
+ conversation's ledger, against the message (turn) that read it. The
563
+ envelope itself is not changed."""
564
+ origin = CONNECTOR_TOOLS.get(tool)
565
+ if origin is None:
566
+ return
567
+ led = ledger(session)
568
+ for part in (getattr(env, "data", None), getattr(env, "message", None)):
569
+ _wrap(part, origin, tool, led, 0, message)
570
+ led.flag(origin, tool, message)
571
+
572
+
573
+ # ───────────────────────── sinks ─────────────────────────
574
+
575
+ SINKS: Dict[str, str] = {} # sink name -> module.qualname
576
+
577
+
578
+ def _origin_of(value: Any, depth: int = 0) -> Optional[str]:
579
+ """origin of the first TaintedText found in value."""
580
+ if depth > MAX_DEPTH or value is None:
581
+ return None
582
+ if isinstance(value, TaintedText):
583
+ return value.origin
584
+ for v in _children(value):
585
+ o = _origin_of(v, depth + 1)
586
+ if o:
587
+ return o
588
+ return None
589
+
590
+
591
+ def _children(value: Any) -> Iterable[Any]:
592
+ if isinstance(value, (str, bytes, bytearray, int, float, bool)) or value is None:
593
+ return ()
594
+ if isinstance(value, dict):
595
+ return list(value.keys()) + list(value.values())
596
+ if isinstance(value, (list, tuple, set, frozenset)):
597
+ return list(value)
598
+ dump = getattr(value, "model_dump", None)
599
+ if callable(dump) and not isinstance(value, type):
600
+ try:
601
+ return [dump(mode="python")]
602
+ except Exception:
603
+ return list(getattr(value, "__dict__", {}).values())
604
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
605
+ return [getattr(value, f.name, None) for f in dataclasses.fields(value)]
606
+ return ()
607
+
608
+
609
+ def _strings(value: Any, depth: int = 0) -> Iterator[str]:
610
+ if depth > MAX_DEPTH or value is None:
611
+ return
612
+ if isinstance(value, str):
613
+ yield value
614
+ return
615
+ for v in _children(value):
616
+ yield from _strings(v, depth + 1)
617
+
618
+
619
+ def check(sink: str, values: Iterable[Any], *, session: Optional[str] = None,
620
+ own_words: bool = False) -> None:
621
+ """Raise TaintedInput (after logging it) if `values` carry connector
622
+ content or, for a non-own-words sink, the conversation is tainted."""
623
+ sid = session or active()
624
+ values = list(values)
625
+ try:
626
+ origin = _origin_of(values)
627
+ if origin:
628
+ _refuse(sink, "tagged", origin, sid)
629
+ texts = [t for t in _strings(values) if len(t) >= MIN_EXACT]
630
+ if texts:
631
+ ledgers = list(_loaded())
632
+ if sid:
633
+ ledgers.append(ledger(sid))
634
+ ledgers.append(ledger(BACKGROUND))
635
+ cands = [candidate_hashes(t) for t in texts]
636
+ for led in {id(x): x for x in ledgers}.values():
637
+ if led.hashes and any(not led.hashes.isdisjoint(c) for c in cands):
638
+ _refuse(sink, "shingle", led.first_origin(), sid)
639
+ if sid and not own_words:
640
+ led = ledger(sid)
641
+ if led.tainted:
642
+ _refuse(sink, "conversation", led.first_origin(), sid)
643
+ except TaintedInput:
644
+ raise
645
+ except Exception as e: # fail closed: an unchecked write could carry connector text
646
+ log.warning("taint check for %s could not run: %s", sink, e, exc_info=True)
647
+ _refuse(sink, "unavailable", "", sid)
648
+
649
+
650
+ def refuse_connector_source(sink: str, source_name: Optional[str]) -> None:
651
+ """Raise TaintedInput when a writer is told its input came from a connector."""
652
+ name = str(source_name or "").strip().lower()
653
+ if name in CONNECTOR_SOURCES:
654
+ _refuse(sink, "tagged", name, active())
655
+
656
+
657
+ def _refuse(sink: str, reason: str, origin: str, sid: Optional[str]) -> None:
658
+ exc = TaintedInput(sink, reason, origin, sid or "")
659
+ log.warning("training sink %s refused connector content (%s, origin=%s, conversation=%s)",
660
+ sink, reason, origin or "-", sid or "-")
661
+ try:
662
+ from hyperclaw.tools.ledger import get_ledger
663
+ get_ledger().record(conversation=sid or "taint_guard", turn=None, surface="taint_guard",
664
+ tool=f"training_sink:{sink}", effect="write",
665
+ target=f"training_sink:{sink}", key=None, status="refused",
666
+ message=f"refused: connector content ({reason}, "
667
+ f"origin {origin or 'unknown'})")
668
+ except Exception as e:
669
+ log.warning("taint guard: could not write the activity log: %s", e)
670
+ raise exc
671
+
672
+
673
+ def training_sink(name: str, *, own_words: bool = False,
674
+ session: Optional[Callable[[dict], Optional[str]]] = None,
675
+ own_words_unless: Optional[Callable[[dict], bool]] = None) -> Callable:
676
+ """Register a writer to a training, corpus, eval, memory or
677
+ self-improvement store. Every call is checked before the writer runs.
678
+
679
+ own_words: a user-directed memory sink (see module docstring).
680
+ own_words_unless(bound_args): the call is NOT own-words (e.g. remember()
681
+ with source="auto_store").
682
+ session(bound_args): the conversation id when the writer names it in its
683
+ arguments (else the active conversation)."""
684
+ def deco(fn: Callable) -> Callable:
685
+ sig = inspect.signature(fn)
686
+
687
+ def _check(a, kw):
688
+ try:
689
+ bound = sig.bind_partial(*a, **kw).arguments
690
+ except TypeError:
691
+ bound = {"args": a, "kwargs": kw}
692
+ values = [v for k, v in bound.items() if k not in ("self", "cls")]
693
+ sid = session(bound) if session else None
694
+ ow = own_words and not (own_words_unless and own_words_unless(bound))
695
+ check(name, values, session=sid, own_words=ow)
696
+
697
+ if inspect.iscoroutinefunction(fn):
698
+ @functools.wraps(fn)
699
+ async def awrapper(*a, **kw):
700
+ _check(a, kw)
701
+ return await fn(*a, **kw)
702
+ wrapper = awrapper
703
+ else:
704
+ @functools.wraps(fn)
705
+ def swrapper(*a, **kw):
706
+ _check(a, kw)
707
+ return fn(*a, **kw)
708
+ wrapper = swrapper
709
+ wrapper.__training_sink__ = name
710
+ SINKS[name] = f"{fn.__module__}.{fn.__qualname__}"
711
+ return wrapper
712
+ return deco
713
+
714
+
715
+ # Model tools that write to a sink: refused before an approval card is shown.
716
+ TOOL_SINKS = {
717
+ "memory_store": ("memory.vector_memory.memory_store", ("content", "domain", "source"), True),
718
+ "learning_log": ("learning.log_interaction", ("action", "detail", "feedback"), False),
719
+ "learning_feedback": ("learning.log_feedback", ("feedback",), False),
720
+ "learning_reflect": ("learning.reflect", ("period",), False),
721
+ }
722
+
723
+
724
+ def check_tool_call(tool: str, args: dict, session: Optional[str]) -> Optional[TaintedInput]:
725
+ """The refusal for a sink tool call, or None. Never raises."""
726
+ spec = TOOL_SINKS.get(tool)
727
+ if spec is None:
728
+ return None
729
+ sink, fields, own = spec
730
+ try:
731
+ check(sink, [args.get(f) for f in fields if isinstance(args, dict)],
732
+ session=session, own_words=own)
733
+ except TaintedInput as e:
734
+ return e
735
+ return None
736
+
737
+
738
+ # ───────────────────────── memory: own words vs learned (per message) ─────────────────────────
739
+
740
+ # Memory tools whose save is decided per message (rc5): own words save
741
+ # without a prompt; anything learned waits for the person's tap.
742
+ MEMORY_TOOLS = frozenset({"memory_store"})
743
+ # Reads whose result is other people's words (pages, documents, feeds): a
744
+ # memory saved in the same message waits for a tap. Reading the person's own
745
+ # memory or files does not.
746
+ PAGE_TOOLS = frozenset({"web_search", "web_fetch", "wikipedia", "news", "reddit", "hackernews",
747
+ "arxiv", "youtube", "wayback", "pdf_read", "document_read",
748
+ "team_delegate", "gitlab_review_queue"})
749
+ # Share of the memory's words (3+ letters) that must appear in the person's
750
+ # own words for it to count as theirs.
751
+ OWN_WORDS_MIN_SHARE = 0.6
752
+
753
+
754
+ def _content_words(text: str) -> list:
755
+ return [w for w in normalize(text).split() if len(w) >= 3]
756
+
757
+
758
+ def own_words_share(content: str, provenance: Iterable[str]) -> float:
759
+ """How much of `content` is in the person's own words (0..1)."""
760
+ words = _content_words(content)
761
+ if not words:
762
+ return 0.0
763
+ said = set()
764
+ for p in provenance or ():
765
+ if isinstance(p, str):
766
+ said.update(_content_words(p))
767
+ return sum(1 for w in words if w in said) / len(words)
768
+
769
+
770
+ def _labels(origins: Iterable[str]) -> str:
771
+ names = sorted({LABELS.get(o, o.replace("_", " ")) for o in origins if o})
772
+ return ", ".join(names) or "a connected account"
773
+
774
+
775
+ def memory_decision(content: str, *, session: Optional[str], message: Optional[str],
776
+ provenance: Iterable[str] = (), read_untrusted: bool = False
777
+ ) -> Optional[str]:
778
+ """None when a memory is the person's own words (save it without a
779
+ prompt). Otherwise the reason it needs the person's tap, naming the source.
780
+
781
+ Per message: what THIS message (turn) read decides, not everything the
782
+ conversation ever read. Verbatim connector text never gets here: the
783
+ sink check (rules 1-2) refuses it first."""
784
+ led = ledger(session) if session else None
785
+ here = led.message_origins(message) if led is not None else set()
786
+ if here:
787
+ return (f"it was learned in a message that read {_labels(here)}; check it is what you "
788
+ f"want kept")
789
+ if read_untrusted:
790
+ return "it was learned in a message that read a web page or document"
791
+ if own_words_share(content, provenance) >= OWN_WORDS_MIN_SHARE:
792
+ return None
793
+ if led is not None and led.origins:
794
+ return (f"it is not in your own words, and this conversation read "
795
+ f"{_labels(o for o, _ in led.origins)}")
796
+ return "it is not in your own words"
797
+
798
+
799
+ # ───────────────────────── session files ─────────────────────────
800
+
801
+ TAINT_KEY = "taint_origin"
802
+
803
+
804
+ def tag_messages(messages: list) -> list:
805
+ """Copies of `messages` with TAINT_KEY set on each message whose content
806
+ is connector content (for a session file)."""
807
+ out = []
808
+ for m in messages:
809
+ if isinstance(m, dict):
810
+ o = _origin_of(m.get("content"))
811
+ if o is None and isinstance(m.get("content"), str) and _matches_any(m["content"]):
812
+ o = "connector"
813
+ if o:
814
+ m = {**m, TAINT_KEY: o}
815
+ out.append(m)
816
+ return out
817
+
818
+
819
+ def _matches_any(text: str) -> bool:
820
+ return any(led.matches(text) for led in _loaded())
821
+
822
+
823
+ def restore_messages(session: str, messages: list) -> list:
824
+ """After loading a session file: re-mark tagged messages (TaintedText +
825
+ ledger) so the reloaded conversation is flagged again, even on an
826
+ install whose taint store does not have it."""
827
+ out = []
828
+ for m in messages:
829
+ if isinstance(m, dict) and m.get(TAINT_KEY):
830
+ m = {**m, "content": mark(m.get("content"), str(m[TAINT_KEY]), session=session)}
831
+ out.append(m)
832
+ return out