adloop 0.35.1__tar.gz → 0.36.0__tar.gz

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 (76) hide show
  1. {adloop-0.35.1 → adloop-0.36.0}/PKG-INFO +5 -3
  2. {adloop-0.35.1 → adloop-0.36.0}/README.md +4 -2
  3. {adloop-0.35.1 → adloop-0.36.0}/pyproject.toml +1 -1
  4. {adloop-0.35.1 → adloop-0.36.0}/pyproject.toml.orig +1 -1
  5. adloop-0.36.0/src/adloop/ads/campaign_settings.py +222 -0
  6. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/read.py +4 -0
  7. adloop-0.36.0/src/adloop/ads/recommendations.py +210 -0
  8. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/write.py +15 -2
  9. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/cli.py +10 -7
  10. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/adloop.md +4 -1
  11. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules_install.py +66 -22
  12. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/server.py +82 -4
  13. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/__init__.py +0 -0
  14. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/__main__.py +0 -0
  15. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/_mcp_patches.py +0 -0
  16. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/__init__.py +0 -0
  17. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/ai_max.py +0 -0
  18. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/assets.py +0 -0
  19. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/audiences.py +0 -0
  20. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/brands.py +0 -0
  21. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/client.py +0 -0
  22. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/conversion_actions.py +0 -0
  23. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/currency.py +0 -0
  24. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/custom_conversion_goals.py +0 -0
  25. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/enums.py +0 -0
  26. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/forecast.py +0 -0
  27. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/gaql.py +0 -0
  28. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/image_fetch.py +0 -0
  29. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/pmax.py +0 -0
  30. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/shopping.py +0 -0
  31. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/snippet_headers.py +0 -0
  32. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ads/validate_only.py +0 -0
  33. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/auth.py +0 -0
  34. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/config.py +0 -0
  35. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/crossref.py +0 -0
  36. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/diagnostics.py +0 -0
  37. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/__init__.py +0 -0
  38. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/client.py +0 -0
  39. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/reports.py +0 -0
  40. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/tracking.py +0 -0
  41. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/ga4/write.py +0 -0
  42. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/__init__.py +0 -0
  43. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/client.py +0 -0
  44. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gsc/reports.py +0 -0
  45. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/__init__.py +0 -0
  46. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/client.py +0 -0
  47. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/entities.py +0 -0
  48. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/read.py +0 -0
  49. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/gtm/write.py +0 -0
  50. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/__init__.py +0 -0
  51. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/client.py +0 -0
  52. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/merchant/read.py +0 -0
  53. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/pagespeed.py +0 -0
  54. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/__init__.py +0 -0
  55. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/auth.py +0 -0
  56. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/client.py +0 -0
  57. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/read.py +0 -0
  58. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/schedule.py +0 -0
  59. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/reddit/write.py +0 -0
  60. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/__init__.py +0 -0
  61. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/analyze-performance.md +0 -0
  62. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/budget-plan.md +0 -0
  63. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/create-ad.md +0 -0
  64. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/create-campaign.md +0 -0
  65. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/diagnose-tracking.md +0 -0
  66. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/rules/commands/optimize-campaign.md +0 -0
  67. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/runtime.py +0 -0
  68. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/__init__.py +0 -0
  69. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/approval.py +0 -0
  70. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/approval_card.html +0 -0
  71. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/audit.py +0 -0
  72. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/guards.py +0 -0
  73. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/safety/preview.py +0 -0
  74. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/tracking.py +0 -0
  75. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/upload_card.html +0 -0
  76. {adloop-0.35.1 → adloop-0.36.0}/src/adloop/uploads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.35.1
3
+ Version: 0.36.0
4
4
  Summary: The AI command center for Google Ads, Reddit Ads, GA4, and tracking code.
5
5
  Keywords: mcp,google-ads,reddit-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -148,6 +148,7 @@ The best features come from real workflows. If you're using AdLoop and find your
148
148
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
149
149
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
150
150
  | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
151
+ | `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
151
152
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
152
153
  | `run_gaql` | Arbitrary GAQL queries for anything else |
153
154
 
@@ -334,6 +335,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
334
335
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
335
336
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
336
337
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
338
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
337
339
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
338
340
  | `enable_entity` | Re-enable a paused entity |
339
341
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -523,8 +525,8 @@ adloop install-rules
523
525
 
524
526
  This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
525
527
 
526
- - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
527
- - **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
528
+ - **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
529
+ - **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
528
530
 
529
531
  To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
530
532
 
@@ -120,6 +120,7 @@ The best features come from real workflows. If you're using AdLoop and find your
120
120
  | `get_brand_list_brands` | List the brands inside a list, with the Commercial KG MID and the criterion ID for removals |
121
121
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes or targets |
122
122
  | `get_ai_max_settings` | AI Max knobs per campaign and ad group — `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list and each ad group's `disable_search_term_matching` |
123
+ | `get_campaign_settings` | One flat record per Search campaign — networks, location option, campaign-level broad match, bidding, budget, URL options and AI Max — with the settings that widen reach flagged |
123
124
  | `get_custom_conversion_goals` | Custom conversion goals with their actions, plus one campaign's goal config |
124
125
  | `run_gaql` | Arbitrary GAQL queries for anything else |
125
126
 
@@ -306,6 +307,7 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
306
307
  | `draft_update_custom_conversion_goal` | Rename a custom conversion goal and/or replace its action list (list replace, not append) |
307
308
  | `draft_assign_custom_conversion_goal` | Point a campaign at a custom conversion goal (`goal_config_level = CAMPAIGN`) |
308
309
  | `draft_clear_custom_conversion_goal` | Put a campaign back on the account-level goals (rollback) |
310
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations you won't take, so they stop reappearing (Google can show them again later) |
309
311
  | `pause_entity` | Pause a campaign, ad group, ad, keyword or PMax asset group |
310
312
  | `enable_entity` | Re-enable a paused entity |
311
313
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -495,8 +497,8 @@ adloop install-rules
495
497
 
496
498
  This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes:
497
499
 
498
- - **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session.
499
- - **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope.
500
+ - **inline** (default for `install-rules`) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable, but they load into every Claude Code session in every repository, about 30K tokens, whether AdLoop is connected or not.
501
+ - **lazy** (`adloop install-rules --lazy`, and the default in `adloop init`) — a short directive in `CLAUDE.md` pointing at `~/.adloop/rules/adloop.md`. The LLM reads the rules file only when AdLoop tools are in scope, so sessions without AdLoop stay almost free. The file sits outside `~/.claude/` on purpose: Claude Code loads everything in `~/.claude/rules/` into every session.
500
502
 
