luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- package/skills/trends/scripts/lib/reddit_rss.py +0 -224
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
+
# fmt: off
|
|
2
3
|
# ruff: noqa: E402
|
|
3
4
|
"""last30days CLI."""
|
|
4
5
|
|
|
@@ -7,12 +8,14 @@ from __future__ import annotations
|
|
|
7
8
|
import argparse
|
|
8
9
|
import atexit
|
|
9
10
|
import datetime
|
|
11
|
+
import hashlib
|
|
10
12
|
import json
|
|
11
13
|
import os
|
|
12
14
|
import re
|
|
13
15
|
import signal
|
|
16
|
+
import sqlite3
|
|
14
17
|
import sys
|
|
15
|
-
import
|
|
18
|
+
from collections.abc import Callable
|
|
16
19
|
from pathlib import Path
|
|
17
20
|
|
|
18
21
|
MIN_PYTHON = (3, 12)
|
|
@@ -47,36 +50,51 @@ if os.name == "nt":
|
|
|
47
50
|
SCRIPT_DIR = Path(__file__).parent.resolve()
|
|
48
51
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
49
52
|
|
|
50
|
-
from lib import dates, env, html_render, permission_preflight, pipeline, render, schema, ui
|
|
53
|
+
from lib import competitors as competitors_mod, corpus, dates, discovery_handoff, env, fetch_memo, freshness, html_render, http, permission_preflight, pipeline, reddit, registers, render, schema, subproc, ui, x_envelope
|
|
54
|
+
from lib.private_files import write_private_text
|
|
51
55
|
|
|
52
|
-
|
|
53
|
-
_child_pids_lock = threading.Lock()
|
|
56
|
+
atexit.register(subproc.cleanup_children)
|
|
54
57
|
|
|
55
58
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
_child_pids.add(pid)
|
|
59
|
+
def _on_sigterm(signum, frame) -> None:
|
|
60
|
+
"""SIGTERM handler: clean descendant groups, then die as SIGTERM.
|
|
59
61
|
|
|
62
|
+
Every run_with_timeout child runs in its own pgid (lib/subproc.py via
|
|
63
|
+
os.setsid), so a group kill aimed at the engine can never reach them;
|
|
64
|
+
only the lib.subproc registry can. atexit never runs on a signal death,
|
|
65
|
+
so without this handler an MCP timeout would orphan node bird-search,
|
|
66
|
+
yt-dlp, and the digg CLI. The MCP server SIGTERMs the engine group
|
|
67
|
+
first, giving this handler room to killpg() each registered child
|
|
68
|
+
group before the SIGKILL backstop. Restoring the default disposition
|
|
69
|
+
and re-raising preserves killed-by-SIGTERM semantics for the parent.
|
|
70
|
+
"""
|
|
71
|
+
subproc.cleanup_children()
|
|
72
|
+
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
|
73
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
60
74
|
|
|
61
|
-
def unregister_child_pid(pid: int) -> None:
|
|
62
|
-
with _child_pids_lock:
|
|
63
|
-
_child_pids.discard(pid)
|
|
64
75
|
|
|
76
|
+
def _install_sigterm_handler() -> None:
|
|
77
|
+
try:
|
|
78
|
+
signal.signal(signal.SIGTERM, _on_sigterm)
|
|
79
|
+
except (ValueError, OSError, RuntimeError):
|
|
80
|
+
pass
|
|
65
81
|
|
|
66
|
-
def _cleanup_children() -> None:
|
|
67
|
-
with _child_pids_lock:
|
|
68
|
-
pids = list(_child_pids)
|
|
69
|
-
for pid in pids:
|
|
70
|
-
try:
|
|
71
|
-
if hasattr(os, "killpg"):
|
|
72
|
-
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
73
|
-
else:
|
|
74
|
-
os.kill(pid, signal.SIGTERM)
|
|
75
|
-
except (ProcessLookupError, PermissionError, OSError):
|
|
76
|
-
continue
|
|
77
82
|
|
|
83
|
+
def parse_meta_ads_page(raw: str) -> str:
|
|
84
|
+
"""Extract an Ad Library page id from a flag value, or "" if there is none.
|
|
78
85
|
|
|
79
|
-
|
|
86
|
+
Accepts a bare numeric id or any Ad Library URL carrying
|
|
87
|
+
``view_all_page_id``. A ``facebook.com/<vanity>`` URL is deliberately
|
|
88
|
+
rejected rather than guessed at: a vanity handle is not a page id, and one
|
|
89
|
+
live check resolved a brand-looking handle to a private person's profile.
|
|
90
|
+
"""
|
|
91
|
+
value = str(raw or "").strip()
|
|
92
|
+
if not value:
|
|
93
|
+
return ""
|
|
94
|
+
if re.fullmatch(r"\d{5,20}", value):
|
|
95
|
+
return value
|
|
96
|
+
match = re.search(r"view_all_page_id=(\d{5,20})", value)
|
|
97
|
+
return match.group(1) if match else ""
|
|
80
98
|
|
|
81
99
|
|
|
82
100
|
def parse_search_flag(raw: str, flag_name: str = "--search") -> list[str]:
|
|
@@ -115,11 +133,192 @@ def resolve_requested_sources(args_search: str | None, config: dict) -> list[str
|
|
|
115
133
|
return None
|
|
116
134
|
|
|
117
135
|
|
|
118
|
-
def
|
|
136
|
+
def add_deep_research_source(
|
|
137
|
+
requested_sources: list[str] | None,
|
|
138
|
+
) -> list[str] | None:
|
|
139
|
+
"""Add Perplexity without replacing the default-source sentinel.
|
|
140
|
+
|
|
141
|
+
``None`` means that the planner can use the normal configured source set.
|
|
142
|
+
Deep Research enables Perplexity through ``INCLUDE_SOURCES`` separately, so
|
|
143
|
+
converting this sentinel to ``["perplexity"]`` would suppress every normal
|
|
144
|
+
source.
|
|
145
|
+
"""
|
|
146
|
+
if requested_sources is None:
|
|
147
|
+
return None
|
|
148
|
+
if "perplexity" in requested_sources:
|
|
149
|
+
return requested_sources
|
|
150
|
+
return [*requested_sources, "perplexity"]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def enable_deep_research_source(config: dict) -> None:
|
|
154
|
+
"""Enable the exact Perplexity token or reject a hard exclusion."""
|
|
155
|
+
excluded = {
|
|
156
|
+
token.strip().lower()
|
|
157
|
+
for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
|
|
158
|
+
if token.strip()
|
|
159
|
+
}
|
|
160
|
+
if "perplexity" in excluded:
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"--deep-research conflicts with EXCLUDE_SOURCES=perplexity"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
include = str(config.get("INCLUDE_SOURCES") or "")
|
|
166
|
+
tokens = [token.strip() for token in include.split(",") if token.strip()]
|
|
167
|
+
if "perplexity" not in {token.lower() for token in tokens}:
|
|
168
|
+
tokens.append("perplexity")
|
|
169
|
+
config["INCLUDE_SOURCES"] = ",".join(tokens)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def plan_has_explicit_trustpilot_domain(comp_plan: dict | None) -> bool:
|
|
173
|
+
"""True when any --competitors-plan entry pins a trustpilot_domain."""
|
|
174
|
+
if not comp_plan:
|
|
175
|
+
return False
|
|
176
|
+
for entry in comp_plan.values():
|
|
177
|
+
if not isinstance(entry, dict):
|
|
178
|
+
continue
|
|
179
|
+
domain = entry.get("trustpilot_domain")
|
|
180
|
+
if isinstance(domain, str) and domain.strip():
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def activate_trustpilot_for_explicit_domain(
|
|
186
|
+
config: dict,
|
|
187
|
+
requested_sources: list[str] | None,
|
|
188
|
+
*,
|
|
189
|
+
reason: str,
|
|
190
|
+
) -> list[str] | None:
|
|
191
|
+
"""Activate the opt-in Trustpilot source when the user pinned a domain.
|
|
192
|
+
|
|
193
|
+
Passing ``--trustpilot-domain`` (or a plan-level ``trustpilot_domain``) is
|
|
194
|
+
unambiguous intent — silently ignoring it when Trustpilot is not in
|
|
195
|
+
``INCLUDE_SOURCES`` / ``--search`` is the #873 failure mode. Auto-resolve
|
|
196
|
+
hints must not call this helper.
|
|
197
|
+
|
|
198
|
+
``EXCLUDE_SOURCES=trustpilot`` still wins. Mutates ``config`` in place and
|
|
199
|
+
returns the (possibly extended) ``requested_sources`` list.
|
|
200
|
+
"""
|
|
201
|
+
excluded = {
|
|
202
|
+
token.strip().lower()
|
|
203
|
+
for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
|
|
204
|
+
if token.strip()
|
|
205
|
+
}
|
|
206
|
+
if "trustpilot" in excluded:
|
|
207
|
+
sys.stderr.write(
|
|
208
|
+
f"[Trustpilot] {reason} ignored: trustpilot is in EXCLUDE_SOURCES\n"
|
|
209
|
+
)
|
|
210
|
+
return requested_sources
|
|
211
|
+
|
|
212
|
+
include = str(config.get("INCLUDE_SOURCES") or "")
|
|
213
|
+
tokens = [token.strip() for token in include.split(",") if token.strip()]
|
|
214
|
+
if "trustpilot" not in {token.lower() for token in tokens}:
|
|
215
|
+
tokens.append("trustpilot")
|
|
216
|
+
config["INCLUDE_SOURCES"] = ",".join(tokens)
|
|
217
|
+
sys.stderr.write(
|
|
218
|
+
f"[Trustpilot] {reason} activated trustpilot source "
|
|
219
|
+
"(add to INCLUDE_SOURCES permanently to skip this auto-enable)\n"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if requested_sources is not None and "trustpilot" not in requested_sources:
|
|
223
|
+
requested_sources = [*requested_sources, "trustpilot"]
|
|
224
|
+
return requested_sources
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def activate_telegram_for_explicit_sources(
|
|
228
|
+
config: dict,
|
|
229
|
+
requested_sources: list[str] | None,
|
|
230
|
+
*,
|
|
231
|
+
channels: str,
|
|
232
|
+
) -> list[str] | None:
|
|
233
|
+
"""Activate the opt-in Telegram source when the user pinned channel(s).
|
|
234
|
+
|
|
235
|
+
Passing ``--telegram-sources`` is unambiguous intent — silently ignoring it
|
|
236
|
+
when Telegram is not in ``INCLUDE_SOURCES`` / ``--search`` is the same
|
|
237
|
+
failure mode as #873 (Trustpilot). Auto-activate the source.
|
|
238
|
+
|
|
239
|
+
``EXCLUDE_SOURCES=telegram`` still wins. Mutates ``config`` in place and
|
|
240
|
+
returns the (possibly extended) ``requested_sources`` list.
|
|
241
|
+
"""
|
|
242
|
+
excluded = {
|
|
243
|
+
token.strip().lower()
|
|
244
|
+
for token in str(config.get("EXCLUDE_SOURCES") or "").split(",")
|
|
245
|
+
if token.strip()
|
|
246
|
+
}
|
|
247
|
+
if "telegram" in excluded:
|
|
248
|
+
sys.stderr.write(
|
|
249
|
+
f"[Telegram] --telegram-sources={channels} ignored: telegram is in EXCLUDE_SOURCES\n"
|
|
250
|
+
)
|
|
251
|
+
return requested_sources
|
|
252
|
+
|
|
253
|
+
config["TELEGRAM_SOURCES"] = channels
|
|
254
|
+
|
|
255
|
+
include = str(config.get("INCLUDE_SOURCES") or "")
|
|
256
|
+
tokens = [token.strip() for token in include.split(",") if token.strip()]
|
|
257
|
+
if "telegram" not in {token.lower() for token in tokens}:
|
|
258
|
+
tokens.append("telegram")
|
|
259
|
+
config["INCLUDE_SOURCES"] = ",".join(tokens)
|
|
260
|
+
sys.stderr.write(
|
|
261
|
+
f"[Telegram] --telegram-sources={channels} activated telegram source "
|
|
262
|
+
"(add to INCLUDE_SOURCES permanently to skip this auto-enable)\n"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if requested_sources is not None and "telegram" not in requested_sources:
|
|
266
|
+
requested_sources = [*requested_sources, "telegram"]
|
|
267
|
+
return requested_sources
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def slugify(value: str, max_length: int = 180) -> str:
|
|
119
271
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
272
|
+
if len(slug) > max_length:
|
|
273
|
+
# Filenames built from long topics can exceed the OS 255-byte limit
|
|
274
|
+
# (macOS errno 63). Truncate and append a hash of the full value so
|
|
275
|
+
# distinct long topics still get distinct, deterministic names.
|
|
276
|
+
digest = hashlib.sha1(slug.encode("utf-8")).hexdigest()[:10]
|
|
277
|
+
slug = f"{slug[:max_length].rstrip('-')}-{digest}"
|
|
120
278
|
return slug or "last30days"
|
|
121
279
|
|
|
122
280
|
|
|
281
|
+
def sanitize_suffix(suffix: str) -> str:
|
|
282
|
+
"""Sanitize a user-provided ``--save-suffix`` into a path-safe token.
|
|
283
|
+
|
|
284
|
+
The suffix is glued directly into the saved-report filename, so restrict it
|
|
285
|
+
to the same ``[a-z0-9-]`` class as the topic slug. This neutralizes path
|
|
286
|
+
separators and parent refs (``/``, ``..``) so a suffix can never escape the
|
|
287
|
+
save directory, while leaving ordinary values ('v3', 'gemini', a client
|
|
288
|
+
slug) unchanged. Unlike ``slugify`` there is no fallback token: a suffix
|
|
289
|
+
that sanitizes to nothing simply drops, yielding no suffix part.
|
|
290
|
+
"""
|
|
291
|
+
return re.sub(r"[^a-z0-9]+", "-", suffix.lower()).strip("-")
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _report_has_private_corpus(report: schema.Report) -> bool:
|
|
295
|
+
items_by_source = getattr(report, "items_by_source", {})
|
|
296
|
+
if isinstance(items_by_source, dict) and items_by_source.get("corpus"):
|
|
297
|
+
return True
|
|
298
|
+
candidates = getattr(report, "ranked_candidates", ())
|
|
299
|
+
if not isinstance(candidates, (list, tuple)):
|
|
300
|
+
return False
|
|
301
|
+
return any(
|
|
302
|
+
candidate.source == "corpus"
|
|
303
|
+
or any(item.source == "corpus" for item in candidate.source_items)
|
|
304
|
+
for candidate in candidates
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _ensure_output_directory(path: Path, *, private: bool) -> None:
|
|
309
|
+
if not private:
|
|
310
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
return
|
|
312
|
+
missing: list[Path] = []
|
|
313
|
+
current = path
|
|
314
|
+
while not current.exists():
|
|
315
|
+
missing.append(current)
|
|
316
|
+
current = current.parent
|
|
317
|
+
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
318
|
+
for directory in missing:
|
|
319
|
+
directory.chmod(0o700)
|
|
320
|
+
|
|
321
|
+
|
|
123
322
|
def save_output(
|
|
124
323
|
report: schema.Report,
|
|
125
324
|
emit: str,
|
|
@@ -128,33 +327,111 @@ def save_output(
|
|
|
128
327
|
synthesis_md: str | None = None,
|
|
129
328
|
topic_override: str | None = None,
|
|
130
329
|
rendered_content: str | None = None,
|
|
330
|
+
json_profile: str = "agent",
|
|
331
|
+
register: str = "default",
|
|
332
|
+
private: bool | None = None,
|
|
333
|
+
render_fn: Callable[[Path], str] | None = None,
|
|
131
334
|
) -> Path:
|
|
132
335
|
from datetime import datetime
|
|
133
336
|
path = Path(save_dir).expanduser().resolve()
|
|
134
|
-
path.mkdir(parents=True, exist_ok=True)
|
|
135
337
|
slug = slugify(topic_override or report.topic)
|
|
136
338
|
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
|
137
339
|
raw_label = "raw-html" if emit == "html" else "raw"
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
340
|
+
safe_suffix = sanitize_suffix(suffix)
|
|
341
|
+
suffix_part = f"-{safe_suffix}" if safe_suffix else ""
|
|
342
|
+
base = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
|
343
|
+
date_str = datetime.now().strftime('%Y-%m-%d')
|
|
344
|
+
candidates = [base]
|
|
345
|
+
candidates.append(path / f"{slug}-{raw_label}{suffix_part}-{date_str}.{extension}")
|
|
346
|
+
for i in range(1, 100):
|
|
347
|
+
candidates.append(path / f"{slug}-{raw_label}{suffix_part}-{date_str}-{i}.{extension}")
|
|
142
348
|
# Markdown saves keep the complete debug artifact. JSON and HTML preserve
|
|
143
349
|
# their requested wire format so file extensions match their content.
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
350
|
+
# When render_fn is supplied, content is produced after O_EXCL allocates
|
|
351
|
+
# the candidate. This lets the footer cite the file actually written
|
|
352
|
+
# without racing a separate filesystem probe.
|
|
353
|
+
if render_fn is None:
|
|
354
|
+
if rendered_content is not None:
|
|
355
|
+
static_content = rendered_content
|
|
356
|
+
elif emit in {"json", "html"}:
|
|
357
|
+
static_content = emit_output(
|
|
358
|
+
report,
|
|
359
|
+
emit,
|
|
360
|
+
synthesis_md=synthesis_md,
|
|
361
|
+
json_profile=json_profile,
|
|
362
|
+
register=register,
|
|
363
|
+
)
|
|
364
|
+
else:
|
|
365
|
+
static_content = render.render_full(report)
|
|
366
|
+
private_corpus = _report_has_private_corpus(report) or bool(private)
|
|
367
|
+
_ensure_output_directory(path, private=private_corpus)
|
|
368
|
+
for candidate in candidates:
|
|
369
|
+
try:
|
|
370
|
+
fd = os.open(
|
|
371
|
+
candidate,
|
|
372
|
+
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
|
373
|
+
0o600 if private_corpus else 0o644,
|
|
374
|
+
)
|
|
375
|
+
except FileExistsError:
|
|
376
|
+
continue
|
|
377
|
+
try:
|
|
378
|
+
with os.fdopen(fd, "wb") as f:
|
|
379
|
+
content = render_fn(candidate) if render_fn is not None else static_content
|
|
380
|
+
f.write(content.encode("utf-8"))
|
|
381
|
+
except BaseException:
|
|
382
|
+
# Deferred rendering happens after the candidate is reserved. Do
|
|
383
|
+
# not leave an empty or partial report if rendering or writing fails.
|
|
384
|
+
try:
|
|
385
|
+
candidate.unlink(missing_ok=True)
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
388
|
+
raise
|
|
389
|
+
if candidate.suffix.lower() == ".md":
|
|
390
|
+
try:
|
|
391
|
+
from lib import library, library_index
|
|
392
|
+
|
|
393
|
+
save_root = candidate.parent.resolve()
|
|
394
|
+
if save_root == Path(library.DEFAULT_MEMORY_DIR).expanduser().resolve():
|
|
395
|
+
library_index.sync_library(save_root)
|
|
396
|
+
else:
|
|
397
|
+
# A scoped save must sync a per-directory index with the
|
|
398
|
+
# same paths scoped search uses; syncing the shared DB
|
|
399
|
+
# from one scope's scan would prune other scopes' rows.
|
|
400
|
+
library_index.sync_library(
|
|
401
|
+
save_root,
|
|
402
|
+
save_root / "briefings",
|
|
403
|
+
db_path=save_root / ".last30days-library.db",
|
|
404
|
+
)
|
|
405
|
+
except (library_index.LibrarySearchUnavailable, OSError, sqlite3.DatabaseError):
|
|
406
|
+
# Saving research must not depend on the optional local index;
|
|
407
|
+
# `library search` reports a clear capability error on demand.
|
|
408
|
+
pass
|
|
409
|
+
return candidate
|
|
410
|
+
# Fallback: all 101 candidates existed (extremely unlikely).
|
|
411
|
+
raise RuntimeError(
|
|
412
|
+
f"save_output: could not find a unique filename after 101 attempts in {path}"
|
|
413
|
+
)
|
|
152
414
|
|
|
153
415
|
|
|
154
|
-
def save_rendered_output(
|
|
416
|
+
def save_rendered_output(
|
|
417
|
+
rendered_content: str,
|
|
418
|
+
output_file: str,
|
|
419
|
+
*,
|
|
420
|
+
private: bool = False,
|
|
421
|
+
) -> Path:
|
|
155
422
|
out_path = Path(output_file).expanduser().resolve()
|
|
156
|
-
out_path.parent
|
|
157
|
-
out_path.
|
|
423
|
+
_ensure_output_directory(out_path.parent, private=private)
|
|
424
|
+
if private and out_path.exists():
|
|
425
|
+
out_path.chmod(0o600)
|
|
426
|
+
fd = os.open(
|
|
427
|
+
out_path,
|
|
428
|
+
os.O_CREAT | os.O_TRUNC | os.O_WRONLY,
|
|
429
|
+
0o600 if private else 0o644,
|
|
430
|
+
)
|
|
431
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
432
|
+
handle.write(rendered_content)
|
|
433
|
+
if private:
|
|
434
|
+
out_path.chmod(0o600)
|
|
158
435
|
return out_path
|
|
159
436
|
|
|
160
437
|
|
|
@@ -193,8 +470,16 @@ def publish_rendered_html(
|
|
|
193
470
|
return result
|
|
194
471
|
|
|
195
472
|
|
|
196
|
-
def _publish_password_for_args(
|
|
197
|
-
|
|
473
|
+
def _publish_password_for_args(
|
|
474
|
+
args: argparse.Namespace,
|
|
475
|
+
config: dict[str, object] | None = None,
|
|
476
|
+
) -> str | None:
|
|
477
|
+
return (
|
|
478
|
+
args.publish_password
|
|
479
|
+
or env.read_secret_env("LAST30DAYS_PUBLISH_PASSWORD")
|
|
480
|
+
or (config or {}).get("LAST30DAYS_PUBLISH_PASSWORD")
|
|
481
|
+
or None
|
|
482
|
+
)
|
|
198
483
|
|
|
199
484
|
|
|
200
485
|
def emit_output(
|
|
@@ -203,15 +488,31 @@ def emit_output(
|
|
|
203
488
|
fun_level: str = "medium",
|
|
204
489
|
save_path: str | None = None,
|
|
205
490
|
synthesis_md: str | None = None,
|
|
491
|
+
json_profile: str = "agent",
|
|
492
|
+
register: str = "default",
|
|
206
493
|
) -> str:
|
|
207
494
|
if emit == "json":
|
|
208
|
-
|
|
495
|
+
payload = (
|
|
496
|
+
schema.to_dict(report)
|
|
497
|
+
if json_profile == "raw"
|
|
498
|
+
else schema.to_agent_export(report)
|
|
499
|
+
)
|
|
500
|
+
return json.dumps(payload, indent=2, sort_keys=True)
|
|
209
501
|
if emit == "html":
|
|
210
502
|
return html_render.render_html(
|
|
211
|
-
report,
|
|
503
|
+
report,
|
|
504
|
+
fun_level=fun_level,
|
|
505
|
+
save_path=save_path,
|
|
506
|
+
synthesis_md=synthesis_md,
|
|
507
|
+
register=register,
|
|
212
508
|
)
|
|
213
509
|
if emit in {"compact", "md"}:
|
|
214
|
-
return render.render_compact(
|
|
510
|
+
return render.render_compact(
|
|
511
|
+
report,
|
|
512
|
+
fun_level=fun_level,
|
|
513
|
+
save_path=save_path,
|
|
514
|
+
register=register,
|
|
515
|
+
)
|
|
215
516
|
if emit == "context":
|
|
216
517
|
return render.render_context(report)
|
|
217
518
|
if emit == "brief":
|
|
@@ -225,16 +526,26 @@ def emit_comparison_output(
|
|
|
225
526
|
fun_level: str = "medium",
|
|
226
527
|
save_path: str | None = None,
|
|
227
528
|
synthesis_md: str | None = None,
|
|
529
|
+
json_profile: str = "agent",
|
|
228
530
|
) -> str:
|
|
229
531
|
if emit == "json":
|
|
230
532
|
payload = {
|
|
231
533
|
"comparison": True,
|
|
232
534
|
"entities": [label for label, _ in entity_reports],
|
|
233
535
|
"reports": [
|
|
234
|
-
{
|
|
536
|
+
{
|
|
537
|
+
"entity": label,
|
|
538
|
+
"report": (
|
|
539
|
+
schema.to_dict(report)
|
|
540
|
+
if json_profile == "raw"
|
|
541
|
+
else schema.to_agent_export(report)
|
|
542
|
+
),
|
|
543
|
+
}
|
|
235
544
|
for label, report in entity_reports
|
|
236
545
|
],
|
|
237
546
|
}
|
|
547
|
+
if json_profile == "agent":
|
|
548
|
+
payload["schema_version"] = schema.AGENT_EXPORT_SCHEMA_VERSION
|
|
238
549
|
return json.dumps(payload, indent=2, sort_keys=True)
|
|
239
550
|
if emit == "html":
|
|
240
551
|
return html_render.render_html_comparison(
|
|
@@ -256,6 +567,17 @@ def comparison_topic(entity_reports: list[tuple[str, schema.Report]]) -> str:
|
|
|
256
567
|
return " vs ".join(label for label, _ in entity_reports)
|
|
257
568
|
|
|
258
569
|
|
|
570
|
+
def comparison_label_key(label: str) -> str:
|
|
571
|
+
"""Normalize an entity label for duplicate detection.
|
|
572
|
+
|
|
573
|
+
Comparison labels double as keys in the fan-out's results dict, so two
|
|
574
|
+
entities differing only in case, surrounding space, or a repeated space
|
|
575
|
+
collide there while still looking distinct on the command line. Spaces
|
|
576
|
+
are collapsed, never stripped: "Open AI" stays distinct from "OpenAI".
|
|
577
|
+
"""
|
|
578
|
+
return " ".join(label.split()).casefold()
|
|
579
|
+
|
|
580
|
+
|
|
259
581
|
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
|
|
260
582
|
"""Compute the user-friendly save path string that will be shown in the footer.
|
|
261
583
|
|
|
@@ -267,7 +589,8 @@ def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str)
|
|
|
267
589
|
slug = slugify(topic)
|
|
268
590
|
extension = "json" if emit == "json" else "html" if emit == "html" else "md"
|
|
269
591
|
raw_label = "raw-html" if emit == "html" else "raw"
|
|
270
|
-
|
|
592
|
+
safe_suffix = sanitize_suffix(suffix)
|
|
593
|
+
suffix_part = f"-{safe_suffix}" if safe_suffix else ""
|
|
271
594
|
raw = path / f"{slug}-{raw_label}{suffix_part}.{extension}"
|
|
272
595
|
try:
|
|
273
596
|
home = _Path.home().resolve()
|
|
@@ -296,27 +619,46 @@ def read_synthesis_file(path: str) -> str:
|
|
|
296
619
|
raise SystemExit(2)
|
|
297
620
|
|
|
298
621
|
|
|
299
|
-
def
|
|
622
|
+
def _scoped_store_db(args: argparse.Namespace) -> Path | None:
|
|
623
|
+
"""Scoped runs write findings inside the save dir, matching scoped reads."""
|
|
624
|
+
save_dir = getattr(args, "save_dir", None)
|
|
625
|
+
if save_dir:
|
|
626
|
+
return Path(save_dir).expanduser().resolve() / "research.db"
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def persist_report(report: schema.Report, store_db: Path | None = None) -> dict[str, int]:
|
|
300
631
|
import store
|
|
301
632
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
633
|
+
private_corpus = _report_has_private_corpus(report)
|
|
634
|
+
with store.scoped_db(store_db):
|
|
635
|
+
if private_corpus:
|
|
636
|
+
store.ensure_private_db_files()
|
|
637
|
+
store.init_db()
|
|
638
|
+
if private_corpus:
|
|
639
|
+
store.ensure_private_db_files()
|
|
640
|
+
topic_row = store.add_topic(report.topic, update_existing=False)
|
|
641
|
+
topic_id = topic_row["id"]
|
|
642
|
+
source_mode = ",".join(sorted(report.items_by_source)) or "v3"
|
|
643
|
+
run_id = store.record_run(topic_id, source_mode=source_mode, status="running")
|
|
644
|
+
try:
|
|
645
|
+
findings = store.findings_from_report(report)
|
|
646
|
+
if private_corpus:
|
|
647
|
+
store.ensure_private_db_files()
|
|
648
|
+
counts = store.store_findings(run_id, topic_id, findings)
|
|
649
|
+
store.update_run(
|
|
650
|
+
run_id,
|
|
651
|
+
status="completed",
|
|
652
|
+
findings_new=counts["new"],
|
|
653
|
+
findings_updated=counts["updated"],
|
|
654
|
+
)
|
|
655
|
+
return counts
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
store.update_run(run_id, status="failed", error_message=str(exc)[:500])
|
|
658
|
+
raise
|
|
659
|
+
finally:
|
|
660
|
+
if private_corpus:
|
|
661
|
+
store.ensure_private_db_files()
|
|
320
662
|
|
|
321
663
|
|
|
322
664
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -326,38 +668,163 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
326
668
|
)
|
|
327
669
|
parser.add_argument("topic", nargs="*", help="Research topic")
|
|
328
670
|
parser.add_argument("--emit", default="compact", choices=["compact", "json", "context", "md", "html", "brief"])
|
|
671
|
+
parser.add_argument(
|
|
672
|
+
"--register",
|
|
673
|
+
choices=registers.REGISTER_NAMES,
|
|
674
|
+
default=None,
|
|
675
|
+
help="Audience synthesis preset for the standard brief (default, exec, dev, creator, eli5)",
|
|
676
|
+
)
|
|
677
|
+
parser.add_argument(
|
|
678
|
+
"--json-profile",
|
|
679
|
+
default="agent",
|
|
680
|
+
choices=["agent", "raw"],
|
|
681
|
+
help="JSON export profile for --emit=json (default: agent)",
|
|
682
|
+
)
|
|
329
683
|
parser.add_argument("--search", help="Comma-separated source list")
|
|
330
684
|
parser.add_argument("--quick", action="store_true", help="Lower-latency retrieval profile")
|
|
331
685
|
parser.add_argument("--deep", action="store_true", help="Higher-recall retrieval profile")
|
|
686
|
+
freshness_group = parser.add_mutually_exclusive_group()
|
|
687
|
+
freshness_group.add_argument(
|
|
688
|
+
"--verify-freshness",
|
|
689
|
+
action="store_true",
|
|
690
|
+
default=None,
|
|
691
|
+
help="Re-check source-grounded claims after research, or verify the cached report when no topic is supplied",
|
|
692
|
+
)
|
|
693
|
+
freshness_group.add_argument(
|
|
694
|
+
"--no-verify-freshness",
|
|
695
|
+
dest="verify_freshness",
|
|
696
|
+
action="store_false",
|
|
697
|
+
help="Disable freshness verification configured by LAST30DAYS_VERIFY_FRESHNESS",
|
|
698
|
+
)
|
|
699
|
+
parser.add_argument(
|
|
700
|
+
"--drill",
|
|
701
|
+
metavar="TARGET",
|
|
702
|
+
help="Deep follow-up on a cluster from the fresh last-report.json cache",
|
|
703
|
+
)
|
|
704
|
+
parser.add_argument(
|
|
705
|
+
"--discover",
|
|
706
|
+
metavar="DOMAIN",
|
|
707
|
+
nargs="?",
|
|
708
|
+
const="",
|
|
709
|
+
default=None,
|
|
710
|
+
help=(
|
|
711
|
+
"Sweep river listings and rank the topics accelerating in a domain; "
|
|
712
|
+
"each survivor gets a full research pass. Bare --discover (no domain) "
|
|
713
|
+
"runs global trending across every feed's hot list"
|
|
714
|
+
),
|
|
715
|
+
)
|
|
716
|
+
parser.add_argument(
|
|
717
|
+
"--discover-shallow",
|
|
718
|
+
action="store_true",
|
|
719
|
+
help=(
|
|
720
|
+
"Skip the per-topic research pass during --discover: rank on listing "
|
|
721
|
+
"evidence only (faster, thinner; the confidence floor still applies)"
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
parser.add_argument(
|
|
725
|
+
"--nominate-only",
|
|
726
|
+
action="store_true",
|
|
727
|
+
help=(
|
|
728
|
+
"Leg 1 of the host-judged discovery protocol: sweep, write the "
|
|
729
|
+
"nominations bundle for host judgment, and stop (no judging, no "
|
|
730
|
+
"enrichment). Requires --discover"
|
|
731
|
+
),
|
|
732
|
+
)
|
|
733
|
+
parser.add_argument(
|
|
734
|
+
"--judgments",
|
|
735
|
+
metavar="PATH",
|
|
736
|
+
help=(
|
|
737
|
+
"Leg 2 of the discovery protocol: resume from the nominations "
|
|
738
|
+
"bundle, applying the host judgments file at PATH. Requires "
|
|
739
|
+
"--discover"
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
parser.add_argument(
|
|
743
|
+
"--finalize",
|
|
744
|
+
action="store_true",
|
|
745
|
+
help=(
|
|
746
|
+
"Leg 3 of the discovery protocol: apply host angles, render the "
|
|
747
|
+
"final discovery brief, and record the topic queue. Requires "
|
|
748
|
+
"--discover"
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
parser.add_argument(
|
|
752
|
+
"--angles",
|
|
753
|
+
metavar="PATH",
|
|
754
|
+
help=(
|
|
755
|
+
"Optional host angles file for --discover --finalize (omitting it "
|
|
756
|
+
"ships the brief without angle lines)"
|
|
757
|
+
),
|
|
758
|
+
)
|
|
332
759
|
parser.add_argument("--debug", action="store_true", help="Enable HTTP debug logging")
|
|
333
760
|
parser.add_argument("--mock", action="store_true", help="Use mock retrieval fixtures")
|
|
761
|
+
parser.add_argument(
|
|
762
|
+
"--record-fixtures",
|
|
763
|
+
metavar="DIR",
|
|
764
|
+
help=argparse.SUPPRESS,
|
|
765
|
+
)
|
|
334
766
|
parser.add_argument("--diagnose", action="store_true", help="Print provider and source availability")
|
|
335
767
|
parser.add_argument("--preflight", action="store_true",
|
|
336
768
|
help="Print a safe human-readable permission preflight")
|
|
769
|
+
parser.add_argument("--welcome", action="store_true",
|
|
770
|
+
help="Print the first-run welcome text (engine-owned; relay verbatim)")
|
|
337
771
|
parser.add_argument("--preflight-report-on-save-dir", help=argparse.SUPPRESS)
|
|
338
772
|
parser.add_argument("--no-browser-cookies", action="store_true",
|
|
339
773
|
help="Disable browser-cookie extraction even when FROM_BROWSER is configured")
|
|
340
774
|
parser.add_argument("--save-dir", help="Optional directory for saving the rendered output")
|
|
775
|
+
parser.add_argument(
|
|
776
|
+
"--resolve-save-dir", action="store_true",
|
|
777
|
+
help="Print the skill save directory from flags/config, then exit without research",
|
|
778
|
+
)
|
|
779
|
+
parser.add_argument(
|
|
780
|
+
"--corpus",
|
|
781
|
+
action="append",
|
|
782
|
+
default=[],
|
|
783
|
+
metavar="DIR",
|
|
784
|
+
help="Add a local .md/.txt/.pdf directory as a private ranked source (repeatable)",
|
|
785
|
+
)
|
|
786
|
+
parser.add_argument(
|
|
787
|
+
"--corpus-all-time",
|
|
788
|
+
action="store_true",
|
|
789
|
+
help="Include matching corpus files older than the research window",
|
|
790
|
+
)
|
|
341
791
|
parser.add_argument("--output", help="Optional exact file path for saving the rendered output")
|
|
342
792
|
parser.add_argument("--synthesis-file", help="Markdown synthesis to embed in --emit=html output")
|
|
343
793
|
parser.add_argument("--publish-html", action="store_true",
|
|
344
794
|
help="Publish --emit=html output to ht-ml.app (explicit opt-in; public by default)")
|
|
795
|
+
parser.add_argument("--publish", action="store_true",
|
|
796
|
+
help="With 'library feed', publish the HTML index and briefs (explicit opt-in; public by default); feed.xml remains local")
|
|
345
797
|
parser.add_argument("--publish-password",
|
|
346
|
-
help="Optional shared password for --publish-html; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
|
|
798
|
+
help="Optional shared password for --publish-html or 'library feed --publish'; prefer LAST30DAYS_PUBLISH_PASSWORD to avoid exposing secrets in process lists")
|
|
347
799
|
parser.add_argument("--store", action="store_true", help="Persist ranked findings to the SQLite research store")
|
|
348
800
|
parser.add_argument("--x-handle", help="X handle for targeted supplemental search")
|
|
349
801
|
parser.add_argument("--x-related", help="Comma-separated related X handles (searched with lower weight)")
|
|
802
|
+
parser.add_argument(
|
|
803
|
+
"--x-posts",
|
|
804
|
+
dest="x_posts",
|
|
805
|
+
metavar="PATH",
|
|
806
|
+
help=(
|
|
807
|
+
"Path to a last30days-x-posts/1 JSON envelope of posts the hosting "
|
|
808
|
+
"model fetched through its X connector; replaces the engine's X "
|
|
809
|
+
"fetch for this run. A file path only (never inline JSON); on a "
|
|
810
|
+
"comparison run use the per-entity x_posts field of --competitors-plan."
|
|
811
|
+
),
|
|
812
|
+
)
|
|
350
813
|
parser.add_argument("--web-backend", default="auto",
|
|
351
|
-
choices=["auto", "brave", "exa", "serper", "parallel", "none"],
|
|
352
|
-
help="Web search backend (default: auto
|
|
814
|
+
choices=["auto", "brave", "exa", "serper", "parallel", "parallel-mcp", "keyless", "none"],
|
|
815
|
+
help="Web search backend (default: auto; parallel-mcp explicitly opts into the "
|
|
816
|
+
"anonymous hosted MCP; keyless forces the zero-key floor)")
|
|
817
|
+
parser.add_argument("--perplexity-search-type", choices=["web", "fast"],
|
|
818
|
+
help="Search backend for direct Perplexity Search API and Agent web_search; overrides LAST30DAYS_PERPLEXITY_SEARCH_TYPE. Does not enable the paid source or select an Agent preset.")
|
|
353
819
|
parser.add_argument("--deep-research", action="store_true",
|
|
354
|
-
help="Use Perplexity Deep Research
|
|
820
|
+
help="Use at most one Perplexity Deep Research run. Direct PERPLEXITY_API_KEY uses the Agent API background path; OPENROUTER_API_KEY keeps the synchronous Sonar fallback; cannot be combined with competitor or vs-mode.")
|
|
355
821
|
parser.add_argument("--hiring-signals", action="store_true",
|
|
356
822
|
help="Analyze public jobs/careers postings as evidence-backed company focus signals.")
|
|
357
823
|
parser.add_argument("--plan", help="JSON query plan (skips internal LLM planner). Can be a JSON string or a file path.")
|
|
358
824
|
parser.add_argument("--save-suffix", help="Suffix for saved output filename (e.g., 'gemini' → kanye-west-raw-gemini.md)")
|
|
359
825
|
parser.add_argument("--subreddits", help="Comma-separated broad/category subreddit names to search (e.g., SaaS,Entrepreneur)")
|
|
360
826
|
parser.add_argument("--dedicated-subreddits", help="Comma-separated entity-home subreddit names (e.g., Kanye,WestSubEver). Pulled in full (top+hot+new) and exempt from the relevance floor since the whole sub is the topic.")
|
|
827
|
+
parser.add_argument("--devto-tags", help="Comma-separated dev.to tags, lowercase alphanumeric (e.g., claudecode,ai)")
|
|
361
828
|
parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
|
|
362
829
|
parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
|
|
363
830
|
parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
|
|
@@ -366,7 +833,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
366
833
|
"--lookback-days",
|
|
367
834
|
dest="lookback_days",
|
|
368
835
|
type=int,
|
|
369
|
-
default=
|
|
836
|
+
default=None,
|
|
370
837
|
help="Number of days to look back for research (default: 30, watchlist uses 90)",
|
|
371
838
|
)
|
|
372
839
|
parser.add_argument(
|
|
@@ -378,10 +845,63 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
378
845
|
"When set, --days looks back from this date instead of today."
|
|
379
846
|
),
|
|
380
847
|
)
|
|
848
|
+
parser.add_argument("--max-results", dest="max_results", type=int,
|
|
849
|
+
help="Override the final ranked-pool cap (pool_limit/rerank_limit) from the depth profile. "
|
|
850
|
+
"Use for high-volume topics where the default (deep=60) under-covers. See issue #716.")
|
|
851
|
+
parser.add_argument("--max-per-source", dest="max_per_source", type=int,
|
|
852
|
+
help="Override the per-stream cap (per_stream_limit) applied to each (source, subquery) before "
|
|
853
|
+
"pooling. Raising it increases unique-item yield when one source has many relevant items. "
|
|
854
|
+
"See issue #716.")
|
|
855
|
+
parser.add_argument("--max-source-fetches", dest="max_source_fetches", type=int,
|
|
856
|
+
help="Override the per-source fetch cap (MAX_SOURCE_FETCHES, default x=2) that limits how many "
|
|
857
|
+
"subqueries actually fetch a capped source. Raise it so every X subquery in a multi-angle "
|
|
858
|
+
"--plan runs instead of just the first two. See issue #716.")
|
|
381
859
|
parser.add_argument("--auto-resolve", action="store_true",
|
|
382
860
|
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
|
|
383
861
|
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
|
|
384
862
|
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
|
|
863
|
+
parser.add_argument(
|
|
864
|
+
"--trustpilot-domain",
|
|
865
|
+
help=(
|
|
866
|
+
"Trustpilot review-page domain for the topic (e.g., www.thriftbooks.com). "
|
|
867
|
+
"Used verbatim, bypasses the brand-shape gate, and auto-activates the "
|
|
868
|
+
"opt-in Trustpilot source for this run (unless EXCLUDE_SOURCES=trustpilot). "
|
|
869
|
+
"Find the domain with `trustpilot-pp-cli search '<name>'`."
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
parser.add_argument(
|
|
873
|
+
"--amazon-query",
|
|
874
|
+
help=(
|
|
875
|
+
"Product keyword the amazon source searches, when that source is active. "
|
|
876
|
+
"Defaults to the topic. Supply it whenever the topic is not the product: "
|
|
877
|
+
"a person topic searches their company's product line "
|
|
878
|
+
"(--amazon-query='June Oven'), and a brand searches brand-plus-category "
|
|
879
|
+
"(--amazon-query='Weber grill', not 'Weber' -- a bare brand keyword lands "
|
|
880
|
+
"on an ad-heavy page that can miss the brand's own bestsellers). "
|
|
881
|
+
"Requires the brightdata CLI on PATH and logged in."
|
|
882
|
+
),
|
|
883
|
+
)
|
|
884
|
+
parser.add_argument(
|
|
885
|
+
"--meta-ads-page",
|
|
886
|
+
help=(
|
|
887
|
+
"Meta Ad Library page id for the topic's advertiser, when the meta_ads "
|
|
888
|
+
"source is active. Skips name-based page resolution and its discovery "
|
|
889
|
+
"credit. Accepts a bare numeric page id (e.g. 123456789012345) or an Ad "
|
|
890
|
+
"Library URL carrying view_all_page_id. A facebook.com vanity URL is not "
|
|
891
|
+
"a page id and is rejected. Use it when a brand advertises under product "
|
|
892
|
+
"names, or when resolution picked the wrong company."
|
|
893
|
+
),
|
|
894
|
+
)
|
|
895
|
+
parser.add_argument(
|
|
896
|
+
"--telegram-sources",
|
|
897
|
+
help=(
|
|
898
|
+
"Comma-separated list of public Telegram channel handles or t.me URLs. "
|
|
899
|
+
"Auto-activates the opt-in Telegram source for this run. "
|
|
900
|
+
"Accepts: bare handle (aipost), @handle (@aipost), "
|
|
901
|
+
"t.me URL (https://t.me/aipost), or preview URL (https://t.me/s/aipost). "
|
|
902
|
+
"Rejects joinchat links and numeric -100 supergroup IDs."
|
|
903
|
+
),
|
|
904
|
+
)
|
|
385
905
|
parser.add_argument(
|
|
386
906
|
"--competitors",
|
|
387
907
|
nargs="?",
|
|
@@ -434,7 +954,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
|
|
|
434
954
|
plan_str = raw
|
|
435
955
|
if os.path.isfile(plan_str):
|
|
436
956
|
try:
|
|
437
|
-
|
|
957
|
+
with open(plan_str, encoding="utf-8") as f:
|
|
958
|
+
plan_str = f.read()
|
|
438
959
|
except (OSError, UnicodeDecodeError) as exc:
|
|
439
960
|
sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
|
|
440
961
|
raise SystemExit(2)
|
|
@@ -451,7 +972,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
|
|
|
451
972
|
raise SystemExit(2)
|
|
452
973
|
known_fields = {
|
|
453
974
|
"x_handle", "x_related", "subreddits",
|
|
454
|
-
"github_user", "github_repos", "context",
|
|
975
|
+
"github_user", "github_repos", "trustpilot_domain", "context",
|
|
976
|
+
"x_posts",
|
|
455
977
|
}
|
|
456
978
|
normalized: dict[str, dict] = {}
|
|
457
979
|
for entity, entry in parsed.items():
|
|
@@ -468,7 +990,8 @@ def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
|
|
|
468
990
|
f"{sorted(unknown)}; ignoring.\n"
|
|
469
991
|
)
|
|
470
992
|
normalized[entity.strip().lower()] = {
|
|
471
|
-
k: v for k, v in entry.items() if k in known_fields
|
|
993
|
+
**{k: v for k, v in entry.items() if k in known_fields},
|
|
994
|
+
"_name": entity.strip(),
|
|
472
995
|
}
|
|
473
996
|
return normalized
|
|
474
997
|
|
|
@@ -516,6 +1039,15 @@ def subrun_kwargs_for(
|
|
|
516
1039
|
if isinstance(github_repos, list):
|
|
517
1040
|
github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None
|
|
518
1041
|
|
|
1042
|
+
trustpilot_domain = _choose("trustpilot_domain", "trustpilot_domain")
|
|
1043
|
+
if isinstance(trustpilot_domain, str):
|
|
1044
|
+
trustpilot_domain = trustpilot_domain.strip() or None
|
|
1045
|
+
# Provenance: a plan-supplied domain is user-set (verbatim-final); one that
|
|
1046
|
+
# only came from auto_resolve is a hint that retries via search on a miss.
|
|
1047
|
+
trustpilot_domain_is_hint = bool(
|
|
1048
|
+
trustpilot_domain and not plan_entry.get("trustpilot_domain")
|
|
1049
|
+
)
|
|
1050
|
+
|
|
519
1051
|
context = plan_entry.get("context") or resolved.get("context") or ""
|
|
520
1052
|
|
|
521
1053
|
return {
|
|
@@ -524,20 +1056,98 @@ def subrun_kwargs_for(
|
|
|
524
1056
|
"subreddits": subreddits,
|
|
525
1057
|
"github_user": github_user,
|
|
526
1058
|
"github_repos": github_repos,
|
|
1059
|
+
"trustpilot_domain": trustpilot_domain,
|
|
1060
|
+
"_trustpilot_domain_is_hint": trustpilot_domain_is_hint,
|
|
527
1061
|
"_context": context,
|
|
528
1062
|
}
|
|
529
1063
|
|
|
530
1064
|
|
|
531
|
-
COMPETITORS_MIN =
|
|
532
|
-
COMPETITORS_MAX =
|
|
533
|
-
COMPETITORS_DEFAULT =
|
|
1065
|
+
COMPETITORS_MIN = competitors_mod.COMPETITORS_MIN
|
|
1066
|
+
COMPETITORS_MAX = competitors_mod.COMPETITORS_MAX
|
|
1067
|
+
COMPETITORS_DEFAULT = competitors_mod.COMPETITORS_DEFAULT
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def truncate_comparison_entities(entities: list[str], *, warn: bool = True) -> list[str]:
|
|
1071
|
+
"""Cap a vs-entity list at COMPARISON_ENTITY_MAX; optionally warn on stderr."""
|
|
1072
|
+
ceiling = competitors_mod.COMPARISON_ENTITY_MAX
|
|
1073
|
+
if len(entities) <= ceiling:
|
|
1074
|
+
return list(entities)
|
|
1075
|
+
kept = entities[:ceiling]
|
|
1076
|
+
dropped = entities[ceiling:]
|
|
1077
|
+
if warn:
|
|
1078
|
+
sys.stderr.write(
|
|
1079
|
+
f"[Competitors] vs-topic has {len(entities)} entities; "
|
|
1080
|
+
f"using first {ceiling}, dropped: {', '.join(dropped)}\n"
|
|
1081
|
+
)
|
|
1082
|
+
return kept
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def apply_vs_competitor_routing(
|
|
1086
|
+
topic: str,
|
|
1087
|
+
*,
|
|
1088
|
+
competitors_flag: int | None,
|
|
1089
|
+
comp_enabled: bool,
|
|
1090
|
+
comp_count: int,
|
|
1091
|
+
comp_explicit: list[str],
|
|
1092
|
+
comp_plan: dict[str, dict] | None = None,
|
|
1093
|
+
) -> tuple[str, bool, int, list[str]]:
|
|
1094
|
+
"""Apply vs-string / plan routing on top of resolve_competitors_args.
|
|
1095
|
+
|
|
1096
|
+
Precedence for *who* runs:
|
|
1097
|
+
1. ``--competitors-list`` (explicit peers; topic unchanged)
|
|
1098
|
+
2. Pure discover-N (``--competitors`` without list or plan) — topic
|
|
1099
|
+
unchanged, even if it contains ``vs``
|
|
1100
|
+
3. vs-string split (first entity becomes main topic) — used for bare
|
|
1101
|
+
vs-topics and vs-topic + ``--competitors-plan``
|
|
1102
|
+
4. ``--competitors-plan`` keys as peers when there is no vs-string
|
|
1103
|
+
(including when ``--competitors N`` is also set)
|
|
1104
|
+
"""
|
|
1105
|
+
from lib import planner as _planner
|
|
1106
|
+
|
|
1107
|
+
if comp_explicit:
|
|
1108
|
+
return topic, True, len(comp_explicit), list(comp_explicit)
|
|
1109
|
+
|
|
1110
|
+
# Preserve discover-N semantics: numeric flag without plan/list must not
|
|
1111
|
+
# rewrite a vs-string into named peers.
|
|
1112
|
+
if competitors_flag is not None and not comp_plan:
|
|
1113
|
+
return topic, True, comp_count, []
|
|
1114
|
+
|
|
1115
|
+
vs_entities = truncate_comparison_entities(
|
|
1116
|
+
_planner._comparison_entities(topic, uncapped=True),
|
|
1117
|
+
warn=True,
|
|
1118
|
+
)
|
|
1119
|
+
if len(vs_entities) >= 2:
|
|
1120
|
+
main, peers = vs_entities[0], vs_entities[1:]
|
|
1121
|
+
sys.stderr.write(
|
|
1122
|
+
f"[Competitors] vs-mode: routing to N-pass fanout: "
|
|
1123
|
+
f"{main} vs {' vs '.join(peers)}\n"
|
|
1124
|
+
)
|
|
1125
|
+
return main, True, len(peers), peers
|
|
1126
|
+
|
|
1127
|
+
if comp_plan:
|
|
1128
|
+
plan_peers = [
|
|
1129
|
+
(entry.get("_name") or key)
|
|
1130
|
+
for key, entry in comp_plan.items()
|
|
1131
|
+
]
|
|
1132
|
+
plan_peers = [name for name in plan_peers if name]
|
|
1133
|
+
if len(plan_peers) > COMPETITORS_MAX:
|
|
1134
|
+
sys.stderr.write(
|
|
1135
|
+
f"[Competitors] --competitors-plan has {len(plan_peers)} entries, "
|
|
1136
|
+
f"clamping to {COMPETITORS_MAX}.\n"
|
|
1137
|
+
)
|
|
1138
|
+
plan_peers = plan_peers[:COMPETITORS_MAX]
|
|
1139
|
+
return topic, True, len(plan_peers), plan_peers
|
|
1140
|
+
|
|
1141
|
+
return topic, comp_enabled, comp_count, comp_explicit
|
|
534
1142
|
|
|
535
1143
|
|
|
536
1144
|
def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
|
|
537
|
-
"""Normalize
|
|
1145
|
+
"""Normalize competitors flags into (enabled, count, explicit_list).
|
|
538
1146
|
|
|
539
|
-
- (False, 0, []) when neither flag is set.
|
|
540
|
-
- An explicit list always wins; count is derived from list length.
|
|
1147
|
+
- (False, 0, []) when neither flag, list, nor plan is set.
|
|
1148
|
+
- An explicit ``--competitors-list`` always wins; count is derived from list length.
|
|
1149
|
+
- ``--competitors-plan`` alone enables mode with an empty peer list; vs-routing
|
|
1150
|
+
fills peers from the vs-string or plan keys.
|
|
541
1151
|
- A numeric count outside [1, 6] is clamped with a stderr warning.
|
|
542
1152
|
- count <= 0 (explicit) raises SystemExit(2).
|
|
543
1153
|
"""
|
|
@@ -556,8 +1166,9 @@ def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[
|
|
|
556
1166
|
competitors_flag = args.competitors
|
|
557
1167
|
list_present = bool(explicit_list)
|
|
558
1168
|
flag_present = competitors_flag is not None
|
|
1169
|
+
plan_present = bool(getattr(args, "competitors_plan", None))
|
|
559
1170
|
|
|
560
|
-
if not list_present and not flag_present:
|
|
1171
|
+
if not list_present and not flag_present and not plan_present:
|
|
561
1172
|
return False, 0, []
|
|
562
1173
|
|
|
563
1174
|
if list_present:
|
|
@@ -575,19 +1186,22 @@ def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[
|
|
|
575
1186
|
count = COMPETITORS_MAX
|
|
576
1187
|
return True, count, explicit_list
|
|
577
1188
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
1189
|
+
if flag_present:
|
|
1190
|
+
count = competitors_flag
|
|
1191
|
+
if count < COMPETITORS_MIN:
|
|
1192
|
+
sys.stderr.write(
|
|
1193
|
+
f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
|
|
1194
|
+
)
|
|
1195
|
+
raise SystemExit(2)
|
|
1196
|
+
if count > COMPETITORS_MAX:
|
|
1197
|
+
sys.stderr.write(
|
|
1198
|
+
f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
|
|
1199
|
+
)
|
|
1200
|
+
count = COMPETITORS_MAX
|
|
1201
|
+
return True, count, []
|
|
1202
|
+
|
|
1203
|
+
# plan_present alone: enable; peers filled by apply_vs_competitor_routing.
|
|
1204
|
+
return True, 0, []
|
|
591
1205
|
|
|
592
1206
|
|
|
593
1207
|
def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
|
@@ -595,8 +1209,8 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
|
|
595
1209
|
missing = []
|
|
596
1210
|
if "reddit" not in available:
|
|
597
1211
|
missing.append("reddit")
|
|
598
|
-
|
|
599
|
-
|
|
1212
|
+
# X is optional. A successful run without X must reach the research output
|
|
1213
|
+
# without an authentication or browser-cookie promo in front of it.
|
|
600
1214
|
# The web promo nudges toward a paid backend for higher-quality web search.
|
|
601
1215
|
# Grounding is now available keyless on non-native hosts, so key the promo on
|
|
602
1216
|
# the absence of a *paid* backend, not on grounding availability. Suppress it
|
|
@@ -606,11 +1220,30 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
|
|
606
1220
|
missing.append("web")
|
|
607
1221
|
if not missing:
|
|
608
1222
|
return None
|
|
609
|
-
if "reddit" in missing and "x" in missing:
|
|
610
|
-
return "both"
|
|
611
1223
|
return missing[0]
|
|
612
1224
|
|
|
613
1225
|
|
|
1226
|
+
def _optional_x_omission_text(
|
|
1227
|
+
diag: dict[str, object],
|
|
1228
|
+
requested_sources: list[str] | None,
|
|
1229
|
+
) -> str | None:
|
|
1230
|
+
"""Return a non-blocking post-result note for a default run without X.
|
|
1231
|
+
|
|
1232
|
+
Explicit ``--search`` runs already define their intended source boundary,
|
|
1233
|
+
so they do not need an omission note. Doctor/diagnose remains the place for
|
|
1234
|
+
X setup or repair instructions.
|
|
1235
|
+
"""
|
|
1236
|
+
if requested_sources is not None:
|
|
1237
|
+
return None
|
|
1238
|
+
available = set(diag.get("available_sources") or [])
|
|
1239
|
+
if "x" in available:
|
|
1240
|
+
return None
|
|
1241
|
+
return (
|
|
1242
|
+
"Optional source omitted: X/Twitter was not enabled; research "
|
|
1243
|
+
"continued with the available sources."
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
|
|
614
1247
|
def _show_runtime_ui(
|
|
615
1248
|
report: schema.Report,
|
|
616
1249
|
progress: ui.ProgressDisplay,
|
|
@@ -672,32 +1305,28 @@ def _report_cache_ttl_seconds(config: dict[str, object]) -> int:
|
|
|
672
1305
|
|
|
673
1306
|
|
|
674
1307
|
def _is_report_cache_fresh(timestamp: object, ttl_seconds: int) -> bool:
|
|
675
|
-
|
|
676
|
-
return False
|
|
677
|
-
if not isinstance(timestamp, str) or not timestamp:
|
|
678
|
-
return False
|
|
679
|
-
try:
|
|
680
|
-
created_at = datetime.datetime.fromisoformat(timestamp)
|
|
681
|
-
except ValueError:
|
|
682
|
-
return False
|
|
683
|
-
if created_at.tzinfo is None:
|
|
684
|
-
created_at = created_at.replace(tzinfo=datetime.timezone.utc)
|
|
685
|
-
age = datetime.datetime.now(datetime.timezone.utc) - created_at.astimezone(
|
|
686
|
-
datetime.timezone.utc
|
|
687
|
-
)
|
|
688
|
-
return age.total_seconds() <= ttl_seconds
|
|
1308
|
+
return env.is_timestamp_fresh(timestamp, ttl_seconds)
|
|
689
1309
|
|
|
690
1310
|
|
|
691
1311
|
def _write_last_run(
|
|
692
1312
|
topic: str,
|
|
693
1313
|
report: "schema.Report",
|
|
694
1314
|
entity_reports: list[tuple[str, schema.Report]] | None = None,
|
|
695
|
-
|
|
1315
|
+
*,
|
|
1316
|
+
x_envelope_sha256: str | None = None,
|
|
1317
|
+
) -> bool:
|
|
1318
|
+
# ``x_envelope_sha256`` binds the cached report to the --x-posts file it
|
|
1319
|
+
# was built from; _load_last_report_cache misses on any mismatch.
|
|
696
1320
|
try:
|
|
697
1321
|
if env.CONFIG_DIR is None:
|
|
698
|
-
return
|
|
1322
|
+
return False
|
|
699
1323
|
target = env.CONFIG_DIR
|
|
700
|
-
|
|
1324
|
+
cached_reports = entity_reports or [(report.topic, report)]
|
|
1325
|
+
has_private_corpus = any(
|
|
1326
|
+
cached_report.items_by_source.get("corpus")
|
|
1327
|
+
for _, cached_report in cached_reports
|
|
1328
|
+
)
|
|
1329
|
+
_ensure_output_directory(target, private=has_private_corpus)
|
|
701
1330
|
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
|
702
1331
|
payload = {
|
|
703
1332
|
"topic": topic,
|
|
@@ -707,38 +1336,55 @@ def _write_last_run(
|
|
|
707
1336
|
"report_cache": str(target / "last-report.json"),
|
|
708
1337
|
"comparison": bool(entity_reports),
|
|
709
1338
|
}
|
|
710
|
-
(target / "last-run.json"
|
|
711
|
-
cached_reports = entity_reports or [(report.topic, report)]
|
|
1339
|
+
write_private_text(target / "last-run.json", json.dumps(payload, indent=2))
|
|
712
1340
|
cache_payload = {
|
|
713
1341
|
"schema": REPORT_CACHE_VERSION,
|
|
714
1342
|
"topic": topic,
|
|
715
1343
|
"timestamp": payload["timestamp"],
|
|
716
1344
|
"comparison": bool(entity_reports),
|
|
1345
|
+
"x_envelope_sha256": x_envelope_sha256 or None,
|
|
717
1346
|
"reports": [
|
|
718
1347
|
{"entity": label, "report": schema.to_dict(cached_report)}
|
|
719
1348
|
for label, cached_report in cached_reports
|
|
720
1349
|
],
|
|
721
1350
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
1351
|
+
report_cache_path = target / "last-report.json"
|
|
1352
|
+
write_private_text(report_cache_path, json.dumps(cache_payload, indent=2))
|
|
1353
|
+
if has_private_corpus and os.name != "posix":
|
|
1354
|
+
report_cache_path.chmod(0o600)
|
|
1355
|
+
return True
|
|
1356
|
+
except Exception as exc:
|
|
1357
|
+
# Never fatal, but never silent either (#787's lesson): callers that
|
|
1358
|
+
# promise cache state (drill chaining) branch on the return value.
|
|
1359
|
+
sys.stderr.write(f"[last30days] warning: could not write run cache: {exc}\n")
|
|
1360
|
+
return False
|
|
725
1361
|
|
|
726
1362
|
|
|
727
1363
|
def _load_last_report_cache(
|
|
728
|
-
topic: str,
|
|
1364
|
+
topic: str | None,
|
|
729
1365
|
ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS,
|
|
1366
|
+
*,
|
|
1367
|
+
x_envelope_sha256: str | None = None,
|
|
730
1368
|
) -> tuple[schema.Report, list[tuple[str, schema.Report]] | None, Path] | None:
|
|
731
1369
|
cache_path = _last_report_cache_path()
|
|
732
1370
|
if cache_path is None or not cache_path.exists():
|
|
733
1371
|
return None
|
|
734
1372
|
try:
|
|
735
1373
|
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
1374
|
+
if not isinstance(payload, dict):
|
|
1375
|
+
raise TypeError("report cache payload must be a JSON object")
|
|
736
1376
|
if payload.get("schema") != REPORT_CACHE_VERSION:
|
|
737
1377
|
return None
|
|
738
1378
|
if not _is_report_cache_fresh(payload.get("timestamp"), ttl_seconds):
|
|
739
1379
|
return None
|
|
1380
|
+
# A report built from a --x-posts envelope is only reusable with the
|
|
1381
|
+
# same envelope content; a digest on either side that does not match
|
|
1382
|
+
# the other is a miss.
|
|
1383
|
+
cached_digest = payload.get("x_envelope_sha256") or None
|
|
1384
|
+
if (cached_digest or x_envelope_sha256) and cached_digest != x_envelope_sha256:
|
|
1385
|
+
return None
|
|
740
1386
|
cached_topic = str(payload.get("topic") or "").strip().lower()
|
|
741
|
-
if cached_topic != topic.strip().lower():
|
|
1387
|
+
if topic is not None and cached_topic != topic.strip().lower():
|
|
742
1388
|
return None
|
|
743
1389
|
reports_payload = payload.get("reports") or []
|
|
744
1390
|
if not reports_payload:
|
|
@@ -757,30 +1403,1050 @@ def _load_last_report_cache(
|
|
|
757
1403
|
return None
|
|
758
1404
|
return entity_reports[0][1], entity_reports, cache_path
|
|
759
1405
|
return entity_reports[0][1], None, cache_path
|
|
760
|
-
except
|
|
1406
|
+
except (OSError, json.JSONDecodeError, KeyError, TypeError, ValueError) as exc:
|
|
1407
|
+
sys.stderr.write(
|
|
1408
|
+
f"[last30days] Could not read report cache {cache_path}: "
|
|
1409
|
+
f"{type(exc).__name__}: {exc}\n"
|
|
1410
|
+
)
|
|
761
1411
|
return None
|
|
762
1412
|
|
|
763
1413
|
|
|
764
|
-
def
|
|
765
|
-
|
|
1414
|
+
def _config_truthy(value: object) -> bool:
|
|
1415
|
+
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _freshness_enabled(args: argparse.Namespace, config: dict[str, object]) -> bool:
|
|
1419
|
+
if args.verify_freshness is not None:
|
|
1420
|
+
return bool(args.verify_freshness)
|
|
1421
|
+
return _config_truthy(config.get("LAST30DAYS_VERIFY_FRESHNESS"))
|
|
1422
|
+
|
|
1423
|
+
|
|
1424
|
+
def _update_cached_freshness(
|
|
1425
|
+
cache_path: Path,
|
|
766
1426
|
report: schema.Report,
|
|
767
1427
|
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1428
|
+
) -> bool:
|
|
1429
|
+
"""Rewrite cached report bodies without extending the research-cache TTL."""
|
|
1430
|
+
try:
|
|
1431
|
+
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
1432
|
+
if not isinstance(payload, dict) or payload.get("schema") != REPORT_CACHE_VERSION:
|
|
1433
|
+
return False
|
|
1434
|
+
existing = payload.get("reports") or []
|
|
1435
|
+
if entity_reports:
|
|
1436
|
+
cached_reports = entity_reports
|
|
1437
|
+
else:
|
|
1438
|
+
label = (
|
|
1439
|
+
str(existing[0].get("entity") or report.topic)
|
|
1440
|
+
if existing and isinstance(existing[0], dict)
|
|
1441
|
+
else report.topic
|
|
1442
|
+
)
|
|
1443
|
+
cached_reports = [(label, report)]
|
|
1444
|
+
payload["reports"] = [
|
|
1445
|
+
{"entity": label, "report": schema.to_dict(cached_report)}
|
|
1446
|
+
for label, cached_report in cached_reports
|
|
1447
|
+
]
|
|
1448
|
+
cache_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
1449
|
+
return True
|
|
1450
|
+
except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
|
|
1451
|
+
sys.stderr.write(
|
|
1452
|
+
f"[last30days] warning: could not update freshness cache: {exc}\n"
|
|
1453
|
+
)
|
|
1454
|
+
return False
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def _verify_report_set(
|
|
1458
|
+
report: schema.Report,
|
|
1459
|
+
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
1460
|
+
*,
|
|
1461
|
+
allow_network: bool,
|
|
1462
|
+
) -> None:
|
|
1463
|
+
reports = [item for _, item in entity_reports] if entity_reports else [report]
|
|
1464
|
+
for current_report in reports:
|
|
1465
|
+
freshness.verify_report(current_report, allow_network=allow_network)
|
|
1466
|
+
if not any(current_report.freshness_verdicts for current_report in reports):
|
|
1467
|
+
# An empty verdict list is a legitimate outcome, but a silent one has
|
|
1468
|
+
# already misled operators once; say why there is nothing to show.
|
|
1469
|
+
sys.stderr.write(
|
|
1470
|
+
"[last30days] Freshness verification found no re-checkable claims"
|
|
1471
|
+
" in this report; the verdict list is empty.\n"
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
|
|
1475
|
+
def _run_cached_freshness(
|
|
1476
|
+
args: argparse.Namespace,
|
|
1477
|
+
config: dict[str, object],
|
|
1478
|
+
) -> int:
|
|
1479
|
+
cached = _load_last_report_cache(
|
|
1480
|
+
None,
|
|
1481
|
+
ttl_seconds=_report_cache_ttl_seconds(config),
|
|
1482
|
+
)
|
|
1483
|
+
if cached is None:
|
|
1484
|
+
sys.stderr.write("[last30days] No fresh cached report; run a research pass first.\n")
|
|
1485
|
+
return 2
|
|
1486
|
+
report, entity_reports, cache_path = cached
|
|
1487
|
+
_verify_report_set(report, entity_reports, allow_network=not args.mock)
|
|
1488
|
+
if _update_cached_freshness(cache_path, report, entity_reports):
|
|
1489
|
+
sys.stderr.write(f"[last30days] Updated freshness verdicts in {cache_path}\n")
|
|
1490
|
+
else:
|
|
1491
|
+
sys.stderr.write("[last30days] warning: freshness cache update failed\n")
|
|
1492
|
+
return _render_save_and_print(args, report, entity_reports, None, config)
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
def _drill_config(config: dict[str, object], sources: list[str]) -> dict[str, object]:
|
|
1496
|
+
"""Enable configured comment enrichments for a deep follow-up."""
|
|
1497
|
+
drill_config = dict(config)
|
|
1498
|
+
include = {
|
|
1499
|
+
value.strip().lower()
|
|
1500
|
+
for value in str(config.get("INCLUDE_SOURCES") or "").split(",")
|
|
1501
|
+
if value.strip()
|
|
1502
|
+
}
|
|
1503
|
+
comment_flags = {
|
|
1504
|
+
"youtube": "youtube_comments",
|
|
1505
|
+
"tiktok": "tiktok_comments",
|
|
1506
|
+
"instagram": "instagram_comments",
|
|
1507
|
+
}
|
|
1508
|
+
include.update(comment_flags[source] for source in sources if source in comment_flags)
|
|
1509
|
+
if include:
|
|
1510
|
+
drill_config["INCLUDE_SOURCES"] = ",".join(sorted(include))
|
|
1511
|
+
drill_config["_drill_mode"] = True
|
|
1512
|
+
return drill_config
|
|
1513
|
+
|
|
1514
|
+
|
|
1515
|
+
def _run_drill(
|
|
1516
|
+
args: argparse.Namespace,
|
|
1517
|
+
config: dict[str, object],
|
|
1518
|
+
) -> int:
|
|
1519
|
+
from lib import planner
|
|
1520
|
+
|
|
1521
|
+
cached = _load_last_report_cache(
|
|
1522
|
+
None,
|
|
1523
|
+
ttl_seconds=_report_cache_ttl_seconds(config),
|
|
1524
|
+
)
|
|
1525
|
+
if cached is None:
|
|
1526
|
+
sys.stderr.write(
|
|
1527
|
+
"[last30days] No fresh cached report; run a research pass first.\n"
|
|
1528
|
+
)
|
|
1529
|
+
return 2
|
|
1530
|
+
report, entity_reports, cache_path = cached
|
|
1531
|
+
if entity_reports:
|
|
1532
|
+
sys.stderr.write(
|
|
1533
|
+
"[last30days] Drill mode needs a single-topic cached report; "
|
|
1534
|
+
"run a research pass for one entity first.\n"
|
|
1535
|
+
)
|
|
1536
|
+
return 2
|
|
1537
|
+
|
|
1538
|
+
lookback_days = args.lookback_days
|
|
1539
|
+
if lookback_days is None:
|
|
1540
|
+
range_from = datetime.date.fromisoformat(report.range_from)
|
|
1541
|
+
range_to = datetime.date.fromisoformat(report.range_to)
|
|
1542
|
+
lookback_days = (range_to - range_from).days
|
|
1543
|
+
as_of_date = args.as_of_date or report.range_to
|
|
1544
|
+
|
|
1545
|
+
try:
|
|
1546
|
+
matched_clusters = planner.resolve_drill_clusters(report, args.drill)
|
|
1547
|
+
drill_plan = planner.build_drill_plan(
|
|
1548
|
+
report,
|
|
1549
|
+
args.drill,
|
|
1550
|
+
clusters=matched_clusters,
|
|
1551
|
+
)
|
|
1552
|
+
except planner.DrillTargetError as exc:
|
|
1553
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
1554
|
+
return 2
|
|
1555
|
+
|
|
1556
|
+
sources = list(drill_plan.source_weights)
|
|
1557
|
+
drill_config = _drill_config(config, sources)
|
|
1558
|
+
diag = pipeline.diagnose(drill_config, sources, safe=False)
|
|
1559
|
+
progress = ui.ProgressDisplay(
|
|
1560
|
+
f"{report.topic} — drill: {args.drill}",
|
|
1561
|
+
show_banner=True,
|
|
1562
|
+
)
|
|
1563
|
+
progress.start_processing()
|
|
1564
|
+
resolved = report.artifacts.get("resolved") or {}
|
|
1565
|
+
try:
|
|
1566
|
+
drill_report = pipeline.run(
|
|
1567
|
+
# Keep source gating anchored to the cached entity (for example,
|
|
1568
|
+
# StockTwits needs the original cashtag/finance context). The
|
|
1569
|
+
# external drill plan below remains cluster-focused.
|
|
1570
|
+
topic=report.topic,
|
|
1571
|
+
config=drill_config,
|
|
1572
|
+
depth="deep",
|
|
1573
|
+
requested_sources=sources,
|
|
1574
|
+
mock=args.mock,
|
|
1575
|
+
x_handle=(
|
|
1576
|
+
(args.x_handle or resolved.get("x_handle") or None)
|
|
1577
|
+
if "x" in sources else None
|
|
1578
|
+
),
|
|
1579
|
+
x_related=(
|
|
1580
|
+
[value.strip() for value in args.x_related.split(",") if value.strip()]
|
|
1581
|
+
if (args.x_related and "x" in sources) else None
|
|
1582
|
+
),
|
|
1583
|
+
web_backend=args.web_backend,
|
|
1584
|
+
external_plan=schema.to_dict(drill_plan),
|
|
1585
|
+
subreddits=(
|
|
1586
|
+
([value.strip().removeprefix("r/") for value in args.subreddits.split(",") if value.strip()]
|
|
1587
|
+
if args.subreddits else list(resolved.get("subreddits") or []) or None)
|
|
1588
|
+
if "reddit" in sources else None
|
|
1589
|
+
),
|
|
1590
|
+
tiktok_hashtags=(
|
|
1591
|
+
[value.strip().lstrip("#") for value in args.tiktok_hashtags.split(",") if value.strip()]
|
|
1592
|
+
if args.tiktok_hashtags else None
|
|
1593
|
+
),
|
|
1594
|
+
tiktok_creators=(
|
|
1595
|
+
[value.strip().lstrip("@") for value in args.tiktok_creators.split(",") if value.strip()]
|
|
1596
|
+
if args.tiktok_creators else None
|
|
1597
|
+
),
|
|
1598
|
+
ig_creators=(
|
|
1599
|
+
[value.strip().lstrip("@") for value in args.ig_creators.split(",") if value.strip()]
|
|
1600
|
+
if args.ig_creators else None
|
|
1601
|
+
),
|
|
1602
|
+
lookback_days=lookback_days,
|
|
1603
|
+
as_of_date=as_of_date,
|
|
1604
|
+
github_user=(
|
|
1605
|
+
(args.github_user or resolved.get("github_user") or None)
|
|
1606
|
+
if "github" in sources else None
|
|
1607
|
+
),
|
|
1608
|
+
github_repos=(
|
|
1609
|
+
([value.strip() for value in args.github_repo.split(",") if value.strip()]
|
|
1610
|
+
if args.github_repo else list(resolved.get("github_repos") or []) or None)
|
|
1611
|
+
if "github" in sources else None
|
|
1612
|
+
),
|
|
1613
|
+
trustpilot_domain=(
|
|
1614
|
+
(args.trustpilot_domain or resolved.get("trustpilot_domain") or None)
|
|
1615
|
+
if "trustpilot" in sources else None
|
|
1616
|
+
),
|
|
1617
|
+
internal_subrun=True,
|
|
1618
|
+
corpus_dirs=args.corpus,
|
|
1619
|
+
corpus_all_time=args.corpus_all_time,
|
|
1620
|
+
)
|
|
1621
|
+
except Exception:
|
|
1622
|
+
progress.end_processing()
|
|
1623
|
+
raise
|
|
1624
|
+
|
|
1625
|
+
_show_runtime_ui(drill_report, progress, diag, suppress_web_promo=True)
|
|
1626
|
+
merged = pipeline.merge_drill_report(
|
|
1627
|
+
report,
|
|
1628
|
+
drill_report,
|
|
1629
|
+
matched_clusters,
|
|
1630
|
+
target=args.drill,
|
|
1631
|
+
)
|
|
1632
|
+
if _freshness_enabled(args, config):
|
|
1633
|
+
_verify_report_set(merged, None, allow_network=not args.mock)
|
|
1634
|
+
else:
|
|
1635
|
+
merged.freshness_verdicts = []
|
|
1636
|
+
if _write_last_run(report.topic, merged):
|
|
1637
|
+
sys.stderr.write(f"[last30days] Updated drill cache in {cache_path}\n")
|
|
1638
|
+
else:
|
|
1639
|
+
sys.stderr.write(
|
|
1640
|
+
"[last30days] warning: drill cache update failed; the next drill "
|
|
1641
|
+
"will see the pre-drill report\n"
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
store_default = str(
|
|
1645
|
+
os.environ.get("LAST30DAYS_STORE")
|
|
1646
|
+
or config.get("LAST30DAYS_STORE")
|
|
1647
|
+
or ""
|
|
1648
|
+
).lower()
|
|
1649
|
+
if args.store or store_default in {"1", "true", "yes"}:
|
|
1650
|
+
counts = persist_report(merged, store_db=_scoped_store_db(args))
|
|
1651
|
+
sys.stderr.write(
|
|
1652
|
+
f"[last30days] Stored {counts['new']} new, "
|
|
1653
|
+
f"{counts['updated']} updated findings\n"
|
|
1654
|
+
)
|
|
1655
|
+
|
|
1656
|
+
synthesis_md = None
|
|
1657
|
+
if args.synthesis_file:
|
|
1658
|
+
if args.emit == "html":
|
|
1659
|
+
synthesis_md = read_synthesis_file(args.synthesis_file)
|
|
1660
|
+
else:
|
|
1661
|
+
sys.stderr.write(
|
|
1662
|
+
"[last30days] Warning: --synthesis-file is only used with "
|
|
1663
|
+
"--emit=html; ignoring.\n"
|
|
1664
|
+
)
|
|
1665
|
+
return _render_save_and_print(args, merged, None, synthesis_md, config)
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _save_discovery_output(
|
|
1669
|
+
rendered: str,
|
|
1670
|
+
*,
|
|
1671
|
+
domain: str,
|
|
1672
|
+
emit: str,
|
|
1673
|
+
save_dir: str,
|
|
1674
|
+
suffix: str = "",
|
|
1675
|
+
) -> Path:
|
|
1676
|
+
directory = Path(save_dir).expanduser().resolve()
|
|
1677
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1678
|
+
extension = "json" if emit == "json" else "md"
|
|
1679
|
+
safe_suffix = sanitize_suffix(suffix)
|
|
1680
|
+
suffix_part = f"-{safe_suffix}" if safe_suffix else ""
|
|
1681
|
+
stem = f"{slugify(domain)}-discover-raw{suffix_part}"
|
|
1682
|
+
date_str = datetime.datetime.now().strftime("%Y-%m-%d")
|
|
1683
|
+
candidates = [directory / f"{stem}.{extension}", directory / f"{stem}-{date_str}.{extension}"]
|
|
1684
|
+
candidates.extend(directory / f"{stem}-{date_str}-{index}.{extension}" for index in range(1, 100))
|
|
1685
|
+
encoded = rendered.encode("utf-8")
|
|
1686
|
+
for candidate in candidates:
|
|
1687
|
+
try:
|
|
1688
|
+
fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
|
|
1689
|
+
except FileExistsError:
|
|
1690
|
+
continue
|
|
1691
|
+
with os.fdopen(fd, "wb") as output:
|
|
1692
|
+
output.write(encoded)
|
|
1693
|
+
return candidate
|
|
1694
|
+
raise RuntimeError("Could not find a unique discovery output filename")
|
|
1695
|
+
|
|
1696
|
+
|
|
1697
|
+
def _pre_run_prior_state(
|
|
1698
|
+
prior: dict[str, object] | None, run_ref: str
|
|
1699
|
+
) -> dict[str, object] | None:
|
|
1700
|
+
"""Reconstruct the queue state a topic had BEFORE this run identity
|
|
1701
|
+
recorded it.
|
|
1702
|
+
|
|
1703
|
+
A row whose last_run_ref equals THIS run's run_ref was stamped by this
|
|
1704
|
+
very run's own earlier attempt (a finalize retry), so its surface_count
|
|
1705
|
+
already includes this run's surfacing: subtract it and keep the prior's
|
|
1706
|
+
covered state (covered_at intact) so the retry renders exactly like the
|
|
1707
|
+
first attempt did. Only when nothing remains after the subtraction AND
|
|
1708
|
+
the row was never covered is the topic genuinely first-ever (no prior).
|
|
1709
|
+
"""
|
|
1710
|
+
if not prior or prior.get("last_run_ref") != run_ref:
|
|
1711
|
+
return prior
|
|
1712
|
+
previously = max(0, int(prior["surface_count"]) - 1)
|
|
1713
|
+
if previously == 0 and prior["status"] != "covered":
|
|
1714
|
+
return None
|
|
1715
|
+
adjusted = dict(prior)
|
|
1716
|
+
adjusted["surface_count"] = previously
|
|
1717
|
+
return adjusted
|
|
1718
|
+
|
|
1719
|
+
|
|
1720
|
+
def _annotate_and_record_discovery_queue(
|
|
1721
|
+
report: schema.DiscoveryReport,
|
|
1722
|
+
args: argparse.Namespace,
|
|
1723
|
+
config: dict[str, object],
|
|
1724
|
+
run_ref: str | None = None,
|
|
1725
|
+
) -> schema.DiscoveryReport:
|
|
1726
|
+
"""Stamp queue annotations onto report topics, then record this surfacing.
|
|
1727
|
+
|
|
1728
|
+
Order matters: annotations describe the queue state BEFORE this run, so
|
|
1729
|
+
each topic is matched first and recorded second. The queue is on by
|
|
1730
|
+
default; the resolved config value LAST30DAYS_DISCOVERY_QUEUE == "off"
|
|
1731
|
+
(env var or .env, via env.get_config) disables it. Scoped runs
|
|
1732
|
+
(--save-dir) write the scoped research.db, never the global one. Runs
|
|
1733
|
+
synchronously after the pipeline returns - this writes disk, so the
|
|
1734
|
+
abandon-on-timeout daemon-thread pattern is forbidden here.
|
|
1735
|
+
|
|
1736
|
+
``run_ref`` overrides the run identity: the finalize leg passes the
|
|
1737
|
+
pending report's leg-2 run_ref through so a finalize retry records (and
|
|
1738
|
+
annotates) as the SAME run - store.record_discovery_surfacing skips the
|
|
1739
|
+
double-count, and rows this very run identity stamped are not "prior"
|
|
1740
|
+
state, so retries render identically instead of claiming a resurfacing.
|
|
1741
|
+
"""
|
|
1742
|
+
queue_setting = str(config.get("LAST30DAYS_DISCOVERY_QUEUE") or "").strip().lower()
|
|
1743
|
+
if queue_setting == "off" or not report.topics:
|
|
1744
|
+
return report
|
|
1745
|
+
|
|
1746
|
+
import dataclasses
|
|
1747
|
+
|
|
1748
|
+
import store
|
|
1749
|
+
|
|
1750
|
+
run_ref = run_ref or f"discover:{report.domain or 'trending'}:{report.generated_at}"
|
|
1751
|
+
as_of = (report.generated_at or "")[:10] or report.range_to
|
|
1752
|
+
annotated: list[schema.DiscoveryTopic] = []
|
|
1753
|
+
with store.scoped_db(_scoped_store_db(args)):
|
|
1754
|
+
store.init_db()
|
|
1755
|
+
# Phase 1: match EVERY topic before recording ANY. Interleaving
|
|
1756
|
+
# match+record in one loop lets topic N fuzzy-match a same-anchor
|
|
1757
|
+
# sibling row this very run recorded seconds earlier, falsely
|
|
1758
|
+
# annotating a first-ever topic as "surfaced 2nd time".
|
|
1759
|
+
# A row stamped by THIS run identity is this run's own earlier
|
|
1760
|
+
# attempt (finalize retry), not prior state: reconstruct the pre-run
|
|
1761
|
+
# state (count minus this run's own surfacing, covered state kept)
|
|
1762
|
+
# so retries render identically for topics WITH history too.
|
|
1763
|
+
priors = [
|
|
1764
|
+
_pre_run_prior_state(prior, run_ref)
|
|
1765
|
+
for prior in (
|
|
1766
|
+
store.match_discovery_topic(topic.name) for topic in report.topics
|
|
1767
|
+
)
|
|
1768
|
+
]
|
|
1769
|
+
# Phase 2: record this run's surfacings. A topic whose (possibly
|
|
1770
|
+
# fuzzy) prior row is covered inherits that covered state, so a
|
|
1771
|
+
# user's covered mark survives judge naming drift instead of
|
|
1772
|
+
# silently forking into a fresh uncovered row.
|
|
1773
|
+
for topic, prior in zip(report.topics, priors):
|
|
1774
|
+
inherit_covered_at = None
|
|
1775
|
+
if prior and prior["status"] == "covered":
|
|
1776
|
+
inherit_covered_at = prior["covered_at"] or prior["last_surfaced"]
|
|
1777
|
+
store.record_discovery_surfacing(
|
|
1778
|
+
topic.name,
|
|
1779
|
+
domain=report.domain,
|
|
1780
|
+
run_ref=run_ref,
|
|
1781
|
+
as_of=as_of,
|
|
1782
|
+
inherit_covered_at=inherit_covered_at,
|
|
1783
|
+
)
|
|
1784
|
+
for topic, prior in zip(report.topics, priors):
|
|
1785
|
+
if prior:
|
|
1786
|
+
topic = dataclasses.replace(
|
|
1787
|
+
topic,
|
|
1788
|
+
previously_surfaced_count=prior["surface_count"],
|
|
1789
|
+
last_surfaced=prior["last_surfaced"],
|
|
1790
|
+
covered=prior["status"] == "covered",
|
|
1791
|
+
)
|
|
1792
|
+
annotated.append(topic)
|
|
1793
|
+
return dataclasses.replace(report, topics=annotated)
|
|
1794
|
+
|
|
1795
|
+
|
|
1796
|
+
def _record_discovery_queue_safely(
|
|
1797
|
+
report: schema.DiscoveryReport,
|
|
1798
|
+
args: argparse.Namespace,
|
|
1799
|
+
config: dict[str, object],
|
|
1800
|
+
run_ref: str | None = None,
|
|
1801
|
+
) -> schema.DiscoveryReport:
|
|
1802
|
+
"""Annotate + record the discovery queue, degrading a broken research.db
|
|
1803
|
+
(locked, read-only dir, corrupt) to a stderr warning: a queue failure
|
|
1804
|
+
must never destroy a finished pipeline run or the protocol's final
|
|
1805
|
+
brief. Shared verbatim by the one-shot and finalize paths."""
|
|
1806
|
+
try:
|
|
1807
|
+
return _annotate_and_record_discovery_queue(
|
|
1808
|
+
report, args, config, run_ref=run_ref,
|
|
1809
|
+
)
|
|
1810
|
+
except (sqlite3.Error, OSError) as exc:
|
|
1811
|
+
sys.stderr.write(
|
|
1812
|
+
f"[last30days] Warning: discovery queue unavailable ({exc}); "
|
|
1813
|
+
"continuing without queue annotations.\n"
|
|
1814
|
+
)
|
|
1815
|
+
return report
|
|
1816
|
+
|
|
1817
|
+
|
|
1818
|
+
def _emit_and_save_discovery_report(
|
|
1819
|
+
report: schema.DiscoveryReport,
|
|
1820
|
+
args: argparse.Namespace,
|
|
1821
|
+
domain: str,
|
|
1822
|
+
) -> None:
|
|
1823
|
+
"""Render a discovery report per --emit, honor --output/--save-dir, and
|
|
1824
|
+
print it. Shared verbatim by the one-shot and finalize paths."""
|
|
1825
|
+
if args.emit == "json":
|
|
1826
|
+
payload = schema.to_dict(report) if args.json_profile == "raw" else schema.to_discovery_export(report)
|
|
1827
|
+
rendered = json.dumps(payload, indent=2, sort_keys=True)
|
|
1828
|
+
else:
|
|
1829
|
+
rendered = render.render_discovery(report)
|
|
1830
|
+
|
|
1831
|
+
if args.output:
|
|
1832
|
+
output_path = save_rendered_output(rendered, args.output)
|
|
1833
|
+
sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
|
|
1834
|
+
if args.save_dir:
|
|
1835
|
+
save_path = _save_discovery_output(
|
|
1836
|
+
rendered,
|
|
1837
|
+
domain=domain or "trending",
|
|
1838
|
+
emit=args.emit,
|
|
1839
|
+
save_dir=args.save_dir,
|
|
1840
|
+
suffix=args.save_suffix or "",
|
|
1841
|
+
)
|
|
1842
|
+
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
|
1843
|
+
print(rendered)
|
|
1844
|
+
|
|
1845
|
+
|
|
1846
|
+
def _discovery_strict_exit_code(
|
|
1847
|
+
source_status: dict[str, schema.SourceOutcome],
|
|
1848
|
+
config: dict[str, object],
|
|
1849
|
+
) -> int:
|
|
1850
|
+
"""The ONE LAST30DAYS_STRICT_EXIT evaluation for every discovery
|
|
1851
|
+
invocation - the one-shot and all three protocol legs (issue #384's
|
|
1852
|
+
discovery counterpart). Rendering/output already happened by the time
|
|
1853
|
+
this runs; only the exit code shifts to 3 when strict exit is on and any
|
|
1854
|
+
source outcome is neither clean nor an expected skip."""
|
|
1855
|
+
strict = str(config.get("LAST30DAYS_STRICT_EXIT") or "").strip().lower()
|
|
1856
|
+
if strict not in {"1", "true", "yes", "on"}:
|
|
1857
|
+
return 0
|
|
1858
|
+
degraded = sorted(
|
|
1859
|
+
source for source, outcome in (source_status or {}).items()
|
|
1860
|
+
if outcome.state not in _STRICT_EXIT_OK_STATES
|
|
1861
|
+
)
|
|
1862
|
+
if not degraded:
|
|
1863
|
+
return 0
|
|
1864
|
+
sys.stderr.write(
|
|
1865
|
+
f"[last30days] strict-exit: degraded sources: {', '.join(degraded)}\n"
|
|
1866
|
+
)
|
|
1867
|
+
sys.stderr.flush()
|
|
1868
|
+
return 3
|
|
1869
|
+
|
|
1870
|
+
|
|
1871
|
+
def _require_discover_mock_parity(
|
|
1872
|
+
loaded_mock: bool,
|
|
1873
|
+
args_mock: bool,
|
|
1874
|
+
*,
|
|
1875
|
+
label: str,
|
|
1876
|
+
path: Path | None,
|
|
1877
|
+
) -> None:
|
|
1878
|
+
"""A protocol leg's --mock flag must match the loaded handoff file's
|
|
1879
|
+
stamped provenance: mock-born state finalized by a real run would fake a
|
|
1880
|
+
real brief from fixture data, and real state finalized by --mock would
|
|
1881
|
+
silently drop the round's queue write. Mismatch is a contract failure
|
|
1882
|
+
(exit 2 via HandoffContractError)."""
|
|
1883
|
+
if bool(loaded_mock) == bool(args_mock):
|
|
1884
|
+
return
|
|
1885
|
+
location = str(path) if path is not None else "(unknown path)"
|
|
1886
|
+
if loaded_mock:
|
|
1887
|
+
raise discovery_handoff.HandoffContractError(
|
|
1888
|
+
f"{label} {location} is mock-born (a --mock leg wrote it): "
|
|
1889
|
+
"mock-born state cannot be finalized by a real run. Re-run this "
|
|
1890
|
+
"leg with --mock, or start a fresh real `--discover "
|
|
1891
|
+
"--nominate-only` sweep."
|
|
1892
|
+
)
|
|
1893
|
+
raise discovery_handoff.HandoffContractError(
|
|
1894
|
+
f"{label} {location} was written by a real run: real state "
|
|
1895
|
+
"cannot be finalized by a --mock run. Drop --mock, or start a fresh "
|
|
1896
|
+
"`--discover --nominate-only --mock` sweep."
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
|
|
1900
|
+
def _run_queue_list(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
1901
|
+
"""List uncovered surfaced topics from the persistent discovery queue."""
|
|
1902
|
+
import store
|
|
1903
|
+
|
|
1904
|
+
db_path = _scoped_store_db(args)
|
|
1905
|
+
if not Path(db_path or store.DB_PATH).exists():
|
|
1906
|
+
print("Discovery queue is empty - no discovery run has recorded topics yet.")
|
|
1907
|
+
return 0
|
|
1908
|
+
with store.scoped_db(db_path):
|
|
1909
|
+
rows = store.list_discovery_queue(status="surfaced")
|
|
1910
|
+
if not rows:
|
|
1911
|
+
# An existing db with zero queue rows (e.g. created via --store)
|
|
1912
|
+
# means no discovery run has recorded anything - only claim
|
|
1913
|
+
# "every topic is covered" when covered rows actually exist.
|
|
1914
|
+
if store.list_discovery_queue():
|
|
1915
|
+
print("Discovery queue is empty - every surfaced topic is marked covered.")
|
|
1916
|
+
else:
|
|
1917
|
+
print("Discovery queue is empty - no discovery run has recorded topics yet.")
|
|
1918
|
+
return 0
|
|
1919
|
+
|
|
1920
|
+
headers = ("name", "domain", "surface_count", "last_surfaced", "status")
|
|
1921
|
+
table = [
|
|
1922
|
+
(
|
|
1923
|
+
str(row["name"]),
|
|
1924
|
+
str(row["domain"] or "-"),
|
|
1925
|
+
str(row["surface_count"]),
|
|
1926
|
+
str(row["last_surfaced"]),
|
|
1927
|
+
str(row["status"]),
|
|
1928
|
+
)
|
|
1929
|
+
for row in rows
|
|
1930
|
+
]
|
|
1931
|
+
widths = [
|
|
1932
|
+
max(len(headers[column]), *(len(row[column]) for row in table))
|
|
1933
|
+
for column in range(len(headers))
|
|
1934
|
+
]
|
|
1935
|
+
lines = [
|
|
1936
|
+
" ".join(header.ljust(widths[i]) for i, header in enumerate(headers)).rstrip(),
|
|
1937
|
+
" ".join("-" * widths[i] for i in range(len(headers))),
|
|
1938
|
+
]
|
|
1939
|
+
lines.extend(
|
|
1940
|
+
" ".join(row[i].ljust(widths[i]) for i in range(len(headers))).rstrip()
|
|
1941
|
+
for row in table
|
|
1942
|
+
)
|
|
1943
|
+
print("\n".join(lines))
|
|
1944
|
+
return 0
|
|
1945
|
+
|
|
1946
|
+
|
|
1947
|
+
def _run_queue_cover(
|
|
1948
|
+
args: argparse.Namespace,
|
|
1949
|
+
config: dict[str, object],
|
|
1950
|
+
name: str,
|
|
1951
|
+
) -> int:
|
|
1952
|
+
"""Mark a queued discovery topic covered; unknown names error loudly."""
|
|
1953
|
+
import store
|
|
1954
|
+
|
|
1955
|
+
if not name:
|
|
1956
|
+
sys.stderr.write(
|
|
1957
|
+
"[last30days] queue cover requires a topic name: "
|
|
1958
|
+
'queue cover "<topic name>".\n'
|
|
1959
|
+
)
|
|
1960
|
+
return 2
|
|
1961
|
+
db_path = _scoped_store_db(args)
|
|
1962
|
+
if not Path(db_path or store.DB_PATH).exists():
|
|
1963
|
+
sys.stderr.write(
|
|
1964
|
+
f"[last30days] No queued topic named {name!r}: the discovery queue "
|
|
1965
|
+
"is empty (no discovery run has recorded topics yet).\n"
|
|
1966
|
+
)
|
|
1967
|
+
return 2
|
|
1968
|
+
with store.scoped_db(db_path):
|
|
1969
|
+
row = store.mark_discovery_covered(
|
|
1970
|
+
name, as_of=datetime.date.today().isoformat()
|
|
1971
|
+
)
|
|
1972
|
+
if row is None:
|
|
1973
|
+
sys.stderr.write(
|
|
1974
|
+
f"[last30days] No queued topic named {name!r}. Covering requires "
|
|
1975
|
+
"the exact topic name; run 'queue list' to see queued names.\n"
|
|
1976
|
+
)
|
|
1977
|
+
return 2
|
|
1978
|
+
print(f"Marked covered: {row['name']} (covered {row['covered_at']})")
|
|
1979
|
+
return 0
|
|
1980
|
+
|
|
1981
|
+
|
|
1982
|
+
def _resolve_discovery_source_boundary(
|
|
1983
|
+
args: argparse.Namespace, config: dict[str, object],
|
|
1984
|
+
) -> tuple[list[str] | None, list[str] | None] | None:
|
|
1985
|
+
"""Resolve the discovery sweep's source lists from the user's boundary.
|
|
1986
|
+
|
|
1987
|
+
Returns ``(listing_sources, enrichment_boundary)`` - the discovery-capable
|
|
1988
|
+
subset for the sweep, and the user's ORIGINAL boundary honored by the
|
|
1989
|
+
per-topic research passes (which reach beyond the listing feeds - e.g.
|
|
1990
|
+
Techmeme, arXiv, YouTube, Polymarket); both None mean every available
|
|
1991
|
+
source. Returns None (after writing the exit-2 error) when the configured
|
|
1992
|
+
boundary leaves nothing to sweep: silently widening to all feeds would
|
|
1993
|
+
query sources the user filtered out.
|
|
1994
|
+
"""
|
|
1995
|
+
requested_sources = resolve_requested_sources(args.search, config)
|
|
1996
|
+
enrich_requested_sources = list(requested_sources) if requested_sources else None
|
|
1997
|
+
if requested_sources:
|
|
1998
|
+
discovery_sources = [
|
|
1999
|
+
source for source in requested_sources
|
|
2000
|
+
if source in pipeline.DISCOVERY_SOURCES
|
|
2001
|
+
]
|
|
2002
|
+
if not discovery_sources:
|
|
2003
|
+
origin = "--search" if args.search is not None else "LAST30DAYS_DEFAULT_SEARCH"
|
|
2004
|
+
sys.stderr.write(
|
|
2005
|
+
f"[last30days] {origin} has no discovery-capable sources "
|
|
2006
|
+
f"(unsupported: {', '.join(requested_sources)}); discovery "
|
|
2007
|
+
f"sweeps use: {', '.join(pipeline.DISCOVERY_SOURCES)}. Pass "
|
|
2008
|
+
"--search with one of those (or clear the source filter) to "
|
|
2009
|
+
"run a sweep.\n"
|
|
2010
|
+
)
|
|
2011
|
+
return None
|
|
2012
|
+
requested_sources = discovery_sources
|
|
2013
|
+
return requested_sources, enrich_requested_sources
|
|
2014
|
+
|
|
2015
|
+
|
|
2016
|
+
def _discover_subreddits(args: argparse.Namespace) -> list[str] | None:
|
|
2017
|
+
return (
|
|
2018
|
+
[value.strip().removeprefix("r/") for value in args.subreddits.split(",") if value.strip()]
|
|
2019
|
+
if args.subreddits else None
|
|
2020
|
+
)
|
|
2021
|
+
|
|
2022
|
+
|
|
2023
|
+
def _discover_domain(args: argparse.Namespace) -> str:
|
|
2024
|
+
"""The whitespace-normalized discovery domain; empty = global trending."""
|
|
2025
|
+
return " ".join(str(args.discover or "").split())
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def _run_discover(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
2029
|
+
domain = _discover_domain(args)
|
|
2030
|
+
# Empty domain = global trending: sweep every river feed's hot list with no
|
|
2031
|
+
# keyword gate. The confidence floor is what keeps junk out, not a keyword.
|
|
2032
|
+
# (--as-of and HTML rejection live in _main's shared --discover dispatch,
|
|
2033
|
+
# so every leg - one-shot or protocol - applies the same guards.)
|
|
2034
|
+
if args.synthesis_file:
|
|
2035
|
+
sys.stderr.write("[last30days] Warning: --synthesis-file is not used by discovery mode.\n")
|
|
2036
|
+
|
|
2037
|
+
boundary = _resolve_discovery_source_boundary(args, config)
|
|
2038
|
+
if boundary is None:
|
|
2039
|
+
return 2
|
|
2040
|
+
requested_sources, enrich_requested_sources = boundary
|
|
2041
|
+
subreddits = _discover_subreddits(args)
|
|
2042
|
+
depth = "deep" if args.deep else "quick" if args.quick else "default"
|
|
2043
|
+
try:
|
|
2044
|
+
report = pipeline.run_discover(
|
|
2045
|
+
domain=domain,
|
|
2046
|
+
config=config,
|
|
2047
|
+
depth=depth,
|
|
2048
|
+
requested_sources=requested_sources,
|
|
2049
|
+
mock=args.mock,
|
|
2050
|
+
subreddits=subreddits,
|
|
2051
|
+
lookback_days=args.lookback_days or 30,
|
|
2052
|
+
as_of_date=args.as_of_date,
|
|
2053
|
+
enrich=not args.discover_shallow,
|
|
2054
|
+
enrich_requested_sources=enrich_requested_sources,
|
|
2055
|
+
)
|
|
2056
|
+
except ValueError as exc:
|
|
2057
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
2058
|
+
return 2
|
|
2059
|
+
|
|
2060
|
+
# Persistent topic queue: annotate this report from prior surfacings, then
|
|
2061
|
+
# record this run's surfacings - BEFORE rendering/export so the Pipeline
|
|
2062
|
+
# line and the JSON queue fields see the annotations. Mock runs stay 100%
|
|
2063
|
+
# side-effect-free.
|
|
2064
|
+
if not args.mock:
|
|
2065
|
+
report = _record_discovery_queue_safely(report, args, config)
|
|
2066
|
+
|
|
2067
|
+
_emit_and_save_discovery_report(report, args, domain)
|
|
2068
|
+
return _discovery_strict_exit_code(report.source_status, config)
|
|
2069
|
+
|
|
2070
|
+
|
|
2071
|
+
def _discover_handoff_state_dir(args: argparse.Namespace) -> Path | None:
|
|
2072
|
+
"""One resolver for every protocol leg's handoff files: the save dir when
|
|
2073
|
+
given (mirroring _scoped_store_db's scoping), else the config dir - the
|
|
2074
|
+
same base _last_report_cache_path uses. args.save_dir is read AFTER the
|
|
2075
|
+
LAST30DAYS_MEMORY_DIR fallback in _main resolved it."""
|
|
2076
|
+
return discovery_handoff.handoff_state_dir(
|
|
2077
|
+
getattr(args, "save_dir", None), env.CONFIG_DIR
|
|
2078
|
+
)
|
|
2079
|
+
|
|
2080
|
+
|
|
2081
|
+
def _run_discover_nominate(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
2082
|
+
"""Protocol leg 1: sweep the listings, build the full judge pool, write
|
|
2083
|
+
the nominations bundle, and print the host-facing judging digest.
|
|
2084
|
+
|
|
2085
|
+
No stage-1 judge, enrichment, confidence floor, or queue writes happen on
|
|
2086
|
+
this leg - the host judges from the bundle and leg 2 (--judgments)
|
|
2087
|
+
resumes from it. A zero-nomination sweep short-circuits to the existing
|
|
2088
|
+
nothing-solid brief with NO bundle written: there is nothing to judge.
|
|
2089
|
+
Writing a fresh bundle starts a NEW protocol round, so any pending
|
|
2090
|
+
report left by a prior round is deleted alongside it.
|
|
2091
|
+
"""
|
|
2092
|
+
domain = _discover_domain(args)
|
|
2093
|
+
boundary = _resolve_discovery_source_boundary(args, config)
|
|
2094
|
+
if boundary is None:
|
|
2095
|
+
return 2
|
|
2096
|
+
requested_sources, enrich_requested_sources = boundary
|
|
2097
|
+
lookback_days = args.lookback_days or 30
|
|
2098
|
+
try:
|
|
2099
|
+
result = pipeline.run_discover_nominate(
|
|
2100
|
+
domain=domain,
|
|
2101
|
+
config=config,
|
|
2102
|
+
depth="deep" if args.deep else "quick" if args.quick else "default",
|
|
2103
|
+
requested_sources=requested_sources,
|
|
2104
|
+
mock=args.mock,
|
|
2105
|
+
subreddits=_discover_subreddits(args),
|
|
2106
|
+
lookback_days=lookback_days,
|
|
2107
|
+
as_of_date=args.as_of_date,
|
|
2108
|
+
)
|
|
2109
|
+
except ValueError as exc:
|
|
2110
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
2111
|
+
return 2
|
|
2112
|
+
|
|
2113
|
+
if not result.pool:
|
|
2114
|
+
print(render.render_discovery(pipeline.nominate_nothing_solid_report(result)))
|
|
2115
|
+
return _discovery_strict_exit_code(result.source_status, config)
|
|
2116
|
+
|
|
2117
|
+
entries = [
|
|
2118
|
+
discovery_handoff.PoolEntry(
|
|
2119
|
+
nomination=nomination,
|
|
2120
|
+
cluster_id=cluster_id,
|
|
2121
|
+
# No provider runs on this leg, so the nomination's name and junk
|
|
2122
|
+
# flag ARE the topic_shape heuristics - stored on the row as
|
|
2123
|
+
# leg 2's fallback for anything the host leaves unjudged.
|
|
2124
|
+
heuristic_name=nomination.name,
|
|
2125
|
+
heuristic_junk=nomination.junk_shape,
|
|
2126
|
+
)
|
|
2127
|
+
for nomination, cluster_id in result.pool
|
|
2128
|
+
]
|
|
2129
|
+
bundle = discovery_handoff.write_nominations_bundle(
|
|
2130
|
+
entries,
|
|
2131
|
+
domain=result.plan.domain,
|
|
2132
|
+
tier="shallow" if args.discover_shallow else "deep",
|
|
2133
|
+
from_date=result.from_date,
|
|
2134
|
+
to_date=result.to_date,
|
|
2135
|
+
lookback_days=lookback_days,
|
|
2136
|
+
enrichment_source_boundary=enrich_requested_sources,
|
|
2137
|
+
requested_sources=requested_sources,
|
|
2138
|
+
# The sweep's finalized per-source outcomes ride the bundle so legs
|
|
2139
|
+
# 2-3 report degraded coverage instead of silently reading clean; the
|
|
2140
|
+
# mock stamp keeps mock-born and real state from cross-finalizing.
|
|
2141
|
+
source_status=result.source_status,
|
|
2142
|
+
warnings=result.warnings,
|
|
2143
|
+
mock=args.mock,
|
|
2144
|
+
# Same resolution as _discover_handoff_state_dir: save dir when
|
|
2145
|
+
# given, else the config dir.
|
|
2146
|
+
save_dir=getattr(args, "save_dir", None),
|
|
2147
|
+
config_dir=env.CONFIG_DIR,
|
|
2148
|
+
)
|
|
2149
|
+
# A fresh bundle starts a NEW protocol round: a pending report left by a
|
|
2150
|
+
# prior round is cross-round state a bare --finalize could silently
|
|
2151
|
+
# consume - delete it (missing file is a no-op).
|
|
2152
|
+
state_dir = _discover_handoff_state_dir(args)
|
|
2153
|
+
if state_dir is not None:
|
|
2154
|
+
discovery_handoff.pending_report_path(state_dir).unlink(missing_ok=True)
|
|
2155
|
+
print(discovery_handoff.build_host_digest(bundle))
|
|
2156
|
+
print(
|
|
2157
|
+
"\nJudgments file schema (leg 2): "
|
|
2158
|
+
f'{{"bundle_id": "{bundle.bundle_id}", "judgments": '
|
|
2159
|
+
'[{"id": "n1", "name": "<short topic name>", "junk": false, '
|
|
2160
|
+
'"worthiness": 0-100}, ...]}. '
|
|
2161
|
+
"Then resume with: --discover --judgments <path>."
|
|
2162
|
+
)
|
|
2163
|
+
return _discovery_strict_exit_code(result.source_status, config)
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
def _run_discover_resume(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
2167
|
+
"""Protocol leg 2: resume from the nominations bundle, apply the host
|
|
2168
|
+
judgments file, run the deep per-topic research pass, and persist the
|
|
2169
|
+
ranked result as the pending report for leg 3 (--finalize).
|
|
2170
|
+
|
|
2171
|
+
Contract failures (missing/stale bundle, judgments not bound to it, a
|
|
2172
|
+
bundle whose mock provenance disagrees with this run's --mock flag, an
|
|
2173
|
+
unwritable pending-report path) raise HandoffContractError and map to
|
|
2174
|
+
exit 2 in _run_discover_protocol_leg. Zero floor survivors renders the
|
|
2175
|
+
nothing-solid brief right here (clearing any stale prior-round pending
|
|
2176
|
+
file): no pending file, no leg 3. No queue writes and no artifact saves
|
|
2177
|
+
happen on this leg - the topic queue and the rendered brief belong to
|
|
2178
|
+
leg 3.
|
|
2179
|
+
"""
|
|
2180
|
+
save_dir = getattr(args, "save_dir", None)
|
|
2181
|
+
bundle = discovery_handoff.read_nominations_bundle(
|
|
2182
|
+
save_dir=save_dir, config_dir=env.CONFIG_DIR,
|
|
2183
|
+
)
|
|
2184
|
+
_require_discover_mock_parity(
|
|
2185
|
+
bundle.mock, args.mock,
|
|
2186
|
+
label="Nominations bundle", path=bundle.path,
|
|
2187
|
+
)
|
|
2188
|
+
judgments = discovery_handoff.read_judgments(
|
|
2189
|
+
args.judgments, bundle, save_dir=save_dir, config_dir=env.CONFIG_DIR,
|
|
2190
|
+
)
|
|
2191
|
+
result = pipeline.run_discover_resume(
|
|
2192
|
+
bundle, judgments, config=config, mock=args.mock,
|
|
2193
|
+
)
|
|
2194
|
+
report = result.report
|
|
2195
|
+
|
|
2196
|
+
if not report.topics:
|
|
2197
|
+
# Nothing cleared the floor: the honest brief ends the protocol here.
|
|
2198
|
+
# This round wrote no pending file, so a stale one from an earlier
|
|
2199
|
+
# round must not survive to feed a bare --finalize (missing file is
|
|
2200
|
+
# a no-op).
|
|
2201
|
+
state_dir = _discover_handoff_state_dir(args)
|
|
2202
|
+
if state_dir is not None:
|
|
2203
|
+
discovery_handoff.pending_report_path(state_dir).unlink(missing_ok=True)
|
|
2204
|
+
print(render.render_discovery(report))
|
|
2205
|
+
return _discovery_strict_exit_code(report.source_status, config)
|
|
2206
|
+
|
|
2207
|
+
state_dir = _discover_handoff_state_dir(args)
|
|
2208
|
+
if state_dir is None:
|
|
2209
|
+
# Unreachable in practice - reading the bundle above required one of
|
|
2210
|
+
# these locations - but kept as a loud contract error, not an assert.
|
|
2211
|
+
raise discovery_handoff.HandoffContractError(
|
|
2212
|
+
"No handoff location available to write the pending report: "
|
|
2213
|
+
"pass --save-dir or configure ~/.config/last30days/."
|
|
2214
|
+
)
|
|
2215
|
+
pending_path = discovery_handoff.pending_report_path(state_dir)
|
|
2216
|
+
payload = {
|
|
2217
|
+
"kind": schema.DISCOVERY_PENDING_KIND,
|
|
2218
|
+
"schema_version": schema.DISCOVERY_PENDING_SCHEMA_VERSION,
|
|
2219
|
+
"bundle_id": bundle.bundle_id,
|
|
2220
|
+
# Fresh TTL clock: leg 3 measures staleness from THIS resume run,
|
|
2221
|
+
# not from the leg-1 sweep.
|
|
2222
|
+
"generated_at": report.generated_at,
|
|
2223
|
+
# Same run_ref format the queue records (leg 3 replays it verbatim).
|
|
2224
|
+
"run_ref": f"discover:{report.domain or 'trending'}:{report.generated_at}",
|
|
2225
|
+
# Leg-2 provenance: leg 3 refuses to finalize across the mock/real
|
|
2226
|
+
# boundary in either direction.
|
|
2227
|
+
"mock": bool(args.mock),
|
|
2228
|
+
# Full schema round-trip (the _write_last_run precedent): leg 3
|
|
2229
|
+
# rebuilds the report from this dict instead of re-running anything.
|
|
2230
|
+
"report": schema.to_dict(report),
|
|
2231
|
+
"angle_inputs": result.angle_inputs,
|
|
2232
|
+
}
|
|
2233
|
+
# ONE post-loop write from the main thread; enrichment workers are daemon
|
|
2234
|
+
# threads and never touch disk.
|
|
2235
|
+
try:
|
|
2236
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
2237
|
+
pending_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
2238
|
+
except OSError as exc:
|
|
2239
|
+
# A locked/read-only/full disk is the protocol's clean exit-2 path,
|
|
2240
|
+
# never a traceback (same contract as the bundle write).
|
|
2241
|
+
raise discovery_handoff.HandoffContractError(
|
|
2242
|
+
f"Could not write pending discovery report {pending_path}: {exc}"
|
|
2243
|
+
) from exc
|
|
2244
|
+
|
|
2245
|
+
print(
|
|
2246
|
+
f"Judged discovery resume: {len(report.topics)} topic"
|
|
2247
|
+
f"{'s' if len(report.topics) != 1 else ''} cleared the floor "
|
|
2248
|
+
f"(bundle_id {bundle.bundle_id})."
|
|
2249
|
+
)
|
|
2250
|
+
print(f"Pending report: {pending_path}")
|
|
2251
|
+
print("\nAngle inputs by nomination id:")
|
|
2252
|
+
print(json.dumps(result.angle_inputs, indent=2))
|
|
2253
|
+
print(
|
|
2254
|
+
"\nWrite the angles file (leg 3): "
|
|
2255
|
+
f'{{"bundle_id": "{bundle.bundle_id}", "angles": '
|
|
2256
|
+
'[{"id": "n1", "podcast": "<one-sentence hook>", '
|
|
2257
|
+
'"x_article": "<one-sentence hook>"}, ...]} - one row per topic id '
|
|
2258
|
+
"above.\n"
|
|
2259
|
+
"Then finalize with: --discover --finalize --angles <path>."
|
|
2260
|
+
)
|
|
2261
|
+
return _discovery_strict_exit_code(report.source_status, config)
|
|
2262
|
+
|
|
2263
|
+
|
|
2264
|
+
def _run_discover_finalize(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
2265
|
+
"""Protocol leg 3: load the leg-2 pending report, apply host angles,
|
|
2266
|
+
render the final brief, save discovery artifacts, and record the topic
|
|
2267
|
+
queue. The cheap offline leg - no sweep, no enrichment, no providers,
|
|
2268
|
+
no network; everything renders from the pending report. (HTML/--as-of
|
|
2269
|
+
rejection lives in _main's shared --discover dispatch.)
|
|
2270
|
+
|
|
2271
|
+
Contract failures (missing/stale/mismatched pending report or angles)
|
|
2272
|
+
raise HandoffContractError and map to exit 2 in
|
|
2273
|
+
_run_discover_protocol_leg. The pending file is deliberately LEFT IN
|
|
2274
|
+
PLACE on success: a finalize retry with a corrected angles file must
|
|
2275
|
+
keep working within the TTL, and the queue records under the pending
|
|
2276
|
+
report's leg-2 run_ref, so retries never double-count a surfacing.
|
|
2277
|
+
Mock finalize renders identically but writes no queue rows.
|
|
2278
|
+
"""
|
|
2279
|
+
import dataclasses
|
|
2280
|
+
|
|
2281
|
+
save_dir = getattr(args, "save_dir", None)
|
|
2282
|
+
pending = discovery_handoff.read_pending_report(
|
|
2283
|
+
save_dir=save_dir, config_dir=env.CONFIG_DIR,
|
|
2284
|
+
)
|
|
2285
|
+
_require_discover_mock_parity(
|
|
2286
|
+
pending.mock, args.mock,
|
|
2287
|
+
label="Pending discovery report", path=pending.path,
|
|
2288
|
+
)
|
|
2289
|
+
angles = discovery_handoff.read_angles(
|
|
2290
|
+
args.angles, pending, save_dir=save_dir, config_dir=env.CONFIG_DIR,
|
|
2291
|
+
)
|
|
2292
|
+
try:
|
|
2293
|
+
report = schema.discovery_report_from_dict(pending.report)
|
|
2294
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
2295
|
+
# The envelope validated but the report body is structurally
|
|
2296
|
+
# incomplete: a contract failure with the resume remedy, never a
|
|
2297
|
+
# traceback out of the finalize leg.
|
|
2298
|
+
raise discovery_handoff.HandoffContractError(
|
|
2299
|
+
f"Pending discovery report {pending.path} carries a malformed "
|
|
2300
|
+
f"report body ({type(exc).__name__}: {exc}). "
|
|
2301
|
+
f"{discovery_handoff._RESUME_REMEDY}"
|
|
2302
|
+
) from exc
|
|
2303
|
+
|
|
2304
|
+
if angles:
|
|
2305
|
+
# Host angles are keyed by nomination id; the pending report's
|
|
2306
|
+
# angle_inputs mapping carries each surviving id's applied topic
|
|
2307
|
+
# name, which is how angles land on the right DiscoveryTopic.
|
|
2308
|
+
angles_by_name = {
|
|
2309
|
+
name: host
|
|
2310
|
+
for nomination_id, host in angles.items()
|
|
2311
|
+
if (name := (pending.angle_inputs.get(nomination_id) or {}).get("name"))
|
|
2312
|
+
}
|
|
2313
|
+
report = dataclasses.replace(report, topics=[
|
|
2314
|
+
dataclasses.replace(
|
|
2315
|
+
topic,
|
|
2316
|
+
podcast_angle=host.podcast,
|
|
2317
|
+
x_article_angle=host.x_article,
|
|
2318
|
+
)
|
|
2319
|
+
if (host := angles_by_name.get(topic.name)) is not None
|
|
2320
|
+
else topic
|
|
2321
|
+
for topic in report.topics
|
|
2322
|
+
])
|
|
2323
|
+
|
|
2324
|
+
# Persistent topic queue: the protocol's ONE queue write happens here,
|
|
2325
|
+
# under the leg-2 run identity (pending.run_ref) so finalize retries are
|
|
2326
|
+
# idempotent. Mock runs stay 100% side-effect-free.
|
|
2327
|
+
if not args.mock:
|
|
2328
|
+
report = _record_discovery_queue_safely(
|
|
2329
|
+
report, args, config, run_ref=pending.run_ref or None,
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
_emit_and_save_discovery_report(report, args, report.domain)
|
|
2333
|
+
return _discovery_strict_exit_code(report.source_status, config)
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _run_discover_protocol_leg(
|
|
2337
|
+
args: argparse.Namespace, config: dict[str, object]
|
|
2338
|
+
) -> int:
|
|
2339
|
+
"""Route one validated protocol invocation to its leg. Contract failures
|
|
2340
|
+
(unreadable/stale/mismatched handoff files) map to stderr + exit 2 here,
|
|
2341
|
+
so the leg bodies (U3-U5) raise HandoffContractError freely."""
|
|
2342
|
+
try:
|
|
2343
|
+
if args.nominate_only:
|
|
2344
|
+
return _run_discover_nominate(args, config)
|
|
2345
|
+
# --judgments dispatch keys on flag presence (is not None), matching
|
|
2346
|
+
# the --discover convention: never on the path string's truthiness.
|
|
2347
|
+
if args.judgments is not None:
|
|
2348
|
+
return _run_discover_resume(args, config)
|
|
2349
|
+
return _run_discover_finalize(args, config)
|
|
2350
|
+
except discovery_handoff.HandoffContractError as exc:
|
|
2351
|
+
sys.stderr.write(f"[last30days] {exc.message}\n")
|
|
2352
|
+
return 2
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
_STRICT_EXIT_OK_STATES = {"ok", "no-results", "skipped-unconfigured"}
|
|
2356
|
+
|
|
2357
|
+
|
|
2358
|
+
def _strict_exit_code(
|
|
2359
|
+
report: schema.Report,
|
|
2360
|
+
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
2361
|
+
config: dict[str, object],
|
|
2362
|
+
) -> int:
|
|
2363
|
+
"""Opt-in machine-detectable degraded-run signal (issue #384).
|
|
2364
|
+
|
|
2365
|
+
When LAST30DAYS_STRICT_EXIT is truthy, a run whose report carries any
|
|
2366
|
+
source outcome that is neither clean nor a plain no-results exits 3 so
|
|
2367
|
+
cron/CI wrappers can distinguish degraded coverage from success. Default
|
|
2368
|
+
behavior (exit 0, warning rendered in the report footer) is unchanged.
|
|
2369
|
+
"""
|
|
2370
|
+
raw = str(config.get("LAST30DAYS_STRICT_EXIT") or "").strip().lower()
|
|
2371
|
+
if raw not in {"1", "true", "yes", "on"}:
|
|
2372
|
+
return 0
|
|
2373
|
+
reports = [report] + [rep for _, rep in (entity_reports or [])]
|
|
2374
|
+
degraded = sorted({
|
|
2375
|
+
name
|
|
2376
|
+
for rep in reports
|
|
2377
|
+
for name, outcome in (rep.source_status or {}).items()
|
|
2378
|
+
if outcome.state not in _STRICT_EXIT_OK_STATES
|
|
2379
|
+
})
|
|
2380
|
+
if not degraded:
|
|
2381
|
+
return 0
|
|
2382
|
+
sys.stderr.write(
|
|
2383
|
+
f"[last30days] strict-exit: degraded sources: {', '.join(degraded)}\n"
|
|
2384
|
+
)
|
|
2385
|
+
sys.stderr.flush()
|
|
2386
|
+
return 3
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
def _audience_register_for_run(
|
|
2390
|
+
args: argparse.Namespace,
|
|
2391
|
+
config: dict[str, object],
|
|
2392
|
+
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
2393
|
+
) -> registers.AudienceRegister:
|
|
2394
|
+
"""Resolve CLI > config for single-topic standard brief renderers."""
|
|
2395
|
+
|
|
2396
|
+
from lib import planner
|
|
2397
|
+
|
|
2398
|
+
topic = " ".join(getattr(args, "topic", [])).strip()
|
|
2399
|
+
comparison_topic_requested = bool(
|
|
2400
|
+
len(planner._comparison_entities(topic)) >= 2
|
|
2401
|
+
or args.competitors is not None
|
|
2402
|
+
or args.competitors_list
|
|
2403
|
+
or args.competitors_plan
|
|
2404
|
+
)
|
|
2405
|
+
if (
|
|
2406
|
+
entity_reports
|
|
2407
|
+
or comparison_topic_requested
|
|
2408
|
+
or args.drill
|
|
2409
|
+
or args.emit not in {"compact", "md", "html"}
|
|
2410
|
+
):
|
|
2411
|
+
return registers.get_register()
|
|
2412
|
+
explicit = getattr(args, "register", None)
|
|
2413
|
+
configured = config.get("LAST30DAYS_REGISTER")
|
|
2414
|
+
name = explicit or (str(configured) if configured else "default")
|
|
2415
|
+
# Preserve configs written by the pre-register ELI5 follow-up command.
|
|
2416
|
+
legacy_eli5 = str(config.get("ELI5_MODE") or "").strip().lower()
|
|
2417
|
+
if not explicit and not configured and legacy_eli5 in {"1", "true", "yes", "on"}:
|
|
2418
|
+
name = "eli5"
|
|
2419
|
+
return registers.get_register(name)
|
|
2420
|
+
|
|
2421
|
+
|
|
2422
|
+
def _render_save_and_print(
|
|
2423
|
+
args: argparse.Namespace,
|
|
2424
|
+
report: schema.Report,
|
|
2425
|
+
entity_reports: list[tuple[str, schema.Report]] | None,
|
|
2426
|
+
synthesis_md: str | None,
|
|
2427
|
+
config: dict[str, object],
|
|
2428
|
+
) -> int:
|
|
2429
|
+
fun_level = str(config.get("FUN_LEVEL", "medium")).lower()
|
|
2430
|
+
try:
|
|
2431
|
+
audience = _audience_register_for_run(args, config, entity_reports)
|
|
2432
|
+
except ValueError as exc:
|
|
2433
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
2434
|
+
return 2
|
|
2435
|
+
if audience.name != "default":
|
|
2436
|
+
sys.stderr.write(f"[last30days] Audience register: {audience.name}\n")
|
|
2437
|
+
sys.stderr.flush()
|
|
2438
|
+
# Comparison HTML is the one case where the saved file's title and content
|
|
2439
|
+
# have to be overridden away from the leading entity's report. Compute the
|
|
2440
|
+
# gate once so the footer-display and save-output paths can't disagree.
|
|
2441
|
+
is_comparison_html = bool(entity_reports) and args.emit == "html"
|
|
2442
|
+
footer_save_path = None
|
|
2443
|
+
if args.output:
|
|
2444
|
+
footer_save_path = compute_output_path_display(args.output)
|
|
2445
|
+
elif args.save_dir:
|
|
2446
|
+
save_topic_for_display = comparison_topic(entity_reports) if is_comparison_html else report.topic
|
|
2447
|
+
footer_save_path = compute_save_path_display(
|
|
2448
|
+
args.save_dir, save_topic_for_display, args.save_suffix or "", args.emit
|
|
2449
|
+
)
|
|
784
2450
|
|
|
785
2451
|
if entity_reports:
|
|
786
2452
|
rendered = emit_comparison_output(
|
|
@@ -789,6 +2455,7 @@ def _render_save_and_print(
|
|
|
789
2455
|
fun_level=fun_level,
|
|
790
2456
|
save_path=footer_save_path,
|
|
791
2457
|
synthesis_md=synthesis_md,
|
|
2458
|
+
json_profile=args.json_profile,
|
|
792
2459
|
)
|
|
793
2460
|
else:
|
|
794
2461
|
rendered = emit_output(
|
|
@@ -797,16 +2464,62 @@ def _render_save_and_print(
|
|
|
797
2464
|
fun_level=fun_level,
|
|
798
2465
|
save_path=footer_save_path,
|
|
799
2466
|
synthesis_md=synthesis_md,
|
|
2467
|
+
json_profile=args.json_profile,
|
|
2468
|
+
register=audience.name,
|
|
800
2469
|
)
|
|
2470
|
+
has_private_corpus = _report_has_private_corpus(report) or bool(
|
|
2471
|
+
entity_reports
|
|
2472
|
+
and any(_report_has_private_corpus(entity) for _label, entity in entity_reports)
|
|
2473
|
+
)
|
|
2474
|
+
private_saved_format = has_private_corpus
|
|
801
2475
|
publish_companion_paths: list[Path] = []
|
|
802
2476
|
if args.output:
|
|
803
|
-
output_path = save_rendered_output(
|
|
2477
|
+
output_path = save_rendered_output(
|
|
2478
|
+
rendered,
|
|
2479
|
+
args.output,
|
|
2480
|
+
private=private_saved_format,
|
|
2481
|
+
)
|
|
804
2482
|
if args.emit == "html":
|
|
805
2483
|
publish_companion_paths.append(output_path)
|
|
806
2484
|
sys.stderr.write(f"[last30days] Saved output to {output_path}\n")
|
|
807
2485
|
sys.stderr.flush()
|
|
808
2486
|
if args.save_dir:
|
|
809
2487
|
# Save the main topic's raw file (single-entity or comparison main).
|
|
2488
|
+
# Bind the render to the path save_output actually allocates so the
|
|
2489
|
+
# saved report and stdout agree even when collision fallback is used.
|
|
2490
|
+
def _render_with_actual_path(actual_path: Path) -> str:
|
|
2491
|
+
nonlocal rendered
|
|
2492
|
+
display = compute_output_path_display(str(actual_path))
|
|
2493
|
+
if entity_reports:
|
|
2494
|
+
rendered = emit_comparison_output(
|
|
2495
|
+
entity_reports,
|
|
2496
|
+
args.emit,
|
|
2497
|
+
fun_level=fun_level,
|
|
2498
|
+
save_path=display,
|
|
2499
|
+
synthesis_md=synthesis_md,
|
|
2500
|
+
json_profile=args.json_profile,
|
|
2501
|
+
)
|
|
2502
|
+
else:
|
|
2503
|
+
rendered = emit_output(
|
|
2504
|
+
report,
|
|
2505
|
+
args.emit,
|
|
2506
|
+
fun_level=fun_level,
|
|
2507
|
+
save_path=display,
|
|
2508
|
+
synthesis_md=synthesis_md,
|
|
2509
|
+
json_profile=args.json_profile,
|
|
2510
|
+
register=audience.name,
|
|
2511
|
+
)
|
|
2512
|
+
if args.emit not in {"json", "html"} and not entity_reports:
|
|
2513
|
+
# Markdown saves keep the complete debug artifact (all clusters
|
|
2514
|
+
# and per-source items), matching the render_fn-less path in
|
|
2515
|
+
# save_output and the comparison peer saves. Saving the compact
|
|
2516
|
+
# stdout render instead made most collected evidence
|
|
2517
|
+
# unrecoverable from the raw file (#923). The stdout re-render
|
|
2518
|
+
# above still runs so the visible footer cites the real path,
|
|
2519
|
+
# and the saved artifact carries the same citation.
|
|
2520
|
+
return render.render_full(report, save_path=display)
|
|
2521
|
+
return rendered
|
|
2522
|
+
|
|
810
2523
|
save_path = save_output(
|
|
811
2524
|
report,
|
|
812
2525
|
args.emit,
|
|
@@ -814,7 +2527,10 @@ def _render_save_and_print(
|
|
|
814
2527
|
suffix=args.save_suffix or "",
|
|
815
2528
|
synthesis_md=synthesis_md,
|
|
816
2529
|
topic_override=comparison_topic(entity_reports) if is_comparison_html else None,
|
|
817
|
-
|
|
2530
|
+
json_profile=args.json_profile,
|
|
2531
|
+
register=audience.name,
|
|
2532
|
+
private=private_saved_format,
|
|
2533
|
+
render_fn=_render_with_actual_path,
|
|
818
2534
|
)
|
|
819
2535
|
if args.emit == "html":
|
|
820
2536
|
publish_companion_paths.append(save_path)
|
|
@@ -828,6 +2544,8 @@ def _render_save_and_print(
|
|
|
828
2544
|
entity_report, args.emit, args.save_dir,
|
|
829
2545
|
suffix=args.save_suffix or "",
|
|
830
2546
|
synthesis_md=synthesis_md,
|
|
2547
|
+
json_profile=args.json_profile,
|
|
2548
|
+
private=_report_has_private_corpus(entity_report),
|
|
831
2549
|
)
|
|
832
2550
|
comparison_peer_paths.append(peer_path)
|
|
833
2551
|
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
|
@@ -839,9 +2557,40 @@ def _render_save_and_print(
|
|
|
839
2557
|
sys.stderr.flush()
|
|
840
2558
|
if args.publish_html:
|
|
841
2559
|
try:
|
|
2560
|
+
has_private_corpus = "corpus" in report.source_status or bool(
|
|
2561
|
+
entity_reports
|
|
2562
|
+
and any("corpus" in entity.source_status for _label, entity in entity_reports)
|
|
2563
|
+
)
|
|
2564
|
+
publish_rendered = rendered
|
|
2565
|
+
if has_private_corpus:
|
|
2566
|
+
sys.stderr.write(
|
|
2567
|
+
"[last30days] Excluding local corpus evidence and synthesis from published HTML.\n"
|
|
2568
|
+
)
|
|
2569
|
+
if entity_reports:
|
|
2570
|
+
publish_rendered = emit_comparison_output(
|
|
2571
|
+
[
|
|
2572
|
+
(label, schema.without_sources(entity, {"corpus"}))
|
|
2573
|
+
for label, entity in entity_reports
|
|
2574
|
+
],
|
|
2575
|
+
"html",
|
|
2576
|
+
fun_level=fun_level,
|
|
2577
|
+
save_path=footer_save_path,
|
|
2578
|
+
synthesis_md=None,
|
|
2579
|
+
json_profile=args.json_profile,
|
|
2580
|
+
)
|
|
2581
|
+
else:
|
|
2582
|
+
publish_rendered = emit_output(
|
|
2583
|
+
schema.without_sources(report, {"corpus"}),
|
|
2584
|
+
"html",
|
|
2585
|
+
fun_level=fun_level,
|
|
2586
|
+
save_path=footer_save_path,
|
|
2587
|
+
synthesis_md=None,
|
|
2588
|
+
json_profile=args.json_profile,
|
|
2589
|
+
register=audience.name,
|
|
2590
|
+
)
|
|
842
2591
|
publish_result = publish_rendered_html(
|
|
843
|
-
|
|
844
|
-
password=_publish_password_for_args(args),
|
|
2592
|
+
publish_rendered,
|
|
2593
|
+
password=_publish_password_for_args(args, config),
|
|
845
2594
|
companion_paths=publish_companion_paths,
|
|
846
2595
|
)
|
|
847
2596
|
sys.stderr.write(f"[last30days] Published HTML to {publish_result['url']}\n")
|
|
@@ -857,7 +2606,7 @@ def _render_save_and_print(
|
|
|
857
2606
|
sys.stderr.write(f"[last30days] HTML publish failed: {exc}\n")
|
|
858
2607
|
sys.stderr.flush()
|
|
859
2608
|
print(rendered)
|
|
860
|
-
return
|
|
2609
|
+
return _strict_exit_code(report, entity_reports, config)
|
|
861
2610
|
|
|
862
2611
|
|
|
863
2612
|
def _propagate_config_to_environ(config: dict[str, object]) -> None:
|
|
@@ -868,7 +2617,7 @@ def _propagate_config_to_environ(config: dict[str, object]) -> None:
|
|
|
868
2617
|
XAI_BASE_URL overrides are silently ignored. This is a no-op for
|
|
869
2618
|
keys that are already set in process env.
|
|
870
2619
|
"""
|
|
871
|
-
for key in ("OPENAI_BASE_URL", "XAI_BASE_URL"):
|
|
2620
|
+
for key in ("OPENAI_BASE_URL", "XAI_BASE_URL", "OPENROUTER_BASE_URL"):
|
|
872
2621
|
val = config.get(key)
|
|
873
2622
|
if val and not os.environ.get(key):
|
|
874
2623
|
os.environ[key] = val
|
|
@@ -883,57 +2632,578 @@ def _setup_allows_browser_cookies(args: argparse.Namespace, extra_argv: list[str
|
|
|
883
2632
|
)
|
|
884
2633
|
|
|
885
2634
|
|
|
886
|
-
SETUP_PASSTHROUGH_FLAGS = {
|
|
887
|
-
"--allow-browser-cookies",
|
|
888
|
-
"--device-auth",
|
|
889
|
-
"--github",
|
|
890
|
-
"--
|
|
891
|
-
|
|
2635
|
+
SETUP_PASSTHROUGH_FLAGS = {
|
|
2636
|
+
"--allow-browser-cookies",
|
|
2637
|
+
"--device-auth",
|
|
2638
|
+
"--github",
|
|
2639
|
+
"--github-start",
|
|
2640
|
+
"--github-poll",
|
|
2641
|
+
"--openclaw",
|
|
2642
|
+
"--store-key",
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
STORE_KEY_FLAG = "--store-key"
|
|
2646
|
+
|
|
2647
|
+
|
|
2648
|
+
def _split_store_key(extra_argv: list[str]) -> tuple[bool, str, list[str]]:
|
|
2649
|
+
"""Pull ``--store-key <NAME>`` / ``--store-key=<NAME>`` out of ``extra_argv``.
|
|
2650
|
+
|
|
2651
|
+
Returns ``(present, name, remaining)``. ``name`` is "" when the flag has
|
|
2652
|
+
no value; ``remaining`` is every other passthrough token, so the regular
|
|
2653
|
+
allowlist check still applies to them.
|
|
2654
|
+
"""
|
|
2655
|
+
present = False
|
|
2656
|
+
name = ""
|
|
2657
|
+
remaining: list[str] = []
|
|
2658
|
+
i = 0
|
|
2659
|
+
while i < len(extra_argv):
|
|
2660
|
+
arg = extra_argv[i]
|
|
2661
|
+
if arg == STORE_KEY_FLAG:
|
|
2662
|
+
present = True
|
|
2663
|
+
if i + 1 < len(extra_argv) and not extra_argv[i + 1].startswith("-"):
|
|
2664
|
+
name = extra_argv[i + 1]
|
|
2665
|
+
i += 2
|
|
2666
|
+
continue
|
|
2667
|
+
i += 1
|
|
2668
|
+
continue
|
|
2669
|
+
if arg.startswith(STORE_KEY_FLAG + "="):
|
|
2670
|
+
present = True
|
|
2671
|
+
name = arg[len(STORE_KEY_FLAG) + 1:]
|
|
2672
|
+
i += 1
|
|
2673
|
+
continue
|
|
2674
|
+
remaining.append(arg)
|
|
2675
|
+
i += 1
|
|
2676
|
+
return present, name, remaining
|
|
2677
|
+
|
|
2678
|
+
|
|
2679
|
+
# One credential line: longer than any real token, short enough that a
|
|
2680
|
+
# misdirected stream on stdin cannot grow memory.
|
|
2681
|
+
STORE_KEY_MAX_BYTES = 64 * 1024
|
|
2682
|
+
|
|
2683
|
+
|
|
2684
|
+
def _run_store_key(name: str) -> int:
|
|
2685
|
+
"""``setup --store-key <NAME>``: persist one allowlisted credential from stdin.
|
|
2686
|
+
|
|
2687
|
+
Reads exactly one line from stdin (bounded to ``STORE_KEY_MAX_BYTES``),
|
|
2688
|
+
strips whitespace, and writes it to the global ``.env`` as a 0o600 secret
|
|
2689
|
+
through ``setup_wizard.write_api_key``. An existing line for the same
|
|
2690
|
+
name is replaced, so a rejected credential can be rotated by running the
|
|
2691
|
+
command again. The value never reaches stdout or stderr: stdout carries
|
|
2692
|
+
``NAME=****`` plus a JSON line ``{"persisted": bool, "key": NAME}``. A
|
|
2693
|
+
name outside ``env.KEYCHAIN_KEYS`` or an empty value exits 2 without
|
|
2694
|
+
echoing anything.
|
|
2695
|
+
"""
|
|
2696
|
+
from lib import setup_wizard
|
|
2697
|
+
|
|
2698
|
+
if name not in env.KEYCHAIN_KEYS:
|
|
2699
|
+
# Do not enumerate the allowlist here: on an official-only host a
|
|
2700
|
+
# failure hint must not name the legacy credential keys.
|
|
2701
|
+
sys.stderr.write(
|
|
2702
|
+
"[last30days] setup --store-key: unknown or missing key name "
|
|
2703
|
+
"(must be a credential name the engine loads from its .env; "
|
|
2704
|
+
"see CONFIGURATION.md).\n"
|
|
2705
|
+
)
|
|
2706
|
+
return 2
|
|
2707
|
+
value = sys.stdin.readline(STORE_KEY_MAX_BYTES).strip()
|
|
2708
|
+
if not value:
|
|
2709
|
+
sys.stderr.write(
|
|
2710
|
+
f"[last30days] setup --store-key {name}: empty value on stdin; "
|
|
2711
|
+
"pipe the credential as a single line.\n"
|
|
2712
|
+
)
|
|
2713
|
+
return 2
|
|
2714
|
+
persisted = bool(
|
|
2715
|
+
setup_wizard.write_api_key(env.CONFIG_FILE, value, key_name=name, replace=True)
|
|
2716
|
+
)
|
|
2717
|
+
print(f"{name}=****")
|
|
2718
|
+
print(json.dumps({"persisted": persisted, "key": name}))
|
|
2719
|
+
return 0 if persisted else 1
|
|
2720
|
+
|
|
2721
|
+
SKILL_ONLY_FLAGS = {
|
|
2722
|
+
"--agent",
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
# Doctor passthrough: `doctor --json` / `doctor --cached` mirror the setup
|
|
2726
|
+
# passthrough pattern (neither is a global parser flag; they only mean
|
|
2727
|
+
# something to doctor). `--cached` serves the stored doctor-cache.json report
|
|
2728
|
+
# within its TTL and falls through to a live run otherwise.
|
|
2729
|
+
DOCTOR_PASSTHROUGH_FLAGS = {
|
|
2730
|
+
"--json",
|
|
2731
|
+
"--cached",
|
|
2732
|
+
"--postmortem",
|
|
2733
|
+
"--probe",
|
|
2734
|
+
}
|
|
2735
|
+
|
|
2736
|
+
|
|
2737
|
+
def _looks_inline_json(value: str) -> bool:
|
|
2738
|
+
"""True when a --x-posts argument is JSON text rather than a path."""
|
|
2739
|
+
stripped = value.strip()
|
|
2740
|
+
return stripped.startswith(("{", "[")) or "\n" in value
|
|
2741
|
+
|
|
2742
|
+
|
|
2743
|
+
def _comparison_requested(args: argparse.Namespace, topic: str) -> bool:
|
|
2744
|
+
"""Whether this invocation is a comparison run (vs-topic or competitor flags)."""
|
|
2745
|
+
from lib import planner as _planner
|
|
2746
|
+
|
|
2747
|
+
return any(
|
|
2748
|
+
value is not None
|
|
2749
|
+
for value in (args.competitors, args.competitors_list, args.competitors_plan)
|
|
2750
|
+
) or len(_planner._comparison_entities(topic, uncapped=True)) >= 2
|
|
2751
|
+
|
|
2752
|
+
|
|
2753
|
+
LAW7_HOST_PLAN_EXIT = 2
|
|
2754
|
+
|
|
2755
|
+
|
|
2756
|
+
def _law7_host_plan_message(signal: str) -> str:
|
|
2757
|
+
return (
|
|
2758
|
+
"[Planner] LAW 7: no --plan passed, and this run is agent-hosted "
|
|
2759
|
+
f"({signal} is set). The engine stopped before retrieval and did not "
|
|
2760
|
+
"call its internal planner. If you are the reasoning model hosting "
|
|
2761
|
+
"this skill, YOU ARE the planner; no API key or provider is needed. "
|
|
2762
|
+
"Write the JSON query plan (references/research-runbook.md Step 0.75) "
|
|
2763
|
+
"to a tmpfile with a "
|
|
2764
|
+
"quoted heredoc - QUERY_PLAN_FILE=$(mktemp "
|
|
2765
|
+
"\"${TMPDIR:-/tmp}/last30days-plan.XXXXXX\"); "
|
|
2766
|
+
"cat >| \"$QUERY_PLAN_FILE\" <<'PLAN_EOF' ... PLAN_EOF - and re-run "
|
|
2767
|
+
"with --plan \"$QUERY_PLAN_FILE\" (see Research Execution in that "
|
|
2768
|
+
"runbook). With no "
|
|
2769
|
+
"web-search tool, pass --auto-resolve instead. For headless or cron "
|
|
2770
|
+
f"runs under an agent, set {env.ALLOW_ENGINE_PLAN_VAR}=1 to let the "
|
|
2771
|
+
"engine plan internally.\n"
|
|
2772
|
+
)
|
|
2773
|
+
|
|
2774
|
+
|
|
2775
|
+
def _law7_host_plan_signal(args: argparse.Namespace, topic: str) -> str:
|
|
2776
|
+
"""Identify a blocked agent run before configuration probes run."""
|
|
2777
|
+
if not topic:
|
|
2778
|
+
return ""
|
|
2779
|
+
if args.plan or args.mock or args.hiring_signals or args.auto_resolve:
|
|
2780
|
+
return ""
|
|
2781
|
+
if _comparison_requested(args, topic):
|
|
2782
|
+
return ""
|
|
2783
|
+
if env.engine_plan_allowed():
|
|
2784
|
+
return ""
|
|
2785
|
+
return env.agent_host_signal()
|
|
2786
|
+
|
|
2787
|
+
|
|
2788
|
+
def _law7_host_plan_gate(args: argparse.Namespace, topic: str) -> int | None:
|
|
2789
|
+
"""Stop a plan-less agent research run after dispatch and cache exemptions."""
|
|
2790
|
+
signal = _law7_host_plan_signal(args, topic)
|
|
2791
|
+
if not signal:
|
|
2792
|
+
return None
|
|
2793
|
+
sys.stderr.write(_law7_host_plan_message(signal))
|
|
2794
|
+
return LAW7_HOST_PLAN_EXIT
|
|
2795
|
+
|
|
2796
|
+
|
|
2797
|
+
def _read_x_envelope(
|
|
2798
|
+
path: str,
|
|
2799
|
+
topic: str,
|
|
2800
|
+
args: argparse.Namespace,
|
|
2801
|
+
*,
|
|
2802
|
+
x_handle: str | None,
|
|
2803
|
+
x_related: list[str] | None,
|
|
2804
|
+
) -> x_envelope.Envelope:
|
|
2805
|
+
"""Validate a host-fetched X envelope against this run's window and topic."""
|
|
2806
|
+
from_date, to_date = dates.get_date_range(
|
|
2807
|
+
args.lookback_days or 30, as_of_date=args.as_of_date
|
|
2808
|
+
)
|
|
2809
|
+
return x_envelope.read(
|
|
2810
|
+
path,
|
|
2811
|
+
(from_date, to_date),
|
|
2812
|
+
topic,
|
|
2813
|
+
handles=[x_handle] if x_handle else [],
|
|
2814
|
+
related=[h for h in (x_related or []) if h and h.strip()],
|
|
2815
|
+
)
|
|
2816
|
+
|
|
2817
|
+
|
|
2818
|
+
def _attach_entity_envelopes(comp_plan: dict[str, dict], args: argparse.Namespace) -> None:
|
|
2819
|
+
"""Validate every per-entity ``x_posts`` path in a --competitors-plan.
|
|
2820
|
+
|
|
2821
|
+
Each envelope is checked against its own entity (topic) and that entry's
|
|
2822
|
+
``x_handle``/``x_related`` handles, and stored on the entry as
|
|
2823
|
+
``_x_envelope`` for the entity sub-run. Raises EnvelopeContractError.
|
|
2824
|
+
"""
|
|
2825
|
+
for entry in comp_plan.values():
|
|
2826
|
+
raw = entry.get("x_posts")
|
|
2827
|
+
if not raw:
|
|
2828
|
+
continue
|
|
2829
|
+
if not isinstance(raw, str) or _looks_inline_json(raw):
|
|
2830
|
+
raise x_envelope.EnvelopeContractError(
|
|
2831
|
+
f"--competitors-plan entry {entry.get('_name', '')!r}: x_posts must "
|
|
2832
|
+
"be a file path to a last30days-x-posts/1 envelope, never inline JSON. "
|
|
2833
|
+
"Rewrite the plan entry, or drop its x_posts field."
|
|
2834
|
+
)
|
|
2835
|
+
related = entry.get("x_related") if isinstance(entry.get("x_related"), list) else None
|
|
2836
|
+
entry["_x_envelope"] = _read_x_envelope(
|
|
2837
|
+
raw, str(entry.get("_name") or ""), args,
|
|
2838
|
+
x_handle=entry.get("x_handle") if isinstance(entry.get("x_handle"), str) else None,
|
|
2839
|
+
x_related=[str(h) for h in related] if related else None,
|
|
2840
|
+
)
|
|
2841
|
+
|
|
2842
|
+
|
|
2843
|
+
def _combine_envelope_digests(main_sha256: str | None, entity_sha256: dict[str, str]) -> str | None:
|
|
2844
|
+
"""One digest binding the last-report cache to every envelope a run uses.
|
|
2845
|
+
|
|
2846
|
+
A single top-level envelope is bound by its own file digest; per-entity
|
|
2847
|
+
comparison envelopes are folded, name-sorted, into one digest. Both the
|
|
2848
|
+
cache write (validated envelopes) and the cache lookup (planned paths)
|
|
2849
|
+
must go through here so a comparison cache can be reused.
|
|
2850
|
+
"""
|
|
2851
|
+
parts: list[str] = []
|
|
2852
|
+
if main_sha256:
|
|
2853
|
+
parts.append(main_sha256)
|
|
2854
|
+
for name in sorted(entity_sha256):
|
|
2855
|
+
parts.append(f"{name}:{entity_sha256[name]}")
|
|
2856
|
+
if not parts:
|
|
2857
|
+
return None
|
|
2858
|
+
if len(parts) == 1 and main_sha256:
|
|
2859
|
+
return main_sha256
|
|
2860
|
+
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()
|
|
2861
|
+
|
|
2862
|
+
|
|
2863
|
+
def _x_envelope_digest(
|
|
2864
|
+
main: x_envelope.Envelope | None, comp_plan: dict[str, dict] | None
|
|
2865
|
+
) -> str | None:
|
|
2866
|
+
"""Digest of the validated envelopes this run used (cache write side)."""
|
|
2867
|
+
entity_sha256 = {
|
|
2868
|
+
name: entry["_x_envelope"].sha256
|
|
2869
|
+
for name, entry in (comp_plan or {}).items()
|
|
2870
|
+
if entry.get("_x_envelope") is not None
|
|
2871
|
+
}
|
|
2872
|
+
return _combine_envelope_digests(main.sha256 if main is not None else None, entity_sha256)
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
def _validate_extra_argv(parser: argparse.ArgumentParser, topic: str, extra_argv: list[str]) -> None:
|
|
2876
|
+
if not extra_argv:
|
|
2877
|
+
return
|
|
2878
|
+
if topic.lower() == "setup":
|
|
2879
|
+
# --store-key carries a value token; the name itself is allowlisted
|
|
2880
|
+
# later in _run_store_key, not here.
|
|
2881
|
+
_, _, extra_argv = _split_store_key(extra_argv)
|
|
2882
|
+
unsupported = [arg for arg in extra_argv if arg not in SETUP_PASSTHROUGH_FLAGS]
|
|
2883
|
+
if unsupported:
|
|
2884
|
+
parser.error(
|
|
2885
|
+
"unsupported setup argument(s): "
|
|
2886
|
+
+ ", ".join(unsupported)
|
|
2887
|
+
+ f"; supported setup passthrough flags are {', '.join(sorted(SETUP_PASSTHROUGH_FLAGS))}"
|
|
2888
|
+
)
|
|
2889
|
+
return
|
|
2890
|
+
if topic.lower() == "doctor":
|
|
2891
|
+
unsupported = [arg for arg in extra_argv if arg not in DOCTOR_PASSTHROUGH_FLAGS]
|
|
2892
|
+
if unsupported:
|
|
2893
|
+
parser.error(
|
|
2894
|
+
"unsupported doctor argument(s): "
|
|
2895
|
+
+ ", ".join(unsupported)
|
|
2896
|
+
+ f"; supported doctor passthrough flags are {', '.join(sorted(DOCTOR_PASSTHROUGH_FLAGS))}"
|
|
2897
|
+
)
|
|
2898
|
+
return
|
|
2899
|
+
skill_only = [arg for arg in extra_argv if arg in SKILL_ONLY_FLAGS]
|
|
2900
|
+
other_unknown = [arg for arg in extra_argv if arg not in SKILL_ONLY_FLAGS]
|
|
2901
|
+
if skill_only:
|
|
2902
|
+
message = (
|
|
2903
|
+
"unsupported Python CLI argument(s): "
|
|
2904
|
+
+ ", ".join(skill_only)
|
|
2905
|
+
+ "; these are skill arguments and must not be forwarded to scripts/last30days.py"
|
|
2906
|
+
)
|
|
2907
|
+
if other_unknown:
|
|
2908
|
+
message += "; also unsupported: " + ", ".join(other_unknown)
|
|
2909
|
+
parser.error(message)
|
|
2910
|
+
parser.error("unsupported Python CLI argument(s): " + ", ".join(extra_argv))
|
|
2911
|
+
|
|
2912
|
+
|
|
2913
|
+
def _config_policy_for_args(args: argparse.Namespace, topic: str, extra_argv: list[str]) -> env.ConfigLoadPolicy:
|
|
2914
|
+
normalized_topic = topic.lower()
|
|
2915
|
+
is_library_command = (
|
|
2916
|
+
normalized_topic == "library feed"
|
|
2917
|
+
or normalized_topic == "library search"
|
|
2918
|
+
or normalized_topic.startswith("library search ")
|
|
2919
|
+
)
|
|
2920
|
+
# Queue commands are local SQLite reads/writes: like library commands they
|
|
2921
|
+
# must never trigger browser-cookie extraction or Keychain prompts.
|
|
2922
|
+
is_queue_command = (
|
|
2923
|
+
normalized_topic == "queue list"
|
|
2924
|
+
or normalized_topic == "queue cover"
|
|
2925
|
+
or normalized_topic.startswith("queue cover ")
|
|
2926
|
+
)
|
|
2927
|
+
is_cached_verification = bool(getattr(args, "verify_freshness", None)) and not normalized_topic
|
|
2928
|
+
if args.no_browser_cookies:
|
|
2929
|
+
browser_mode = "off"
|
|
2930
|
+
elif (
|
|
2931
|
+
args.diagnose or args.preflight or normalized_topic == "doctor"
|
|
2932
|
+
or is_library_command or is_queue_command or is_cached_verification
|
|
2933
|
+
):
|
|
2934
|
+
# doctor is plan-only like --diagnose: it must never read cookies.
|
|
2935
|
+
# Cache-only freshness verification hits only point APIs (Polymarket,
|
|
2936
|
+
# GitHub, StockTwits) - no cookie-backed source, so no Keychain prompt.
|
|
2937
|
+
browser_mode = "plan_only"
|
|
2938
|
+
elif normalized_topic == "setup":
|
|
2939
|
+
browser_mode = "read" if _setup_allows_browser_cookies(args, extra_argv) else "off"
|
|
2940
|
+
elif _law7_host_plan_signal(args, topic):
|
|
2941
|
+
# Hosted and cached paths need config before dispatch, but no browser read.
|
|
2942
|
+
browser_mode = "plan_only"
|
|
2943
|
+
else:
|
|
2944
|
+
browser_mode = "read"
|
|
2945
|
+
return env.ConfigLoadPolicy(
|
|
2946
|
+
browser_cookies=browser_mode,
|
|
2947
|
+
inspect_ignored_project_config=args.diagnose or args.preflight or normalized_topic == "doctor",
|
|
2948
|
+
)
|
|
2949
|
+
|
|
2950
|
+
|
|
2951
|
+
def _run_library_feed(args: argparse.Namespace, config: dict[str, object]) -> int:
|
|
2952
|
+
"""Generate the local research index/feed and optionally publish it."""
|
|
2953
|
+
from lib import feed, html_publish, library
|
|
2954
|
+
|
|
2955
|
+
if args.publish_html:
|
|
2956
|
+
sys.stderr.write(
|
|
2957
|
+
"[last30days] library feed uses --publish, not --publish-html.\n"
|
|
2958
|
+
)
|
|
2959
|
+
return 2
|
|
2960
|
+
if args.output:
|
|
2961
|
+
sys.stderr.write(
|
|
2962
|
+
"[last30days] library feed writes index.html and feed.xml to --save-dir; "
|
|
2963
|
+
"--output is not supported.\n"
|
|
2964
|
+
)
|
|
2965
|
+
return 2
|
|
2966
|
+
|
|
2967
|
+
memory_dir = Path(args.save_dir).expanduser() if args.save_dir else library.DEFAULT_MEMORY_DIR
|
|
2968
|
+
output_dir = memory_dir.resolve()
|
|
2969
|
+
# Scoped libraries (--save-dir) must not mix in the global briefing
|
|
2970
|
+
# archive: a client-specific or publishable feed pulling unrelated default
|
|
2971
|
+
# briefings could publish them publicly. The default library keeps the
|
|
2972
|
+
# archive; a scoped one reads only its own directory.
|
|
2973
|
+
briefs_dir = (
|
|
2974
|
+
library.DEFAULT_BRIEFS_DIR if not args.save_dir else memory_dir / "briefings"
|
|
2975
|
+
)
|
|
2976
|
+
entries, notes = library.scan_library(memory_dir, briefs_dir)
|
|
2977
|
+
feed_author = str(
|
|
2978
|
+
config.get("LAST30DAYS_LIBRARY_OWNER") or "last30days research library"
|
|
2979
|
+
)
|
|
2980
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
2981
|
+
library_id = library.get_or_create_library_id(output_dir)
|
|
2982
|
+
rendered_briefs_dir = output_dir / "briefs"
|
|
2983
|
+
has_private_entries = any(
|
|
2984
|
+
render.PRIVATE_CORPUS_START in entry.content for entry in entries
|
|
2985
|
+
)
|
|
2986
|
+
_ensure_output_directory(rendered_briefs_dir, private=has_private_entries)
|
|
2987
|
+
|
|
2988
|
+
def _preserve_hand_written_page(existing_path: Path, generated_marker: str) -> None:
|
|
2989
|
+
"""Back up any page library feed did not generate before overwriting it."""
|
|
2990
|
+
if not existing_path.exists():
|
|
2991
|
+
return
|
|
2992
|
+
try:
|
|
2993
|
+
marker_found = generated_marker in existing_path.read_text(encoding="utf-8")
|
|
2994
|
+
except (OSError, UnicodeDecodeError):
|
|
2995
|
+
marker_found = False
|
|
2996
|
+
if marker_found:
|
|
2997
|
+
return
|
|
2998
|
+
backup = existing_path.with_suffix(existing_path.suffix + ".bak")
|
|
2999
|
+
counter = 1
|
|
3000
|
+
while backup.exists():
|
|
3001
|
+
backup = existing_path.with_suffix(f"{existing_path.suffix}.bak{counter}")
|
|
3002
|
+
counter += 1
|
|
3003
|
+
existing_path.replace(backup)
|
|
3004
|
+
sys.stderr.write(
|
|
3005
|
+
f"[last30days] {existing_path.name} was not generated by "
|
|
3006
|
+
f"library feed; preserved the original at {backup.name}\n"
|
|
3007
|
+
)
|
|
3008
|
+
|
|
3009
|
+
publishable_brief_documents: dict[str, str] = {}
|
|
3010
|
+
for entry in entries:
|
|
3011
|
+
rendered = html_render.render_library_brief(entry)
|
|
3012
|
+
target = rendered_briefs_dir / entry.output_name
|
|
3013
|
+
_preserve_hand_written_page(target, html_render.LIBRARY_BRIEF_MARKER)
|
|
3014
|
+
save_rendered_output(
|
|
3015
|
+
rendered,
|
|
3016
|
+
str(target),
|
|
3017
|
+
private=render.PRIVATE_CORPUS_START in entry.content,
|
|
3018
|
+
)
|
|
3019
|
+
publishable_brief_documents[entry.entry_id] = html_render.render_library_brief(
|
|
3020
|
+
entry, include_private=False
|
|
3021
|
+
)
|
|
3022
|
+
|
|
3023
|
+
current_brief_names = {entry.output_name for entry in entries}
|
|
3024
|
+
for path in rendered_briefs_dir.glob("*.html"):
|
|
3025
|
+
is_orphan = path.name not in current_brief_names
|
|
3026
|
+
if not (is_orphan and library.is_generated_brief_name(path.name)):
|
|
3027
|
+
continue
|
|
3028
|
+
# A generated-looking name is not proof of ownership; only prune
|
|
3029
|
+
# pages that carry the renderer's own marker.
|
|
3030
|
+
try:
|
|
3031
|
+
generated = html_render.LIBRARY_BRIEF_MARKER in path.read_text(
|
|
3032
|
+
encoding="utf-8"
|
|
3033
|
+
)
|
|
3034
|
+
except (OSError, UnicodeDecodeError):
|
|
3035
|
+
generated = False
|
|
3036
|
+
if generated:
|
|
3037
|
+
path.unlink()
|
|
3038
|
+
|
|
3039
|
+
feed_xml = feed.render_atom(entries, library_id=library_id, author=feed_author)
|
|
3040
|
+
index_html = html_render.render_library_index(entries)
|
|
3041
|
+
feed_path = output_dir / "feed.xml"
|
|
3042
|
+
index_path = output_dir / "index.html"
|
|
3043
|
+
_preserve_hand_written_page(feed_path, "urn:last30days:research-library")
|
|
3044
|
+
_preserve_hand_written_page(
|
|
3045
|
+
index_path, "Generated locally by <strong>last30days</strong>"
|
|
3046
|
+
)
|
|
3047
|
+
feed_path.write_text(feed_xml, encoding="utf-8")
|
|
3048
|
+
index_path.write_text(index_html, encoding="utf-8")
|
|
3049
|
+
|
|
3050
|
+
for note in notes:
|
|
3051
|
+
sys.stderr.write(f"[last30days] Library note: {note}\n")
|
|
3052
|
+
sys.stderr.write(
|
|
3053
|
+
f"[last30days] Library feed generated {len(entries)} brief(s): "
|
|
3054
|
+
f"{index_path} and {feed_path}\n"
|
|
3055
|
+
)
|
|
3056
|
+
|
|
3057
|
+
if args.publish:
|
|
3058
|
+
password = _publish_password_for_args(args, config)
|
|
3059
|
+
entry_urls: dict[str, str] = {}
|
|
3060
|
+
try:
|
|
3061
|
+
brief_results = html_publish.publish_html_documents(
|
|
3062
|
+
publishable_brief_documents,
|
|
3063
|
+
password=password,
|
|
3064
|
+
)
|
|
3065
|
+
entry_urls = {
|
|
3066
|
+
entry_id: str(result["url"])
|
|
3067
|
+
for entry_id, result in brief_results.items()
|
|
3068
|
+
}
|
|
3069
|
+
if batch_error := getattr(brief_results, "error", None):
|
|
3070
|
+
raise batch_error
|
|
3071
|
+
published_index = html_render.render_library_index(
|
|
3072
|
+
entries,
|
|
3073
|
+
entry_urls=entry_urls,
|
|
3074
|
+
feed_url=None,
|
|
3075
|
+
)
|
|
3076
|
+
index_result = html_publish.publish_html(published_index, password=password)
|
|
3077
|
+
index_url = str(index_result["url"])
|
|
3078
|
+
except (html_publish.HtmlPublishError, KeyError, OSError) as exc:
|
|
3079
|
+
sys.stderr.write(f"[last30days] Library publish failed: {exc}\n")
|
|
3080
|
+
if entry_urls:
|
|
3081
|
+
sys.stderr.write(
|
|
3082
|
+
f"[last30days] Partial publish: {len(entry_urls)} public brief "
|
|
3083
|
+
"page(s) were created before the failure.\n"
|
|
3084
|
+
)
|
|
3085
|
+
return 1
|
|
3086
|
+
|
|
3087
|
+
# Keep the local artifacts useful as a record of the live publication.
|
|
3088
|
+
feed_path.write_text(
|
|
3089
|
+
feed.render_atom(
|
|
3090
|
+
entries,
|
|
3091
|
+
library_id=library_id,
|
|
3092
|
+
entry_urls=entry_urls,
|
|
3093
|
+
author=feed_author,
|
|
3094
|
+
),
|
|
3095
|
+
encoding="utf-8",
|
|
3096
|
+
)
|
|
3097
|
+
index_path.write_text(
|
|
3098
|
+
html_render.render_library_index(entries, entry_urls=entry_urls),
|
|
3099
|
+
encoding="utf-8",
|
|
3100
|
+
)
|
|
3101
|
+
sys.stderr.write(f"[last30days] Published library to {index_url}\n")
|
|
3102
|
+
sys.stderr.write(f"[last30days] Local Atom feed: {feed_path}\n")
|
|
3103
|
+
print(
|
|
3104
|
+
f"Library: {index_url}\nFeed: {feed_path}\n"
|
|
3105
|
+
"Atom feed is local; host feed.xml on any static host (for example, GitHub Pages) "
|
|
3106
|
+
"to make it subscribable."
|
|
3107
|
+
)
|
|
3108
|
+
return 0
|
|
3109
|
+
|
|
3110
|
+
print(
|
|
3111
|
+
f"Library: {index_path}\nFeed: {feed_path}\n"
|
|
3112
|
+
"Atom feed is local; host feed.xml on any static host (for example, GitHub Pages) "
|
|
3113
|
+
"to make it subscribable."
|
|
3114
|
+
)
|
|
3115
|
+
return 0
|
|
3116
|
+
|
|
892
3117
|
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
3118
|
+
def _run_library_search(
|
|
3119
|
+
args: argparse.Namespace,
|
|
3120
|
+
config: dict[str, object],
|
|
3121
|
+
query: str,
|
|
3122
|
+
) -> int:
|
|
3123
|
+
"""Search saved briefs and store sightings without network access."""
|
|
3124
|
+
from lib import library, library_index
|
|
896
3125
|
|
|
3126
|
+
if not query.strip():
|
|
3127
|
+
sys.stderr.write("[last30days] library search requires a non-empty query.\n")
|
|
3128
|
+
return 2
|
|
3129
|
+
if args.publish or args.publish_html:
|
|
3130
|
+
sys.stderr.write("[last30days] library search does not publish output.\n")
|
|
3131
|
+
return 2
|
|
3132
|
+
if args.emit != "compact":
|
|
3133
|
+
sys.stderr.write("[last30days] library search currently supports text output only.\n")
|
|
3134
|
+
return 2
|
|
3135
|
+
if args.output:
|
|
3136
|
+
sys.stderr.write(
|
|
3137
|
+
"[last30days] library search prints to stdout; --output is not supported.\n"
|
|
3138
|
+
)
|
|
3139
|
+
return 2
|
|
897
3140
|
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
3141
|
+
memory_dir = Path(args.save_dir).expanduser() if args.save_dir else library.DEFAULT_MEMORY_DIR
|
|
3142
|
+
try:
|
|
3143
|
+
matches, synced = library_index.sync_and_search(
|
|
3144
|
+
query,
|
|
3145
|
+
memory_dir=memory_dir,
|
|
3146
|
+
briefs_dir=(
|
|
3147
|
+
memory_dir / "briefings" if args.save_dir else library.DEFAULT_BRIEFS_DIR
|
|
3148
|
+
),
|
|
3149
|
+
db_path=(
|
|
3150
|
+
memory_dir.resolve() / ".last30days-library.db"
|
|
3151
|
+
if args.save_dir else library_index.DEFAULT_LIBRARY_DB
|
|
3152
|
+
),
|
|
3153
|
+
# A scoped search must never merge in the shared store: one
|
|
3154
|
+
# client's sightings would leak into another client's scope. A
|
|
3155
|
+
# scoped store is read only if it exists inside the save dir.
|
|
3156
|
+
store_db_path=(
|
|
3157
|
+
memory_dir.resolve() / "research.db"
|
|
3158
|
+
if args.save_dir else library_index.DEFAULT_STORE_DB
|
|
3159
|
+
),
|
|
917
3160
|
)
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
3161
|
+
except library_index.LibrarySearchUnavailable as exc:
|
|
3162
|
+
sys.stderr.write(f"[last30days] Library search unavailable: {exc}.\n")
|
|
3163
|
+
return 2
|
|
3164
|
+
except (OSError, sqlite3.DatabaseError) as exc:
|
|
3165
|
+
sys.stderr.write(f"[last30days] Library search failed: {exc}.\n")
|
|
3166
|
+
return 1
|
|
3167
|
+
for note in synced.notes:
|
|
3168
|
+
sys.stderr.write(f"[last30days] Library note: {note}\n")
|
|
3169
|
+
if synced.rebuilt:
|
|
3170
|
+
sys.stderr.write("[last30days] Rebuilt a corrupt library search index.\n")
|
|
3171
|
+
print(render.render_library_search(query, matches), end="")
|
|
3172
|
+
return 0
|
|
922
3173
|
|
|
923
3174
|
|
|
924
|
-
def
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
3175
|
+
def _looks_like_entity_topic(topic: str) -> bool:
|
|
3176
|
+
"""Whether a topic names a person, company, or product rather than a theme.
|
|
3177
|
+
|
|
3178
|
+
Keys on brevity, not capitalization. People type lowercase: "bentgo",
|
|
3179
|
+
"peter steinberger" and "getenergy.com" are entity searches every bit as
|
|
3180
|
+
much as their title-cased forms, and requiring a capital meant the most
|
|
3181
|
+
common real-world spelling never resolved a handle.
|
|
3182
|
+
|
|
3183
|
+
A short topic is an entity search; a longer one is a theme. "Peter
|
|
3184
|
+
Steinberger", "bentgo" and "getenergy.com" qualify; "best AI coding tools
|
|
3185
|
+
2026" and "how to build agents that scale" do not. Question-shaped topics
|
|
3186
|
+
are themes regardless of length.
|
|
3187
|
+
|
|
3188
|
+
Used only to decide whether resolving an X handle is worth one web search,
|
|
3189
|
+
so a false negative costs the old behavior and a false positive costs a
|
|
3190
|
+
single search.
|
|
3191
|
+
"""
|
|
3192
|
+
text = (topic or "").strip()
|
|
3193
|
+
if not text or text.endswith("?"):
|
|
3194
|
+
return False
|
|
3195
|
+
words = [w for w in re.findall(r"[A-Za-z0-9_.@'-]+", text) if w]
|
|
3196
|
+
if not words or len(words) > 4:
|
|
3197
|
+
return False
|
|
3198
|
+
if any(w.startswith("@") for w in words):
|
|
3199
|
+
return True
|
|
3200
|
+
# A theme reads as a phrase built from common words; an entity does not.
|
|
3201
|
+
common = {
|
|
3202
|
+
"best", "top", "how", "why", "what", "when", "vs", "versus", "guide",
|
|
3203
|
+
"tips", "review", "reviews", "news", "latest", "update", "updates",
|
|
3204
|
+
"trends", "tools", "and", "or", "for", "the", "with", "about",
|
|
3205
|
+
}
|
|
3206
|
+
return not any(w.lower() in common for w in words)
|
|
937
3207
|
|
|
938
3208
|
|
|
939
3209
|
def main() -> int:
|
|
@@ -941,13 +3211,106 @@ def main() -> int:
|
|
|
941
3211
|
# Use parse_known_args so setup sub-flags (--device-auth, --github,
|
|
942
3212
|
# --openclaw) pass through without argparse hard-exiting.
|
|
943
3213
|
args, extra_argv = parser.parse_known_args()
|
|
3214
|
+
if args.record_fixtures:
|
|
3215
|
+
with http.recording_requests(Path(args.record_fixtures)):
|
|
3216
|
+
return _main(parser, args, extra_argv)
|
|
3217
|
+
return _main(parser, args, extra_argv)
|
|
3218
|
+
|
|
3219
|
+
|
|
3220
|
+
def _quality_research_results(report, diag, yt_fetch_stats):
|
|
3221
|
+
youtube_items = report.items_by_source.get("youtube") or []
|
|
3222
|
+
instagram_items = report.items_by_source.get("instagram") or []
|
|
3223
|
+
x_outcome = report.source_status.get("x")
|
|
3224
|
+
x_degraded_error = None
|
|
3225
|
+
if (
|
|
3226
|
+
report.items_by_source.get("x")
|
|
3227
|
+
and x_outcome is not None
|
|
3228
|
+
and x_outcome.detail
|
|
3229
|
+
and x_outcome.detail.startswith("X served via ")
|
|
3230
|
+
and " after xai:" in x_outcome.detail
|
|
3231
|
+
):
|
|
3232
|
+
x_degraded_error = x_outcome.detail
|
|
3233
|
+
return {
|
|
3234
|
+
"active_sources": diag.get("available_sources") or [],
|
|
3235
|
+
"youtube_videos_count": len(youtube_items),
|
|
3236
|
+
"youtube_transcripts_count": sum(
|
|
3237
|
+
1 for it in youtube_items
|
|
3238
|
+
if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
|
|
3239
|
+
),
|
|
3240
|
+
"youtube_error": report.errors_by_source.get("youtube"),
|
|
3241
|
+
"x_error": report.errors_by_source.get("x"),
|
|
3242
|
+
"x_degraded_error": x_degraded_error,
|
|
3243
|
+
"youtube_captions_disabled_count": sum(
|
|
3244
|
+
1 for it in youtube_items if it.metadata.get("captions_disabled")
|
|
3245
|
+
),
|
|
3246
|
+
"youtube_transcript_fetch_attempts": yt_fetch_stats["attempts"],
|
|
3247
|
+
"youtube_transcript_fetch_failures": yt_fetch_stats["failures"],
|
|
3248
|
+
"instagram_items_count": len(instagram_items),
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
|
|
3252
|
+
def _main(
|
|
3253
|
+
parser: argparse.ArgumentParser,
|
|
3254
|
+
args: argparse.Namespace,
|
|
3255
|
+
extra_argv: list[str],
|
|
3256
|
+
) -> int:
|
|
944
3257
|
if args.debug:
|
|
945
3258
|
os.environ["LAST30DAYS_DEBUG"] = "1"
|
|
946
3259
|
|
|
3260
|
+
if args.welcome:
|
|
3261
|
+
from lib import setup_wizard
|
|
3262
|
+
print(setup_wizard.render_welcome())
|
|
3263
|
+
return 0
|
|
3264
|
+
|
|
947
3265
|
topic = " ".join(args.topic).strip()
|
|
948
3266
|
original_topic = topic
|
|
949
3267
|
_validate_extra_argv(parser, topic, extra_argv)
|
|
3268
|
+
if args.resolve_save_dir:
|
|
3269
|
+
print(env.resolve_memory_dir(args.save_dir))
|
|
3270
|
+
return 0
|
|
3271
|
+
if args.x_posts is not None and _looks_inline_json(args.x_posts):
|
|
3272
|
+
sys.stderr.write(
|
|
3273
|
+
"[last30days] --x-posts accepts a file path only (inline JSON is not "
|
|
3274
|
+
"accepted); write the envelope to a .json file and pass its path.\n"
|
|
3275
|
+
)
|
|
3276
|
+
return 2
|
|
3277
|
+
if args.publish and topic.lower() != "library feed":
|
|
3278
|
+
sys.stderr.write(
|
|
3279
|
+
"[last30days] --publish is only supported by the 'library feed' command.\n"
|
|
3280
|
+
)
|
|
3281
|
+
return 2
|
|
3282
|
+
if topic.lower() == "setup":
|
|
3283
|
+
# Persisting a credential needs no config load (no Keychain / pass
|
|
3284
|
+
# probes, no cookie policy), so it dispatches before get_config.
|
|
3285
|
+
store_key_present, store_key_name, _ = _split_store_key(extra_argv)
|
|
3286
|
+
if store_key_present:
|
|
3287
|
+
return _run_store_key(store_key_name)
|
|
3288
|
+
|
|
950
3289
|
config = env.get_config(policy=_config_policy_for_args(args, topic, extra_argv))
|
|
3290
|
+
if args.perplexity_search_type is not None:
|
|
3291
|
+
config["LAST30DAYS_PERPLEXITY_SEARCH_TYPE"] = args.perplexity_search_type
|
|
3292
|
+
# One memo per command: comparison mode runs pipeline.run per entity in
|
|
3293
|
+
# parallel, so the reset must not live inside the pipeline.
|
|
3294
|
+
http.reset_reddit_keyless_memo()
|
|
3295
|
+
reddit.reset_scrapecreators_memo()
|
|
3296
|
+
fetch_memo.reset()
|
|
3297
|
+
resolved_corpus_dirs = corpus.resolve_directories(
|
|
3298
|
+
args.corpus, config.get("LAST30DAYS_CORPUS_DIRS")
|
|
3299
|
+
)
|
|
3300
|
+
# EXCLUDE_SOURCES=corpus disables corpus retrieval entirely; the hosted
|
|
3301
|
+
# privacy bypass below must use the same predicate, or hosted users with
|
|
3302
|
+
# configured-but-excluded dirs silently lose the remote backend.
|
|
3303
|
+
excluded_sources = {
|
|
3304
|
+
value.strip().lower()
|
|
3305
|
+
for value in str(config.get("EXCLUDE_SOURCES") or "").split(",")
|
|
3306
|
+
if value.strip()
|
|
3307
|
+
}
|
|
3308
|
+
if "corpus" in excluded_sources:
|
|
3309
|
+
resolved_corpus_dirs = []
|
|
3310
|
+
if resolved_corpus_dirs:
|
|
3311
|
+
config["_CORPUS_DIRS"] = [str(path) for path in resolved_corpus_dirs]
|
|
3312
|
+
if _config_truthy(config.get("LAST30DAYS_CORPUS_IN_EXPORT")):
|
|
3313
|
+
config["_CORPUS_IN_EXPORT"] = True
|
|
951
3314
|
_propagate_config_to_environ(config)
|
|
952
3315
|
|
|
953
3316
|
# Env-var fallback for --save-dir, mirroring the LAST30DAYS_STORE pattern below.
|
|
@@ -984,6 +3347,30 @@ def main() -> int:
|
|
|
984
3347
|
print(permission_preflight.render_text(preflight), end="")
|
|
985
3348
|
return 0
|
|
986
3349
|
|
|
3350
|
+
# Handle doctor subcommand: topic-word dispatch mirroring setup (exact
|
|
3351
|
+
# match only, so multi-word research topics containing "doctor" still
|
|
3352
|
+
# research normally). Aggregates probes/descriptors/prescriptions into
|
|
3353
|
+
# one grouped health surface; always exits 0.
|
|
3354
|
+
if topic.lower() == "doctor":
|
|
3355
|
+
from lib import doctor
|
|
3356
|
+
return doctor.run(
|
|
3357
|
+
config,
|
|
3358
|
+
emit_json=(args.emit == "json" or "--json" in extra_argv),
|
|
3359
|
+
cached="--cached" in extra_argv,
|
|
3360
|
+
postmortem="--postmortem" in extra_argv,
|
|
3361
|
+
probe="--probe" in extra_argv,
|
|
3362
|
+
)
|
|
3363
|
+
|
|
3364
|
+
if topic.lower() == "library feed":
|
|
3365
|
+
return _run_library_feed(args, config)
|
|
3366
|
+
if topic.lower() == "library search" or topic.lower().startswith("library search "):
|
|
3367
|
+
return _run_library_search(args, config, topic[len("library search") :].strip())
|
|
3368
|
+
|
|
3369
|
+
if topic.lower() == "queue list":
|
|
3370
|
+
return _run_queue_list(args, config)
|
|
3371
|
+
if topic.lower() == "queue cover" or topic.lower().startswith("queue cover "):
|
|
3372
|
+
return _run_queue_cover(args, config, topic[len("queue cover") :].strip())
|
|
3373
|
+
|
|
987
3374
|
# Handle setup subcommand
|
|
988
3375
|
if topic.lower() == "setup":
|
|
989
3376
|
from lib import setup_wizard
|
|
@@ -991,8 +3378,12 @@ def main() -> int:
|
|
|
991
3378
|
results = setup_wizard.run_openclaw_setup(config)
|
|
992
3379
|
print(json.dumps(results))
|
|
993
3380
|
return 0
|
|
994
|
-
if
|
|
995
|
-
if "--github" in extra_argv:
|
|
3381
|
+
if any(f in extra_argv for f in ("--github", "--device-auth", "--github-start", "--github-poll")):
|
|
3382
|
+
if "--github-start" in extra_argv:
|
|
3383
|
+
results = setup_wizard.run_github_start()
|
|
3384
|
+
elif "--github-poll" in extra_argv:
|
|
3385
|
+
results = setup_wizard.run_github_poll()
|
|
3386
|
+
elif "--github" in extra_argv:
|
|
996
3387
|
results = setup_wizard.run_github_auth()
|
|
997
3388
|
else:
|
|
998
3389
|
results = setup_wizard.run_full_device_auth()
|
|
@@ -1000,8 +3391,16 @@ def main() -> int:
|
|
|
1000
3391
|
# run, and mask it in stdout so the secret never lands in the host
|
|
1001
3392
|
# model's captured Bash output.
|
|
1002
3393
|
api_key = results.get("api_key")
|
|
1003
|
-
|
|
1004
|
-
|
|
3394
|
+
status = results.get("status")
|
|
3395
|
+
if api_key:
|
|
3396
|
+
if status == "success":
|
|
3397
|
+
results["persisted"] = setup_wizard.write_api_key(env.CONFIG_FILE, api_key)
|
|
3398
|
+
elif status == "already_registered":
|
|
3399
|
+
results["persisted"] = True # key was already saved
|
|
3400
|
+
else:
|
|
3401
|
+
results.setdefault("persisted", False)
|
|
3402
|
+
# Mask for EVERY status that carries a key, not just success, so
|
|
3403
|
+
# the raw secret never reaches the host model's captured stdout.
|
|
1005
3404
|
results["api_key"] = setup_wizard.mask_api_key(api_key)
|
|
1006
3405
|
else:
|
|
1007
3406
|
results["persisted"] = False
|
|
@@ -1012,27 +3411,382 @@ def main() -> int:
|
|
|
1012
3411
|
config,
|
|
1013
3412
|
allow_browser_cookies=_setup_allows_browser_cookies(args, extra_argv),
|
|
1014
3413
|
)
|
|
1015
|
-
#
|
|
1016
|
-
#
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
3414
|
+
# Keep only successful browsers, including distinct service winners;
|
|
3415
|
+
# "auto" would also probe browsers that did not supply any cookies.
|
|
3416
|
+
found_browsers = dict.fromkeys(
|
|
3417
|
+
"firefox" if browser == "firefox-wsl" else browser
|
|
3418
|
+
for browser in results.get("cookies_found", {}).values()
|
|
3419
|
+
)
|
|
3420
|
+
from_browser = ",".join(found_browsers) or None
|
|
3421
|
+
denied_browsers = (
|
|
3422
|
+
results.get("x_cookie_access_denied", [])
|
|
3423
|
+
if results.get("browser_cookie_scan_attempted") else []
|
|
3424
|
+
)
|
|
3425
|
+
prior_denial = config.get("LAST30DAYS_X_COOKIE_ACCESS_DENIED")
|
|
3426
|
+
denial_marker = (
|
|
3427
|
+
",".join(denied_browsers) or ("none" if prior_denial else None)
|
|
3428
|
+
if results.get("browser_cookie_scan_attempted") else None
|
|
3429
|
+
)
|
|
3430
|
+
results["env_written"] = setup_wizard.write_setup_config(
|
|
3431
|
+
env.CONFIG_FILE,
|
|
3432
|
+
from_browser=from_browser,
|
|
3433
|
+
x_cookie_access_denied=denial_marker,
|
|
3434
|
+
browser_consent=(
|
|
3435
|
+
None if args.diagnose else _setup_allows_browser_cookies(args, extra_argv)
|
|
3436
|
+
),
|
|
3437
|
+
)
|
|
3438
|
+
if not results["env_written"]:
|
|
3439
|
+
sys.stderr.write("Setup configuration could not be fully saved; some settings may already be saved.\n")
|
|
3440
|
+
return 1
|
|
1026
3441
|
sys.stderr.write(setup_wizard.get_setup_status_text(results) + "\n")
|
|
1027
3442
|
return 0
|
|
1028
3443
|
|
|
1029
|
-
|
|
1030
|
-
|
|
3444
|
+
# Bare --discover (no domain) is global trending, so the dispatch keys on
|
|
3445
|
+
# "flag present" (is not None), never on the domain string's truthiness.
|
|
3446
|
+
if args.deep_research and not topic:
|
|
3447
|
+
sys.stderr.write(
|
|
3448
|
+
"[last30days] --deep-research requires a normal positional topic; "
|
|
3449
|
+
"it cannot be combined with discovery, drill, or cached-only modes.\n"
|
|
3450
|
+
)
|
|
3451
|
+
return 2
|
|
3452
|
+
|
|
3453
|
+
if args.discover is not None:
|
|
3454
|
+
if topic:
|
|
3455
|
+
sys.stderr.write(
|
|
3456
|
+
"[last30days] --discover supplies the domain and cannot be combined "
|
|
3457
|
+
"with a positional topic.\n"
|
|
3458
|
+
)
|
|
3459
|
+
return 2
|
|
3460
|
+
if args.drill:
|
|
3461
|
+
sys.stderr.write("[last30days] --discover and --drill are mutually exclusive.\n")
|
|
3462
|
+
return 2
|
|
3463
|
+
# Shared guards for EVERY discover invocation - the one-shot and all
|
|
3464
|
+
# three protocol legs - hoisted here so no leg can drift: discovery
|
|
3465
|
+
# sweeps live listings (never --as-of) and has no HTML pipeline yet.
|
|
3466
|
+
if args.as_of_date:
|
|
3467
|
+
sys.stderr.write(
|
|
3468
|
+
"[last30days] --as-of cannot be used with --discover because discovery "
|
|
3469
|
+
"sweeps current live listings.\n"
|
|
3470
|
+
)
|
|
3471
|
+
return 2
|
|
3472
|
+
if args.emit == "html" or args.publish_html:
|
|
3473
|
+
sys.stderr.write("[last30days] discovery mode does not support HTML publishing yet.\n")
|
|
3474
|
+
return 2
|
|
3475
|
+
# The three protocol legs are one-leg-per-invocation: each pairing
|
|
3476
|
+
# below asks for two legs at once, so name the combination and stop.
|
|
3477
|
+
# (--judgments/--angles dispatch on presence, never path truthiness.)
|
|
3478
|
+
for first, second, conflict in (
|
|
3479
|
+
("--nominate-only", "--judgments", args.nominate_only and args.judgments is not None),
|
|
3480
|
+
("--nominate-only", "--finalize", args.nominate_only and args.finalize),
|
|
3481
|
+
("--judgments", "--finalize", args.judgments is not None and args.finalize),
|
|
3482
|
+
):
|
|
3483
|
+
if conflict:
|
|
3484
|
+
sys.stderr.write(
|
|
3485
|
+
f"[last30days] {first} and {second} are mutually exclusive: "
|
|
3486
|
+
"each runs a different leg of the discovery protocol.\n"
|
|
3487
|
+
)
|
|
3488
|
+
return 2
|
|
3489
|
+
if args.angles is not None and not args.finalize:
|
|
3490
|
+
sys.stderr.write(
|
|
3491
|
+
"[last30days] --angles only applies to --discover --finalize "
|
|
3492
|
+
"runs; add --finalize or drop the flag.\n"
|
|
3493
|
+
)
|
|
3494
|
+
return 2
|
|
3495
|
+
protocol_leg = (
|
|
3496
|
+
args.nominate_only or args.judgments is not None or args.finalize
|
|
3497
|
+
)
|
|
3498
|
+
if protocol_leg and args.mock and not args.save_dir:
|
|
3499
|
+
# Truthiness is right here: an empty --save-dir/env value means
|
|
3500
|
+
# "no save dir", and handoff state would land in the real config
|
|
3501
|
+
# dir - a side effect mock runs must never have.
|
|
3502
|
+
sys.stderr.write(
|
|
3503
|
+
"[last30days] mock protocol legs require --save-dir to stay "
|
|
3504
|
+
"side-effect-free: --mock with --nominate-only/--judgments/"
|
|
3505
|
+
"--finalize would otherwise write handoff state into the real "
|
|
3506
|
+
"config dir.\n"
|
|
3507
|
+
)
|
|
3508
|
+
return 2
|
|
3509
|
+
if protocol_leg:
|
|
3510
|
+
return _run_discover_protocol_leg(args, config)
|
|
3511
|
+
return _run_discover(args, config)
|
|
3512
|
+
|
|
3513
|
+
if args.discover_shallow:
|
|
3514
|
+
# Without --discover this flag would silently no-op into a full
|
|
3515
|
+
# research run - reject it instead of ignoring the requested mode.
|
|
3516
|
+
sys.stderr.write(
|
|
3517
|
+
"[last30days] --discover-shallow only applies to --discover runs; "
|
|
3518
|
+
"add --discover [domain] or drop the flag.\n"
|
|
3519
|
+
)
|
|
3520
|
+
return 2
|
|
3521
|
+
|
|
3522
|
+
# Same orphan rule for every protocol-leg flag: without --discover each
|
|
3523
|
+
# would silently no-op into a normal research run.
|
|
3524
|
+
for flag_label, present in (
|
|
3525
|
+
("--nominate-only", args.nominate_only),
|
|
3526
|
+
("--judgments", args.judgments is not None),
|
|
3527
|
+
("--finalize", args.finalize),
|
|
3528
|
+
):
|
|
3529
|
+
if present:
|
|
3530
|
+
sys.stderr.write(
|
|
3531
|
+
f"[last30days] {flag_label} only applies to --discover runs; "
|
|
3532
|
+
"add --discover [domain] or drop the flag.\n"
|
|
3533
|
+
)
|
|
3534
|
+
return 2
|
|
3535
|
+
if args.angles is not None:
|
|
3536
|
+
sys.stderr.write(
|
|
3537
|
+
"[last30days] --angles only applies to --discover --finalize runs; "
|
|
3538
|
+
"add --discover --finalize or drop the flag.\n"
|
|
3539
|
+
)
|
|
3540
|
+
return 2
|
|
3541
|
+
|
|
3542
|
+
if args.drill:
|
|
3543
|
+
if topic:
|
|
3544
|
+
sys.stderr.write(
|
|
3545
|
+
"[last30days] --drill uses the cached topic and cannot be "
|
|
3546
|
+
"combined with a new topic.\n"
|
|
3547
|
+
)
|
|
3548
|
+
return 2
|
|
3549
|
+
if args.publish_html and args.emit != "html":
|
|
3550
|
+
sys.stderr.write("[last30days] --publish-html requires --emit=html\n")
|
|
3551
|
+
return 2
|
|
3552
|
+
if args.dedicated_subreddits:
|
|
3553
|
+
config["_dedicated_subreddits"] = [
|
|
3554
|
+
value.strip().removeprefix("r/")
|
|
3555
|
+
for value in args.dedicated_subreddits.split(",")
|
|
3556
|
+
if value.strip()
|
|
3557
|
+
]
|
|
3558
|
+
if args.polymarket_keywords:
|
|
3559
|
+
config["_polymarket_keywords"] = [
|
|
3560
|
+
value.strip().lower()
|
|
3561
|
+
for value in args.polymarket_keywords.split(",")
|
|
3562
|
+
if value.strip()
|
|
3563
|
+
]
|
|
3564
|
+
return _run_drill(args, config)
|
|
3565
|
+
|
|
3566
|
+
if args.verify_freshness and not topic:
|
|
3567
|
+
return _run_cached_freshness(args, config)
|
|
3568
|
+
|
|
3569
|
+
if args.lookback_days is None:
|
|
3570
|
+
args.lookback_days = 30
|
|
3571
|
+
|
|
3572
|
+
if args.deep_research and not args.diagnose:
|
|
3573
|
+
from lib import planner as _planner
|
|
3574
|
+
|
|
3575
|
+
if not (
|
|
3576
|
+
config.get("PERPLEXITY_API_KEY")
|
|
3577
|
+
or config.get("OPENROUTER_API_KEY")
|
|
3578
|
+
):
|
|
3579
|
+
print(
|
|
3580
|
+
"Error: --deep-research requires PERPLEXITY_API_KEY or "
|
|
3581
|
+
"OPENROUTER_API_KEY",
|
|
3582
|
+
file=sys.stderr,
|
|
3583
|
+
)
|
|
3584
|
+
return 1
|
|
3585
|
+
comparison_requested = any(
|
|
3586
|
+
value is not None
|
|
3587
|
+
for value in (
|
|
3588
|
+
args.competitors,
|
|
3589
|
+
args.competitors_list,
|
|
3590
|
+
args.competitors_plan,
|
|
3591
|
+
)
|
|
3592
|
+
) or len(_planner._comparison_entities(topic, uncapped=True)) >= 2
|
|
3593
|
+
if comparison_requested:
|
|
3594
|
+
sys.stderr.write(
|
|
3595
|
+
"Error: --deep-research cannot be combined with competitor or vs-mode. "
|
|
3596
|
+
"It permits one paid Deep Research run per user action; run each topic "
|
|
3597
|
+
"separately.\n"
|
|
3598
|
+
)
|
|
3599
|
+
return 2
|
|
3600
|
+
config["_deep_research"] = True
|
|
3601
|
+
try:
|
|
3602
|
+
enable_deep_research_source(config)
|
|
3603
|
+
except ValueError as exc:
|
|
3604
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
3605
|
+
return 2
|
|
3606
|
+
|
|
3607
|
+
# Reject a misspelled configured register before remote submission or any
|
|
3608
|
+
# local source retrieval. Excluded modes resolve to default and remain
|
|
3609
|
+
# unaffected by the register setting.
|
|
3610
|
+
try:
|
|
3611
|
+
_audience_register_for_run(args, config, None)
|
|
3612
|
+
except ValueError as exc:
|
|
3613
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
3614
|
+
return 2
|
|
3615
|
+
|
|
3616
|
+
# Remote API path: when BOTH LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are
|
|
3617
|
+
# set (and --mock is not), the search runs through the configured remote API
|
|
3618
|
+
# instead of local sources; no local provider keys are needed (see
|
|
3619
|
+
# lib/hosted.py). With either env var unset, behavior below is byte-identical
|
|
3620
|
+
# to local-only runs - there is no built-in endpoint.
|
|
3621
|
+
if (
|
|
3622
|
+
topic
|
|
3623
|
+
and resolved_corpus_dirs
|
|
3624
|
+
and env.read_secret_env("LAST30DAYS_API_KEY")
|
|
3625
|
+
and os.environ.get("LAST30DAYS_API_BASE")
|
|
3626
|
+
):
|
|
3627
|
+
sys.stderr.write(
|
|
3628
|
+
"[last30days] Local corpus configured; bypassing the hosted backend so files stay on this machine.\n"
|
|
3629
|
+
)
|
|
3630
|
+
# An explicit --perplexity-search-type is per-invocation intent the hosted
|
|
3631
|
+
# backend cannot honor, so it runs locally, but only when a direct
|
|
3632
|
+
# PERPLEXITY_API_KEY can apply it; otherwise the switch would trade hosted
|
|
3633
|
+
# coverage for nothing. Key on the parsed CLI flag only: a value from
|
|
3634
|
+
# LAST30DAYS_PERPLEXITY_SEARCH_TYPE must never move routing.
|
|
3635
|
+
elif (
|
|
3636
|
+
topic
|
|
3637
|
+
and args.perplexity_search_type is not None
|
|
3638
|
+
and config.get("PERPLEXITY_API_KEY")
|
|
3639
|
+
and not args.diagnose
|
|
3640
|
+
and not args.mock
|
|
3641
|
+
and not args.record_fixtures
|
|
3642
|
+
and not args.deep_research
|
|
3643
|
+
and env.read_secret_env("LAST30DAYS_API_KEY")
|
|
3644
|
+
and os.environ.get("LAST30DAYS_API_BASE")
|
|
3645
|
+
):
|
|
3646
|
+
sys.stderr.write(
|
|
3647
|
+
"[last30days] --perplexity-search-type set; bypassing the hosted backend "
|
|
3648
|
+
"because it does not apply the Perplexity search type.\n"
|
|
3649
|
+
)
|
|
3650
|
+
if (
|
|
3651
|
+
topic
|
|
3652
|
+
and not args.diagnose
|
|
3653
|
+
and not args.mock
|
|
3654
|
+
and not args.record_fixtures
|
|
3655
|
+
and env.read_secret_env("LAST30DAYS_API_KEY")
|
|
3656
|
+
and os.environ.get("LAST30DAYS_API_BASE")
|
|
3657
|
+
and not resolved_corpus_dirs
|
|
3658
|
+
and not args.deep_research
|
|
3659
|
+
and (args.perplexity_search_type is None or not config.get("PERPLEXITY_API_KEY"))
|
|
3660
|
+
):
|
|
3661
|
+
if args.perplexity_search_type is not None:
|
|
3662
|
+
sys.stderr.write(
|
|
3663
|
+
"hosted backend does not apply --perplexity-search-type and no direct "
|
|
3664
|
+
"PERPLEXITY_API_KEY is configured to run it locally; skipping\n"
|
|
3665
|
+
)
|
|
3666
|
+
elif config.get("LAST30DAYS_PERPLEXITY_SEARCH_TYPE"):
|
|
3667
|
+
sys.stderr.write(
|
|
3668
|
+
"hosted backend does not apply LAST30DAYS_PERPLEXITY_SEARCH_TYPE; skipping\n"
|
|
3669
|
+
)
|
|
3670
|
+
if _freshness_enabled(args, config):
|
|
3671
|
+
if args.verify_freshness is True:
|
|
3672
|
+
sys.stderr.write(
|
|
3673
|
+
"[last30days] Freshness verification is not supported by the hosted backend; "
|
|
3674
|
+
"run locally or omit --verify-freshness.\n"
|
|
3675
|
+
)
|
|
3676
|
+
return 2
|
|
3677
|
+
sys.stderr.write(
|
|
3678
|
+
"hosted backend does not support freshness verification; skipping\n"
|
|
3679
|
+
)
|
|
3680
|
+
if args.emit == "json" and args.json_profile == "agent":
|
|
3681
|
+
sys.stderr.write(
|
|
3682
|
+
"[last30days] --json-profile=agent requires the local Report; "
|
|
3683
|
+
"the remote API backend only supports --json-profile=raw.\n"
|
|
3684
|
+
)
|
|
3685
|
+
return 2
|
|
3686
|
+
if args.x_posts is not None:
|
|
3687
|
+
# The envelope is a local-engine contract; the remote API has no
|
|
3688
|
+
# lane to receive it.
|
|
3689
|
+
sys.stderr.write(
|
|
3690
|
+
"[last30days] --x-posts is not supported by the hosted backend; "
|
|
3691
|
+
"run locally or omit --x-posts.\n"
|
|
3692
|
+
)
|
|
3693
|
+
return 2
|
|
3694
|
+
from lib import hosted
|
|
3695
|
+
depth = "deep" if args.deep else "quick" if args.quick else "default"
|
|
3696
|
+
try:
|
|
3697
|
+
audience = _audience_register_for_run(args, config, None)
|
|
3698
|
+
except ValueError as exc:
|
|
3699
|
+
sys.stderr.write(f"[last30days] {exc}\n")
|
|
3700
|
+
return 2
|
|
3701
|
+
hosted_kwargs = {
|
|
3702
|
+
"emit": args.emit,
|
|
3703
|
+
"save_dir": args.save_dir,
|
|
3704
|
+
"save_suffix": args.save_suffix or "",
|
|
3705
|
+
}
|
|
3706
|
+
if audience.name != "default":
|
|
3707
|
+
hosted_kwargs["register"] = audience.name
|
|
3708
|
+
return hosted.run_hosted(topic, depth, **hosted_kwargs)
|
|
1031
3709
|
|
|
3710
|
+
requested_sources = resolve_requested_sources(args.search, config)
|
|
3711
|
+
if args.deep_research:
|
|
3712
|
+
requested_sources = add_deep_research_source(requested_sources)
|
|
3713
|
+
# Explicit --trustpilot-domain is user intent: activate the opt-in source
|
|
3714
|
+
# before diagnose/run so the flag cannot silently no-op (#873). Auto-resolve
|
|
3715
|
+
# hints are applied later and must not call this path.
|
|
3716
|
+
cli_trustpilot_domain = (
|
|
3717
|
+
args.trustpilot_domain.strip() if args.trustpilot_domain else ""
|
|
3718
|
+
)
|
|
3719
|
+
if cli_trustpilot_domain:
|
|
3720
|
+
requested_sources = activate_trustpilot_for_explicit_domain(
|
|
3721
|
+
config,
|
|
3722
|
+
requested_sources,
|
|
3723
|
+
reason=f"--trustpilot-domain={cli_trustpilot_domain}",
|
|
3724
|
+
)
|
|
3725
|
+
# Explicit --telegram-sources is user intent: activate the opt-in source
|
|
3726
|
+
# before diagnose/run so the flag cannot silently no-op (same pattern as
|
|
3727
|
+
# Trustpilot #873). Sets TELEGRAM_SOURCES in config for pipeline.
|
|
3728
|
+
cli_telegram_sources = (
|
|
3729
|
+
args.telegram_sources.strip() if args.telegram_sources else ""
|
|
3730
|
+
)
|
|
3731
|
+
if cli_telegram_sources:
|
|
3732
|
+
requested_sources = activate_telegram_for_explicit_sources(
|
|
3733
|
+
config,
|
|
3734
|
+
requested_sources,
|
|
3735
|
+
channels=cli_telegram_sources,
|
|
3736
|
+
)
|
|
3737
|
+
# --devto-tags activates the off-by-default dev.to source for this run
|
|
3738
|
+
# (the --trustpilot-domain pattern): carried on config for the dispatch,
|
|
3739
|
+
# and appended to an explicit or default --search list so it is not
|
|
3740
|
+
# filtered back out. EXCLUDE_SOURCES=devto still wins in the pipeline.
|
|
3741
|
+
cli_devto_tags = [
|
|
3742
|
+
t.strip() for t in (args.devto_tags or "").split(",") if t.strip()
|
|
3743
|
+
]
|
|
3744
|
+
if cli_devto_tags:
|
|
3745
|
+
config["_devto_tags"] = cli_devto_tags
|
|
3746
|
+
if requested_sources is not None and "devto" not in requested_sources:
|
|
3747
|
+
requested_sources = [*requested_sources, "devto"]
|
|
3748
|
+
# Host-fetched X envelope: validated before diagnose so a present
|
|
3749
|
+
# envelope plans X in (available_sources) and a bad one fails closed here.
|
|
3750
|
+
x_posts_envelope: x_envelope.Envelope | None = None
|
|
3751
|
+
if args.x_posts is not None:
|
|
3752
|
+
if not topic:
|
|
3753
|
+
sys.stderr.write("[last30days] --x-posts requires a research topic.\n")
|
|
3754
|
+
return 2
|
|
3755
|
+
if _comparison_requested(args, topic):
|
|
3756
|
+
sys.stderr.write(
|
|
3757
|
+
"[last30days] --x-posts applies to a single-topic run; on a "
|
|
3758
|
+
"comparison run pass each entity's envelope through the "
|
|
3759
|
+
"x_posts field of its --competitors-plan entry.\n"
|
|
3760
|
+
)
|
|
3761
|
+
return 2
|
|
3762
|
+
try:
|
|
3763
|
+
x_posts_envelope = _read_x_envelope(
|
|
3764
|
+
args.x_posts, topic, args,
|
|
3765
|
+
x_handle=args.x_handle,
|
|
3766
|
+
x_related=args.x_related.split(",") if args.x_related else None,
|
|
3767
|
+
)
|
|
3768
|
+
except x_envelope.EnvelopeContractError as exc:
|
|
3769
|
+
sys.stderr.write(f"[last30days] {exc.message}\n")
|
|
3770
|
+
return 2
|
|
1032
3771
|
if args.diagnose:
|
|
3772
|
+
diag = pipeline.diagnose(
|
|
3773
|
+
config, requested_sources, safe=True,
|
|
3774
|
+
x_envelope=x_posts_envelope is not None,
|
|
3775
|
+
)
|
|
1033
3776
|
print(json.dumps(diag, indent=2, sort_keys=True))
|
|
1034
3777
|
return 0
|
|
1035
3778
|
|
|
3779
|
+
# Per-entity host-fetched X envelopes are validated here, on the main
|
|
3780
|
+
# thread and BEFORE the report-cache lookup, so a bad or stale one fails
|
|
3781
|
+
# closed (exit 2) instead of silently dropping that entity inside the
|
|
3782
|
+
# fan-out or being served from a cache built while it was still valid.
|
|
3783
|
+
comp_plan = parse_competitors_plan(args.competitors_plan)
|
|
3784
|
+
try:
|
|
3785
|
+
_attach_entity_envelopes(comp_plan, args)
|
|
3786
|
+
except x_envelope.EnvelopeContractError as exc:
|
|
3787
|
+
sys.stderr.write(f"[last30days] {exc.message}\n")
|
|
3788
|
+
return 2
|
|
3789
|
+
|
|
1036
3790
|
if not topic:
|
|
1037
3791
|
parser.print_usage(sys.stderr)
|
|
1038
3792
|
return 2
|
|
@@ -1054,10 +3808,15 @@ def main() -> int:
|
|
|
1054
3808
|
sys.stderr.write(refuse_msg)
|
|
1055
3809
|
return 2
|
|
1056
3810
|
|
|
1057
|
-
if
|
|
3811
|
+
if (
|
|
3812
|
+
args.emit == "html"
|
|
3813
|
+
and synthesis_md is not None
|
|
3814
|
+
and not args.deep_research
|
|
3815
|
+
):
|
|
1058
3816
|
cached = _load_last_report_cache(
|
|
1059
3817
|
topic,
|
|
1060
3818
|
ttl_seconds=_report_cache_ttl_seconds(config),
|
|
3819
|
+
x_envelope_sha256=_x_envelope_digest(x_posts_envelope, comp_plan),
|
|
1061
3820
|
)
|
|
1062
3821
|
if cached is not None:
|
|
1063
3822
|
cached_report, cached_entity_reports, cache_path = cached
|
|
@@ -1065,6 +3824,17 @@ def main() -> int:
|
|
|
1065
3824
|
f"[last30days] Reusing cached report data from {cache_path}\n"
|
|
1066
3825
|
)
|
|
1067
3826
|
sys.stderr.flush()
|
|
3827
|
+
if _freshness_enabled(args, config):
|
|
3828
|
+
_verify_report_set(
|
|
3829
|
+
cached_report,
|
|
3830
|
+
cached_entity_reports,
|
|
3831
|
+
allow_network=not args.mock,
|
|
3832
|
+
)
|
|
3833
|
+
_update_cached_freshness(
|
|
3834
|
+
cache_path,
|
|
3835
|
+
cached_report,
|
|
3836
|
+
cached_entity_reports,
|
|
3837
|
+
)
|
|
1068
3838
|
return _render_save_and_print(
|
|
1069
3839
|
args, cached_report, cached_entity_reports, synthesis_md, config
|
|
1070
3840
|
)
|
|
@@ -1074,10 +3844,35 @@ def main() -> int:
|
|
|
1074
3844
|
)
|
|
1075
3845
|
sys.stderr.flush()
|
|
1076
3846
|
|
|
3847
|
+
# LAW 7: an agent host that skipped --plan stops before live diagnostics,
|
|
3848
|
+
# auto-resolve, the internal planner, or any source retrieval can spend.
|
|
3849
|
+
law7_exit = _law7_host_plan_gate(args, topic)
|
|
3850
|
+
if law7_exit is not None:
|
|
3851
|
+
return law7_exit
|
|
3852
|
+
|
|
3853
|
+
diag = pipeline.diagnose(
|
|
3854
|
+
config, requested_sources, safe=False,
|
|
3855
|
+
x_envelope=x_posts_envelope is not None,
|
|
3856
|
+
)
|
|
3857
|
+
|
|
3858
|
+
# Competitor sub-runs shallow-copy this config. The shared object makes the
|
|
3859
|
+
# paid Perplexity cap command-wide and thread-safe across that fanout. Keep
|
|
3860
|
+
# this runtime-only object out of the safe diagnose configuration contract.
|
|
3861
|
+
config["_perplexity_paid_budget"] = pipeline.PaidSourceBudget()
|
|
3862
|
+
|
|
1077
3863
|
progress = ui.ProgressDisplay(topic, show_banner=True)
|
|
1078
3864
|
progress.start_processing()
|
|
1079
3865
|
|
|
1080
3866
|
depth = "deep" if args.deep else "quick" if args.quick else "default"
|
|
3867
|
+
# CLI overrides for the depth profile's result caps (issue #716). Stashed on
|
|
3868
|
+
# config so pipeline.run() can apply them without widening its signature; the
|
|
3869
|
+
# comparison path inherits them via `entity_config = dict(config)`.
|
|
3870
|
+
if args.max_results is not None:
|
|
3871
|
+
config["_max_results"] = args.max_results
|
|
3872
|
+
if args.max_per_source is not None:
|
|
3873
|
+
config["_max_per_source"] = args.max_per_source
|
|
3874
|
+
if args.max_source_fetches is not None:
|
|
3875
|
+
config["_max_source_fetches"] = args.max_source_fetches
|
|
1081
3876
|
try:
|
|
1082
3877
|
x_related = [h.strip() for h in args.x_related.split(",") if h.strip()] if args.x_related else None
|
|
1083
3878
|
subreddits = [s.strip().removeprefix("r/") for s in args.subreddits.split(",") if s.strip()] if args.subreddits else None
|
|
@@ -1092,7 +3887,8 @@ def main() -> int:
|
|
|
1092
3887
|
plan_str = args.plan
|
|
1093
3888
|
if os.path.isfile(plan_str):
|
|
1094
3889
|
try:
|
|
1095
|
-
|
|
3890
|
+
with open(plan_str, encoding="utf-8") as f:
|
|
3891
|
+
plan_str = f.read()
|
|
1096
3892
|
except (OSError, UnicodeDecodeError) as exc:
|
|
1097
3893
|
sys.stderr.write(f"[Planner] Cannot read --plan file: {exc}\n")
|
|
1098
3894
|
raise SystemExit(2)
|
|
@@ -1104,20 +3900,57 @@ def main() -> int:
|
|
|
1104
3900
|
# and burning a paid run the user did not ask for. Mirrors the
|
|
1105
3901
|
# --plan file-read branch above and parse_competitors_plan.
|
|
1106
3902
|
raise SystemExit(2)
|
|
3903
|
+
from lib import planner as _plan_validator
|
|
3904
|
+
try:
|
|
3905
|
+
_plan_validator.validate_external_plan(external_plan)
|
|
3906
|
+
except ValueError as exc:
|
|
3907
|
+
sys.stderr.write(f"[Planner] Invalid --plan schema: {exc}.\n")
|
|
3908
|
+
raise SystemExit(2)
|
|
1107
3909
|
|
|
1108
3910
|
# Auto-resolve: use web search to discover subreddits/handles before planning.
|
|
1109
3911
|
# This is the engine-side equivalent of SKILL.md Steps 0.55/0.75 for platforms
|
|
1110
3912
|
# without WebSearch (OpenClaw, Codex, raw CLI).
|
|
1111
3913
|
repos_from_auto_resolve = False
|
|
3914
|
+
trustpilot_domain_is_hint = False
|
|
3915
|
+
# Resolve automatically for entity-shaped topics even without the flag.
|
|
3916
|
+
# A person or company topic whose handle the user did not supply is the
|
|
3917
|
+
# case where first-party evidence is hardest to protect: the handle is
|
|
3918
|
+
# absent from the topic and may never appear in retrieved mentions, so
|
|
3919
|
+
# nothing downstream can identify the subject's own posts. One web
|
|
3920
|
+
# search closes that. If it returns nothing, pipeline.run skips the X
|
|
3921
|
+
# relevance floor entirely — a noisier report beats losing evidence.
|
|
3922
|
+
# Skipped when a handle was already supplied, when an external plan
|
|
3923
|
+
# owns resolution, or in mock runs.
|
|
3924
|
+
auto_resolve_topic = topic
|
|
3925
|
+
if args.competitors_list is None and (args.competitors is None or comp_plan):
|
|
3926
|
+
from lib import planner as _planner
|
|
3927
|
+
vs_entities = _planner._comparison_entities(topic, uncapped=True)
|
|
3928
|
+
if len(vs_entities) >= 2:
|
|
3929
|
+
auto_resolve_topic = vs_entities[0]
|
|
3930
|
+
if (
|
|
3931
|
+
not args.auto_resolve
|
|
3932
|
+
and not external_plan
|
|
3933
|
+
and not args.x_handle
|
|
3934
|
+
and not args.mock
|
|
3935
|
+
and _looks_like_entity_topic(auto_resolve_topic)
|
|
3936
|
+
):
|
|
3937
|
+
args.auto_resolve = True
|
|
3938
|
+
sys.stderr.write(
|
|
3939
|
+
"[AutoResolve] entity-shaped topic with no --x-handle; "
|
|
3940
|
+
"resolving the subject's handle so its own posts are not pruned\n"
|
|
3941
|
+
)
|
|
3942
|
+
|
|
1112
3943
|
if args.auto_resolve and not external_plan:
|
|
1113
3944
|
from lib import resolve
|
|
1114
|
-
resolution = resolve.auto_resolve(
|
|
3945
|
+
resolution = resolve.auto_resolve(auto_resolve_topic, config)
|
|
1115
3946
|
if resolution.get("subreddits") and not subreddits:
|
|
1116
3947
|
subreddits = resolution["subreddits"]
|
|
1117
3948
|
sys.stderr.write(f"[AutoResolve] Subreddits: {', '.join(subreddits)}\n")
|
|
1118
3949
|
if resolution.get("x_handle") and not args.x_handle:
|
|
1119
3950
|
args.x_handle = resolution["x_handle"]
|
|
1120
3951
|
sys.stderr.write(f"[AutoResolve] X handle: @{args.x_handle}\n")
|
|
3952
|
+
# Empty x_handle is intentional: do not invent a lexical stand-in.
|
|
3953
|
+
# pipeline.run treats an unidentified subject as "skip the X floor".
|
|
1121
3954
|
if resolution.get("github_user") and not args.github_user:
|
|
1122
3955
|
args.github_user = resolution["github_user"]
|
|
1123
3956
|
sys.stderr.write(f"[AutoResolve] GitHub user: @{args.github_user}\n")
|
|
@@ -1127,6 +3960,12 @@ def main() -> int:
|
|
|
1127
3960
|
# mark so we don't re-canonicalize below and clobber its relevance order.
|
|
1128
3961
|
repos_from_auto_resolve = True
|
|
1129
3962
|
sys.stderr.write(f"[AutoResolve] GitHub repos: {args.github_repo}\n")
|
|
3963
|
+
if resolution.get("trustpilot_domain") and not args.trustpilot_domain:
|
|
3964
|
+
# Hint provenance matters: only user-set flags are verbatim-final;
|
|
3965
|
+
# a resolved hint retries via the CLI search when it misses.
|
|
3966
|
+
args.trustpilot_domain = resolution["trustpilot_domain"]
|
|
3967
|
+
trustpilot_domain_is_hint = True
|
|
3968
|
+
sys.stderr.write(f"[AutoResolve] Trustpilot domain: {args.trustpilot_domain} (hint)\n")
|
|
1130
3969
|
if resolution.get("context"):
|
|
1131
3970
|
# Inject context into external_plan metadata for the planner to use
|
|
1132
3971
|
if not external_plan:
|
|
@@ -1137,6 +3976,20 @@ def main() -> int:
|
|
|
1137
3976
|
|
|
1138
3977
|
github_user = args.github_user.lstrip("@").lower() if args.github_user else None
|
|
1139
3978
|
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
|
|
3979
|
+
trustpilot_domain = args.trustpilot_domain.strip() if args.trustpilot_domain else None
|
|
3980
|
+
|
|
3981
|
+
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
|
|
3982
|
+
# comp_plan was parsed, and its per-entity envelopes validated, before
|
|
3983
|
+
# the report-cache lookup above.
|
|
3984
|
+
|
|
3985
|
+
# Plan-level trustpilot_domain pins are the same user intent as the CLI
|
|
3986
|
+
# flag (already activated above). Auto-resolve hints must not activate.
|
|
3987
|
+
if plan_has_explicit_trustpilot_domain(comp_plan):
|
|
3988
|
+
requested_sources = activate_trustpilot_for_explicit_domain(
|
|
3989
|
+
config,
|
|
3990
|
+
requested_sources,
|
|
3991
|
+
reason="competitors-plan trustpilot_domain",
|
|
3992
|
+
)
|
|
1140
3993
|
|
|
1141
3994
|
# Only canonicalize when repos came from a user-supplied --github-repo flag.
|
|
1142
3995
|
# When repos_from_auto_resolve is True, auto_resolve already ran
|
|
@@ -1152,20 +4005,6 @@ def main() -> int:
|
|
|
1152
4005
|
f"{','.join(original_github_repos)} -> {','.join(github_repos)}\n"
|
|
1153
4006
|
)
|
|
1154
4007
|
|
|
1155
|
-
# --deep-research: auto-enable perplexity source and set deep flag
|
|
1156
|
-
if args.deep_research:
|
|
1157
|
-
if not (config.get("PERPLEXITY_API_KEY") or config.get("OPENROUTER_API_KEY")):
|
|
1158
|
-
print("Error: --deep-research requires PERPLEXITY_API_KEY or OPENROUTER_API_KEY", file=sys.stderr)
|
|
1159
|
-
sys.exit(1)
|
|
1160
|
-
config["_deep_research"] = True
|
|
1161
|
-
# Auto-enable perplexity in INCLUDE_SOURCES
|
|
1162
|
-
include = config.get("INCLUDE_SOURCES") or ""
|
|
1163
|
-
if "perplexity" not in include.lower():
|
|
1164
|
-
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
|
|
1165
|
-
|
|
1166
|
-
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
|
|
1167
|
-
comp_plan = parse_competitors_plan(args.competitors_plan)
|
|
1168
|
-
|
|
1169
4008
|
# Polymarket disambiguation: if user passed --polymarket-keywords,
|
|
1170
4009
|
# store on config so the polymarket adapter can filter matches.
|
|
1171
4010
|
if args.polymarket_keywords:
|
|
@@ -1177,23 +4016,82 @@ def main() -> int:
|
|
|
1177
4016
|
if keywords:
|
|
1178
4017
|
config["_polymarket_keywords"] = keywords
|
|
1179
4018
|
|
|
1180
|
-
#
|
|
1181
|
-
#
|
|
1182
|
-
#
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
4019
|
+
# Product keyword for the amazon source. Carried on config rather than
|
|
4020
|
+
# threaded through the run signature (the _polymarket_keywords idiom):
|
|
4021
|
+
# it is one optional string consumed in exactly two places.
|
|
4022
|
+
if getattr(args, "amazon_query", None):
|
|
4023
|
+
config["_amazon_query"] = args.amazon_query.strip()
|
|
4024
|
+
# Unlike --trustpilot-domain, this flag deliberately does NOT
|
|
4025
|
+
# auto-activate its source: the lane spends metered credits, so
|
|
4026
|
+
# turning it on stays an explicit request. But silence is the
|
|
4027
|
+
# wrong failure mode -- a model that resolves the keyword and
|
|
4028
|
+
# forgets the --search token would otherwise get no signal at
|
|
4029
|
+
# all that the flag did nothing.
|
|
4030
|
+
_amazon_requested = (
|
|
4031
|
+
(requested_sources and "amazon" in requested_sources)
|
|
4032
|
+
or "amazon" in str(config.get("INCLUDE_SOURCES") or "").lower()
|
|
4033
|
+
)
|
|
4034
|
+
if not _amazon_requested:
|
|
4035
|
+
sys.stderr.write(
|
|
4036
|
+
"[Amazon] --amazon-query was set but the amazon source was not "
|
|
4037
|
+
"requested; add it to --search (e.g. --search reddit,x,amazon) "
|
|
4038
|
+
"or set INCLUDE_SOURCES=amazon. Ignoring the keyword.\n"
|
|
4039
|
+
)
|
|
4040
|
+
|
|
4041
|
+
# Advertiser page override for the meta_ads source. Same shape as
|
|
4042
|
+
# --amazon-query (config-carried, warn-not-activate) and for the same
|
|
4043
|
+
# reason: the lane spends metered credits per call.
|
|
4044
|
+
if getattr(args, "meta_ads_page", None):
|
|
4045
|
+
page_id = parse_meta_ads_page(args.meta_ads_page)
|
|
4046
|
+
if not page_id:
|
|
4047
|
+
sys.stderr.write(
|
|
4048
|
+
"[Meta Ads] --meta-ads-page must be a numeric Ad Library page id "
|
|
4049
|
+
"or an Ad Library URL containing view_all_page_id; a facebook.com "
|
|
4050
|
+
"vanity URL is not a page id. Ignoring the override.\n"
|
|
4051
|
+
)
|
|
4052
|
+
else:
|
|
4053
|
+
config["_meta_ads_page"] = page_id
|
|
4054
|
+
_meta_ads_requested = (
|
|
4055
|
+
(requested_sources and "meta_ads" in requested_sources)
|
|
4056
|
+
or "meta_ads" in str(config.get("INCLUDE_SOURCES") or "").lower()
|
|
4057
|
+
)
|
|
4058
|
+
if not _meta_ads_requested:
|
|
4059
|
+
sys.stderr.write(
|
|
4060
|
+
"[Meta Ads] --meta-ads-page was set but the meta_ads source "
|
|
4061
|
+
"was not requested; add it to --search (e.g. --search "
|
|
4062
|
+
"reddit,x,meta_ads) or set INCLUDE_SOURCES=meta_ads. "
|
|
4063
|
+
"Ignoring the page.\n"
|
|
4064
|
+
)
|
|
4065
|
+
|
|
4066
|
+
# vs-mode / plan routing: split a vs-topic into main + peers unless
|
|
4067
|
+
# discover-N or an explicit --competitors-list already decided who runs.
|
|
4068
|
+
topic, comp_enabled, comp_count, comp_explicit = apply_vs_competitor_routing(
|
|
4069
|
+
topic,
|
|
4070
|
+
competitors_flag=args.competitors,
|
|
4071
|
+
comp_enabled=comp_enabled,
|
|
4072
|
+
comp_count=comp_count,
|
|
4073
|
+
comp_explicit=comp_explicit,
|
|
4074
|
+
comp_plan=comp_plan,
|
|
4075
|
+
)
|
|
4076
|
+
if comp_enabled:
|
|
4077
|
+
config["_perplexity_paid_budget"] = pipeline.PaidSourceBudget(
|
|
4078
|
+
owner=topic,
|
|
4079
|
+
)
|
|
4080
|
+
|
|
4081
|
+
# Plan alone with zero peers (empty/invalid JSON object, or all entries
|
|
4082
|
+
# skipped) must not fall through to discover-N with a misleading abort.
|
|
4083
|
+
if (
|
|
4084
|
+
comp_enabled
|
|
4085
|
+
and not comp_explicit
|
|
4086
|
+
and args.competitors is None
|
|
4087
|
+
and args.competitors_plan
|
|
4088
|
+
):
|
|
1193
4089
|
sys.stderr.write(
|
|
1194
|
-
|
|
1195
|
-
|
|
4090
|
+
"[Competitors] --competitors-plan has no usable peer entries "
|
|
4091
|
+
"(and the topic is not a vs-comparison). Pass a non-empty plan, "
|
|
4092
|
+
"a vs-topic, --competitors-list, or --competitors N.\n"
|
|
1196
4093
|
)
|
|
4094
|
+
return 2
|
|
1197
4095
|
|
|
1198
4096
|
# Dedicated subs ride the config dict (already threaded to every source
|
|
1199
4097
|
# fetch) so the keyless Reddit path can pull them floor-exempt without
|
|
@@ -1220,8 +4118,17 @@ def main() -> int:
|
|
|
1220
4118
|
as_of_date=args.as_of_date,
|
|
1221
4119
|
github_user=github_user,
|
|
1222
4120
|
github_repos=github_repos,
|
|
4121
|
+
trustpilot_domain=trustpilot_domain,
|
|
4122
|
+
trustpilot_domain_is_hint=trustpilot_domain_is_hint,
|
|
1223
4123
|
internal_subrun=comp_enabled,
|
|
1224
4124
|
hiring_signals_mode=args.hiring_signals,
|
|
4125
|
+
save_dir=args.save_dir,
|
|
4126
|
+
corpus_dirs=args.corpus,
|
|
4127
|
+
corpus_all_time=args.corpus_all_time,
|
|
4128
|
+
x_posts=(
|
|
4129
|
+
comp_plan.get(topic.strip().lower(), {}).get("_x_envelope")
|
|
4130
|
+
if comp_enabled else x_posts_envelope
|
|
4131
|
+
),
|
|
1225
4132
|
)
|
|
1226
4133
|
r.artifacts["resolved"] = {
|
|
1227
4134
|
"entity": topic,
|
|
@@ -1229,6 +4136,7 @@ def main() -> int:
|
|
|
1229
4136
|
"subreddits": list(subreddits or []),
|
|
1230
4137
|
"github_user": (github_user or ""),
|
|
1231
4138
|
"github_repos": list(github_repos or []),
|
|
4139
|
+
"trustpilot_domain": (trustpilot_domain or ""),
|
|
1232
4140
|
"context": config.get("_auto_resolve_context", "") or "",
|
|
1233
4141
|
}
|
|
1234
4142
|
return r
|
|
@@ -1253,7 +4161,7 @@ def main() -> int:
|
|
|
1253
4161
|
" 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' "
|
|
1254
4162
|
"--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":"
|
|
1255
4163
|
"[\"s1\"],...},\"Peer2\":{...}}'.\n"
|
|
1256
|
-
"See
|
|
4164
|
+
"See the skill's references/competitors.md for the full protocol.\n"
|
|
1257
4165
|
"\n"
|
|
1258
4166
|
"HEADLESS / CRON PATH (no hosting model available): set "
|
|
1259
4167
|
"BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / "
|
|
@@ -1274,15 +4182,55 @@ def main() -> int:
|
|
|
1274
4182
|
)
|
|
1275
4183
|
return 2
|
|
1276
4184
|
|
|
4185
|
+
# run_competitor_fanout keys its results by label, so two
|
|
4186
|
+
# submissions sharing one collapse to a single report while the
|
|
4187
|
+
# returned list still carries two entries. That yields a
|
|
4188
|
+
# comparison of an entity against itself, and it hides a failed
|
|
4189
|
+
# main topic from the survivor check below: the duplicate peer's
|
|
4190
|
+
# report answers for the label the main run was supposed to fill.
|
|
4191
|
+
distinct_peers: list[str] = []
|
|
4192
|
+
claimed_labels = {comparison_label_key(topic)}
|
|
4193
|
+
for peer in discovered:
|
|
4194
|
+
key = comparison_label_key(peer)
|
|
4195
|
+
if key in claimed_labels:
|
|
4196
|
+
sys.stderr.write(
|
|
4197
|
+
f"[Competitors] Dropping {peer!r}: duplicates the main "
|
|
4198
|
+
"topic or an earlier peer.\n"
|
|
4199
|
+
)
|
|
4200
|
+
continue
|
|
4201
|
+
claimed_labels.add(key)
|
|
4202
|
+
distinct_peers.append(peer)
|
|
4203
|
+
if not distinct_peers:
|
|
4204
|
+
sys.stderr.write(
|
|
4205
|
+
f"[Competitors] No peer distinct from {topic!r} remains; "
|
|
4206
|
+
"there is nothing to compare against. Pass "
|
|
4207
|
+
"--competitors-list with distinct entities.\n"
|
|
4208
|
+
)
|
|
4209
|
+
return 2
|
|
4210
|
+
discovered = distinct_peers
|
|
4211
|
+
|
|
1277
4212
|
sys.stderr.write(
|
|
1278
4213
|
f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
|
|
1279
4214
|
)
|
|
1280
4215
|
|
|
1281
4216
|
def _competitor_runner(entity: str) -> schema.Report:
|
|
1282
|
-
#
|
|
1283
|
-
# leak across sub-runs. Each sub-run writes its own
|
|
1284
|
-
# `_auto_resolve_context` into its local config copy.
|
|
4217
|
+
# Resolution belongs to each entity, not the shared command.
|
|
1285
4218
|
entity_config = dict(config)
|
|
4219
|
+
entity_config.pop("_auto_resolve_context", None)
|
|
4220
|
+
# The Amazon keyword is entity-SPECIFIC, unlike the depth caps
|
|
4221
|
+
# this shallow copy exists to inherit. Leaving the main topic's
|
|
4222
|
+
# keyword in place would search Weber SKUs for a Traeger peer,
|
|
4223
|
+
# render a rival's products as that peer's buyer evidence, and
|
|
4224
|
+
# multiply the metered spend by the number of entities. Drop it
|
|
4225
|
+
# so each peer derives its own keyword from its own topic; a
|
|
4226
|
+
# per-entity keyword can ride in the --competitors-plan entry.
|
|
4227
|
+
entity_config.pop("_amazon_query", None)
|
|
4228
|
+
# dev.to tags describe the main topic; a peer falls back to
|
|
4229
|
+
# the slug of its own name instead of a rival's tags.
|
|
4230
|
+
entity_config.pop("_devto_tags", None)
|
|
4231
|
+
# An advertiser page is per-entity state by definition: left in
|
|
4232
|
+
# place it would render one brand's ads as every peer's.
|
|
4233
|
+
entity_config.pop("_meta_ads_page", None)
|
|
1286
4234
|
plan_entry = comp_plan.get(entity.strip().lower(), {})
|
|
1287
4235
|
resolved = {
|
|
1288
4236
|
"entity": entity,
|
|
@@ -1290,6 +4238,7 @@ def main() -> int:
|
|
|
1290
4238
|
"subreddits": [],
|
|
1291
4239
|
"github_user": "",
|
|
1292
4240
|
"github_repos": [],
|
|
4241
|
+
"trustpilot_domain": "",
|
|
1293
4242
|
"context": "",
|
|
1294
4243
|
}
|
|
1295
4244
|
# Skip engine-internal auto_resolve when the hosting model
|
|
@@ -1316,6 +4265,7 @@ def main() -> int:
|
|
|
1316
4265
|
resolved["subreddits"] = list(r.get("subreddits") or [])
|
|
1317
4266
|
resolved["github_user"] = r.get("github_user", "") or ""
|
|
1318
4267
|
resolved["github_repos"] = list(r.get("github_repos") or [])
|
|
4268
|
+
resolved["trustpilot_domain"] = r.get("trustpilot_domain", "") or ""
|
|
1319
4269
|
resolved["context"] = r.get("context", "") or ""
|
|
1320
4270
|
kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
|
|
1321
4271
|
# Record effective per-entity targeting for the Resolved block.
|
|
@@ -1325,6 +4275,7 @@ def main() -> int:
|
|
|
1325
4275
|
"subreddits": kwargs["subreddits"] or [],
|
|
1326
4276
|
"github_user": kwargs["github_user"] or "",
|
|
1327
4277
|
"github_repos": kwargs["github_repos"] or [],
|
|
4278
|
+
"trustpilot_domain": kwargs["trustpilot_domain"] or "",
|
|
1328
4279
|
"context": kwargs["_context"],
|
|
1329
4280
|
}
|
|
1330
4281
|
if kwargs["_context"]:
|
|
@@ -1347,11 +4298,17 @@ def main() -> int:
|
|
|
1347
4298
|
subreddits=kwargs["subreddits"],
|
|
1348
4299
|
github_user=kwargs["github_user"],
|
|
1349
4300
|
github_repos=kwargs["github_repos"],
|
|
4301
|
+
trustpilot_domain=kwargs["trustpilot_domain"],
|
|
4302
|
+
trustpilot_domain_is_hint=kwargs["_trustpilot_domain_is_hint"],
|
|
1350
4303
|
web_backend=args.web_backend,
|
|
1351
4304
|
lookback_days=args.lookback_days,
|
|
1352
4305
|
as_of_date=args.as_of_date,
|
|
1353
4306
|
hiring_signals_mode=args.hiring_signals,
|
|
1354
4307
|
internal_subrun=True,
|
|
4308
|
+
save_dir=args.save_dir,
|
|
4309
|
+
corpus_dirs=args.corpus,
|
|
4310
|
+
corpus_all_time=args.corpus_all_time,
|
|
4311
|
+
x_posts=plan_entry.get("_x_envelope"),
|
|
1355
4312
|
)
|
|
1356
4313
|
report.artifacts["resolved"] = resolved_effective
|
|
1357
4314
|
return report
|
|
@@ -1362,6 +4319,25 @@ def main() -> int:
|
|
|
1362
4319
|
competitors=discovered,
|
|
1363
4320
|
competitor_runner=_competitor_runner,
|
|
1364
4321
|
)
|
|
4322
|
+
# run_competitor_fanout drops a failed sub-run from the list, and
|
|
4323
|
+
# the render takes entity_reports[0] as the comparison's subject.
|
|
4324
|
+
# Without this check, a main topic that raised while >=2 peers
|
|
4325
|
+
# succeeded silently promoted a competitor to be the subject: the
|
|
4326
|
+
# report was headed by that peer, saved under its slug, and the
|
|
4327
|
+
# topic the user actually asked about went unmentioned.
|
|
4328
|
+
survived = {label for label, _ in entity_reports}
|
|
4329
|
+
dropped = [
|
|
4330
|
+
label for label in (topic, *discovered) if label not in survived
|
|
4331
|
+
]
|
|
4332
|
+
if topic not in survived:
|
|
4333
|
+
progress.end_processing()
|
|
4334
|
+
sys.stderr.write(
|
|
4335
|
+
f"[Competitors] The main topic {topic!r} failed; "
|
|
4336
|
+
f"{len(entity_reports)} competitor sub-run(s) survived. "
|
|
4337
|
+
"Refusing to render a comparison headed by a competitor. "
|
|
4338
|
+
"Check the warnings above.\n"
|
|
4339
|
+
)
|
|
4340
|
+
return 1
|
|
1365
4341
|
if len(entity_reports) < 2:
|
|
1366
4342
|
progress.end_processing()
|
|
1367
4343
|
sys.stderr.write(
|
|
@@ -1371,6 +4347,14 @@ def main() -> int:
|
|
|
1371
4347
|
)
|
|
1372
4348
|
return 1
|
|
1373
4349
|
report = entity_reports[0][1]
|
|
4350
|
+
if dropped:
|
|
4351
|
+
# A narrower comparison than the user asked for is a result
|
|
4352
|
+
# they need to see, not a silent substitution.
|
|
4353
|
+
report.warnings.append(
|
|
4354
|
+
"Comparison is incomplete: "
|
|
4355
|
+
f"{len(dropped)} of {len(discovered) + 1} entities failed and "
|
|
4356
|
+
f"were dropped ({', '.join(dropped)})."
|
|
4357
|
+
)
|
|
1374
4358
|
else:
|
|
1375
4359
|
entity_reports = None
|
|
1376
4360
|
report = _main_runner()
|
|
@@ -1378,11 +4362,17 @@ def main() -> int:
|
|
|
1378
4362
|
progress.end_processing()
|
|
1379
4363
|
progress.show_error(str(exc))
|
|
1380
4364
|
raise
|
|
4365
|
+
if _freshness_enabled(args, config):
|
|
4366
|
+
_verify_report_set(report, entity_reports, allow_network=not args.mock)
|
|
4367
|
+
|
|
1381
4368
|
_show_runtime_ui(
|
|
1382
4369
|
report, progress, diag,
|
|
1383
4370
|
suppress_web_promo=bool(external_plan or comp_plan),
|
|
1384
4371
|
)
|
|
1385
|
-
_write_last_run(
|
|
4372
|
+
_write_last_run(
|
|
4373
|
+
original_topic, report, entity_reports=entity_reports,
|
|
4374
|
+
x_envelope_sha256=_x_envelope_digest(x_posts_envelope, comp_plan),
|
|
4375
|
+
)
|
|
1386
4376
|
# LAST30DAYS_STORE env var = persistence default-on. Read both os.environ
|
|
1387
4377
|
# (for shell-exported users) and config (for users who set it in
|
|
1388
4378
|
# ~/.config/last30days/.env, which env.py loads but does not propagate
|
|
@@ -1394,7 +4384,7 @@ def main() -> int:
|
|
|
1394
4384
|
or ""
|
|
1395
4385
|
).lower()
|
|
1396
4386
|
if args.store or _store_env in ("1", "true", "yes"):
|
|
1397
|
-
counts = persist_report(report)
|
|
4387
|
+
counts = persist_report(report, store_db=_scoped_store_db(args))
|
|
1398
4388
|
sys.stderr.write(
|
|
1399
4389
|
f"[last30days] Stored {counts['new']} new, {counts['updated']} updated findings\n"
|
|
1400
4390
|
)
|
|
@@ -1406,39 +4396,9 @@ def main() -> int:
|
|
|
1406
4396
|
try:
|
|
1407
4397
|
from lib import quality_nudge
|
|
1408
4398
|
from lib import youtube_yt as _youtube_yt
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
youtube_items = report.items_by_source.get("youtube") or []
|
|
1413
|
-
_yt_fetch_stats = _youtube_yt.get_transcript_fetch_stats()
|
|
1414
|
-
instagram_items = report.items_by_source.get("instagram") or []
|
|
1415
|
-
research_results = {
|
|
1416
|
-
"youtube_videos_count": len(youtube_items),
|
|
1417
|
-
"youtube_transcripts_count": sum(
|
|
1418
|
-
1 for it in youtube_items
|
|
1419
|
-
if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
|
|
1420
|
-
),
|
|
1421
|
-
"youtube_error": report.errors_by_source.get("youtube"),
|
|
1422
|
-
"x_error": report.errors_by_source.get("x"),
|
|
1423
|
-
# Captions-disabled videos can never produce a transcript regardless
|
|
1424
|
-
# of yt-dlp version; subtract them from the degraded-ratio
|
|
1425
|
-
# denominator so a single uploader-disabled video does not trip the
|
|
1426
|
-
# "stale yt-dlp" nudge.
|
|
1427
|
-
"youtube_captions_disabled_count": sum(
|
|
1428
|
-
1 for it in youtube_items if it.metadata.get("captions_disabled")
|
|
1429
|
-
),
|
|
1430
|
-
# Actual yt-dlp fetch outcomes for this run. The counts above are
|
|
1431
|
-
# computed from post-pruning items, so they can't tell "fetches
|
|
1432
|
-
# failed (stale binary)" from "fetches succeeded but the videos
|
|
1433
|
-
# were pruned downstream"; the latter was producing false
|
|
1434
|
-
# stale-yt-dlp nudges (#531).
|
|
1435
|
-
"youtube_transcript_fetch_attempts": _yt_fetch_stats["attempts"],
|
|
1436
|
-
"youtube_transcript_fetch_failures": _yt_fetch_stats["failures"],
|
|
1437
|
-
# Track Instagram returned-zero-items so quality_nudge can detect
|
|
1438
|
-
# the silent-failure case (SC configured but the v2 reels endpoint
|
|
1439
|
-
# 500'd through both the original query and the hashtag retry).
|
|
1440
|
-
"instagram_items_count": len(instagram_items),
|
|
1441
|
-
}
|
|
4399
|
+
research_results = _quality_research_results(
|
|
4400
|
+
report, diag, _youtube_yt.get_transcript_fetch_stats()
|
|
4401
|
+
)
|
|
1442
4402
|
quality = quality_nudge.compute_quality_score(config, research_results)
|
|
1443
4403
|
if quality.get("nudge_text"):
|
|
1444
4404
|
sys.stderr.write(f"\n{quality['nudge_text']}\n")
|
|
@@ -1460,8 +4420,15 @@ def main() -> int:
|
|
|
1460
4420
|
)
|
|
1461
4421
|
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
|
1462
4422
|
|
|
1463
|
-
|
|
4423
|
+
exit_code = _render_save_and_print(args, report, entity_reports, synthesis_md, config)
|
|
4424
|
+
if args.emit in {"compact", "md", "brief"}:
|
|
4425
|
+
x_omission = _optional_x_omission_text(diag, requested_sources)
|
|
4426
|
+
if x_omission:
|
|
4427
|
+
sys.stderr.write(f"\n{x_omission}\n")
|
|
4428
|
+
sys.stderr.flush()
|
|
4429
|
+
return exit_code
|
|
1464
4430
|
|
|
1465
4431
|
|
|
1466
4432
|
if __name__ == "__main__":
|
|
4433
|
+
_install_sigterm_handler()
|
|
1467
4434
|
raise SystemExit(main())
|