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,724 @@
1
+ """
2
+ Effect identity for the tool standard wave 2 (S3 idempotency, S5 per-resource
3
+ budgets).
4
+
5
+ Three things live here, all pure functions (no I/O except reading files that
6
+ are about to be written, to hash them):
7
+
8
+ 1. The **effect key** (S3). The runtime derives it; the model never supplies
9
+ one:
10
+
11
+ key = sha256(effect, canonical_target, canonical_payload)
12
+
13
+ `canonical_target` comes from the registry's `target_on` fields: the
14
+ resolved absolute path for files (family "file", so write_file, edit_file
15
+ and a bash redirect to the same path share it), the recipients for an
16
+ email, the channel for Slack, title + start for a calendar event, and so
17
+ on. `canonical_payload` comes from the remaining `idempotent_on` fields.
18
+ For a whole-file write the payload is the sha256 of the file content (one
19
+ trailing newline run ignored, so `echo x > f` and write_file("x") agree).
20
+ The key is the same digest the agent-mode broker calls `payloadDigest`.
21
+
22
+ 2. **Bash file writes** (Q-T2). `bash_file_writes(command)` parses the simple
23
+ shell forms models use to write files: `>`/`>>` redirects (with echo,
24
+ printf, cat <<EOF), `tee [-a]`, `cp`/`mv` onto a path and `sed -i`. Each
25
+ becomes a FileWrite on the same "file:<abs path>" target as write_file.
26
+ Limits (documented in docs/TOOL-STANDARD.md): anything with variable or
27
+ command expansion in the target, `dd of=`, `install`, `rsync`, writes made
28
+ by scripts or programs the command runs (python -c, node -e, git ...),
29
+ `cd` changing the base directory, and subshell/eval tricks are not seen.
30
+
31
+ 3. **Resource identity** (S5). `resources(tool, args)` lists the things a
32
+ call touches: paths, URLs, message/event/plan/memory ids, and for bash the
33
+ path-like words of the command plus its text. `touches(r, missing)`
34
+ decides whether a call is still going after a resource that was just
35
+ reported not_found: the same path; the same basename anywhere under a home
36
+ directory (so an invented `/Users/<someone>/...` copy counts); a directory
37
+ that contains it below home (listing the folder to look for it); or, for
38
+ bash, the basename in the command text.
39
+ """
40
+ from __future__ import annotations
41
+
42
+ import hashlib
43
+ import json
44
+ import os
45
+ import re
46
+ from dataclasses import dataclass
47
+ from typing import Any, Iterable, List, Optional, Sequence, Tuple
48
+ from urllib.parse import urlsplit, urlunsplit
49
+
50
+ # Arguments whose value is a local path (resolved to an absolute path).
51
+ PATH_FIELDS = ("path", "image_path", "file", "save_path", "attachments")
52
+ # Arguments that identify a resource for the per-resource budget.
53
+ RESOURCE_PATH_FIELDS = ("path", "image_path", "file", "attachments")
54
+ RESOURCE_ID_FIELDS = ("message_id", "event_id", "plan_id", "memory_id")
55
+ # Tools whose target is a whole file's content.
56
+ FILE_CONTENT_TOOLS = {"write_file": "content"}
57
+ # Tools that create the path they name; a not_found on that path earlier in
58
+ # the turn does not block them (creating it is not searching for it). Their
59
+ # other resources (a download URL) are still checked.
60
+ CREATE_TOOLS = frozenset({"write_file", "download", "qr_code", "image_generate"})
61
+ _RECIPIENT_FIELDS = ("to", "cc", "bcc", "recipient")
62
+ MAX_HASH_BYTES = 50 * 1024 * 1024
63
+
64
+
65
+ # ───────────────────────── canonical forms ─────────────────────────
66
+
67
+ def home() -> str:
68
+ return os.path.realpath(os.path.expanduser("~"))
69
+
70
+
71
+ def resolve_path(p: Any, base: Optional[str] = None) -> Optional[str]:
72
+ """Absolute, normalised, symlink-resolved path; None if not a usable path."""
73
+ if not isinstance(p, str) or not p.strip():
74
+ return None
75
+ p = p.strip()
76
+ for var in ("${HOME}", "$HOME"):
77
+ if p == var or p.startswith(var + "/"):
78
+ p = "~" + p[len(var):]
79
+ if "$" in p or "`" in p:
80
+ return None
81
+ p = os.path.expanduser(p)
82
+ if not os.path.isabs(p):
83
+ p = os.path.join(base or home(), p)
84
+ return os.path.realpath(os.path.normpath(p))
85
+
86
+
87
+ def normalize_url(u: Any) -> Optional[str]:
88
+ if not isinstance(u, str) or not u.strip():
89
+ return None
90
+ try:
91
+ s = urlsplit(u.strip())
92
+ except ValueError:
93
+ return u.strip()
94
+ path = s.path.rstrip("/") or ""
95
+ return urlunsplit((s.scheme.lower(), s.netloc.lower(), path, s.query, ""))
96
+
97
+
98
+ def _canon_value(field: str, v: Any) -> Any:
99
+ if field in _RECIPIENT_FIELDS:
100
+ items = v if isinstance(v, (list, tuple)) else re.split(r"[,;]", str(v or ""))
101
+ return sorted({str(x).strip().lower() for x in items if str(x).strip()})
102
+ if field in PATH_FIELDS:
103
+ if isinstance(v, (list, tuple)):
104
+ return sorted(filter(None, (resolve_path(x) for x in v)))
105
+ return resolve_path(v) or v
106
+ if field == "url":
107
+ return normalize_url(v)
108
+ if field == "method" and isinstance(v, str):
109
+ return v.strip().upper()
110
+ if isinstance(v, str):
111
+ return v.strip()
112
+ return v
113
+
114
+
115
+ def _present(v: Any) -> bool:
116
+ return v not in (None, "", [], {}, ())
117
+
118
+
119
+ def _dumps(obj: Any) -> str:
120
+ return json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(",", ":"), default=str)
121
+
122
+
123
+ def sha256(text: str) -> str:
124
+ return hashlib.sha256(text.encode("utf-8", "surrogateescape")).hexdigest()
125
+
126
+
127
+ def content_hash(content: str) -> str:
128
+ """Hash of file content as written: one trailing run of newlines ignored."""
129
+ data = content.encode("utf-8", "surrogateescape") if isinstance(content, str) else content
130
+ return hashlib.sha256(data.rstrip(b"\n")).hexdigest()
131
+
132
+
133
+ def file_hash(path: Optional[str]) -> Optional[str]:
134
+ """content_hash of the file on disk now; None if missing/unreadable/huge."""
135
+ if not path:
136
+ return None
137
+ try:
138
+ if not os.path.isfile(path) or os.path.getsize(path) > MAX_HASH_BYTES:
139
+ return None
140
+ with open(path, "rb") as fh:
141
+ return hashlib.sha256(fh.read().rstrip(b"\n")).hexdigest()
142
+ except OSError:
143
+ return None
144
+
145
+
146
+ def read_text(path: Optional[str]) -> Optional[str]:
147
+ try:
148
+ if path and os.path.isfile(path) and os.path.getsize(path) <= MAX_HASH_BYTES:
149
+ with open(path, "rb") as fh:
150
+ return fh.read().decode("utf-8", "surrogateescape")
151
+ except OSError:
152
+ pass
153
+ return None
154
+
155
+
156
+ def file_target(path: str) -> str:
157
+ return f"file:{path}"
158
+
159
+
160
+ def make_key(effect: str, target: str, payload: str) -> str:
161
+ return sha256(f"{effect}\x1f{target}\x1f{payload}")
162
+
163
+
164
+ # ───────────────────────── effective effect ─────────────────────────
165
+
166
+ _SQL_READ = re.compile(r"^\s*(select|with|pragma|explain|show|describe)\b", re.I)
167
+ _GITHUB_READ = {"repos", "issues", "prs", "search_code", "user_info"}
168
+
169
+
170
+ def effective_effect(spec, args: dict) -> str:
171
+ """The registry effect, narrowed by payload for the few tools whose
172
+ effect depends on it (a GET is a read, not a send)."""
173
+ name, effect = spec.name, spec.effect
174
+ if name == "http_request" and str(args.get("method") or "GET").upper() in ("GET", "HEAD",
175
+ "OPTIONS"):
176
+ return "read"
177
+ if name == "sql_query" and _SQL_READ.match(str(args.get("query") or "")):
178
+ return "read"
179
+ if name == "github" and str(args.get("action") or "") in _GITHUB_READ:
180
+ return "read"
181
+ return effect
182
+
183
+
184
+ # ───────────────────────── effect identity for a tool call ─────────────────────────
185
+
186
+ @dataclass(frozen=True)
187
+ class Effect:
188
+ tool: str
189
+ effect: str # write / send / spend / delete / system
190
+ target: str # canonical target
191
+ payload: str # canonical payload (string form)
192
+ key: Optional[str] # None: not idempotent (recorded for the activity log only)
193
+ file_path: Optional[str] = None # set for file-family targets
194
+ expected_post_hash: Optional[str] = None # file content hash after a whole-file write
195
+
196
+ @property
197
+ def payload_hash(self) -> str:
198
+ return sha256(self.payload)
199
+
200
+
201
+ def call_effect(spec, args: dict) -> Optional[Effect]:
202
+ """Effect identity for one non-read tool call; None for reads."""
203
+ effect = effective_effect(spec, args)
204
+ if effect == "read":
205
+ return None
206
+ idem = tuple(getattr(spec, "idempotent_on", ()) or ())
207
+ target_on = tuple(getattr(spec, "target_on", ()) or ())
208
+ name = spec.name
209
+
210
+ file_path = None
211
+ if target_on and all(f in PATH_FIELDS for f in target_on) and len(target_on) == 1:
212
+ file_path = resolve_path(args.get(target_on[0]))
213
+ if file_path:
214
+ target = file_target(file_path)
215
+ elif target_on:
216
+ parts = {f: _canon_value(f, args.get(f)) for f in target_on if _present(args.get(f))}
217
+ target = f"{name}:{_dumps(parts)}" if parts else name
218
+ else:
219
+ target = name
220
+
221
+ if name in FILE_CONTENT_TOOLS and file_path:
222
+ content = args.get(FILE_CONTENT_TOOLS[name])
223
+ content = content if isinstance(content, str) else str(content or "")
224
+ h = content_hash(content)
225
+ payload = f"content:{h}"
226
+ return Effect(name, effect, target, payload, make_key("write", target, payload),
227
+ file_path=file_path, expected_post_hash=h)
228
+
229
+ payload_fields = [f for f in idem if f not in target_on]
230
+ payload_obj = {f: _canon_value(f, args.get(f)) for f in payload_fields if _present(args.get(f))}
231
+ payload = _dumps(payload_obj)
232
+ key = make_key(effect, target, payload) if idem else None
233
+ return Effect(name, effect, target, payload, key, file_path=file_path)
234
+
235
+
236
+ def summarize_args(args: Any, limit: int = 400) -> str:
237
+ try:
238
+ s = _dumps(args)
239
+ except Exception:
240
+ s = repr(args)
241
+ return s if len(s) <= limit else s[:limit] + "..."
242
+
243
+
244
+ # ───────────────────────── bash: tokenizer ─────────────────────────
245
+
246
+ @dataclass
247
+ class Tok:
248
+ kind: str # "word" | "op"
249
+ value: str
250
+ expansion: bool = False # contains unresolved $var / `cmd` / glob
251
+ quoted: bool = False
252
+
253
+
254
+ _OPS = ("&&", "||", ";;", ">>", "&>>", "&>", ">|", ">&", "<<-", "<<", "<&", "|&",
255
+ ";", "|", "&", "<", ">", "(", ")", "\n")
256
+ _SEPARATORS = {"&&", "||", ";", ";;", "&", "\n", "(", ")", "|", "|&"}
257
+
258
+
259
+ def tokenize(cmd: str) -> List[Tok]:
260
+ """A small POSIX-ish shell tokenizer: quotes, escapes, operators, fd
261
+ redirects (2>, 1>>) and here-documents (body attached to the `<<` op)."""
262
+ toks: List[Tok] = []
263
+ i, n = 0, len(cmd)
264
+ word: List[str] = []
265
+ in_word = False
266
+ expansion = quoted = False
267
+ pending_heredocs: List[Tuple[Tok, str, bool]] = []
268
+ h = home()
269
+
270
+ def end_word():
271
+ nonlocal word, in_word, expansion, quoted
272
+ if in_word:
273
+ toks.append(Tok("word", "".join(word), expansion, quoted))
274
+ word, in_word, expansion, quoted = [], False, False, False
275
+
276
+ def dollar(j: int) -> Tuple[Optional[str], int]:
277
+ for var in ("${HOME}", "$HOME"):
278
+ if cmd.startswith(var, j):
279
+ k = j + len(var)
280
+ if var == "$HOME" and k < n and (cmd[k].isalnum() or cmd[k] == "_"):
281
+ break
282
+ return h, k
283
+ return None, j
284
+
285
+ while i < n:
286
+ c = cmd[i]
287
+ if c == "\\" and i + 1 < n:
288
+ if cmd[i + 1] == "\n":
289
+ i += 2
290
+ continue
291
+ word.append(cmd[i + 1]); in_word = True; i += 2
292
+ continue
293
+ if c == "'":
294
+ j = cmd.find("'", i + 1)
295
+ j = n if j < 0 else j
296
+ word.append(cmd[i + 1:j]); in_word = True; quoted = True; i = j + 1
297
+ continue
298
+ if c == '"':
299
+ in_word = True; quoted = True; i += 1
300
+ while i < n and cmd[i] != '"':
301
+ if cmd[i] == "\\" and i + 1 < n and cmd[i + 1] in '"\\$`\n':
302
+ word.append(cmd[i + 1]); i += 2
303
+ continue
304
+ if cmd[i] == "$":
305
+ val, k = dollar(i)
306
+ if val is not None:
307
+ word.append(val); i = k
308
+ continue
309
+ expansion = True
310
+ if cmd[i] == "`":
311
+ expansion = True
312
+ word.append(cmd[i]); i += 1
313
+ i += 1
314
+ continue
315
+ if c == "$":
316
+ val, k = dollar(i)
317
+ if val is not None:
318
+ word.append(val); in_word = True; i = k
319
+ continue
320
+ expansion = True; word.append(c); in_word = True; i += 1
321
+ continue
322
+ if c in "`*?[":
323
+ expansion = True; word.append(c); in_word = True; i += 1
324
+ continue
325
+ if c == "~" and not in_word:
326
+ word.append(c); in_word = True; i += 1
327
+ continue
328
+ if c == "#" and not in_word:
329
+ j = cmd.find("\n", i)
330
+ i = n if j < 0 else j
331
+ continue
332
+ if c in " \t":
333
+ end_word(); i += 1
334
+ continue
335
+ op = next((o for o in _OPS if cmd.startswith(o, i)), None)
336
+ if op:
337
+ # "2>" / "1>>": a digit-only word glued to a redirect is an fd.
338
+ if op[0] in "<>" and in_word and not quoted and "".join(word).isdigit():
339
+ fd = "".join(word)
340
+ word, in_word = [], False
341
+ toks.append(Tok("op", fd + op))
342
+ else:
343
+ end_word()
344
+ toks.append(Tok("op", op))
345
+ i += len(op)
346
+ if op in ("<<", "<<-"):
347
+ # Read the delimiter word now; the body follows the next newline.
348
+ while i < n and cmd[i] in " \t":
349
+ i += 1
350
+ j = i
351
+ while j < n and cmd[j] not in " \t\n;|&<>()":
352
+ j += 1
353
+ raw = cmd[i:j]
354
+ delim = raw.strip("'\"").replace("\\", "")
355
+ pending_heredocs.append((toks[-1], delim, raw != delim))
356
+ i = j
357
+ if op == "\n" and pending_heredocs:
358
+ for htok, delim, lit in pending_heredocs:
359
+ body_lines = []
360
+ while i < n:
361
+ j = cmd.find("\n", i)
362
+ line = cmd[i:] if j < 0 else cmd[i:j]
363
+ i = n if j < 0 else j + 1
364
+ if (line.lstrip("\t") if htok.value == "<<-" else line) == delim:
365
+ break
366
+ body_lines.append(line)
367
+ body = "".join(l + "\n" for l in body_lines)
368
+ htok.value = htok.value + "\x00" + body
369
+ htok.expansion = (not lit) and ("$" in body or "`" in body)
370
+ pending_heredocs = []
371
+ continue
372
+ word.append(c); in_word = True; i += 1
373
+ end_word()
374
+ return toks
375
+
376
+
377
+ # ───────────────────────── bash: file writes ─────────────────────────
378
+
379
+ @dataclass
380
+ class FileWrite:
381
+ path: str # resolved absolute path
382
+ mode: str # "w" (replace) or "a" (append)
383
+ content: Optional[str] # known content written (None: unknown)
384
+ via: str # ">", ">>", "tee", "cp", "mv", "sed -i", ...
385
+
386
+
387
+ @dataclass
388
+ class _Seg:
389
+ words: List[Tok]
390
+ redirects: List[Tuple[str, Tok]]
391
+ heredoc: Optional[Tok]
392
+ piped_from_prev: bool
393
+
394
+
395
+ def _segments(toks: Sequence[Tok]) -> List[_Seg]:
396
+ segs: List[_Seg] = []
397
+ cur = _Seg([], [], None, False)
398
+ i = 0
399
+ while i < len(toks):
400
+ t = toks[i]
401
+ if t.kind == "op" and t.value in _SEPARATORS:
402
+ segs.append(cur)
403
+ cur = _Seg([], [], None, t.value in ("|", "|&"))
404
+ i += 1
405
+ continue
406
+ if t.kind == "op" and t.value.split("\x00")[0] in ("<<", "<<-"):
407
+ cur.heredoc = t
408
+ i += 1
409
+ continue
410
+ if t.kind == "op" and t.value.lstrip("0123456789") in (">", ">>", ">|", "&>", "&>>",
411
+ "<", ">&", "<&"):
412
+ nxt = toks[i + 1] if i + 1 < len(toks) and toks[i + 1].kind == "word" else None
413
+ cur.redirects.append((t.value, nxt))
414
+ i += 2 if nxt else 1
415
+ continue
416
+ if t.kind == "word":
417
+ cur.words.append(t)
418
+ i += 1
419
+ segs.append(cur)
420
+ return [s for s in segs if s.words or s.redirects]
421
+
422
+
423
+ _PRINTF_ESC = {"n": "\n", "t": "\t", "\\": "\\", "r": "\r", "'": "'", '"': '"'}
424
+
425
+
426
+ def _printf_unescape(s: str) -> Optional[str]:
427
+ out, i = [], 0
428
+ while i < len(s):
429
+ c = s[i]
430
+ if c == "\\" and i + 1 < len(s):
431
+ e = _PRINTF_ESC.get(s[i + 1])
432
+ if e is None:
433
+ return None
434
+ out.append(e); i += 2
435
+ continue
436
+ out.append(c); i += 1
437
+ return "".join(out)
438
+
439
+
440
+ def _printf(args: List[str]) -> Optional[str]:
441
+ if not args:
442
+ return None
443
+ fmt = _printf_unescape(args[0])
444
+ if fmt is None:
445
+ return None
446
+ pieces = re.split(r"(%%|%s|%[^%s]?)", fmt)
447
+ spec_count = sum(1 for p in pieces if p == "%s")
448
+ if any(p.startswith("%") and p not in ("%%", "%s") for p in pieces if p):
449
+ return None
450
+ rest = args[1:]
451
+ if spec_count == 0:
452
+ return fmt.replace("%%", "%")
453
+ out = []
454
+ groups = max(1, -(-len(rest) // spec_count))
455
+ for g in range(groups):
456
+ vals = rest[g * spec_count:(g + 1) * spec_count]
457
+ k = 0
458
+ for p in pieces:
459
+ if p == "%s":
460
+ out.append(vals[k] if k < len(vals) else ""); k += 1
461
+ elif p == "%%":
462
+ out.append("%")
463
+ else:
464
+ out.append(p)
465
+ return "".join(out)
466
+
467
+
468
+ def _echo(args: List[str]) -> Optional[str]:
469
+ newline, escapes = True, False
470
+ while args and re.fullmatch(r"-[neE]+", args[0]):
471
+ newline = newline and "n" not in args[0]
472
+ escapes = escapes or "e" in args[0]
473
+ args = args[1:]
474
+ text = " ".join(args)
475
+ if escapes and "\\" in text:
476
+ text = _printf_unescape(text)
477
+ if text is None:
478
+ return None
479
+ return text + ("\n" if newline else "")
480
+
481
+
482
+ def _stdout(seg: _Seg, stdin: Optional[str], base: str) -> Optional[str]:
483
+ """Known stdout of a simple command, or None."""
484
+ if not seg.words or any(w.expansion for w in seg.words):
485
+ return None
486
+ cmd, args = seg.words[0].value, [w.value for w in seg.words[1:]]
487
+ if cmd == "echo":
488
+ return _echo(args)
489
+ if cmd == "printf":
490
+ return _printf(args)
491
+ if cmd == "cat":
492
+ files = [a for a in args if not a.startswith("-") or a == "-"]
493
+ if not files:
494
+ if seg.heredoc is not None:
495
+ if seg.heredoc.expansion:
496
+ return None
497
+ return seg.heredoc.value.split("\x00", 1)[1] if "\x00" in seg.heredoc.value else None
498
+ return stdin
499
+ texts = [read_text(resolve_path(f, base)) for f in files]
500
+ return None if any(t is None for t in texts) else "".join(texts)
501
+ return None
502
+
503
+
504
+ def bash_file_writes(command: str, base: Optional[str] = None) -> List[FileWrite]:
505
+ """File writes a bash command will make, as far as simple parsing can tell."""
506
+ if not isinstance(command, str) or not command.strip():
507
+ return []
508
+ base = base or home()
509
+ try:
510
+ segs = _segments(tokenize(command))
511
+ except Exception: # never let a parser bug block a command
512
+ return []
513
+ writes: List[FileWrite] = []
514
+ prev_out: Optional[str] = None
515
+ for seg in segs:
516
+ stdin = prev_out if seg.piped_from_prev else None
517
+ out = _stdout(seg, stdin, base)
518
+ for op, target in seg.redirects:
519
+ if target is None or target.expansion:
520
+ continue
521
+ core = op.lstrip("0123456789")
522
+ fd = op[: len(op) - len(core)] or "1"
523
+ if core in ("<", "<&", ">&"):
524
+ continue
525
+ path = resolve_path(target.value, base)
526
+ if not path or path.startswith("/dev/"):
527
+ continue
528
+ mode = "a" if core in (">>", "&>>") else "w"
529
+ known = out if (fd == "1" and core in (">", ">>", ">|")) else None
530
+ if fd == "2":
531
+ known = None
532
+ writes.append(FileWrite(path, mode, known, op))
533
+ words = [w for w in seg.words]
534
+ if words and not any(w.expansion for w in words):
535
+ cmd = words[0].value
536
+ args = [w.value for w in words[1:]]
537
+ if cmd == "tee":
538
+ append = any(a in ("-a", "--append") for a in args)
539
+ for a in args:
540
+ if a.startswith("-"):
541
+ continue
542
+ p = resolve_path(a, base)
543
+ if p and not p.startswith("/dev/"):
544
+ writes.append(FileWrite(p, "a" if append else "w", stdin, "tee"))
545
+ elif cmd in ("cp", "mv"):
546
+ operands = [a for a in args if not a.startswith("-")]
547
+ if "-t" in args or "--target-directory" in args or len(operands) < 2:
548
+ pass
549
+ else:
550
+ dest = resolve_path(operands[-1], base)
551
+ srcs = [resolve_path(s, base) for s in operands[:-1]]
552
+ if dest and all(srcs):
553
+ into_dir = len(srcs) > 1 or os.path.isdir(dest)
554
+ for s in srcs:
555
+ d = os.path.join(dest, os.path.basename(s)) if into_dir else dest
556
+ writes.append(FileWrite(d, "w", read_text(s), cmd))
557
+ elif cmd == "sed":
558
+ inplace, i, script_seen = False, 0, False
559
+ targets = []
560
+ while i < len(args):
561
+ a = args[i]
562
+ if a == "-i" or a.startswith("-i") or a.startswith("--in-place"):
563
+ inplace = True
564
+ # BSD sed: `-i ''` / `-i .bak` takes a suffix argument.
565
+ if a == "-i" and i + 1 < len(args) and (args[i + 1] == ""
566
+ or args[i + 1].startswith(".")) \
567
+ and i + 2 < len(args):
568
+ i += 1
569
+ elif a in ("-e", "-f", "--expression", "--file"):
570
+ script_seen = True
571
+ i += 1
572
+ elif a.startswith("-"):
573
+ pass
574
+ elif not script_seen:
575
+ script_seen = True
576
+ else:
577
+ targets.append(a)
578
+ i += 1
579
+ if inplace:
580
+ for a in targets:
581
+ p = resolve_path(a, base)
582
+ if p:
583
+ writes.append(FileWrite(p, "w", None, "sed -i"))
584
+ prev_out = out
585
+ return writes
586
+
587
+
588
+ def planned_file_effects(writes: Iterable[FileWrite]) -> List[Tuple[str, Optional[Effect]]]:
589
+ """Per path: the effect we can key BEFORE running (None if unknown).
590
+
591
+ A path written once with known content in replace mode keys on the final
592
+ content (same key as write_file). A path only appended to with known
593
+ content keys on the appended text. Anything else is unknown until it ran.
594
+ """
595
+ by_path: dict = {}
596
+ for w in writes:
597
+ by_path.setdefault(w.path, []).append(w)
598
+ out = []
599
+ for path, ws in by_path.items():
600
+ target = file_target(path)
601
+ eff = None
602
+ if len(ws) == 1 and ws[0].mode == "w" and ws[0].content is not None:
603
+ h = content_hash(ws[0].content)
604
+ payload = f"content:{h}"
605
+ eff = Effect("bash", "write", target, payload, make_key("write", target, payload),
606
+ file_path=path, expected_post_hash=h)
607
+ elif all(w.mode == "a" and w.content is not None for w in ws):
608
+ appended = "".join(w.content for w in ws)
609
+ payload = f"append:{content_hash(appended)}"
610
+ eff = Effect("bash", "write", target, payload, make_key("write", target, payload),
611
+ file_path=path)
612
+ out.append((path, eff))
613
+ return out
614
+
615
+
616
+ def recorded_file_effect(path: str, writes: Sequence[FileWrite]) -> Optional[Effect]:
617
+ """Per path, AFTER running: the effect to record, keyed on what is on disk
618
+ (appends with known text keep the append key)."""
619
+ target = file_target(path)
620
+ ws = [w for w in writes if w.path == path]
621
+ if ws and all(w.mode == "a" and w.content is not None for w in ws):
622
+ payload = f"append:{content_hash(''.join(w.content for w in ws))}"
623
+ return Effect("bash", "write", target, payload, make_key("write", target, payload),
624
+ file_path=path)
625
+ h = file_hash(path)
626
+ if h is None:
627
+ return None
628
+ payload = f"content:{h}"
629
+ return Effect("bash", "write", target, payload, make_key("write", target, payload),
630
+ file_path=path, expected_post_hash=h)
631
+
632
+
633
+ # ───────────────────────── resources (S5) ─────────────────────────
634
+
635
+ @dataclass(frozen=True)
636
+ class Resource:
637
+ kind: str # "path" | "url" | "<field>" (ids) | "text" (bash command)
638
+ value: str
639
+
640
+ def __str__(self) -> str:
641
+ return f"{self.kind}:{self.value}"
642
+
643
+
644
+ _HOME_ROOT = re.compile(r"^/(?:Users|home)/[^/]+(?=/|$)|^/root(?=/|$)")
645
+ _PATHISH = re.compile(r"^(?:~|\.{1,2}/|/)|/|\.[A-Za-z0-9]{1,8}$")
646
+
647
+
648
+ def home_tail(path: str) -> Optional[str]:
649
+ """Path relative to a home directory (the real one or any /Users/<x>,
650
+ /home/<x>, /root); None when it is not under a home."""
651
+ h = home()
652
+ if path == h or path.startswith(h + "/"):
653
+ return path[len(h):].lstrip("/")
654
+ m = _HOME_ROOT.match(path)
655
+ if m:
656
+ return path[m.end():].lstrip("/")
657
+ # macOS realpath of /tmp etc. is not a home; a sandboxed HOME may live
658
+ # under /private/var/... and is covered by the first branch.
659
+ return None
660
+
661
+
662
+ def resources(spec_name: str, args: dict, base: Optional[str] = None) -> List[Resource]:
663
+ out: List[Resource] = []
664
+ if not isinstance(args, dict):
665
+ return out
666
+ if spec_name == "bash":
667
+ cmd = args.get("command")
668
+ if isinstance(cmd, str) and cmd.strip():
669
+ out.append(Resource("text", cmd))
670
+ try:
671
+ toks = tokenize(cmd)
672
+ except Exception:
673
+ toks = []
674
+ for t in toks:
675
+ if t.kind == "word" and not t.expansion and _PATHISH.search(t.value) \
676
+ and not t.value.startswith("-") and "://" not in t.value:
677
+ p = resolve_path(t.value, base)
678
+ if p:
679
+ out.append(Resource("path", p))
680
+ return out
681
+ for f in RESOURCE_PATH_FIELDS:
682
+ v = args.get(f)
683
+ for item in (v if isinstance(v, (list, tuple)) else [v]):
684
+ p = resolve_path(item, base) if isinstance(item, str) else None
685
+ if p:
686
+ out.append(Resource("path", p))
687
+ u = normalize_url(args.get("url"))
688
+ if u:
689
+ out.append(Resource("url", u))
690
+ for f in RESOURCE_ID_FIELDS:
691
+ v = args.get(f)
692
+ if _present(v):
693
+ out.append(Resource(f, str(v).strip()))
694
+ return out
695
+
696
+
697
+ def _stem(name: str) -> str:
698
+ return os.path.splitext(name)[0]
699
+
700
+
701
+ def touches(r: Resource, missing: Resource) -> bool:
702
+ """Is call resource `r` still about the not-found resource `missing`?"""
703
+ if missing.kind == "path":
704
+ m = missing.value
705
+ mbase = os.path.basename(m)
706
+ if r.kind == "text":
707
+ stem = _stem(mbase)
708
+ return bool(mbase) and (mbase in r.value or (len(stem) >= 6 and stem in r.value))
709
+ if r.kind != "path":
710
+ return False
711
+ p = r.value
712
+ if p == m:
713
+ return True
714
+ if mbase and os.path.basename(p) == mbase and home_tail(p) is not None:
715
+ return True # same file name anywhere under a home (invented paths)
716
+ # A directory above it below a home (also an invented home), or its
717
+ # direct parent anywhere: listing the folder to look for it.
718
+ pt, mt = home_tail(p), home_tail(m)
719
+ if pt and mt and mt.startswith(pt.rstrip("/") + "/"):
720
+ return True
721
+ if p == os.path.dirname(m) and p != "/":
722
+ return True
723
+ return False
724
+ return r.kind == missing.kind and r.value == missing.value