501
503
  To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content.
502
504
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.1"
3
+ version = "0.36.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.35.1"
3
+ version = "0.36.0"
4
4
  description = "The AI command center for Google Ads, Reddit Ads, GA4, and tracking code."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -0,0 +1,222 @@
1
+ """One read for the settings that decide how far a Search campaign reaches.
2
+
3
+ A recurring audit ("has Google switched anything back on?") otherwise needs
4
+ ``get_ai_max_settings`` plus hand-written GAQL for networks, location option,
5
+ campaign-level broad match and URL options. Every field below was checked
6
+ against the v25 ``campaign`` resource:
7
+
8
+ campaign.network_settings.target_google_search / target_search_network /
9
+ target_content_network / target_partner_search_network
10
+ campaign.geo_target_type_setting.positive_geo_target_type PRESENCE_OR_INTEREST,
11
+ SEARCH_INTEREST, PRESENCE
12
+ campaign.geo_target_type_setting.negative_geo_target_type PRESENCE_OR_INTEREST, PRESENCE
13
+ campaign.keyword_match_type BROAD, or unset
14
+ campaign.tracking_url_template, final_url_suffix, url_custom_parameters
15
+ campaign.bidding_strategy_type and the per-strategy targets
16
+ campaign_budget.amount_micros, explicitly_shared
17
+
18
+ The AI Max half comes from ``ai_max.read_ai_max_state`` unchanged, so both tools
19
+ always agree. Read-only: nothing here mutates.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from adloop.config import AdLoopConfig
28
+
29
+ _SETTINGS_SELECT = """
30
+ SELECT campaign.id, campaign.name, campaign.status,
31
+ campaign.advertising_channel_type,
32
+ campaign.bidding_strategy_type, campaign.bidding_strategy,
33
+ campaign.target_spend.cpc_bid_ceiling_micros,
34
+ campaign.maximize_conversions.target_cpa_micros,
35
+ campaign.target_cpa.target_cpa_micros,
36
+ campaign.target_roas.target_roas,
37
+ campaign.maximize_conversion_value.target_roas,
38
+ campaign.network_settings.target_google_search,
39
+ campaign.network_settings.target_search_network,
40
+ campaign.network_settings.target_content_network,
41
+ campaign.geo_target_type_setting.positive_geo_target_type,
42
+ campaign.geo_target_type_setting.negative_geo_target_type,
43
+ campaign.keyword_match_type,
44
+ campaign.tracking_url_template, campaign.final_url_suffix,
45
+ campaign.url_custom_parameters,
46
+ campaign_budget.amount_micros, campaign_budget.explicitly_shared
47
+ FROM campaign
48
+ WHERE {where}
49
+ ORDER BY campaign.name
50
+ """
51
+
52
+ _SEARCH_ONLY = "campaign.advertising_channel_type = 'SEARCH'"
53
+ _WHERE_ALL = f"campaign.status != 'REMOVED' AND {_SEARCH_ONLY}"
54
+
55
+ # Google's default; it also reaches people elsewhere who search *about* the
56
+ # targeted locations.
57
+ _WIDE_LOCATION_OPTIONS = {"PRESENCE_OR_INTEREST", "SEARCH_INTEREST"}
58
+
59
+
60
+ def _money(micros: object) -> float | None:
61
+ """Micros to account currency; 0 or missing means "not set"."""
62
+ if not micros:
63
+ return None
64
+ return round(int(micros) / 1_000_000, 2)
65
+
66
+
67
+ def _target_cpa(row: dict) -> float | None:
68
+ return _money(
69
+ row.get("campaign.target_cpa.target_cpa_micros")
70
+ or row.get("campaign.maximize_conversions.target_cpa_micros")
71
+ )
72
+
73
+
74
+ def _target_roas(row: dict) -> float | None:
75
+ value = row.get("campaign.target_roas.target_roas") or row.get(
76
+ "campaign.maximize_conversion_value.target_roas"
77
+ )
78
+ return value or None
79
+
80
+
81
+ def _custom_parameters(value: object) -> list[dict]:
82
+ if not isinstance(value, list):
83
+ return []
84
+ return [
85
+ {"key": item.get("key", ""), "value": item.get("value", "")}
86
+ for item in value
87
+ if isinstance(item, dict)
88
+ ]
89
+
90
+
91
+ def _widening(campaign: dict) -> list[str]:
92
+ """The settings on this campaign that let ads reach beyond what was chosen."""
93
+ found: list[str] = []
94
+ if campaign["search_partners"]:
95
+ found.append("search partners on")
96
+ if campaign["display_network"]:
97
+ found.append("Display network on")
98
+ if campaign["location_option"] in _WIDE_LOCATION_OPTIONS:
99
+ found.append(f"location option {campaign['location_option']}")
100
+ if campaign["keyword_match_type"] == "BROAD":
101
+ found.append("campaign-level broad match on")
102
+ if campaign["enable_ai_max"]:
103
+ found.append("AI Max on")
104
+ for automation, status in campaign["asset_automation"].items():
105
+ if status == "OPTED_IN":
106
+ found.append(f"{automation} opted in")
107
+ return found
108
+
109
+
110
+ def get_campaign_settings(
111
+ config: AdLoopConfig,
112
+ *,
113
+ customer_id: str = "",
114
+ campaign_id: str = "",
115
+ ) -> dict:
116
+ """Read-only, one flat record per Search campaign. See the module docstring."""
117
+ from adloop.ads.client import get_ads_client, normalize_customer_id
118
+ from adloop.ads.currency import get_currency_code
119
+
120
+ campaign_id = str(campaign_id or "").strip()
121
+ if campaign_id and not campaign_id.isdigit():
122
+ return {"error": "campaign_id must be a numeric ID"}
123
+
124
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
125
+ client = get_ads_client(config)
126
+ result = read_campaign_settings(client, cid, campaign_id=campaign_id)
127
+ result["currency"] = get_currency_code(config, cid)
128
+ return result
129
+
130
+
131
+ def read_campaign_settings(client: object, cid: str, *, campaign_id: str = "") -> dict:
132
+ """Run both reads and merge them per campaign."""
133
+ from adloop.ads.ai_max import _search, read_ai_max_state
134
+
135
+ if campaign_id and not str(campaign_id).isdigit():
136
+ # The id goes into the query text; anything else must not reach GAQL.
137
+ raise ValueError("campaign_id must be a numeric ID")
138
+ # An explicit ID stays Search-only too: the AI Max and location settings
139
+ # read here don't apply to Performance Max or Display campaigns.
140
+ where = f"campaign.id = {campaign_id} AND {_SEARCH_ONLY}" if campaign_id else _WHERE_ALL
141
+ rows = _search(
142
+ client.get_service("GoogleAdsService"), cid, _SETTINGS_SELECT.format(where=where)
143
+ )
144
+ ai_max = {
145
+ c["campaign_id"]: c
146
+ for c in read_ai_max_state(client, cid, campaign_id=campaign_id)["campaigns"]
147
+ }
148
+
149
+ campaigns: list[dict] = []
150
+ for row in rows:
151
+ key = str(row.get("campaign.id", ""))
152
+ if not key or (campaign_id and key != str(campaign_id)):
153
+ continue
154
+ ai = ai_max.get(key, {})
155
+ campaign = {
156
+ "campaign_id": key,
157
+ "campaign_name": row.get("campaign.name"),
158
+ "status": row.get("campaign.status"),
159
+ "advertising_channel_type": row.get("campaign.advertising_channel_type"),
160
+ "bidding_strategy_type": row.get("campaign.bidding_strategy_type"),
161
+ "portfolio_bidding_strategy": row.get("campaign.bidding_strategy") or None,
162
+ "cpc_bid_ceiling": _money(
163
+ row.get("campaign.target_spend.cpc_bid_ceiling_micros")
164
+ ),
165
+ "target_cpa": _target_cpa(row),
166
+ "target_roas": _target_roas(row),
167
+ "daily_budget": _money(row.get("campaign_budget.amount_micros")),
168
+ "budget_shared": bool(row.get("campaign_budget.explicitly_shared")),
169
+ "google_search": bool(row.get("campaign.network_settings.target_google_search")),
170
+ "search_partners": bool(
171
+ row.get("campaign.network_settings.target_search_network")
172
+ ),
173
+ "display_network": bool(
174
+ row.get("campaign.network_settings.target_content_network")
175
+ ),
176
+ "location_option": row.get(
177
+ "campaign.geo_target_type_setting.positive_geo_target_type"
178
+ ),
179
+ "negative_location_option": row.get(
180
+ "campaign.geo_target_type_setting.negative_geo_target_type"
181
+ ),
182
+ "keyword_match_type": row.get("campaign.keyword_match_type"),
183
+ "tracking_url_template": row.get("campaign.tracking_url_template") or "",
184
+ "final_url_suffix": row.get("campaign.final_url_suffix") or "",
185
+ "url_custom_parameters": _custom_parameters(
186
+ row.get("campaign.url_custom_parameters")
187
+ ),
188
+ "enable_ai_max": bool(ai.get("enable_ai_max")),
189
+ "asset_automation": {
190
+ item["asset_automation_type"]: item.get("asset_automation_status", "")
191
+ for item in ai.get("asset_automation_settings", [])
192
+ },
193
+ # Search term matching only acts under AI Max; listing every ad
194
+ # group's default False otherwise would be noise.
195
+ "ad_groups_with_search_term_matching": [
196
+ g["ad_group_name"]
197
+ for g in ai.get("ad_groups", [])
198
+ if g.get("disable_search_term_matching") is False
199
+ ] if ai.get("enable_ai_max") else [],
200
+ }
201
+ campaign["widening_settings"] = _widening(campaign)
202
+ campaigns.append(campaign)
203
+
204
+ insights: list[str] = []
205
+ for campaign in campaigns:
206
+ if campaign["widening_settings"]:
207
+ insights.append(
208
+ f"{campaign['campaign_name']} ({campaign['campaign_id']}): "
209
+ + ", ".join(campaign["widening_settings"])
210
+ + ". These let the ads reach beyond the keywords and locations "
211
+ "chosen; confirm they are intended."
212
+ )
213
+ if campaigns and not insights:
214
+ insights.append("No setting that widens reach is on in any campaign returned.")
215
+ if not campaigns:
216
+ insights.append("No non-removed Search campaign matched.")
217
+
218
+ return {
219
+ "campaigns": campaigns,
220
+ "total_campaigns": len(campaigns),
221
+ "insights": insights,
222
+ }
@@ -843,6 +843,10 @@ def get_recommendations(
843
843
  pot_conversions = _round_metric(getattr(pot, "conversions", 0))
844
844
 
845
845
  entry = {
846
+ # The ID (last segment of the resource name) is what
847
+ # draft_dismiss_recommendations takes.
848
+ "recommendation.id": rec.resource_name.rsplit("/", 1)[-1],
849
+ "recommendation.resource_name": rec.resource_name,
846
850
  "recommendation.type": rec_type,
847
851
  "recommendation.campaign": rec.campaign,
848
852
  "recommendation.ad_group": rec.ad_group or "",
@@ -0,0 +1,210 @@
1
+ """Dismiss Google Ads recommendations.
2
+
3
+ ``get_recommendations`` (in ``ads/read.py``) lists the active ones; this module
4
+ turns a chosen subset down. The v25 call (verified against the SDK):
5
+
6
+ RecommendationService.DismissRecommendation
7
+ DismissRecommendationRequest{customer_id, operations[], partial_failure}
8
+ DismissRecommendationOperation{resource_name}
9
+
10
+ The request has **no** ``validate_only`` field, so a dry run cannot hand the
11
+ exact call to Google the way other Google Ads writes do. Instead the dry run
12
+ re-reads the account and checks that every planned recommendation is still
13
+ active (``preflight``), the same approach the Reddit, Tag Manager and GA4
14
+ writes use. The validate-only client still refuses the call outright, so this
15
+ path can never dismiss anything during a dry run.
16
+
17
+ Dismissing is mild: nothing in the account changes, and Google can show the
18
+ recommendation again later. A single confirmation is enough.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from typing import TYPE_CHECKING
25
+
26
+ if TYPE_CHECKING:
27
+ from adloop.config import AdLoopConfig
28
+
29
+ OPERATION = "dismiss_recommendations"
30
+
31
+ # A recommendation ID is the last segment of its resource name. Google does not
32
+ # document the alphabet, so accept what resource names can carry and nothing
33
+ # that could break out of one.
34
+ _ID_RE = re.compile(r"^[A-Za-z0-9_~=+-]+$")
35
+ _RESOURCE_RE = re.compile(r"^customers/(\d+)/recommendations/([A-Za-z0-9_~=+-]+)$")
36
+
37
+
38
+ def recommendation_id(resource_name: str) -> str:
39
+ """``customers/1/recommendations/abc`` -> ``abc``."""
40
+ return str(resource_name or "").rsplit("/", 1)[-1]
41
+
42
+
43
+ def _clean_ids(values: list[str] | None, cid: str) -> tuple[list[str], list[str]]:
44
+ """Accept bare IDs or full resource names. Returns (errors, deduped IDs)."""
45
+ errors: list[str] = []
46
+ ids: list[str] = []
47
+ for raw in values or []:
48
+ value = str(raw or "").strip()
49
+ if not value:
50
+ continue
51
+ match = _RESOURCE_RE.match(value)
52
+ if match:
53
+ if match.group(1) != cid:
54
+ errors.append(
55
+ f"{value} belongs to account {match.group(1)}, not {cid}"
56
+ )
57
+ continue
58
+ value = match.group(2)
59
+ elif not _ID_RE.match(value):
60
+ errors.append(f"'{value}' is not a recommendation ID or resource name")
61
+ continue
62
+ if value not in ids:
63
+ ids.append(value)
64
+ return errors, ids
65
+
66
+
67
+ def _active_by_id(config: AdLoopConfig, customer_id: str) -> dict[str, dict]:
68
+ """Every active (not dismissed) recommendation, keyed by ID."""
69
+ from adloop.ads.read import get_recommendations
70
+
71
+ rows = get_recommendations(config, customer_id=customer_id).get(
72
+ "recommendations", []
73
+ )
74
+ return {
75
+ recommendation_id(row.get("recommendation.resource_name", "")): row
76
+ for row in rows
77
+ if row.get("recommendation.resource_name")
78
+ }
79
+
80
+
81
+ def draft_dismiss_recommendations(
82
+ config: AdLoopConfig,
83
+ *,
84
+ customer_id: str = "",
85
+ recommendation_ids: list[str] | None = None,
86
+ ) -> dict:
87
+ """Draft dismissing recommendations — returns PREVIEW.
88
+
89
+ Reads the active recommendations first: an ID that is unknown or already
90
+ dismissed is refused by name, so the preview only ever lists what the
91
+ apply will turn down. Each entry carries its type, campaign and Google's
92
+ estimated improvement, the same numbers ``get_recommendations`` shows.
93
+
94
+ Call ``confirm_and_apply`` with the returned plan_id to execute.
95
+ """
96
+ from adloop.ads.client import normalize_customer_id
97
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
98
+ from adloop.safety.preview import ChangePlan, store_plan
99
+
100
+ try:
101
+ check_blocked_operation(OPERATION, config.safety)
102
+ except SafetyViolation as e:
103
+ return {"error": str(e)}
104
+
105
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
106
+ errors, ids = _clean_ids(recommendation_ids, cid)
107
+ if not ids and not errors:
108
+ errors.append("At least one recommendation_id is required")
109
+ if errors:
110
+ return {"error": "Validation failed", "details": errors}
111
+
112
+ active = _active_by_id(config, customer_id)
113
+ missing = [rec_id for rec_id in ids if rec_id not in active]
114
+ if missing:
115
+ return {
116
+ "error": "Validation failed",
117
+ "details": [
118
+ "Not an active recommendation in this account (unknown, or "
119
+ "already dismissed): " + ", ".join(missing)
120
+ + ". get_recommendations lists the active ones with their IDs."
121
+ ],
122
+ }
123
+
124
+ recommendations = [
125
+ {
126
+ "recommendation_id": rec_id,
127
+ "resource_name": active[rec_id]["recommendation.resource_name"],
128
+ "type": active[rec_id].get("recommendation.type", ""),
129
+ "campaign": active[rec_id].get("recommendation.campaign", ""),
130
+ "ad_group": active[rec_id].get("recommendation.ad_group", ""),
131
+ "estimated_improvement": active[rec_id].get("estimated_improvement", {}),
132
+ }
133
+ for rec_id in ids
134
+ ]
135
+ plan = ChangePlan(
136
+ operation=OPERATION,
137
+ entity_type="recommendation",
138
+ entity_id=",".join(ids),
139
+ customer_id=customer_id,
140
+ changes={
141
+ "recommendations": recommendations,
142
+ "count": len(recommendations),
143
+ "note": (
144
+ "Dismissing changes nothing in the account. Google can show a "
145
+ "dismissed recommendation again later."
146
+ ),
147
+ },
148
+ )
149
+ store_plan(plan)
150
+ return plan.to_preview()
151
+
152
+
153
+ def preflight(config: AdLoopConfig, plan: object) -> dict:
154
+ """Dry run: every planned recommendation must still be active.
155
+
156
+ DismissRecommendation has no validate-only mode, so this re-read is the
157
+ whole check. Raises when any recommendation has gone (dismissed in the UI,
158
+ applied, or expired), so the dry run fails instead of reporting success.
159
+ """
160
+ planned = [r["recommendation_id"] for r in plan.changes["recommendations"]]
161
+ active = _active_by_id(config, plan.customer_id)
162
+ gone = [rec_id for rec_id in planned if rec_id not in active]
163
+ if gone:
164
+ raise ValueError(
165
+ "No longer active (dismissed, applied or expired since the "
166
+ "preview): " + ", ".join(gone) + ". Draft again."
167
+ )
168
+ return {"recommendations_checked": len(planned), "still_active": len(planned)}
169
+
170
+
171
+ def _apply_dismiss_recommendations(client: object, cid: str, changes: dict) -> dict:
172
+ """Dismiss the planned recommendations; per-recommendation errors are reported."""
173
+ from adloop.ads.write import _parse_partial_failure_per_op
174
+
175
+ names = [r["resource_name"] for r in changes["recommendations"]]
176
+ service = client.get_service("RecommendationService")
177
+ request = client.get_type("DismissRecommendationRequest")
178
+ request.customer_id = cid
179
+ request.partial_failure = True
180
+ operation_type = type(request).DismissRecommendationOperation
181
+ for name in names:
182
+ request.operations.append(operation_type(resource_name=name))
183
+
184
+ response = service.dismiss_recommendation(request=request)
185
+
186
+ pf_error = getattr(response, "partial_failure_error", None)
187
+ per_op_errors = _parse_partial_failure_per_op(client, pf_error)
188
+ dismissed: list[str] = []
189
+ failed: list[dict] = []
190
+ for index, result in enumerate(response.results):
191
+ if result.resource_name:
192
+ dismissed.append(result.resource_name)
193
+ else:
194
+ failed.append(
195
+ {
196
+ "recommendation_id": recommendation_id(names[index]),
197
+ "error": per_op_errors.get(
198
+ index, "Unknown error (see partial_failure_message)"
199
+ ),
200
+ }
201
+ )
202
+
203
+ out: dict = {"dismissed": dismissed, "dismissed_count": len(dismissed)}
204
+ if failed:
205
+ out["partial_failure"] = True
206
+ out["failed"] = failed
207
+ message = getattr(pf_error, "message", "") if pf_error is not None else ""
208
+ if message:
209
+ out["partial_failure_message"] = message
210
+ return out
@@ -2717,6 +2717,9 @@ def confirm_and_apply(
2717
2717
  is_reddit = plan.operation.startswith("reddit_")
2718
2718
  is_gtm = plan.operation.startswith("gtm_")
2719
2719
  is_ga4 = plan.operation in GA4_OPERATIONS
2720
+ # Google Ads calls without a validate_only field: the dry run re-reads the
2721
+ # target instead, like the platforms above.
2722
+ is_ads_reread = plan.operation == "dismiss_recommendations"
2720
2723
  platform_label = (
2721
2724
  "Reddit Ads" if is_reddit
2722
2725
  else "Google Tag Manager" if is_gtm
@@ -2749,6 +2752,12 @@ def confirm_and_apply(
2749
2752
  from adloop.ga4.write import preflight as ga4_preflight
2750
2753
 
2751
2754
  preflight_checks = ga4_preflight(config, plan)
2755
+ elif is_ads_reread:
2756
+ # DismissRecommendation has no validate_only field, and the
2757
+ # validate-only client refuses it; re-read the targets instead.
2758
+ from adloop.ads.recommendations import preflight as rec_preflight
2759
+
2760
+ preflight_checks = rec_preflight(config, plan)
2752
2761
  else:
2753
2762
  # Google Ads checks the exact mutates with validate_only=True
2754
2763
  # and executes nothing.
@@ -2784,7 +2793,7 @@ def confirm_and_apply(
2784
2793
  )
2785
2794
  checked_against = (
2786
2795
  f"re-checked the target against {platform_label}"
2787
- if is_reddit or is_gtm or is_ga4
2796
+ if is_reddit or is_gtm or is_ga4 or is_ads_reread
2788
2797
  else "sent the change to Google Ads in validate-only mode"
2789
2798
  )
2790
2799
  return {
@@ -2835,7 +2844,9 @@ def confirm_and_apply(
2835
2844
  if preflight_checks is not None:
2836
2845
  response["checks"] = preflight_checks
2837
2846
  response["note"] = (
2838
- f"{platform_label} has no validate-only mode: the dry run "
2847
+ f"{platform_label} has no validate-only mode"
2848
+ + (" for dismissing recommendations" if is_ads_reread else "")
2849
+ + ": the dry run "
2839
2850
  "re-read the target and re-checked the safety gates; nothing "
2840
2851
  "was changed live."
2841
2852
  + (
@@ -4089,6 +4100,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
4089
4100
  from adloop.ads.pmax import apply_asset_group_assets as _apply_asset_group_assets
4090
4101
  from adloop.ads.pmax import apply_asset_group_signals as _apply_asset_group_signals
4091
4102
  from adloop.ads.pmax import apply_pmax_product_filter as _apply_pmax_product_filter
4103
+ from adloop.ads.recommendations import _apply_dismiss_recommendations
4092
4104
  # Custom conversion goals live in their own module for the same reason.
4093
4105
  from adloop.ads.custom_conversion_goals import (
4094
4106
  _apply_assign_custom_conversion_goal,
@@ -4123,6 +4135,7 @@ def _dispatch_ads_plan(client: object, cid: str, plan: object) -> dict:
4123
4135
  "attach_brand_list_to_campaigns": _apply_attach_brand_list_to_campaigns,
4124
4136
  "detach_brand_list_from_campaigns": _apply_detach_brand_list_from_campaigns,
4125
4137
  "update_ai_max_settings": _apply_ai_max_settings,
4138
+ "dismiss_recommendations": _apply_dismiss_recommendations,
4126
4139
  "create_custom_conversion_goal": _apply_create_custom_conversion_goal,
4127
4140
  "update_custom_conversion_goal": _apply_update_custom_conversion_goal,
4128
4141
  "assign_custom_conversion_goal": _apply_assign_custom_conversion_goal,
@@ -995,7 +995,7 @@ def _wizard_finish(
995
995
 
996
996
  def _maybe_offer_global_rules_install() -> None:
997
997
  """If Claude is detected, offer to install rules globally during init."""
998
- from adloop.rules_install import detect_clients, install_rules
998
+ from adloop.rules_install import detect_clients, install_rules, rules_token_estimate
999
999
 
1000
1000
  clients = detect_clients()
1001
1001
  if not clients:
@@ -1017,16 +1017,19 @@ def _maybe_offer_global_rules_install() -> None:
1017
1017
  _print(" Skipping. Run `adloop install-rules` later if you change your mind.")
1018
1018
  return
1019
1019
 
1020
+ # Lazy is the default: inline puts the full rules into every Claude Code
1021
+ # session in every repository, AdLoop or not (#114).
1022
+ tokens = rules_token_estimate() // 1000
1020
1023
  _print()
1021
1024
  _print(" Install mode:")
1022
- _print(" 1. inline (default) — full rules in ~/.claude/CLAUDE.md")
1023
- _print(" Reliable; loaded every Claude Code session (~10K tokens).")
1024
- _print(" 2. lazy — small directive in CLAUDE.md, full rules in")
1025
- _print(" ~/.claude/rules/adloop.md, loaded only when AdLoop is active.")
1026
- _print(" Cheaper baseline cost; slightly less reliable.")
1025
+ _print(" 1. lazy (default) — a short pointer in ~/.claude/CLAUDE.md; Claude")
1026
+ _print(" reads the full rules (~/.adloop/rules/adloop.md) only when")
1027
+ _print(" AdLoop tools are in use. Almost free in sessions without AdLoop.")
1028
+ _print(" 2. inline — the full rules in ~/.claude/CLAUDE.md, loaded into")
1029
+ _print(f" every Claude Code session (about {tokens}K tokens). Most reliable.")
1027
1030
  _print()
1028
1031
  raw = input(" Choose [1/2, default 1]: ").strip()
1029
- mode = "lazy" if raw == "2" else "inline"
1032
+ mode = "inline" if raw == "2" else "lazy"
1030
1033
 
1031
1034
  _print()
1032
1035
  results = install_rules(mode=mode)
@@ -70,6 +70,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
70
70
  | `get_brand_list_brands` | Contents of a brand list, with `entity_id` (the MID) and `criterion_id` (needed for removal) | `shared_set_id` (required) |
71
71
  | `get_brand_list_campaigns` | Which campaigns a brand list is attached to, and whether each attachment excludes (`role: excluded`) or targets (`role: targeted`) | `shared_set_id` (optional) |
72
72
  | `get_ai_max_settings` | The AI Max knobs of Search campaigns: `enable_ai_max`, `bundling_required`, the full `asset_automation_settings` list, and each ad group's `disable_search_term_matching` | `campaign_id` (optional) |
73
+ | `get_campaign_settings` | Every setting that decides how far a Search campaign reaches, one flat record per campaign: networks, location option, campaign-level broad match, bidding and budget, tracking template / URL suffix / custom parameters, and the AI Max state. `widening_settings` and `insights[]` name anything that lets ads reach beyond the chosen keywords and locations. The one call for a recurring "did Google switch something on?" audit | `campaign_id` (optional) |
73
74
  | `get_custom_conversion_goals` | Custom conversion goals (named sets of conversion actions) and one campaign's goal config | `campaign_id` (optional) |
74
75
  | `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) |
75
76
 
@@ -79,7 +80,7 @@ You have access to AdLoop MCP tools that connect Google Ads, Reddit Ads and Goog
79
80
  - `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs, plus `ad_group_ad.policy_summary.approval_status`, `review_status` and `policy_topics` (topic names only) per ad. Ads that are `DISAPPROVED` (cannot serve) or `APPROVED_LIMITED` (serving restricted) are listed in `policy_issues` with their spend and flagged in `insights[]`; compact mode adds `by_approval_status` counts.
80
81
  - `get_campaign_performance` returns `metrics.search_impression_share`, `metrics.search_budget_lost_impression_share` and `metrics.search_rank_lost_impression_share` as fractions (0.25 = 25%) with matching `*_pct` percentages. Google caps the reported values: 0.0999 means below 10% share, 0.9001 means above 90% lost. They are `null` for campaigns that do not serve on Search (Display, Video, PMax, ...) or had no impressions, which means "does not apply", not zero. `budget_limited_converters` and `insights[]` flag converting campaigns losing 20% or more of impressions to budget: those can absorb more budget, rank-lost share instead points at bids, ad relevance and quality.
81
82
  - `get_change_history` returns changes newest first with counts `by_resource_type`, `by_client`, `by_user` and `by_day`. Google Ads keeps 30 days of change history, so an older start date is moved forward (see `notes`). `insights[]` flag auto-applied recommendations and budget or bidding changes. `truncated: true` means the limit was hit; narrow the range or filters to see older changes.
82
- - `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag budget-increase recommendations, whose projected gain comes from spending more.
83
+ - `get_recommendations` returns each recommendation's `recommendation.id` (what `draft_dismiss_recommendations` takes) and `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag budget-increase recommendations, whose projected gain comes from spending more.
83
84
  - PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API.
84
85
  - `get_pmax_assets` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in the Google Ads API. Use `get_detailed_asset_performance` for quality signals via top combinations.
85
86
  - `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. When the results include SEARCH campaigns, `insights[]` reminds you that custom segments cannot be attached to them (see the compatibility matrix below) — relay that constraint instead of proposing impossible pairings.
@@ -317,6 +318,7 @@ Conversions that happen offline (a sales call, a lead that turned into a deal) g
317
318
  | `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` |
318
319
  | `attach_shared_set_to_campaigns` | Attach an EXISTING shared set (e.g. shared negative keyword list) to one or more campaigns. Use after creating a campaign to inherit pre-built negatives. | `shared_set_id` (from `get_negative_keyword_lists`), `campaign_ids` list |
319
320
  | `detach_shared_set_from_campaigns` | Detach a shared set from one or more campaigns. Removes only the linkage; the shared set and its keywords stay intact. | `shared_set_id`, `campaign_ids` list |
321
+ | `draft_dismiss_recommendations` | Dismiss Google recommendations the user has decided not to take. Nothing in the account changes and Google can show them again later; unknown or already dismissed IDs are refused. No validate-only mode: the dry run re-reads the account and checks each one is still active | `recommendation_ids` (`recommendation.id` from `get_recommendations`) |
320
322
  | `draft_demographic_targeting` | Propose demographic criteria (age, gender, parental status, income range) at ad group or campaign level. Defaults to EXCLUSION (`negative=True`). | exactly one of `ad_group_id` or `campaign_id`, at least one of `age_ranges`/`genders`/`parental_statuses`/`income_ranges`, optional `negative` (default True) |
321
323
  | `draft_audience_attachment` | Attach audiences to a campaign or ad group as OBSERVATION (default: bids and reporting, reach unchanged), TARGETING (ads only reach the audiences; double confirmation) or exclusion. Refuses incompatible pairings, Search lists too small to target, audiences at both campaign and ad group level, and Performance Max | exactly one of `campaign_id` or `ad_group_id`; any of `user_list_ids`, `segment_ids`, `custom_audience_ids`, `combined_audience_ids`; `mode` observation/targeting; `exclude` |
322
324
  | `draft_ai_max_settings` | Draft AI Max controls for one Search campaign: `enable_ai_max`, per-ad-group `disable_search_term_matching`, text and final-URL asset automation. Refuses a plan that would switch AI Max on while an automation it was never told about keeps running; an explicitly requested automation asks for a double confirmation. | `campaign_id`, optional `enable_ai_max`, optional `disable_search_term_matching`, optional `ad_group_ids`, optional `include_paused_ad_groups`, optional `text_asset_automation`, optional `final_url_expansion` |
@@ -657,6 +659,7 @@ Reddit's structure is campaign → ad group (budget, bid, pixel, targeting) →
657
659
  4. Use `estimated_improvement` to prioritize: recommendations with >1 estimated additional conversion are worth investigating
658
660
  5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting
659
661
  6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue
662
+ 7. For the ones the user decides not to take, offer `draft_dismiss_recommendations` with their IDs, so they stop reappearing. Dismiss only what the user turned down: a dismissal is not a verdict you make on their behalf
660
663
 
661
664
  ### When user asks about audience performance or targeting
662
665
 
@@ -20,12 +20,14 @@ on upgrade. Anything outside the sentinel pair is preserved verbatim.
20
20
  Two install modes:
21
21
 
22
22
  - **inline** (default): the full rules text is pasted between the sentinels
23
- in ``~/.claude/CLAUDE.md``. Reliable but ~10K tokens loaded every Claude
24
- Code session.
23
+ in ``~/.claude/CLAUDE.md``. Reliable, but loaded into every Claude Code
24
+ session in every repository (:func:`rules_token_estimate`, about 30K tokens).
25
25
  - **lazy**: a small directive is written between the sentinels pointing at
26
- ``~/.claude/rules/adloop.md`` (which contains the full rules). The LLM is
26
+ ``~/.adloop/rules/adloop.md`` (which contains the full rules). The LLM is
27
27
  instructed to read the rules file only when AdLoop tools are in scope.
28
- Cheaper baseline cost, less reliable.
28
+ The file deliberately lives outside ``~/.claude/``: Claude Code loads every
29
+ file in ``~/.claude/rules/`` into every session, which is where lazy mode
30
+ used to write it (#114). Cheaper baseline cost, less reliable.
29
31
 
30
32
  Slash commands are installed namespaced (``adloop-*``) so they don't collide
31
33
  with user-defined commands of the same name.
@@ -61,11 +63,18 @@ _SENTINEL_BLOCK_RE = re.compile(
61
63
 
62
64
  InstallMode = Literal["inline", "lazy"]
63
65
 
66
+ # Where lazy mode keeps the full rules, relative to the home directory. Not
67
+ # under ~/.claude/: Claude Code auto-loads everything in ~/.claude/rules/.
68
+ LAZY_RULES_RELATIVE = Path(".adloop") / "rules" / "adloop.md"
69
+ # Where lazy mode wrote them before #114. Cleaned up when it is ours.
70
+ _LEGACY_LAZY_RULES_RELATIVE = Path(".claude") / "rules" / "adloop.md"
71
+ _RULES_HEADING = "# AdLoop — AI Orchestration Rules"
72
+
64
73
  # The directive used in lazy mode. Kept short to minimise baseline token cost.
65
74
  _LAZY_DIRECTIVE = """\
66
75
  **AdLoop orchestration**: if MCP tools like `get_campaign_performance`,
67
76
  `run_gaql`, `draft_responsive_search_ad`, or other AdLoop tools are available
68
- in this session, read `~/.claude/rules/adloop.md` for the full safety rules,
77
+ in this session, read `~/.adloop/rules/adloop.md` for the full safety rules,
69
78
  orchestration patterns, and GAQL reference before using them. Skip this read
70
79
  if AdLoop is not active in the current session.
71
80
  """
@@ -182,7 +191,7 @@ def _read_bundled_rules() -> str:
182
191
  """Load the bundled rules markdown shipped with the package.
183
192
 
184
193
  Returns the file as-is (with its YAML frontmatter intact) since lazy mode
185
- writes it to ``~/.claude/rules/adloop.md`` where the frontmatter is
194
+ writes it to ``~/.adloop/rules/adloop.md`` where the frontmatter is
186
195
  meaningful. For inline mode, use :func:`_read_bundled_rules_body`.
187
196
  """
188
197
  ref = importlib.resources.files("adloop.rules").joinpath("adloop.md")
@@ -203,6 +212,38 @@ def _read_bundled_rules_body() -> str:
203
212
  return text[end + 3:].lstrip("\n")
204
213
 
205
214
 
215
+ def rules_token_estimate() -> int:
216
+ """Rough token cost of the inline rules, measured from the bundled file.
217
+
218
+ About four bytes per token, rounded to the nearest thousand, so the
219
+ wizard and README never quote a stale hard-coded figure again.
220
+ """
221
+ size = len(_read_bundled_rules_body().encode("utf-8"))
222
+ return max(1000, round(size / 4 / 1000) * 1000)
223
+
224
+
225
+ def _home_of(rules_target: Path) -> Path:
226
+ """``~/.claude/CLAUDE.md`` -> ``~``."""
227
+ return rules_target.parent.parent
228
+
229
+
230
+ def _remove_if_ours(path: Path) -> None:
231
+ """Delete a rules file AdLoop wrote; never a file the user wrote."""
232
+ if not path.is_file():
233
+ return
234
+ try:
235
+ text = path.read_text()
236
+ except (OSError, UnicodeDecodeError):
237
+ return
238
+ if _RULES_HEADING not in text:
239
+ return
240
+ path.unlink()
241
+ try:
242
+ path.parent.rmdir() # only if empty
243
+ except OSError:
244
+ pass
245
+
246
+
206
247
  def _list_bundled_commands() -> list[tuple[str, str]]:
207
248
  """Return ``[(filename, content), ...]`` for every bundled slash command."""
208
249
  commands: list[tuple[str, str]] = []
@@ -335,16 +376,10 @@ def uninstall_rules(
335
376
  client.rules_target.write_text(cleaned)
336
377
  result.action = "uninstalled"
337
378
 
338
- # Remove the lazy-mode rules file if present
339
- lazy_rules_file = (
340
- client.rules_target.parent / "rules" / "adloop.md"
341
- )
342
- if lazy_rules_file.exists():
343
- lazy_rules_file.unlink()
344
- try:
345
- lazy_rules_file.parent.rmdir() # only if empty
346
- except OSError:
347
- pass
379
+ # Remove the lazy-mode rules file, at its current and its old place.
380
+ home = _home_of(client.rules_target)
381
+ _remove_if_ours(home / LAZY_RULES_RELATIVE)
382
+ _remove_if_ours(home / _LEGACY_LAZY_RULES_RELATIVE)
348
383
 
349
384
  # Remove namespaced slash commands
350
385
  if remove_commands and client.commands_dir and client.commands_dir.exists():
@@ -397,11 +432,18 @@ def _apply_to_clients(
397
432
  else:
398
433
  resolved_mode = "inline"
399
434
 
400
- # In lazy mode, the full rules go to a sibling file.
435
+ # In lazy mode the full rules go to a file Claude Code does not load
436
+ # on its own. A file from an older lazy install under ~/.claude/rules/
437
+ # would be loaded into every session, so it goes either way; inline
438
+ # mode needs no lazy file at all.
439
+ home = _home_of(rules_target)
440
+ _remove_if_ours(home / _LEGACY_LAZY_RULES_RELATIVE)
441
+ lazy_rules_file = home / LAZY_RULES_RELATIVE
401
442
  if resolved_mode == "lazy":
402
- lazy_rules_dir = rules_target.parent / "rules"
403
- lazy_rules_dir.mkdir(parents=True, exist_ok=True)
404
- (lazy_rules_dir / "adloop.md").write_text(_read_bundled_rules())
443
+ lazy_rules_file.parent.mkdir(parents=True, exist_ok=True)
444
+ lazy_rules_file.write_text(_read_bundled_rules())
445
+ else:
446
+ _remove_if_ours(lazy_rules_file)
405
447
 
406
448
  new_block = _build_managed_block(resolved_mode, rules_target)
407
449
  merged = _replace_or_append_block(existing, new_block)
@@ -431,8 +473,10 @@ def _existing_block_is_lazy(existing: str) -> bool:
431
473
  if not match:
432
474
  return False
433
475
  block = match.group(0)
434
- # Lazy blocks are short and reference the rules file path.
435
- return "~/.claude/rules/adloop.md" in block and len(block) < 2000
476
+ # Lazy blocks are short and reference the rules file path (the current one,
477
+ # or the ~/.claude/rules/ one older versions wrote).
478
+ mentions_file = "~/.adloop/rules/adloop.md" in block or "~/.claude/rules/adloop.md" in block
479
+ return mentions_file and len(block) < 2000
436
480
 
437
481
 
438
482
  # ---------------------------------------------------------------------------
@@ -1546,10 +1546,12 @@ def get_recommendations(
1546
1546
  ) -> dict:
1547
1547
  """Retrieve Google's auto-generated recommendations with estimated impact.
1548
1548
 
1549
- Returns each recommendation's type, associated campaign/ad group, current
1550
- (base) and projected (potential) metrics, and the estimated improvement.
1551
- Includes insights that flag budget-increase recommendations (their projected
1552
- gain comes from spending more) and highlight high-impact suggestions.
1549
+ Returns each recommendation's ID, type, associated campaign/ad group,
1550
+ current (base) and projected (potential) metrics, and the estimated
1551
+ improvement. Includes insights that flag budget-increase recommendations
1552
+ (their projected gain comes from spending more) and highlight high-impact
1553
+ suggestions. The `recommendation.id` is what draft_dismiss_recommendations
1554
+ takes.
1553
1555
 
1554
1556
  Args:
1555
1557
  customer_id: Google Ads customer ID, digits with or without dashes
@@ -1570,6 +1572,44 @@ def get_recommendations(
1570
1572
  )
1571
1573
 
1572
1574
 
1575
+ @_tool(title="Draft dismissing recommendations", annotations=_WRITE, tags={"ads"})
1576
+ @_safe
1577
+ def draft_dismiss_recommendations(
1578
+ recommendation_ids: _StrList,
1579
+ customer_id: str = "",
1580
+ ) -> dict:
1581
+ """Dismiss Google Ads recommendations — returns a PREVIEW.
1582
+
1583
+ Turns down recommendations you will not take (e.g. "add broad match" on a
1584
+ deliberately narrow account) so they stop reappearing in
1585
+ get_recommendations. Nothing in the account changes, and Google can show a
1586
+ dismissed recommendation again later, so a single confirmation is enough.
1587
+
1588
+ Each ID must be an active recommendation: unknown or already dismissed
1589
+ IDs are refused by name. The preview lists each one's type, campaign and
1590
+ estimated improvement, so the user sees what they are turning down.
1591
+
1592
+ DismissRecommendation has no validate-only mode: the dry run re-reads the
1593
+ account and checks every recommendation is still active instead of
1594
+ sending the call to Google. The returned plan_id is applied with
1595
+ confirm_and_apply.
1596
+
1597
+ Args:
1598
+ recommendation_ids: `recommendation.id` values from
1599
+ get_recommendations, or full resource names
1600
+ (customers/{customer_id}/recommendations/{id}). Duplicates are
1601
+ collapsed.
1602
+ customer_id: Ads account ID. Defaults to the configured account.
1603
+ """
1604
+ from adloop.ads.recommendations import draft_dismiss_recommendations as _impl
1605
+
1606
+ return _impl(
1607
+ current_config(),
1608
+ customer_id=customer_id or current_config().ads.customer_id,
1609
+ recommendation_ids=recommendation_ids,
1610
+ )
1611
+
1612
+
1573
1613
  @_tool(title="Performance Max performance", annotations=_READONLY, tags={"ads"})
1574
1614
  @_safe
1575
1615
  def get_pmax_performance(
@@ -3980,6 +4020,44 @@ def get_ai_max_settings(
3980
4020
  campaign_id=campaign_id,
3981
4021
  )
3982
4022
 
4023
+
4024
+ @_tool(title="Campaign settings", annotations=_READONLY, tags={"ads"})
4025
+ @_safe
4026
+ def get_campaign_settings(
4027
+ campaign_id: str = "",
4028
+ customer_id: str = "",
4029
+ ) -> dict:
4030
+ """Read every setting that decides how far a Search campaign reaches, in one call.
4031
+
4032
+ Returns one flat record per campaign: status, bidding strategy and its
4033
+ target or CPC ceiling, daily budget and whether it is shared, the Google
4034
+ Search / search partner / Display network flags, the location option
4035
+ (positive_geo_target_type) and its negative counterpart, campaign-level
4036
+ broad match (keyword_match_type), the tracking template, final URL suffix
4037
+ and custom parameters, and the AI Max state get_ai_max_settings reports
4038
+ (enable_ai_max, asset automation per type). Money is in the account
4039
+ currency (`currency`).
4040
+
4041
+ Each campaign lists its `widening_settings`: search partners or Display on,
4042
+ a presence-or-interest location option, broad match on, AI Max on, an
4043
+ automation opted in. `insights[]` names the campaigns that have any. Made
4044
+ for recurring audits: the same call every week diffs cleanly.
4045
+
4046
+ Args:
4047
+ campaign_id: Numeric campaign ID of a Search campaign. When omitted,
4048
+ every non-removed Search campaign in the account is listed.
4049
+ customer_id: Google Ads customer ID (digits, dashes optional). Defaults to
4050
+ the configured account.
4051
+ """
4052
+ from adloop.ads.campaign_settings import get_campaign_settings as _impl
4053
+
4054
+ return _impl(
4055
+ current_config(),
4056
+ customer_id=customer_id or current_config().ads.customer_id,
4057
+ campaign_id=campaign_id,
4058
+ )
4059
+
4060
+
3983
4061
  @_tool(title="Draft AI Max settings", annotations=_WRITE, tags={"ads"})
3984
4062
  @_safe
3985
4063
  def draft_ai_max_settings(
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes